From a9a82521631c2f64338022b294707719b34a52a0 Mon Sep 17 00:00:00 2001 From: Rendi Wijaya Date: Tue, 6 Oct 2026 09:45:09 +0700 Subject: [PATCH] feat(extension): open the popup from the inline picker's more-logins row When the page's host has no entries of its own but other hosts of the domain do, the picker's last row now opens the toolbar popup through the service worker (`inline-open-popup`, `chrome.action.openPopup`). It fills nothing and releases nothing, so it needs a trusted click but not the six guards. Where the popup cannot open, the footer turns into text naming the Extensions menu and the `fill-login` shortcut for the rest of the page. Closes #20. --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 6 ++ KNOWN-LIMITS.md | 1 + README.md | 20 +++++ SECURITY.md | 6 ++ extension/src/background.ts | 38 +++++--- extension/src/content/inline.ts | 39 +++++++-- extension/src/lib/messages.ts | 9 +- extension/test/inline.spec.ts | 130 +++++++++++++++++++++++++--- extension/test/playwright.config.ts | 4 +- 10 files changed, 220 insertions(+), 35 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6898fe7..1255dc3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,7 +113,7 @@ App data dir of the bundle id (`dev.rendy.subclave`; `pnpm tauri:dev` uses 3. **Lock**: manual, from the lock button, the status bar or the `vault.lock` shortcut (`useVaultStore.lock`, `vault_lock`, `lock_inner`); idle, when `spawn_vault_tick_thread` calls `VaultState::access` every 5 s past the deadline that `vault_touch` extends on activity (`drain_auto_lock`); minimize, in `on_main_window_event` when `lock_on_minimize` is set; tray, from the Lock item in `tray.rs`. Every path calls `Unlocked::wipe` and drops the key, then `emit_locked` closes the sync session (`session_closed`), drops every browser connection (`close_all`) and emits `subclave:vault-locked` with the `LockReason`. 4. **Sync pull and push**: `startSync` builds the scheduler (`createPolicy`). It pulls on unlock and on window focus (`onFocus`, at most once per `FOCUS_INTERVAL_MS`), and pushes `PUSH_DEBOUNCE_MS` after the first change of a burst (`markDirty`, on any `subclave:vault-changed` whose origin is not `sync`). Passes run one at a time (`serialize`) and re-read the switch each time (`readConfig`). Pull: `sync_pull` snapshots `locals_from_payload` and the etags, releases the lock, runs `pull` (list, fetch the objects whose etag moved, `open_envelope`, `merge`, prune this device's expired tombstones), lands the report with `apply_pull` (`merge_into_payload` per record) and `commit`s, then emits origin `sync`. Push: `sync_push` takes `take_dirty_envelopes`, `push` seals each one (`seal_envelope`) and writes it conditionally on its etag where the backend can, re-merging once on a conflict (`retry_merged`), then `finish_push` clears the marks that landed and `commit`s. 5. **Browser fill**: every request goes service worker, `createNativeTransport`, `subclave-proxy` (`read_frame`, `write_frame`), then the app's `accept_loop`, `handle_conn` and `dispatch` (status, locked check, `hello` and `auth` handshake, then the action). - - Inline: `startInline` and `createInline` draw an icon per field found by `scan`. Opening the picker sends `inline-logins`; the service worker answers it in `handleInline` at `scope: "host"`, and fills and the picker use the sender's own URL (`getState`, `get-logins`, `get_logins`, which returns no password). A pick passes `guard.check` (`createGuard`), then `inline-fill` runs `fillEntry` with `via: "inline"`: `get-credential` (`get_credential` re-runs `matching::matches` and requires `same_host`), then `subclave:fill` to the content script, where `fillTarget` refuses another origin and `fillCredential` fills. + - Inline: `startInline` and `createInline` draw an icon per field found by `scan`. Opening the picker sends `inline-logins`; the service worker answers it in `handleInline` at `scope: "host"`, and fills and the picker use the sender's own URL (`getState`, `get-logins`, `get_logins`, which returns no password). A pick passes `guard.check` (`createGuard`), then `inline-fill` runs `fillEntry` with `via: "inline"`: `get-credential` (`get_credential` re-runs `matching::matches` and requires `same_host`), then `subclave:fill` to the content script, where `fillTarget` refuses another origin and `fillCredential` fills. When `get_logins` counts `otherMatches`, the picker's last row sends `inline-open-popup`, which runs `openPopup` (`chrome.action.openPopup`) and nothing else. Where that is missing or rejects, the answer carries the `fill-login` shortcut from `chrome.commands.getAll`, and the row turns into text naming the Extensions menu and the shortcut for the rest of the page (`popupFallback`). - Save prompt: `pass` wires the form of each login field (`wireForm`). A trusted submit, or a click on its submit button, that follows a user gesture reads the pair with `submittedLogin` and sends `inline-submitted`; the service worker keeps it per tab in `chrome.storage.session` with the sender's URL and a fresh id. Each page load in that tab, and the submitting page itself once the password has left its form, sends `inline-pending-save`, which runs `check-login` with the asking page's URL as `pageUrl` (`check_login`: `other-site` off the submit's site by the Domain rule, else the submitted host's exact-host entries, no password returned); a new or changed login opens the picker as a prompt in the viewport's corner (`promptSave`). Add or Update pass `guard.check`, then `inline-save` carries the pair's id and sends `save-login` with `via: "inline"` (`save_login` requires `same_host` for an update, and the old password goes to history); Cancel sends `inline-save-cancel`, which drops the pair. A new submit closes a prompt that is showing. - Popup: `PopupApp` asks `get-state` for the active tab at `scope: "all"`, and a pick sends `fill-entry`, which runs `fillEntry` with `via: "popup"` (`ensureContentScript` injects `content.js` when it is missing). - Command: `fill-login` runs `runFillCommand`: `get-logins` for the active tab, then one match fills with `via: "command"`, and several open the popup where `chrome.action.openPopup` exists, or fill the most recently used one. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f92889..655b660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ The latest release only. Every earlier version: [GitHub Releases](https://github.com/rendyuwu/subclave/releases). Versions: [SemVer](https://semver.org/); before `1.0` a minor bump may break things. +## [Unreleased] + +### Changed + +- The inline picker's "more logins" footer is now a row that opens the Subclave popup, which lists the logins saved for other hosts of the domain. Where the browser cannot open the popup from the page, the footer names the Extensions menu and the fill shortcut instead. + ## [0.1.1] - 06-10-2026 No change to the desktop app or to the extension's code. diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 0a2380f..1b4872c 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -29,6 +29,7 @@ lives, and what would change it. - **No fill in cross-origin iframes**: the content script runs in the top frame only and reaches same-origin iframes through `contentDocument`, which a cross-origin frame does not expose, so a login form inside one is neither detected nor filled. `extension/src/content/fill.ts` (`rootsOf`). Changes when: the content script runs in every frame with a per-frame origin check. - **The inline guards are JavaScript against JavaScript**: the six clickjacking guards run in the same page as the code they guard against, and Firefox has no `IntersectionObserver` v2, so the visibility guard (guard 6) runs on Chromium only. `extension/src/content/guard.ts` (`createGuard`). Changes when: browsers give extension UI a visibility guarantee. - **No inline UI without the Popover API**: Firefox 115 to 124 (and Chrome before 114) get no icon in login fields and no save prompt; the popup and the fill command still work. The save prompt is part of the inline UI, so "Show in login fields" off turns it off too. `extension/src/content/inline.ts` (`startInline`). Changes when: the minimum Firefox version reaches 125. +- **The more-logins row needs action.openPopup**: Chrome 118 to 126 open the popup only for a policy-installed extension and older Chrome not at all, and Firefox before 149 opens it only inside a user action handler (whether a click relayed from the page counts there is not tested). On those browsers the row's first click on a page turns it into text pointing at the Extensions menu and the fill shortcut for the rest of that page, and the next page shows the row again. `extension/src/background.ts` (`openPopup`), `extension/src/content/inline.ts` (`popupFallback` in `createInline`). Changes when: the minimum versions reach Chrome 127 and Firefox 149, or the service worker learns before the first click that the popup cannot open. - **Very late login forms get no icon**: a page with no login field 5 seconds after it has loaded stops watching for good, so a single-page app that shows its login form later (or navigates to it without a page load) gets no icon. The popup and the fill command still fill it. `extension/src/content/inline.ts` (`SETTLE_AFTER_LOAD_MS`, `settle` in `createInline`). Changes when: detection keeps a cheap watch running on pages without a field. - **An unhinted first login step gets no icon**: a text field on its own (the first step of a two-step login) gets an icon only when it says `autocomplete="username"` or `"email"`, which keeps icons out of search boxes and newsletter fields. A first step without the hint (Yahoo and Zoom when last checked) gets no icon; the popup and the fill command still fill it. `extension/src/content/detect.ts` (`scan`). Changes when: detection gets a name or label heuristic that still leaves search and newsletter fields alone. - **"Show in login fields" applies from the next page load**: the content script reads the setting once when the page loads, so open tabs keep their current state. `extension/src/content/index.ts` (`askSettings`). Changes when: the service worker tells open tabs about the change. diff --git a/README.md b/README.md index b5bf207..a7237f6 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,26 @@ Pair from the Extensions menu as in step 4 above. If the popup offers Pair with Subclave again after a restart, pair again and revoke the old entry under Settings > Browser. +## Fill a login + +Click a login field, or the key icon in it, to list the logins saved for that +page's exact host, and pick one. + +Logins for another host of the same domain (an entry for `github.com` opened +from `gist.github.com`) are not listed. The picker's last row, +`N more logins on github.com`, opens the Subclave popup, which lists every +match. Where the browser cannot open the popup from the page, the row turns +into text; then open Subclave from the browser's Extensions menu (or the +toolbar once pinned) and pick the login there. + +`Ctrl+Shift+L` (`Cmd+Shift+L` on macOS) fills the current page without the +picker: one match fills right away; several open the popup, or fill the most +recently used login where the browser cannot open it. + +The browser leaves the shortcut unset when another extension already holds +it. Set it at `chrome://extensions/shortcuts`, or in Firefox under +`about:addons`, gear menu, Manage Extension Shortcuts. + ## Build from source Needs Rust stable, Node 20.19+ with pnpm, and diff --git a/SECURITY.md b/SECURITY.md index 7d28951..768907a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,6 +54,12 @@ Until `1.0.0`, only the latest minor gets security fixes. `example.com` login, which was the main vector in Marek Tóth's DEF CON 33 research ([DOM-based Extension Clickjacking](https://marektoth.com/blog/dom-based-extension-clickjacking)). + When other hosts of the domain hold matches, the picker's last row counts + them and only opens the popup (`chrome.action.openPopup`). It fills + nothing and releases nothing, so it needs a trusted click but not the six + guards. A clickjacked click on it can only open the popup, where a fill + still takes a click in browser-owned UI. Where the popup cannot be opened, + the row turns into text. - Inline fill needs a trusted user action and passes the six guards in `extension/src/content/guard.ts`. If any guard fails, nothing is filled. - The page never supplies the URL. It comes from the browser: diff --git a/extension/src/background.ts b/extension/src/background.ts index 0322c71..d4a687f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -81,6 +81,7 @@ const INLINE_REQUEST_TYPES: Record = { "inline-pending-save": true, "inline-save": true, "inline-save-cancel": true, + "inline-open-popup": true, }; export type BackgroundOptions = { @@ -413,18 +414,7 @@ export function startBackground(options: BackgroundOptions = {}): void { return { type: "fill", ok: true, filled: 0 }; } const entries = logins.result.entries; - // `chrome.action.openPopup` is Chrome 127+; Firefox and older Chrome have - // no such member, so it is read through a named interface. - const action: { openPopup?: () => Promise } = chrome.action; - if (entries.length > 1 && action.openPopup) { - try { - await action.openPopup.call(chrome.action); - return { type: "fill", ok: true, filled: 0 }; - } catch { - // Firefox and older Chrome reject openPopup; fall through to the - // newest-match fallback below. - } - } + if (entries.length > 1 && (await openPopup())) return { type: "fill", ok: true, filled: 0 }; let pick = entries[0]; if (entries.length > 1) { for (const entry of entries) { @@ -579,7 +569,8 @@ export function startBackground(options: BackgroundOptions = {}): void { * URL, so they never reach another host's logins or the popup's wider match. * The save prompt saves to the browser-stamped URL of the tab's own submitted * sign-in, and shows only on a page of that sign-in's site (`check-login` - * decides, by the vault's Domain rule). */ + * decides, by the vault's Domain rule). `inline-open-popup` uses neither the + * tab nor the URL; it only opens the popup. */ const handleInline = async ( message: InlineRequest, tabId: number, @@ -622,6 +613,12 @@ export function startBackground(options: BackgroundOptions = {}): void { case "inline-save-cancel": await dropPending(tabId, message.id); return { type: "save", ok: true }; + case "inline-open-popup": { + if (await openPopup()) return { type: "popup", ok: true }; + const commands = await chrome.commands.getAll(); + const shortcut = commands.find((command) => command.name === "fill-login")?.shortcut; + return { type: "popup", ok: false, shortcut: shortcut ?? "" }; + } } }; @@ -683,6 +680,21 @@ function runtimeMessageType(message: unknown): string | null { return typeof type === "string" ? type : null; } +/** Opens the toolbar popup in the focused window. `chrome.action.openPopup` + * is open to every extension from Chrome 127 and needs no user action from + * Firefox 149; older versions lack it or reject the call, which reads as + * `false`. Read through a named interface because the member can be missing. */ +async function openPopup(): Promise { + const action: { openPopup?: () => Promise } = chrome.action; + if (!action.openPopup) return false; + try { + await action.openPopup.call(chrome.action); + return true; + } catch { + return false; + } +} + /** Chromium's UA always says "Chrome", so the other products are checked first. */ function browserName(): string { const ua = navigator.userAgent; diff --git a/extension/src/content/inline.ts b/extension/src/content/inline.ts index 23d516d..152c1d2 100644 --- a/extension/src/content/inline.ts +++ b/extension/src/content/inline.ts @@ -245,6 +245,13 @@ function createInline(): Inline { * to update. The prompt is the picker open with no anchor. */ let saving: { prompt: SavePrompt; listing: boolean } | null = null; let submitSeq = 0; + /** The fill command's shortcut ("" when unbound) once the service worker + * could not open the popup for this page; the more-logins footer is text + * from then on. */ + // ponytail: remembered per page, so a browser that can never open the popup + // shows the row until its first click on each page; have the service worker + // learn it up front if that matters. + let popupFallback: string | null = null; const isOpen = (): boolean => picker.matches(":popover-open"); @@ -456,6 +463,21 @@ function createInline(): Inline { } }; + /** Opens the toolbar popup, which lists the wider matches in browser-owned + * UI. Fills and releases nothing, so its row needs a trusted event only. */ + const openPopup = async (state: Extract): Promise => { + const mine = seq; + const response = await ask({ type: "inline-open-popup" }); + if (!response || mine !== seq) return; + if (response.type === "popup" && response.ok) close(); + else if (response.type === "popup") { + popupFallback = response.shortcut; + showReady(state); + } else if (response.type === "error") { + errorView(response.code, response.message); + } + }; + const showReady = (state: Extract): void => { const next: Row[] = []; if (anchor && fields.get(anchor) === true) { @@ -484,11 +506,18 @@ function createInline(): Inline { if (state.entries.length === 0) lines.push(note(`No logins for ${state.host}.`)); const more = state.otherMatches; if (more > 0) { - lines.push( - note( - `${more} more login${more === 1 ? "" : "s"} on ${state.domain}: use the Subclave toolbar button`, - ), - ); + const count = `${more} more login${more === 1 ? "" : "s"} on ${state.domain}`; + if (popupFallback === null) { + next.push({ + label: count, + detail: "Open in Subclave popup", + guarded: false, + run: () => openPopup(state), + }); + } else { + const press = popupFallback ? `, or press ${popupFallback}` : ""; + lines.push(note(`${count}: open Subclave from the browser's Extensions menu${press}`)); + } } render(next, lines); }; diff --git a/extension/src/lib/messages.ts b/extension/src/lib/messages.ts index 6acc0c3..db6ac8e 100644 --- a/extension/src/lib/messages.ts +++ b/extension/src/lib/messages.ts @@ -26,7 +26,9 @@ export type PopupRequest = * `inline-save-cancel`) act on the sign-in that same tab submitted: it saves * to the `sender.url` stamped on its `inline-submitted`, and shows only on a * page of that URL's site (`check-login` decides). `id` names the pending - * sign-in the prompt shows, so a newer one is never written in its place. */ + * sign-in the prompt shows, so a newer one is never written in its place. + * `inline-open-popup` only opens the toolbar popup, which lists the wider + * matches for the active tab in browser-owned UI; it names no entry and no URL. */ export type InlineRequest = | { type: "inline-settings" } | { type: "inline-logins" } @@ -37,7 +39,8 @@ export type InlineRequest = | { type: "inline-submitted"; username: string; password: string } | { type: "inline-pending-save" } | { type: "inline-save"; id: string; entryId: string | null } - | { type: "inline-save-cancel"; id: string }; + | { type: "inline-save-cancel"; id: string } + | { type: "inline-open-popup" }; /** A sign-in waiting for the save prompt. `id` is the pending sign-in's own; * `host` is the submitted page's hostname. */ @@ -71,6 +74,8 @@ export type SwResponse = | { type: "save-prompt"; prompt: SavePrompt | null } | { type: "save"; ok: true } | { type: "save"; ok: false; code: NmErrorCode; message: string } + | { type: "popup"; ok: true } + | { type: "popup"; ok: false; shortcut: string } | { type: "error"; code: NmErrorCode; message: string }; /** `url` is the page the credential was released for, and `anchored` says the diff --git a/extension/test/inline.spec.ts b/extension/test/inline.spec.ts index 2dd10d5..cb2b7ed 100644 --- a/extension/test/inline.spec.ts +++ b/extension/test/inline.spec.ts @@ -62,6 +62,48 @@ async function openOn(page: Page, ui: Inline, field: Locator): Promise { await waitForOptions(page, ui); } +/** Replaces `chrome.action.openPopup` in the service worker with a stub that + * counts its calls in `__popupOpens` and, with `reject`, rejects like Firefox + * before 149. `delete` already works on this property, so it is configurable. */ +async function stubOpenPopup(reject: boolean): Promise { + await harness.worker.evaluate((fail) => { + // The worker's global keeps the count between `evaluate` calls; this stub + // is the only writer, so the shape is known. + const counter = globalThis as unknown as { __popupOpens: number }; + counter.__popupOpens = 0; + Object.defineProperty(chrome.action, "openPopup", { + configurable: true, + value: async () => { + counter.__popupOpens += 1; + if (fail) throw new Error("openPopup rejected"); + }, + }); + }, reject); +} + +async function popupOpens(): Promise { + return harness.worker.evaluate(() => { + // Written only by `stubOpenPopup`; absent before the first stub. + const counter = globalThis as unknown as { __popupOpens?: number }; + return counter.__popupOpens ?? 0; + }); +} + +/** Replaces `chrome.commands.getAll` in the service worker, since headless + * Chromium leaves `fill-login` unbound. Another command comes first, so the + * lookup has to pick `fill-login` by name. */ +async function stubShortcut(shortcut: string): Promise { + await harness.worker.evaluate((key) => { + Object.defineProperty(chrome.commands, "getAll", { + configurable: true, + value: async () => [ + { name: "_execute_action", shortcut: "Alt+Shift+S" }, + { name: "fill-login", shortcut: key }, + ], + }); + }, shortcut); +} + const GUARD_FIXTURES = [ "guard-untrusted.html", "guard-timing.html", @@ -174,7 +216,7 @@ test("opens on a field click, on the icon and on ArrowDown, for this host only", await username.click(); await expect.poll(() => ui.popoverOpen()).toBe(true); - await expect.poll(() => ui.count(OPTION)).toBe(FAKE_ENTRIES.length); + await expect.poll(() => ui.count(OPTION)).toBe(FAKE_ENTRIES.length + 1); const text = await ui.pickerText(); for (const entry of FAKE_ENTRIES) { expect(text).toContain(entry.title); @@ -202,24 +244,86 @@ test("opens on a field click, on the icon and on ArrowDown, for this host only", expect(await page.evaluate(() => document.activeElement?.id)).toBe("username"); }); -test("the footer counts the logins left for the toolbar button", async ({}, testInfo) => { +test("the more-logins row opens the popup by pointer and by keyboard, and ignores an untrusted click", async ({}, testInfo) => { harness = await startHarness(testInfo); + await stubOpenPopup(false); await setMode(harness, "no-fields"); const page = await openFixture(harness, "login.html"); const ui = inlineOf(page); - await page.locator("#username").click(); - await expect.poll(() => ui.pickerText()).toContain("No logins for 127.0.0.1."); - expect(await ui.pickerText()).toContain( - `1 more login on ${FAKE_DOMAIN}: use the Subclave toolbar button`, - ); + await openOn(page, ui, page.locator("#username")); + expect(await ui.count(OPTION)).toBe(1); + const text = await ui.pickerText(); + expect(text).toContain(`1 more login on ${FAKE_DOMAIN}`); + expect(text).toContain("Open in Subclave popup"); + expect(text).toContain("No logins for 127.0.0.1."); + + // Not a guarded row: an untrusted click is ignored, not refused. + await ui.dispatchUntrustedClick(OPTION, 0); + await page.waitForTimeout(300); + expect(await popupOpens()).toBe(0); + expect(await ui.count(OPTION)).toBe(1); + expect(await ui.count(".message")).toBe(0); + + await clickInline(page, ui, OPTION, 0); + await expect.poll(() => popupOpens()).toBe(1); + await expect.poll(() => ui.popoverOpen()).toBe(false); await setMode(harness, "ok"); - const second = await openFixture(harness, "login.html"); - const secondUi = inlineOf(second); - await second.locator("#username").click(); - await expect - .poll(() => secondUi.pickerText()) - .toContain(`2 more logins on ${FAKE_DOMAIN}: use the Subclave toolbar button`); + const keyboard = await openFixture(harness, "login.html"); + const keyboardUi = inlineOf(keyboard); + await keyboard.locator("#username").focus(); + await keyboard.keyboard.press("ArrowDown"); + await waitForOptions(keyboard, keyboardUi); + expect(await keyboardUi.count(OPTION)).toBe(FAKE_ENTRIES.length + 1); + expect(await keyboardUi.pickerText()).toContain(`2 more logins on ${FAKE_DOMAIN}`); + for (let step = 0; step < FAKE_ENTRIES.length; step += 1) { + await keyboard.keyboard.press("ArrowDown"); + } + await keyboard.keyboard.press("Enter"); + await expect.poll(() => popupOpens()).toBe(2); + await expect.poll(() => keyboardUi.popoverOpen()).toBe(false); + + expect((await readLog(harness)).some((entry) => entry.action === "get-credential")).toBe(false); +}); + +test("where the popup cannot open, the more-logins footer names the Extensions menu and the shortcut", async ({}, testInfo) => { + harness = await startHarness(testInfo); + await stubShortcut("Ctrl+Shift+L"); + await stubOpenPopup(true); + await setMode(harness, "no-fields"); + const page = await openFixture(harness, "login.html"); + const ui = inlineOf(page); + const username = page.locator("#username"); + await openOn(page, ui, username); + await clickInline(page, ui, OPTION, 0); + + const text = `1 more login on ${FAKE_DOMAIN}: open Subclave from the browser's Extensions menu`; + const fallback = `${text}, or press Ctrl+Shift+L`; + await expect.poll(() => ui.pickerText()).toContain(fallback); + expect(await ui.count(OPTION)).toBe(0); + expect(await popupOpens()).toBe(1); + + // The page remembers: the next picker is text at once and asks no more. + await page.keyboard.press("Escape"); + await expect.poll(() => ui.popoverOpen()).toBe(false); + await username.click(); + await expect.poll(() => ui.pickerText()).toContain(fallback); + expect(await ui.count(OPTION)).toBe(0); + expect(await popupOpens()).toBe(1); + + // A browser with no openPopup at all reads the same; with the shortcut + // unbound, the text names the Extensions menu only. + await harness.worker.evaluate(() => { + const action: { openPopup?: () => Promise } = chrome.action; + delete action.openPopup; + }); + await stubShortcut(""); + const fresh = await openFixture(harness, "login.html"); + const freshUi = inlineOf(fresh); + await openOn(fresh, freshUi, fresh.locator("#username")); + await clickInline(fresh, freshUi, OPTION, 0); + await expect.poll(() => freshUi.pickerText()).toContain(text); + expect(await freshUi.pickerText()).not.toContain(", or press"); }); test("a pick fills the field's login through via inline, by pointer and by keyboard", async ({}, testInfo) => { diff --git a/extension/test/playwright.config.ts b/extension/test/playwright.config.ts index 6d48ae4..adfb933 100644 --- a/extension/test/playwright.config.ts +++ b/extension/test/playwright.config.ts @@ -25,7 +25,9 @@ import { FIXTURE_PORT } from "./support/harness"; // the API first, which is the same condition as Firefox and Chrome before // 127, and the multi-match branch then takes its documented // newest-`lastUsedAt` fallback. The real openPopup path needs a headed -// browser, so these headless specs do not exercise it. +// browser, so these headless specs do not exercise it. `inline.spec.ts` +// replaces it with a stub that counts calls (or rejects) to drive the +// picker's more-logins row. // 2. A key press synthesized over CDP does not reach Chrome's own // `chrome.commands` handling, so `fill.spec.ts` falls back to sending the // service worker's internal `{ type: "fill-command" }` message from the