chrome-extension-messaging-tests
Overview
Extension components run in separate contexts (content script,
service worker, popup, options page, an external web page, a native
host process) and can only reach each other through the messaging
APIs described on the
Chrome Extensions message passing page. That page defines
five distinct shapes, each with its own failure mode:
- One-shot runtime messages inside one extension
(
chrome.runtime.sendMessage).
- One-shot tabs messages into a content script
(
chrome.tabs.sendMessage).
- Long-lived ports (
chrome.runtime.connect).
- Cross-extension and web-page messages, gated by
externally_connectable.
- Native messaging (
chrome.runtime.connectNative).
Most "the feature silently does nothing" bugs in an extension are one
of these five failing quietly: a channel closed before the response, a
message sent to a tab with no matching content script, a port dropped
by a suspended service worker, or an origin missing from an
allow-list. Tests here make each of those loud.
Scope boundary
This skill owns assertions about messages once an extension is
installed and running: who can send, who receives, what the response
contract is, when a channel closes, and what a payload is allowed to
contain.
It deliberately does not cover getting the extension installed,
reloaded, or refreshed after a code edit. Those are a separate job with
a separate symptom: an install problem shows up as an extension that is
absent or shows an error before any test runs, while everything here
shows up as a running extension whose messages go nowhere. Assume the
extension under test is loaded and its ID is known before applying
anything below.
When to use
sendMessage resolves undefined or the callback never fires, and
you need a test that pins down which side dropped the channel.
- A popup action does nothing on the page: the message may never be
reaching the content script.
- A long-lived connection dies partway through a session and you need
to distinguish a deliberate
disconnect() from an unloaded frame.
- A companion web site must talk to the extension and the
externally_connectable allow-list has to be proven correct before
publishing.
- A native helper application is part of the product and CI has to
prove the host is reachable.
How to use
- Identify the shape. Map the failing (or new) path to one of the
five messaging shapes in the Overview - one-shot runtime, one-shot
tabs, long-lived port, external / web-page, or native.
- Confirm the extension is running. Install / reload is out of
scope (see Scope boundary); assume the extension is loaded and its ID
is known before writing an assertion.
- Write the sender / listener pair for that shape. The two one-shot
forms are in Authoring below; ports and the cross-boundary surfaces
are in the linked references.
- Assert the full response object with an equality matcher, never
toBeDefined() - the failure this API produces most often is an
undefined-shaped response that toBeDefined() passes on.
- Apply the payload guardrails. Build fixtures from
JSON-representable values only, and route on a message-type field so
exactly one
onMessage listener owns each type.
- Re-check the draft against the Anti-patterns table before
committing.
Authoring
1. One-shot runtime messages
Per cr-msg, the sender awaits a promise and the listener responds:
// Sender (content script, popup, or options page)
const response = await chrome.runtime.sendMessage({ greeting: 'hello' });
// Listener (service worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.greeting !== 'hello') return;
fetch('https://example.com')
.then(r => sendResponse({ statusCode: r.status }));
return true; // hold the channel open for the async sendResponse
});
The return true is load-bearing. Per cr-msg, to respond
asynchronously using sendResponse() you must return "a literal
true (not just a truthy value)". Returning a truthy non-literal, or
returning nothing, closes the channel and the sender's promise settles
without the response.
From Chrome 148 a listener may instead return a promise whose resolved
value becomes the response, per cr-msg. The same page notes promise
support "is not enabled in extension script contexts if the extension
extends DevTools with a devtools page", so a DevTools-extending
extension must keep using the return true form.
The async-listener trap. An async listener implicitly returns a
promise, which is not the literal true that cr-msg requires. On a
Chrome older than 148, or in a DevTools-extending extension, that
promise is ignored and the channel closes. Author either a
non-async listener with return true, or an async listener that
returns the response value.
Test to write:
test('status request resolves with the fetched status code', async () => {
const response = await sendFromContentScript({ greeting: 'hello' });
expect(response).toEqual({ statusCode: 200 }); // not toBeDefined()
});
Assert the full response object. A toBeDefined() assertion passes on
the undefined-shaped failure this API produces most often.
2. One-shot tabs messages
Per cr-msg, drive a specific tab's content script from the popup or
service worker:
chrome.tabs.sendMessage(tab.id, { greeting: 'hello' }, response => {
const resp = JSON.parse(response.farewell);
});
The chrome.tabs reference gives the signature as
chrome.tabs.sendMessage(tabId, message, options?) returning a
promise, with options.frameId to target one frame instead of all
frames and options.documentId (Chrome 106 and later) to target one
document. Per cr-tabs, "If an error occurs while connecting to the
specified tab, the promise will be rejected" - which is the assertion
to write for the no-content-script-injected case.
Test targets:
- Happy path: a tab with the content script injected resolves the
expected response body.
- Rejection path: a tab whose URL does not match any
content_scripts pattern rejects the promise. Assert the rejection,
not a timeout.
- Frame targeting: with an iframe present,
frameId limits delivery
to that frame per cr-tabs.
Long-lived ports, external, and native surfaces
The three remaining shapes carry more incidental depth - port lifecycle
tied to the service worker, the externally_connectable allow-list, and
machine-level native-host registration - so their code recipes live in
two companion references:
- Long-lived ports + service-worker lifecycle -
connect /
onConnect, the onDisconnect conditions, the wrong-port-name
disconnect test, and the 30-second idle-termination interaction:
references/long-lived-ports.md.
- Cross-boundary messages (web-page, cross-extension, native) -
externally_connectable defaults, onMessageExternal,
connectNative, and the per-OS native-host manifest table:
references/cross-boundary-messaging.md.
Payload constraints every test must respect
Per cr-msg, the maximum size of a message is 64 MiB, and Chrome
uses JSON serialization, "different to other browsers which implement
the same APIs with the structured clone algorithm."
That single sentence invalidates a class of test fixtures. Under JSON
serialization:
| Value sent | What the receiver gets |
|---|
Map / Set | {} (own enumerable properties only) |
Date | an ISO string, not a Date |
undefined property | key dropped from the object |
NaN / Infinity | null |
typed array / ArrayBuffer | an index-keyed object or {} |
| circular reference | serialization throws |
Build fixtures out of plain JSON-representable objects, and assert on
the serialized shape the receiver actually observes rather than on
identity with the object you sent. A cross-browser suite should note
that the same fixture behaves differently elsewhere, since cr-msg
attributes structured clone to other browsers implementing these APIs.
Per cr-msg, when several onMessage listeners are registered,
"Only the first listener to respond, reject, or throw an error will
affect the sender; all other listeners will run, but their results
will be ignored." Any test asserting a specific response in a
codebase with more than one listener is implicitly asserting listener
order, which is fragile. Prefer routing on a message-type field so
exactly one listener can respond to a given message.
Errors surface in two shapes per cr-runtime: callback-style calls
set chrome.runtime.lastError, described as "Populated with an error
message if calling an API function fails; otherwise undefined", while
"API functions that return promises do not set this property" and
reject instead. A test must check the mechanism matching the call
style it used, or a real failure reads as a pass.
Worked example: a full messaging regression suite
runtime one-shot
[x] resolves the full response object for a known message type
[x] async listener without `return true` is caught (response is undefined)
[x] unknown message type produces no response
tabs
[x] matching tab resolves the response body
[x] non-matching tab rejects the promise
[x] frameId targets one frame only
ports
[x] named port receives the expected reply sequence
[x] wrong port name yields exactly one onDisconnect and no reply
[x] explicit disconnect() records exactly one onDisconnect
[x] traffic every <30s keeps the port alive across the idle window
external
[x] allow-listed origin gets a response
[x] non-allow-listed origin gets none
[x] non-allow-listed sender extension id is ignored
[x] adding `matches` did not break extension-to-extension sends
payload
[x] Date round-trips as an ISO string, not a Date
[x] Map serializes to {}
[x] a message over 64 MiB fails rather than silently truncating
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|
Async onMessage listener returning nothing | Not the literal true cr-msg requires, so the channel closes and the sender sees no response | Return the response value, or use a non-async listener with return true |
Returning a truthy non-true value to keep the channel open | cr-msg requires "a literal true (not just a truthy value)" | return true |
Sending Map / Set / Date and asserting on the type | JSON serialization, not structured clone, per cr-msg | Send plain objects; assert on the serialized shape |
expect(response).toBeDefined() | Passes on the undefined result the closed-channel bug produces | Assert the full object with an equality matcher |
Asserting a response with several onMessage listeners registered | Only the first to respond affects the sender per cr-msg | Route on a message-type field so one listener owns each type |
| Idling a port test past the service-worker idle window | Termination after 30 seconds of inactivity per cr-sw; opening a port does not reset the timer | Exchange a message inside the window, or assert the termination deliberately |
Adding externally_connectable.matches without ids | Other extensions lose the ability to connect per cr-extconn | Set ids alongside matches and keep a cross-extension regression test |
| Expecting the extension to push a message to a web page | "It is not possible to send a message from an extension to a web page" per cr-msg | Use custom DOM events or content-script injection |
Checking chrome.runtime.lastError after a promise-based call | Promise-returning functions do not set it per cr-runtime | Assert on the rejection instead |
Reusing a published extension ID in a native-messaging allowed_origins while testing an unpacked build | allowed_origins values cannot contain wildcards per cr-native, and an unpacked load has a different ID | List the unpacked build's actual ID in allowed_origins |
Limitations
- Cross-extension tests need both extensions present. A
chrome.runtime.sendMessage(otherExtensionId, ...) test is
meaningless unless the recipient extension is also installed in the
same profile.
- Native messaging needs a machine-level install. The host
manifest must exist at the per-OS path listed in
references/cross-boundary-messaging.md
per cr-native, so CI images need an explicit setup step; this
cannot be arranged from inside the extension.
- Service-worker lifetime bounds long-running assertions. Per
cr-sw the worker terminates after 30 seconds of inactivity, so
"wait a minute then assert" is not a valid test shape.
externally_connectable is a Chromium manifest key. A
cross-browser suite has to branch: the key is declared in the
Chromium manifest documentation at cr-extconn, and a browser that
does not implement it will not gate connections the same way.
- Behaviour differs by serialization model across browsers. Per
cr-msg Chrome uses JSON serialization while other browsers
implementing the same APIs use structured clone, so payload
assertions are not portable without branching.
References
- Chrome Extensions, Message passing (all five messaging shapes,
return true, onDisconnect conditions, externally_connectable
examples, 64 MiB cap, JSON serialization, first-listener-wins,
extension-to-page direction):
https://developer.chrome.com/docs/extensions/develop/concepts/messaging
chrome.runtime API reference (connect / connectInfo.name,
onMessageExternal, connectNative, lastError versus promise
rejection):
https://developer.chrome.com/docs/extensions/reference/api/runtime
chrome.tabs API reference (sendMessage signature, frameId,
documentId, rejection on connect failure):
https://developer.chrome.com/docs/extensions/reference/api/tabs
externally_connectable manifest key (defaults, ids, matches):
https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable
- Chrome Extensions, Native messaging (host manifest locations,
allowed_origins, 1 MB inbound / 64 MiB outbound limits):
https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging
- Extension service worker lifecycle (30-second idle termination,
long-lived messaging keeping the worker alive):
https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
- Deep reference - long-lived ports + service-worker lifecycle:
references/long-lived-ports.md.
- Deep reference - cross-boundary messaging (web-page, cross-extension,
native): references/cross-boundary-messaging.md.