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
3 files src/drafts/AUTOFILL-TESTS/_setup.ts+261 −0src/drafts/AUTOFILL-TESTS/_smoke.test.ts+25 −0vitest.drafts.config.ts+15 −0
src/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+}

view file

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

view file

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

view file