/** * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test. * * Builds a Codex PDF from a seeded encrypted vault, then runs the real * importCodexPDF pipeline inside a fresh 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 { test, expect } from "@playwright/test"; import type { BrowserContext, Page } from "@playwright/test"; import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { EXTENSION_PATH, makeContext, setupVault, VAULT_PASSWORD, } from "./helpers/vault"; const SEEDED_ENTRY_COUNT = 250; const SEEDED_ENTRY_TITLE = "Seeded Codex recovery entry"; async function findCodexGeneratorBundle(): Promise { const assetsPath = join(EXTENSION_PATH, "assets"); const viewBundle = (await readdir(assetsPath)).find((name) => name.startsWith("CodexGenerateView-"), ); if (!viewBundle) throw new Error("built Codex generation view was not found"); const source = await readFile(join(assetsPath, viewBundle), "utf8"); const generatorBundle = source.match(/\.\/((?:generate)-[^"'`]+\.js)/)?.[1]; if (!generatorBundle) throw new Error("built Codex generator bundle was not found"); return generatorBundle; } async function createSeededCodexFile(): Promise<{ name: string; mimeType: string; buffer: Buffer; }> { const seedContext = await makeContext("peach-codex-seed"); try { const { popup } = await setupVault(seedContext); const seeded = await popup.evaluate(async ({ count, sentinelTitle }) => { const entries = Array.from({ length: count }, (_, index) => { const title = index === 0 ? sentinelTitle : `Seeded Codex entry ${index + 1}`; switch (index % 4) { case 1: return { type: "note", title, notes: `Recovery note ${index + 1}: ${(index * 2654435761).toString(36)}`, }; case 2: return { type: "card", title, cardholderName: `Seeded Holder ${index + 1}`, cardNumber: `41111111${String(index).padStart(8, "0")}`, expMonth: String((index % 12) + 1).padStart(2, "0"), expYear: String(2030 + (index % 8)), cvc: String(100 + (index % 900)), network: "visa", last4: String(index).padStart(4, "0").slice(-4), }; case 3: return { type: "passkey", title, passkey: { rpId: `passkey-${index + 1}.codex-recovery.test`, rpName: `Seeded Passkey ${index + 1}`, credentialId: `credential-${index + 1}`, publicKeyCose: `cose-${index + 1}`, userHandle: `handle-${index + 1}`, userName: `passkey-user-${index + 1}`, userDisplayName: `Passkey User ${index + 1}`, algorithm: -7, signCount: index, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: 1_700_000_000_000 + index, }, }; default: return { type: "login", title, url: `https://account-${index + 1}.codex-recovery.test`, username: `seeded-user-${index + 1}`, password: `seeded-password-${index + 1}-${(index * 2654435761).toString(36)}`, }; } }); return chrome.runtime.sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries }, }); }, { count: SEEDED_ENTRY_COUNT, sentinelTitle: SEEDED_ENTRY_TITLE }); if (!seeded.ok || seeded.data.length !== SEEDED_ENTRY_COUNT) { throw new Error(`seeded vault contains ${seeded.data?.length ?? 0} entries`); } const snapshot = await popup.evaluate(async () => { const stored = await chrome.storage.local.get([ "peach_vault_a", "peach_vault_b", "peach_last_modified_at", ]); return { slotA: JSON.stringify(stored.peach_vault_a), slotB: JSON.stringify(stored.peach_vault_b), lastModified: String(stored.peach_last_modified_at), }; }); const generatorBundle = await findCodexGeneratorBundle(); const pdfBytes = await popup.evaluate( async ({ bundle, vault, count }) => { const generatorModule = (await import( chrome.runtime.getURL(`assets/${bundle}`) )) as Record; const generatorApi = Object.values(generatorModule).find( (value): value is { generateCodex: (deps: object) => Promise<{ pdfBytes: Uint8Array }>; } => typeof value === "object" && value !== null && "generateCodex" in value && typeof value.generateCodex === "function", ); if (!generatorApi) throw new Error("Codex generator export was not found"); const result = await generatorApi.generateCodex({ getSlotA: async () => vault.slotA, getSlotB: async () => vault.slotB, getLastModified: async () => vault.lastModified, getAllEntries: async () => Array.from({ length: count }, () => ({ deletedAt: null })), getVerifyCode: async () => "PX-SEED-CDEX", isVaultUnlocked: () => true, }); return Array.from(result.pdfBytes); }, { bundle: generatorBundle, vault: snapshot, count: SEEDED_ENTRY_COUNT }, ); return { name: "seeded-vault-codex.pdf", mimeType: "application/pdf", buffer: Buffer.from(pdfBytes), }; } finally { await seedContext.close(); } } 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; let seededCodexFile: Awaited>; test.describe.configure({ timeout: 180_000 }); test.beforeAll(async () => { test.setTimeout(180_000); seededCodexFile = await createSeededCodexFile(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test.afterEach(async () => { await ctx.close(); }); test("restores a seeded vault from its generated Codex PDF", async () => { const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId); await page.locator('input[type="file"]').setInputFiles(seededCodexFile); // 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"); await page.locator("#codex-passphrase").fill(VAULT_PASSWORD); await page.getByRole("button", { name: "restore vault" }).click(); await expect(page.getByText("✓ vault restored")).toBeVisible({ timeout: 30_000 }); const restored = await page.evaluate(async () => { const response = await chrome.runtime.sendMessage({ type: "GET_ENTRIES" }); const entries = response.data as { title: string; type?: string }[]; return { titles: entries.map((entry) => entry.title), typeCounts: Object.fromEntries( ["login", "note", "card", "passkey"].map((type) => [ type, entries.filter((entry) => entry.type === type).length, ]), ), }; }); expect(restored.titles).toHaveLength(SEEDED_ENTRY_COUNT); expect(restored.titles).toContain(SEEDED_ENTRY_TITLE); expect(restored.typeCounts).toEqual({ login: 63, note: 63, card: 62, passkey: 62, }); }); test("decodes at least one Aztec envelope from page 1", async () => { const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId); await page.locator('input[type="file"]').setInputFiles(seededCodexFile); 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 generated current-generation Codex PDF without missing stripes", async () => { const extId = await getExtId(ctx); const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1"); await page.locator('input[type="file"]').setInputFiles(seededCodexFile); 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 Window & { __codexImportMetrics?: { decodeRate: number; shortStripeCount: number; perStripeDecoded: number[]; }; } ).__codexImportMetrics ?? null, ); expect(metrics).not.toBeNull(); if (!metrics) throw new Error("Codex import metrics were not recorded"); 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"); }); });