cbcc3e0006bd
add inject.ts UI AUTOFILL-TESTS drafts: field-icon (T-IC01-08, real light-DOM button — render/aria/click/teardown/narrow-hide) and selector-ui (T-I01-16) — selector mounts a CLOSED shadow root so only mount/dismiss are externally observable; interior nav/filter/TOTP routed to e2e with documented rationale; ResizeObserver polyfill added to harness
- time
- 2026-06-12 12:55 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- autofill-tests draft suite
- parent
- 6f1bb7a67d67
↓ peach-browser-cbcc3e00.tar.gzatlas at this save
files changed3 files
src/drafts/AUTOFILL-TESTS/_setup.ts+11 −0src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+128 −0src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+107 −0src/drafts/AUTOFILL-TESTS/_setup.ts+11 −0
@@ -108,6 +108,17 @@108108 // modules under test, exactly as it is inside a real content script.109109 (globalThis as any).chrome = chromeMock;110110 111+// jsdom ships no ResizeObserver; inject.ts's observeAnchorPosition and the112+// selector's height-animation both construct one at call time. A no-op stub is113+// enough — the tests assert on injected DOM, not on layout reflow.114+if (typeof (globalThis as any).ResizeObserver === "undefined") {115+ (globalThis as any).ResizeObserver = class {116+ observe(): void {}117+ unobserve(): void {}118+ disconnect(): void {}119+ };120+}121+111122 // navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does112123 // not provide a writable clipboard, so define a spy-able one.113124 export const clipboardWrite = vi.fn(() => Promise.resolve());src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+128 −0
@@ -0,0 +1,128 @@1+// @vitest-environment jsdom2+//3+// Test File 6 — inject.ts injectFieldIcon (T-IC01–T-IC08).4+//5+// injectFieldIcon renders the small peach button into the LIGHT DOM (appended to6+// document.body, position:fixed), so unlike the selector it is fully observable7+// from a unit test. We import the REAL inject.ts and assert on the button it8+// produces: presence, accessibility, click wiring, teardown, and the9+// too-narrow-field hide rule. Where the brief assumed behavior that lives10+// elsewhere (idempotency, autocomplete policy), it is documented, not faked.11+12+import "./_setup"; // chrome mock + ResizeObserver polyfill (observeAnchorPosition needs it)13+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";14+import { injectFieldIcon } from "../../content/inject";15+import { makeInput, makeFieldVisible } from "./_setup";16+17+const ICON_SELECTOR = 'button[aria-label="Peach autofill"]';18+19+beforeEach(() => {20+ document.body.innerHTML = "";21+});22+afterEach(() => {23+ document.body.innerHTML = "";24+});25+26+describe("T-IC01, T-IC08: rendering and accessibility", () => {27+ // T-IC01: the icon button is injected into the document for a normal field.28+ it("T-IC01: injects a position:fixed button into the document", () => {29+ const field = makeInput({ type: "password" });30+ const cleanup = injectFieldIcon(field, () => {});31+ const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR);32+ expect(btn).not.toBeNull();33+ expect(btn!.style.position).toBe("fixed");34+ cleanup();35+ });36+37+ // T-IC08: the button carries an aria-label and title for screen readers.38+ it("T-IC08: button has an aria-label and title", () => {39+ const field = makeInput({ type: "password" });40+ const cleanup = injectFieldIcon(field, () => {});41+ const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;42+ expect(btn.getAttribute("aria-label")).toBe("Peach autofill");43+ expect(btn.title).toBeTruthy();44+ cleanup();45+ });46+});47+48+describe("T-IC02: click opens the selector", () => {49+ // T-IC02: clicking the icon invokes the onClick callback (which, in index.ts,50+ // is what summons the selector).51+ it("T-IC02: clicking the icon calls onClick exactly once", () => {52+ const field = makeInput({ type: "password" });53+ const onClick = vi.fn();54+ const cleanup = injectFieldIcon(field, onClick);55+ const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;56+ btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));57+ expect(onClick).toHaveBeenCalledTimes(1);58+ cleanup();59+ });60+});61+62+describe("T-IC04: teardown", () => {63+ // T-IC04: the returned cleanup removes the button — index.ts calls it when the64+ // field leaves the DOM, so a stale icon never lingers.65+ it("T-IC04: cleanup() removes the injected button", () => {66+ const field = makeInput({ type: "password" });67+ const cleanup = injectFieldIcon(field, () => {});68+ expect(document.querySelector(ICON_SELECTOR)).not.toBeNull();69+ cleanup();70+ expect(document.querySelector(ICON_SELECTOR)).toBeNull();71+ });72+});73+74+describe("T-IC06: size / narrow-field hide rule", () => {75+ // T-IC06: on a field too narrow to host the icon cleanly (<40px wide), the76+ // button is hidden (opacity 0, pointer-events none) so it never overlaps a77+ // tiny input or an adjacent control.78+ it("T-IC06: a <40px-wide field hides the icon", () => {79+ const field = makeInput({ type: "password" });80+ Object.defineProperty(field, "getBoundingClientRect", {81+ configurable: true,82+ value: () => ({ width: 30, height: 24, top: 10, left: 10, right: 40, bottom: 34, x: 10, y: 10, toJSON: () => ({}) }),83+ });84+ const cleanup = injectFieldIcon(field, () => {});85+ const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;86+ // Read synchronously, before the post-mount requestAnimationFrame fires.87+ expect(btn.style.opacity).toBe("0");88+ expect(btn.style.pointerEvents).toBe("none");89+ cleanup();90+ });91+92+ it("T-IC06b: a normally-wide field shows the icon", () => {93+ const field = makeInput({ type: "password" });94+ makeFieldVisible(field); // 160×32 → wide enough95+ const cleanup = injectFieldIcon(field, () => {});96+ const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;97+ expect(btn.style.opacity).toBe("1");98+ cleanup();99+ });100+});101+102+// ── Documented scope boundaries (T-IC03, T-IC05, T-IC07) ─────────────────────103+describe("T-IC03 / T-IC05 / T-IC07: documented boundaries", () => {104+ // T-IC03: injectFieldIcon is NOT idempotent on its own — each call appends a105+ // button. Idempotency is enforced by the caller (index.ts processedInputs),106+ // covered by content.initial-scan.test.ts T-S10. We pin the real behavior so107+ // the contract is explicit.108+ it("T-IC03: injectFieldIcon itself is not idempotent (dedup is the caller's job)", () => {109+ const field = makeInput({ type: "password" });110+ const c1 = injectFieldIcon(field, () => {});111+ const c2 = injectFieldIcon(field, () => {});112+ expect(document.querySelectorAll(ICON_SELECTOR).length).toBe(2);113+ c1();114+ c2();115+ });116+117+ // T-IC05 (reposition on scroll) is driven by observeAnchorPosition via118+ // ResizeObserver + requestAnimationFrame + scroll listeners. The reflow is not119+ // deterministically observable under jsdom (no real layout); it is verified in120+ // the Playwright e2e.121+ it.todo("T-IC05: icon repositions on scroll (e2e — needs real layout)");122+123+ // T-IC07: injectFieldIcon does not inspect autocomplete; the "don't offer on124+ // current-password where the browser already does" policy, if desired, belongs125+ // to the detect/index gating layer, not the renderer. No such gate exists126+ // today — flagged in INTEGRATION-CONTRACT.md rather than asserted falsely.127+ it.todo("T-IC07: suppress on autocomplete=current-password (no such policy in inject today)");128+});src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+107 −0
@@ -0,0 +1,107 @@1+// @vitest-environment jsdom2+//3+// Test File 5 — inject.ts showAutofillSelector (T-I01–T-I16).4+//5+// IMPORTANT ARCHITECTURE NOTE: showAutofillSelector mounts its UI inside a6+// CLOSED shadow root (host.attachShadow({ mode: "closed" })). Everything the7+// brief wants to assert about the selector's interior — the entry list, arrow/8+// Enter/Escape keyboard navigation, the search filter, the lazy GET_ENTRY load,9+// the TOTP chip, focus-trapping, above/below placement, isTrusted gating — lives10+// behind that closed boundary and is, by design, unreachable from any external11+// script (including this test and even a page-context Playwright script).12+//13+// So this file asserts the ONLY externally-observable contract: the selector14+// mounts a host into the document and the returned dismiss function tears it15+// down. The interior behaviors are marked it.todo and routed to the Playwright16+// e2e, which verifies them through their effects (fields getting filled, the17+// host disappearing) rather than by reading the closed tree. The recommendation18+// to add a test-only "open shadow" seam is recorded in INTEGRATION-CONTRACT.md.19+20+import "./_setup"; // chrome mock + ResizeObserver polyfill21+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";22+import { showAutofillSelector } from "../../content/inject";23+import { makeInput } from "./_setup";24+25+const ENTRIES = [26+ { id: "e1", title: "Acme", username: "[email protected]" },27+ { id: "e2", title: "Acme Admin", username: "[email protected]" },28+];29+30+// The host is a position:fixed div with the max z-index; closed shadow means we31+// can only see the host, never its contents.32+function selectorHost(): HTMLElement | null {33+ return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');34+}35+36+beforeEach(() => {37+ document.body.innerHTML = "";38+});39+afterEach(() => {40+ document.body.innerHTML = "";41+});42+43+describe("T-I01: mount + dismiss (the observable contract)", () => {44+ // T-I01: opening the selector mounts a host into the document and returns a45+ // dismiss function.46+ it("T-I01: showAutofillSelector mounts a host and returns a dismiss fn", () => {47+ const anchor = makeInput({ type: "password" });48+ const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());49+ expect(typeof dismiss).toBe("function");50+ expect(selectorHost()).not.toBeNull();51+ dismiss();52+ });53+54+ // Dismiss tears the host back down — the closed shadow goes with it. Teardown55+ // is animated: dismiss() schedules host.remove() ~130ms later, so we wait it56+ // out rather than asserting synchronously.57+ it("T-I05/T-I06 (teardown half): the returned dismiss removes the host", async () => {58+ const anchor = makeInput({ type: "password" });59+ const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());60+ expect(selectorHost()).not.toBeNull();61+ dismiss();62+ await new Promise((r) => setTimeout(r, 180));63+ expect(selectorHost()).toBeNull();64+ });65+66+ // The host mounts even in locked mode (the unlock prompt is also closed-shadow).67+ it("T-I01b: locked-mode open also mounts a host", () => {68+ const anchor = makeInput({ type: "password" });69+ const dismiss = showAutofillSelector(anchor, [], vi.fn(), { locked: true });70+ expect(selectorHost()).not.toBeNull();71+ dismiss();72+ });73+74+ // Re-opening dismisses any previous host the caller forgot — but the caller75+ // (index.ts) owns currentDismiss, so here we just confirm two opens leave two76+ // hosts unless dismissed, proving each open is independent.77+ it("T-I01c: each open creates its own host (caller owns single-instance policy)", () => {78+ const anchor = makeInput({ type: "password" });79+ const d1 = showAutofillSelector(anchor, ENTRIES, vi.fn());80+ const d2 = showAutofillSelector(anchor, ENTRIES, vi.fn());81+ expect(document.body.querySelectorAll('div[style*="2147483647"]').length).toBe(2);82+ d1();83+ d2();84+ });85+});86+87+// ── Closed-shadow internals → Playwright e2e (T-I02–T-I16) ───────────────────88+// Each of these requires reading or interacting with the closed shadow tree.89+// They are real test intents, documented here and implemented in90+// content.real-page.e2e.test.ts where behavior is verified through effects.91+describe("T-I02–T-I16: interior behavior (closed shadow → e2e)", () => {92+ it.todo("T-I02: ArrowDown moves selection to the next entry");93+ it.todo("T-I03: ArrowUp wraps from first to last entry");94+ it.todo("T-I04: Enter selects the focused entry, fills, and closes");95+ it.todo("T-I05: Escape closes the selector and fires the dismissal lock");96+ it.todo("T-I06: click outside closes the selector and fires the dismissal lock");97+ it.todo("T-I07: typing in the search box filters entries by title/domain");98+ it.todo("T-I08: search lazily fires GET_ENTRY_FOR_FILL only on a user gesture (MV-2)");99+ it.todo("T-I09: empty search results show the 'no matches' state");100+ it.todo("T-I10: selector repositions on window scroll (stays anchored)");101+ it.todo("T-I11: selector repositions on window resize");102+ it.todo("T-I12: selector renders above the field when space below is insufficient");103+ it.todo("T-I13: an entry with TOTP shows a live-countdown chip");104+ it.todo("T-I14: an entry without TOTP shows no chip");105+ it.todo("T-I15: keyboard nav is trapped — Tab cycles entries, does not escape to page");106+ it.todo("T-I16: isTrusted — synthetic keyboard events do not trigger fill");107+});