/** * uat-report/screenshot-embeds.ts — IO: collect failure screenshots as base64 * data URIs so the report is a single transmissible document. * * Deliberately NOT pure (statSync/readFileSync) — the pure renderer receives the * resulting map through the model and never touches the disk. Policy: * - only TRUE failures embed (executed, determinate, !ok, screenshot set) — * indeterminates and passes keep their relative links, saving budget; * - a per-image cap and a total budget bound the report size; over-cap shots * fall back to the relative link with an explicit note (never silent); * - artifact order ⇒ deterministic selection under the total budget. * * Map keys are the artifact's POSIX rel paths VERBATIM (`screenshots/{role}/…`, * `screenshotRelPath` builds with forward slashes) — `path.join` is used for * READING only, so Windows separators never leak into the keys. */ import { readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { humanBytes, type UiRunFile } from '../lib/run-results.js'; import type { ScreenshotEmbed } from './build-model.js'; export const PER_IMAGE_CAP_BYTES = 300 * 1024; export const TOTAL_CAP_BYTES = 8 * 1024 * 1024; export interface EmbedCaps { perImage: number; total: number; } export function collectScreenshotEmbeds( runDirAbs: string, uiFile: UiRunFile | null, caps: EmbedCaps = { perImage: PER_IMAGE_CAP_BYTES, total: TOTAL_CAP_BYTES }, ): Record { const embeds: Record = {}; if (!uiFile) return embeds; let budget = caps.total; for (const r of uiFile.results) { if (!r.screenshot || !r.executed || r.ok || r.actual === 'indeterminate') continue; if (r.screenshot in embeds) continue; // one entry per file const abs = join(runDirAbs, r.screenshot); let size: number; try { size = statSync(abs).size; } catch { continue; // missing file → no entry, the renderer falls back to the link } if (size > caps.perImage) { embeds[r.screenshot] = { note: `not embedded (${humanBytes(size)} > ${humanBytes(caps.perImage)} cap)` }; continue; } if (size > budget) { embeds[r.screenshot] = { note: 'embed budget reached — linked instead' }; continue; } try { const buf = readFileSync(abs); embeds[r.screenshot] = { dataUri: `data:image/png;base64,${buf.toString('base64')}` }; budget -= size; } catch { // unreadable → no entry, link fallback } } return embeds; }