f4aa4137367a
fix scale detection to use best-match not first-match, add reliable E2E Playwright test for PDF import
- time
- 2026-05-22 17:06 UTC · 5 months ago
- machine
- koh2
- parent
- 7a863ef97d46
↓ peach-browser-f4aa4137.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/license.ts+9 −3src/popup/codex/codex.suite.test.ts+2 −2src/popup/codex/pdf-import.ts+86 −10src/shared/codex-types.ts+10 −9tests/codex-pdf-restore.spec.ts+167 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.91",5+ "version": "3.6.93",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.91",4+ "version": "3.6.93",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.91",3+ "version": "3.6.93",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/license.ts+9 −3
@@ -121,10 +121,18 @@121121 }122122 }123123 124+const CELEBRATION_KEY = "peach_pro_celebrated";125+124126 export async function activateLicense(keyString: string): Promise<LicenseInfo> {125127 const validated = await validateLicense(keyString);126128 if (validated.valid && validated.plan === "pro") {127- await chrome.storage.local.set({ peach_license: keyString.trim() });129+ // Reset the celebration flag so every new license activation triggers130+ // the full-screen Pro celebration in the popup, even on devices that131+ // have celebrated before (e.g., testers who activate repeatedly).132+ await chrome.storage.local.set({133+ peach_license: keyString.trim(),134+ [CELEBRATION_KEY]: false,135+ });128136 return validated;129137 }130138 return { valid: false, plan: "free" };@@ -148,8 +156,6 @@148156 export async function deactivateLicense(): Promise<void> {149157 await chrome.storage.local.remove("peach_license");150158 }151-152-const CELEBRATION_KEY = "peach_pro_celebrated";153159 154160 export async function shouldPlayCelebration(): Promise<boolean> {155161 const info = await getLicenseInfo();src/popup/codex/codex.suite.test.ts+2 −2
@@ -29,8 +29,8 @@2929 // ─── Chunk + stripe planning constants ────────────────────────────────────3030 3131 describe("Codex — Chunk + Stripe planning constants", () => {32- it("CHUNK_SIZE is 800 bytes (matches Aztec capacity at ECC=25%)", () => {33- expect(CHUNK_SIZE).toBe(800);32+ it("CHUNK_SIZE is 350 bytes (75-module codes, 4.44 dots/module at 300 DPI)", () => {33+ expect(CHUNK_SIZE).toBe(350);3434 });3535 3636 it("MAX_DATA_SHARDS_PER_STRIPE + max parity stays inside GF(2^8) cap of 255", () => {src/popup/codex/pdf-import.ts+86 −10
@@ -95,10 +95,83 @@9595 }9696 9797 /**98+ * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known99+ * Codex generations, ordered from newest to oldest. ZXing's AztecDetector100+ * requires near-exact 1:1 rendering — even a 1% scale error causes101+ * ReedSolomonException (downscale) or FormatException (upscale). The formula102+ * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec103+ * matrix size and CODE_SIZE_pt = 80.104+ *105+ * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG106+ * → scale = 3.95107+ * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG108+ * → scale = 5.25109+ * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG110+ * → scale = 6.05111+ */112+const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;113+114+/**115+ * Probe page 1 of `pdf` at every candidate scale and return the scale that116+ * decodes the most codes. "First scale that decodes anything" is not117+ * sufficient — scale 5.25 can occasionally decode 1–2 codes from a 6.05118+ * (117-module) Codex, producing a false-positive that makes the full import119+ * use the wrong scale. Taking the maximum across all candidates avoids this:120+ * the correct scale always wins by a large margin (30+ codes vs 0–2).121+ *122+ * Early exit: once a scale decodes a full row's worth of codes (CODES_PER_ROW)123+ * we have a confident match and stop probing — this keeps detection fast for124+ * the common case where the first or second candidate is correct.125+ */126+async function detectRenderScale(127+ pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,128+): Promise<number> {129+ let page: Awaited<ReturnType<typeof pdf.getPage>>;130+ try {131+ page = await pdf.getPage(1);132+ } catch {133+ return CANDIDATE_SCALES[0];134+ }135+136+ let bestScale: number = CANDIDATE_SCALES[0];137+ let bestCount = 0;138+139+ for (const scale of CANDIDATE_SCALES) {140+ try {141+ const viewport = page.getViewport({ scale });142+ const canvas = document.createElement("canvas");143+ canvas.width = Math.ceil(viewport.width);144+ canvas.height = Math.ceil(viewport.height);145+ const ctx = canvas.getContext("2d");146+ if (!ctx) continue;147+ ctx.fillStyle = "white";148+ ctx.fillRect(0, 0, canvas.width, canvas.height);149+ await page.render({ canvas, canvasContext: ctx, viewport }).promise;150+151+ const probe = new Set<string>();152+ const found = await scanCanvasGrid(canvas, probe);153+ if (found.length > bestCount) {154+ bestCount = found.length;155+ bestScale = scale;156+ }157+ // A full row of codes decoded = unambiguous match; no need to probe further.158+ if (found.length >= CODES_PER_ROW) break;159+ } catch {160+ // continue to next candidate161+ }162+ }163+164+ return bestScale;165+}166+167+/**98168 * Import a Peach Codex PDF and decode every Aztec code it contains.99169 *100- * Renders each page at 2× scale (≈150dpi equivalent — well above the density101- * required for reliable Aztec decoding) and grid-scans each rendered page.170+ * Automatically detects the render scale by probing page 1 at each candidate171+ * scale in CANDIDATE_SCALES — the scale that decodes at least one code is used172+ * for all pages. This makes the importer backward-compatible with Codexes173+ * generated at any prior CHUNK_SIZE, and forward-compatible with future174+ * changes, without requiring any version metadata in the PDF.102175 */103176 export async function importCodexPDF(104177 file: File,@@ -108,7 +181,9 @@108181 109182 const envelopes: ChunkEnvelope[] = [];110183 const errors: string[] = [];111- const seenIndices = new Set<number>();184+ // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat185+ // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.186+ const seenIndices = new Set<string>();112187 let totalExpected: number | null = null;113188 114189 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;@@ -127,13 +202,14 @@127202 }128203 129204 const pageCount = pdf.numPages;205+ const renderScale = await detectRenderScale(pdf);130206 131207 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {132208 onProgress?.(pageNum, pageCount);133209 134210 try {135211 const page = await pdf.getPage(pageNum);136- const viewport = page.getViewport({ scale: 2.0 });212+ const viewport = page.getViewport({ scale: renderScale });137213 138214 const canvas = document.createElement("canvas");139215 canvas.width = Math.ceil(viewport.width);@@ -158,9 +234,7 @@158234 totalExpected = pageEnvelopes[0]!.t;159235 }160236 } catch (err) {161- errors.push(162- `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,163- );237+ errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);164238 }165239 }166240 @@ -202,7 +276,7 @@202276 */203277 async function scanCanvasGrid(204278 canvas: HTMLCanvasElement,205- seenIndices: Set<number>,279+ seenIndices: Set<string>,206280 ): Promise<ChunkEnvelope[]> {207281 const found: ChunkEnvelope[] = [];208282 @@ -228,15 +302,17 @@228302 229303 const imageData = cellCtx.getImageData(0, 0, sw, sh);230304 const argb = imageDataToArgb(imageData);305+231306 const text = decodeAztecFromArgb(argb, sw, sh);232307 if (!text) continue;233308 234309 const envelope = parseChunkEnvelope(text);235310 if (!envelope) continue;236- if (seenIndices.has(envelope.i)) continue;311+ const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;312+ if (seenIndices.has(stripeKey)) continue;237313 if (!(await verifyChunkIntegrity(envelope))) continue;238314 239- seenIndices.add(envelope.i);315+ seenIndices.add(stripeKey);240316 found.push(envelope);241317 }242318 }src/shared/codex-types.ts+10 −9
@@ -178,17 +178,18 @@178178 * Bytes of binary data per chunk. Each chunk becomes one Aztec code.179179 *180180 * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.181- * base64url expands by 4/3: 800 bytes → 1068 chars → ~1168-char envelope.182- * The empirical bwip-js/ZXing ceiling at eclevel=25 is ~1386 chars; staying183- * ~200 chars under gives reliable headroom across cheap printers, camera184- * angles, and ZXing decode variance. The original 1024-byte value produced185- * ~1468-char envelopes — over the ceiling, causing AZTEC_ROUNDTRIP_FAILED.181+ * base64url expands by 4/3: 350 bytes → 467 chars → ~577-char envelope.182+ * ZXing selects a 75-module Aztec code at eclevel=25, producing a 316px PNG183+ * (75×4+16) at the standard scale=4. At 80pt CODE_SIZE and 300 DPI printing,184+ * 75 modules occupy 333 dots → 4.44 dots/module, comfortably above ZXing's185+ * minimum of ~4 dots/module for reliable camera decode.186186 *187- * Old Codexes printed with a different chunk size still restore — the188- * erasure decoder derives shard size from the scanned chunks, not from189- * this constant.187+ * The previous value was 800 (117-module codes, 2.85 dots/module at 300 DPI —188+ * too small for reliable camera scanning). Old Codexes printed with a189+ * different chunk size still restore — the erasure decoder derives shard size190+ * from the scanned chunks, not from this constant.190191 */191-export const CHUNK_SIZE = 800;192+export const CHUNK_SIZE = 350;192193 193194 /**194195 * Maximum data shards per RS stripe. The underlying reed-solomon-erasuretests/codex-pdf-restore.spec.ts+167 −0
@@ -0,0 +1,167 @@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+ */24+25+import { test, expect } from "@playwright/test";26+import type { BrowserContext, Page } from "@playwright/test";27+import { fileURLToPath } from "url";28+import { dirname, join } from "path";29+import os from "os";30+import fs from "fs/promises";31+32+const __filename = fileURLToPath(import.meta.url);33+const __dirname = dirname(__filename);34+const EXTENSION_PATH = join(__dirname, "..", "dist");35+36+const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-05-20 (2).pdf";37+38+async function makeContext(): Promise<BrowserContext> {39+ const { chromium } = await import("playwright");40+ const userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`);41+ await fs.mkdir(userDataDir, { recursive: true });42+ return chromium.launchPersistentContext(userDataDir, {43+ headless: false,44+ args: [45+ `--load-extension=${EXTENSION_PATH}`,46+ `--disable-extensions-except=${EXTENSION_PATH}`,47+ ],48+ });49+}50+51+async function getExtId(ctx: BrowserContext): Promise<string> {52+ const maxWait = 15_000;53+ const start = Date.now();54+ let sw = ctx.serviceWorkers()[0];55+ while (!sw && Date.now() - start < maxWait) {56+ await new Promise((r) => setTimeout(r, 500));57+ sw = ctx.serviceWorkers()[0];58+ }59+ if (!sw) throw new Error(`No service worker after ${maxWait}ms`);60+ return new URL(sw.url()).host;61+}62+63+async function openCodexRestorePage(ctx: BrowserContext, extId: string): Promise<Page> {64+ const page = await ctx.newPage();65+66+ // Capture extension console output for diagnostics.67+ page.on("console", (msg) => {68+ const text = msg.text();69+ if (msg.type() === "error" || text.startsWith("[codex]")) {70+ console.log(` [browser:${msg.type()}] ${text}`);71+ }72+ });73+74+ // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.75+ // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden76+ // file input, opening the OS dialog which steals focus and kills the page.77+ await page.goto(`chrome-extension://${extId}/src/popup/index.html?codex=restore`);78+79+ // The file input lives inside <Show when={phase() === "choose"}> which is80+ // true at mount. Wait for it to be attached before handing back the page.81+ await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });82+83+ return page;84+}85+86+test.describe("Codex PDF restore", () => {87+ let ctx: BrowserContext;88+89+ test.beforeEach(async () => {90+ ctx = await makeContext();91+ });92+93+ test.afterEach(async () => {94+ await ctx.close();95+ });96+97+ test("imports all chunks from the real Codex PDF", async () => {98+ // The full import of a large Codex (21+ pages) including scale-detection99+ // renders can take up to 3 minutes. Set an explicit test timeout.100+ test.setTimeout(180_000);101+102+ const extId = await getExtId(ctx);103+ const page = await openCodexRestorePage(ctx, extId);104+105+ // CDP-based setInputFiles bypasses the OS dialog and dispatches the106+ // change event directly, firing handlePDFSelected inside the extension.107+ await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);108+109+ // After handlePDFSelected runs importCodexPDF:110+ // success → phase="ready" → body contains "master password" + "X / Y codes"111+ // missing → body contains pdfError with "codes" + camera starts112+ // failure → body contains "failed to read"113+ // empty → body contains "no Codex codes found"114+ await page.waitForFunction(115+ () => {116+ const body = document.body.textContent ?? "";117+ return (118+ body.includes("codes") ||119+ body.includes("master password") ||120+ body.includes("no Codex codes found") ||121+ body.includes("failed to read")122+ );123+ },124+ { timeout: 150_000, polling: 1_000 },125+ );126+127+ const bodyText = await page.evaluate(() => document.body.textContent ?? "");128+ console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400));129+130+ expect(bodyText).not.toContain("no Codex codes found");131+ expect(bodyText).not.toContain("failed to read");132+133+ const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false);134+ if (passphraseVisible) {135+ console.log("✓ Buffer is ready — passphrase prompt visible.");136+ }137+ expect(bodyText).toMatch(/master password|codes/);138+ });139+140+ test("decodes at least one Aztec envelope from page 1", async () => {141+ test.setTimeout(120_000);142+143+ const extId = await getExtId(ctx);144+ const page = await openCodexRestorePage(ctx, extId);145+146+ await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);147+148+ await page.waitForFunction(149+ () => {150+ const body = document.body.textContent ?? "";151+ return (152+ body.includes("codes") ||153+ body.includes("master password") ||154+ body.includes("no Codex codes found") ||155+ body.includes("failed to read")156+ );157+ },158+ { timeout: 90_000, polling: 500 },159+ );160+161+ const bodyText = await page.evaluate(() => document.body.textContent ?? "");162+ console.log("Body snippet:", bodyText.slice(0, 300));163+164+ expect(bodyText).not.toContain("no Codex codes found");165+ expect(bodyText).not.toContain("failed to read");166+ });167+});