promptward

Plan 001: Add characterization tests for the content-script protection flow

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the “STOP conditions” section occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/README.md — unless a reviewer dispatched you and told you they maintain the index.

Drift check (run first): git diff --stat a6293e1..HEAD -- src/content.ts src/content/ tests/ If any in-scope file changed since this plan was written, compare the “Current state” excerpts against the live code before proceeding; on a mismatch, treat it as a STOP condition.

Status

Why this matters

PromptWard is a Chrome MV3 extension that intercepts the “send” action on AI chat sites (ChatGPT, Claude, etc.), redacts PII locally, and replays the send. The orchestration of that entire safety contract lives in src/content.ts (protectAndMaybeSubmit, handleFailure, replay), and it has zero test coverage — the 12 existing test files cover leaf utilities only. Three follow-up plans (002 IME fix, 003 retry-flow fix, 004 timeouts) will modify this exact code; without characterization tests first, they land blind on the most safety-critical file in the repo. This plan adds an integration-style test suite that drives the real event listeners through a stubbed chrome global, freezing today’s behavior — including behavior that later plans will deliberately change.

Current state

Commands you will need

Purpose Command Expected on success
Install npm install exit 0
Typecheck npx tsc -p tsconfig.json --noEmit exit 0, no output
All tests npm test 12+ files, all pass
One file npx vitest run tests/content-flow.test.ts all pass

Scope

In scope (the only files you should create/modify):

Out of scope (do NOT touch):

Git workflow

Steps

Step 1: Create the chrome stub helper

Create tests/helpers/chrome-stub.ts. It must be installed on globalThis before src/content.ts is imported (the module calls chrome.runtime.sendMessage at import time). Shape:

import { vi } from "vitest";
import type { ProtectTextResponse } from "../../src/shared/messages";

export type ChromeStub = {
  chrome: { runtime: { sendMessage: ReturnType<typeof vi.fn> } };
  /** Queue the response for the next PW_PROTECT_TEXT message. */
  setProtectResponse(response: ProtectTextResponse): void;
  sentMessages: Array<Record<string, unknown>>;
};

export function installChromeStub(): ChromeStub {
  const sentMessages: Array<Record<string, unknown>> = [];
  let protectResponse: ProtectTextResponse = {
    ok: true, safeText: "", changed: false, placeholders: [], durationMs: 1
  };
  const sendMessage = vi.fn(async (message: Record<string, unknown>) => {
    sentMessages.push(message);
    switch (message.type) {
      case "PW_GET_DEBUG_SETTINGS": return { rawDiagnosticsEnabled: false };
      case "PW_DEBUG_LOG": return { ok: true };
      case "PW_PROTECT_TEXT": return protectResponse;
      default: return { ok: true };
    }
  });
  const stub = { runtime: { sendMessage } };
  vi.stubGlobal("chrome", stub);
  return {
    chrome: stub,
    setProtectResponse: (r) => { protectResponse = r; },
    sentMessages
  };
}

(Adjust typing as needed to satisfy strict TS — as unknown as typeof chrome is acceptable here; this is a test double, and the repo has no lint rule against it in tests.)

Verify: npx tsc -p tsconfig.json --noEmit → exit 0.

Step 2: Create the test file skeleton with one happy-path test

Create tests/content-flow.test.ts. Structure:

import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { installChromeStub, type ChromeStub } from "./helpers/chrome-stub";
import { AUTO_CONFIRM_SECONDS } from "../src/content/review-modal";

let stub: ChromeStub;

beforeAll(async () => {
  stub = installChromeStub();
  await import("../src/content"); // installs capture listeners once
});

Because the module import (and its listeners) persist for the whole file, reset per test in afterEach: clear document.body, remove any promptward-review hosts, vi.useRealTimers(), and reinstall the protect response to a default. Do NOT try to re-import the module.

DOM fixture helper (used by most tests):

function mountComposer(text: string): { textarea: HTMLTextAreaElement; button: HTMLButtonElement; form: HTMLFormElement } {
  document.body.innerHTML = `
    <form>
      <textarea>${text}</textarea>
      <button type="button" aria-label="Send prompt">Send</button>
    </form>`;
  // findEditorIn prefers document.activeElement; focus the textarea like a real user
  const textarea = document.querySelector("textarea")!;
  textarea.focus();
  return { textarea, button: document.querySelector("button")!, form: document.querySelector("form")! };
}

Note the button is type="button" deliberately — a type="submit" button would ALSO fire the form submit capture listener and double-drive the flow. Clicks must be dispatched as new MouseEvent("click", { bubbles: true, cancelable: true }) on the button.

First test — unchanged text replays without a modal:

Verify: npx vitest run tests/content-flow.test.ts → 1 test passes.

Step 3: Add the review-modal decision tests

Use vi.useFakeTimers() (pattern: tests/review-modal.test.ts). With fake timers, drive async progress with await vi.advanceTimersByTimeAsync(0) after dispatching, since the flow awaits several promises before the modal mounts.

  1. Changed text shows the modal; idle auto-confirm sends redacted: protect response changed: true, safeText: "hi [PERSON_1]". Dispatch click → advance timers by 0 until document.querySelector("promptward-review") exists → advance by AUTO_CONFIRM_SECONDS * 1000 → assert textarea value is now "hi [PERSON_1]" and a second (unprevented) click occurred.
  2. Cancel restores original and does not send: click the [data-action='cancel'] button inside the shadow root → assert textarea value is the original and only one click event total (no replay).
  3. Send original: click [data-action='original'] → textarea value is the original AND a replay click occurred.
  4. Protect failure shows error modal, no auto-send: protect response { ok: false, ... error: "boom" } → modal appears with no [data-action='original'] button (error variant), advance AUTO_CONFIRM_SECONDS * 2 * 1000 → no replay click ever happens. Then click [data-action='cancel'] to clean up.

Verify: npx vitest run tests/content-flow.test.ts → 5 tests pass.

Step 4: Add the guard-behavior tests

  1. Fail-closed when redacted text does not stick: mount the composer, then override the textarea’s value setter to a no-op so setText readback fails:

    Object.defineProperty(textarea, "value", {
      get: () => "original text", set: () => { /* editor rejects writes */ }
    });
    

    Protect response changed: true. Auto-confirm the modal. Assert: NO replay click occurs, and a second promptward-review modal appears whose shadow contains the text "couldn't confirm the redacted text". (This is the fail-closed guard at src/content.ts:200-211.)

  2. Empty editor is ignored: mount composer with "" → dispatch click → assert defaultPrevented === false on that click and no PW_PROTECT_TEXT message in stub.sentMessages.
  3. In-flight duplicate send is swallowed: make the protect response hang (a promise you resolve manually). Dispatch click twice; assert the second click was defaultPrevented and only ONE PW_PROTECT_TEXT message was sent. Then resolve the pending response (as changed: false) and let the flow finish.
  4. Enter key in editor triggers the flow: focus the textarea, dispatch new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) on it, protect response changed: false → assert the keydown was defaultPrevented and a form submit event was fired (attach a submit listener that calls preventDefault() and records — jsdom supports requestSubmit, see tests/dom-adapter.test.ts:36-49 for the pattern).

Verify: npx vitest run tests/content-flow.test.ts → 9 tests pass.

Step 5: Characterize today’s retry behavior (will change in plan 003)

Add a describe("error-modal retry (current behavior — see plans/003)") block:

Verify: npx vitest run tests/content-flow.test.ts → 10 tests pass.

Step 6: Full suite + typecheck

Verify: npm test → all files pass (12 existing + 1 new). Verify: npx tsc -p tsconfig.json --noEmit → exit 0.

Test plan

This plan IS the test plan; the cases are enumerated in steps 2–5. Model the fake-timer handling on tests/review-modal.test.ts and the jsdom form/submit handling on tests/dom-adapter.test.ts.

Done criteria

STOP conditions

Stop and report back (do not improvise) if:

Maintenance notes