// Shared test harness for the AUTOFILL-TESTS draft suite. // // Two responsibilities: // 1. SIDE EFFECT — installing a chrome extension API mock onto globalThis the // moment this module is evaluated. index.ts and inject.ts touch // `chrome.runtime.*` at module-evaluation time (inject.ts line 41 fires a // GET_SETTINGS call; index.ts registers a runtime.onMessage listener and // runs an async init IIFE). ES module imports are evaluated in source // order, so a test file that does `import "./_setup"` BEFORE // `import "./index"` guarantees chrome exists first. // 2. HELPERS — DOM factories and a jsdom visibility shim. jsdom reports a 0x0 // getBoundingClientRect for every element, which would make detect.ts's // isVisible() return false for everything. makeFieldVisible() patches the // rect the same way the existing content tests do. // // Nothing here mocks the functions under test — only the browser/extension // boundary. Tests drive the real exported functions with controlled DOM input. import { vi } from "vitest"; // ── Chrome message plumbing ────────────────────────────────────────────────── // A test can install its own responder to control what the background "replies" // for a given message, and inspect every message that was sent. export type MessageResponder = (msg: any) => any; let messageResponder: MessageResponder = (msg) => { // Sane defaults so module-load-time calls (GET_SETTINGS, GET_VAULT_STATUS) // resolve to a harmless shape instead of undefined. switch (msg?.type) { case "GET_SETTINGS": return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } }; case "GET_VAULT_STATUS": return { ok: true, data: { unlocked: false } }; default: return { ok: true, data: null }; } }; /** Messages sent via chrome.runtime.sendMessage, in order. */ export const sentMessages: any[] = []; /** Listeners registered via chrome.runtime.onMessage.addListener. */ export const messageListeners: Array< (msg: any, sender: any, sendResponse: (r?: any) => void) => unknown > = []; /** Override the reply for subsequent sendMessage calls. */ export function setMessageResponder(fn: MessageResponder): void { messageResponder = fn; } /** Deliver a message to every registered onMessage listener (e.g. VAULT_UNLOCKED). */ const sendMessage = vi.fn((msg: any, maybeCb?: (resp: any) => void) => { sentMessages.push(msg); const result = messageResponder(msg); const resolved = Promise.resolve(result); if (typeof maybeCb === "function") { resolved.then((r) => maybeCb(r)); return undefined as unknown as Promise; } return resolved; }); const sessionStore = new Map(); const chromeMock = { runtime: { id: "peach-test-extension-id", sendMessage, getURL: (path: string) => `chrome-extension://peach-test/${path}`, onMessage: { addListener: (fn: any) => messageListeners.push(fn), removeListener: (fn: any) => { const i = messageListeners.indexOf(fn); if (i >= 0) messageListeners.splice(i, 1); }, }, lastError: undefined as undefined | { message: string }, }, storage: { session: { get: (key: string) => Promise.resolve(sessionStore.has(key) ? { [key]: sessionStore.get(key) } : {}), set: (obj: Record) => { for (const [k, v] of Object.entries(obj)) sessionStore.set(k, v); return Promise.resolve(); }, }, local: { get: () => Promise.resolve({}), set: () => Promise.resolve(), }, }, }; // Install on globalThis so `chrome` is a resolvable free identifier in the // modules under test, exactly as it is inside a real content script. (globalThis as any).chrome = chromeMock; // jsdom ships no ResizeObserver; inject.ts's observeAnchorPosition and the // selector's height-animation both construct one at call time. A no-op stub is // enough — the tests assert on injected DOM, not on layout reflow. if (typeof (globalThis as any).ResizeObserver === "undefined") { (globalThis as any).ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} }; } // navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does // not provide a writable clipboard, so define a spy-able one. export const clipboardWrite = vi.fn(() => Promise.resolve()); if (!("clipboard" in navigator)) { Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: clipboardWrite }, }); } else { Object.defineProperty(navigator.clipboard, "writeText", { configurable: true, value: clipboardWrite, }); } /** Reset all captured state between tests. Does NOT clear registered listeners * (those are installed once at module import and must survive across tests). */ export function resetChromeMock(): void { sentMessages.length = 0; sessionStore.clear(); sendMessage.mockClear(); clipboardWrite.mockClear(); messageResponder = (msg) => { switch (msg?.type) { case "GET_SETTINGS": return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } }; case "GET_VAULT_STATUS": return { ok: true, data: { unlocked: false } }; default: return { ok: true, data: null }; } }; } // ── DOM helpers ────────────────────────────────────────────────────────────── const VISIBLE_RECT = { width: 160, height: 32, top: 10, left: 10, right: 170, bottom: 42, x: 10, y: 10, toJSON: () => ({}), } as const; /** Patch an element so detect.ts's isVisible() treats it as on-screen. * jsdom returns a 0x0 rect for everything; without this every field reads * as invisible and detection returns nothing. */ export function makeFieldVisible(el: HTMLElement): void { Object.defineProperty(el, "getBoundingClientRect", { configurable: true, value: () => ({ ...VISIBLE_RECT }), }); } /** Create a visible with the given attributes appended to the body. */ export function makeInput( attrs: Record = {}, parent: HTMLElement = document.body ): HTMLInputElement { const input = document.createElement("input"); for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v); parent.appendChild(input); makeFieldVisible(input); return input; } /** Standard username + current-password login form. */ export function createLoginForm(): { form: HTMLFormElement; username: HTMLInputElement; password: HTMLInputElement; } { const form = document.createElement("form"); document.body.appendChild(form); const username = makeInput( { type: "text", name: "username", autocomplete: "username" }, form ); const password = makeInput( { type: "password", name: "password", autocomplete: "current-password" }, form ); const button = document.createElement("button"); button.type = "submit"; button.textContent = "Log in"; form.appendChild(button); return { form, username, password }; } /** Email-first (identifier) form: single email field, no password. */ export function createEmailFirstForm(): { form: HTMLFormElement; email: HTMLInputElement; } { const form = document.createElement("form"); document.body.appendChild(form); const email = makeInput( { type: "email", name: "email", autocomplete: "email" }, form ); const button = document.createElement("button"); button.type = "submit"; button.textContent = "Next"; form.appendChild(button); return { form, email }; } /** Signup form: username + new-password + confirm. */ /** Split-OTP form: `count` adjacent single-char numeric inputs. */