@invarn/cibuild 2.0.0 → 2.0.2

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,15 +30,33 @@ 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
61
  ' product=""; target=""; pkg=""',
43
62
  ' while [ $# -gt 0 ]; do',
@@ -136,6 +155,66 @@ function runScript(project, script, env = {}) {
136
155
  },
137
156
  });
138
157
  }
158
+ function tarHeader(name, size, typeflag) {
159
+ const header = Buffer.alloc(512);
160
+ header.write(name, 0, 100, 'utf-8');
161
+ header.write('0000755\0', 100, 8, 'ascii'); // mode
162
+ header.write('0000000\0', 108, 8, 'ascii'); // uid
163
+ header.write('0000000\0', 116, 8, 'ascii'); // gid
164
+ header.write(size.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii');
165
+ header.write('00000000000\0', 136, 12, 'ascii'); // mtime
166
+ header.write(' ', 148, 8, 'ascii'); // checksum placeholder
167
+ header.write(typeflag, 156, 1, 'ascii');
168
+ header.write('ustar\0' + '00', 257, 8, 'ascii');
169
+ let checksum = 0;
170
+ for (const byte of header)
171
+ checksum += byte;
172
+ header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
173
+ return header;
174
+ }
175
+ function makeTarGz(entries) {
176
+ const blocks = [];
177
+ for (const entry of entries) {
178
+ const isDir = entry.name.endsWith('/');
179
+ const content = Buffer.from(entry.content ?? '', 'utf-8');
180
+ blocks.push(tarHeader(entry.name, isDir ? 0 : content.length, isDir ? '5' : '0'));
181
+ if (!isDir && content.length > 0) {
182
+ const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512);
183
+ content.copy(padded);
184
+ blocks.push(padded);
185
+ }
186
+ }
187
+ blocks.push(Buffer.alloc(1024)); // end-of-archive
188
+ return gzipSync(Buffer.concat(blocks));
189
+ }
190
+ const SHIPPED_MANIFEST = [
191
+ '// swift-tools-version:5.9',
192
+ 'import PackageDescription',
193
+ 'let package = Package(',
194
+ ' name: "shipped-package",',
195
+ ' platforms: [.macOS(.v13)],',
196
+ ' products: [.library(name: "FixtureViews", targets: ["FixtureViews"])],',
197
+ ' targets: [.target(name: "FixtureViews")]',
198
+ ')',
199
+ ].join('\n');
200
+ /** Entries for a well-formed shipped package under the given root dir ('' = flat). */
201
+ function shippedPackageEntries(root = 'pkg') {
202
+ const prefix = root === '' ? '' : root + '/';
203
+ const entries = root === '' ? [] : [{ name: prefix }];
204
+ entries.push({ name: prefix + 'Package.swift', content: SHIPPED_MANIFEST }, {
205
+ name: prefix + 'Sources/FixtureViews/HomeView.swift',
206
+ content: 'import SwiftUI\npublic struct HomeView: View { public init() {} public var body: some View { Text("home") } }\n',
207
+ });
208
+ return entries;
209
+ }
210
+ function stageArchive(project, archive) {
211
+ writeFileSync(join(project.dir, '.ci', 'inputs', 'package.tar.gz'), Buffer.isBuffer(archive) ? archive : makeTarGz(archive));
212
+ }
213
+ async function buildInputsScript(extra = {}) {
214
+ const executor = new UiFidelityRenderStepExecutor();
215
+ const step = await executor.execute({ package_source: 'inputs', ...extra }, {}, testConfig);
216
+ return step.script;
217
+ }
139
218
  function readResult(project) {
140
219
  const resultPath = join(project.dir, '.ci', 'artifacts', 'protocol-result.json');
141
220
  expect(existsSync(resultPath)).toBe(true);
@@ -151,8 +230,9 @@ function isPng(filePath) {
151
230
  /**
152
231
  * Asserts that no error message in the result document contains an absolute
153
232
  * 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.
233
+ * temp dir (where harness packages and extracted shipped packages live), in
234
+ * either symlinked (/var/folders/...) or resolved (/private/var/...) form.
235
+ * Covers both per-screen errors and the build-level error object.
156
236
  */
157
237
  function expectNoAbsolutePathLeaks(project, result) {
158
238
  const forbidden = new Set();
@@ -165,8 +245,9 @@ function expectNoAbsolutePathLeaks(project, result) {
165
245
  // nonexistent — literal form only
166
246
  }
167
247
  }
168
- for (const screen of result.screens) {
169
- const message = screen.error?.message ?? '';
248
+ const messages = result.screens.map((screen) => screen.error?.message ?? '');
249
+ messages.push(result.error?.message ?? '');
250
+ for (const message of messages) {
170
251
  for (const dir of forbidden) {
171
252
  expect(message).not.toContain(dir);
172
253
  }
@@ -182,18 +263,38 @@ describe('registry integration', () => {
182
263
  const inputs = metadata?.inputs ?? {};
183
264
  expect(Object.keys(inputs).sort()).toEqual([
184
265
  'package_path',
266
+ 'package_source',
185
267
  'render_size',
186
268
  'scale',
187
269
  'target',
188
270
  ]);
189
- expect(inputs.package_path.required).toBe(true);
190
- expect(inputs.target.required).toBe(true);
271
+ // package_path/target are enforced by the executor in repo mode (the
272
+ // default); a package_source:inputs config is valid without either, so
273
+ // neither is unconditionally required at the schema level.
274
+ expect(inputs.package_path.required).toBe(false);
275
+ expect(inputs.target.required).toBe(false);
276
+ expect(inputs.package_source.required).toBe(false);
277
+ expect(inputs.package_source.default).toBe('repo');
191
278
  expect(inputs.render_size.required).toBe(false);
192
279
  expect(inputs.render_size.default).toBe(DEFAULT_RENDER_SIZE);
193
280
  expect(inputs.scale.required).toBe(false);
194
281
  expect(inputs.scale.default).toBe(DEFAULT_SCALE);
195
282
  });
196
283
  });
284
+ describe('v1 byte compatibility', () => {
285
+ // Snapshot of the script generated before package_source existed, captured
286
+ // from the executor with package_path './MyAppPackage' and target
287
+ // 'MyAppViews' (defaults for everything else). Omitting package_source must
288
+ // keep the generated script byte-identical to that snapshot, so existing
289
+ // protocols are provably untouched. Update the snapshot only for a
290
+ // deliberate, reviewed change to the v1 runtime.
291
+ const V1_SCRIPT_SNAPSHOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-render-v1-script.txt');
292
+ test('omitting package_source generates a byte-identical script', async () => {
293
+ const executor = new UiFidelityRenderStepExecutor();
294
+ const step = await executor.execute({ package_path: './MyAppPackage', target: 'MyAppViews' }, {}, testConfig);
295
+ expect(step.script).toBe(readFileSync(V1_SCRIPT_SNAPSHOT, 'utf-8'));
296
+ });
297
+ });
197
298
  describe('UiFidelityRenderStepExecutor', () => {
198
299
  test('returns a node StepDef', async () => {
199
300
  const executor = new UiFidelityRenderStepExecutor();
@@ -234,6 +335,52 @@ describe('UiFidelityRenderStepExecutor', () => {
234
335
  await expect(executor.execute({ package_path: './pkg', target: 'MyViews', render_size: 'huge' }, {}, testConfig)).rejects.toThrow(/render_size/);
235
336
  });
236
337
  });
338
+ describe('package_source input', () => {
339
+ test('rejects an unknown package_source value', async () => {
340
+ const executor = new UiFidelityRenderStepExecutor();
341
+ await expect(executor.execute({ package_path: './pkg', target: 'MyViews', package_source: 'artifact' }, {}, testConfig)).rejects.toThrow(/package_source/);
342
+ });
343
+ test('omitting package_source bakes no packageSource into the script config', async () => {
344
+ const executor = new UiFidelityRenderStepExecutor();
345
+ const step = await executor.execute({ package_path: './pkg', target: 'MyViews' }, {}, testConfig);
346
+ expect(step.script).not.toContain('packageSource');
347
+ });
348
+ test('explicit repo keeps the v1 inputs required and records the source', async () => {
349
+ const executor = new UiFidelityRenderStepExecutor();
350
+ const step = await executor.execute({ package_path: './pkg', target: 'MyViews', package_source: 'repo' }, {}, testConfig);
351
+ expect(step.kind).toBe('node');
352
+ expect(step.script).toContain('"packageSource":"repo"');
353
+ expect(step.script).toContain('"packagePath":"./pkg"');
354
+ await expect(executor.execute({ target: 'MyViews', package_source: 'repo' }, {}, testConfig)).rejects.toThrow("Missing required input 'package_path'");
355
+ await expect(executor.execute({ package_path: './pkg', package_source: 'repo' }, {}, testConfig)).rejects.toThrow("Missing required input 'target'");
356
+ });
357
+ test('inputs mode requires neither package_path nor target', async () => {
358
+ const script = await buildInputsScript();
359
+ expect(script).toContain('"packageSource":"inputs"');
360
+ expect(script).toContain('"packagePath":null');
361
+ expect(script).toContain('"target":null');
362
+ });
363
+ test('inputs mode ignores package_path and honors an explicit target', async () => {
364
+ const script = await buildInputsScript({ package_path: './pkg', target: 'MyViews' });
365
+ expect(script).toContain('"packagePath":null');
366
+ expect(script).toContain('"target":"MyViews"');
367
+ });
368
+ test('inputs mode still rejects a malformed target', async () => {
369
+ const executor = new UiFidelityRenderStepExecutor();
370
+ await expect(executor.execute({ package_source: 'inputs', target: 'My Views' }, {}, testConfig)).rejects.toThrow(/target/i);
371
+ });
372
+ test('validation requirements branch on the package source', () => {
373
+ const executor = new UiFidelityRenderStepExecutor();
374
+ const v1Names = executor
375
+ .getValidationRequirements({ package_path: './pkg', target: 'MyViews' }, {}, testConfig)
376
+ .map((requirement) => requirement.name);
377
+ expect(v1Names).toEqual(['package_path', 'target', 'swift']);
378
+ const inputsNames = executor
379
+ .getValidationRequirements({ package_source: 'inputs' }, {}, testConfig)
380
+ .map((requirement) => requirement.name);
381
+ expect(inputsNames).toEqual(['swift', 'tar']);
382
+ });
383
+ });
237
384
  describe('render size and scale parsing', () => {
238
385
  test('default render size is 393x852 device points', () => {
239
386
  expect(DEFAULT_RENDER_SIZE).toBe('393x852');
@@ -692,6 +839,388 @@ describe('render script runtime', () => {
692
839
  expect(readResult(project).screens[0].status).toBe('rendered');
693
840
  });
694
841
  });
842
+ describe('render script runtime (package_source: repo)', () => {
843
+ test('explicit repo renders like v1 and records the package source', async () => {
844
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
845
+ writeReference(project, 'home.png');
846
+ const script = await buildScript(project, { package_source: 'repo' });
847
+ const run = runScript(project, script);
848
+ expect(run.status).toBe(0);
849
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
850
+ expect(readResult(project)).toEqual({
851
+ renderer: 'imagerenderer-spm',
852
+ package_source: 'repo',
853
+ error: null,
854
+ screens: [
855
+ {
856
+ screen: 'HomeView',
857
+ status: 'rendered',
858
+ error: null,
859
+ reference_image_path: 'ui-fidelity/references/HomeView.png',
860
+ rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
861
+ },
862
+ ],
863
+ });
864
+ });
865
+ });
866
+ describe('render script runtime (package_source: inputs)', () => {
867
+ test('renders every screen from a shipped archive with a single package root', async () => {
868
+ const project = makeProject({
869
+ screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
870
+ });
871
+ writeReference(project, 'home.png');
872
+ writeReference(project, 'settings.png');
873
+ stageArchive(project, shippedPackageEntries('pkg'));
874
+ const script = await buildInputsScript({ target: 'FixtureViews' });
875
+ const logPath = join(project.dir, 'swift-invocations.log');
876
+ const run = runScript(project, script, {
877
+ FAKE_SWIFT_LOG: logPath,
878
+ UI_FIDELITY_KEEP_HARNESS: '1',
879
+ });
880
+ expect(run.status).toBe(0);
881
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
882
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
883
+ expect(readResult(project)).toEqual({
884
+ renderer: 'imagerenderer-spm',
885
+ package_source: 'inputs',
886
+ error: null,
887
+ screens: [
888
+ {
889
+ screen: 'HomeView',
890
+ status: 'rendered',
891
+ error: null,
892
+ reference_image_path: 'ui-fidelity/references/HomeView.png',
893
+ rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
894
+ },
895
+ {
896
+ screen: 'SettingsView',
897
+ status: 'rendered',
898
+ error: null,
899
+ reference_image_path: 'ui-fidelity/references/SettingsView.png',
900
+ rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
901
+ },
902
+ ],
903
+ });
904
+ // The synthesized harness depends on the EXTRACTED package, not on any
905
+ // path in the repo checkout.
906
+ const probeArgs = readFileSync(logPath, 'utf-8').trim().split('\n')[0].split(' ');
907
+ const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
908
+ tempDirs.push(harnessDir);
909
+ const manifest = readFileSync(join(harnessDir, 'Package.swift'), 'utf-8');
910
+ expect(manifest).toMatch(/\.package\(path: "[^"]*ui-fidelity-package-[^"]*\/pkg"\)/);
911
+ expect(manifest).toContain('.product(name: "FixtureViews", package: "pkg")');
912
+ const extracted = manifest.match(/\.package\(path: "([^"]*)\/pkg"\)/);
913
+ if (extracted)
914
+ tempDirs.push(extracted[1]);
915
+ });
916
+ test('accepts Package.swift at the archive root', async () => {
917
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
918
+ writeReference(project, 'home.png');
919
+ stageArchive(project, shippedPackageEntries(''));
920
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
921
+ expect(run.status).toBe(0);
922
+ const result = readResult(project);
923
+ expect(result.error).toBeNull();
924
+ expect(result.screens[0].status).toBe('rendered');
925
+ });
926
+ test('ignores macOS junk entries when locating the package root', async () => {
927
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
928
+ writeReference(project, 'home.png');
929
+ stageArchive(project, [
930
+ { name: '.DS_Store', content: 'junk' },
931
+ { name: '._pkg', content: 'apple-double junk' },
932
+ ...shippedPackageEntries('pkg'),
933
+ ]);
934
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
935
+ expect(run.status).toBe(0);
936
+ expect(readResult(project).screens[0].status).toBe('rendered');
937
+ });
938
+ test('discovers the target from a single library product', async () => {
939
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
940
+ writeReference(project, 'home.png');
941
+ stageArchive(project, shippedPackageEntries('pkg'));
942
+ const logPath = join(project.dir, 'swift-invocations.log');
943
+ const run = runScript(project, await buildInputsScript(), {
944
+ FAKE_SWIFT_LOG: logPath,
945
+ UI_FIDELITY_KEEP_HARNESS: '1',
946
+ });
947
+ expect(run.status).toBe(0);
948
+ expect(readResult(project).screens[0].status).toBe('rendered');
949
+ const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
950
+ expect(invocations[0]).toContain('dump-package');
951
+ const dumpArgs = invocations[0].split(' ');
952
+ tempDirs.push(dumpArgs[dumpArgs.indexOf('--package-path') + 1]);
953
+ // The discovered product is what the harness imports.
954
+ const probeArgs = invocations[1].split(' ');
955
+ const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
956
+ tempDirs.push(harnessDir);
957
+ expect(readFileSync(join(harnessDir, 'Sources', 'Render_HomeView', 'Render.swift'), 'utf-8')).toContain('import FixtureViews');
958
+ });
959
+ test('an explicit target skips manifest discovery', async () => {
960
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
961
+ writeReference(project, 'home.png');
962
+ stageArchive(project, shippedPackageEntries('pkg'));
963
+ const logPath = join(project.dir, 'swift-invocations.log');
964
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), { FAKE_SWIFT_LOG: logPath });
965
+ expect(run.status).toBe(0);
966
+ expect(readFileSync(logPath, 'utf-8')).not.toContain('dump-package');
967
+ });
968
+ test('cleans up the extraction scratch directory after rendering', async () => {
969
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
970
+ writeReference(project, 'home.png');
971
+ stageArchive(project, shippedPackageEntries('pkg'));
972
+ const logPath = join(project.dir, 'swift-invocations.log');
973
+ const run = runScript(project, await buildInputsScript(), { FAKE_SWIFT_LOG: logPath });
974
+ expect(run.status).toBe(0);
975
+ // The dump-package invocation names the extracted package root.
976
+ const dumpArgs = readFileSync(logPath, 'utf-8').trim().split('\n')[0].split(' ');
977
+ const extractedRoot = dumpArgs[dumpArgs.indexOf('--package-path') + 1];
978
+ expect(extractedRoot).toContain('ui-fidelity-package-');
979
+ expect(existsSync(extractedRoot)).toBe(false);
980
+ });
981
+ test('missing archive yields PACKAGE_ARCHIVE_MISSING with every screen entry present', async () => {
982
+ const project = makeProject({
983
+ screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
984
+ });
985
+ writeReference(project, 'home.png');
986
+ writeReference(project, 'settings.png');
987
+ const logPath = join(project.dir, 'swift-invocations.log');
988
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
989
+ FAKE_SWIFT_LOG: logPath,
990
+ });
991
+ expect(run.status).not.toBe(0);
992
+ const result = readResult(project);
993
+ expect(result.package_source).toBe('inputs');
994
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_MISSING');
995
+ expect(result.error?.message).toContain('package.tar.gz');
996
+ // v1 invariant: every screen entry is present even on build-level
997
+ // failure, with the build error kept distinct from per-screen errors.
998
+ expect(result.screens).toHaveLength(2);
999
+ for (const screen of result.screens) {
1000
+ expect(screen.status).toBe('render_failed');
1001
+ expect(screen.error).toBeNull();
1002
+ expect(screen.reference_image_path).not.toBeNull();
1003
+ expect(screen.rendered_image_path).toBeNull();
1004
+ }
1005
+ // The harness never builds: swift is never invoked.
1006
+ expect(existsSync(logPath)).toBe(false);
1007
+ expectNoAbsolutePathLeaks(project, result);
1008
+ });
1009
+ test('a corrupt archive yields PACKAGE_ARCHIVE_INVALID', async () => {
1010
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1011
+ writeReference(project, 'home.png');
1012
+ stageArchive(project, Buffer.from('definitely not a gzipped tarball'));
1013
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1014
+ expect(run.status).not.toBe(0);
1015
+ const result = readResult(project);
1016
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1017
+ expect(result.screens).toHaveLength(1);
1018
+ expect(result.screens[0].error).toBeNull();
1019
+ expectNoAbsolutePathLeaks(project, result);
1020
+ });
1021
+ test('an archive with dot-dot or absolute members yields PACKAGE_ARCHIVE_INVALID', async () => {
1022
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1023
+ writeReference(project, 'home.png');
1024
+ const script = await buildInputsScript({ target: 'FixtureViews' });
1025
+ stageArchive(project, [
1026
+ ...shippedPackageEntries('pkg'),
1027
+ { name: 'pkg/../escape.swift', content: '// escape' },
1028
+ ]);
1029
+ let run = runScript(project, script);
1030
+ expect(run.status).not.toBe(0);
1031
+ let result = readResult(project);
1032
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1033
+ expect(result.error?.message).toContain('escape.swift');
1034
+ stageArchive(project, [
1035
+ ...shippedPackageEntries('pkg'),
1036
+ { name: '/abs/escape.swift', content: '// escape' },
1037
+ ]);
1038
+ run = runScript(project, script);
1039
+ expect(run.status).not.toBe(0);
1040
+ result = readResult(project);
1041
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1042
+ expectNoAbsolutePathLeaks(project, result);
1043
+ });
1044
+ test('an empty archive yields PACKAGE_ARCHIVE_INVALID', async () => {
1045
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1046
+ writeReference(project, 'home.png');
1047
+ stageArchive(project, []);
1048
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1049
+ expect(run.status).not.toBe(0);
1050
+ expect(readResult(project).error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1051
+ });
1052
+ test('multiple top-level roots yield PACKAGE_ARCHIVE_INVALID', async () => {
1053
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1054
+ writeReference(project, 'home.png');
1055
+ stageArchive(project, [
1056
+ ...shippedPackageEntries('pkg-one'),
1057
+ ...shippedPackageEntries('pkg-two'),
1058
+ ]);
1059
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1060
+ expect(run.status).not.toBe(0);
1061
+ const result = readResult(project);
1062
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1063
+ expect(result.error?.message).toContain('pkg-one');
1064
+ expect(result.error?.message).toContain('pkg-two');
1065
+ expectNoAbsolutePathLeaks(project, result);
1066
+ });
1067
+ test('a single top-level file yields PACKAGE_ARCHIVE_INVALID', async () => {
1068
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1069
+ writeReference(project, 'home.png');
1070
+ stageArchive(project, [{ name: 'HomeView.swift', content: 'import SwiftUI\n' }]);
1071
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1072
+ expect(run.status).not.toBe(0);
1073
+ expect(readResult(project).error?.code).toBe('PACKAGE_ARCHIVE_INVALID');
1074
+ });
1075
+ test('a package root without Package.swift yields PACKAGE_MANIFEST_MISSING', async () => {
1076
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1077
+ writeReference(project, 'home.png');
1078
+ // The manifest exists, but only deeper than the single top-level root.
1079
+ stageArchive(project, [
1080
+ { name: 'pkg/' },
1081
+ { name: 'pkg/Nested/Package.swift', content: SHIPPED_MANIFEST },
1082
+ { name: 'pkg/Nested/Sources/FixtureViews/HomeView.swift', content: 'import SwiftUI\n' },
1083
+ ]);
1084
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1085
+ expect(run.status).not.toBe(0);
1086
+ const result = readResult(project);
1087
+ expect(result.error?.code).toBe('PACKAGE_MANIFEST_MISSING');
1088
+ expectNoAbsolutePathLeaks(project, result);
1089
+ });
1090
+ test('an extraction larger than the size bound yields PACKAGE_ARCHIVE_TOO_LARGE', async () => {
1091
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1092
+ writeReference(project, 'home.png');
1093
+ stageArchive(project, shippedPackageEntries('pkg'));
1094
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1095
+ UI_FIDELITY_MAX_PACKAGE_BYTES: '64',
1096
+ });
1097
+ expect(run.status).not.toBe(0);
1098
+ const result = readResult(project);
1099
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_TOO_LARGE');
1100
+ expect(result.error?.message).toContain('64');
1101
+ expectNoAbsolutePathLeaks(project, result);
1102
+ });
1103
+ test('the default extraction bound is 32 MiB', async () => {
1104
+ const script = await buildInputsScript({ target: 'FixtureViews' });
1105
+ expect(script).toContain('32 * 1024 * 1024');
1106
+ });
1107
+ test('archive validation failures fail the step even with zero screens', async () => {
1108
+ const project = makeProject({ screens: {} });
1109
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1110
+ expect(run.status).not.toBe(0);
1111
+ const result = readResult(project);
1112
+ expect(result.error?.code).toBe('PACKAGE_ARCHIVE_MISSING');
1113
+ expect(result.screens).toEqual([]);
1114
+ });
1115
+ test('an empty screens object with a valid archive still exits zero', async () => {
1116
+ const project = makeProject({ screens: {} });
1117
+ stageArchive(project, shippedPackageEntries('pkg'));
1118
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1119
+ expect(run.status).toBe(0);
1120
+ expect(readResult(project)).toEqual({
1121
+ renderer: 'imagerenderer-spm',
1122
+ package_source: 'inputs',
1123
+ error: null,
1124
+ screens: [],
1125
+ });
1126
+ });
1127
+ test('absent params.json still records the package source with empty screens', async () => {
1128
+ const project = makeProject();
1129
+ stageArchive(project, shippedPackageEntries('pkg'));
1130
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1131
+ expect(run.status).not.toBe(0);
1132
+ expect(readResult(project)).toEqual({
1133
+ renderer: 'imagerenderer-spm',
1134
+ package_source: 'inputs',
1135
+ error: null,
1136
+ screens: [],
1137
+ });
1138
+ });
1139
+ test('zero library products yield PACKAGE_TARGET_UNRESOLVED', async () => {
1140
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1141
+ writeReference(project, 'home.png');
1142
+ stageArchive(project, shippedPackageEntries('pkg'));
1143
+ const run = runScript(project, await buildInputsScript(), {
1144
+ FAKE_SWIFT_DUMP_PRODUCTS: '',
1145
+ });
1146
+ expect(run.status).not.toBe(0);
1147
+ const result = readResult(project);
1148
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1149
+ expect(result.error?.message).toContain('no library products');
1150
+ });
1151
+ test('multiple library products yield PACKAGE_TARGET_UNRESOLVED naming the candidates', async () => {
1152
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1153
+ writeReference(project, 'home.png');
1154
+ stageArchive(project, shippedPackageEntries('pkg'));
1155
+ const run = runScript(project, await buildInputsScript(), {
1156
+ FAKE_SWIFT_DUMP_PRODUCTS: 'AlphaViews,BetaViews',
1157
+ });
1158
+ expect(run.status).not.toBe(0);
1159
+ const result = readResult(project);
1160
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1161
+ expect(result.error?.message).toContain('AlphaViews');
1162
+ expect(result.error?.message).toContain('BetaViews');
1163
+ });
1164
+ test('a failing dump-package yields PACKAGE_TARGET_UNRESOLVED', async () => {
1165
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1166
+ writeReference(project, 'home.png');
1167
+ stageArchive(project, shippedPackageEntries('pkg'));
1168
+ const run = runScript(project, await buildInputsScript(), {
1169
+ FAKE_SWIFT_DUMP_FAIL: '1',
1170
+ });
1171
+ expect(run.status).not.toBe(0);
1172
+ const result = readResult(project);
1173
+ expect(result.error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1174
+ expect(result.error?.message).toContain('malformed Package.swift manifest');
1175
+ expectNoAbsolutePathLeaks(project, result);
1176
+ });
1177
+ test('a discovered product that is not a Swift identifier yields PACKAGE_TARGET_UNRESOLVED', async () => {
1178
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1179
+ writeReference(project, 'home.png');
1180
+ stageArchive(project, shippedPackageEntries('pkg'));
1181
+ const run = runScript(project, await buildInputsScript(), {
1182
+ FAKE_SWIFT_DUMP_PRODUCTS: 'My-Views',
1183
+ });
1184
+ expect(run.status).not.toBe(0);
1185
+ expect(readResult(project).error?.code).toBe('PACKAGE_TARGET_UNRESOLVED');
1186
+ });
1187
+ test('a broken screen fails alone in inputs mode', async () => {
1188
+ const project = makeProject({
1189
+ screens: { HomeView: 'home.png', MissingView: 'missing-view.png' },
1190
+ });
1191
+ writeReference(project, 'home.png');
1192
+ writeReference(project, 'missing-view.png');
1193
+ stageArchive(project, shippedPackageEntries('pkg'));
1194
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1195
+ FAKE_SWIFT_COMPILE_FAIL: 'MissingView',
1196
+ });
1197
+ expect(run.status).not.toBe(0);
1198
+ const result = readResult(project);
1199
+ // Per-screen isolation is unchanged: the break is per-screen, the
1200
+ // build-level error stays null.
1201
+ expect(result.error).toBeNull();
1202
+ const [home, missing] = result.screens;
1203
+ expect(home.status).toBe('rendered');
1204
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
1205
+ expect(missing.status).toBe('render_failed');
1206
+ expect(missing.error?.code).toBe('VIEW_COMPILE_FAILED');
1207
+ expectNoAbsolutePathLeaks(project, result);
1208
+ });
1209
+ test('a shipped package that does not build for macOS is sanitized to <package>', async () => {
1210
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1211
+ writeReference(project, 'home.png');
1212
+ stageArchive(project, shippedPackageEntries('pkg'));
1213
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }), {
1214
+ FAKE_SWIFT_PROBE_FAIL: '1',
1215
+ });
1216
+ expect(run.status).not.toBe(0);
1217
+ const result = readResult(project);
1218
+ expect(result.error).toBeNull();
1219
+ expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
1220
+ expect(result.screens[0].error?.message).toContain('<package>/pkg');
1221
+ expectNoAbsolutePathLeaks(project, result);
1222
+ });
1223
+ });
695
1224
  describe('committed fixture package', () => {
696
1225
  test('declares the FixtureViews library with the screens the example pipeline uses', () => {
697
1226
  const manifest = readFileSync(join(FIXTURE_PACKAGE, 'Package.swift'), 'utf-8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",