/** * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test. * * Runs the real importCodexPDF pipeline against the user's actual Codex PDF * inside a live Chrome+extension context. This is the only reliable way to * exercise the full chain (pdf.js → canvas render → cell crop → Aztec decode * → ChunkBuffer → reconstructAndVerify) because every step needs a real DOM. * * Run: npx playwright test tests/codex-pdf-restore.spec.ts * (build first: npm run build && no additional flags needed) * * FILE INJECTION STRATEGY * ----------------------- * We navigate with ?codex=restore (not ?codex=pdf). Both URLs route to * CodexRestoreView, but only ?codex=pdf sets autoPickPdf=true, which fires * a 50ms-delayed pdfFileInput.click() that opens the OS file dialog. The OS * dialog steals focus, which in Chrome's extension popup context terminates * the page. By using ?codex=restore we skip the auto-click entirely. * * We then use Playwright's CDP-based setInputFiles() on the hidden file input, * which bypasses the OS dialog and directly dispatches the change event, * triggering handlePDFSelected() inside the extension. */ import { chromium, test, expect } from "@playwright/test"; import type { BrowserContext, Page } from "@playwright/test"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import os from "os"; import fs from "fs/promises"; import { existsSync } from "fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const EXTENSION_PATH = join(__dirname, "..", "dist-chrome"); const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-08-20.pdf"; const CURRENT_GEN_PDF_PATH = join(__dirname, "fixtures", "peach-codex-current-gen.pdf"); async function makeContext(): Promise { const userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`); await fs.mkdir(userDataDir, { recursive: true }); return chromium.launchPersistentContext(userDataDir, { headless: false, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, ], }); } async function getExtId(ctx: BrowserContext): Promise { const maxWait = 15_000; const start = Date.now(); let sw = ctx.serviceWorkers()[0]; while (!sw && Date.now() - start < maxWait) { await new Promise((r) => setTimeout(r, 500)); sw = ctx.serviceWorkers()[0]; } if (!sw) throw new Error(`No service worker after ${maxWait}ms`); return new URL(sw.url()).host; } async function openCodexRestorePage( ctx: BrowserContext, extId: string, search = "codex=restore", ): Promise { const page = await ctx.newPage(); // Capture extension console output for diagnostics. page.on("console", (msg) => { const text = msg.text(); if (msg.type() === "error" || text.startsWith("[codex]")) { console.log(` [browser:${msg.type()}] ${text}`); } }); // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true. // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden // file input, opening the OS dialog which steals focus and kills the page. await page.goto(`chrome-extension://${extId}/src/popup/index.html?${search}`); // The hidden file input is mounted alongside the restore view. Wait for it // to be attached before handing back the page. await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 }); return page; } test.describe("Codex PDF restore", () => { let ctx: BrowserContext; test.beforeEach(async () => { ctx = await makeContext(); }); test.afterEach(async () => { await ctx.close(); }); test("imports all chunks from the real Codex PDF", async () => { // The full import of a large Codex (21+ pages) including scale-detection // renders can take up to 3 minutes. Set an explicit test timeout. test.setTimeout(180_000); const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId); // CDP-based setInputFiles bypasses the OS dialog and dispatches the // change event directly, firing handlePDFSelected inside the extension. await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH); // After handlePDFSelected runs importCodexPDF: // success → phase="ready" → body contains "master password" + "X / Y codes" // missing → body contains pdfError with "codes" + camera starts // failure → body contains "failed to read" // empty → body contains "no Codex codes found" await page.waitForFunction( () => { const body = document.body.textContent ?? ""; return ( body.includes("codes") || body.includes("master password") || body.includes("no Codex codes found") || body.includes("failed to read") ); }, { timeout: 150_000, polling: 1_000 }, ); const bodyText = await page.evaluate(() => document.body.textContent ?? ""); console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400)); expect(bodyText).not.toContain("no Codex codes found"); expect(bodyText).not.toContain("failed to read"); const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false); if (passphraseVisible) { console.log("✓ Buffer is ready — passphrase prompt visible."); } expect(bodyText).toMatch(/master password|codes/); }); test("decodes at least one Aztec envelope from page 1", async () => { test.setTimeout(120_000); const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId); await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH); await page.waitForFunction( () => { const body = document.body.textContent ?? ""; return ( body.includes("codes") || body.includes("master password") || body.includes("no Codex codes found") || body.includes("failed to read") ); }, { timeout: 90_000, polling: 500 }, ); const bodyText = await page.evaluate(() => document.body.textContent ?? ""); console.log("Body snippet:", bodyText.slice(0, 300)); expect(bodyText).not.toContain("no Codex codes found"); expect(bodyText).not.toContain("failed to read"); }); test("imports the current-generation Codex PDF when the fixture is present", async () => { test.setTimeout(180_000); if (!existsSync(CURRENT_GEN_PDF_PATH)) { test.skip(); return; } const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1"); await page.locator('input[type="file"]').setInputFiles(CURRENT_GEN_PDF_PATH); await page.waitForFunction( () => { const body = document.body.textContent ?? ""; return ( body.includes("master password") || body.includes("pdf import needs another pass") || body.includes("failed to read") ); }, { timeout: 150_000, polling: 1_000 }, ); const metrics = await page.evaluate(() => (window as any).__codexImportMetrics ?? null); expect(metrics).not.toBeNull(); expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98); expect(metrics.shortStripeCount).toBe(0); expect(metrics.perStripeDecoded.length).toBeGreaterThan(0); const bodyText = await page.evaluate(() => document.body.textContent ?? ""); expect(bodyText).not.toContain("failed to read"); }); });