// tests/uat/lib/zip.ts — Plan 01-13 Wave 3D harness archive-shape helper. // // Assertion 13 verifies the session_report_*.zip produced by the SW's // saveArchive contains: // - `video/last_30sec.webm` (size > A13_MIN_VIDEO_BYTES = 1024 bytes) // - `meta.json` whose parsed JSON has `extensionVersion === ` // (the SessionMetadata type at src/shared/types.ts:103 names the // field `extensionVersion`; the production write site at // src/background/index.ts:572 stamps it from // `chrome.runtime.getManifest().version`). // // References: // - JSZip: https://stuk.github.io/jszip/documentation/api_jszip.html // - Plan 01-07 archive shape (session_report contract): // .planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md // - SessionMetadata shape: src/shared/types.ts:103-111 import { readFileSync } from 'node:fs'; import JSZip from 'jszip'; /** A13 minimum webm entry size — same 1 KB floor A5 uses for the zip * as a whole. A successful 35s recording (A11 → A12+A13) produces a * remuxed webm in the multi-MB range, so 1 KB is a very generous * floor that catches the regression class "zip exists but webm entry * is corrupted/empty" without false-positives on real captures. */ const A13_MIN_VIDEO_BYTES = 1024; /** * Outcome of an archive shape inspection. `errors` lists every * missing-file / wrong-size / version-mismatch finding. */ export interface ArchiveShapeResult { readonly hasVideoEntry: boolean; readonly videoSizeBytes: number; readonly hasMetaEntry: boolean; readonly metaJson: { extensionVersion?: unknown } | null; readonly errors: ReadonlyArray; } /** * Open a downloaded session_report_*.zip and verify its shape. * * @param zipPath - Absolute path to the downloaded .zip file. * @param expectedVersion - The version string from chrome.runtime.getManifest().version. * @returns Structured shape result. `errors` non-empty == assertion failure. */ export async function assertArchiveShape( zipPath: string, expectedVersion: string, ): Promise { const zipBuf = readFileSync(zipPath); const zip = await JSZip.loadAsync(zipBuf); const errors: string[] = []; // video/last_30sec.webm presence + size floor const videoEntry = zip.file('video/last_30sec.webm'); let hasVideoEntry = false; let videoSizeBytes = 0; if (videoEntry === null) { errors.push('video/last_30sec.webm entry missing from archive'); } else { hasVideoEntry = true; const videoBuf = await videoEntry.async('uint8array'); videoSizeBytes = videoBuf.byteLength; if (videoSizeBytes < A13_MIN_VIDEO_BYTES) { errors.push( `video/last_30sec.webm entry too small: ${videoSizeBytes} bytes (floor ${A13_MIN_VIDEO_BYTES})`, ); } } // meta.json presence + extensionVersion match // // NOTE: the production SessionMetadata shape (src/shared/types.ts:103) // names this field `extensionVersion` — NOT `version`. The earlier // 01-11 prototype of this helper assumed `version`; Wave 3D corrects // the field name to match the actual zip contract. const metaEntry = zip.file('meta.json'); let hasMetaEntry = false; let metaJson: { extensionVersion?: unknown } | null = null; if (metaEntry === null) { errors.push('meta.json entry missing from archive'); } else { hasMetaEntry = true; const metaText = await metaEntry.async('string'); try { metaJson = JSON.parse(metaText) as { extensionVersion?: unknown }; } catch (parseErr) { const msg = parseErr instanceof Error ? parseErr.message : String(parseErr); errors.push(`meta.json failed to parse as JSON: ${msg}`); } if (metaJson !== null) { if (typeof metaJson.extensionVersion !== 'string') { errors.push( `meta.json.extensionVersion expected string, got ${typeof metaJson.extensionVersion} (${JSON.stringify(metaJson.extensionVersion)})`, ); } else if (metaJson.extensionVersion !== expectedVersion) { errors.push( `meta.json.extensionVersion mismatch — expected "${expectedVersion}", got "${metaJson.extensionVersion}"`, ); } } } return { hasVideoEntry, videoSizeBytes, hasMetaEntry, metaJson, errors, }; } /** * Extract a single named entry from a .zip to an absolute filesystem * path. Used by assertion 12 (ffprobe gate on video/last_30sec.webm). * * @param zipPath - Absolute path to the .zip. * @param entryName - Name of the entry inside the zip (e.g. 'video/last_30sec.webm'). * @param outPath - Absolute filesystem path to write the entry to. * @returns The number of bytes written. * @throws If the entry is missing from the zip. */ export async function extractEntryToFile( zipPath: string, entryName: string, outPath: string, ): Promise { const { writeFileSync } = await import('node:fs'); const zipBuf = readFileSync(zipPath); const zip = await JSZip.loadAsync(zipBuf); const entry = zip.file(entryName); if (entry === null) { throw new Error(`extractEntryToFile: entry '${entryName}' missing in ${zipPath}`); } const buf = await entry.async('nodebuffer'); writeFileSync(outPath, buf); return buf.byteLength; }