_setup.ts

9.2 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). */53export 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}6364const 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});7475const sessionStore = new Map<string, unknown>();7677const 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};106107// 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;110111// 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.114if (typeof (globalThis as any).ResizeObserver === "undefined") {115  (globalThis as any).ResizeObserver = class {116    observe(): void {}117    unobserve(): void {}118    disconnect(): void {}119  };120}121122// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does123// not provide a writable clipboard, so define a spy-able one.124export const clipboardWrite = vi.fn(() => Promise.resolve());125if (!("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}136137/** Reset all captured state between tests. Does NOT clear registered listeners138 *  (those are installed once at module import and must survive across tests). */139export 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}155156// ── DOM helpers ──────────────────────────────────────────────────────────────157158const 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;169170/** 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. */173export function makeFieldVisible(el: HTMLElement): void {174  Object.defineProperty(el, "getBoundingClientRect", {175    configurable: true,176    value: () => ({ ...VISIBLE_RECT }),177  });178}179180/** Create a visible <input> with the given attributes appended to the body. */181export 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}191192/** Standard username + current-password login form. */193export 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}214215/** Email-first (identifier) form: single email field, no password. */216export 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}232233/** Signup form: username + new-password + confirm. */234export 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}257258/** Split-OTP form: `count` adjacent single-char numeric inputs. */259export 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}