Testing Context Menus and Shortcuts End to End

Test MV3 context menu items and keyboard commands despite automation limits: verifying registration, invoking onClicked and onCommand handlers from the service worker, testing in-page shortcuts with real key presses, and what still needs manual checks.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Context menus and keyboard shortcuts are some of an extension’s most-used features and some of its least-tested. Browser automation tools can click page elements, but they cannot open Chrome’s native context menu or press a browser-level extension shortcut, because both are handled by the browser UI outside the page. Teams conclude these features are untestable and check them by hand — until a refactor moves a listener inside a function and right-click “Save” silently stops working after the worker restarts. Most of what can break is testable: whether items are registered with the right properties, whether handlers do the right thing with realistic event data, and whether in-page shortcuts work. This guide tests each layer. It belongs to end-to-end testing and automation.

What automation can and cannot reach

Native menus and browser commands are outside the page: Playwright’s page.click({ button: "right" }) opens no extension menu, and page.keyboard.press("Alt+Shift+S") is delivered to the page, not to Chrome’s command handler. What you can reach is the service worker: you can read which menu items exist (by recording create calls or querying your own registry), and you can call the same functions your onClicked and onCommand listeners call, with realistic info and tab objects. Content-script shortcuts handled by keydown listeners are ordinary page events and fully testable with real key presses. The remaining gap — the browser actually dispatching the event — is narrow and is best covered by a short manual checklist.

Testable layers of menus and shortcutsFrom top to bottom: browser UI dispatch of native menus and commands (manual check); registration of items and commands (automated); handler logic invoked with realistic event objects (automated); in-page keydown shortcuts (automated with real key presses).Browser dispatchnative menu, command keys — manualRegistrationitems, contexts, commands — automatedHandler logiconClicked / onCommand bodies — automatedIn-page shortcutskeydown in content script — automated
Automate everything below the browser's own UI.

Step-by-step: testing menus and shortcuts

1. Structure handlers so tests can call them

 1// sw.js
 2export async function handleMenuClick(info, tab) {
 3  switch (info.menuItemId) {
 4    case "save-link": return saveUrl(info.linkUrl, tab);
 5    case "save-selection": return saveText(info.selectionText, tab);
 6  }
 7}
 8export async function handleCommand(command, tab) {
 9  if (command === "save-page") return saveUrl(tab.url, tab);
10}
11chrome.contextMenus.onClicked.addListener(handleMenuClick);
12chrome.commands.onCommand.addListener(handleCommand);
13
14if (import.meta.env.MODE === "test") Object.assign(globalThis, { handleMenuClick, handleCommand });

Execution context: the service worker. Named handler functions registered at the top level keep the production behaviour correct and give tests something to call. Exposing them on globalThis only in the test build lets Playwright invoke them through the worker handle without leaking test hooks into production.

2. Verify registration after install and after restart

 1test("menu items are registered after a worker restart", async ({ context, serviceWorker, restartWorker }) => {
 2  const read = (sw) => sw.evaluate(() => self.__menuRegistry);          // populated by a create() wrapper in test builds
 3  expect(await read(serviceWorker)).toEqual(expect.arrayContaining([
 4    expect.objectContaining({ id: "save-link", contexts: ["link"] }),
 5    expect.objectContaining({ id: "save-selection", contexts: ["selection"] }),
 6  ]));
 7  const sw2 = await restartWorker();
 8  await sw2.evaluate(() => self.handleMenuClick({ menuItemId: "save-link", linkUrl: "https://ex.test/a" }, { id: 1, url: "https://ex.test" }));
 9  await expect.poll(() => sw2.evaluate(() => chrome.storage.local.get("items").then((r) => r.items?.length ?? 0))).toBe(1);
10});

Execution context: a Playwright test. Chrome has no API to list context menu items, so a thin wrapper around chrome.contextMenus.create in test builds records what was created. Restarting the worker — by stopping it through the CDP ServiceWorker.stopWorker command or reloading the extension — and invoking a handler afterwards proves listeners and state survive termination. See rebuilding context menus after worker restarts.

What to test for each featureContext menus, browser commands and in-page shortcuts compared on what can be automated and what remains manual.FeatureAutomatedManualContext menu itemsRegistration, handler with info/tabItem appears on right-clickBrowser commandsManifest keys, getAll, handlerKey press triggers commandIn-page shortcutsReal key presses, conflicts—activeTab-gated actionsHandler logicGrant from real gesture
Automate registration and handlers; keep a short manual dispatch check.

3. Call handlers with realistic event objects

1test("save-selection saves trimmed text with source URL", async ({ context, serviceWorker }) => {
2  const page = await context.newPage();
3  await page.goto("/article.html");
4  const tab = await serviceWorker.evaluate(() => chrome.tabs.query({ active: true, lastFocusedWindow: true }).then(([t]) => t));
5  await serviceWorker.evaluate(([t]) => self.handleMenuClick(
6    { menuItemId: "save-selection", selectionText: "  The quick brown fox  ", pageUrl: t.url, frameId: 0, editable: false }, t), [tab]);
7  const items = await serviceWorker.evaluate(() => chrome.storage.local.get("items").then((r) => r.items));
8  expect(items.at(-1)).toMatchObject({ text: "The quick brown fox", source: expect.stringContaining("/article.html") });
9});

Execution context: a Playwright test. Using the real tab object from chrome.tabs.query and an info object shaped like Chrome’s makes the test exercise the same code paths as a real click. Include the fields your handler reads — frameId, pageUrl, editable, linkUrl — and test edge cases such as empty selections and selections inside iframes. Note that a real click also grants activeTab; if the handler injects scripts, the test build needs host permissions for the fixture host instead.

Testing an in-page shortcut with real key pressesThe test opens a fixture page, waits for the content script, presses Alt+Shift+H with Playwright's keyboard, the content script's keydown handler highlights the selection, and the test asserts the highlight; pressing the same keys inside a text field is ignored.TestPageContent scriptselect textkeyboard.press('Alt+Shift+H')keydownwrap selection in <mark>focus input; press again → no-op
In-page shortcuts are ordinary key events — test them for real.

4. Test in-page shortcuts with real key presses

 1test("Alt+Shift+H highlights the selection but not inside inputs", async ({ context }) => {
 2  const page = await context.newPage();
 3  await page.goto("/article.html");
 4  await page.locator("html[data-readable-ready='1']").waitFor();
 5  await page.locator("p >> nth=0").selectText();
 6  await page.keyboard.press("Alt+Shift+KeyH");
 7  await expect(page.locator("mark[data-readable]")).toHaveCount(1);
 8
 9  await page.locator("input#search").focus();
10  await page.keyboard.press("Alt+Shift+KeyH");
11  await expect(page.locator("mark[data-readable]")).toHaveCount(1);     // unchanged
12});

Execution context: a Playwright test. Key presses from Playwright reach the page’s keydown listeners, including those registered by content scripts. Test that shortcuts are ignored in editable fields and do not break the page’s own shortcuts. See handling shortcuts in content scripts.

5. Validate command configuration

1test("commands declare valid suggested keys", async ({ serviceWorker }) => {
2  const manifest = await serviceWorker.evaluate(() => chrome.runtime.getManifest());
3  const suggested = Object.values(manifest.commands).filter((c) => c.suggested_key);
4  expect(suggested.length).toBeLessThanOrEqual(4);
5  const all = await serviceWorker.evaluate(() => chrome.commands.getAll());
6  expect(all.map((c) => c.name)).toEqual(expect.arrayContaining(["_execute_action", "save-page"]));
7});

Execution context: a Playwright test. Chrome assigns at most four suggested shortcuts; a test catches a fifth before release. commands.getAll() in a clean test profile shows what Chrome actually assigned, which catches invalid key strings that Chrome silently ignored. See the _execute_action command and the four-shortcut limit.

6. Keep a short manual dispatch checklist

1release-checklist.md — context menus & shortcuts (≈3 minutes, per browser)
2[ ] Right-click a link → "Save link to Readable" present → saves
3[ ] Select text, right-click → "Save selection" → saves
4[ ] Right-click toolbar icon → action menu items present
5[ ] Press Alt+Shift+R → popup opens
6[ ] Press Ctrl/⌘+Shift+S → page saved, badge flashes
7[ ] Stop worker (chrome://serviceworker-internals) → right-click "Save link" still works

Execution context: a release checklist. The browser’s own dispatch is the only part automation cannot reach; a short, specific checklist run on each browser before release covers it in minutes.

Common mistakes

  • Anonymous listener functions. Tests can’t call the handler logic.
  • Testing only in a fresh worker. Restart bugs go unnoticed.
  • Fake info objects missing fields. Edge cases untested.
  • Expecting page.keyboard to trigger browser commands. It only reaches the page.
  • Test hooks in production builds. Gate them by build mode.

Cross-browser variation

  • Chrome / Edge: no API to list menu items; record creates in test builds; commands.getAll available.
  • Firefox: menus can be inspected similarly; Firefox’s commands.update lets tests set shortcuts programmatically, but dispatch still needs manual checks.
  • Safari: test handler logic in Chromium; check dispatch manually in Safari.

Verification

  1. Move the onClicked listener inside a function and confirm the restart test fails.
  2. Add a fifth suggested shortcut and confirm the configuration test fails.
  3. Remove the editable-field guard and confirm the in-page shortcut test fails.
  4. Run the manual checklist on each browser before release.

FAQ

Can CDP open a native context menu?

No supported API opens Chrome’s native context menu for extensions in automation.

Should handler tests go in unit tests instead?

Both. Unit tests cover logic with mocked APIs; end-to-end tests confirm it works with real storage and tabs.

How do I restart the worker in a test?

Use a CDP session and ServiceWorker.stopWorker, or call chrome.runtime.reload() and wait for the new worker.

Can I test that the menu shows only on certain pages?

Assert documentUrlPatterns and contexts in the recorded registry; the browser’s filtering itself is covered by the manual check.

Other Testing, Debugging & Performance Optimization Resources