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
3 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 −0
src/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());

view file

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+});

view file

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+});

view file