1e03ef874559
add AUTOFILL-TESTS draft harness: _setup.ts chrome mock + DOM factories + jsdom visibility shim, throwaway runner config, passing smoke test proving index.ts/inject.ts import under jsdom
- time
- 2026-06-12 12:30 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- autofill-tests draft suite
- parent
- 0cbcb74850d8
↓ peach-browser-1e03ef87.tar.gzatlas at this save
files changed3 files
src/drafts/AUTOFILL-TESTS/_setup.ts+261 −0src/drafts/AUTOFILL-TESTS/_smoke.test.ts+25 −0vitest.drafts.config.ts+15 −0src/drafts/AUTOFILL-TESTS/_setup.ts+261 −0
@@ -0,0 +1,261 @@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+// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does112+// not provide a writable clipboard, so define a spy-able one.113+export const clipboardWrite = vi.fn(() => Promise.resolve());114+if (!("clipboard" in navigator)) {115+ Object.defineProperty(navigator, "clipboard", {116+ configurable: true,117+ value: { writeText: clipboardWrite },118+ });119+} else {120+ Object.defineProperty(navigator.clipboard, "writeText", {121+ configurable: true,122+ value: clipboardWrite,123+ });124+}125+126+/** Reset all captured state between tests. Does NOT clear registered listeners127+ * (those are installed once at module import and must survive across tests). */128+export function resetChromeMock(): void {129+ sentMessages.length = 0;130+ sessionStore.clear();131+ sendMessage.mockClear();132+ clipboardWrite.mockClear();133+ messageResponder = (msg) => {134+ switch (msg?.type) {135+ case "GET_SETTINGS":136+ return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };137+ case "GET_VAULT_STATUS":138+ return { ok: true, data: { unlocked: false } };139+ default:140+ return { ok: true, data: null };141+ }142+ };143+}144+145+// ── DOM helpers ──────────────────────────────────────────────────────────────146+147+const VISIBLE_RECT = {148+ width: 160,149+ height: 32,150+ top: 10,151+ left: 10,152+ right: 170,153+ bottom: 42,154+ x: 10,155+ y: 10,156+ toJSON: () => ({}),157+} as const;158+159+/** Patch an element so detect.ts's isVisible() treats it as on-screen.160+ * jsdom returns a 0x0 rect for everything; without this every field reads161+ * as invisible and detection returns nothing. */162+export function makeFieldVisible(el: HTMLElement): void {163+ Object.defineProperty(el, "getBoundingClientRect", {164+ configurable: true,165+ value: () => ({ ...VISIBLE_RECT }),166+ });167+}168+169+/** Create a visible <input> with the given attributes appended to the body. */170+export function makeInput(171+ attrs: Record<string, string> = {},172+ parent: HTMLElement = document.body173+): HTMLInputElement {174+ const input = document.createElement("input");175+ for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);176+ parent.appendChild(input);177+ makeFieldVisible(input);178+ return input;179+}180+181+/** Standard username + current-password login form. */182+export function createLoginForm(): {183+ form: HTMLFormElement;184+ username: HTMLInputElement;185+ password: HTMLInputElement;186+} {187+ const form = document.createElement("form");188+ document.body.appendChild(form);189+ const username = makeInput(190+ { type: "text", name: "username", autocomplete: "username" },191+ form192+ );193+ const password = makeInput(194+ { type: "password", name: "password", autocomplete: "current-password" },195+ form196+ );197+ const button = document.createElement("button");198+ button.type = "submit";199+ button.textContent = "Log in";200+ form.appendChild(button);201+ return { form, username, password };202+}203+204+/** Email-first (identifier) form: single email field, no password. */205+export function createEmailFirstForm(): {206+ form: HTMLFormElement;207+ email: HTMLInputElement;208+} {209+ const form = document.createElement("form");210+ document.body.appendChild(form);211+ const email = makeInput(212+ { type: "email", name: "email", autocomplete: "email" },213+ form214+ );215+ const button = document.createElement("button");216+ button.type = "submit";217+ button.textContent = "Next";218+ form.appendChild(button);219+ return { form, email };220+}221+222+/** Signup form: username + new-password + confirm. */223+export function createSignupForm(): {224+ form: HTMLFormElement;225+ username: HTMLInputElement;226+ password: HTMLInputElement;227+ confirm: HTMLInputElement;228+} {229+ const form = document.createElement("form");230+ document.body.appendChild(form);231+ const username = makeInput({ type: "text", name: "new-username" }, form);232+ const password = makeInput(233+ { type: "password", name: "new-password", autocomplete: "new-password" },234+ form235+ );236+ const confirm = makeInput(237+ { type: "password", name: "confirm-password", autocomplete: "new-password" },238+ form239+ );240+ const button = document.createElement("button");241+ button.type = "submit";242+ button.textContent = "Create account";243+ form.appendChild(button);244+ return { form, username, password, confirm };245+}246+247+/** Split-OTP form: `count` adjacent single-char numeric inputs. */248+export function createSplitOtpForm(count = 6): {249+ form: HTMLFormElement;250+ boxes: HTMLInputElement[];251+} {252+ const form = document.createElement("form");253+ document.body.appendChild(form);254+ const boxes: HTMLInputElement[] = [];255+ for (let i = 0; i < count; i++) {256+ boxes.push(257+ makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, form)258+ );259+ }260+ return { form, boxes };261+}src/drafts/AUTOFILL-TESTS/_smoke.test.ts+25 −0
@@ -0,0 +1,25 @@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+});vitest.drafts.config.ts+15 −0
@@ -0,0 +1,15 @@1+// THROWAWAY runner config — verifies the AUTOFILL-TESTS drafts during authoring.2+// Not part of the committed suite; trashed before each koh save. The real3+// integration step (documented in INTEGRATION-CONTRACT.md) is to either move4+// these files into src/content/ or drop the src/drafts/** exclude.5+import { defineConfig } from "vitest/config";6+import solidPlugin from "vite-plugin-solid";7+8+export default defineConfig({9+ plugins: [solidPlugin()],10+ test: {11+ include: ["src/drafts/AUTOFILL-TESTS/**/*.test.ts"],12+ exclude: [],13+ environment: "node",14+ },15+});