import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import sharp from 'sharp'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { argosMetadataSidecarPath, readTileOrigins, writeArgosMetadataSidecar, } from '../e2e/helpers/argos-metadata.js'; import { collectScreenshots, composeSheet, DEFAULT_TILE_IMAGE_HEIGHT, DEFAULT_TILE_WIDTH, deriveGroupKey, ensureSheetMetadataSidecars, extendOrder, findUnordered, formatTileTitle, LABEL_HEIGHT, planSheets, updateOrder, writeSheets, } from './screenshot-sheets.js'; import type { OrderManifest, TileOrigins } from './screenshot-sheets.js'; const SLOT_WIDTH = 40; const SLOT_HEIGHT = 30; const FC = 'rendering/flowchart'; const CLS = 'rendering/class'; const FC_V2 = `${FC}/flowchart-v2.spec.js`; const FC_MAIN = `${FC}/flowchart.spec.js`; const CLS_V3 = `${CLS}/classDiagram-v3.spec.js`; describe('deriveGroupKey', () => { it('returns the path up to and including the *.spec.js segment', () => { expect(deriveGroupKey(`${FC_V2}/Some Test.png`)).toBe(FC_V2); }); it('handles .spec.ts specs', () => { expect(deriveGroupKey('rendering/treemap/treemap.spec.ts/A.png')).toBe( 'rendering/treemap/treemap.spec.ts' ); }); it('keeps each spec file in its own group, even within one folder', () => { expect(deriveGroupKey(`${FC_MAIN}/x.png`)).toBe(FC_MAIN); expect(deriveGroupKey(`${FC}/flowchart-dagre.spec.js/y.png`)).toBe( `${FC}/flowchart-dagre.spec.js` ); }); it('groups folder-structured mmd snapshots by their diagram folder', () => { // mmd screenshots mirror the fixture path (diagrams//), so they // group by their containing folder — no spec segment, no name parsing. expect(deriveGroupKey('diagrams/packet/should-render-a-simple-packet-diagram.png')).toBe( 'diagrams/packet' ); // Hyphenated and nested folders are preserved as-is. expect(deriveGroupKey('diagrams/state-diagram-v2/forks.png')).toBe('diagrams/state-diagram-v2'); expect(deriveGroupKey('diagrams/flowchart/elk/basic.png')).toBe('diagrams/flowchart/elk'); }); }); describe('planSheets', () => { const paths = [`${FC_V2}/b.png`, `${FC_MAIN}/a.png`, `${CLS_V3}/c.png`]; it('isolates each spec file into separate groups and sheets', () => { const sheets = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const groups = sheets.map((s) => s.group); expect(groups).toContain(FC_V2); expect(groups).toContain(FC_MAIN); expect(groups).toContain(CLS_V3); // No sheet mixes two spec files. for (const s of sheets) { expect(s.tiles.every((t) => deriveGroupKey(t.source) === s.group)).toBe(true); } }); it('is deterministic regardless of input order', () => { const a = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const b = planSheets([...paths].reverse(), { tilesPerSheet: 12, cols: 3 }); expect(a).toStrictEqual(b); }); it('chunks a spec into fixed-size sheets', () => { const many = Array.from( { length: 13 }, (_, i) => `${FC_MAIN}/t${String(i).padStart(2, '0')}.png` ); const sheets = planSheets(many, { tilesPerSheet: 12, cols: 3 }); expect(sheets).toHaveLength(2); expect(sheets[0].tiles).toHaveLength(12); expect(sheets[1].tiles).toHaveLength(1); expect(sheets[0].output).toBe(`${FC_MAIN}/flowchart-001.png`); expect(sheets[1].output).toBe(`${FC_MAIN}/flowchart-002.png`); }); it('assigns row/col by column count', () => { const four = ['a', 'b', 'c', 'd'].map((n) => `${FC_MAIN}/${n}.png`); const [sheet] = planSheets(four, { tilesPerSheet: 12, cols: 3 }); expect(sheet.tiles.map((t) => [t.row, t.col])).toStrictEqual([ [0, 0], [0, 1], [0, 2], [1, 0], ]); }); it('adding a test to one spec leaves other specs’ sheets byte-identical', () => { const before = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const after = planSheets([...paths, `${FC_MAIN}/aa.png`], { tilesPerSheet: 12, cols: 3, }); const clsBefore = before.filter((s) => s.group === CLS_V3); const clsAfter = after.filter((s) => s.group === CLS_V3); expect(clsAfter).toStrictEqual(clsBefore); }); }); describe('formatTileTitle', () => { it('restores spaces from hyphenated Cypress screenshot names', () => { expect(formatTileTitle('1-should-render-a-basic-treemap')).toBe( '1 should render a basic treemap' ); }); }); describe('compositor', () => { let dir: string; beforeAll(async () => { dir = await mkdtemp(join(tmpdir(), 'argos-batch-')); const specDir = join(dir, 'rendering/flowchart/flowchart.spec.js'); await mkdir(specDir, { recursive: true }); // Three differently-sized solid PNGs. const tiles = [ { name: 'a.png', w: 20, h: 10, c: { r: 255, g: 0, b: 0, alpha: 1 } }, { name: 'b.png', w: 10, h: 30, c: { r: 0, g: 255, b: 0, alpha: 1 } }, { name: 'c.png', w: 40, h: 15, c: { r: 0, g: 0, b: 255, alpha: 1 } }, ]; for (const t of tiles) { const buf = await sharp({ create: { width: t.w, height: t.h, channels: 4, background: t.c }, }) .png() .toBuffer(); await writeFile(join(specDir, t.name), buf); } }); afterAll(async () => { await rm(dir, { recursive: true, force: true }); }); it('collectScreenshots returns sorted relative png paths', async () => { const paths = await collectScreenshots(dir); expect(paths).toStrictEqual([ 'rendering/flowchart/flowchart.spec.js/a.png', 'rendering/flowchart/flowchart.spec.js/b.png', 'rendering/flowchart/flowchart.spec.js/c.png', ]); }); it('composes a fixed viewport cell grid with title labels', async () => { const paths = await collectScreenshots(dir); const [plan] = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const slot = { tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT }; const { buffer, manifest } = await composeSheet(plan, { inputDir: dir, ...slot }); const meta = await sharp(buffer).metadata(); expect(meta.width).toBe(SLOT_WIDTH * 3); expect(meta.height).toBe(SLOT_HEIGHT + LABEL_HEIGHT); expect(manifest.grid).toStrictEqual({ cols: 3, rows: 1, cellWidth: SLOT_WIDTH, cellHeight: SLOT_HEIGHT + LABEL_HEIGHT, imageHeight: SLOT_HEIGHT, labelHeight: LABEL_HEIGHT, scale: 1, }); expect(manifest.tiles[0]).toMatchObject({ name: 'a', title: 'a', row: 0, col: 0 }); }); it('keeps grid dimensions when tile screenshot sizes differ', async () => { const specDir = join(dir, 'rendering/flowchart/flowchart.spec.js'); const huge = await sharp({ create: { width: 200, height: 150, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, }) .png() .toBuffer(); await writeFile(join(specDir, 'huge.png'), huge); const slot = { tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT }; const [smallPlan] = planSheets(['rendering/flowchart/flowchart.spec.js/a.png'], { tilesPerSheet: 12, cols: 3, }); const [hugePlan] = planSheets(['rendering/flowchart/flowchart.spec.js/huge.png'], { tilesPerSheet: 12, cols: 3, }); const small = await composeSheet(smallPlan, { inputDir: dir, ...slot }); const withHuge = await composeSheet(hugePlan, { inputDir: dir, ...slot }); expect(withHuge.manifest.grid).toStrictEqual(small.manifest.grid); }); it('defaults to the Cypress viewport slot size', () => { expect(DEFAULT_TILE_WIDTH).toBe(1440); expect(DEFAULT_TILE_IMAGE_HEIGHT).toBe(1024); }); it('scales output dimensions when scale > 1', async () => { const paths = (await collectScreenshots(dir)).filter((p) => !p.endsWith('huge.png')); const [plan] = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const slot = { tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT, scale: 2 as const }; const { buffer, manifest } = await composeSheet(plan, { inputDir: dir, ...slot }); const meta = await sharp(buffer).metadata(); expect(meta.width).toBe(SLOT_WIDTH * 3 * 2); expect(meta.height).toBe((SLOT_HEIGHT + LABEL_HEIGHT) * 2); expect(manifest.grid.scale).toBe(2); expect(manifest.grid.cellWidth).toBe(SLOT_WIDTH * 2); }); it('produces byte-identical output on re-run (determinism)', async () => { const paths = (await collectScreenshots(dir)).filter((p) => !p.endsWith('huge.png')); const [plan] = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const slot = { tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT }; const first = await composeSheet(plan, { inputDir: dir, ...slot }); const second = await composeSheet(plan, { inputDir: dir, ...slot }); expect(first.buffer.equals(second.buffer)).toBe(true); }); it('writes Argos metadata sidecars with one tile annotation per source', async () => { const outDir = await mkdtemp(join(tmpdir(), 'argos-out-')); const mmdDir = join(dir, 'diagrams/packet'); await mkdir(mmdDir, { recursive: true }); const mmdTile = 'diagrams/packet/simple-diagram.png'; const mmdBuf = await sharp({ create: { width: 10, height: 10, channels: 4, background: { r: 255, g: 255, b: 0, alpha: 1 }, }, }) .png() .toBuffer(); await writeFile(join(dir, mmdTile), mmdBuf); await writeArgosMetadataSidecar(join(dir, mmdTile), { automationLibrary: { name: 'playwright', version: '1.0.0' }, sdk: { name: '@argos-ci/cli', version: '5.0.0' }, test: { title: 'simple diagram', annotations: [ { type: 'mmd-fixture', description: 'e2e/diagrams/packet/simple-diagram.mmd', location: { file: 'e2e/diagrams/packet/simple-diagram.mmd', line: 1, column: 1 }, }, ], }, }); const [plan] = planSheets([mmdTile], { tilesPerSheet: 12, cols: 3 }); await writeSheets([plan], { inputDir: dir, outDir, tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT, }); const sheetPath = join(outDir, plan.output); const sidecarPath = argosMetadataSidecarPath(sheetPath); await access(sidecarPath); const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8')); expect(sidecar.test.annotations).toStrictEqual([ { type: 'tile', description: 'R1 C1: e2e/diagrams/packet/simple-diagram.mmd', location: { file: 'e2e/diagrams/packet/simple-diagram.mmd', line: 1, column: 1 }, }, ]); await rm(outDir, { recursive: true, force: true }); }); it('regenerates sidecars from tile manifests via ensureSheetMetadataSidecars', async () => { const outDir = await mkdtemp(join(tmpdir(), 'argos-out-')); const mmdTile = 'diagrams/packet/simple-diagram.png'; await mkdir(join(dir, 'diagrams/packet'), { recursive: true }); await writeFile( join(dir, mmdTile), await sharp({ create: { width: 10, height: 10, channels: 4, background: { r: 0, g: 128, b: 255, alpha: 1 }, }, }) .png() .toBuffer() ); const [plan] = planSheets([mmdTile], { tilesPerSheet: 12, cols: 3 }); await writeSheets([plan], { inputDir: dir, outDir, tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT, }); const sidecarPath = argosMetadataSidecarPath(join(outDir, plan.output)); await rm(sidecarPath); expect(await ensureSheetMetadataSidecars(outDir)).toBe(1); const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8')); expect(sidecar.test.annotations).toHaveLength(1); expect(sidecar.test.annotations[0].description).toContain('simple-diagram.mmd'); await rm(outDir, { recursive: true, force: true }); }); it('preserves rich per-tile annotations (real spec file/line/column) through regeneration', async () => { const outDir = await mkdtemp(join(tmpdir(), 'argos-out-')); const specTile = 'rendering/flowchart/flowchart.spec.js/should-do-a-thing.png'; await mkdir(join(dir, 'rendering/flowchart/flowchart.spec.js'), { recursive: true }); await writeFile( join(dir, specTile), await sharp({ create: { width: 10, height: 10, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, }) .png() .toBuffer() ); // Input sidecar carries the real test location (line/column ≠ 1) that pure // path inference cannot recover. await writeArgosMetadataSidecar(join(dir, specTile), { automationLibrary: { name: 'playwright', version: '1.0.0' }, sdk: { name: '@argos-ci/cli', version: '5.0.0' }, test: { title: 'should do a thing', titlePath: ['flowchart.spec.js', 'Flowchart', 'should do a thing'], annotations: [ { type: 'test', description: 'Flowchart › should do a thing', location: { file: 'e2e/rendering/flowchart/flowchart.spec.js', line: 42, column: 7 }, }, ], }, }); const [plan] = planSheets([specTile], { tilesPerSheet: 12, cols: 3 }); await writeSheets([plan], { inputDir: dir, outDir, tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT, }); const sidecarPath = argosMetadataSidecarPath(join(outDir, plan.output)); await rm(sidecarPath); // Regeneration reads the manifest (source of truth) — it must NOT downgrade // the location to line 1 / column 1 the way path inference would. expect(await ensureSheetMetadataSidecars(outDir)).toBe(1); const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8')); expect(sidecar.test.annotations).toStrictEqual([ { type: 'tile', description: 'R1 C1: Flowchart › should do a thing', location: { file: 'e2e/rendering/flowchart/flowchart.spec.js', line: 42, column: 7 }, }, ]); await rm(outDir, { recursive: true, force: true }); }); }); describe('append-only tile order', () => { // The order manifest is keyed by group (deriveGroupKey) with the group prefix // stripped, so a per-spec group's tiles are bare filenames. const tiles = (n: number, prefix = 'a'): string[] => Array.from({ length: n }, (_, i) => `${FC_MAIN}/${prefix}-${String(i).padStart(3, '0')}.png`); const sheetSig = (paths: string[], order?: Record): string[] => planSheets(paths, { tilesPerSheet: 12, cols: 3, order }).map( (s) => `${s.group}#${s.index}:${s.tiles.map((t) => t.source).join(',')}` ); it('updateOrder appends new sources at the group tail and drops removed ones', () => { const base = updateOrder(tiles(3)); // Add z-new, remove a-000. const next = updateOrder( [`${FC_MAIN}/a-001.png`, `${FC_MAIN}/a-002.png`, `${FC_MAIN}/z-new.png`], base ); expect(next[FC_MAIN]).toEqual([ 'a-001.png', // kept, original order 'a-002.png', 'z-new.png', // appended at the tail, not sorted into the middle ]); }); it('inserting a mid-sorted tile leaves all prior sheets byte-identical (the churn fix)', () => { const initial = tiles(26); // 26 tiles, N=12 → sheets [0..11],[12..23],[24..25] const order = updateOrder(initial); const before = sheetSig(initial, order); const inserted = `${FC_MAIN}/a-005-INSERTED.png`; // name sorts into the middle const orderAfter = updateOrder([...initial, inserted], order); // append-only manifest const after = sheetSig([...initial, inserted], orderAfter); expect(after[0]).toBe(before[0]); // sheet 0 untouched expect(after[1]).toBe(before[1]); // sheet 1 untouched const changed = before.filter((s, i) => s !== after[i]).length + (after.length - before.length); expect(changed).toBeLessThanOrEqual(2); }); it('alphabetical insertion (no manifest) shifts the tail — the problem this fixes', () => { const initial = tiles(26); const before = sheetSig(initial); // no order → alphabetical const inserted = `${FC_MAIN}/a-005-INSERTED.png`; const after = sheetSig([...initial, inserted]); const changed = before.filter((s, i) => s !== after[i]).length + (after.length - before.length); expect(changed).toBeGreaterThan(2); }); it('findUnordered flags only sources absent from the manifest, grouped by key', () => { const order = updateOrder(tiles(2)); expect(findUnordered([...tiles(2), `${FC_MAIN}/brand-new.png`], order)).toEqual({ [FC_MAIN]: ['brand-new.png'], }); expect(findUnordered(tiles(2), order)).toEqual({}); }); it('planSheets without an order is unchanged (alphabetical fallback)', () => { const paths = tiles(5).reverse(); const fallback = planSheets(paths, { tilesPerSheet: 12, cols: 3 }); const empty = planSheets(paths, { tilesPerSheet: 12, cols: 3, order: {} }); expect(fallback[0].tiles.map((t) => t.source)).toEqual(empty[0].tiles.map((t) => t.source)); expect(fallback[0].tiles.map((t) => t.source)).toEqual([...paths].sort()); }); }); describe('declaration-order tail for unpinned tiles', () => { // Every *.spec.* group is missing from the committed manifest today (it only // covers the mmd `diagrams/*` folders), so alphabetical layout let a new test // whose slug sorts early take cell 1 and shift every other tile. The CLI now // extends the manifest over all captured screenshots before planning // (extendOrder): unpinned tiles are pinned in test-declaration order, so a // test added at the end of its spec appends at its group's tail. const SPEC_FILE = `e2e/${FC_MAIN}`; /** Sources with the `test()` call line their capture sidecars would record. */ const declared = (entries: [string, number][]) => ({ sources: entries.map(([name]) => `${FC_MAIN}/${name}`), origins: new Map( entries.map(([name, line]) => [`${FC_MAIN}/${name}`, { file: SPEC_FILE, line, column: 3 }]) ), }); /** Tile names as the CLI lays them out: manifest extended, then planned. */ const cells = (sources: string[], origins?: TileOrigins, previous: OrderManifest = {}) => planSheets(sources, { tilesPerSheet: 12, cols: 3, order: extendOrder(previous, sources, origins), }).flatMap((sheet) => sheet.tiles.map((t) => t.name)); it('lays an unpinned group out in declaration order, not alphabetically', () => { const { sources, origins } = declared([ ['zebra.png', 10], ['apple.png', 20], ['mango.png', 30], ]); expect(cells(sources, origins)).toEqual(['zebra', 'apple', 'mango']); // Without the extend pass the same group re-sorts alphabetically. const [bare] = planSheets(sources, { tilesPerSheet: 12, cols: 3 }); expect(bare.tiles.map((t) => t.name)).toEqual(['apple', 'mango', 'zebra']); }); it('a test added at the end of its spec appends, leaving every other tile in place', () => { const { sources, origins } = declared([ ['basic-er-diagram.png', 10], ['crows-foot-notation.png', 20], ['zzz-last-one.png', 30], ]); const before = cells(sources, origins); // Alphabetically first, but declared last — the case that used to take cell 1. const added = `${FC_MAIN}/aaa-newly-added-test.png`; const after = cells( [...sources, added], new Map([...origins, [added, { file: SPEC_FILE, line: 40, column: 3 }]]) ); expect(after).toEqual([...before, 'aaa-newly-added-test']); }); it('keeps earlier sheets byte-identical when a test is appended to a multi-sheet group', () => { const entries: [string, number][] = Array.from({ length: 26 }, (_, i) => [ `t-${String(i).padStart(3, '0')}.png`, (i + 1) * 10, ]); const { sources, origins } = declared(entries); const sig = (paths: string[], o: TileOrigins) => planSheets(paths, { tilesPerSheet: 12, cols: 3, order: extendOrder({}, paths, o) }).map( (sheet) => `${sheet.group}#${sheet.index}:${sheet.tiles.map((t) => t.source).join(',')}` ); const before = sig(sources, origins); const added = `${FC_MAIN}/aaa-appended.png`; // sorts first, declared last const after = sig( [...sources, added], new Map([...origins, [added, { file: SPEC_FILE, line: 999, column: 3 }]]) ); expect(after[0]).toBe(before[0]); expect(after[1]).toBe(before[1]); expect(after).toHaveLength(before.length); // 27 tiles still fit in 3 sheets expect(after[2]).toBe(`${before[2]},${added}`); }); it('breaks call-site ties (loop-registered tests) and sidecar-less tiles by path order', () => { const { sources, origins } = declared([ ['loop-b.png', 12], ['loop-a.png', 12], // same test() line as loop-b ['later.png', 20], ]); const orphans = [`${FC_MAIN}/no-sidecar-b.png`, `${FC_MAIN}/no-sidecar-a.png`]; expect(cells([...orphans, ...sources], origins)).toEqual([ 'loop-a', 'loop-b', 'later', 'no-sidecar-a', // sidecar-less tiles sort after located ones 'no-sidecar-b', ]); }); it('leaves mmd fixture groups alphabetical (one runner call site registers all)', () => { // mmd-snapshots.spec.ts registers every fixture from a single test() call, so // all of its captures tie on declaration position — the mmd `diagrams/*` // groups (the only ones the committed manifest covers) keep today's layout. const runner = { file: 'e2e/rendering/mmd-snapshots.spec.ts', line: 26, column: 5 }; const fixtures = ['diagrams/packet/zebra.png', 'diagrams/packet/apple.png']; const origins = new Map(fixtures.map((f) => [f, runner])); expect(extendOrder({}, fixtures, origins)['diagrams/packet']).toEqual([ 'apple.png', 'zebra.png', ]); }); it('extendOrder keeps committed entries verbatim, appending only unpinned tiles', () => { const { sources, origins } = declared([ ['pinned-a.png', 10], ['new-zebra.png', 30], ['new-apple.png', 40], ]); // pinned-b has no capture this run: its entry (and thus its blank slot) stays. const previous = { [FC_MAIN]: ['pinned-a.png', 'pinned-b.png'] }; expect(extendOrder(previous, sources, origins)[FC_MAIN]).toEqual([ 'pinned-a.png', 'pinned-b.png', 'new-zebra.png', // declared before new-apple, so it appends first 'new-apple.png', ]); expect(cells(sources, origins, previous)).toEqual([ 'pinned-a', 'pinned-b', // blank cell — slot kept 'new-zebra', 'new-apple', ]); }); it('updateOrder folds newly-seen screenshots in declaration order and prunes removals', () => { const { sources, origins } = declared([ ['kept.png', 10], ['new-zebra.png', 20], ['new-apple.png', 30], ]); const previous = { [FC_MAIN]: ['removed.png', 'kept.png'], 'gone/entirely.spec.js': ['x.png'], }; expect(updateOrder(sources, previous, origins)).toEqual({ [FC_MAIN]: ['kept.png', 'new-zebra.png', 'new-apple.png'], }); // Without origins the new pair falls back to alphabetical order. expect(updateOrder(sources, previous)[FC_MAIN]).toEqual([ 'kept.png', 'new-apple.png', 'new-zebra.png', ]); }); it('round-trips a root-level screenshot without blanking or pruning it', () => { // deriveGroupKey('tile.png') is the synthetic 'root' key; rebuilding sources // must not prefix it back on, or the tile turns into a blank 'root/tile.png' // cell and drops out of the refreshed manifest. const order = extendOrder({}, ['tile.png']); expect(order).toEqual({ root: ['tile.png'] }); const [sheet] = planSheets(['tile.png'], { tilesPerSheet: 12, cols: 3, order }); expect(sheet.tiles.map((t) => [t.source, t.missing ?? false])).toEqual([['tile.png', false]]); expect(updateOrder(['tile.png'], order)).toEqual({ root: ['tile.png'] }); }); it('reads declaration positions from the capture sidecars on disk', async () => { const dir = await mkdtemp(join(tmpdir(), 'argos-origins-')); const source = `${FC_MAIN}/a.png`; await mkdir(join(dir, FC_MAIN), { recursive: true }); await writeArgosMetadataSidecar(join(dir, source), { $schema: 'https://api.argos-ci.com/v2/screenshot-metadata.json', test: { title: 'a', location: { file: SPEC_FILE, line: 42, column: 3 }, annotations: [], }, automationLibrary: { name: 'playwright', version: '1' }, sdk: { name: '@argos-ci/cli', version: '1' }, }); await writeFile(join(dir, FC_MAIN, 'b.png.argos.json'), 'not json'); const origins = readTileOrigins(dir, [source, `${FC_MAIN}/b.png`, `${FC_MAIN}/c.png`]); expect(origins.get(source)).toEqual({ file: SPEC_FILE, line: 42, column: 3 }); expect(origins.has(`${FC_MAIN}/b.png`)).toBe(false); // corrupt sidecar expect(origins.has(`${FC_MAIN}/c.png`)).toBe(false); // no sidecar await rm(dir, { recursive: true, force: true }); }); }); describe('blank cells for removed tests', () => { const order = { [FC_MAIN]: ['a.png', 'b.png', 'c.png'] }; it('keeps a removed test’s slot as a blank tile (no shift of later tiles)', () => { // b removed; its group (the spec) still ran (a and c present). const present = [`${FC_MAIN}/a.png`, `${FC_MAIN}/c.png`]; const [sheet] = planSheets(present, { tilesPerSheet: 12, cols: 3, order }); expect(sheet.tiles.map((t) => [t.name, t.missing ?? false])).toEqual([ ['a', false], ['b', true], // blank placeholder, keeps position 1 ['c', false], // still at position 2 — not shifted up ]); }); it('produces no sheet (no stray blanks) for a manifest group that did not run', () => { // Unlike the coarse-grouped upstream, our group key IS the scoping unit, so a // scoped-out group simply has no captured screenshots → no sheet, no blanks. const present = [`${FC_V2}/x.png`]; const sheets = planSheets(present, { tilesPerSheet: 12, cols: 3, order }); expect(sheets.map((s) => s.group)).toEqual([FC_V2]); expect(sheets.flatMap((s) => s.tiles).every((t) => !t.missing)).toBe(true); }); it('composeSheet renders a missing tile as a blank cell without reading a file', async () => { const plan = planSheets([`${FC_MAIN}/a.png`, `${FC_MAIN}/c.png`], { tilesPerSheet: 12, cols: 3, order, })[0]; // Empty inputDir: only present tiles would be read, so reading a missing tile // would throw. Mark every slot missing → pure blank-cell render. const emptyDir = await mkdtemp(join(tmpdir(), 'argos-missing-')); const blankPlan = { ...plan, tiles: plan.tiles.map((t) => ({ ...t, missing: true })) }; const { buffer, manifest } = await composeSheet(blankPlan, { inputDir: emptyDir, tileWidth: SLOT_WIDTH, tileImageHeight: SLOT_HEIGHT, }); expect((await sharp(buffer).metadata()).width).toBe(SLOT_WIDTH * 3); expect(manifest.tiles.every((t) => t.missing)).toBe(true); await rm(emptyDir, { recursive: true, force: true }); }); });