// tests/uat/lib/zip.ts — Plan 01-11 harness archive-shape helper. // // Assertion 13 verifies the session_report_*.zip produced by the SW's // saveArchive contains: // - `video/last_30sec.webm` (non-zero size) // - `meta.json` whose parsed JSON has `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 import { readFileSync } from 'node:fs'; import JSZip from 'jszip'; /** * 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: { version?: 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 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 === 0) { errors.push('video/last_30sec.webm entry is zero bytes (no captured video)'); } } // meta.json presence + version match const metaEntry = zip.file('meta.json'); let hasMetaEntry = false; let metaJson: { version?: 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 { version?: 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.version !== 'string') { errors.push( `meta.json.version expected string, got ${typeof metaJson.version} (${JSON.stringify(metaJson.version)})`, ); } else if (metaJson.version !== expectedVersion) { errors.push( `meta.json.version mismatch — expected "${expectedVersion}", got "${metaJson.version}"`, ); } } } 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; }