@invarn/cibuild 2.0.1 → 2.0.3

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.
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { describe, test, expect, afterAll } from '@jest/globals';
18
18
  import { spawnSync } from 'node:child_process';
19
+ import { gzipSync } from 'node:zlib';
19
20
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
20
21
  import { tmpdir } from 'node:os';
21
22
  import { dirname, join, resolve } from 'node:path';
@@ -29,25 +30,45 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
29
30
  const FIXTURE_PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-package');
30
31
  /**
31
32
  * Fake `swift` CLI. Mirrors the exact invocations the render script makes:
33
+ * swift package --package-path <dir> dump-package
32
34
  * swift build --package-path <dir> --target RenderProbe
33
35
  * swift build --package-path <dir> --product Render_<Screen>
34
36
  * swift run --package-path <dir> --skip-build Render_<Screen> <output.png>
37
+ * dump-package output is driven by FAKE_SWIFT_DUMP_PRODUCTS (comma-separated
38
+ * library product names; unset = "FixtureViews", empty = no products) and
39
+ * FAKE_SWIFT_DUMP_FAIL.
35
40
  */
36
41
  const FAKE_SWIFT_LINES = [
37
42
  '#!/bin/bash',
38
43
  '[ -n "$FAKE_SWIFT_LOG" ] && echo "$*" >> "$FAKE_SWIFT_LOG"',
39
44
  'cmd="$1"; shift',
40
45
  'case "$cmd" in',
46
+ ' package)',
47
+ ' if [ -n "$FAKE_SWIFT_DUMP_FAIL" ]; then',
48
+ ' echo "error: malformed Package.swift manifest" >&2',
49
+ ' exit 1',
50
+ ' fi',
51
+ ' products="${FAKE_SWIFT_DUMP_PRODUCTS-FixtureViews}"',
52
+ ' json=""',
53
+ ' IFS=","',
54
+ ' for p in $products; do',
55
+ ' [ -n "$json" ] && json="$json,"',
56
+ ' json="$json{\\"name\\":\\"$p\\",\\"type\\":{\\"library\\":[\\"automatic\\"]}}"',
57
+ ' done',
58
+ ' printf \'{"name":"shipped-package","products":[%s]}\\n\' "$json"',
59
+ ' exit 0 ;;',
41
60
  ' build)',
42
- ' product=""; target=""; pkg=""',
61
+ ' product=""; target=""; pkg=""; showbin=""',
43
62
  ' while [ $# -gt 0 ]; do',
44
63
  ' case "$1" in',
45
64
  ' --product) product="$2"; shift 2 ;;',
46
65
  ' --target) target="$2"; shift 2 ;;',
47
66
  ' --package-path) pkg="$2"; shift 2 ;;',
67
+ ' --show-bin-path) showbin=1; shift ;;',
48
68
  ' *) shift ;;',
49
69
  ' esac',
50
70
  ' done',
71
+ ' if [ -n "$showbin" ]; then echo "$pkg/.build/debug"; exit 0; fi',
51
72
  ' name="${product:-$target}"',
52
73
  ' if [ "$name" = "RenderProbe" ]; then',
53
74
  ' if [ -n "$FAKE_SWIFT_PROBE_FAIL" ]; then',
@@ -90,6 +111,35 @@ const FAKE_SWIFT_LINES = [
90
111
  ' exit 0 ;;',
91
112
  'esac',
92
113
  ];
114
+ /**
115
+ * PATH-shimmed fake `xcrun` covering `xcrun actool`. Logs its args to
116
+ * $FAKE_XCRUN_LOG, writes a stub Assets.car into the --compile directory,
117
+ * and fails (exit 1) when FAKE_ACTOOL_FAIL is set so the warn-and-continue
118
+ * path is exercisable.
119
+ */
120
+ const FAKE_XCRUN_LINES = [
121
+ '#!/bin/bash',
122
+ '[ -n "$FAKE_XCRUN_LOG" ] && echo "$*" >> "$FAKE_XCRUN_LOG"',
123
+ 'sub="$1"; shift',
124
+ 'case "$sub" in',
125
+ ' actool)',
126
+ ' out=""',
127
+ ' while [ $# -gt 0 ]; do',
128
+ ' case "$1" in',
129
+ ' --compile) out="$2"; shift 2 ;;',
130
+ ' *) shift ;;',
131
+ ' esac',
132
+ ' done',
133
+ ' if [ -n "$FAKE_ACTOOL_FAIL" ]; then',
134
+ ' echo "error: actool: malformed asset catalog" >&2',
135
+ ' exit 1',
136
+ ' fi',
137
+ ' [ -n "$out" ] && mkdir -p "$out" && printf \'fake-car\' > "$out/Assets.car"',
138
+ ' exit 0 ;;',
139
+ ' *)',
140
+ ' exit 0 ;;',
141
+ 'esac',
142
+ ];
93
143
  const tempDirs = [];
94
144
  afterAll(() => {
95
145
  for (const dir of tempDirs) {
@@ -112,6 +162,9 @@ function makeProject(params) {
112
162
  const swiftPath = join(binDir, 'swift');
113
163
  writeFileSync(swiftPath, FAKE_SWIFT_LINES.join('\n') + '\n');
114
164
  chmodSync(swiftPath, 0o755);
165
+ const xcrunPath = join(binDir, 'xcrun');
166
+ writeFileSync(xcrunPath, FAKE_XCRUN_LINES.join('\n') + '\n');
167
+ chmodSync(xcrunPath, 0o755);
115
168
  if (params !== undefined) {
116
169
  writeFileSync(join(dir, '.ci', 'inputs', 'params.json'), typeof params === 'string' ? params : JSON.stringify(params));
117
170
  }
@@ -136,6 +189,66 @@ function runScript(project, script, env = {}) {
136
189
  },
137
190
  });
138
191
  }
192
+ function tarHeader(name, size, typeflag) {
193
+ const header = Buffer.alloc(512);
194
+ header.write(name, 0, 100, 'utf-8');
195
+ header.write('0000755\0', 100, 8, 'ascii'); // mode
196
+ header.write('0000000\0', 108, 8, 'ascii'); // uid
197
+ header.write('0000000\0', 116, 8, 'ascii'); // gid
198
+ header.write(size.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii');
199
+ header.write('00000000000\0', 136, 12, 'ascii'); // mtime
200
+ header.write(' ', 148, 8, 'ascii'); // checksum placeholder
201
+ header.write(typeflag, 156, 1, 'ascii');
202
+ header.write('ustar\0' + '00', 257, 8, 'ascii');
203
+ let checksum = 0;
204
+ for (const byte of header)
205
+ checksum += byte;
206
+ header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
207
+ return header;
208
+ }
209
+ function makeTarGz(entries) {
210
+ const blocks = [];
211
+ for (const entry of entries) {
212
+ const isDir = entry.name.endsWith('/');
213
+ const content = Buffer.from(entry.content ?? '', 'utf-8');
214
+ blocks.push(tarHeader(entry.name, isDir ? 0 : content.length, isDir ? '5' : '0'));
215
+ if (!isDir && content.length > 0) {
216
+ const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512);
217
+ content.copy(padded);
218
+ blocks.push(padded);
219
+ }
220
+ }
221
+ blocks.push(Buffer.alloc(1024)); // end-of-archive
222
+ return gzipSync(Buffer.concat(blocks));
223
+ }
224
+ const SHIPPED_MANIFEST = [
225
+ '// swift-tools-version:5.9',
226
+ 'import PackageDescription',
227
+ 'let package = Package(',
228
+ ' name: "shipped-package",',
229
+ ' platforms: [.macOS(.v13)],',
230
+ ' products: [.library(name: "FixtureViews", targets: ["FixtureViews"])],',
231
+ ' targets: [.target(name: "FixtureViews")]',
232
+ ')',
233
+ ].join('\n');
234
+ /** Entries for a well-formed shipped package under the given root dir ('' = flat). */
235
+ function shippedPackageEntries(root = 'pkg') {
236
+ const prefix = root === '' ? '' : root + '/';
237
+ const entries = root === '' ? [] : [{ name: prefix }];
238
+ entries.push({ name: prefix + 'Package.swift', content: SHIPPED_MANIFEST }, {
239
+ name: prefix + 'Sources/FixtureViews/HomeView.swift',
240
+ content: 'import SwiftUI\npublic struct HomeView: View { public init() {} public var body: some View { Text("home") } }\n',
241
+ });
242
+ return entries;
243
+ }
244
+ function stageArchive(project, archive) {
245
+ writeFileSync(join(project.dir, '.ci', 'inputs', 'package.tar.gz'), Buffer.isBuffer(archive) ? archive : makeTarGz(archive));
246
+ }
247
+ async function buildInputsScript(extra = {}) {
248
+ const executor = new UiFidelityRenderStepExecutor();
249
+ const step = await executor.execute({ package_source: 'inputs', ...extra }, {}, testConfig);
250
+ return step.script;
251
+ }
139
252
  function readResult(project) {
140
253
  const resultPath = join(project.dir, '.ci', 'artifacts', 'protocol-result.json');
141
254
  expect(existsSync(resultPath)).toBe(true);
@@ -151,8 +264,9 @@ function isPng(filePath) {
151
264
  /**
152
265
  * Asserts that no error message in the result document contains an absolute
153
266
  * runner-filesystem path: not the project dir (the script's cwd), not the OS
154
- * temp dir (where harness packages live), in either symlinked
155
- * (/var/folders/...) or resolved (/private/var/...) form.
267
+ * temp dir (where harness packages and extracted shipped packages live), in
268
+ * either symlinked (/var/folders/...) or resolved (/private/var/...) form.
269
+ * Covers both per-screen errors and the build-level error object.
156
270
  */
157
271
  function expectNoAbsolutePathLeaks(project, result) {
158
272
  const forbidden = new Set();
@@ -165,8 +279,9 @@ function expectNoAbsolutePathLeaks(project, result) {
165
279
  // nonexistent — literal form only
166
280
  }
167
281
  }
168
- for (const screen of result.screens) {
169
- const message = screen.error?.message ?? '';
282
+ const messages = result.screens.map((screen) => screen.error?.message ?? '');
283
+ messages.push(result.error?.message ?? '');
284
+ for (const message of messages) {
170
285
  for (const dir of forbidden) {
171
286
  expect(message).not.toContain(dir);
172
287
  }
@@ -182,18 +297,38 @@ describe('registry integration', () => {
182
297
  const inputs = metadata?.inputs ?? {};
183
298
  expect(Object.keys(inputs).sort()).toEqual([
184
299
  'package_path',
300
+ 'package_source',
185
301
  'render_size',
186
302
  'scale',
187
303
  'target',
188
304
  ]);
189
- expect(inputs.package_path.required).toBe(true);
190
- expect(inputs.target.required).toBe(true);
305
+ // package_path/target are enforced by the executor in repo mode (the
306
+ // default); a package_source:inputs config is valid without either, so
307
+ // neither is unconditionally required at the schema level.
308
+ expect(inputs.package_path.required).toBe(false);
309
+ expect(inputs.target.required).toBe(false);
310
+ expect(inputs.package_source.required).toBe(false);
311
+ expect(inputs.package_source.default).toBe('repo');
191
312
  expect(inputs.render_size.required).toBe(false);
192
313
  expect(inputs.render_size.default).toBe(DEFAULT_RENDER_SIZE);
193
314
  expect(inputs.scale.required).toBe(false);
194
315
  expect(inputs.scale.default).toBe(DEFAULT_SCALE);
195
316
  });
196
317
  });
318
+ describe('v1 byte compatibility', () => {
319
+ // Snapshot of the script generated before package_source existed, captured
320
+ // from the executor with package_path './MyAppPackage' and target
321
+ // 'MyAppViews' (defaults for everything else). Omitting package_source must
322
+ // keep the generated script byte-identical to that snapshot, so existing
323
+ // protocols are provably untouched. Update the snapshot only for a
324
+ // deliberate, reviewed change to the v1 runtime.
325
+ const V1_SCRIPT_SNAPSHOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-render-v1-script.txt');
326
+ test('omitting package_source generates a byte-identical script', async () => {
327
+ const executor = new UiFidelityRenderStepExecutor();
328
+ const step = await executor.execute({ package_path: './MyAppPackage', target: 'MyAppViews' }, {}, testConfig);
329
+ expect(step.script).toBe(readFileSync(V1_SCRIPT_SNAPSHOT, 'utf-8'));
330
+ });
331
+ });
197
332
  describe('UiFidelityRenderStepExecutor', () => {
198
333
  test('returns a node StepDef', async () => {
199
334
  const executor = new UiFidelityRenderStepExecutor();
@@ -234,6 +369,52 @@ describe('UiFidelityRenderStepExecutor', () => {
234
369
  await expect(executor.execute({ package_path: './pkg', target: 'MyViews', render_size: 'huge' }, {}, testConfig)).rejects.toThrow(/render_size/);
235
370
  });
236
371
  });
372
+ describe('package_source input', () => {
373
+ test('rejects an unknown package_source value', async () => {
374
+ const executor = new UiFidelityRenderStepExecutor();
375
+ await expect(executor.execute({ package_path: './pkg', target: 'MyViews', package_source: 'artifact' }, {}, testConfig)).rejects.toThrow(/package_source/);
376
+ });
377
+ test('omitting package_source bakes no packageSource into the script config', async () => {
378
+ const executor = new UiFidelityRenderStepExecutor();
379
+ const step = await executor.execute({ package_path: './pkg', target: 'MyViews' }, {}, testConfig);
380
+ expect(step.script).not.toContain('packageSource');
381
+ });
382
+ test('explicit repo keeps the v1 inputs required and records the source', async () => {
383
+ const executor = new UiFidelityRenderStepExecutor();
384
+ const step = await executor.execute({ package_path: './pkg', target: 'MyViews', package_source: 'repo' }, {}, testConfig);
385
+ expect(step.kind).toBe('node');
386
+ expect(step.script).toContain('"packageSource":"repo"');
387
+ expect(step.script).toContain('"packagePath":"./pkg"');
388
+ await expect(executor.execute({ target: 'MyViews', package_source: 'repo' }, {}, testConfig)).rejects.toThrow("Missing required input 'package_path'");
389
+ await expect(executor.execute({ package_path: './pkg', package_source: 'repo' }, {}, testConfig)).rejects.toThrow("Missing required input 'target'");
390
+ });
391
+ test('inputs mode requires neither package_path nor target', async () => {
392
+ const script = await buildInputsScript();
393
+ expect(script).toContain('"packageSource":"inputs"');
394
+ expect(script).toContain('"packagePath":null');
395
+ expect(script).toContain('"target":null');
396
+ });
397
+ test('inputs mode ignores package_path and honors an explicit target', async () => {
398
+ const script = await buildInputsScript({ package_path: './pkg', target: 'MyViews' });
399
+ expect(script).toContain('"packagePath":null');
400
+ expect(script).toContain('"target":"MyViews"');
401
+ });
402
+ test('inputs mode still rejects a malformed target', async () => {
403
+ const executor = new UiFidelityRenderStepExecutor();
404
+ await expect(executor.execute({ package_source: 'inputs', target: 'My Views' }, {}, testConfig)).rejects.toThrow(/target/i);
405
+ });
406
+ test('validation requirements branch on the package source', () => {
407
+ const executor = new UiFidelityRenderStepExecutor();
408
+ const v1Names = executor
409
+ .getValidationRequirements({ package_path: './pkg', target: 'MyViews' }, {}, testConfig)
410
+ .map((requirement) => requirement.name);
411
+ expect(v1Names).toEqual(['package_path', 'target', 'swift']);
412
+ const inputsNames = executor
413
+ .getValidationRequirements({ package_source: 'inputs' }, {}, testConfig)
414
+ .map((requirement) => requirement.name);
415
+ expect(inputsNames).toEqual(['swift', 'tar']);
416
+ });
417
+ });
237
418
  describe('render size and scale parsing', () => {
238
419
  test('default render size is 393x852 device points', () => {
239
420
  expect(DEFAULT_RENDER_SIZE).toBe('393x852');
@@ -692,6 +873,429 @@ describe('render script runtime', () => {
692
873
  expect(readResult(project).screens[0].status).toBe('rendered');
693
874
  });
694
875
  });
876
+ describe('render script runtime (package_source: repo)', () => {
877
+ test('explicit repo renders like v1 and records the package source', async () => {
878
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
879
+ writeReference(project, 'home.png');
880
+ const script = await buildScript(project, { package_source: 'repo' });
881
+ const run = runScript(project, script);
882
+ expect(run.status).toBe(0);
883
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
884
+ expect(readResult(project)).toEqual({
885
+ renderer: 'imagerenderer-spm',
886
+ package_source: 'repo',
887
+ error: null,
888
+ screens: [
889
+ {
890
+ screen: 'HomeView',
891
+ status: 'rendered',
892
+ error: null,
893
+ reference_image_path: 'ui-fidelity/references/HomeView.png',
894
+ rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
895
+ },
896
+ ],
897
+ });
898
+ });
899
+ test('compiles asset catalogs found in the package before rendering', async () => {
900
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
901
+ writeReference(project, 'home.png');
902
+ // A catalog at the package root: SwiftPM ignores it, so the renderer must
903
+ // compile it with actool for Image("name") to resolve at render time.
904
+ const catalog = join(project.packageDir, 'Assets', 'Media.xcassets');
905
+ mkdirSync(catalog, { recursive: true });
906
+ writeFileSync(join(catalog, 'Contents.json'), '{}');
907
+ const script = await buildScript(project, { package_source: 'repo' });
908
+ const xcrunLog = join(project.dir, 'xcrun-invocations.log');
909
+ const run = runScript(project, script, { FAKE_XCRUN_LOG: xcrunLog });
910
+ expect(run.status).toBe(0);
911
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
912
+ const xcrunCalls = readFileSync(xcrunLog, 'utf-8');
913
+ expect(xcrunCalls).toContain('actool');
914
+ expect(xcrunCalls).toContain('Media.xcassets');
915
+ expect(xcrunCalls).toContain('--compile');
916
+ expect(xcrunCalls).toContain('--platform macosx');
917
+ });
918
+ test('does not invoke actool when the package has no asset catalogs', async () => {
919
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
920
+ writeReference(project, 'home.png');
921
+ const script = await buildScript(project, { package_source: 'repo' });
922
+ const xcrunLog = join(project.dir, 'xcrun-invocations.log');
923
+ const run = runScript(project, script, { FAKE_XCRUN_LOG: xcrunLog });
924
+ expect(run.status).toBe(0);
925
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
926
+ expect(existsSync(xcrunLog)).toBe(false);
927
+ });
928
+ test('a failing asset-catalog compile warns but still renders (warn-and-continue)', async () => {
929
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
930
+ writeReference(project, 'home.png');
931
+ const catalog = join(project.packageDir, 'Media.xcassets');
932
+ mkdirSync(catalog, { recursive: true });
933
+ writeFileSync(join(catalog, 'Contents.json'), '{}');
934
+ const script = await buildScript(project, { package_source: 'repo' });
935
+ const run = runScript(project, script, { FAKE_ACTOOL_FAIL: '1' });
936
+ expect(run.status).toBe(0);
937
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
938
+ expect(run.stderr).toContain('asset-catalog compile failed');
939
+ });
940
+ });
941
+ describe('render script runtime (package_source: inputs)', () => {
942
+ test('renders every screen from a shipped archive with a single package root', async () => {
943
+ const project = makeProject({
944
+ screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
945
+ });
946
+ writeReference(project, 'home.png');
947
+ writeReference(project, 'settings.png');
948
+ stageArchive(project, shippedPackageEntries('pkg'));
949
+ const script = await buildInputsScript({ target: 'FixtureViews' });
950
+ const logPath = join(project.dir, 'swift-invocations.log');
951
+ const run = runScript(project, script, {
952
+ FAKE_SWIFT_LOG: logPath,
953
+ UI_FIDELITY_KEEP_HARNESS: '1',
954
+ });
955
+ expect(run.status).toBe(0);
956
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
957
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
958
+ expect(readResult(project)).toEqual({
959
+ renderer: 'imagerenderer-spm',
960
+ package_source: 'inputs',
961
+ error: null,
962
+ screens: [
963
+ {
964
+ screen: 'HomeView',
965
+ status: 'rendered',
966
+ error: null,
967
+ reference_image_path: 'ui-fidelity/references/HomeView.png',
968
+ rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
969
+ },
970
+ {
971
+ screen: 'SettingsView',
972
+ status: 'rendered',
973
+ error: null,
974
+ reference_image_path: 'ui-fidelity/references/SettingsView.png',
975
+ rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
976
+ },
977
+ ],
978
+ });
979
+ // The synthesized harness depends on the EXTRACTED package, not on any
980
+ // path in the repo checkout.
981
+ const probeArgs = readFileSync(logPath, 'utf-8').trim().split('\n')[0].split(' ');
982
+ const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
983
+ tempDirs.push(harnessDir);
984
+ const manifest = readFileSync(join(harnessDir, 'Package.swift'), 'utf-8');
985
+ expect(manifest).toMatch(/\.package\(path: "[^"]*ui-fidelity-package-[^"]*\/pkg"\)/);
986
+ expect(manifest).toContain('.product(name: "FixtureViews", package: "pkg")');
987
+ const extracted = manifest.match(/\.package\(path: "([^"]*)\/pkg"\)/);
988
+ if (extracted)
989
+ tempDirs.push(extracted[1]);
990
+ });
991
+ test('accepts Package.swift at the archive root', async () => {
992
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
993
+ writeReference(project, 'home.png');
994
+ stageArchive(project, shippedPackageEntries(''));
995
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
996
+ expect(run.status).toBe(0);
997
+ const result = readResult(project);
998
+ expect(result.error).toBeNull();
999
+ expect(result.screens[0].status).toBe('rendered');
1000
+ });
1001
+ test('ignores macOS junk entries when locating the package root', async () => {
1002
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1003
+ writeReference(project, 'home.png');
1004
+ stageArchive(project, [
1005
+ { name: '.DS_Store', content: 'junk' },
1006
+ { name: '._pkg', content: 'apple-double junk' },
1007
+ ...shippedPackageEntries('pkg'),
1008
+ ]);
1009
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1010
+ expect(run.status).toBe(0);
1011
+ expect(readResult(project).screens[0].status).toBe('rendered');
1012
+ });
1013
+ test('discovers the target from a single library product', async () => {
1014
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1015
+ writeReference(project, 'home.png');
1016
+ stageArchive(project, shippedPackageEntries('pkg'));
1017
+ const logPath = join(project.dir, 'swift-invocations.log');
1018
+ const run = runScript(project, await buildInputsScript(), {
1019
+ FAKE_SWIFT_LOG: logPath,
1020
+ UI_FIDELITY_KEEP_HARNESS: '1',
1021
+ });
1022
+ expect(run.status).toBe(0);
1023
+ expect(readResult(project).screens[0].status).toBe('rendered');
1024
+ const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
1025
+ expect(invocations[0]).toContain('dump-package');
1026
+ const dumpArgs = invocations[0].split(' ');
1027
+ tempDirs.push(dumpArgs[dumpArgs.indexOf('--package-path') + 1]);
1028
+ // The discovered product is what the harness imports.
1029
+ const probeArgs = invocations[1].split(' ');
1030
+ const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
1031
+ tempDirs.push(harnessDir);
1032
+ expect(readFileSync(join(harnessDir, 'Sources', 'Render_HomeView', 'Render.swift'), 'utf-8')).toContain('import FixtureViews');
1033
+ });
1034
+ test('an explicit target skips manifest discovery', async () => {
1035
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1036
+ writeReference(project, 'home.png');
1037
+ stageArchive(project, shippedPackageEntries('pkg'));
1038
+ const logPath = join(project.dir, 'swift-invocations.log');
1039
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), { FAKE_SWIFT_LOG: logPath });
1040
+ expect(run.status).toBe(0);
1041
+ expect(readFileSync(logPath, 'utf-8')).not.toContain('dump-package');
1042
+ });
1043
+ test('cleans up the extraction scratch directory after rendering', async () => {
1044
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1045
+ writeReference(project, 'home.png');
1046
+ stageArchive(project, shippedPackageEntries('pkg'));
1047
+ const logPath = join(project.dir, 'swift-invocations.log');
1048
+ const run = runScript(project, await buildInputsScript(), { FAKE_SWIFT_LOG: logPath });
1049
+ expect(run.status).toBe(0);
1050
+ // The dump-package invocation names the extracted package root.
1051
+ const dumpArgs = readFileSync(logPath, 'utf-8').trim().split('\n')[0].split(' ');
1052
+ const extractedRoot = dumpArgs[dumpArgs.indexOf('--package-path') + 1];
1053
+ expect(extractedRoot).toContain('ui-fidelity-package-');
1054
+ expect(existsSync(extractedRoot)).toBe(false);
1055
+ });
1056
+ test('missing archive yields PACKAGE_ARCHIVE_MISSING with every screen entry present', async () => {
1057
+ const project = makeProject({
1058
+ screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
1059
+ });
1060
+ writeReference(project, 'home.png');
1061
+ writeReference(project, 'settings.png');
1062
+ const logPath = join(project.dir, 'swift-invocations.log');
1063
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1064
+ FAKE_SWIFT_LOG: logPath,
1065
+ });
1066
+ expect(run.status).not.toBe(0);
1067
+ const result = readResult(project);
1068
+ expect(result.package_source).toBe('inputs');
1069
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_MISSING');
1070
+ expect(result.error?.message).toContain('package.tar.gz');
1071
+ // v1 invariant: every screen entry is present even on build-level
1072
+ // failure, with the build error kept distinct from per-screen errors.
1073
+ expect(result.screens).toHaveLength(2);
1074
+ for (const screen of result.screens) {
1075
+ expect(screen.status).toBe('render_failed');
1076
+ expect(screen.error).toBeNull();
1077
+ expect(screen.reference_image_path).not.toBeNull();
1078
+ expect(screen.rendered_image_path).toBeNull();
1079
+ }
1080
+ // The harness never builds: swift is never invoked.
1081
+ expect(existsSync(logPath)).toBe(false);
1082
+ expectNoAbsolutePathLeaks(project, result);
1083
+ });
1084
+ test('a corrupt archive yields PACKAGE_ARCHIVE_INVALID', async () => {
1085
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1086
+ writeReference(project, 'home.png');
1087
+ stageArchive(project, Buffer.from('definitely not a gzipped tarball'));
1088
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1089
+ expect(run.status).not.toBe(0);
1090
+ const result = readResult(project);
1091
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1092
+ expect(result.screens).toHaveLength(1);
1093
+ expect(result.screens[0].error).toBeNull();
1094
+ expectNoAbsolutePathLeaks(project, result);
1095
+ });
1096
+ test('an archive with dot-dot or absolute members yields PACKAGE_ARCHIVE_INVALID', async () => {
1097
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1098
+ writeReference(project, 'home.png');
1099
+ const script = await buildInputsScript({ target: 'FixtureViews' });
1100
+ stageArchive(project, [
1101
+ ...shippedPackageEntries('pkg'),
1102
+ { name: 'pkg/../escape.swift', content: '// escape' },
1103
+ ]);
1104
+ let run = runScript(project, script);
1105
+ expect(run.status).not.toBe(0);
1106
+ let result = readResult(project);
1107
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1108
+ expect(result.error?.message).toContain('escape.swift');
1109
+ stageArchive(project, [
1110
+ ...shippedPackageEntries('pkg'),
1111
+ { name: '/abs/escape.swift', content: '// escape' },
1112
+ ]);
1113
+ run = runScript(project, script);
1114
+ expect(run.status).not.toBe(0);
1115
+ result = readResult(project);
1116
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1117
+ expectNoAbsolutePathLeaks(project, result);
1118
+ });
1119
+ test('an empty archive yields PACKAGE_ARCHIVE_INVALID', async () => {
1120
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1121
+ writeReference(project, 'home.png');
1122
+ stageArchive(project, []);
1123
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1124
+ expect(run.status).not.toBe(0);
1125
+ expect(readResult(project).error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1126
+ });
1127
+ test('multiple top-level roots yield PACKAGE_ARCHIVE_INVALID', async () => {
1128
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1129
+ writeReference(project, 'home.png');
1130
+ stageArchive(project, [
1131
+ ...shippedPackageEntries('pkg-one'),
1132
+ ...shippedPackageEntries('pkg-two'),
1133
+ ]);
1134
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1135
+ expect(run.status).not.toBe(0);
1136
+ const result = readResult(project);
1137
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1138
+ expect(result.error?.message).toContain('pkg-one');
1139
+ expect(result.error?.message).toContain('pkg-two');
1140
+ expectNoAbsolutePathLeaks(project, result);
1141
+ });
1142
+ test('a single top-level file yields PACKAGE_ARCHIVE_INVALID', async () => {
1143
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1144
+ writeReference(project, 'home.png');
1145
+ stageArchive(project, [{ name: 'HomeView.swift', content: 'import SwiftUI\n' }]);
1146
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1147
+ expect(run.status).not.toBe(0);
1148
+ expect(readResult(project).error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1149
+ });
1150
+ test('a package root without Package.swift yields PACKAGE_MANIFEST_MISSING', async () => {
1151
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1152
+ writeReference(project, 'home.png');
1153
+ // The manifest exists, but only deeper than the single top-level root.
1154
+ stageArchive(project, [
1155
+ { name: 'pkg/' },
1156
+ { name: 'pkg/Nested/Package.swift', content: SHIPPED_MANIFEST },
1157
+ { name: 'pkg/Nested/Sources/FixtureViews/HomeView.swift', content: 'import SwiftUI\n' },
1158
+ ]);
1159
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1160
+ expect(run.status).not.toBe(0);
1161
+ const result = readResult(project);
1162
+ expect(result.error?.code).toBe('PACKAGE_MANIFEST_MISSING');
1163
+ expectNoAbsolutePathLeaks(project, result);
1164
+ });
1165
+ test('an extraction larger than the size bound yields PACKAGE_ARCHIVE_TOO_LARGE', async () => {
1166
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1167
+ writeReference(project, 'home.png');
1168
+ stageArchive(project, shippedPackageEntries('pkg'));
1169
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1170
+ UI_FIDELITY_MAX_PACKAGE_BYTES: '64',
1171
+ });
1172
+ expect(run.status).not.toBe(0);
1173
+ const result = readResult(project);
1174
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_TOO_LARGE');
1175
+ expect(result.error?.message).toContain('64');
1176
+ expectNoAbsolutePathLeaks(project, result);
1177
+ });
1178
+ test('the default extraction bound is 32 MiB', async () => {
1179
+ const script = await buildInputsScript({ target: 'FixtureViews' });
1180
+ expect(script).toContain('32 * 1024 * 1024');
1181
+ });
1182
+ test('archive validation failures fail the step even with zero screens', async () => {
1183
+ const project = makeProject({ screens: {} });
1184
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1185
+ expect(run.status).not.toBe(0);
1186
+ const result = readResult(project);
1187
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_MISSING');
1188
+ expect(result.screens).toEqual([]);
1189
+ });
1190
+ test('an empty screens object with a valid archive still exits zero', async () => {
1191
+ const project = makeProject({ screens: {} });
1192
+ stageArchive(project, shippedPackageEntries('pkg'));
1193
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1194
+ expect(run.status).toBe(0);
1195
+ expect(readResult(project)).toEqual({
1196
+ renderer: 'imagerenderer-spm',
1197
+ package_source: 'inputs',
1198
+ error: null,
1199
+ screens: [],
1200
+ });
1201
+ });
1202
+ test('absent params.json still records the package source with empty screens', async () => {
1203
+ const project = makeProject();
1204
+ stageArchive(project, shippedPackageEntries('pkg'));
1205
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1206
+ expect(run.status).not.toBe(0);
1207
+ expect(readResult(project)).toEqual({
1208
+ renderer: 'imagerenderer-spm',
1209
+ package_source: 'inputs',
1210
+ error: null,
1211
+ screens: [],
1212
+ });
1213
+ });
1214
+ test('zero library products yield PACKAGE_TARGET_UNRESOLVED', async () => {
1215
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1216
+ writeReference(project, 'home.png');
1217
+ stageArchive(project, shippedPackageEntries('pkg'));
1218
+ const run = runScript(project, await buildInputsScript(), {
1219
+ FAKE_SWIFT_DUMP_PRODUCTS: '',
1220
+ });
1221
+ expect(run.status).not.toBe(0);
1222
+ const result = readResult(project);
1223
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1224
+ expect(result.error?.message).toContain('no library products');
1225
+ });
1226
+ test('multiple library products yield PACKAGE_TARGET_UNRESOLVED naming the candidates', async () => {
1227
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1228
+ writeReference(project, 'home.png');
1229
+ stageArchive(project, shippedPackageEntries('pkg'));
1230
+ const run = runScript(project, await buildInputsScript(), {
1231
+ FAKE_SWIFT_DUMP_PRODUCTS: 'AlphaViews,BetaViews',
1232
+ });
1233
+ expect(run.status).not.toBe(0);
1234
+ const result = readResult(project);
1235
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1236
+ expect(result.error?.message).toContain('AlphaViews');
1237
+ expect(result.error?.message).toContain('BetaViews');
1238
+ });
1239
+ test('a failing dump-package yields PACKAGE_TARGET_UNRESOLVED', async () => {
1240
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1241
+ writeReference(project, 'home.png');
1242
+ stageArchive(project, shippedPackageEntries('pkg'));
1243
+ const run = runScript(project, await buildInputsScript(), {
1244
+ FAKE_SWIFT_DUMP_FAIL: '1',
1245
+ });
1246
+ expect(run.status).not.toBe(0);
1247
+ const result = readResult(project);
1248
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1249
+ expect(result.error?.message).toContain('malformed Package.swift manifest');
1250
+ expectNoAbsolutePathLeaks(project, result);
1251
+ });
1252
+ test('a discovered product that is not a Swift identifier yields PACKAGE_TARGET_UNRESOLVED', async () => {
1253
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1254
+ writeReference(project, 'home.png');
1255
+ stageArchive(project, shippedPackageEntries('pkg'));
1256
+ const run = runScript(project, await buildInputsScript(), {
1257
+ FAKE_SWIFT_DUMP_PRODUCTS: 'My-Views',
1258
+ });
1259
+ expect(run.status).not.toBe(0);
1260
+ expect(readResult(project).error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1261
+ });
1262
+ test('a broken screen fails alone in inputs mode', async () => {
1263
+ const project = makeProject({
1264
+ screens: { HomeView: 'home.png', MissingView: 'missing-view.png' },
1265
+ });
1266
+ writeReference(project, 'home.png');
1267
+ writeReference(project, 'missing-view.png');
1268
+ stageArchive(project, shippedPackageEntries('pkg'));
1269
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1270
+ FAKE_SWIFT_COMPILE_FAIL: 'MissingView',
1271
+ });
1272
+ expect(run.status).not.toBe(0);
1273
+ const result = readResult(project);
1274
+ // Per-screen isolation is unchanged: the break is per-screen, the
1275
+ // build-level error stays null.
1276
+ expect(result.error).toBeNull();
1277
+ const [home, missing] = result.screens;
1278
+ expect(home.status).toBe('rendered');
1279
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
1280
+ expect(missing.status).toBe('render_failed');
1281
+ expect(missing.error?.code).toBe('VIEW_COMPILE_FAILED');
1282
+ expectNoAbsolutePathLeaks(project, result);
1283
+ });
1284
+ test('a shipped package that does not build for macOS is sanitized to <package>', async () => {
1285
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1286
+ writeReference(project, 'home.png');
1287
+ stageArchive(project, shippedPackageEntries('pkg'));
1288
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1289
+ FAKE_SWIFT_PROBE_FAIL: '1',
1290
+ });
1291
+ expect(run.status).not.toBe(0);
1292
+ const result = readResult(project);
1293
+ expect(result.error).toBeNull();
1294
+ expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
1295
+ expect(result.screens[0].error?.message).toContain('<package>/pkg');
1296
+ expectNoAbsolutePathLeaks(project, result);
1297
+ });
1298
+ });
695
1299
  describe('committed fixture package', () => {
696
1300
  test('declares the FixtureViews library with the screens the example pipeline uses', () => {
697
1301
  const manifest = readFileSync(join(FIXTURE_PACKAGE, 'Package.swift'), 'utf-8');