codex-pdf-restore.spec.ts

7.4 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 { chromium, 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-chrome");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 userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`);42  await fs.mkdir(userDataDir, { recursive: true });43  return chromium.launchPersistentContext(userDataDir, {44    headless: false,45    args: [46      `--load-extension=${EXTENSION_PATH}`,47      `--disable-extensions-except=${EXTENSION_PATH}`,48    ],49  });50}5152async function getExtId(ctx: BrowserContext): Promise<string> {53  const maxWait = 15_000;54  const start = Date.now();55  let sw = ctx.serviceWorkers()[0];56  while (!sw && Date.now() - start < maxWait) {57    await new Promise((r) => setTimeout(r, 500));58    sw = ctx.serviceWorkers()[0];59  }60  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);61  return new URL(sw.url()).host;62}6364async function openCodexRestorePage(65  ctx: BrowserContext,66  extId: string,67  search = "codex=restore",68): Promise<Page> {69  const page = await ctx.newPage();7071  // Capture extension console output for diagnostics.72  page.on("console", (msg) => {73    const text = msg.text();74    if (msg.type() === "error" || text.startsWith("[codex]")) {75      console.log(`  [browser:${msg.type()}] ${text}`);76    }77  });7879  // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.80  // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden81  // file input, opening the OS dialog which steals focus and kills the page.82  await page.goto(`chrome-extension://${extId}/src/popup/index.html?${search}`);8384  // The hidden file input is mounted alongside the restore view. Wait for it85  // to be attached before handing back the page.86  await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });8788  return page;89}9091test.describe("Codex PDF restore", () => {92  let ctx: BrowserContext;9394  test.beforeEach(async () => {95    ctx = await makeContext();96  });9798  test.afterEach(async () => {99    await ctx.close();100  });101102  test("imports all chunks from the real Codex PDF", async () => {103    // The full import of a large Codex (21+ pages) including scale-detection104    // renders can take up to 3 minutes. Set an explicit test timeout.105    test.setTimeout(180_000);106107    const extId = await getExtId(ctx);108    const page = await openCodexRestorePage(ctx, extId);109110    // CDP-based setInputFiles bypasses the OS dialog and dispatches the111    // change event directly, firing handlePDFSelected inside the extension.112    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);113114    // After handlePDFSelected runs importCodexPDF:115    //   success  → phase="ready"  → body contains "master password" + "X / Y codes"116    //   missing  → body contains pdfError with "codes" + camera starts117    //   failure  → body contains "failed to read"118    //   empty    → body contains "no Codex codes found"119    await page.waitForFunction(120      () => {121        const body = document.body.textContent ?? "";122        return (123          body.includes("codes") ||124          body.includes("master password") ||125          body.includes("no Codex codes found") ||126          body.includes("failed to read")127        );128      },129      { timeout: 150_000, polling: 1_000 },130    );131132    const bodyText = await page.evaluate(() => document.body.textContent ?? "");133    console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400));134135    expect(bodyText).not.toContain("no Codex codes found");136    expect(bodyText).not.toContain("failed to read");137138    const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false);139    if (passphraseVisible) {140      console.log("✓ Buffer is ready — passphrase prompt visible.");141    }142    expect(bodyText).toMatch(/master password|codes/);143  });144145  test("decodes at least one Aztec envelope from page 1", async () => {146    test.setTimeout(120_000);147148    const extId = await getExtId(ctx);149    const page = await openCodexRestorePage(ctx, extId);150151    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);152153    await page.waitForFunction(154      () => {155        const body = document.body.textContent ?? "";156        return (157          body.includes("codes") ||158          body.includes("master password") ||159          body.includes("no Codex codes found") ||160          body.includes("failed to read")161        );162      },163      { timeout: 90_000, polling: 500 },164    );165166    const bodyText = await page.evaluate(() => document.body.textContent ?? "");167    console.log("Body snippet:", bodyText.slice(0, 300));168169    expect(bodyText).not.toContain("no Codex codes found");170    expect(bodyText).not.toContain("failed to read");171  });172173  test("imports the current-generation Codex PDF when the fixture is present", async () => {174    test.setTimeout(180_000);175    if (!existsSync(CURRENT_GEN_PDF_PATH)) {176      test.skip();177      return;178    }179180    const extId = await getExtId(ctx);181    const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1");182    await page.locator('input[type="file"]').setInputFiles(CURRENT_GEN_PDF_PATH);183184    await page.waitForFunction(185      () => {186        const body = document.body.textContent ?? "";187        return (188          body.includes("master password") ||189          body.includes("pdf import needs another pass") ||190          body.includes("failed to read")191        );192      },193      { timeout: 150_000, polling: 1_000 },194    );195196    const metrics = await page.evaluate(() => (window as any).__codexImportMetrics ?? null);197    expect(metrics).not.toBeNull();198    expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98);199    expect(metrics.shortStripeCount).toBe(0);200    expect(metrics.perStripeDecoded.length).toBeGreaterThan(0);201202    const bodyText = await page.evaluate(() => document.body.textContent ?? "");203    expect(bodyText).not.toContain("failed to read");204  });205});