@invarn/cibuild 2.3.8 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cli.cjs +54 -37
  2. package/dist/src/cli.js +8 -0
  3. package/dist/src/commands/fidelity-trim.d.ts +13 -0
  4. package/dist/src/commands/fidelity-trim.d.ts.map +1 -0
  5. package/dist/src/commands/fidelity-trim.js +21 -0
  6. package/dist/src/commands/fidelity-trim.test.d.ts +8 -0
  7. package/dist/src/commands/fidelity-trim.test.d.ts.map +1 -0
  8. package/dist/src/commands/fidelity-trim.test.js +79 -0
  9. package/dist/src/yaml/steps/render-post-processor.d.ts +52 -32
  10. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -1
  11. package/dist/src/yaml/steps/render-post-processor.js +104 -205
  12. package/dist/src/yaml/steps/render-post-processor.test.d.ts +7 -10
  13. package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -1
  14. package/dist/src/yaml/steps/render-post-processor.test.js +80 -183
  15. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +28 -0
  16. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +1 -0
  17. package/dist/src/yaml/steps/trim-align-corpus.test.js +383 -0
  18. package/dist/src/yaml/steps/trim-align.d.ts +85 -0
  19. package/dist/src/yaml/steps/trim-align.d.ts.map +1 -0
  20. package/dist/src/yaml/steps/trim-align.js +348 -0
  21. package/dist/src/yaml/steps/trim-align.test.d.ts +11 -0
  22. package/dist/src/yaml/steps/trim-align.test.d.ts.map +1 -0
  23. package/dist/src/yaml/steps/trim-align.test.js +258 -0
  24. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +1 -1
  25. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -1
  26. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +9 -0
  27. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +4 -3
  28. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -1
  29. package/dist/src/yaml/steps/ui-fidelity-preview.js +9 -0
  30. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +13 -3
  31. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +16 -5
  32. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
  33. package/dist/src/yaml/steps/ui-fidelity-render-android.js +190 -26
  34. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +165 -10
  35. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +20 -4
  36. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
  37. package/dist/src/yaml/steps/ui-fidelity-render.js +186 -10
  38. package/dist/src/yaml/steps/ui-fidelity-render.test.js +180 -22
  39. package/package.json +3 -1
@@ -15,7 +15,7 @@
15
15
  * Swift toolchain (macOS with Xcode required, slow).
16
16
  */
17
17
  import { describe, test, expect, afterAll } from '@jest/globals';
18
- import { spawnSync } from 'node:child_process';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
19
  import { gzipSync } from 'node:zlib';
20
20
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
@@ -111,15 +111,24 @@ const FAKE_SWIFT_LINES = [
111
111
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out\"",
112
112
  ' printf \'fake-render:%s\' "$screen" >> "$out"',
113
113
  ' exit 0 ;;',
114
- ' *.swift)',
115
- // Script-interpreter mode (swift <Trim.swift> <in> <out> <aligned> <ref>):
116
- // stands in for the CoreGraphics trim helper. Overwrites <out> with a PNG
117
- // marked "trimmed:<in basename>" and writes an "aligned:<in basename>" PNG so
118
- // tests can prove both outputs. Emits a fixed TRIMDIMS line (rendered
119
- // 320x640, reference 160x320, source 400x800). FAKE_TRIM_FAIL exercises
120
- // warn-and-continue.
114
+ ' *)',
115
+ ' exit 0 ;;',
116
+ 'esac',
117
+ ];
118
+ /**
119
+ * PATH-shimmed fake `ci`, providing only the branch the shared trim stage
120
+ * uses: `ci fidelity-trim <in> <out> <aligned> <ref>`. Overwrites <out> with a
121
+ * PNG marked "trimmed:<in basename>" and writes an "aligned:<in basename>" PNG
122
+ * so tests can prove both outputs. Emits a fixed TRIMDIMS line (rendered
123
+ * 320x640, reference 160x320, source 400x800). FAKE_TRIM_FAIL exercises
124
+ * warn-and-continue.
125
+ */
126
+ const FAKE_CI_LINES = [
127
+ '#!/bin/bash',
128
+ 'case "$1" in',
129
+ ' fidelity-trim)',
121
130
  ' if [ -n "$FAKE_TRIM_FAIL" ]; then echo "trim: simulated failure" >&2; exit 1; fi',
122
- ' in_png="$1"; out_png="$2"; aligned_png="$3"',
131
+ ' in_png="$2"; out_png="$3"; aligned_png="$4"',
123
132
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out_png\"",
124
133
  ' printf \'trimmed:%s\' "$(basename "$in_png")" >> "$out_png"',
125
134
  ' if [ -n "$aligned_png" ]; then',
@@ -128,8 +137,7 @@ const FAKE_SWIFT_LINES = [
128
137
  ' fi',
129
138
  ' echo "TRIMDIMS 320 640 160 320 400 800"',
130
139
  ' exit 0 ;;',
131
- ' *)',
132
- ' exit 0 ;;',
140
+ ' *) exit 0 ;;',
133
141
  'esac',
134
142
  ];
135
143
  /**
@@ -186,6 +194,9 @@ function makeProject(params) {
186
194
  const xcrunPath = join(binDir, 'xcrun');
187
195
  writeFileSync(xcrunPath, FAKE_XCRUN_LINES.join('\n') + '\n');
188
196
  chmodSync(xcrunPath, 0o755);
197
+ const ciPath = join(binDir, 'ci');
198
+ writeFileSync(ciPath, FAKE_CI_LINES.join('\n') + '\n');
199
+ chmodSync(ciPath, 0o755);
189
200
  if (params !== undefined) {
190
201
  writeFileSync(join(dir, '.ci', 'inputs', 'params.json'), typeof params === 'string' ? params : JSON.stringify(params));
191
202
  }
@@ -231,7 +242,9 @@ function makeTarGz(entries) {
231
242
  const blocks = [];
232
243
  for (const entry of entries) {
233
244
  const isDir = entry.name.endsWith('/');
234
- const content = Buffer.from(entry.content ?? '', 'utf-8');
245
+ const content = Buffer.isBuffer(entry.content)
246
+ ? entry.content
247
+ : Buffer.from(entry.content ?? '', 'utf-8');
235
248
  blocks.push(tarHeader(entry.name, isDir ? 0 : content.length, isDir ? '5' : '0'));
236
249
  if (!isDir && content.length > 0) {
237
250
  const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512);
@@ -439,6 +452,157 @@ describe('package_source input', () => {
439
452
  expect(inputsNames).toEqual(['swift', 'tar']);
440
453
  });
441
454
  });
455
+ // Clone-free package delivery (fidelity-linux 03): the dispatched YAML carries
456
+ // a signed package_url; the runtime downloads it to .ci/inputs/package.tar.gz
457
+ // and proceeds like inputs mode, resolving each screen's committed reference
458
+ // INSIDE the extracted package (repo-relative path minus the package_path
459
+ // prefix) — there is no checkout on a clone-free worker. Mirrors the Android
460
+ // step's url mode so both platform kinds accept the same dispatched inputs.
461
+ describe('package_source: url', () => {
462
+ /**
463
+ * Serve one tar.gz (or a 404) from a loopback HTTP server IN A CHILD
464
+ * PROCESS. runScript executes the generated script via spawnSync, which
465
+ * blocks this process's event loop — an in-process server would deadlock
466
+ * against the script's curl.
467
+ */
468
+ const SERVE_PACKAGE_SRC = [
469
+ "var http = require('http');",
470
+ "var fs = require('fs');",
471
+ 'var filePath = process.argv[1];',
472
+ 'var server = http.createServer(function (req, res) {',
473
+ " if (filePath === '-') { res.statusCode = 404; res.end('gone'); return; }",
474
+ " res.setHeader('content-type', 'application/gzip');",
475
+ ' res.end(fs.readFileSync(filePath));',
476
+ '});',
477
+ "server.listen(0, '127.0.0.1', function () {",
478
+ " console.log('PORT ' + server.address().port);",
479
+ '});',
480
+ ].join('\n');
481
+ async function servePackage(archive) {
482
+ let filePath = '-';
483
+ if (archive !== null) {
484
+ const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-url-serve-'));
485
+ tempDirs.push(dir);
486
+ filePath = join(dir, 'package.tar.gz');
487
+ writeFileSync(filePath, archive);
488
+ }
489
+ const child = spawn('node', ['-e', SERVE_PACKAGE_SRC, filePath], {
490
+ stdio: ['ignore', 'pipe', 'inherit'],
491
+ });
492
+ const port = await new Promise((resolvePort, rejectPort) => {
493
+ let buffered = '';
494
+ const timer = setTimeout(() => rejectPort(new Error('package server did not start')), 10_000);
495
+ child.stdout.on('data', (chunk) => {
496
+ buffered += chunk.toString();
497
+ const match = /PORT (\d+)/.exec(buffered);
498
+ if (match) {
499
+ clearTimeout(timer);
500
+ resolvePort(Number(match[1]));
501
+ }
502
+ });
503
+ child.on('exit', () => rejectPort(new Error('package server exited early')));
504
+ });
505
+ return {
506
+ url: `http://127.0.0.1:${port}/api/fidelity/packages/blob1?exp=1&sig=ab`,
507
+ close: () => new Promise((resolveClose) => {
508
+ child.once('exit', () => resolveClose(undefined));
509
+ child.kill('SIGKILL');
510
+ }),
511
+ };
512
+ }
513
+ /** Archive shaped like the dashboard's assembly: package-dir contents at the
514
+ * archive root, committed references under references/. */
515
+ function packagedProjectWithReference() {
516
+ return makeTarGz([
517
+ ...shippedPackageEntries(''),
518
+ {
519
+ name: 'references/home.png',
520
+ content: Buffer.concat([PNG_MAGIC, Buffer.from('ref:url-home')]),
521
+ },
522
+ ]);
523
+ }
524
+ test('package_source url requires package_url and package_path', async () => {
525
+ const executor = new UiFidelityRenderStepExecutor();
526
+ await expect(executor.execute({ package_source: 'url', package_path: 'ios-pkg' }, {}, testConfig)).rejects.toThrow(/package_url/);
527
+ await expect(executor.execute({ package_source: 'url', package_url: 'https://x.test/p' }, {}, testConfig)).rejects.toThrow(/package_path/);
528
+ });
529
+ test('rejects reference_source alongside package_source url', async () => {
530
+ const executor = new UiFidelityRenderStepExecutor();
531
+ await expect(executor.execute({
532
+ package_source: 'url',
533
+ package_url: 'https://x.test/p',
534
+ package_path: 'ios-pkg',
535
+ reference_source: 'repo',
536
+ }, {}, testConfig)).rejects.toThrow(/reference_source/);
537
+ });
538
+ test('url mode requires swift, tar, and curl', () => {
539
+ const executor = new UiFidelityRenderStepExecutor();
540
+ const names = executor
541
+ .getValidationRequirements({ package_source: 'url' }, {}, testConfig)
542
+ .map((requirement) => requirement.name);
543
+ expect(names).toEqual(['swift', 'tar', 'curl']);
544
+ });
545
+ test('downloads the package, renders, and resolves references inside it', async () => {
546
+ const project = makeProject({ screens: { HomeView: 'ios-pkg/references/home.png' } });
547
+ const served = await servePackage(packagedProjectWithReference());
548
+ try {
549
+ const executor = new UiFidelityRenderStepExecutor();
550
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
551
+ const run = runScript(project, step.script);
552
+ expect(run.status).toBe(0);
553
+ const doc = readResult(project);
554
+ expect(doc.package_source).toBe('url');
555
+ const screen = doc.screens.find((s) => s.screen === 'HomeView');
556
+ expect(screen?.status).toBe('rendered');
557
+ expect(screen?.reference_image_path).toBe('ui-fidelity/references/HomeView.png');
558
+ expect(isPng(artifact(project, 'ui-fidelity/references/HomeView.png'))).toBe(true);
559
+ }
560
+ finally {
561
+ await served.close();
562
+ }
563
+ });
564
+ test('a failed download is one structured build error, not per-screen noise', async () => {
565
+ const project = makeProject({ screens: { HomeView: 'ios-pkg/references/home.png' } });
566
+ const served = await servePackage(null); // 404
567
+ try {
568
+ const executor = new UiFidelityRenderStepExecutor();
569
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
570
+ const run = runScript(project, step.script);
571
+ expect(run.status).not.toBe(0);
572
+ const doc = readResult(project);
573
+ expect(doc.error?.code).toBe('PACKAGE_DOWNLOAD_FAILED');
574
+ const screen = doc.screens.find((s) => s.screen === 'HomeView');
575
+ expect(screen?.error).toBeNull();
576
+ expectNoAbsolutePathLeaks(project, doc);
577
+ }
578
+ finally {
579
+ await served.close();
580
+ }
581
+ });
582
+ test('a reference outside the package directory fails that screen only', async () => {
583
+ const project = makeProject({
584
+ screens: {
585
+ HomeView: 'ios-pkg/references/home.png',
586
+ Stray: 'design/elsewhere/stray.png',
587
+ },
588
+ });
589
+ const served = await servePackage(packagedProjectWithReference());
590
+ try {
591
+ const executor = new UiFidelityRenderStepExecutor();
592
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
593
+ const run = runScript(project, step.script, { FAKE_SWIFT_RUN_SILENT: 'Stray' });
594
+ expect(run.status).not.toBe(0);
595
+ const doc = readResult(project);
596
+ expect(doc.screens.find((s) => s.screen === 'HomeView')?.status).toBe('rendered');
597
+ const stray = doc.screens.find((s) => s.screen === 'Stray');
598
+ expect(stray?.status).toBe('render_failed');
599
+ expect(stray?.error?.code).toBe('REFERENCE_MISSING');
600
+ }
601
+ finally {
602
+ await served.close();
603
+ }
604
+ });
605
+ });
442
606
  describe('render size and scale parsing', () => {
443
607
  test('default render size is 393x852 device points', () => {
444
608
  expect(DEFAULT_RENDER_SIZE).toBe('393x852');
@@ -1016,20 +1180,14 @@ describe('render output trimming (package_source variants)', () => {
1016
1180
  function readArtifact(project, relative) {
1017
1181
  return readFileSync(artifact(project, relative), 'utf-8');
1018
1182
  }
1019
- test('trims the rendered PNG in place via the Swift helper', async () => {
1183
+ test('trims the rendered PNG in place via the ci fidelity-trim helper', async () => {
1020
1184
  const project = makeProject({ screens: { HomeView: 'home.png' } });
1021
1185
  writeReference(project, 'home.png');
1022
1186
  const script = await buildScript(project, { package_source: 'repo' });
1023
- const logPath = join(project.dir, 'swift-invocations.log');
1024
- const run = runScript(project, script, { FAKE_SWIFT_LOG: logPath });
1187
+ const run = runScript(project, script);
1025
1188
  expect(run.status).toBe(0);
1026
- // The helper is invoked in script mode with the rendered PNG as in and out
1027
- // (in-place overwrite), after the render run.
1028
- const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
1029
- const trimCall = invocations.find((c) => c.includes('Trim.swift'));
1030
- expect(trimCall).toBeTruthy();
1031
- expect(trimCall).toContain('ui-fidelity/rendered/HomeView.png');
1032
- // The rendered file now holds the trim helper's output, not the raw render.
1189
+ // The rendered file now holds the trim helper's output, not the raw render
1190
+ // (the fake ci overwrites in place, proving the in/out in-place contract).
1033
1191
  expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
1034
1192
  expect(readArtifact(project, 'ui-fidelity/rendered/HomeView.png')).toContain('trimmed:HomeView.png');
1035
1193
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.3.8",
3
+ "version": "2.4.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",
@@ -55,12 +55,14 @@
55
55
  "homepage": "https://github.com/invarnhq/cibuild",
56
56
  "dependencies": {
57
57
  "js-yaml": "^4.1.0",
58
+ "pngjs": "7.0.0",
58
59
  "prompts": "^2.4.2"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@types/jest": "^29.5.14",
62
63
  "@types/js-yaml": "^4.0.9",
63
64
  "@types/node": "^22.10.1",
65
+ "@types/pngjs": "6.0.5",
64
66
  "@types/prompts": "^2.4.9",
65
67
  "@yao-pkg/pkg": "^6.14.1",
66
68
  "esbuild": "^0.27.3",