Files
gstack/test/motion-encode.test.ts
T
Garry Tanandcapy-ai[bot] c2e01e4ba7 v1.91.264.0 feat(design-motion): new /design-motion skill for showcase loops, adapted from Elaya's design-to-motion (GSTA-92 phase 2) (#3430)
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-motion): new /design-motion skill: showcase MP4 and GIF loops from a design, adapted from Elaya design-to-motion (GSTA-92 phase 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-motion): run design-motion-showcase in the marathon tier; the periodic census is at its 38-slice cap (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): review and QA fixes before ship

- encode: refuse unknown options and --formats tokens, validate --pad (#RRGGBB[AA])
  and --crop (compare too) so nothing but a colour or crop reaches ffmpeg's filter graph
- encode: export refuses a capture with missing window frames; check prints
  MOTION_SETTINGS for any threshold/phase override; contact sheet drops -fps_mode
- capture: portrait-aware scale, --cycle must be whole 60 fps frames, stills with
  CSS animations disabled, pauseAt tolerates slow loads, clears stale manifest/stills
- skill: <motion-dir> placeholder instead of a cross-block shell variable,
  GSTACK_FFMPEG-aware probe, stop points before the first capture, doctrine fixes
- tests: refusal rows, portrait scale, cycle guard

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-motion): capture-check-export pipeline on real Chromium and ffmpeg

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): second review cycle

- capture: refuse an --out that holds a still or frames but no capture.json
  instead of deleting them; --cycle must be a multiple of 0.05 s so the seam
  frame lands exactly one cycle later for every PRE (nearest value round-trips)
- encode: a capture without a mask ends 'MOTION_RESULT: clean (overflow not checked)'
- skill: Step 5 names window.__mark in the capture contract
- tests: foreign/previous --out handling on real Chromium, cycle guard and PRE sweep

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): third review cycle and adversarial findings

- capture: an ownership marker (.gstack-motion-capture) replaces the
  capture.json test, so the rerun after MOTION_CONTRACT_MISSING or a crash
  works and a foreign --out is still refused; offscreen lazy images are made
  eager and font/image loading is bounded at 30 s instead of hanging
- encode: refuse a capture.json whose fields are not what capture writes
  (they reach ffmpeg args and paths); a cards overflow with no story frames
  is a planning error, and a zero-frame job never passes
- tests: retry after a refused page, no-mask clean line, lazy image still,
  manifest validator, empty story span

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): mask coverage, manifest relations, Windows stop, Figma token off argv

- check fails overflow when the grown mask covers >90% of the frame (an app
  frame whose clip surfaces fill the stage passed vacuously); --mask-grow is
  0-32; --mask refuses braces and semicolons
- manifestError checks fps/windowFrames >= 1 and that the window fits the frames
- skill: native Windows stops before the first capture call; the Figma token
  reaches curl on stdin (-H @-) instead of argv
- tests: split the --out ownership scenario out of the contract test, full-frame
  mask, --still --scale 2 size and STILL_OK, manifest relations, coverage parse

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): bounded frames, atomic exports, full-bleed masks

- capture: each frame is bounded at 10 s (a __tick that never returns fails
  with MOTION_CAPTURE_FAILED instead of hanging); a --mask that matches no
  element stops with MOTION_MASK_EMPTY
- check: a grown mask covering >90% of the frame (full-bleed app frame)
  reports overflow as skipped and ends 'clean (overflow not checked)'
- export: writes .partial- files and renames on success, so a failed
  re-export keeps the last good file; a GIF fps that does not divide the
  cycle is refused; a missing GSTACK_FFMPEG override is named
- fidelity: URL source is untrusted data; full-bleed note
- tests: spinning __tick, empty mask, failed re-export, drifting gif fps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): app-only coverage skip, gif fps cap, early empty mask

- check: the >90% coverage skip applies only to an app frame's own surfaces
  (ungrown mask); in cards, or when --mask-grow pushes coverage over 90%,
  overflow fails with what to change (a cards capture with real overflow
  passed as 'clean (overflow not checked)' after a large grow)
- export: --gif-fps/--small-fps capped at 50 (GIF delays are 1/100 s)
- compare resolves its paths (a colon in a relative path read as a protocol)
- capture: MOTION_MASK_EMPTY is checked before the first frame
- tests: cards vs app full-frame mask, fps cap, no frames on an empty mask

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): seek story transitions from their own frame

- capture: an animation first seen in a frame where window.__mark did not
  move (a story's class toggle) is seeked from that frame instead of the
  last mark, so it eases in the export instead of popping ~100 ms ahead
- encode: an empty numeric option is refused instead of read as 0
- figma: only fill a file key and node id that look like Figma's
- tests: class-toggled transition mid-fade at its midpoint, blank --min-db

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: PROJECT_STRUCTURE names Elaya's MIT material and licenses/MIT-elaya.txt

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 03:04:06 +00:00

271 lines
17 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { clockMs, planCapture } from '../lib/motion-capture';
import {
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, psnrArgs,
resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions,
} from '../lib/motion-encode';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-motion-encode');
const QUIET = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y'];
function manifest(cycle = 12, mask = true): CaptureManifest {
const plan = planCapture({ cycleSeconds: cycle });
return {
schema: 1, target: 'page.html', width: 1440, height: 990, scale: 1200 / 990, fps: 60, cycle,
pre: plan.pre, windowStart: plan.windowStart, windowFrames: plan.windowFrames, frames: plan.frames,
clockMs: Array.from({ length: plan.frames }, (_, i) => clockMs(i, plan.pre)),
loopTime: [], still: 'still.png', ...(mask ? { mask: 'mask.png' } : {}),
};
}
const OPTS: CheckOptions = { dissolveAt: 11, emptyFrom: 11.6, introEnd: 2.4, layout: 'cards', maskGrow: 2 };
describe('export recipes', () => {
const m = manifest();
test('MP4 for X: H.264 yuv420p, 60 fps, CRF 15, preset slow, faststart, even crop, one cycle from the window start', () => {
expect(mp4Args(m, '/c', '/c/n-x.mp4')).toEqual([...QUIET,
'-start_number', '14', '-framerate', '60', '-i', '/c/frames/%05d.png', '-frames:v', '720',
'-vf', 'crop=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'slow', '-crf', '15',
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '/c/n-x.mp4']);
});
test('loop GIF: trim to the cycle, 20 fps, 960 wide lanczos, 192-colour diff palette, bayer 4, loop forever', () => {
const args = loopGifArgs(m, '/c', '/c/n-loop.gif');
expect(args.slice(args.indexOf('-filter_complex') + 1)).toEqual([
'trim=end_frame=720,fps=20,scale=960:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle',
'-loop', '0', '/c/n-loop.gif']);
});
test('800x500 GIF: optional content crop, fit, pad with the page background', () => {
const args = smallGifArgs(m, '/c', '/c/n.gif', { crop: '1600:1000:72:100', pad: '#f4f1ea', fps: 12 });
const filter = args[args.indexOf('-filter_complex') + 1]!;
expect(filter.startsWith('trim=end_frame=720,fps=12,crop=1600:1000:72:100,scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos,pad=800:500:(ow-iw)/2:(oh-ih)/2:color=0xf4f1ea,split[a][b];')).toBe(true);
expect(smallGifArgs(m, '/c', '/c/n.gif').join(' ')).toContain('fps=20,scale=800:500');
});
test('a crop must be 1.6:1', () => {
expect(cropError('1600:1000:0:0')).toBeNull();
expect(cropError('801:500:0:0')).toBeNull();
expect(cropError('1000:1000:0:0')).toContain('width 1600');
expect(cropError('1600x1000')).toContain('w:h:x:y');
});
// Value: protects=values that reach the ffmpeg filter graph (--pad, compare's --crop) are plain colours and w:h:x:y;
// fails_when=a value with ',' or ';' reaches the graph and adds filters or inputs; why_new=pad and compare's crop were unvalidated;
// seam=none; no_claim=validators only; the CLI refusal rows are in the exit-2 test below
test('filter-graph values are validated: a hex pad and a w:h:x:y crop', () => {
expect(padError('#f4f1ea')).toBeNull();
expect(padError('f4f1eaff')).toBeNull();
expect(padError('#fff')).toContain('hex colour');
expect(padError('#ffffff,hue=s=0')).toContain('hex colour');
expect(cropError('1000:1000:0:0', { ratio: false })).toBeNull();
expect(cropError('100:100:0:0,hue=s=0', { ratio: false })).toContain('w:h:x:y');
});
test('the contact sheet tiles twelve frames from the first to the last of the window', () => {
const args = contactSheetArgs(m, '/c', '/c/sheet.png');
const vf = args[args.indexOf('-vf') + 1]!;
expect(vf).toContain(String.raw`eq(n\,0)+`);
expect(vf).toContain(String.raw`eq(n\,719)'`);
expect(vf).toContain('tile=4x3');
});
});
describe('check planning', () => {
test('seam, empty beat, rest and overflow compare the right frames', () => {
const m = manifest();
const jobs = planChecks(m, '/c', OPTS) as CheckJob[];
expect(jobs.map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest', 'overflow']);
const [seam, empty, rest, overflow] = jobs;
expect(seam!.what).toBe('frame 14 vs 734');
expect(seam!.args).toContain('/c/frames/00014.png');
expect(seam!.args).toContain('/c/frames/00734.png');
// Empty beat: clock in [11.6 s, 12 s) inside the window, compared with the pre-start frame.
const emptyFrames = Array.from({ length: m.frames }, (_, i) => i)
.filter(i => i > 14 && i < 734 && m.clockMs[i]! >= 11_600);
expect(empty!.expected).toBe(emptyFrames.length);
expect(empty!.what).toBe(`frames ${emptyFrames[0]}-733 vs 14`);
expect(empty!.args.slice(empty!.args.indexOf('-start_number'), empty!.args.indexOf('-start_number') + 2)).toEqual(['-start_number', String(emptyFrames[0])]);
// Rest: the last frame whose clock is still before the dissolve.
const restFrame = Math.max(...Array.from({ length: 734 }, (_, i) => i).filter(i => i > 14 && m.clockMs[i]! < 11_000));
expect(rest!.what).toBe(`frame ${restFrame} vs still.png`);
expect(m.clockMs[restFrame + 1]).toBeGreaterThanOrEqual(11_000);
// Cards overflow: story frames (intro landed .. dissolve) against the still, through the mask.
expect(overflow!.what).toContain('story frames');
expect(overflow!.args.join(' ')).toContain('/c/mask.png');
expect(overflow!.args.join(' ')).toContain('/c/still.png');
});
test('an app frame compares every window frame with the empty first frame', () => {
const jobs = planChecks(manifest(), '/c', { ...OPTS, layout: 'app' }) as CheckJob[];
const overflow = jobs.find(j => j.id === 'overflow')!;
expect(overflow.expected).toBe(720);
expect(overflow.what).toBe('every window frame vs 14, outside the mask');
});
test('no mask, no overflow job; bad phases and a short tail are refused', () => {
expect((planChecks(manifest(12, false), '/c', OPTS) as CheckJob[]).map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest']);
expect((planChecks(manifest(), '/c', { ...OPTS, dissolveAt: 12.5 }) as { error: string }).error).toContain('intro-end < dissolve-at');
const short = { ...manifest(), frames: 734 };
expect((planChecks(short, '/c', OPTS) as { error: string }).error).toContain('--tail');
});
test('the mask is grown by one dilation per pixel and blacks out the containers on both sides', () => {
const args = psnrArgs({ first: '/c/frames/%05d.png', start: 20, count: 5, reference: '/c/still.png', mask: '/c/mask.png', maskGrow: 3 });
const graph = args[args.indexOf('-filter_complex') + 1]!;
expect(graph).toContain('format=gray,dilation,dilation,dilation,format=gbrp,split[m1][m2]');
expect(graph).toContain("[f][m1]blend=all_expr='if(gt(B,127),0,A)'[fa]");
expect(graph).toContain('[fa][ra]psnr=stats_file=-');
expect(graph).toContain('trim=end_frame=5');
});
test('compare crops and blurs both sides the same way', () => {
const args = compareArgs('a.png', 'b.png', { blur: 1.2, crop: '100:100:10:10' });
expect(args[args.indexOf('-filter_complex') + 1]).toBe('[0:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[a];[1:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[b];[a][b]psnr=stats_file=-');
});
});
describe('PSNR parsing and verdicts', () => {
const STATS = [
'n:1 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
'n:2 mse_avg:1.53 mse_r:1.40 mse_g:1.60 mse_b:1.59 psnr_avg:46.28 psnr_r:46.67 psnr_g:46.09 psnr_b:46.12',
'garbage line',
'n:3 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
].join('\n');
const job: CheckJob = { id: 'empty-beat', what: 'frames 711-733 vs 14', args: [], expected: 3 };
test('inf becomes Infinity and junk lines are skipped', () => {
expect(parsePsnrStats(STATS)).toEqual([{ n: 1, psnr: Infinity }, { n: 2, psnr: 46.28 }, { n: 3, psnr: Infinity }]);
expect(parsePsnrStats('')).toEqual([]);
});
test('identical frames pass; one differing frame fails with its worst value', () => {
const allInf = parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf'));
expect(judgeCheck(job, allInf)).toEqual({ id: 'empty-beat', pass: true, line: 'MOTION_CHECK: empty-beat pass (frames 711-733 vs 14)' });
const v = judgeCheck(job, parsePsnrStats(STATS));
expect(v.pass).toBe(false);
expect(v.line).toBe('MOTION_CHECK: empty-beat fail 1 frame(s) differ, worst 46.28 dB at compared frame 2 (frames 711-733 vs 14)');
expect(judgeCheck(job, parsePsnrStats(STATS), 45).pass).toBe(true);
});
test('fewer compared frames than planned is a failure, never a pass', () => {
const v = judgeCheck({ ...job, expected: 23 }, parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf')));
expect(v.pass).toBe(false);
expect(v.line).toContain('compared 3 of 23 frames');
expect(judgeCheck({ ...job, expected: 0 }, []).pass).toBe(false);
});
// Value: protects=phase overrides that leave no story frame refuse the check instead of passing overflow on zero frames;
// fails_when=the empty-span guard is dropped and overflow reports pass for 'story frames undefined-undefined'; why_new=new guard; seam=none;
// no_claim=planning only
test('a cards overflow with no story frames is a planning error', () => {
const r = planChecks(manifest(), '/c', { ...OPTS, introEnd: 10.99, dissolveAt: 11 });
expect(r).toEqual({ error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' });
});
// Value: protects=a capture.json this tool did not write cannot put text into ffmpeg filter graphs or paths outside the capture dir;
// fails_when=a field check is dropped and a string windowFrames or a ../ still reaches the encoder; why_new=new validator; seam=none;
// no_claim=validator only; the bin calls it before building any job
test('a capture manifest is checked before its fields reach ffmpeg', () => {
expect(manifestError(manifest())).toBeNull();
expect(manifestError(manifest(12, false))).toBeNull();
expect(manifestError({ ...manifest(), schema: 2 })).toContain('schema 1');
expect(manifestError({ ...manifest(), windowFrames: '60,metadata=mode=print' })).toContain('windowFrames must be a non-negative integer');
expect(manifestError({ ...manifest(), cycle: 0 })).toContain('cycle must be a positive number');
expect(manifestError({ ...manifest(), clockMs: [0] })).toContain('one number per frame');
expect(manifestError({ ...manifest(), still: '../still.png' })).toContain('still must be a .png file name');
expect(manifestError({ ...manifest(), mask: 'mask.png,hue=s=0' })).toContain('mask must be a .png file name');
expect(manifestError(null)).toContain('schema 1');
expect(manifestError({ ...manifest(), fps: 0 })).toContain('at least 1');
expect(manifestError({ ...manifest(), windowFrames: 1_000_000_000 })).toContain('runs past its frames');
});
test('mask coverage reads signalstats YAVG as a fraction of white', () => {
const args = maskCoverageArgs('/c/mask.png', 2);
expect(args[args.indexOf('-vf') + 1]).toBe('format=gray,dilation,dilation,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-');
expect(parseMaskCoverage('frame:0 pts:0 pts_time:0\nlavfi.signalstats.YAVG=127.5\n')).toBe(0.5);
expect(parseMaskCoverage('')).toBeNull();
});
});
describe('ffmpeg is optional and named when missing', () => {
test('resolution prefers GSTACK_FFMPEG, refuses a missing override, else uses PATH', () => {
const which = (b: string) => (b === 'ffmpeg' ? '/usr/bin/ffmpeg' : null);
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, p => p === '/opt/ff')).toBe('/opt/ff');
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, () => false)).toBeNull();
expect(resolveFfmpeg({}, which, () => true)).toBe('/usr/bin/ffmpeg');
expect(resolveFfmpeg({}, () => null, () => true)).toBeNull();
});
test('the CLI prints FFMPEG_MISSING with install hints and exits 3', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-'));
try {
const r = Bun.spawnSync([BIN, 'check', dir], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: path.join(dir, 'no-ffmpeg') } });
expect(r.exitCode).toBe(3);
expect(r.stdout.toString().trim()).toBe(FFMPEG_MISSING);
expect(r.stderr.toString()).toContain(`GSTACK_FFMPEG=${path.join(dir, 'no-ffmpeg')} does not exist`);
expect(FFMPEG_MISSING).toContain('brew install ffmpeg');
expect(FFMPEG_MISSING).toContain('apt-get install -y ffmpeg');
expect(FFMPEG_MISSING).toContain('winget install');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a usage error exits 2 without touching ffmpeg', () => {
const r = Bun.spawnSync([BIN, 'encode'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: '/nonexistent/ffmpeg' } });
expect(r.exitCode).toBe(2);
expect(r.stderr.toString()).toContain('Usage: gstack-motion-encode check');
});
// Value: protects=an unreadable capture, a bad --layout, impossible phases, a missing --name and a non-1.6:1 crop exit 2 before any ffmpeg run;
// fails_when=a guard is dropped and ffmpeg runs on bad input or the CLI exits 0/1; why_new=only the no-subcommand usage path was tested; seam=none;
// no_claim=a stand-in file satisfies GSTACK_FFMPEG resolution and is never executed, so real ffmpeg is not exercised
test('bad captures and bad options exit 2 once ffmpeg resolves, before it runs', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-usage-'));
try {
const fake = path.join(dir, 'fake-ffmpeg');
fs.writeFileSync(fake, '');
const cli = (...args: string[]) => {
const r = Bun.spawnSync([BIN, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: fake } });
return { code: r.exitCode, err: r.stderr.toString() };
};
const empty = path.join(dir, 'empty');
fs.mkdirSync(empty);
const missing = cli('check', empty);
expect(missing.code).toBe(2);
expect(missing.err).toContain('capture.json');
expect(missing.err).toContain('capture first with gstack-motion-capture.ts');
const cap = path.join(dir, 'cap');
fs.mkdirSync(cap);
fs.writeFileSync(path.join(cap, 'capture.json'), JSON.stringify(manifest()));
expect(cli('check', cap, '--layout', 'grid')).toEqual({ code: 2, err: '--layout must be app or cards\n' });
const phases = cli('check', cap, '--dissolve-at', '13');
expect(phases.code).toBe(2);
expect(phases.err).toContain('intro-end < dissolve-at <= empty-from < cycle');
const name = cli('export', cap, '--name', 'Bad Name');
expect(name.code).toBe(2);
expect(name.err).toContain('--name <kebab-case-name> is required');
const crop = cli('export', cap, '--name', 'ok', '--crop', '1000:1000:0:0');
expect(crop.code).toBe(2);
expect(crop.err).toContain('is not 1.6:1');
// Value: protects=unknown flags, unknown formats, a non-hex pad, a malformed compare crop and missing frames exit 2 before ffmpeg;
// fails_when=a typo'd threshold or format is ignored, or export encodes a truncated capture and prints EXPORT_OK;
// why_new=these guards are new; seam=none; no_claim=the stand-in ffmpeg is never executed
expect(cli('check', cap, '--dissolveat', '10').err).toContain("unknown option '--dissolveat' for check");
expect(cli('check', cap, '--dissolveat', '10').code).toBe(2);
expect(cli('export', cap, '--name', 'ok', '--formats', 'mp4,gif')).toMatchObject({ code: 2, err: expect.stringContaining("got 'gif'") });
expect(cli('export', cap, '--name', 'ok', '--pad', '#ffffff,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--pad must be a hex colour') });
expect(cli('compare', 'a.png', 'b.png', '--crop', '1:1:0:0,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--crop must be w:h:x:y') });
expect(cli('export', cap, '--name', 'ok')).toMatchObject({ code: 2, err: expect.stringContaining('the capture is incomplete') });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});