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.
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.
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.
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.
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
infoobjects missing fields. Edge cases untested. - Expecting
page.keyboardto 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.getAllavailable. - Firefox:
menuscan be inspected similarly; Firefox’scommands.updatelets tests set shortcuts programmatically, but dispatch still needs manual checks. - Safari: test handler logic in Chromium; check dispatch manually in Safari.
Verification
- Move the
onClickedlistener inside a function and confirm the restart test fails. - Add a fifth suggested shortcut and confirm the configuration test fails.
- Remove the editable-field guard and confirm the in-page shortcut test fails.
- 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.
Related
- Driving service worker state from a test — worker handles and hooks.
- Rebuilding context menus after worker restarts — what the restart test protects.
- Listing configured shortcuts with commands.getAll — command configuration.
- End-to-end testing and automation — the parent topic.