2a7932644ca9
chore: version bump 3.9.9 → 4.0.0 (manifest.json + manifest.firefox.json + package.json) + build Chrome/Firefox/Mobile; trash now-integrated AUTOFILL-TESTS draft files (kept INTEGRATION-CONTRACT.md as reference). Final: tsc no new errors (27 pre-existing), vitest 117 files/3474 passed/0 skipped/40 todo (v4.0.0)
- time
- 2026-06-12 14:04 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- autofill tests integration
- parent
- ab5c9a638d4b
↓ peach-browser-2a793264.tar.gzatlas at this save
files changed19 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/drafts/AUTOFILL-TESTS/_setup.ts+0 −272src/drafts/AUTOFILL-TESTS/_smoke.test.ts+0 −25src/drafts/AUTOFILL-TESTS/content.detect.test.ts+0 −390src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+0 −268src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+0 −128src/drafts/AUTOFILL-TESTS/content.field-interaction.test.ts+0 −194src/drafts/AUTOFILL-TESTS/content.fill.test.ts+0 −198src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+0 −144src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+0 −115src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+0 −184src/drafts/AUTOFILL-TESTS/content.policy.test.ts+0 −126src/drafts/AUTOFILL-TESTS/content.real-page.e2e.spec.ts+0 −191src/drafts/AUTOFILL-TESTS/content.save.test.ts+0 −136src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+0 −107src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+0 −59src/drafts/AUTOFILL-TESTS/login-form.fixture.html+0 −32manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.9.9",5+ "version": "4.0.0",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.9.9",4+ "version": "4.0.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.9.9",3+ "version": "4.0.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/drafts/AUTOFILL-TESTS/_setup.ts+0 −272
@@ -1,272 +0,0 @@1-// Shared test harness for the AUTOFILL-TESTS draft suite.2-//3-// Two responsibilities:4-// 1. SIDE EFFECT — installing a chrome extension API mock onto globalThis the5-// moment this module is evaluated. index.ts and inject.ts touch6-// `chrome.runtime.*` at module-evaluation time (inject.ts line 41 fires a7-// GET_SETTINGS call; index.ts registers a runtime.onMessage listener and8-// runs an async init IIFE). ES module imports are evaluated in source9-// order, so a test file that does `import "./_setup"` BEFORE10-// `import "../../content/index"` guarantees chrome exists first.11-// 2. HELPERS — DOM factories and a jsdom visibility shim. jsdom reports a 0x012-// getBoundingClientRect for every element, which would make detect.ts's13-// isVisible() return false for everything. makeFieldVisible() patches the14-// rect the same way the existing content tests do.15-//16-// Nothing here mocks the functions under test — only the browser/extension17-// boundary. Tests drive the real exported functions with controlled DOM input.18-19-import { vi } from "vitest";20-21-// ── Chrome message plumbing ──────────────────────────────────────────────────22-// A test can install its own responder to control what the background "replies"23-// for a given message, and inspect every message that was sent.24-25-export type MessageResponder = (msg: any) => any;26-27-let messageResponder: MessageResponder = (msg) => {28- // Sane defaults so module-load-time calls (GET_SETTINGS, GET_VAULT_STATUS)29- // resolve to a harmless shape instead of undefined.30- switch (msg?.type) {31- case "GET_SETTINGS":32- return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };33- case "GET_VAULT_STATUS":34- return { ok: true, data: { unlocked: false } };35- default:36- return { ok: true, data: null };37- }38-};39-40-/** Messages sent via chrome.runtime.sendMessage, in order. */41-export const sentMessages: any[] = [];42-/** Listeners registered via chrome.runtime.onMessage.addListener. */43-export const messageListeners: Array<44- (msg: any, sender: any, sendResponse: (r?: any) => void) => unknown45-> = [];46-47-/** Override the reply for subsequent sendMessage calls. */48-export function setMessageResponder(fn: MessageResponder): void {49- messageResponder = fn;50-}51-52-/** Deliver a message to every registered onMessage listener (e.g. VAULT_UNLOCKED). */53-export function dispatchRuntimeMessage(54- msg: any,55- sender: any = { id: "peach-test-extension-id" }56-): Array<unknown> {57- const responses: unknown[] = [];58- for (const listener of messageListeners) {59- responses.push(listener(msg, sender, (r?: any) => responses.push(r)));60- }61- return responses;62-}63-64-const sendMessage = vi.fn((msg: any, maybeCb?: (resp: any) => void) => {65- sentMessages.push(msg);66- const result = messageResponder(msg);67- const resolved = Promise.resolve(result);68- if (typeof maybeCb === "function") {69- resolved.then((r) => maybeCb(r));70- return undefined as unknown as Promise<any>;71- }72- return resolved;73-});74-75-const sessionStore = new Map<string, unknown>();76-77-const chromeMock = {78- runtime: {79- id: "peach-test-extension-id",80- sendMessage,81- getURL: (path: string) => `chrome-extension://peach-test/${path}`,82- onMessage: {83- addListener: (fn: any) => messageListeners.push(fn),84- removeListener: (fn: any) => {85- const i = messageListeners.indexOf(fn);86- if (i >= 0) messageListeners.splice(i, 1);87- },88- },89- lastError: undefined as undefined | { message: string },90- },91- storage: {92- session: {93- get: (key: string) =>94- Promise.resolve(sessionStore.has(key) ? { [key]: sessionStore.get(key) } : {}),95- set: (obj: Record<string, unknown>) => {96- for (const [k, v] of Object.entries(obj)) sessionStore.set(k, v);97- return Promise.resolve();98- },99- },100- local: {101- get: () => Promise.resolve({}),102- set: () => Promise.resolve(),103- },104- },105-};106-107-// Install on globalThis so `chrome` is a resolvable free identifier in the108-// modules under test, exactly as it is inside a real content script.109-(globalThis as any).chrome = chromeMock;110-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-122-// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does123-// not provide a writable clipboard, so define a spy-able one.124-export const clipboardWrite = vi.fn(() => Promise.resolve());125-if (!("clipboard" in navigator)) {126- Object.defineProperty(navigator, "clipboard", {127- configurable: true,128- value: { writeText: clipboardWrite },129- });130-} else {131- Object.defineProperty(navigator.clipboard, "writeText", {132- configurable: true,133- value: clipboardWrite,134- });135-}136-137-/** Reset all captured state between tests. Does NOT clear registered listeners138- * (those are installed once at module import and must survive across tests). */139-export function resetChromeMock(): void {140- sentMessages.length = 0;141- sessionStore.clear();142- sendMessage.mockClear();143- clipboardWrite.mockClear();144- messageResponder = (msg) => {145- switch (msg?.type) {146- case "GET_SETTINGS":147- return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };148- case "GET_VAULT_STATUS":149- return { ok: true, data: { unlocked: false } };150- default:151- return { ok: true, data: null };152- }153- };154-}155-156-// ── DOM helpers ──────────────────────────────────────────────────────────────157-158-const VISIBLE_RECT = {159- width: 160,160- height: 32,161- top: 10,162- left: 10,163- right: 170,164- bottom: 42,165- x: 10,166- y: 10,167- toJSON: () => ({}),168-} as const;169-170-/** Patch an element so detect.ts's isVisible() treats it as on-screen.171- * jsdom returns a 0x0 rect for everything; without this every field reads172- * as invisible and detection returns nothing. */173-export function makeFieldVisible(el: HTMLElement): void {174- Object.defineProperty(el, "getBoundingClientRect", {175- configurable: true,176- value: () => ({ ...VISIBLE_RECT }),177- });178-}179-180-/** Create a visible <input> with the given attributes appended to the body. */181-export function makeInput(182- attrs: Record<string, string> = {},183- parent: HTMLElement = document.body184-): HTMLInputElement {185- const input = document.createElement("input");186- for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);187- parent.appendChild(input);188- makeFieldVisible(input);189- return input;190-}191-192-/** Standard username + current-password login form. */193-export function createLoginForm(): {194- form: HTMLFormElement;195- username: HTMLInputElement;196- password: HTMLInputElement;197-} {198- const form = document.createElement("form");199- document.body.appendChild(form);200- const username = makeInput(201- { type: "text", name: "username", autocomplete: "username" },202- form203- );204- const password = makeInput(205- { type: "password", name: "password", autocomplete: "current-password" },206- form207- );208- const button = document.createElement("button");209- button.type = "submit";210- button.textContent = "Log in";211- form.appendChild(button);212- return { form, username, password };213-}214-215-/** Email-first (identifier) form: single email field, no password. */216-export function createEmailFirstForm(): {217- form: HTMLFormElement;218- email: HTMLInputElement;219-} {220- const form = document.createElement("form");221- document.body.appendChild(form);222- const email = makeInput(223- { type: "email", name: "email", autocomplete: "email" },224- form225- );226- const button = document.createElement("button");227- button.type = "submit";228- button.textContent = "Next";229- form.appendChild(button);230- return { form, email };231-}232-233-/** Signup form: username + new-password + confirm. */234-export function createSignupForm(): {235- form: HTMLFormElement;236- username: HTMLInputElement;237- password: HTMLInputElement;238- confirm: HTMLInputElement;239-} {240- const form = document.createElement("form");241- document.body.appendChild(form);242- const username = makeInput({ type: "text", name: "new-username" }, form);243- const password = makeInput(244- { type: "password", name: "new-password", autocomplete: "new-password" },245- form246- );247- const confirm = makeInput(248- { type: "password", name: "confirm-password", autocomplete: "new-password" },249- form250- );251- const button = document.createElement("button");252- button.type = "submit";253- button.textContent = "Create account";254- form.appendChild(button);255- return { form, username, password, confirm };256-}257-258-/** Split-OTP form: `count` adjacent single-char numeric inputs. */259-export function createSplitOtpForm(count = 6): {260- form: HTMLFormElement;261- boxes: HTMLInputElement[];262-} {263- const form = document.createElement("form");264- document.body.appendChild(form);265- const boxes: HTMLInputElement[] = [];266- for (let i = 0; i < count; i++) {267- boxes.push(268- makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, form)269- );270- }271- return { form, boxes };272-}src/drafts/AUTOFILL-TESTS/_smoke.test.ts+0 −25
@@ -1,25 +0,0 @@1-// @vitest-environment jsdom2-//3-// Smoke test: proves the heavy content-script modules import cleanly under the4-// shared chrome mock. If this fails, every index.ts/inject.ts-dependent test5-// below it is unrunnable — so it gates the whole suite.6-7-import "./_setup";8-import { beforeAll, describe, expect, it } from "vitest";9-10-describe("AUTOFILL-TESTS harness smoke", () => {11- it("imports index.ts seams without throwing", async () => {12- const mod = await import("../../content/index");13- expect(typeof mod.handleFieldInteraction).toBe("function");14- expect(typeof mod.initialScan).toBe("function");15- expect(typeof mod.wasAlreadyFilled).toBe("function");16- expect(mod._testSeam).toBeTruthy();17- expect(typeof mod._testSeam.DISMISS_LOCK_MS).toBe("number");18- });19-20- it("imports inject.ts UI exports without throwing", async () => {21- const mod = await import("../../content/inject");22- expect(typeof mod.showAutofillSelector).toBe("function");23- expect(typeof mod.injectFieldIcon).toBe("function");24- });25-});src/drafts/AUTOFILL-TESTS/content.detect.test.ts+0 −390
@@ -1,390 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 7 — detect.ts field detection (T-DT01–T-DT21).4-// Plus Addendum 1 — split-OTP grouping.5-//6-// detect.ts is the foundation of the whole autofill layer: every gate in7-// index.ts asks it "is this a login/signup/email-first/card/totp form?". A8-// false positive injects an icon onto a search box; a false negative means the9-// user's saved login never offers to fill. These tests pin the classification10-// boundaries against real HTML structures that production sites emit.11-12-import { beforeEach, describe, expect, it } from "vitest";13-14-import {15- detectCardForm,16- detectLoginFields,17- findOTPDigitBoxGroup,18- isAnyCredentialForm,19- isEmailFirstForm,20- isLoginForm,21- isSignupForm,22- isTOTPField,23- isVisible,24-} from "../../content/detect";25-import { makeFieldVisible, makeInput } from "./_setup";26-27-// detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field28-// nested under html>body>form appears 3× in detectLoginFields() output. The29-// committed detect.test.ts copes the same way we do here: assert detection by30-// *membership* (fields.some) and gate "is this page fillable?" through31-// isAnyCredentialForm (the exact predicate index.ts uses — true via the signup32-// branch even when duplication inflates the password count). The precise33-// login-vs-signup classifier contract is exercised on clean, hand-built arrays.34-35-beforeEach(() => {36- document.body.innerHTML = "";37-});38-39-// Build a form, append it, and return it. Children are added via makeInput so40-// every field gets a non-zero bounding rect (jsdom defaults to 0×0, which41-// detect.ts's isVisible() would reject).42-function form(): HTMLFormElement {43- const f = document.createElement("form");44- document.body.appendChild(f);45- return f;46-}47-48-describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {49- // T-DT01: a text + current-password pair is the canonical login form. If this50- // regresses, saved logins stop offering on the most common form on the web.51- it("T-DT01: text + password → detected as a fillable login form", () => {52- const f = form();53- const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);54- const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);55- const fields = detectLoginFields();56- expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);57- expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);58- // The gate index.ts actually consults:59- expect(isAnyCredentialForm(fields)).toBe(true);60- // Classifier contract on a clean field set:61- expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);62- });63-64- // T-DT02: two password fields (password + confirm) is the signature of signup.65- // Misclassifying it as login would offer the saved-password selector on an66- // account-creation form.67- it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {68- const f = form();69- makeInput({ type: "text", name: "username" }, f);70- makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);71- makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);72- const fields = detectLoginFields();73- expect(isSignupForm(fields)).toBe(true);74- expect(isLoginForm(fields)).toBe(false);75- });76-77- // T-DT03: email-only step with an auth-intent submit button is the Google/MS78- // "enter your email first" pattern. Requires an auth signal so newsletter79- // boxes don't match.80- it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {81- const f = form();82- makeInput({ type: "email", name: "email", autocomplete: "email" }, f);83- const btn = document.createElement("button");84- btn.type = "submit";85- btn.textContent = "Next";86- f.appendChild(btn);87- const fields = detectLoginFields();88- expect(isEmailFirstForm(fields)).toBe(true);89- });90-91- // T-DT06: a search box (single text input, search submit) must NOT read as a92- // login form. False positive here is the classic "icon on the search bar" bug.93- it("T-DT06: search form → isLoginForm false", () => {94- const f = form();95- makeInput({ type: "search", name: "q", placeholder: "Search" }, f);96- const fields = detectLoginFields();97- expect(isLoginForm(fields)).toBe(false);98- expect(isSignupForm(fields)).toBe(false);99- });100-});101-102-describe("T-DT04: TOTP field detection", () => {103- // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP104- // selector path keys off this; losing it breaks 2FA autofill.105- it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {106- const f = form();107- const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);108- expect(isTOTPField(otp)).toBe(true);109- });110-111- it("T-DT04b: a plain password field is not a TOTP field", () => {112- const f = form();113- const pw = makeInput({ type: "password", name: "password" }, f);114- expect(isTOTPField(pw)).toBe(false);115- });116-});117-118-describe("T-DT05: credit-card form detection", () => {119- // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect120- // exposes detectCardForm (returns the field set or null), not a boolean.121- it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {122- const f = form();123- const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);124- const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);125- const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);126- const card = detectCardForm();127- expect(card).not.toBeNull();128- expect(card!.number).toBe(number);129- expect(card!.expiryCombo).toBe(exp);130- expect(card!.cvc).toBe(cvc);131- });132-133- it("T-DT05b: a login form is not a card form", () => {134- const f = form();135- makeInput({ type: "text", name: "username" }, f);136- makeInput({ type: "password", name: "password" }, f);137- expect(detectCardForm()).toBeNull();138- });139-});140-141-describe("T-DT07–T-DT09: isVisible branches", () => {142- // isVisible gates every detection path. Each of its three rejection reasons143- // (zero area, display:none, opacity:0) must independently return false, or144- // off-screen/hidden honeypot fields would receive icons.145-146- // T-DT07: a zero-area element (jsdom default, models an unrendered field).147- it("T-DT07: zero-size rect → isVisible false", () => {148- const el = makeInput({ type: "text" });149- Object.defineProperty(el, "getBoundingClientRect", {150- configurable: true,151- value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),152- });153- expect(isVisible(el)).toBe(false);154- });155-156- // T-DT08: display:none.157- it("T-DT08: display:none → isVisible false", () => {158- const el = makeInput({ type: "text" });159- el.style.display = "none";160- expect(isVisible(el)).toBe(false);161- });162-163- // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique).164- it("T-DT09: opacity:0 → isVisible false", () => {165- const el = makeInput({ type: "text" });166- el.style.opacity = "0";167- expect(isVisible(el)).toBe(false);168- });169-170- it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {171- const el = makeInput({ type: "text" });172- expect(isVisible(el)).toBe(true);173- });174-});175-176-describe("T-DT10: deep (shadow DOM) detection", () => {177- // T-DT10: web-component login forms render fields inside a shadow root.178- // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression179- // to a flat querySelectorAll would miss every shadow-DOM login.180- it("T-DT10: login fields inside an open shadow root are detected", () => {181- const host = document.createElement("div");182- document.body.appendChild(host);183- const root = host.attachShadow({ mode: "open" });184- // Real web-component login forms wrap fields in a container element inside185- // the shadow root; querySelectorAllDeep collects inputs via each element's186- // querySelectorAll, so the wrapper is what surfaces them.187- const wrapper = document.createElement("div");188- root.appendChild(wrapper);189- const user = document.createElement("input");190- user.type = "text";191- user.setAttribute("name", "username");192- user.setAttribute("autocomplete", "username");193- const pass = document.createElement("input");194- pass.type = "password";195- pass.setAttribute("autocomplete", "current-password");196- wrapper.append(user, pass);197- makeFieldVisible(user);198- makeFieldVisible(pass);199-200- const fields = detectLoginFields();201- expect(fields.some((f) => f.type === "password")).toBe(true);202- expect(isAnyCredentialForm(fields)).toBe(true);203- });204-});205-206-describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {207- // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them208- // with CSS.escape AND wraps the label lookup in try/catch so a single weird id209- // can never abort detection for the whole page.210- //211- // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on212- // CSS.escape's output for these ids, so the *label-resolution* benefit cannot213- // be observed here — it is verified in the Playwright e2e. What we pin in214- // jsdom is the resilience invariant: a field whose id is hostile to215- // querySelector must not throw out of detectLoginFields, and the other fields216- // on the page must still be found. Reverting the try/catch makes T-DT12b throw.217- it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {218- const f = form();219- makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);220- makeInput({ type: "password", autocomplete: "current-password" }, f);221- let fields: ReturnType<typeof detectLoginFields> = [];222- expect(() => { fields = detectLoginFields(); }).not.toThrow();223- expect(fields.some((x) => x.type === "password")).toBe(true);224- });225-226- it("T-DT12: id=\"it's-me\" does not abort detection", () => {227- const f = form();228- makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);229- makeInput({ type: "password", autocomplete: "current-password" }, f);230- expect(() => detectLoginFields()).not.toThrow();231- });232-233- // T-DT12b: an id containing a double-quote genuinely throws inside a raw234- // `label[for="..."]` selector. Only the try/catch keeps detection alive — this235- // case fails the moment the catch is removed.236- it("T-DT12b: id with an embedded double-quote does not abort detection", () => {237- const f = form();238- makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);239- makeInput({ type: "password", autocomplete: "current-password" }, f);240- let fields: ReturnType<typeof detectLoginFields> = [];241- expect(() => { fields = detectLoginFields(); }).not.toThrow();242- expect(fields.some((x) => x.type === "password")).toBe(true);243- });244-});245-246-describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {247- // T-DT13: a <label for=id> with a plain (escape-safe) id classifies an248- // otherwise-unhinted text input as the username field.249- it("T-DT13: label[for] resolves the field as username", () => {250- const f = form();251- const input = makeInput({ type: "text", id: "login_user" }, f);252- const label = document.createElement("label");253- label.setAttribute("for", "login_user");254- label.textContent = "Username";255- // Append AFTER the input so the previous-sibling heuristic cannot match —256- // forcing the for-attribute path to be the one that resolves the label.257- f.appendChild(label);258- makeInput({ type: "password", autocomplete: "current-password" }, f);259- const fields = detectLoginFields();260- expect(fields.find((x) => x.el === input)?.type).toBe("username");261- });262-263- // T-DT14: placeholder text is a real-world classification signal.264- it("T-DT14: placeholder='Username' → classified username", () => {265- const f = form();266- const input = makeInput({ type: "text", placeholder: "Username" }, f);267- makeInput({ type: "password", autocomplete: "current-password" }, f);268- const fields = detectLoginFields();269- expect(fields.find((x) => x.el === input)?.type).toBe("username");270- });271-272- // T-DT15: aria-label is authoritative for accessibility-first forms.273- it("T-DT15: aria-label='Email address' → classified", () => {274- const f = form();275- const input = makeInput({ type: "text", "aria-label": "Email address" }, f);276- makeInput({ type: "password", autocomplete: "current-password" }, f);277- const fields = detectLoginFields();278- expect(fields.find((x) => x.el === input)?.type).toBe("username");279- });280-});281-282-describe("T-DT16–T-DT17: edge form shapes", () => {283- // T-DT16: many SPA login forms submit via JS and have no submit button. Field284- // classification must not depend on a button existing.285- it("T-DT16: form without a submit button is still a fillable login form", () => {286- const f = form();287- const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);288- const p = makeInput({ type: "password", autocomplete: "current-password" }, f);289- const fields = detectLoginFields();290- expect(fields.some((x) => x.el === u)).toBe(true);291- expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);292- expect(isAnyCredentialForm(fields)).toBe(true);293- expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);294- });295-296- // T-DT17: change-password forms have old + new + confirm. Three password297- // fields must read as signup-shaped (>=2 passwords), never as login.298- it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {299- const f = form();300- makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);301- makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);302- makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);303- const fields = detectLoginFields();304- expect(isSignupForm(fields)).toBe(true);305- expect(isLoginForm(fields)).toBe(false);306- });307-});308-309-// ── Addendum 1 — split-OTP grouping ─────────────────────────────────────────310-// The existing detector is findOTPDigitBoxGroup: it returns the ordered set of311-// single-character boxes (4–10 of them) or null. index.ts uses it to open ONE312-// TOTP selector anchored on the first box instead of icon-spamming all six.313-describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {314- // T-DT18: a 6-box numeric group resolves to the full ordered set, anchored so315- // index.ts injects a single icon on box[0] rather than one per box.316- it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {317- const f = form();318- const boxes: HTMLInputElement[] = [];319- for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));320- const group = findOTPDigitBoxGroup(boxes[2]!);321- expect(group).not.toBeNull();322- expect(group!.length).toBe(6);323- expect(group![0]).toBe(boxes[0]);324- });325-326- // T-DT19: when a real one-time-code field exists, IT is the TOTP target; the327- // single-char boxes elsewhere are a distinct concern. isTOTPField ignores328- // maxlength=1 boxes, so the proper field is the one that classifies as TOTP.329- it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {330- const f = form();331- const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);332- const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);333- expect(isTOTPField(real)).toBe(true);334- expect(isTOTPField(box)).toBe(false);335- });336-337- // T-DT21: a group below the 4-box floor is not an OTP group (existing338- // threshold). Addendum 1 proposes a 3+ threshold for isSplitOtpGroup — see the339- // skipped block below.340- it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {341- const f = form();342- const boxes = [343- makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),344- makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345- ];346- expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();347- });348-});349-350-// Addendum 1 PROPOSED API — isSplitOtpGroup(field) with a 3+ threshold and a351-// numeric-only guard so an initials form (3 single-char *alpha* inputs) is not352-// treated as OTP. This function does not exist yet; these tests activate the353-// moment it is exported from detect.ts. They are the executable spec for the354-// addendum. See INTEGRATION-CONTRACT.md.355-import * as detectModule from "../../content/detect";356-const hasIsSplitOtpGroup =357- typeof (detectModule as unknown as { isSplitOtpGroup?: unknown }).isSplitOtpGroup === "function";358-359-describe.skipIf(!hasIsSplitOtpGroup)("Addendum 1 — proposed isSplitOtpGroup (requires detect.ts change)", () => {360- const isSplitOtpGroup = (detectModule as unknown as {361- isSplitOtpGroup: (f: HTMLInputElement) => boolean;362- }).isSplitOtpGroup;363-364- it("T-DT18 (proposed): 6 adjacent numeric single-char inputs → true", () => {365- const f = form();366- let first: HTMLInputElement | null = null;367- for (let i = 0; i < 6; i++) {368- const b = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);369- first ??= b;370- }371- expect(isSplitOtpGroup(first!)).toBe(true);372- });373-374- it("T-DT20 (proposed): 3 single-char ALPHA inputs (initials) → false", () => {375- const f = form();376- let first: HTMLInputElement | null = null;377- for (let i = 0; i < 3; i++) {378- const b = makeInput({ type: "text", maxlength: "1" }, f); // no numeric mode379- first ??= b;380- }381- expect(isSplitOtpGroup(first!)).toBe(false);382- });383-384- it("T-DT21 (proposed): a 2-box group is below the 3+ threshold → false", () => {385- const f = form();386- const a = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);387- makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);388- expect(isSplitOtpGroup(a)).toBe(false);389- });390-});src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+0 −268
@@ -1,268 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 1 — index.ts 3-layer dismissal model (T-D01–T-D15).4-//5-// This is the most regression-prone logic in the project (v1.4.14 broke it6-// repeatedly). Three independent layers each prevent the selector reopening:7-// Layer 1 — lastDismissedField: per-field lock until focus genuinely leaves.8-// Layer 2 — dismissLock (DISMISS_LOCK_MS=800): global post-dismiss cooldown.9-// Layer 3 — wasAlreadyFilled: filledForms (form-level, permanent) + pageFilledAt10-// (page-level, PAGE_FILL_GRACE_MS=3000 window).11-//12-// All three short-circuit handleFieldInteraction BEFORE the ML race, so we drive13-// them through the exported seams: _testSeam (read/write lastDismissedField,14-// read dismissLock), the captured onDismiss callback (to fire a real dismiss →15-// startDismissLock), and the captured onFillFields callback (to fire a real fill16-// → recordFillSuccess). Fake timers are used ONLY for the expiry windows, per17-// the brief's quality bar.18-19-import { vi } from "vitest";20-21-const { predictBatch, dismissSpy } = vi.hoisted(() => ({22- predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),23- dismissSpy: vi.fn(),24-}));25-26-vi.mock("../../content/inject", () => ({27- injectFieldIcon: vi.fn(() => vi.fn()),28- showAutofillSelector: vi.fn(() => dismissSpy),29- showTOTPSelector: vi.fn(() => dismissSpy),30- showCardSelector: vi.fn(() => dismissSpy),31- showRememberSiteToast: vi.fn(),32- showPasskeyAvailable: vi.fn(),33- showPasskeyProGate: vi.fn(),34- showPasskeyRegistrationOffer: vi.fn(),35-}));36-vi.mock("../../content/content-ml-detector", () => ({37- contentMLDetector: { predictBatch },38-}));39-40-import { afterEach, beforeEach, describe, expect, it } from "vitest";41-import "./_setup";42-import { resetChromeMock, setMessageResponder, makeInput, createLoginForm } from "./_setup";43-import { handleFieldInteraction, wasAlreadyFilled, _testSeam } from "../../content/index";44-import { showAutofillSelector } from "../../content/inject";45-46-const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;47-const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];48-49-function lastOptions(): any {50- const calls = showSelector.mock.calls;51- return calls.length ? calls[calls.length - 1]![3] : undefined;52-}53-54-function withEntries(): void {55- setMessageResponder((msg) =>56- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }57- );58-}59-60-let urlCounter = 0;61-62-beforeEach(async () => {63- // index.ts has no public reset for pageFilledAt, but its url-watcher observer64- // nulls it whenever location.href changes. Push a fresh URL and mutate the65- // body so that observer fires — giving every test a clean fill-grace slate.66- try {67- history.pushState({}, "", `/t${++urlCounter}`);68- } catch { /* jsdom history unavailable — fall through */ }69- document.body.innerHTML = "";70- await new Promise((r) => setTimeout(r, 0)); // let the url-watcher observer run71-72- resetChromeMock();73- showSelector.mockClear();74- dismissSpy.mockClear();75- predictBatch.mockReset();76- predictBatch.mockReturnValue(Promise.resolve([]));77- _testSeam.setLastDismissedField(null);78-});79-80-afterEach(async () => {81- vi.useRealTimers();82- await new Promise((r) => setTimeout(r, 0)); // flush index.ts's url-watcher observer83-});84-85-// ── Layer 1 — per-field lock (lastDismissedField) ────────────────────────────86-describe("T-D01–T-D04: Layer 1 per-field lock", () => {87- // T-D01: a field whose selector was dismissed must not reopen on re-focus.88- it("T-D01: re-focusing the dismissed field does NOT reopen the selector", async () => {89- const { password } = createLoginForm();90- withEntries();91- _testSeam.setLastDismissedField(password); // simulate "user pressed Escape on this field"92- await handleFieldInteraction(password);93- await new Promise((r) => setTimeout(r, 20));94- expect(showSelector).not.toHaveBeenCalled();95- });96-97- // T-D02 / T-D04: the lock is field-specific — focusing a DIFFERENT field opens,98- // and doing so clears the stale per-field lock.99- it("T-D02/T-D04: a different field opens (lock is field-specific)", async () => {100- const { username, password } = createLoginForm();101- withEntries();102- _testSeam.setLastDismissedField(username);103- await handleFieldInteraction(password);104- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());105- expect(_testSeam.getLastDismissedField()).toBeNull(); // stale lock cleared106- });107-108- // T-D03: once the per-field lock is cleared (focus moved away and back), the109- // same field opens again.110- it("T-D03: clearing the lock lets the same field open again", async () => {111- const { password } = createLoginForm();112- withEntries();113- _testSeam.setLastDismissedField(null);114- await handleFieldInteraction(password);115- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());116- });117-});118-119-// ── Layer 2 — global cooldown (DISMISS_LOCK_MS) ──────────────────────────────120-describe("T-D05–T-D07: Layer 2 post-dismiss global cooldown", () => {121- // T-D05: immediately after a dismiss, focusing ANY other field is blocked by122- // the global lock — not just the dismissed field.123- it("T-D05: dismiss → focus a different field → blocked by global cooldown", async () => {124- const { username, password } = createLoginForm();125- withEntries();126- await handleFieldInteraction(password);127- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());128-129- const onDismiss = lastOptions().onDismiss as () => void;130- vi.useFakeTimers();131- onDismiss(); // real dismiss → startDismissLock()132- expect(_testSeam.isDismissLocked()).toBe(true);133-134- showSelector.mockClear();135- await handleFieldInteraction(username); // different field, but cooldown active136- expect(showSelector).not.toHaveBeenCalled();137-138- // T-D06: after DISMISS_LOCK_MS the global lock clears.139- vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);140- expect(_testSeam.isDismissLocked()).toBe(false);141- });142-143- // T-D06: with the cooldown expired, a fresh focus opens normally again.144- it("T-D06: after the cooldown expires, the selector opens again", async () => {145- const { password } = createLoginForm();146- withEntries();147- await handleFieldInteraction(password);148- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());149- const onDismiss = lastOptions().onDismiss as () => void;150-151- vi.useFakeTimers();152- onDismiss();153- vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);154- expect(_testSeam.isDismissLocked()).toBe(false);155- vi.useRealTimers();156-157- // New field after expiry → opens. (lastDismissedField was set to the old158- // password element; a different element is unaffected.)159- _testSeam.setLastDismissedField(null);160- const fresh = makeInput({ type: "password", autocomplete: "current-password" });161- showSelector.mockClear();162- await handleFieldInteraction(fresh);163- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());164- });165-166- // T-D07: multiple rapid focuses during the cooldown all suppress — only the167- // original (pre-dismiss) open ever happened.168- it("T-D07: rapid focus events during cooldown are all suppressed", async () => {169- const { username, password } = createLoginForm();170- withEntries();171- await handleFieldInteraction(password);172- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());173- const onDismiss = lastOptions().onDismiss as () => void;174-175- vi.useFakeTimers();176- onDismiss();177- showSelector.mockClear();178- await handleFieldInteraction(username);179- await handleFieldInteraction(password);180- await handleFieldInteraction(username);181- expect(showSelector).not.toHaveBeenCalled();182- vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);183- });184-});185-186-// ── Layer 3 — fill-success grace (wasAlreadyFilled) ──────────────────────────187-describe("T-D08–T-D12: Layer 3 fill-success grace", () => {188- // Drive a real fill by capturing the onFillFields callback the selector was189- // opened with and invoking it — that calls recordFillSuccess internally.190- async function openAndFill() {191- const { form, username, password } = createLoginForm();192- withEntries();193- await handleFieldInteraction(password);194- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());195- const onFill = lastOptions().onFillFields as (u: string, p: string, e?: string) => void;196- onFill("alice", "s3cret");197- return { form, username, password };198- }199-200- // T-D08 / T-D11: immediately after a fill, both the form-level and page-level201- // checks report "already filled".202- it("T-D08/T-D11: after fill, wasAlreadyFilled is true (form-level and page-level)", async () => {203- const { password } = await openAndFill();204- expect(wasAlreadyFilled(password)).toBe(true); // form-level (filledForms)205- const formless = makeInput({ type: "password" }); // not in a <form>206- expect(wasAlreadyFilled(formless)).toBe(true); // page-level grace window207- });208-209- // T-D09 / T-D12: once the page grace window expires, a formless field is no210- // longer suppressed (a field still inside the filled form stays locked — that211- // is the permanent form-level guard, documented below).212- it("T-D09/T-D12: after the grace window, a formless field is no longer suppressed", async () => {213- await openAndFill();214- const formless = makeInput({ type: "password" });215- expect(wasAlreadyFilled(formless)).toBe(true);216- vi.useFakeTimers();217- vi.advanceTimersByTime(5000); // well past PAGE_FILL_GRACE_MS218- expect(wasAlreadyFilled(formless)).toBe(false);219- vi.useRealTimers();220- });221-222- // T-D10: the brief expected the grace to be form-scoped (form B opens during223- // form A's grace). The implementation's page-level grace is NOT form-scoped:224- // for PAGE_FILL_GRACE_MS, EVERY field is suppressed. After the window, the225- // filled form A's fields stay locked (filledForms is permanent) while a field226- // in an unrelated form B opens. We pin the ACTUAL behavior. See227- // INTEGRATION-CONTRACT.md.228- it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => {229- const { password: aPassword } = await openAndFill();230- // Build a second, independent form B.231- const formB = document.createElement("form");232- document.body.appendChild(formB);233- const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB);234-235- // During grace: B is suppressed too (page-level grace is global).236- expect(wasAlreadyFilled(bPassword)).toBe(true);237-238- vi.useFakeTimers();239- vi.advanceTimersByTime(5000);240- // After grace: A's form is permanently locked, B is free.241- expect(wasAlreadyFilled(aPassword)).toBe(true); // filledForms (permanent)242- expect(wasAlreadyFilled(bPassword)).toBe(false);243- vi.useRealTimers();244- });245-});246-247-// ── Edge cases (T-D13–T-D15) ─────────────────────────────────────────────────248-describe("T-D13–T-D15: edge cases and documented boundaries", () => {249- // T-D13: a second concurrent interaction on the same field is debounced by250- // pendingInteractions, so a rapid focus/click pair cannot double-open (the251- // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.)252- it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => {253- const { password } = createLoginForm();254- withEntries();255- await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);256- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());257- expect(showSelector).toHaveBeenCalledTimes(1);258- });259-260- // T-D14 (scroll-into-view) and T-D15 (isTrusted/programmatic focus): these are261- // not decisions made inside handleFieldInteraction — the function is gesture-262- // agnostic and is invoked by the focusin listener regardless of isTrusted.263- // They belong to the Playwright e2e layer (real user gestures vs synthetic).264- // Documented in INTEGRATION-CONTRACT.md rather than asserted with a false265- // unit-level proxy that would always pass.266- it.todo("T-D14: focus after scroll-into-view opens normally (e2e — real gesture)");267- it.todo("T-D15: programmatic focus (isTrusted=false) suppressed (e2e — real gesture)");268-});src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+0 −128
@@ -1,128 +0,0 @@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.field-interaction.test.ts+0 −194
@@ -1,194 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 2 — index.ts handleFieldInteraction core paths (T-F01–T-F12).4-//5-// handleFieldInteraction is the central decision: ML predict → suppression6-// layers → TOTP/OTP branch → vault query → which surface opens. We mock the7-// boundary it talks to — ./inject (so we can see WHICH surface opened) and8-// ./content-ml-detector (so we can control predict timing) — and drive the REAL9-// exported function with controlled DOM + chrome responses. Nothing about the10-// decision logic itself is mocked.11-12-import { vi } from "vitest";13-14-// Spies shared with the hoisted vi.mock factories must themselves be hoisted.15-const { predictBatch, dismissSpy } = vi.hoisted(() => ({16- predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),17- dismissSpy: vi.fn(),18-}));19-20-// Observe which inject surface opens. showAutofillSelector returns a dismiss fn.21-vi.mock("../../content/inject", () => ({22- injectFieldIcon: vi.fn(() => vi.fn()),23- showAutofillSelector: vi.fn(() => dismissSpy),24- showTOTPSelector: vi.fn(() => dismissSpy),25- showCardSelector: vi.fn(() => dismissSpy),26- showRememberSiteToast: vi.fn(),27- showPasskeyAvailable: vi.fn(),28- showPasskeyProGate: vi.fn(),29- showPasskeyRegistrationOffer: vi.fn(),30-}));31-32-// Control ML predict timing/result per test.33-vi.mock("../../content/content-ml-detector", () => ({34- contentMLDetector: { predictBatch },35-}));36-37-import { afterEach, beforeEach, describe, expect, it } from "vitest";38-import "./_setup";39-import { setMessageResponder, resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";40-import { handleFieldInteraction } from "../../content/index";41-import { showAutofillSelector, showTOTPSelector } from "../../content/inject";42-43-const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44-const showTOTP = showTOTPSelector as unknown as ReturnType<typeof vi.fn>;45-46-// Reset module-private suppression state between tests by exercising the public47-// surface: a fresh DOM + cleared mocks. handleFieldInteraction reads live DOM48-// each call, so a clean body is a clean slate for everything except the global49-// dismiss lock — which we never trip in this file (no dismissals here).50-beforeEach(() => {51- document.body.innerHTML = "";52- resetChromeMock();53- showSelector.mockClear();54- showTOTP.mockClear();55- predictBatch.mockReset();56- predictBatch.mockReturnValue(Promise.resolve([]));57-});58-59-afterEach(async () => {60- vi.useRealTimers();61- // index.ts installs a url-watcher MutationObserver on document.body that62- // reads location.href. Clearing the DOM each test queues its callback; flush63- // it here so it runs while jsdom's globals still exist, instead of throwing64- // "location is not defined" during environment teardown.65- await new Promise((r) => setTimeout(r, 0));66-});67-68-// Last options object passed to showAutofillSelector (4th arg).69-function lastSelectorOptions(): any {70- const calls = showSelector.mock.calls;71- return calls.length ? calls[calls.length - 1]![3] : undefined;72-}73-74-describe("T-F01–T-F02: ML predict timeout race (150ms)", () => {75- // T-F02: when ML resolves IN TIME with a non-credential verdict for a text76- // field, handleFieldInteraction suppresses — the selector must NOT open.77- it("T-F02: ML resolves fast with a suppressing prediction → no selector", async () => {78- const { username } = createLoginForm();79- predictBatch.mockReturnValue(Promise.resolve([{ type: "search", confidence: 0.9 }]));80- await handleFieldInteraction(username); // passive (explicitSummon=false)81- // give any stray microtasks a tick82- await new Promise((r) => setTimeout(r, 20));83- expect(showSelector).not.toHaveBeenCalled();84- });85-86- // T-F01: when ML is SLOWER than the 150ms cap, the timeout wins, the87- // prediction is discarded (treated as null), and the heuristic path proceeds —88- // so the very same suppressing verdict no longer blocks the selector. This is89- // the regression that froze autofill on pages with a slow ML worker.90- it("T-F01: ML slower than 150ms → timeout path taken, selector still opens", async () => {91- const { username } = createLoginForm();92- predictBatch.mockReturnValue(93- new Promise((resolve) => setTimeout(() => resolve([{ type: "search", confidence: 0.9 }]), 250))94- );95- setMessageResponder((msg) =>96- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }97- );98- await handleFieldInteraction(username);99- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled(), { timeout: 1000 });100- });101-});102-103-describe("T-F03–T-F05: vault state + form shape", () => {104- // T-F03: a locked vault shows the inline unlock prompt (showAutofillSelector105- // with {locked:true}), never the entry list.106- it("T-F03: locked vault → selector opened in locked mode", async () => {107- const { password } = createLoginForm();108- setMessageResponder((msg) =>109- msg.type === "AUTOFILL_QUERY" ? { ok: false, error: "vault_locked" } : { ok: true, data: null }110- );111- await handleFieldInteraction(password);112- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());113- expect(lastSelectorOptions()?.locked).toBe(true);114- });115-116- // T-F04: unlocked vault with no entries for this domain → signup/identity117- // surface (isSignup:true), not a credential list.118- it("T-F04: no domain entries → signup/identity surface", async () => {119- const { password } = createLoginForm();120- setMessageResponder((msg) =>121- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }122- );123- await handleFieldInteraction(password);124- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());125- expect(lastSelectorOptions()?.isSignup).toBe(true);126- expect(lastSelectorOptions()?.locked).toBeFalsy();127- });128-129- // T-F05: an email-first form (no password on this step) still opens on the130- // email field — the path must not bail just because there is no password.131- it("T-F05: email-first form opens on the email field", async () => {132- const { email } = createEmailFirstForm();133- setMessageResponder((msg) =>134- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }135- );136- await handleFieldInteraction(email);137- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());138- });139-});140-141-describe("T-F07: TOTP field path", () => {142- // T-F07: a lone verification-code field (no login form present) routes to the143- // TOTP selector, not the credential selector.144- it("T-F07: standalone one-time-code field → TOTP selector", async () => {145- const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });146- setMessageResponder((msg) =>147- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null }148- );149- await handleFieldInteraction(totp);150- await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());151- expect(showSelector).not.toHaveBeenCalled();152- });153-});154-155-describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {156- // T-F09: autocomplete="off" on the form must not stop Peach offering — the157- // user installed Peach precisely to override the site's opt-out.158- it("T-F09: form with autocomplete=off still offers", async () => {159- const form = document.createElement("form");160- form.setAttribute("autocomplete", "off");161- document.body.appendChild(form);162- makeInput({ type: "text", name: "username", autocomplete: "username" }, form);163- const password = makeInput({ type: "password", autocomplete: "current-password" }, form);164- setMessageResponder((msg) =>165- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }166- );167- await handleFieldInteraction(password);168- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());169- });170-});171-172-describe("T-F10–T-F12: robustness", () => {173- // T-F10: a field whose ownerDocument is a different (cross-origin-style)174- // context must be handled without crashing. We approximate by anchoring a175- // detached input — the function must abort cleanly, never throw.176- it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => {177- const detached = document.createElement("input");178- detached.type = "password"; // never attached → isVisible() false179- await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();180- expect(showSelector).not.toHaveBeenCalled();181- });182-183- // T-F11: two concurrent calls for the SAME field (focusin + icon click racing)184- // must not double-open — pendingInteractions debounces the second.185- it("T-F11: concurrent calls for the same field → selector opens at most once", async () => {186- const { password } = createLoginForm();187- setMessageResponder((msg) =>188- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }189- );190- await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);191- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192- expect(showSelector).toHaveBeenCalledTimes(1);193- });194-});src/drafts/AUTOFILL-TESTS/content.fill.test.ts+0 −198
@@ -1,198 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 8 — fill.ts credential injection (T-FL01–T-FL12).4-//5-// fill.ts is where credentials actually enter the page. The subtle part is6-// setNativeValue: React/Vue track input values through the native value setter,7-// not direct `.value =` assignment, so fill.ts pulls the prototype setter and8-// dispatches real input+change events. If that breaks, fills look successful to9-// Peach but the site's controlled-input state stays empty — the canonical10-// "autofill did nothing" bug. These tests use real DOM elements, never mocks.11-12-import { beforeEach, describe, expect, it, vi } from "vitest";13-14-import {15- detectCardNetwork,16- extractLast4,17- fillCardForm,18- fillFields,19- fillTOTPCode,20- formatCardNumber,21- passesLuhn,22- setNativeValue,23-} from "../../content/fill";24-import { makeInput } from "./_setup";25-26-beforeEach(() => {27- document.body.innerHTML = "";28-});29-30-describe("T-FL01–T-FL03: fillFields + event dispatch", () => {31- // T-FL01: the core contract — username and password land in their inputs.32- it("T-FL01: fills username and password into standard inputs", () => {33- const u = makeInput({ type: "text" });34- const p = makeInput({ type: "password" });35- fillFields(u, p, "alice", "s3cret");36- expect(u.value).toBe("alice");37- expect(p.value).toBe("s3cret");38- });39-40- // T-FL02: an input event must fire so reactive frameworks re-read the field.41- it("T-FL02: dispatches an input event on filled fields", () => {42- const p = makeInput({ type: "password" });43- const onInput = vi.fn();44- p.addEventListener("input", onInput);45- fillFields(null, p, "", "pw");46- expect(onInput).toHaveBeenCalledTimes(1);47- });48-49- // T-FL03: a change event must fire too (some frameworks/validators key on it).50- it("T-FL03: dispatches a change event on filled fields", () => {51- const p = makeInput({ type: "password" });52- const onChange = vi.fn();53- p.addEventListener("change", onChange);54- fillFields(null, p, "", "pw");55- expect(onChange).toHaveBeenCalledTimes(1);56- });57-});58-59-describe("T-FL04–T-FL05: framework-controlled inputs (setNativeValue)", () => {60- // T-FL04: React installs its own value setter on the *instance*; setNativeValue61- // must call the *prototype* setter so React's value tracker observes the change.62- // We emulate React's tracker by shadowing the instance value descriptor and63- // asserting the prototype setter still updated the underlying value AND that64- // the synthetic input event carried the new value.65- it("T-FL04: setNativeValue bypasses a React-style instance setter via the prototype setter", () => {66- const el = makeInput({ type: "text" });67- let trackerSawValue: string | null = null;68- el.addEventListener("input", () => { trackerSawValue = el.value; });69-70- // React installs an own `value` accessor on the element whose setter71- // swallows direct `.value =` assignment (its value-tracker). setNativeValue72- // must reach the *prototype* setter to update the real value slot. The73- // getter here delegates to the prototype so reads still work.74- const protoDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!;75- let instanceSetterCalled = false;76- Object.defineProperty(el, "value", {77- configurable: true,78- get() { return protoDesc.get!.call(el); },79- set() { instanceSetterCalled = true; /* React swallows direct assignment */ },80- });81-82- setNativeValue(el, "from-peach");83-84- expect(instanceSetterCalled).toBe(false); // went around the instance setter85- expect(el.value).toBe("from-peach"); // prototype setter wrote the slot86- expect(trackerSawValue).toBe("from-peach"); // event fired AFTER the value was set87- });88-89- // T-FL05: Vue (and any framework) relies on the same input/change emission.90- it("T-FL05: setNativeValue emits input then change for Vue-style v-model", () => {91- const el = makeInput({ type: "text" });92- const seq: string[] = [];93- el.addEventListener("input", () => seq.push("input"));94- el.addEventListener("change", () => seq.push("change"));95- setNativeValue(el, "x");96- expect(seq).toEqual(["input", "change"]);97- });98-});99-100-describe("T-FL06: cross-origin / wrong-domain guard", () => {101- // T-FL06: a true cross-origin iframe can't be scripted from jsdom, but the102- // same defensive path fires when a form's action points at a different103- // registrable domain (phished/relayed form). fillFields must refuse to fill104- // and must not throw.105- it("T-FL06: form action on a foreign domain → fields are not filled, no crash", () => {106- const formEl = document.createElement("form");107- formEl.action = "https://evil.example.org/collect";108- document.body.appendChild(formEl);109- const u = makeInput({ type: "text" }, formEl);110- const p = makeInput({ type: "password" }, formEl);111- expect(() =>112- fillFields(u, p, "alice", "s3cret", formEl, "mybank.example.com")113- ).not.toThrow();114- expect(u.value).toBe("");115- expect(p.value).toBe("");116- });117-});118-119-describe("T-FL07–T-FL09: card fill + validation", () => {120- // T-FL07: every card field that exists gets its value.121- it("T-FL07: fills number, expiry combo, cvc, and cardholder name", () => {122- const number = makeInput({ autocomplete: "cc-number" });123- const expiryCombo = makeInput({ autocomplete: "cc-exp" });124- const cvc = makeInput({ autocomplete: "cc-csc" });125- const name = makeInput({ autocomplete: "cc-name" });126- fillCardForm(127- { number, expMonth: null, expYear: null, expiryCombo, cvc, name },128- { cardNumber: "4111111111111111", expMonth: "08", expYear: "2027", cvc: "123", cardholderName: "Alice Wong" }129- );130- expect(number.value).toBe("4111 1111 1111 1111"); // formatted in 4-digit groups131- expect(expiryCombo.value).toBe("08/27");132- expect(cvc.value).toBe("123");133- expect(name.value).toBe("Alice Wong");134- });135-136- // T-FL08: Luhn check — guards against a fill writing a transposed/typo'd PAN.137- it("T-FL08: passesLuhn accepts valid PANs and rejects invalid ones", () => {138- expect(passesLuhn("4111111111111111")).toBe(true); // canonical Visa test number139- expect(passesLuhn("4111 1111 1111 1111")).toBe(true); // whitespace tolerated140- expect(passesLuhn("4111111111111112")).toBe(false); // last digit wrong141- });142-143- // T-FL09: network detection from the leading digits drives the card icon.144- it("T-FL09: detectCardNetwork keys off the issuer prefix", () => {145- expect(detectCardNetwork("4111111111111111")).toBe("visa");146- expect(detectCardNetwork("5500005555555559")).toBe("mastercard");147- expect(detectCardNetwork("340000000000009")).toBe("amex");148- expect(detectCardNetwork("6011000000000004")).toBe("discover");149- expect(detectCardNetwork("9999999999999999")).toBe("unknown");150- });151-152- it("T-FL09b: amex (15-digit) numbers format in 4-6-5 groups", () => {153- expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");154- expect(extractLast4("3400 000000 00009")).toBe("0009");155- });156-});157-158-describe("T-FL10–T-FL12: TOTP fill, preservation, and the return contract", () => {159- // T-FL10: TOTP code goes into an empty one-time-code field.160- it("T-FL10: fillTOTPCode inserts the code into an empty visible field", () => {161- const totp = makeInput({ type: "text", autocomplete: "one-time-code" });162- expect(fillTOTPCode(totp, "123456")).toBe(true);163- expect(totp.value).toBe("123456");164- });165-166- it("T-FL10b: fillTOTPCode refuses to overwrite a non-empty field", () => {167- const totp = makeInput({ type: "text", autocomplete: "one-time-code" });168- setNativeValue(totp, "000000");169- expect(fillTOTPCode(totp, "123456")).toBe(false);170- expect(totp.value).toBe("000000");171- });172-173- // T-FL11: only the targeted fields are mutated — a stray "remember me" or174- // search box on the same page keeps its value.175- it("T-FL11: fields not passed to fillFields are left untouched", () => {176- const u = makeInput({ type: "text" });177- const p = makeInput({ type: "password" });178- const other = makeInput({ type: "text" });179- setNativeValue(other, "do-not-touch");180- fillFields(u, p, "alice", "s3cret");181- expect(other.value).toBe("do-not-touch");182- });183-184- // T-FL12: the brief assumes fillFields returns the filled set; the real API185- // returns void and the caller (index.ts recordFillSuccess) tracks success by186- // the elements it passed. We pin the actual contract: fillFields returns187- // undefined, and an invisible password is intentionally skipped — which is how188- // a caller can tell a fill was partial. (See INTEGRATION-CONTRACT.md.)189- it("T-FL12: fillFields returns void and skips an invisible password field", () => {190- const u = makeInput({ type: "text" });191- const p = makeInput({ type: "password" });192- p.style.display = "none"; // invisible → isVisible() false → not filled193- const result = fillFields(u, p, "alice", "s3cret");194- expect(result).toBeUndefined();195- expect(u.value).toBe("alice");196- expect(p.value).toBe(""); // skipped because not visible197- });198-});src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+0 −144
@@ -1,144 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 3 — index.ts initialScan aggression levels (T-S01–T-S10).4-//5-// initialScan decides where the field icon is injected at page load. Aggression6-// climbs with each pass: level 0 only touches pages with a recognised credential7-// form; level 2 final-pass injects on a bare password field even without8-// surrounding form context (SPA reveal-after-username case). We mock ./inject so9-// injectFieldIcon is observable, and assert on its call count — the real10-// regression surface is "icon missing" or "icon on the wrong field".11-12-import { vi } from "vitest";13-14-const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn(() => vi.fn()) }));15-16-vi.mock("../../content/inject", () => ({17- injectFieldIcon: injectIcon,18- showAutofillSelector: vi.fn(() => vi.fn()),19- showTOTPSelector: vi.fn(() => vi.fn()),20- showCardSelector: vi.fn(() => vi.fn()),21- showRememberSiteToast: vi.fn(),22- showPasskeyAvailable: vi.fn(),23- showPasskeyProGate: vi.fn(),24- showPasskeyRegistrationOffer: vi.fn(),25-}));26-vi.mock("../../content/content-ml-detector", () => ({27- contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) },28-}));29-30-import { afterEach, beforeEach, describe, expect, it } from "vitest";31-import "./_setup";32-import { resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";33-import { initialScan } from "../../content/index";34-35-// Each test uses fresh DOM elements, so the module-private processedInputs36-// WeakSet never collides across tests; injectIcon's count is therefore a clean37-// per-test signal.38-beforeEach(() => {39- document.body.innerHTML = "";40- resetChromeMock();41- injectIcon.mockClear();42-});43-44-afterEach(async () => {45- await new Promise((r) => setTimeout(r, 0));46-});47-48-describe("T-S01–T-S02: level 0 is conservative", () => {49- // T-S01: an empty page → scan runs, injects nothing.50- it("T-S01: no forms at all + level 0 → no icon", () => {51- initialScan(0);52- expect(injectIcon).not.toHaveBeenCalled();53- });54-55- // T-S02: a non-credential form (search box) → level 0 injects nothing.56- it("T-S02: non-matching form + level 0 → no icon", () => {57- const f = document.createElement("form");58- document.body.appendChild(f);59- makeInput({ type: "search", name: "q", placeholder: "Search" }, f);60- initialScan(0);61- expect(injectIcon).not.toHaveBeenCalled();62- });63-});64-65-describe("T-S03–T-S06: icons get injected as aggression rises", () => {66- // T-S03: a standard login form at level 1 → icon injected (on the credential67- // fields).68- it("T-S03: standard login form + level 1 → icon injected", () => {69- createLoginForm();70- initialScan(1);71- expect(injectIcon).toHaveBeenCalled();72- });73-74- // T-S04: an email-first form (email only) → icon injected on the email field.75- it("T-S04: email-first form + level 1 → icon injected", () => {76- createEmailFirstForm();77- initialScan(1);78- expect(injectIcon).toHaveBeenCalled();79- });80-81- // T-S05: a bare password field (no username) at level 2 final-pass → icon82- // injected. This is the SPA case where the password appears after the83- // username step; without the final pass the field would never get an icon.84- it("T-S05: bare password + level 2 final-pass → icon injected", () => {85- makeInput({ type: "password", autocomplete: "current-password" });86- initialScan(2, true);87- expect(injectIcon).toHaveBeenCalled();88- });89-90- // T-S07: multiple independent login forms → each qualifying password field91- // gets its own icon.92- it("T-S07: two login forms → an icon per form's password field", () => {93- createLoginForm();94- createLoginForm();95- initialScan(1);96- // At least two injections — one per password field. (Username fields may add97- // more; the floor is the two passwords.)98- expect(injectIcon.mock.calls.length).toBeGreaterThanOrEqual(2);99- });100-});101-102-describe("T-S08–T-S10: visibility, dynamic forms, idempotency", () => {103- // T-S08: a display:none form is not a fillable surface — no icon, even at104- // level 1, because isVisible() rejects its fields.105- it("T-S08: hidden (display:none) form → no icon", () => {106- const f = document.createElement("form");107- f.style.display = "none";108- document.body.appendChild(f);109- const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);110- const p = makeInput({ type: "password", autocomplete: "current-password" }, f);111- // Force isVisible() false by zeroing the rects (display:none isn't computed112- // for ancestors in jsdom, so neutralise the per-field rect shim too).113- for (const el of [u, p]) {114- Object.defineProperty(el, "getBoundingClientRect", {115- configurable: true,116- value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),117- });118- }119- initialScan(1);120- expect(injectIcon).not.toHaveBeenCalled();121- });122-123- // T-S09: a form inserted after the first scan gets an icon when the scan runs124- // again (the MutationObserver path re-invokes the reattach logic; here we125- // drive the rescan directly to keep the test deterministic — the debounce126- // timing itself is covered by the Playwright e2e).127- it("T-S09: a form inserted after the first scan is picked up on rescan", () => {128- initialScan(1);129- expect(injectIcon).not.toHaveBeenCalled(); // nothing on the page yet130- createLoginForm(); // dynamic insertion131- initialScan(1);132- expect(injectIcon).toHaveBeenCalled();133- });134-135- // T-S10: running the scan twice on the same form injects exactly once —136- // processedInputs makes it idempotent, preventing duplicate stacked icons.137- it("T-S10: scanning the same form twice → icon injected once per field", () => {138- createLoginForm();139- initialScan(1);140- const afterFirst = injectIcon.mock.calls.length;141- initialScan(1);142- expect(injectIcon.mock.calls.length).toBe(afterFirst); // no new injections143- });144-});src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+0 −115
@@ -1,115 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 12 — interaction.ts target resolution (T-IT01–T-IT06).4-//5-// resolveAutofillInteractionTargets is the small, pure decision that turns a set6-// of detected fields + the clicked anchor into "what do we fill, and do we even7-// open?". It is deliberately narrow: it does NOT scope by form (the caller hands8-// it a pre-scoped field list) and it does NOT inspect the live DOM. The tests9-// below pin its real contract; where the brief assumed broader behavior10-// (form-scoping, email-first shape), the deviation is noted inline and in11-// INTEGRATION-CONTRACT.md.12-13-import { beforeEach, describe, expect, it } from "vitest";14-15-import type { DetectedField } from "../../content/detect";16-import { resolveAutofillInteractionTargets } from "../../content/interaction";17-import { makeInput } from "./_setup";18-19-beforeEach(() => {20- document.body.innerHTML = "";21-});22-23-function field(type: DetectedField["type"], el: HTMLInputElement): DetectedField {24- return { type, el };25-}26-27-describe("resolveAutofillInteractionTargets", () => {28- // T-IT01: a standard form resolves both the username and password elements and29- // opens.30- it("T-IT01: standard form → {usernameEl, passwordEl} and shouldOpen", () => {31- const u = makeInput({ type: "text" });32- const p = makeInput({ type: "password" });33- const targets = resolveAutofillInteractionTargets(p, [field("username", u), field("password", p)]);34- expect(targets.usernameEl).toBe(u);35- expect(targets.passwordEl).toBe(p);36- expect(targets.emailEl).toBeNull();37- expect(targets.shouldOpen).toBe(true);38- });39-40- // T-IT02: email-first step has no password. The function treats a lone email41- // as the identity field (usernameEl), so the caller fills entry.username into42- // it; emailEl stays null because there is no *separate* username field. (The43- // brief's {emailEl, passwordEl:null} shape predates this consolidation.)44- it("T-IT02: email-first form → email becomes usernameEl, passwordEl null", () => {45- const e = makeInput({ type: "email" });46- const targets = resolveAutofillInteractionTargets(e, [field("email", e)]);47- expect(targets.usernameEl).toBe(e);48- expect(targets.passwordEl).toBeNull();49- expect(targets.emailEl).toBeNull();50- // shouldOpen depends on isAnyCredentialForm; an email field with no auth51- // context on a "/" path is not yet a credential form, so passive open is52- // gated. An explicit summon overrides (next test).53- });54-55- // T-IT02b: distinct username AND email → both tracked separately.56- it("T-IT02b: distinct username + email → emailEl is tracked alongside usernameEl", () => {57- const u = makeInput({ type: "text", autocomplete: "username" });58- const e = makeInput({ type: "email", autocomplete: "email" });59- const p = makeInput({ type: "password" });60- const targets = resolveAutofillInteractionTargets(61- u,62- [field("username", u), field("email", e), field("password", p)]63- );64- expect(targets.usernameEl).toBe(u);65- expect(targets.emailEl).toBe(e);66- expect(targets.passwordEl).toBe(p);67- });68-69- // T-IT03: password-only step → passwordEl set, usernameEl null, and it opens70- // (isPasswordStepForm).71- it("T-IT03: password-only form → passwordEl set, usernameEl null, shouldOpen", () => {72- const p = makeInput({ type: "password", autocomplete: "current-password" });73- const targets = resolveAutofillInteractionTargets(p, [field("password", p)]);74- expect(targets.passwordEl).toBe(p);75- expect(targets.usernameEl).toBeNull();76- expect(targets.shouldOpen).toBe(true);77- });78-79- // T-IT04: no qualifying fields → do not open (passive).80- it("T-IT04: empty field set + passive → shouldOpen false", () => {81- const anchor = makeInput({ type: "text" });82- const targets = resolveAutofillInteractionTargets(anchor, []);83- expect(targets.shouldOpen).toBe(false);84- expect(targets.usernameEl).toBeNull();85- expect(targets.passwordEl).toBeNull();86- });87-88- // T-IT05: the function resolves targets from the field list it is GIVEN — form89- // scoping is the caller's job (index.ts calls detectLoginFields per the focused90- // field's context). With explicitSummon, a bare anchor still resolves as a91- // fallback target so an icon click always opens something.92- it("T-IT05: explicit summon on a bare password anchor falls back to the anchor", () => {93- const anchor = makeInput({ type: "password" });94- const targets = resolveAutofillInteractionTargets(anchor, [], true);95- expect(targets.shouldOpen).toBe(true);96- expect(targets.passwordEl).toBe(anchor);97- });98-99- it("T-IT05b: explicit summon on a bare username anchor falls back to the anchor", () => {100- const anchor = makeInput({ type: "text" });101- const targets = resolveAutofillInteractionTargets(anchor, [], true);102- expect(targets.shouldOpen).toBe(true);103- expect(targets.usernameEl).toBe(anchor);104- });105-106- // T-IT06: a detached anchor with no detected fields resolves to a non-opening107- // result rather than throwing — the graceful-abort path index.ts relies on.108- it("T-IT06: detached anchor + empty fields → shouldOpen false, no throw", () => {109- const detached = document.createElement("input");110- detached.type = "text"; // never appended to the document111- let targets!: ReturnType<typeof resolveAutofillInteractionTargets>;112- expect(() => { targets = resolveAutofillInteractionTargets(detached, []); }).not.toThrow();113- expect(targets.shouldOpen).toBe(false);114- });115-});src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+0 −184
@@ -1,184 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 4 — index.ts message routing (T-M01–T-M13).4-//5-// index.ts registers a single chrome.runtime.onMessage listener that routes6-// SUMMON_AUTOFILL (keyboard shortcut / toolbar button), VAULT_UNLOCKED (retry7-// after unlock), PING, GET_SITE_PASSWORD_POLICY, and the passkey-toast control8-// messages. The harness captures every registered listener; we invoke them with9-// a spy sendResponse and a same-extension sender, then assert the routed effect.10-//11-// Where the brief assumed messages that this listener does not own12-// (DISMISS_AUTOFILL, FILL_AND_SUBMIT), and the Addendum-2 showTotpCopyToast that13-// does not exist yet, the cases are documented rather than faked. See14-// INTEGRATION-CONTRACT.md.15-16-import { vi } from "vitest";17-18-const { predictBatch, dismissSpy } = vi.hoisted(() => ({19- predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),20- dismissSpy: vi.fn(),21-}));22-23-vi.mock("../../content/inject", () => ({24- injectFieldIcon: vi.fn(() => vi.fn()),25- showAutofillSelector: vi.fn(() => dismissSpy),26- showTOTPSelector: vi.fn(() => dismissSpy),27- showCardSelector: vi.fn(() => dismissSpy),28- showRememberSiteToast: vi.fn(),29- showPasskeyAvailable: vi.fn(),30- showPasskeyProGate: vi.fn(),31- showPasskeyRegistrationOffer: vi.fn(),32-}));33-vi.mock("../../content/content-ml-detector", () => ({34- contentMLDetector: { predictBatch },35-}));36-37-import { afterEach, beforeEach, describe, expect, it } from "vitest";38-import "./_setup";39-import { resetChromeMock, setMessageResponder, messageListeners, createLoginForm, makeInput } from "./_setup";40-import "../../content/index"; // registers the onMessage listener at import time41-import * as inject from "../../content/inject";42-43-const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44-const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;45-const SELF_SENDER = { id: "peach-test-extension-id" };46-const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];47-48-// Deliver a message to every registered onMessage listener; return the49-// sendResponse spy so the test can assert the reply.50-function dispatch(msg: any, sender: any = SELF_SENDER) {51- const sendResponse = vi.fn();52- for (const l of messageListeners) l(msg, sender, sendResponse);53- return sendResponse;54-}55-56-function withEntries(): void {57- setMessageResponder((msg) =>58- msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }59- );60-}61-62-let urlCounter = 0;63-beforeEach(async () => {64- try { history.pushState({}, "", `/m${++urlCounter}`); } catch { /* ignore */ }65- document.body.innerHTML = "";66- await new Promise((r) => setTimeout(r, 0));67- resetChromeMock();68- showSelector.mockClear();69- showPasskeyProGate.mockClear();70- predictBatch.mockReset();71- predictBatch.mockReturnValue(Promise.resolve([]));72-});73-74-afterEach(async () => {75- await new Promise((r) => setTimeout(r, 0));76-});77-78-describe("T-M01–T-M02: SUMMON_AUTOFILL", () => {79- // T-M01: the shortcut on a login form opens the selector for the focused field.80- it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => {81- const { password } = createLoginForm();82- password.focus();83- withEntries();84- const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });85- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());86- expect(send).toHaveBeenCalled(); // listener acknowledged87- });88-89- // T-M02: the shortcut on a page with no login form must not crash and must not90- // open the credential selector (it falls back to the search palette).91- it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {92- expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();93- expect(showSelector).not.toHaveBeenCalled();94- });95-});96-97-describe("T-M03–T-M05: VAULT_UNLOCKED", () => {98- // T-M03: after unlock, the pending field's interaction is retried.99- it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => {100- const { password } = createLoginForm();101- password.focus();102- withEntries();103- dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); // opens → sets currentAnchorField104- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());105- showSelector.mockClear();106-107- dispatch({ type: "VAULT_UNLOCKED" });108- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); // reopened at the same field109- });110-111- // T-M05: VAULT_UNLOCKED with nothing pending and no focused field is a no-op —112- // it must not throw or open anything.113- it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => {114- (document.activeElement as HTMLElement | null)?.blur?.();115- const send = dispatch({ type: "VAULT_UNLOCKED" });116- await new Promise((r) => setTimeout(r, 20));117- expect(send).toHaveBeenCalledWith({ ok: true });118- expect(showSelector).not.toHaveBeenCalled();119- });120-121- // T-M04: when a passkey unlock is in flight (passkeySummonActive), VAULT_UNLOCKED122- // must NOT re-trigger autofill — the passkey toast owns the next step. We arm123- // passkeySummonActive via the passkey-unlock summon path, then assert the124- // unlock only dismisses (no fresh credential selector). Ordered last in the125- // group because the VAULT_UNLOCKED it sends also clears the flag.126- it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => {127- // The passkey-unlock branch only fires when there is NO credential form on128- // the page (vault locked mid-passkey-GET); it anchors the locked prompt on129- // whatever element is focused (typically the sign-in button).130- const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" });131- anchor.focus();132- dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); // sets passkeySummonActive133- await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());134- showSelector.mockClear();135-136- dispatch({ type: "VAULT_UNLOCKED" }); // passkey branch: dismiss only, no reopen137- await new Promise((r) => setTimeout(r, 20));138- expect(showSelector).not.toHaveBeenCalled();139- });140-});141-142-describe("T-M08–T-M10: PING, passkey gate, robustness", () => {143- // PING is the liveness probe the background uses to confirm the content script144- // is injected.145- it("PING → sendResponse({ ok: true })", () => {146- const send = dispatch({ type: "PING" });147- expect(send).toHaveBeenCalledWith({ ok: true });148- });149-150- // GET_SITE_PASSWORD_POLICY returns the scraped policy for the generator.151- it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => {152- document.body.innerHTML = '<input type="password" maxlength="32" />';153- const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" });154- expect(send).toHaveBeenCalledTimes(1);155- const arg = send.mock.calls[0]![0];156- expect(arg.ok).toBe(true);157- expect(arg.data.maxLength).toBe(32);158- });159-160- // PASSKEY_PRO_GATE surfaces the upgrade prompt (State 11).161- it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => {162- dispatch({ type: "PASSKEY_PRO_GATE" });163- expect(showPasskeyProGate).toHaveBeenCalled();164- });165-166- // T-M09: an unknown message type falls through harmlessly — no throw, no167- // unhandled rejection.168- it("T-M09: unknown message type → handled gracefully", () => {169- expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow();170- expect(showSelector).not.toHaveBeenCalled();171- });172-173- // T-M10: a foreign-extension sender (sender.id mismatch) is ignored entirely —174- // a security boundary, not just robustness.175- it("T-M10: message from a foreign sender id is ignored", () => {176- const send = dispatch({ type: "PING" }, { id: "some-other-extension" });177- expect(send).not.toHaveBeenCalled();178- });179-});180-181-// Addendum 2 (T-M11–T-M13, proposed showTotpCopyToast) lives in182-// content.totp-toast.addendum.test.ts, which imports the REAL inject.ts so it183-// can detect whether the proposed export exists. It cannot be probed here184-// because this file mocks ./inject (and the mock throws on unknown exports).src/drafts/AUTOFILL-TESTS/content.policy.test.ts+0 −126
@@ -1,126 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 11 — policy.ts site password policy (T-PO01–T-PO06).4-//5-// detectPasswordPolicy scrapes the page so Peach's generator can produce a6-// password the site will actually accept. Its real inputs are: the password7-// field's maxLength attribute, its pattern attribute, and nearby descriptive8-// text (aria-describedby, hints, labels). It does NOT read a minlength attribute9-// and it inspects only the first password field — where the brief assumed10-// otherwise (T-PO01 minlength, T-PO05 generator wiring, T-PO06 multi-field11-// "most restrictive"), the deviation is pinned and documented in12-// INTEGRATION-CONTRACT.md so the gap is explicit, not silently "passing".13-14-import { beforeEach, describe, expect, it } from "vitest";15-16-import { detectPasswordPolicy, extractNearbyText } from "../../content/policy";17-18-beforeEach(() => {19- document.body.innerHTML = "";20-});21-22-function pwField(html: string): void {23- document.body.innerHTML = html;24-}25-26-describe("T-PO01–T-PO04: attribute / pattern / text extraction", () => {27- // T-PO01: minLength is NOT read from a minlength attribute — only from the28- // pattern or descriptive text. This test pins the real source (pattern) and29- // documents that a bare minlength attribute yields nothing.30- it("T-PO01: minLength comes from the pattern, not the minlength attribute", () => {31- pwField('<input type="password" minlength="12" />');32- expect(detectPasswordPolicy().minLength).toBeUndefined();33-34- pwField('<input type="password" pattern=".{8,}" />');35- expect(detectPasswordPolicy().minLength).toBe(8);36- });37-38- // T-PO02: maxLength IS read directly from the attribute.39- it("T-PO02: maxlength attribute → policy.maxLength", () => {40- pwField('<input type="password" maxlength="64" />');41- expect(detectPasswordPolicy().maxLength).toBe(64);42- });43-44- // T-PO03: a range pattern yields both bounds; forbidden characters are derived45- // from negation TEXT (not the pattern). Pin both real mechanisms.46- it("T-PO03: range pattern → min+max; negation text → forbidden chars", () => {47- pwField('<input type="password" pattern=".{10,32}" />');48- const p1 = detectPasswordPolicy();49- expect(p1.minLength).toBe(10);50- expect(p1.maxLength).toBe(32);51-52- pwField(53- '<div><input type="password" id="pw" aria-describedby="rule" />' +54- '<p id="rule">Password cannot contain ` ~ < > characters.</p></div>'55- );56- const p2 = detectPasswordPolicy();57- expect(p2.forbiddenChars).toBeTruthy();58- for (const ch of ["`", "~", "<", ">"]) expect(p2.forbiddenChars).toContain(ch);59- });60-61- // T-PO04: no policy signals anywhere → an empty policy object (the generator62- // then uses its own defaults).63- it("T-PO04: no attributes or hints → empty policy", () => {64- pwField('<input type="password" />');65- expect(detectPasswordPolicy()).toEqual({});66- });67-});68-69-describe("T-PO01b: human-readable length ranges in nearby text", () => {70- // The most common real-world phrasing is "8-20 characters" in a hint element.71- it("dash range '8-20 characters' → min 8, max 20", () => {72- pwField(73- '<div><input type="password" id="pw" aria-describedby="h" />' +74- '<span id="h">Use 8-20 characters.</span></div>'75- );76- const p = detectPasswordPolicy();77- expect(p.minLength).toBe(8);78- expect(p.maxLength).toBe(20);79- });80-81- it("'at least 12 characters' → min 12", () => {82- pwField(83- '<div><input type="password" id="pw" aria-describedby="h" />' +84- '<small id="h">Must be at least 12 characters.</small></div>'85- );86- expect(detectPasswordPolicy().minLength).toBe(12);87- });88-});89-90-describe("T-PO05–T-PO06: documented scope boundaries", () => {91- // T-PO05 (generator integration) lives in the generator's own suite, not here:92- // detectPasswordPolicy only PRODUCES the policy. We pin the shape the generator93- // consumes so a field rename would break a real contract.94- it("T-PO05: produced policy uses the PasswordPolicy field names the generator reads", () => {95- pwField('<input type="password" pattern=".{6,18}" maxlength="18" />');96- const p = detectPasswordPolicy();97- expect(Object.keys(p).sort()).toEqual(["maxLength", "minLength"]);98- });99-100- // T-PO06: only the FIRST new-password/password field is inspected. "Most101- // restrictive across multiple fields" is not implemented — pin the actual102- // single-field behavior (new-password is preferred by selector order).103- it("T-PO06: reads the first matching password field (new-password preferred)", () => {104- pwField(105- '<input type="password" autocomplete="new-password" pattern=".{16,}" />' +106- '<input type="password" autocomplete="current-password" />'107- );108- expect(detectPasswordPolicy().minLength).toBe(16);109- });110-});111-112-describe("extractNearbyText", () => {113- // extractNearbyText is the scraping primitive; null input must be safe.114- it("returns empty string for a null field", () => {115- expect(extractNearbyText(null)).toBe("");116- });117-118- it("pulls aria-describedby, title, placeholder, and aria-label together", () => {119- document.body.innerHTML =120- '<input type="password" id="pw" title="t-text" placeholder="p-text" ' +121- 'aria-label="a-text" aria-describedby="d" /><span id="d">d-text</span>';122- const el = document.querySelector<HTMLInputElement>("#pw")!;123- const text = extractNearbyText(el);124- for (const s of ["t-text", "p-text", "a-text", "d-text"]) expect(text).toContain(s);125- });126-});src/drafts/AUTOFILL-TESTS/content.real-page.e2e.spec.ts+0 −191
@@ -1,191 +0,0 @@1-// DRAFT — Playwright e2e (T-028). Moves to tests/content.real-page.e2e.spec.ts2-// during integration; the fixture login-form.fixture.html moves to3-// tests/fixtures/login-form.html. Named *.spec.ts (Playwright's testMatch) and4-// kept in src/drafts/ so neither the vitest suite nor the drafts runner picks it5-// up. It is NOT run as part of this draft pass — it needs the built extension in6-// dist/ and a served origin.7-//8-// WHY THIS LAYER EXISTS: the inline selector mounts a CLOSED shadow root, so its9-// interior (entry list, arrow/Enter/Escape nav, search filter, TOTP chip) cannot10-// be asserted by reading the DOM from any unit test. The e2e verifies these11-// through EFFECTS instead — the light-DOM icon button appears and is clickable,12-// fields actually receive values, the host appears/disappears, and the13-// dismissal/grace behavior is observed end to end. This is the home for the14-// it.todo cases left in content.selector-ui.test.ts (T-I02–T-I16) and the15-// gesture-dependent dismissal cases (T-D14/T-D15) and save acceptance flows16-// (T-SV06–T-SV09).17-//18-// SETUP ASSUMPTIONS for the integrator:19-// - Serve the fixture from a real origin so domain matching works. Either a20-// tiny static server (http://localhost:<port>/login-form.html) or21-// page.route() interception mapping an https origin to the fixture file.22-// file:// will NOT exercise domain-scoped vault matching correctly.23-// - Seed the vault with one entry whose url matches that origin and whose24-// username/password are known, via the popup or an IMPORT message.25-26-import { test, expect } from "@playwright/test";27-import type { BrowserContext, Page } from "@playwright/test";28-import { fileURLToPath } from "url";29-import { dirname, join } from "path";30-import os from "os";31-import fs from "fs/promises";32-33-const __filename = fileURLToPath(import.meta.url);34-const __dirname = dirname(__filename);35-const EXTENSION_PATH = join(__dirname, "..", "..", "..", "dist");36-// After the move to tests/, this becomes join(__dirname, "fixtures", "login-form.html").37-const FIXTURE = join(__dirname, "login-form.fixture.html");38-const ICON = 'button[aria-label="Peach autofill"]';39-40-async function makeContext(): Promise<BrowserContext> {41- const { chromium } = await import("playwright");42- const userDataDir = join(os.tmpdir(), `peach-e2e-${Date.now()}`);43- await fs.mkdir(userDataDir, { recursive: true });44- return chromium.launchPersistentContext(userDataDir, {45- headless: false,46- args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],47- });48-}49-50-// Unlock/create the vault and seed one entry for the fixture origin. Left as a51-// helper skeleton — wire to the project's popup setup (see autofill.spec.ts52-// setupVault) and an IMPORT/ADD_ENTRY for the seeded credential.53-async function setupVaultWithEntry(_ctx: BrowserContext, _origin: string): Promise<void> {54- // TODO(integration): reuse setupVault() from autofill.spec.ts, then add an55- // entry { url: origin, username: "alice", password: "s3cret" }.56-}57-58-// Serve the fixture from a real origin. TODO(integration): replace with a static59-// server or page.route mapping; returning the file path is a placeholder so the60-// spec reads end-to-end.61-async function openFixture(ctx: BrowserContext): Promise<{ page: Page; origin: string }> {62- const page = await ctx.newPage();63- await page.goto(`file://${FIXTURE}`); // placeholder — see SETUP ASSUMPTIONS64- return { page, origin: new URL(page.url()).origin };65-}66-67-test.describe("Real-page autofill e2e (T-E01–T-E10)", () => {68- let ctx: BrowserContext;69-70- test.beforeAll(async () => {71- ctx = await makeContext();72- });73- test.afterAll(async () => {74- await ctx?.close();75- });76-77- // T-E01: the field icon appears in the password field of the standard form.78- test("T-E01: field icon appears in the password field", async () => {79- const { page } = await openFixture(ctx);80- await expect(page.locator(ICON)).toBeVisible();81- await page.close();82- });83-84- // T-E02: clicking the icon opens the selector (host mounts; closed shadow, so85- // assert on the host's presence and the absence of a page-level error).86- test("T-E02: clicking the icon opens the selector for domain-matched entries", async () => {87- const { page, origin } = await openFixture(ctx);88- await setupVaultWithEntry(ctx, origin);89- await page.locator(ICON).click();90- await expect(page.locator('div[style*="2147483647"]')).toBeVisible();91- await page.close();92- });93-94- // T-E03: selecting the entry fills username and password correctly (the effect95- // that proves the closed-shadow selection worked).96- test("T-E03: selecting an entry fills username and password", async () => {97- const { page, origin } = await openFixture(ctx);98- await setupVaultWithEntry(ctx, origin);99- await page.locator(ICON).click();100- // TODO(integration): activate the first entry. The row is in a closed shadow,101- // so drive it via keyboard on the focused host (ArrowDown + Enter) and assert102- // the fill effect rather than locating the row.103- await page.keyboard.press("Enter");104- await expect(page.locator("#username")).toHaveValue("alice");105- await expect(page.locator("#password")).toHaveValue("s3cret");106- await page.close();107- });108-109- // T-E04: after fill, the icon remains but the selector host is gone.110- test("T-E04: after fill the selector closes, the icon remains", async () => {111- const { page, origin } = await openFixture(ctx);112- await setupVaultWithEntry(ctx, origin);113- await page.locator(ICON).click();114- await page.keyboard.press("Enter");115- await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);116- await expect(page.locator(ICON)).toBeVisible();117- await page.close();118- });119-120- // T-E05: clicking the same field immediately after fill does NOT reopen121- // (fill-success grace — the Layer 3 behavior, observed end to end).122- test("T-E05: clicking the filled field within the grace window does not reopen", async () => {123- const { page, origin } = await openFixture(ctx);124- await setupVaultWithEntry(ctx, origin);125- await page.locator(ICON).click();126- await page.keyboard.press("Enter");127- await page.locator("#password").click();128- await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);129- await page.close();130- });131-132- // T-E06: after the grace window (>3s), clicking the field opens again.133- test("T-E06: after the grace window the selector opens again", async () => {134- const { page, origin } = await openFixture(ctx);135- await setupVaultWithEntry(ctx, origin);136- await page.locator(ICON).click();137- await page.keyboard.press("Enter");138- await page.waitForTimeout(3200); // past PAGE_FILL_GRACE_MS139- await page.locator("#password").click();140- await expect(page.locator('div[style*="2147483647"]')).toBeVisible();141- await page.close();142- });143-144- // T-E07: submitting the form after a fill keeps post-submit suppression active145- // (no save banner for the just-filled, unchanged credential).146- test("T-E07: form submit after fill does not re-prompt to save unchanged creds", async () => {147- const { page, origin } = await openFixture(ctx);148- await setupVaultWithEntry(ctx, origin);149- await page.locator(ICON).click();150- await page.keyboard.press("Enter");151- await page.evaluate(() => (document.getElementById("login-form") as HTMLFormElement).requestSubmit());152- // No save/update banner for an unchanged saved credential.153- await page.waitForTimeout(500);154- await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);155- await page.close();156- });157-158- // T-E08: the email-first form (shown) gets an icon on the email field and159- // opens an email surface.160- test("T-E08: email-first form gets an email icon and opens", async () => {161- const { page } = await openFixture(ctx);162- await page.evaluate(() => ((document.getElementById("email-first-form") as HTMLElement).style.display = "block"));163- await expect(page.locator(ICON)).toHaveCount(1); // login form's icon; email form adds another after rescan164- await page.close();165- });166-167- // T-E09: revealing the signup form → signup detection (identity surface).168- test("T-E09: signup form detection shows the identity surface", async () => {169- const { page } = await openFixture(ctx);170- await page.evaluate(() => ((document.getElementById("signup-form") as HTMLElement).style.display = "block"));171- await page.locator("#new-password").click();172- await expect(page.locator('div[style*="2147483647"]')).toBeVisible();173- await page.close();174- });175-176- // T-E10: a cross-origin iframe on the page must not crash the content script;177- // the main form still works.178- test("T-E10: cross-origin iframe present → no crash, main form still fills", async () => {179- const { page, origin } = await openFixture(ctx);180- await setupVaultWithEntry(ctx, origin);181- await page.evaluate(() => {182- const f = document.createElement("iframe");183- f.src = "https://example.com/";184- document.body.appendChild(f);185- });186- await page.locator(ICON).first().click();187- await page.keyboard.press("Enter");188- await expect(page.locator("#password")).toHaveValue("s3cret");189- await page.close();190- });191-});src/drafts/AUTOFILL-TESTS/content.save.test.ts+0 −136
@@ -1,136 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 9 — save.ts save-on-submit detection (T-SV01–T-SV10).4-//5-// setupSaveDetection installs a document submit listener that scrapes the6-// submitted username/password, looks up a matching vault entry7-// (AUTOFILL_QUERY → GET_ENTRY_FOR_FILL), and shows a save/update banner unless8-// the credentials already match. The banner itself is a CLOSED shadow root, and9-// its Save/Update buttons (which send ADD_ENTRY/UPDATE_ENTRY) are unreachable10-// from outside — so those acceptance flows are routed to the e2e. What we pin11-// here is the decision: does the banner mount, and is the lookup correct?12-13-import "./_setup";14-import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";15-import { setupSaveDetection } from "../../content/save";16-import { resetChromeMock, setMessageResponder, sentMessages, makeInput } from "./_setup";17-18-// Mounted save/update banner host (top-right, max z-index, closed shadow).19-function bannerHost(): HTMLElement | null {20- return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');21-}22-23-// Build and submit a form with the given fields; returns after the async24-// save-detection chain settles.25-async function submitForm(fields: { user?: string; pass?: string; type?: string }): Promise<void> {26- const form = document.createElement("form");27- document.body.appendChild(form);28- if (fields.user !== undefined) {29- const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, form);30- u.value = fields.user;31- }32- if (fields.pass !== undefined) {33- const p = makeInput({ type: fields.type ?? "password", name: "password" }, form);34- p.value = fields.pass;35- }36- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));37- await new Promise((r) => setTimeout(r, 30)); // let the AUTOFILL_QUERY/GET_ENTRY roundtrip settle38-}39-40-beforeAll(() => {41- setupSaveDetection(); // one document submit listener for the whole file42-});43-44-beforeEach(() => {45- document.body.innerHTML = "";46- resetChromeMock();47-});48-afterEach(() => {49- document.body.innerHTML = "";50-});51-52-describe("T-SV01, T-SV03: banner mounts for new and changed credentials", () => {53- // T-SV01: submitting credentials with no matching vault entry → save banner.54- it("T-SV01: new credentials → save banner mounts", async () => {55- setMessageResponder((msg) => (msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }));56- await submitForm({ user: "alice", pass: "s3cret" });57- expect(bannerHost()).not.toBeNull();58- });59-60- // T-SV03: submitting a changed password for an existing entry → update banner.61- it("T-SV03: changed password for an existing entry → banner mounts (update)", async () => {62- setMessageResponder((msg) => {63- if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };64- if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old-password" } };65- return { ok: true, data: null };66- });67- await submitForm({ user: "alice", pass: "new-password" });68- expect(bannerHost()).not.toBeNull();69- });70-});71-72-describe("T-SV02, T-SV04, T-SV05: no banner cases", () => {73- // T-SV02: the submitted password already matches the saved entry → no banner.74- it("T-SV02: credentials already saved (password matches) → no banner", async () => {75- setMessageResponder((msg) => {76- if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };77- if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "s3cret" } };78- return { ok: true, data: null };79- });80- await submitForm({ user: "alice", pass: "s3cret" });81- expect(bannerHost()).toBeNull();82- });83-84- // T-SV04: a search form has no password field — save detection ignores it and85- // never even runs the vault lookup.86- it("T-SV04: search form (no password) → no banner, no vault lookup", async () => {87- setMessageResponder(() => ({ ok: true, data: [] }));88- const form = document.createElement("form");89- document.body.appendChild(form);90- const q = makeInput({ type: "search", name: "q" }, form);91- q.value = "kittens";92- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));93- await new Promise((r) => setTimeout(r, 30));94- expect(bannerHost()).toBeNull();95- expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);96- });97-98- // T-SV05: an empty password short-circuits before any lookup.99- it("T-SV05: empty password → no banner, no vault lookup", async () => {100- setMessageResponder(() => ({ ok: true, data: [] }));101- await submitForm({ user: "alice", pass: "" });102- expect(bannerHost()).toBeNull();103- expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);104- });105-});106-107-describe("T-SV10: domain-matched lookup (MV-1 surface)", () => {108- // T-SV10: the lookup queries AUTOFILL_QUERY with the page domain, then fetches109- // the full entry by the returned id via GET_ENTRY_FOR_FILL.110- it("T-SV10: sends AUTOFILL_QUERY{domain} then GET_ENTRY_FOR_FILL{id}", async () => {111- setMessageResponder((msg) => {112- if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };113- if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old" } };114- return { ok: true, data: null };115- });116- await submitForm({ user: "alice", pass: "new" });117-118- const query = sentMessages.find((m) => m.type === "AUTOFILL_QUERY");119- expect(query?.payload?.domain).toBeTruthy();120- const fetch = sentMessages.find((m) => m.type === "GET_ENTRY_FOR_FILL");121- expect(fetch?.payload?.id).toBe("e1");122- });123-});124-125-// ── Closed-shadow / lifecycle boundaries (T-SV06–T-SV09) ─────────────────────126-describe("T-SV06–T-SV09: documented boundaries", () => {127- // The Save/Dismiss buttons and the homograph warning render inside the128- // banner's CLOSED shadow root; the accept/dismiss flows (which send129- // ADD_ENTRY/UPDATE_ENTRY), the popup-lifecycle suppression, and the homograph130- // warning copy are all verified through the Playwright e2e. See131- // INTEGRATION-CONTRACT.md.132- it.todo("T-SV06: user accepts the banner → ADD_ENTRY sent (closed shadow → e2e)");133- it.todo("T-SV07: user dismisses the banner → nothing saved (closed shadow → e2e)");134- it.todo("T-SV08: banner does not reappear after popup lifecycle break (e2e)");135- it.todo("T-SV09: homograph warning shown for non-ASCII domain (closed shadow → e2e)");136-});src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+0 −107
@@ -1,107 +0,0 @@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-});src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+0 −59
@@ -1,59 +0,0 @@1-// @vitest-environment jsdom2-//3-// Addendum 2 — TOTP auto-copy toast (T-M11–T-M13), proposed feature.4-//5-// Today, when Peach auto-copies a TOTP code it shows the generic "2FA code6-// copied" toast (showToast in index.ts). The addendum asks for a dedicated7-// showTotpCopyToast(secondsUntilClear) that renders a passkey-family toast8-// ("2FA code copied / clears in 30s") and auto-dismisses after 3s, resetting its9-// timer if a second copy lands while it is visible.10-//11-// showTotpCopyToast does not exist yet. This file imports the REAL inject.ts and12-// activates the spec the moment the export appears — so it is an executable13-// contract, not a stub that always passes. See INTEGRATION-CONTRACT.md.14-15-import "./_setup"; // installs the chrome mock inject.ts touches at import time16-import { describe, expect, it, vi } from "vitest";17-import * as inject from "../../content/inject";18-19-const showTotpCopyToast = (inject as unknown as {20- showTotpCopyToast?: (secondsUntilClear: number) => void;21-}).showTotpCopyToast;22-23-describe.skipIf(typeof showTotpCopyToast !== "function")(24- "Addendum 2 — proposed showTotpCopyToast (requires inject.ts change)",25- () => {26- const toast = showTotpCopyToast!;27-28- it("T-M11: renders a toast showing the configured clear time", () => {29- document.body.innerHTML = "";30- expect(() => toast(30)).not.toThrow();31- expect(document.body.textContent ?? "").toMatch(/clears in 30/i);32- });33-34- it("T-M12: a second copy while showing resets, leaving a single toast", () => {35- document.body.innerHTML = "";36- toast(30);37- toast(30);38- const toasts = document.querySelectorAll('[data-peach-toast], .peach-totp-toast');39- expect(toasts.length).toBeLessThanOrEqual(1);40- });41-42- it("T-M13: auto-dismisses after ~3s", () => {43- document.body.innerHTML = "";44- vi.useFakeTimers();45- toast(30);46- vi.advanceTimersByTime(3500);47- vi.useRealTimers();48- expect(document.body.textContent ?? "").not.toMatch(/2FA code copied/i);49- });50- }51-);52-53-// Guard so the file always has at least one executing assertion even before the54-// feature exists — documents the current state explicitly.55-describe("Addendum 2 status", () => {56- it("documents that showTotpCopyToast is not yet implemented", () => {57- expect(typeof showTotpCopyToast).toBe("undefined");58- });59-});src/drafts/AUTOFILL-TESTS/login-form.fixture.html+0 −32
@@ -1,32 +0,0 @@1-<!DOCTYPE html>2-<!--3- DRAFT FIXTURE — moves to tests/fixtures/login-form.html during integration.4- Referenced by content.real-page.e2e.spec.ts (T-E01–T-E10).5--->6-<html>7-<head><title>Login Fixture</title></head>8-<body>9- <!-- Standard login form -->10- <form id="login-form" action="/submit" method="post">11- <label for="username">Username</label>12- <input type="text" id="username" name="username" autocomplete="username" />13- <label for="password">Password</label>14- <input type="password" id="password" name="password" autocomplete="current-password" />15- <button type="submit" id="submit-btn">Log in</button>16- </form>17-18- <!-- Email-first form (separate) -->19- <form id="email-first-form" style="display:none">20- <input type="email" id="email" name="email" autocomplete="email" />21- <button type="submit">Next</button>22- </form>23-24- <!-- Signup form (separate) -->25- <form id="signup-form" style="display:none">26- <input type="text" id="new-username" name="username" />27- <input type="password" id="new-password" name="password" autocomplete="new-password" />28- <input type="password" id="confirm-password" name="confirm" autocomplete="new-password" />29- <button type="submit">Create account</button>30- </form>31-</body>32-</html>