@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.
- package/dist/cli.cjs +272 -3
- package/dist/src/runner-node-step.test.d.ts +12 -0
- package/dist/src/runner-node-step.test.d.ts.map +1 -0
- package/dist/src/runner-node-step.test.js +61 -0
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +16 -1
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +14 -5
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +71 -8
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +463 -7
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +535 -6
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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');
|