_setup.ts

7.7 KB · raw

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.1819import { vi } from "vitest";2021// ── 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.2425export type MessageResponder = (msg: any) => any;2627let 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};3940/** Messages sent via chrome.runtime.sendMessage, in order. */41export const sentMessages: any[] = [];42/** Listeners registered via chrome.runtime.onMessage.addListener. */43export const messageListeners: Array<44  (msg: any, sender: any, sendResponse: (r?: any) => void) => unknown45> = [];4647/** Override the reply for subsequent sendMessage calls. */48export function setMessageResponder(fn: MessageResponder): void {49  messageResponder = fn;50}5152/** Deliver a message to every registered onMessage listener (e.g. VAULT_UNLOCKED). */5354const sendMessage = vi.fn((msg: any, maybeCb?: (resp: any) => void) => {55  sentMessages.push(msg);56  const result = messageResponder(msg);57  const resolved = Promise.resolve(result);58  if (typeof maybeCb === "function") {59    resolved.then((r) => maybeCb(r));60    return undefined as unknown as Promise<any>;61  }62  return resolved;63});6465const sessionStore = new Map<string, unknown>();6667const chromeMock = {68  runtime: {69    id: "peach-test-extension-id",70    sendMessage,71    getURL: (path: string) => `chrome-extension://peach-test/${path}`,72    onMessage: {73      addListener: (fn: any) => messageListeners.push(fn),74      removeListener: (fn: any) => {75        const i = messageListeners.indexOf(fn);76        if (i >= 0) messageListeners.splice(i, 1);77      },78    },79    lastError: undefined as undefined | { message: string },80  },81  storage: {82    session: {83      get: (key: string) =>84        Promise.resolve(sessionStore.has(key) ? { [key]: sessionStore.get(key) } : {}),85      set: (obj: Record<string, unknown>) => {86        for (const [k, v] of Object.entries(obj)) sessionStore.set(k, v);87        return Promise.resolve();88      },89    },90    local: {91      get: () => Promise.resolve({}),92      set: () => Promise.resolve(),93    },94  },95};9697// Install on globalThis so `chrome` is a resolvable free identifier in the98// modules under test, exactly as it is inside a real content script.99(globalThis as any).chrome = chromeMock;100101// jsdom ships no ResizeObserver; inject.ts's observeAnchorPosition and the102// selector's height-animation both construct one at call time. A no-op stub is103// enough — the tests assert on injected DOM, not on layout reflow.104if (typeof (globalThis as any).ResizeObserver === "undefined") {105  (globalThis as any).ResizeObserver = class {106    observe(): void {}107    unobserve(): void {}108    disconnect(): void {}109  };110}111112// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does113// not provide a writable clipboard, so define a spy-able one.114export const clipboardWrite = vi.fn(() => Promise.resolve());115if (!("clipboard" in navigator)) {116  Object.defineProperty(navigator, "clipboard", {117    configurable: true,118    value: { writeText: clipboardWrite },119  });120} else {121  Object.defineProperty(navigator.clipboard, "writeText", {122    configurable: true,123    value: clipboardWrite,124  });125}126127/** Reset all captured state between tests. Does NOT clear registered listeners128 *  (those are installed once at module import and must survive across tests). */129export function resetChromeMock(): void {130  sentMessages.length = 0;131  sessionStore.clear();132  sendMessage.mockClear();133  clipboardWrite.mockClear();134  messageResponder = (msg) => {135    switch (msg?.type) {136      case "GET_SETTINGS":137        return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };138      case "GET_VAULT_STATUS":139        return { ok: true, data: { unlocked: false } };140      default:141        return { ok: true, data: null };142    }143  };144}145146// ── DOM helpers ──────────────────────────────────────────────────────────────147148const VISIBLE_RECT = {149  width: 160,150  height: 32,151  top: 10,152  left: 10,153  right: 170,154  bottom: 42,155  x: 10,156  y: 10,157  toJSON: () => ({}),158} as const;159160/** Patch an element so detect.ts's isVisible() treats it as on-screen.161 *  jsdom returns a 0x0 rect for everything; without this every field reads162 *  as invisible and detection returns nothing. */163export function makeFieldVisible(el: HTMLElement): void {164  Object.defineProperty(el, "getBoundingClientRect", {165    configurable: true,166    value: () => ({ ...VISIBLE_RECT }),167  });168}169170/** Create a visible <input> with the given attributes appended to the body. */171export function makeInput(172  attrs: Record<string, string> = {},173  parent: HTMLElement = document.body174): HTMLInputElement {175  const input = document.createElement("input");176  for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);177  parent.appendChild(input);178  makeFieldVisible(input);179  return input;180}181182/** Standard username + current-password login form. */183export function createLoginForm(): {184  form: HTMLFormElement;185  username: HTMLInputElement;186  password: HTMLInputElement;187} {188  const form = document.createElement("form");189  document.body.appendChild(form);190  const username = makeInput(191    { type: "text", name: "username", autocomplete: "username" },192    form193  );194  const password = makeInput(195    { type: "password", name: "password", autocomplete: "current-password" },196    form197  );198  const button = document.createElement("button");199  button.type = "submit";200  button.textContent = "Log in";201  form.appendChild(button);202  return { form, username, password };203}204205/** Email-first (identifier) form: single email field, no password. */206export function createEmailFirstForm(): {207  form: HTMLFormElement;208  email: HTMLInputElement;209} {210  const form = document.createElement("form");211  document.body.appendChild(form);212  const email = makeInput(213    { type: "email", name: "email", autocomplete: "email" },214    form215  );216  const button = document.createElement("button");217  button.type = "submit";218  button.textContent = "Next";219  form.appendChild(button);220  return { form, email };221}222223/** Signup form: username + new-password + confirm. */224225/** Split-OTP form: `count` adjacent single-char numeric inputs. */