_setup.ts

8.8 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 "../../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.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// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does112// not provide a writable clipboard, so define a spy-able one.113export const clipboardWrite = vi.fn(() => Promise.resolve());114if (!("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}125126/** Reset all captured state between tests. Does NOT clear registered listeners127 *  (those are installed once at module import and must survive across tests). */128export 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}144145// ── DOM helpers ──────────────────────────────────────────────────────────────146147const 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;158159/** 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. */162export function makeFieldVisible(el: HTMLElement): void {163  Object.defineProperty(el, "getBoundingClientRect", {164    configurable: true,165    value: () => ({ ...VISIBLE_RECT }),166  });167}168169/** Create a visible <input> with the given attributes appended to the body. */170export 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}180181/** Standard username + current-password login form. */182export 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}203204/** Email-first (identifier) form: single email field, no password. */205export 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}221222/** Signup form: username + new-password + confirm. */223export 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}246247/** Split-OTP form: `count` adjacent single-char numeric inputs. */248export 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}