Ctrl K
ComunidadGuías…

scripts/screenshot-sheets.spec.ts

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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/<type>/<name>), 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, string[]>): 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 });
  });
});