codex-pdf-restore.spec.ts

7.5 KB · raw

1/**2 * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test.3 *4 * Runs the real importCodexPDF pipeline against the user's actual Codex PDF5 * inside a live Chrome+extension context. This is the only reliable way to6 * exercise the full chain (pdf.js → canvas render → cell crop → Aztec decode7 * → ChunkBuffer → reconstructAndVerify) because every step needs a real DOM.8 *9 * Run: npx playwright test tests/codex-pdf-restore.spec.ts10 * (build first: npm run build && no additional flags needed)11 *12 * FILE INJECTION STRATEGY13 * -----------------------14 * We navigate with ?codex=restore (not ?codex=pdf). Both URLs route to15 * CodexRestoreView, but only ?codex=pdf sets autoPickPdf=true, which fires16 * a 50ms-delayed pdfFileInput.click() that opens the OS file dialog. The OS17 * dialog steals focus, which in Chrome's extension popup context terminates18 * the page. By using ?codex=restore we skip the auto-click entirely.19 *20 * We then use Playwright's CDP-based setInputFiles() on the hidden file input,21 * which bypasses the OS dialog and directly dispatches the change event,22 * triggering handlePDFSelected() inside the extension.23 */2425import { test, expect } from "@playwright/test";26import type { BrowserContext, Page } from "@playwright/test";27import { fileURLToPath } from "url";28import { dirname, join } from "path";29import os from "os";30import fs from "fs/promises";31import { existsSync } from "fs";3233const __filename = fileURLToPath(import.meta.url);34const __dirname = dirname(__filename);35const EXTENSION_PATH = join(__dirname, "..", "dist");3637const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-08-20.pdf";38const CURRENT_GEN_PDF_PATH = join(__dirname, "fixtures", "peach-codex-current-gen.pdf");3940async function makeContext(): Promise<BrowserContext> {41  const { chromium } = await import("playwright");42  const userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`);43  await fs.mkdir(userDataDir, { recursive: true });44  return chromium.launchPersistentContext(userDataDir, {45    headless: false,46    args: [47      `--load-extension=${EXTENSION_PATH}`,48      `--disable-extensions-except=${EXTENSION_PATH}`,49    ],50  });51}5253async function getExtId(ctx: BrowserContext): Promise<string> {54  const maxWait = 15_000;55  const start = Date.now();56  let sw = ctx.serviceWorkers()[0];57  while (!sw && Date.now() - start < maxWait) {58    await new Promise((r) => setTimeout(r, 500));59    sw = ctx.serviceWorkers()[0];60  }61  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);62  return new URL(sw.url()).host;63}6465async function openCodexRestorePage(66  ctx: BrowserContext,67  extId: string,68  search = "codex=restore",69): Promise<Page> {70  const page = await ctx.newPage();7172  // Capture extension console output for diagnostics.73  page.on("console", (msg) => {74    const text = msg.text();75    if (msg.type() === "error" || text.startsWith("[codex]")) {76      console.log(`  [browser:${msg.type()}] ${text}`);77    }78  });7980  // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.81  // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden82  // file input, opening the OS dialog which steals focus and kills the page.83  await page.goto(`chrome-extension://${extId}/src/popup/index.html?${search}`);8485  // The hidden file input is mounted alongside the restore view. Wait for it86  // to be attached before handing back the page.87  await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });8889  return page;90}9192test.describe("Codex PDF restore", () => {93  let ctx: BrowserContext;9495  test.beforeEach(async () => {96    ctx = await makeContext();97  });9899  test.afterEach(async () => {100    await ctx.close();101  });102103  test("imports all chunks from the real Codex PDF", async () => {104    // The full import of a large Codex (21+ pages) including scale-detection105    // renders can take up to 3 minutes. Set an explicit test timeout.106    test.setTimeout(180_000);107108    const extId = await getExtId(ctx);109    const page = await openCodexRestorePage(ctx, extId);110111    // CDP-based setInputFiles bypasses the OS dialog and dispatches the112    // change event directly, firing handlePDFSelected inside the extension.113    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);114115    // After handlePDFSelected runs importCodexPDF:116    //   success  → phase="ready"  → body contains "master password" + "X / Y codes"117    //   missing  → body contains pdfError with "codes" + camera starts118    //   failure  → body contains "failed to read"119    //   empty    → body contains "no Codex codes found"120    await page.waitForFunction(121      () => {122        const body = document.body.textContent ?? "";123        return (124          body.includes("codes") ||125          body.includes("master password") ||126          body.includes("no Codex codes found") ||127          body.includes("failed to read")128        );129      },130      { timeout: 150_000, polling: 1_000 },131    );132133    const bodyText = await page.evaluate(() => document.body.textContent ?? "");134    console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400));135136    expect(bodyText).not.toContain("no Codex codes found");137    expect(bodyText).not.toContain("failed to read");138139    const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false);140    if (passphraseVisible) {141      console.log("✓ Buffer is ready — passphrase prompt visible.");142    }143    expect(bodyText).toMatch(/master password|codes/);144  });145146  test("decodes at least one Aztec envelope from page 1", async () => {147    test.setTimeout(120_000);148149    const extId = await getExtId(ctx);150    const page = await openCodexRestorePage(ctx, extId);151152    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);153154    await page.waitForFunction(155      () => {156        const body = document.body.textContent ?? "";157        return (158          body.includes("codes") ||159          body.includes("master password") ||160          body.includes("no Codex codes found") ||161          body.includes("failed to read")162        );163      },164      { timeout: 90_000, polling: 500 },165    );166167    const bodyText = await page.evaluate(() => document.body.textContent ?? "");168    console.log("Body snippet:", bodyText.slice(0, 300));169170    expect(bodyText).not.toContain("no Codex codes found");171    expect(bodyText).not.toContain("failed to read");172  });173174  test("imports the current-generation Codex PDF when the fixture is present", async () => {175    test.setTimeout(180_000);176    if (!existsSync(CURRENT_GEN_PDF_PATH)) {177      test.skip();178      return;179    }180181    const extId = await getExtId(ctx);182    const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1");183    await page.locator('input[type="file"]').setInputFiles(CURRENT_GEN_PDF_PATH);184185    await page.waitForFunction(186      () => {187        const body = document.body.textContent ?? "";188        return (189          body.includes("master password") ||190          body.includes("pdf import needs another pass") ||191          body.includes("failed to read")192        );193      },194      { timeout: 150_000, polling: 1_000 },195    );196197    const metrics = await page.evaluate(() => (window as any).__codexImportMetrics ?? null);198    expect(metrics).not.toBeNull();199    expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98);200    expect(metrics.shortStripeCount).toBe(0);201    expect(metrics.perStripeDecoded.length).toBeGreaterThan(0);202203    const bodyText = await page.evaluate(() => document.body.textContent ?? "");204    expect(bodyText).not.toContain("failed to read");205  });206});