Testland
Browse all skills & agents

chrome-extension-messaging-tests

Asserts Chrome extension message-passing behaviour against a running extension: one-shot `chrome.runtime.sendMessage` plus the literal `return true` that holds the response channel open for an async `sendResponse`, `chrome.tabs.sendMessage` into one tab's content script, long-lived `chrome.runtime.connect` ports and their `onDisconnect` triggers, web-page messages gated by `externally_connectable`, and `chrome.runtime.connectNative` native-messaging hosts. Covers the payload rules a test must respect (Chrome uses JSON serialization rather than structured clone, so `Map` / `Set` / `Date` do not round-trip; maximum message size is 64 MiB) and the first-listener-wins rule when several `onMessage` listeners are registered. Scope is messaging behaviour on an already-running extension, not the install or reload step. Use when a message reaches no listener, a `sendResponse` callback never fires, a port disconnects mid-test, or a page origin has to be proven allowed before publishing.

Install with skills.sh (any agent)

npx skills add testland/qa --skill chrome-extension-messaging-tests
View source

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 (opens in new window). That page defines five distinct shapes, each with its own failure mode:

  1. One-shot runtime messages inside one extension (chrome.runtime.sendMessage).
  2. One-shot tabs messages into a content script (chrome.tabs.sendMessage).
  3. Long-lived ports (chrome.runtime.connect).
  4. Cross-extension and web-page messages, gated by externally_connectable.
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Re-check the draft against the Anti-patterns table before committing.

Authoring

1. One-shot runtime messages

Per cr-msg (opens in new window), 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 (opens in new window), 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 (opens in new window). 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 (opens in new window) 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 (opens in new window), 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 (opens in new window) 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 (opens in new window), "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 (opens in new window).

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 (opens in new window), 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 sentWhat the receiver gets
Map / Set{} (own enumerable properties only)
Datean ISO string, not a Date
undefined propertykey dropped from the object
NaN / Infinitynull
typed array / ArrayBufferan index-keyed object or {}
circular referenceserialization 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 (opens in new window) attributes structured clone to other browsers implementing these APIs.

Per cr-msg (opens in new window), 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 (opens in new window): 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-patternWhy it failsFix
Async onMessage listener returning nothingNot the literal true cr-msg (opens in new window) requires, so the channel closes and the sender sees no responseReturn the response value, or use a non-async listener with return true
Returning a truthy non-true value to keep the channel opencr-msg (opens in new window) requires "a literal true (not just a truthy value)"return true
Sending Map / Set / Date and asserting on the typeJSON serialization, not structured clone, per cr-msg (opens in new window)Send plain objects; assert on the serialized shape
expect(response).toBeDefined()Passes on the undefined result the closed-channel bug producesAssert the full object with an equality matcher
Asserting a response with several onMessage listeners registeredOnly the first to respond affects the sender per cr-msg (opens in new window)Route on a message-type field so one listener owns each type
Idling a port test past the service-worker idle windowTermination after 30 seconds of inactivity per cr-sw (opens in new window); opening a port does not reset the timerExchange a message inside the window, or assert the termination deliberately
Adding externally_connectable.matches without idsOther extensions lose the ability to connect per cr-extconn (opens in new window)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 (opens in new window)Use custom DOM events or content-script injection
Checking chrome.runtime.lastError after a promise-based callPromise-returning functions do not set it per cr-runtime (opens in new window)Assert on the rejection instead
Reusing a published extension ID in a native-messaging allowed_origins while testing an unpacked buildallowed_origins values cannot contain wildcards per cr-native (opens in new window), and an unpacked load has a different IDList 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 (opens in new window), 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 (opens in new window) 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 (opens in new window), 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 (opens in new window) 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.

Cross-boundary messaging: web pages, other extensions, and native hosts

View source (opens in new window)

Cross-boundary messaging: web pages, other extensions, and native hosts

Deep reference for chrome-extension-messaging-tests SKILL.md. Consult when a companion web site or another extension must talk to the extension under test, or when a native helper application is part of the product.

Web-page and cross-extension messages

Per the Chrome Extensions message passing page (opens in new window), a web page can send to an extension only if the extension declares the page's origin in externally_connectable:

"externally_connectable": {
  "matches": ["https://*.example.com/*"]
}
// On the allow-listed web page
chrome.runtime.sendMessage(editorExtensionId, { openUrlInEditor: url },
  response => { if (!response.success) handleError(url); });
// In the extension
chrome.runtime.onMessageExternal.addListener((request, sender, sendResponse) => {
  if (sender.id !== allowlistedExtension) return;
  if (request.getTargetData) sendResponse({ targetData });
});

The externally_connectable reference (opens in new window) defines the defaults that produce the two most common surprises, both worth a regression test:

  • Without the key declared, "all extensions can connect, but no web pages can connect."
  • Adding the key without "ids": ["*"] means other extensions lose the ability to connect, per cr-extconn (opens in new window). A manifest change that adds a matches list for a web page silently breaks a previously working extension-to-extension flow unless ids is set too.

Per the chrome.runtime reference (opens in new window), chrome.runtime.onMessageExternal fires "when a message is sent from another extension (by runtime.sendMessage)" and cannot be used in content scripts, so the listener belongs in the service worker or an extension page.

Direction matters. Per cr-msg (opens in new window): "It is not possible to send a message from an extension to a web page." A test expecting the reverse direction is testing the wrong mechanism and should be rewritten against custom DOM events or content-script injection.

Test targets:

  • An origin inside matches gets a response.
  • An origin outside matches gets no response at all (not an error response).
  • A sender extension ID not on the allow-list is rejected by the sender.id guard.
  • After adding a matches list, a previously working cross-extension send still works (the ids regression above).

Native messaging

Per cr-msg (opens in new window), native messaging works like cross-extension messaging with one substitution: "runtime.connectNative() is used instead of runtime.connect()". The native messaging guide (opens in new window) adds that the nativeMessaging permission must be declared and that these methods are unavailable in content scripts, and cr-runtime (opens in new window) gives the signature as chrome.runtime.connectNative(application) returning a Port.

The host must be registered on the machine before any test can pass. Per cr-native (opens in new window), the host manifest lives at:

OSLocation
WindowsRegistry key HKEY_LOCAL_MACHINE\SOFTWARE\Google\Chrome\NativeMessagingHosts\[host-name] or the HKEY_CURRENT_USER equivalent
macOS/Library/Google/Chrome/NativeMessagingHosts/ system-wide, ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/ per user
Linux/etc/opt/chrome/native-messaging-hosts/ system-wide, ~/.config/google-chrome/NativeMessagingHosts/ per user

Per cr-native (opens in new window), the manifest's allowed_origins is the "List of extensions that should have access to the native messaging host", and those values "can't contain wildcards" - so the extension ID under test must be listed literally, which means a fresh unpacked-load ID will not match a manifest written for the published ID.

Native messaging has its own size limits, tighter than the general ones. Per cr-native (opens in new window): "The maximum size of a single message from the native messaging host is 1 MB" while "The maximum size of the message sent to the native messaging host is 64 MiB." Assert the inbound 1 MB ceiling explicitly if the host returns bulk data.

Sources

  • Chrome Extensions, Message passing (externally_connectable examples, extension-to-page direction, connectNative substitution): https://developer.chrome.com/docs/extensions/develop/concepts/messaging
  • externally_connectable manifest key (defaults, ids, matches): https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable
  • chrome.runtime API reference (onMessageExternal, connectNative): https://developer.chrome.com/docs/extensions/reference/api/runtime
  • 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

Long-lived ports and service-worker lifecycle

View source (opens in new window)

Long-lived ports and service-worker lifecycle

Deep reference for chrome-extension-messaging-tests SKILL.md. Consult when a long-lived chrome.runtime.connect port drops mid-session or a port test has to account for service-worker suspension.

Per the Chrome Extensions message passing page (opens in new window), a port is opened with chrome.runtime.connect and answered by onConnect:

// Content script
const port = chrome.runtime.connect({ name: 'knockknock' });
port.onMessage.addListener(msg => {
  if (msg.question === "Who's there?") port.postMessage({ answer: 'Madame' });
});
port.postMessage({ joke: 'Knock knock' });

// Service worker
chrome.runtime.onConnect.addListener(port => {
  if (port.name !== 'knockknock') return;
  port.onMessage.addListener(msg => {
    if (msg.joke === 'Knock knock') port.postMessage({ question: "Who's there?" });
  });
});

The chrome.runtime reference (opens in new window) gives chrome.runtime.connect(extensionId?, connectInfo?) returning a Port, where connectInfo.name "will be passed into onConnect for processes that are listening for the connection event". That name is what the port.name !== 'knockknock' guard above filters on, so a test that connects with the wrong name should observe no reply.

Per cr-msg (opens in new window), onDisconnect fires when: no listeners exist for onConnect, the tab unloads, the originating frame unloads, all receiving frames unload, or disconnect() is called. A port test should cover at least the first and last of those, because they are the two a bug produces:

test('connecting with an unknown port name disconnects immediately', async () => {
  const events = [];
  const port = chrome.runtime.connect({ name: 'no-such-listener' });
  port.onDisconnect.addListener(() => events.push('disconnect'));
  await settle();
  expect(events).toEqual(['disconnect']);
});

Accumulate disconnect events into an array rather than setting a boolean. A port can have multiple receiving frames per cr-msg (opens in new window), so "exactly one disconnect" is an assertion you should make explicitly rather than assume.

Service-worker suspension interacts with ports

Per the extension service worker lifecycle page (opens in new window), the service worker terminates after 30 seconds of inactivity, and "Opening a port no longer resets the timers" as of Chrome 114. What keeps it alive is traffic: cr-sw (opens in new window) states "Sending a message with long-lived messaging keeps the service worker alive". A port test that idles longer than 30 seconds without exchanging a message is asserting against a terminated worker, and the disconnect it observes is the platform, not a bug.

Sources

  • Chrome Extensions, Message passing (onDisconnect conditions, multiple receiving frames): https://developer.chrome.com/docs/extensions/develop/concepts/messaging
  • chrome.runtime API reference (connect / connectInfo.name): https://developer.chrome.com/docs/extensions/reference/api/runtime
  • 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

Related skills

manifest-v3-test-surface-reference

Pure-reference catalog of the Manifest V3 test surface for Firefox + Chromium browser extensions. Maps each manifest field that changed from MV2 (manifest_version, background.service_worker vs background.scripts, action vs browser_action / page_action, host_permissions split, web_accessible_resources object-form, content_security_policy object-form), the runtime restrictions service workers impose (no DOM, no XMLHttpRequest, no localStorage, ephemeral lifecycle, synchronous listener registration, alarms instead of setTimeout), and the Firefox-vs-Chrome key matrix (browser_specific_settings.gecko, externally_connectable / offline_enabled gaps, MV2-only user_scripts manifest key); references/ carry Mozilla's web-ext CLI (lint via addons-linter, run on firefox-desktop / firefox-android / chromium targets, deterministic build, AMO sign). Use as the manifest-surface reference when authoring extension tests across both browsers, or when driving Firefox runs / AMO signing with web-ext.

playwright-extension-fixtures

Author the Playwright fixture layer every Chromium extension test depends on - `chromium.launchPersistentContext` with `--disable-extensions-except=$DIR` + `--load-extension=$DIR`, the `channel: 'chromium'` selection that unlocks headless extension support, the `context.serviceWorkers()` + `waitForEvent('serviceworker')` race-handling pattern, and the `extensionId = serviceWorker.url().split('/')[2]` extraction recipe - plus, in references/, the extension load / reload matrix (which edit re-evaluates which surface), the `chrome.storage` test suite (area selection, quota-exceeded, `storage.onChanged`, managed read-only), and the per-surface assertion recipes (popup, content script, background messaging, MV3 auto-suspend survival). Use when authoring or debugging the fixture a Chromium extension test imports, when an edit appears to have no effect and you need the reload matrix, or when authoring popup / content-script / storage tests.