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
2 files src/content/_setup.ts+272 −0vitest.drafts.config.ts+0 −15
src/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+}

view file

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

view file at the parent