7f3e1f92d029
chore: add content/_setup.ts shared test harness (chrome mock + DOM factories + jsdom rect/ResizeObserver shims) and trash throwaway vitest.drafts.config.ts (v3.9.9)
- time
- 2026-06-12 13:43 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- autofill tests integration
- parent
- f29f759b9d38
↓ peach-browser-7f3e1f92.tar.gzatlas at this save
files changed2 files
src/content/_setup.ts+272 −0vitest.drafts.config.ts+0 −15src/content/_setup.ts+272 −0
@@ -0,0 +1,272 @@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 "./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+}vitest.drafts.config.ts+0 −15
@@ -1,15 +0,0 @@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-});