scripts/screenshot-sheets.ts
Rama develop · réplica de lectura del repositorio original.
/**
* Batches per-test Playwright screenshots into composite "sheets" (later uploaded
* to Argos by a separate step), grouping by test file so a new test in one spec
* never alters another spec's sheets. Pure planning is separated from sharp-backed
* compositing so the grouping/ordering rules can be unit-tested without images.
*
* CLI usage:
* pnpm run screenshots:sheets
* SCREENSHOT_DIR=e2e/screenshots SHEETS_DIR=e2e/sheets
* TILES_PER_SHEET=12 SHEET_COLS=3 SHEET_SCALE=2
* TILE_WIDTH=1440 TILE_IMAGE_HEIGHT=1024 pnpm run screenshots:sheets
*/
import { readdir, mkdir, writeFile, readFile } from 'node:fs/promises';
import { join, dirname, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import {
annotateTilePosition,
annotationFromScreenshotRelPath,
buildSheetMetadata,
formatTileTitle,
listRelativeFiles,
readTileAnnotation,
readTileOrigins,
verifyArgosMetadataSidecars,
writeArgosMetadataSidecar,
} from '../e2e/helpers/argos-metadata.ts';
import type {
ArgosLocation,
ArgosScreenshotMetadata,
ArgosTestAnnotation,
} from '../e2e/helpers/argos-metadata.ts';
export { formatTileTitle };
// Matches a Cypress spec-file path segment: foo.spec.js / foo.spec.ts / .cjs / .mts
const SPEC_SEGMENT_RE = /\.spec\.[cm]?[jt]s$/;
/** Fixed label band under each screenshot tile (deterministic grid sizing). */
export const LABEL_HEIGHT = 48;
/** Matches cypress.config.ts viewport — every cell uses this slot, not max(tile). */
export const DEFAULT_TILE_WIDTH = 1440;
export const DEFAULT_TILE_IMAGE_HEIGHT = 1024;
// Sized for the 1440px-wide cell: a small font has too few pixels per glyph and
// reads as pixelated when the sheet is zoomed.
const LABEL_FONT_SIZE = 28;
const LABEL_PADDING = 10;
const GRID_LINE_WIDTH = 1;
/** Inset (px, before scale) between a tile image and its cell edges/label. */
const CELL_PADDING = 16;
const GRID_LINE_COLOR = '#cccccc';
/** Default output scale for composite sheets (1 = native pixel dimensions). */
export const DEFAULT_SHEET_SCALE = 1;
/** Default sheets composited concurrently (bounded so memory stays sane). */
export const DEFAULT_SHEET_CONCURRENCY = 4;
/** zlib level for the final written sheet — uploaded then discarded, so size barely matters. */
const SHEET_PNG_COMPRESSION = 3;
function scaled(value: number, scale: number): number {
return Math.round(value * scale);
}
export interface SheetManifest {
sheet: string;
group: string;
grid: {
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
imageHeight: number;
labelHeight: number;
scale: number;
};
/**
* Per-tile metadata, including the resolved Argos annotation. The annotation
* is the source of truth for the grid cell → mmd/test mapping, so sidecars can
* be regenerated (e.g. before upload) without re-reading the input screenshots.
*/
tiles: (Tile & { title: string; annotation: ArgosTestAnnotation })[];
}
export interface Sheet {
group: string;
index: number;
output: string;
cols: number;
tiles: Tile[];
}
export interface Tile {
index: number;
row: number;
col: number;
name: string;
source: string;
/**
* The order manifest places this tile but its screenshot wasn't captured this
* run (a removed test whose group still ran). It keeps its grid slot — drawn
* as a blank cell — so removing a test doesn't shift every later tile across
* sheet boundaries.
*/
missing?: boolean;
}
/** Cypress screenshot names use hyphens instead of spaces; restore for display. */
function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function truncateTitle(title: string, maxWidth: number, fontSize: number, padding: number): string {
const maxChars = Math.floor((maxWidth - padding * 2) / (fontSize * 0.55));
if (title.length <= maxChars) {
return title;
}
return `${title.slice(0, Math.max(0, maxChars - 1))}…`;
}
interface OverlayTile {
col: number;
row: number;
title: string;
}
interface OverlayOptions {
width: number;
height: number;
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
labelFontSize: number;
labelPadding: number;
lineWidth: number;
tiles: readonly OverlayTile[];
}
/**
* One SVG overlay for the whole sheet — every grid line, the outer border, and
* every tile's title label. Compositing this single buffer rasterizes the chrome
* in one pass, instead of a separate SVG per tile label plus a grid buffer
* (which scaled with the tile count). Labels sit on the white sheet background,
* and the overlay is composited last so grid lines stay on top.
*/
function buildSheetOverlaySvg(o: OverlayOptions): Buffer {
const lines: string[] = [];
for (let c = 1; c < o.cols; c++) {
const x = c * o.cellWidth;
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${o.height}"/>`);
}
for (let r = 1; r < o.rows; r++) {
const y = r * o.cellHeight;
lines.push(`<line x1="0" y1="${y}" x2="${o.width}" y2="${y}"/>`);
}
const inset = o.lineWidth / 2;
const border = `<rect x="${inset}" y="${inset}" width="${o.width - o.lineWidth}" height="${o.height - o.lineWidth}"/>`;
const labels = o.tiles.map((t) => {
const x = t.col * o.cellWidth + o.labelPadding;
const y = t.row * o.cellHeight + o.labelFontSize + o.labelPadding;
const text = escapeXml(truncateTitle(t.title, o.cellWidth, o.labelFontSize, o.labelPadding));
return `<text x="${x}" y="${y}">${text}</text>`;
});
const svg = [
`<svg width="${o.width}" height="${o.height}" xmlns="http://www.w3.org/2000/svg">`,
// Grid lines + border share one stroke style.
`<g fill="none" stroke="${GRID_LINE_COLOR}" stroke-width="${o.lineWidth}">${lines.join('')}${border}</g>`,
// Labels share one text style.
`<g font-family="sans-serif" font-size="${o.labelFontSize}" fill="#333333">${labels.join('')}</g>`,
`</svg>`,
].join('');
return Buffer.from(svg);
}
/**
* Committed tile order, keyed by group (the same key {@link deriveGroupKey}
* produces). Each value lists that group's screenshot sources with the group
* prefix stripped, in canonical sheet order. New screenshots append at their
* group's tail and removed ones leave a blank cell, so adding/removing a test
* re-tiles only that group's last/affected sheet instead of cascading the whole
* sorted tail across sheet boundaries. Maintained in git via `pnpm run screenshots:order`.
*/
export type OrderManifest = Record<string, string[]>;
export interface PlanSheetsOptions {
tilesPerSheet?: number;
cols?: number;
/** Canonical per-group tile order. Sources absent here sort to the tail. */
order?: OrderManifest;
}
export interface ComposeSheetOptions {
inputDir: string;
background?: { r: number; g: number; b: number; alpha: number };
/** Output scale factor (1 = native screenshot size, 2 = 2× pixels). */
scale?: number;
/** Fixed image slot width in pixels before scale (default: Cypress viewport width). */
tileWidth?: number;
/** Fixed image slot height in pixels before scale (default: Cypress viewport height). */
tileImageHeight?: number;
}
export interface WriteSheetsOptions {
inputDir: string;
outDir: string;
scale?: number;
tileWidth?: number;
tileImageHeight?: number;
concurrency?: number;
/** Called after each sheet is written, for progress reporting. */
onSheetWritten?: (output: string, written: number, total: number) => void;
}
/**
* Maps a screenshot path to its group key. Spec-based screenshots group by the
* path up to and including the `*.spec.*` segment. mmd-snapshot screenshots are
* written under a folder that mirrors the fixture's storage (e.g.
* `diagrams/<type>/<name>.png` — see util.ts `screenshotPath`), so they have no
* spec segment and group by their containing folder, preserving the diagram
* folder structure (including hyphenated and nested folders).
*/
export function deriveGroupKey(relPath: string): string {
const parts = relPath.split('/');
const specIdx = parts.findIndex((p) => SPEC_SEGMENT_RE.test(p));
if (specIdx >= 0) {
return parts.slice(0, specIdx + 1).join('/');
}
return parts.slice(0, -1).join('/') || 'root';
}
/** Source path with its group-key prefix removed (the form stored in the manifest). */
function groupRelative(source: string, groupKey: string): string {
return groupKey && source.startsWith(`${groupKey}/`) ? source.slice(groupKey.length + 1) : source;
}
/**
* Inverse of {@link groupRelative}: the full source path for a manifest entry.
* `root` is {@link deriveGroupKey}'s synthetic key for top-level screenshots, not
* a real path segment, so it must not be prefixed back on.
*/
function groupSource(groupKey: string, rel: string): string {
return groupKey === 'root' ? rel : `${groupKey}/${rel}`;
}
/** A slot in a group's layout: a captured screenshot, or a blank placeholder. */
interface TileSlot {
source: string;
missing: boolean;
}
/**
* Where each screenshot's test is declared (its sidecar's `test.location`: spec
* file plus line/column of the `test()` call), keyed by screenshot source path.
* Produced by {@link readTileOrigins}.
*/
export type TileOrigins = ReadonlyMap<string, ArgosLocation>;
/**
* Lays out a group's tiles in committed-manifest order:
* - manifest sources still present keep their slot;
* - manifest sources now absent keep their slot as a blank placeholder, so
* removing a test doesn't shift later tiles across sheets;
* - present sources not in the manifest append at the tail, sorted.
* Without a manifest entry for the group, falls back to plain alphabetical order.
* (The CLI pins every captured tile up front via {@link extendOrder}, so these
* fallbacks matter only for direct callers.)
*
* Unlike the upstream coarse-grouped batcher, our group key {@link deriveGroupKey}
* IS the scoping unit (a whole spec file or diagram folder), and planSheets only
* lays out groups that captured at least one screenshot. So within a laid-out
* group an absent manifest tile always means a removed test — never a scoped-out
* one — and needs no per-spec "did it run?" check.
*/
function layoutGroup(sources: string[], groupKey: string, order?: OrderManifest): TileSlot[] {
const canonical = order?.[groupKey];
if (!canonical) {
return [...sources].sort().map((source) => ({ source, missing: false }));
}
const presentSet = new Set(sources);
const canonicalSet = new Set(canonical);
const slots: TileSlot[] = canonical.map((rel) => {
const source = groupSource(groupKey, rel);
return { source, missing: !presentSet.has(source) };
});
const appended = sources
.filter((s) => !canonicalSet.has(groupRelative(s, groupKey)))
.sort()
.map((source) => ({ source, missing: false }));
return [...slots, ...appended];
}
/**
* Tail order for tiles the committed manifest doesn't pin. Alphabetical order
* would re-sort a group on every insertion — a new test whose slug sorts early
* takes the first cell and shifts every later tile across sheet boundaries.
* Declaration order appends instead: a test added at the end of its spec sorts
* last, so the tiles before it keep their cells. Tiles sharing one call site
* (e.g. the mmd runner registers every fixture from a single test() call) or
* lacking a readable sidecar tie, and fall back to path order — deterministic,
* and identical to the old alphabetical layout when no origins are known.
*/
function byDeclaration(origins?: TileOrigins) {
return (a: string, b: string): number => {
const originA = origins?.get(a);
const originB = origins?.get(b);
if (originA && originB) {
if (originA.file !== originB.file) {
return originA.file < originB.file ? -1 : 1;
}
if (originA.line !== originB.line) {
return originA.line - originB.line;
}
if (originA.column !== originB.column) {
return originA.column - originB.column;
}
} else if (originA || originB) {
// Sidecar-less tiles sort after located ones rather than interleaving.
return originA ? -1 : 1;
}
return a < b ? -1 : a > b ? 1 : 0;
};
}
/**
* The committed order extended to place every captured screenshot: each group's
* committed entries are kept verbatim (so a removed test keeps its blank slot)
* and captured tiles the manifest doesn't pin append at their group's tail in
* declaration order. The CLI feeds the result to {@link planSheets}, which
* therefore never has to invent an order of its own.
*/
export function extendOrder(
previous: OrderManifest,
relPaths: string[],
origins?: TileOrigins
): OrderManifest {
const captured = new Map<string, string[]>();
for (const p of relPaths) {
const key = deriveGroupKey(p);
(captured.get(key) ?? captured.set(key, []).get(key)!).push(p);
}
const next: OrderManifest = {};
for (const key of [...new Set([...Object.keys(previous), ...captured.keys()])].sort()) {
const pinned = previous[key] ?? [];
const pinnedSet = new Set(pinned);
const appended = (captured.get(key) ?? [])
.filter((source) => !pinnedSet.has(groupRelative(source, key)))
.sort(byDeclaration(origins))
.map((source) => groupRelative(source, key));
next[key] = [...pinned, ...appended];
}
return next;
}
/**
* Recomputes the manifest from the screenshots currently present: each group
* keeps its committed order for tiles still captured, newly-seen tiles append
* in declaration order, and removed tiles and groups drop out. Pure and
* deterministic — {@link extendOrder} with the leftovers pruned.
*/
export function updateOrder(
relPaths: string[],
previous: OrderManifest = {},
origins?: TileOrigins
): OrderManifest {
const present = new Set(relPaths);
const next: OrderManifest = {};
for (const [key, rels] of Object.entries(extendOrder(previous, relPaths, origins))) {
const kept = rels.filter((rel) => present.has(groupSource(key, rel)));
if (kept.length > 0) {
next[key] = kept;
}
}
return next;
}
/**
* Sources present on disk but missing from the committed manifest, grouped by
* key. Non-empty means new visual tests were added without refreshing the order
* manifest — informational only (the runtime appends them anyway).
*/
export function findUnordered(relPaths: string[], order: OrderManifest): OrderManifest {
const missing: OrderManifest = {};
for (const p of relPaths) {
const key = deriveGroupKey(p);
const rel = groupRelative(p, key);
if (!(order[key] ?? []).includes(rel)) {
(missing[key] ?? (missing[key] = [])).push(rel);
}
}
return missing;
}
/** Reads the committed order manifest, or an empty manifest when it doesn't exist yet. */
export async function readOrderManifest(file: string): Promise<OrderManifest> {
try {
return JSON.parse(await readFile(file, 'utf8')) as OrderManifest;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
/** Groups, orders (per the committed manifest), and chunks screenshots into fixed-size grid sheets. */
export function planSheets(relPaths: string[], options: PlanSheetsOptions = {}): Sheet[] {
const tilesPerSheet = options.tilesPerSheet ?? 12;
const cols = options.cols ?? 3;
const groups = new Map<string, string[]>();
for (const p of relPaths) {
const key = deriveGroupKey(p);
const bucket = groups.get(key);
if (bucket) {
bucket.push(p);
} else {
groups.set(key, [p]);
}
}
const sheets: Sheet[] = [];
for (const key of [...groups.keys()].sort()) {
const slots = layoutGroup(groups.get(key) ?? [], key, options.order);
const basename = (key.split('/').pop() ?? 'sheet').replace(SPEC_SEGMENT_RE, '');
for (let start = 0; start < slots.length; start += tilesPerSheet) {
const chunk = slots.slice(start, start + tilesPerSheet);
const index = start / tilesPerSheet;
const output = `${key}/${basename}-${String(index + 1).padStart(3, '0')}.png`;
sheets.push({
group: key,
index,
output,
cols,
tiles: chunk.map(({ source, missing }, i) => ({
index: i,
row: Math.floor(i / cols),
col: i % cols,
name:
source
.split('/')
.pop()
?.replace(/\.png$/, '') ?? '',
source,
missing,
})),
});
}
}
return sheets;
}
/** Recursively collects PNG paths under `dir`, relative with forward slashes, sorted. */
export async function collectScreenshots(dir: string): Promise<string[]> {
return listRelativeFiles(dir, (name) => name.endsWith('.png'));
}
/** Composites one sheet into a deterministic PNG plus a tile manifest. */
export async function composeSheet(
plan: Sheet,
options: ComposeSheetOptions
): Promise<{ buffer: Buffer; manifest: SheetManifest }> {
const { inputDir } = options;
const background = options.background ?? { r: 255, g: 255, b: 255, alpha: 1 };
const { cols } = plan;
const scale = options.scale ?? 1;
const baseCellWidth = options.tileWidth ?? DEFAULT_TILE_WIDTH;
const baseImageHeight = options.tileImageHeight ?? DEFAULT_TILE_IMAGE_HEIGHT;
const cellWidth = scaled(baseCellWidth, scale);
const imageHeight = scaled(baseImageHeight, scale);
const labelHeight = scaled(LABEL_HEIGHT, scale);
const cellHeight = imageHeight + labelHeight;
const labelFontSize = scaled(LABEL_FONT_SIZE, scale);
const labelPadding = scaled(LABEL_PADDING, scale);
const gridLineWidth = scaled(GRID_LINE_WIDTH, scale);
const cellPadding = scaled(CELL_PADDING, scale);
const rows = Math.max(...plan.tiles.map((t) => t.row)) + 1;
// The image sits in a padded box inside the cell's image area, so diagrams
// don't touch the grid lines or the label.
const contentWidth = Math.max(1, cellWidth - 2 * cellPadding);
const contentHeight = Math.max(1, imageHeight - 2 * cellPadding);
// Enlarge each screenshot to fill the padded box: `fit: 'inside'` scales it up
// (or down) to the largest size that fits while preserving aspect ratio (sharp
// enlarges by default — `withoutEnlargement` is off). Decode to raw pixels (not
// PNG) so the sheet composite below consumes them directly, skipping a per-tile
// PNG encode + re-decode round-trip. `info` carries the resolved dimensions and
// channel count needed to place and re-wrap each buffer.
// Missing tiles (a manifest slot with no captured screenshot) have no image to
// load — they render as a blank cell (grid + label only, from the overlay).
const tileBuffers = await Promise.all(
plan.tiles.map((t) =>
t.missing
? Promise.resolve(null)
: sharp(join(inputDir, t.source))
.resize(contentWidth, contentHeight, {
fit: 'inside',
kernel: sharp.kernel.lanczos3,
})
.raw()
.toBuffer({ resolveWithObject: true })
)
);
const sheetWidth = cellWidth * cols;
const sheetHeight = cellHeight * rows;
// Tile images, each centered in its padded box below the label. Missing tiles
// contribute no image; their cell shows only the overlay's grid + label.
const tileComposites = plan.tiles.flatMap((t, i) => {
const buffer = tileBuffers[i];
if (!buffer) {
return [];
}
const { data, info } = buffer;
return [
{
input: data,
raw: { width: info.width, height: info.height, channels: info.channels },
left: t.col * cellWidth + cellPadding + Math.round((contentWidth - info.width) / 2),
top:
t.row * cellHeight +
labelHeight +
cellPadding +
Math.round((contentHeight - info.height) / 2),
},
];
});
// All grid lines, border, and labels in a single overlay rasterized once.
const overlay = buildSheetOverlaySvg({
width: sheetWidth,
height: sheetHeight,
cols,
rows,
cellWidth,
cellHeight,
labelFontSize,
labelPadding,
lineWidth: gridLineWidth,
tiles: plan.tiles.map((t) => ({ col: t.col, row: t.row, title: formatTileTitle(t.name) })),
});
const buffer = await sharp({
create: { width: sheetWidth, height: sheetHeight, channels: 4, background },
})
.composite([...tileComposites, { input: overlay, left: 0, top: 0 }])
.png({ compressionLevel: SHEET_PNG_COMPRESSION })
.toBuffer();
const manifest: SheetManifest = {
sheet: plan.output,
group: plan.group,
grid: {
cols,
rows,
cellWidth,
cellHeight,
imageHeight,
labelHeight,
scale,
},
tiles: plan.tiles.map((t) => ({
index: t.index,
row: t.row,
col: t.col,
name: t.name,
source: t.source,
missing: t.missing,
title: formatTileTitle(t.name),
// Resolve the rich annotation (real spec file/line/column when an input
// sidecar exists) once, here, so it is persisted in the manifest. For a
// missing tile no input sidecar exists, so this falls back to path inference.
annotation: readTileAnnotation(inputDir, t.source),
})),
};
return { buffer, manifest };
}
/** Build a sheet's Argos sidecar metadata from its tile manifest (the source of truth). */
function sheetMetadataFromManifest(manifest: SheetManifest): ArgosScreenshotMetadata {
const sheetBasename =
manifest.sheet
.split('/')
.pop()
?.replace(/\.png$/, '') ?? manifest.sheet;
const tileAnnotations = manifest.tiles.map((tile) =>
annotateTilePosition(
tile.row,
tile.col,
// Pre-annotation manifests (older artifacts) lack the resolved annotation;
// fall back to path inference for them.
tile.annotation ?? annotationFromScreenshotRelPath(tile.source)
)
);
return buildSheetMetadata({ group: manifest.group, sheetBasename, tileAnnotations });
}
/** Recursively collects sheet tile manifest paths (`*.json` with a sibling PNG). */
export async function collectSheetManifests(dir: string): Promise<string[]> {
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
const toRel = (e: (typeof entries)[number]): string =>
relative(dir, join(e.parentPath ?? e.path, e.name))
.split(sep)
.join('/');
// Build the PNG set from the same walk so the sibling-PNG check needs no extra stat per manifest.
const pngs = new Set(entries.filter((e) => e.isFile() && e.name.endsWith('.png')).map(toRel));
return entries
.filter((e) => e.isFile() && e.name.endsWith('.json') && !e.name.endsWith('.argos.json'))
.map(toRel)
.filter((manifestRel) => pngs.has(manifestRel.replace(/\.json$/, '.png')))
.sort();
}
/**
* (Re)write `.png.argos.json` sidecars from tile manifests. Manifests carry the
* resolved annotation, so this reproduces the sidecars losslessly (without the
* input screenshots) — e.g. as a pre-upload safety net.
*/
export async function ensureSheetMetadataSidecars(outDir: string): Promise<number> {
const manifests = await collectSheetManifests(outDir);
let written = 0;
for (const manifestRel of manifests) {
const manifest = JSON.parse(await readFile(join(outDir, manifestRel), 'utf8')) as SheetManifest;
await writeArgosMetadataSidecar(
join(outDir, manifest.sheet),
sheetMetadataFromManifest(manifest)
);
written += 1;
}
return written;
}
/** Writes composite PNGs, tile manifests (`.json`), and Argos metadata sidecars. */
export async function writeSheets(plans: Sheet[], options: WriteSheetsOptions): Promise<void> {
const concurrency = Math.max(1, options.concurrency ?? DEFAULT_SHEET_CONCURRENCY);
let written = 0;
const writeOne = async (plan: Sheet): Promise<void> => {
const { buffer, manifest } = await composeSheet(plan, {
inputDir: options.inputDir,
scale: options.scale,
tileWidth: options.tileWidth,
tileImageHeight: options.tileImageHeight,
});
const sheetPath = join(options.outDir, plan.output);
await mkdir(dirname(sheetPath), { recursive: true });
await writeFile(sheetPath, buffer);
await writeFile(sheetPath.replace(/\.png$/, '.json'), JSON.stringify(manifest, null, 2) + '\n');
await writeArgosMetadataSidecar(sheetPath, sheetMetadataFromManifest(manifest));
// Single-threaded increment between awaits, so the count is consistent even
// though sheets within a batch complete in nondeterministic order.
options.onSheetWritten?.(plan.output, (written += 1), plans.length);
};
for (let start = 0; start < plans.length; start += concurrency) {
await Promise.all(plans.slice(start, start + concurrency).map(writeOne));
}
}
/** Progress/diagnostic logging for the CLI run (stdout, so it shows in CI logs). */
function log(message: string): void {
process.stdout.write(`[sheets] ${message}\n`);
}
async function main(): Promise<void> {
const startedAt = Date.now();
const inputDir = process.env.SCREENSHOT_DIR ?? 'e2e/screenshots';
const outDir = process.env.SHEETS_DIR ?? 'e2e/sheets';
const tilesPerSheet = Number(process.env.TILES_PER_SHEET ?? 12);
const cols = Number(process.env.SHEET_COLS ?? 3);
const scale = Number(process.env.SHEET_SCALE ?? DEFAULT_SHEET_SCALE);
const tileWidth = Number(process.env.TILE_WIDTH ?? DEFAULT_TILE_WIDTH);
const tileImageHeight = Number(process.env.TILE_IMAGE_HEIGHT ?? DEFAULT_TILE_IMAGE_HEIGHT);
const concurrency = Number(process.env.SHEET_CONCURRENCY ?? DEFAULT_SHEET_CONCURRENCY);
const orderFile = process.env.SHEET_ORDER_FILE ?? 'e2e/sheet-order.json';
log(
`config: in=${inputDir} out=${outDir} tilesPerSheet=${tilesPerSheet} cols=${cols} scale=${scale} concurrency=${concurrency}`
);
const order = await readOrderManifest(orderFile);
const orderedGroups = Object.keys(order).length;
log(
orderedGroups > 0
? `loaded committed tile order for ${orderedGroups} groups from ${orderFile}`
: `no committed tile order at ${orderFile} — groups fall back to declaration order`
);
const relPaths = await collectScreenshots(inputDir);
log(`collected ${relPaths.length} screenshots from ${inputDir}`);
// Extend the committed order over every captured screenshot: tiles it doesn't
// pin (new tests, groups it doesn't cover) append at their group's tail in
// test-declaration order, read from the capture sidecars.
const origins = readTileOrigins(inputDir, relPaths);
const effectiveOrder = extendOrder(order, relPaths, origins);
log(`declaration order known for ${origins.size}/${relPaths.length} screenshots`);
const plans = planSheets(relPaths, { tilesPerSheet, cols, order: effectiveOrder });
const groupCount = new Set(plans.map((p) => p.group)).size;
if (plans.length === 0) {
log('no screenshots found — nothing to composite');
return;
}
log(`planned ${plans.length} sheets across ${groupCount} groups`);
await writeSheets(plans, {
inputDir,
outDir,
scale,
tileWidth,
tileImageHeight,
concurrency,
onSheetWritten: (output, written, total) => log(`wrote [${written}/${total}] ${output}`),
});
// writeSheets already wrote each sidecar from the manifest; just verify them.
const metaCheck = await verifyArgosMetadataSidecars(outDir);
log(
`metadata check: ${metaCheck.withAnnotations}/${metaCheck.pngs} sheets with tile annotations` +
(metaCheck.missingSidecars.length
? `, missing sidecars: ${metaCheck.missingSidecars.slice(0, 3).join(', ')}`
: '')
);
if (
metaCheck.missingSidecars.length > 0 ||
metaCheck.corruptSidecars.length > 0 ||
metaCheck.emptyAnnotations.length > 0
) {
throw new Error(
`Argos metadata incomplete: ${metaCheck.missingSidecars.length} missing sidecars, ` +
`${metaCheck.corruptSidecars.length} corrupt, ${metaCheck.emptyAnnotations.length} without annotations`
);
}
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
log(`done in ${seconds}s: ${relPaths.length} screenshots → ${plans.length} sheets in ${outDir}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
void main();
}