codex-pdf-restore.spec.ts

12.7 KB · raw

1/**2 * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test.3 *4 * Builds a Codex PDF from a seeded encrypted vault, then runs the real5 * importCodexPDF pipeline inside a fresh Chrome+extension context. This is the6 * only reliable way to 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 { readFile, readdir } from "node:fs/promises";28import { join } from "node:path";29import {30  EXTENSION_PATH,31  makeContext,32  setupVault,33  VAULT_PASSWORD,34} from "./helpers/vault";3536const SEEDED_ENTRY_COUNT = 250;37const SEEDED_ENTRY_TITLE = "Seeded Codex recovery entry";3839async function findCodexGeneratorBundle(): Promise<string> {40  const assetsPath = join(EXTENSION_PATH, "assets");41  const viewBundle = (await readdir(assetsPath)).find((name) =>42    name.startsWith("CodexGenerateView-"),43  );44  if (!viewBundle) throw new Error("built Codex generation view was not found");45  const source = await readFile(join(assetsPath, viewBundle), "utf8");46  const generatorBundle = source.match(/\.\/((?:generate)-[^"'`]+\.js)/)?.[1];47  if (!generatorBundle) throw new Error("built Codex generator bundle was not found");48  return generatorBundle;49}5051async function createSeededCodexFile(): Promise<{52  name: string;53  mimeType: string;54  buffer: Buffer;55}> {56  const seedContext = await makeContext("peach-codex-seed");57  try {58    const { popup } = await setupVault(seedContext);59    const seeded = await popup.evaluate(async ({ count, sentinelTitle }) => {60      const entries = Array.from({ length: count }, (_, index) => {61        const title = index === 0 ? sentinelTitle : `Seeded Codex entry ${index + 1}`;62        switch (index % 4) {63          case 1:64            return {65              type: "note",66              title,67              notes: `Recovery note ${index + 1}: ${(index * 2654435761).toString(36)}`,68            };69          case 2:70            return {71              type: "card",72              title,73              cardholderName: `Seeded Holder ${index + 1}`,74              cardNumber: `41111111${String(index).padStart(8, "0")}`,75              expMonth: String((index % 12) + 1).padStart(2, "0"),76              expYear: String(2030 + (index % 8)),77              cvc: String(100 + (index % 900)),78              network: "visa",79              last4: String(index).padStart(4, "0").slice(-4),80            };81          case 3:82            return {83              type: "passkey",84              title,85              passkey: {86                rpId: `passkey-${index + 1}.codex-recovery.test`,87                rpName: `Seeded Passkey ${index + 1}`,88                credentialId: `credential-${index + 1}`,89                publicKeyCose: `cose-${index + 1}`,90                userHandle: `handle-${index + 1}`,91                userName: `passkey-user-${index + 1}`,92                userDisplayName: `Passkey User ${index + 1}`,93                algorithm: -7,94                signCount: index,95                aaguid: "AAAAAAAAAAAAAAAAAAAAAA",96                backupEligible: true,97                backupState: false,98                createdAt: 1_700_000_000_000 + index,99              },100            };101          default:102            return {103              type: "login",104              title,105              url: `https://account-${index + 1}.codex-recovery.test`,106              username: `seeded-user-${index + 1}`,107              password: `seeded-password-${index + 1}-${(index * 2654435761).toString(36)}`,108            };109        }110      });111      return chrome.runtime.sendMessage({112        type: "BATCH_ADD_ENTRIES",113        payload: { entries },114      });115    }, { count: SEEDED_ENTRY_COUNT, sentinelTitle: SEEDED_ENTRY_TITLE });116    if (!seeded.ok || seeded.data.length !== SEEDED_ENTRY_COUNT) {117      throw new Error(`seeded vault contains ${seeded.data?.length ?? 0} entries`);118    }119    const snapshot = await popup.evaluate(async () => {120      const stored = await chrome.storage.local.get([121        "peach_vault_a",122        "peach_vault_b",123        "peach_last_modified_at",124      ]);125      return {126        slotA: JSON.stringify(stored.peach_vault_a),127        slotB: JSON.stringify(stored.peach_vault_b),128        lastModified: String(stored.peach_last_modified_at),129      };130    });131    const generatorBundle = await findCodexGeneratorBundle();132    const pdfBytes = await popup.evaluate(133      async ({ bundle, vault, count }) => {134        const generatorModule = (await import(135          chrome.runtime.getURL(`assets/${bundle}`)136        )) as Record<string, unknown>;137        const generatorApi = Object.values(generatorModule).find(138          (value): value is {139            generateCodex: (deps: object) => Promise<{ pdfBytes: Uint8Array }>;140          } =>141            typeof value === "object" &&142            value !== null &&143            "generateCodex" in value &&144            typeof value.generateCodex === "function",145        );146        if (!generatorApi) throw new Error("Codex generator export was not found");147        const result = await generatorApi.generateCodex({148          getSlotA: async () => vault.slotA,149          getSlotB: async () => vault.slotB,150          getLastModified: async () => vault.lastModified,151          getAllEntries: async () =>152            Array.from({ length: count }, () => ({ deletedAt: null })),153          getVerifyCode: async () => "PX-SEED-CDEX",154          isVaultUnlocked: () => true,155        });156        return Array.from(result.pdfBytes);157      },158      { bundle: generatorBundle, vault: snapshot, count: SEEDED_ENTRY_COUNT },159    );160    return {161      name: "seeded-vault-codex.pdf",162      mimeType: "application/pdf",163      buffer: Buffer.from(pdfBytes),164    };165  } finally {166    await seedContext.close();167  }168}169170async function getExtId(ctx: BrowserContext): Promise<string> {171  const maxWait = 15_000;172  const start = Date.now();173  let sw = ctx.serviceWorkers()[0];174  while (!sw && Date.now() - start < maxWait) {175    await new Promise((r) => setTimeout(r, 500));176    sw = ctx.serviceWorkers()[0];177  }178  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);179  return new URL(sw.url()).host;180}181182async function openCodexRestorePage(183  ctx: BrowserContext,184  extId: string,185  search = "codex=restore",186): Promise<Page> {187  const page = await ctx.newPage();188189  // Capture extension console output for diagnostics.190  page.on("console", (msg) => {191    const text = msg.text();192    if (msg.type() === "error" || text.startsWith("[codex]")) {193      console.log(`  [browser:${msg.type()}] ${text}`);194    }195  });196197  // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.198  // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden199  // file input, opening the OS dialog which steals focus and kills the page.200  await page.goto(`chrome-extension://${extId}/src/popup/index.html?${search}`);201202  // The hidden file input is mounted alongside the restore view. Wait for it203  // to be attached before handing back the page.204  await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });205206  return page;207}208209test.describe("Codex PDF restore", () => {210  let ctx: BrowserContext;211  let seededCodexFile: Awaited<ReturnType<typeof createSeededCodexFile>>;212213  test.describe.configure({ timeout: 180_000 });214215  test.beforeAll(async () => {216    test.setTimeout(180_000);217    seededCodexFile = await createSeededCodexFile();218  });219220  test.beforeEach(async () => {221    ctx = await makeContext();222  });223224  test.afterEach(async () => {225    await ctx.close();226  });227228  test("restores a seeded vault from its generated Codex PDF", async () => {229    const extId = await getExtId(ctx);230    const page = await openCodexRestorePage(ctx, extId);231232    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);233234    // After handlePDFSelected runs importCodexPDF:235    //   success  → phase="ready"  → body contains "master password" + "X / Y codes"236    //   missing  → body contains pdfError with "codes" + camera starts237    //   failure  → body contains "failed to read"238    //   empty    → body contains "no Codex codes found"239    await page.waitForFunction(240      () => {241        const body = document.body.textContent ?? "";242        return (243          body.includes("codes") ||244          body.includes("master password") ||245          body.includes("no Codex codes found") ||246          body.includes("failed to read")247        );248      },249      { timeout: 150_000, polling: 1_000 },250    );251252    const bodyText = await page.evaluate(() => document.body.textContent ?? "");253    console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400));254255    expect(bodyText).not.toContain("no Codex codes found");256    expect(bodyText).not.toContain("failed to read");257258    await page.locator("#codex-passphrase").fill(VAULT_PASSWORD);259    await page.getByRole("button", { name: "restore vault" }).click();260    await expect(page.getByText("✓ vault restored")).toBeVisible({ timeout: 30_000 });261262    const restored = await page.evaluate(async () => {263      const response = await chrome.runtime.sendMessage({ type: "GET_ENTRIES" });264      const entries = response.data as { title: string; type?: string }[];265      return {266        titles: entries.map((entry) => entry.title),267        typeCounts: Object.fromEntries(268          ["login", "note", "card", "passkey"].map((type) => [269            type,270            entries.filter((entry) => entry.type === type).length,271          ]),272        ),273      };274    });275    expect(restored.titles).toHaveLength(SEEDED_ENTRY_COUNT);276    expect(restored.titles).toContain(SEEDED_ENTRY_TITLE);277    expect(restored.typeCounts).toEqual({278      login: 63,279      note: 63,280      card: 62,281      passkey: 62,282    });283  });284285  test("decodes at least one Aztec envelope from page 1", async () => {286    const extId = await getExtId(ctx);287    const page = await openCodexRestorePage(ctx, extId);288289    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);290291    await page.waitForFunction(292      () => {293        const body = document.body.textContent ?? "";294        return (295          body.includes("codes") ||296          body.includes("master password") ||297          body.includes("no Codex codes found") ||298          body.includes("failed to read")299        );300      },301      { timeout: 90_000, polling: 500 },302    );303304    const bodyText = await page.evaluate(() => document.body.textContent ?? "");305    console.log("Body snippet:", bodyText.slice(0, 300));306307    expect(bodyText).not.toContain("no Codex codes found");308    expect(bodyText).not.toContain("failed to read");309  });310311  test("imports the generated current-generation Codex PDF without missing stripes", async () => {312    const extId = await getExtId(ctx);313    const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1");314    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);315316    await page.waitForFunction(317      () => {318        const body = document.body.textContent ?? "";319        return (320          body.includes("master password") ||321          body.includes("pdf import needs another pass") ||322          body.includes("failed to read")323        );324      },325      { timeout: 150_000, polling: 1_000 },326    );327328    const metrics = await page.evaluate(329      () =>330        (331          window as Window & {332            __codexImportMetrics?: {333              decodeRate: number;334              shortStripeCount: number;335              perStripeDecoded: number[];336            };337          }338        ).__codexImportMetrics ?? null,339    );340    expect(metrics).not.toBeNull();341    if (!metrics) throw new Error("Codex import metrics were not recorded");342    expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98);343    expect(metrics.shortStripeCount).toBe(0);344    expect(metrics.perStripeDecoded.length).toBeGreaterThan(0);345346    const bodyText = await page.evaluate(() => document.body.textContent ?? "");347    expect(bodyText).not.toContain("failed to read");348  });349});