@invarn/cibuild 1.9.8 → 2.0.0
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 +477 -1
- package/dist/src/yaml/platform-detector.js +1 -1
- package/dist/src/yaml/platform-detector.test.js +19 -0
- package/dist/src/yaml/steps/cache.d.ts.map +1 -1
- package/dist/src/yaml/steps/cache.js +6 -1
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +29 -0
- package/dist/src/yaml/steps/steps.test.js +40 -9
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +120 -0
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render.js +610 -0
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts +18 -0
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +770 -0
- package/package.json +1 -1
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ui-fidelity-render step.
|
|
3
|
+
*
|
|
4
|
+
* The runtime tests execute the generated node script for real inside a temp
|
|
5
|
+
* project directory, with a PATH-shimmed fake `swift` binary so harness
|
|
6
|
+
* builds and renders are deterministic. Fake swift behavior is driven by env
|
|
7
|
+
* vars (FAKE_SWIFT_PROBE_FAIL, FAKE_SWIFT_COMPILE_FAIL, FAKE_SWIFT_RUN_FAIL,
|
|
8
|
+
* FAKE_SWIFT_RUN_SILENT). Like the real toolchain, its failure diagnostics
|
|
9
|
+
* embed the absolute harness package path, so the suite can prove those
|
|
10
|
+
* paths never reach protocol-result.json.
|
|
11
|
+
*
|
|
12
|
+
* The committed SwiftPM fixture (test/fixtures/ui-fidelity-package) is wired
|
|
13
|
+
* through the harness with the fake toolchain in every run; set
|
|
14
|
+
* CIBUILD_UI_FIDELITY_REAL_SWIFT=1 to also build and render it with the real
|
|
15
|
+
* Swift toolchain (macOS with Xcode required, slow).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect, afterAll } from '@jest/globals';
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { DEFAULT_RENDER_SIZE, DEFAULT_SCALE, UiFidelityRenderStepExecutor, generateRenderScript, getRenderScriptInternals, parseRenderSize, parseScale, } from './ui-fidelity-render.js';
|
|
24
|
+
import { clearRegistry, getStepMetadata, hasStep } from './registry.js';
|
|
25
|
+
import { initializeStepRegistry } from './index.js';
|
|
26
|
+
import { testConfig } from './test-config.js';
|
|
27
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
28
|
+
/** The committed SwiftPM fixture used by examples/ui-fidelity-render.yml. */
|
|
29
|
+
const FIXTURE_PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-package');
|
|
30
|
+
/**
|
|
31
|
+
* Fake `swift` CLI. Mirrors the exact invocations the render script makes:
|
|
32
|
+
* swift build --package-path <dir> --target RenderProbe
|
|
33
|
+
* swift build --package-path <dir> --product Render_<Screen>
|
|
34
|
+
* swift run --package-path <dir> --skip-build Render_<Screen> <output.png>
|
|
35
|
+
*/
|
|
36
|
+
const FAKE_SWIFT_LINES = [
|
|
37
|
+
'#!/bin/bash',
|
|
38
|
+
'[ -n "$FAKE_SWIFT_LOG" ] && echo "$*" >> "$FAKE_SWIFT_LOG"',
|
|
39
|
+
'cmd="$1"; shift',
|
|
40
|
+
'case "$cmd" in',
|
|
41
|
+
' build)',
|
|
42
|
+
' product=""; target=""; pkg=""',
|
|
43
|
+
' while [ $# -gt 0 ]; do',
|
|
44
|
+
' case "$1" in',
|
|
45
|
+
' --product) product="$2"; shift 2 ;;',
|
|
46
|
+
' --target) target="$2"; shift 2 ;;',
|
|
47
|
+
' --package-path) pkg="$2"; shift 2 ;;',
|
|
48
|
+
' *) shift ;;',
|
|
49
|
+
' esac',
|
|
50
|
+
' done',
|
|
51
|
+
' name="${product:-$target}"',
|
|
52
|
+
' if [ "$name" = "RenderProbe" ]; then',
|
|
53
|
+
' if [ -n "$FAKE_SWIFT_PROBE_FAIL" ]; then',
|
|
54
|
+
' echo "$pkg/Sources/RenderProbe/Probe.swift:1:8: error: could not resolve package dependencies" >&2',
|
|
55
|
+
' exit 1',
|
|
56
|
+
' fi',
|
|
57
|
+
' exit 0',
|
|
58
|
+
' fi',
|
|
59
|
+
' screen="${name#Render_}"',
|
|
60
|
+
' case ",$FAKE_SWIFT_COMPILE_FAIL," in',
|
|
61
|
+
' *",$screen,"*)',
|
|
62
|
+
' echo "$pkg/Sources/Render_$screen/Render.swift:7:20: error: cannot find $screen in scope" >&2',
|
|
63
|
+
' exit 1 ;;',
|
|
64
|
+
' esac',
|
|
65
|
+
' exit 0 ;;',
|
|
66
|
+
' run)',
|
|
67
|
+
' pos=()',
|
|
68
|
+
' while [ $# -gt 0 ]; do',
|
|
69
|
+
' case "$1" in',
|
|
70
|
+
' --package-path) shift 2 ;;',
|
|
71
|
+
' --skip-build) shift ;;',
|
|
72
|
+
' *) pos+=("$1"); shift ;;',
|
|
73
|
+
' esac',
|
|
74
|
+
' done',
|
|
75
|
+
' name="${pos[0]}"; out="${pos[1]}"',
|
|
76
|
+
' screen="${name#Render_}"',
|
|
77
|
+
' case ",$FAKE_SWIFT_RUN_FAIL," in',
|
|
78
|
+
' *",$screen,"*)',
|
|
79
|
+
' echo "Fatal error: render failed for $screen" >&2',
|
|
80
|
+
' exit 1 ;;',
|
|
81
|
+
' esac',
|
|
82
|
+
' case ",$FAKE_SWIFT_RUN_SILENT," in',
|
|
83
|
+
' *",$screen,"*)',
|
|
84
|
+
' exit 0 ;;',
|
|
85
|
+
' esac',
|
|
86
|
+
" printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out\"",
|
|
87
|
+
' printf \'fake-render:%s\' "$screen" >> "$out"',
|
|
88
|
+
' exit 0 ;;',
|
|
89
|
+
' *)',
|
|
90
|
+
' exit 0 ;;',
|
|
91
|
+
'esac',
|
|
92
|
+
];
|
|
93
|
+
const tempDirs = [];
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
for (const dir of tempDirs) {
|
|
96
|
+
try {
|
|
97
|
+
rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// best effort
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
function makeProject(params) {
|
|
105
|
+
const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-test-'));
|
|
106
|
+
tempDirs.push(dir);
|
|
107
|
+
mkdirSync(join(dir, '.ci', 'inputs'), { recursive: true });
|
|
108
|
+
const packageDir = join(dir, 'user-package');
|
|
109
|
+
mkdirSync(packageDir, { recursive: true });
|
|
110
|
+
const binDir = join(dir, 'bin');
|
|
111
|
+
mkdirSync(binDir, { recursive: true });
|
|
112
|
+
const swiftPath = join(binDir, 'swift');
|
|
113
|
+
writeFileSync(swiftPath, FAKE_SWIFT_LINES.join('\n') + '\n');
|
|
114
|
+
chmodSync(swiftPath, 0o755);
|
|
115
|
+
if (params !== undefined) {
|
|
116
|
+
writeFileSync(join(dir, '.ci', 'inputs', 'params.json'), typeof params === 'string' ? params : JSON.stringify(params));
|
|
117
|
+
}
|
|
118
|
+
return { dir, packageDir, binDir };
|
|
119
|
+
}
|
|
120
|
+
function writeReference(project, basename, label = basename) {
|
|
121
|
+
writeFileSync(join(project.dir, '.ci', 'inputs', basename), Buffer.concat([PNG_MAGIC, Buffer.from('ref:' + label)]));
|
|
122
|
+
}
|
|
123
|
+
async function buildScript(project, inputs = {}) {
|
|
124
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
125
|
+
const step = await executor.execute({ package_path: project.packageDir, target: 'FixtureViews', ...inputs }, {}, testConfig);
|
|
126
|
+
return step.script;
|
|
127
|
+
}
|
|
128
|
+
function runScript(project, script, env = {}) {
|
|
129
|
+
return spawnSync('node', ['-e', script], {
|
|
130
|
+
cwd: project.dir,
|
|
131
|
+
encoding: 'utf-8',
|
|
132
|
+
env: {
|
|
133
|
+
...process.env,
|
|
134
|
+
PATH: project.binDir + ':' + (process.env.PATH ?? ''),
|
|
135
|
+
...env,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function readResult(project) {
|
|
140
|
+
const resultPath = join(project.dir, '.ci', 'artifacts', 'protocol-result.json');
|
|
141
|
+
expect(existsSync(resultPath)).toBe(true);
|
|
142
|
+
return JSON.parse(readFileSync(resultPath, 'utf-8'));
|
|
143
|
+
}
|
|
144
|
+
function artifact(project, relative) {
|
|
145
|
+
return join(project.dir, '.ci', 'artifacts', relative);
|
|
146
|
+
}
|
|
147
|
+
function isPng(filePath) {
|
|
148
|
+
const head = readFileSync(filePath).subarray(0, PNG_MAGIC.length);
|
|
149
|
+
return head.equals(PNG_MAGIC);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Asserts that no error message in the result document contains an absolute
|
|
153
|
+
* 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.
|
|
156
|
+
*/
|
|
157
|
+
function expectNoAbsolutePathLeaks(project, result) {
|
|
158
|
+
const forbidden = new Set();
|
|
159
|
+
for (const dir of [project.dir, tmpdir()]) {
|
|
160
|
+
forbidden.add(dir);
|
|
161
|
+
try {
|
|
162
|
+
forbidden.add(realpathSync(dir));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// nonexistent — literal form only
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const screen of result.screens) {
|
|
169
|
+
const message = screen.error?.message ?? '';
|
|
170
|
+
for (const dir of forbidden) {
|
|
171
|
+
expect(message).not.toContain(dir);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
describe('registry integration', () => {
|
|
176
|
+
test('ui-fidelity-render is registered with its input schema', () => {
|
|
177
|
+
clearRegistry();
|
|
178
|
+
initializeStepRegistry();
|
|
179
|
+
expect(hasStep('ui-fidelity-render')).toBe(true);
|
|
180
|
+
const metadata = getStepMetadata('ui-fidelity-render');
|
|
181
|
+
expect(metadata?.platform).toBe('ios');
|
|
182
|
+
const inputs = metadata?.inputs ?? {};
|
|
183
|
+
expect(Object.keys(inputs).sort()).toEqual([
|
|
184
|
+
'package_path',
|
|
185
|
+
'render_size',
|
|
186
|
+
'scale',
|
|
187
|
+
'target',
|
|
188
|
+
]);
|
|
189
|
+
expect(inputs.package_path.required).toBe(true);
|
|
190
|
+
expect(inputs.target.required).toBe(true);
|
|
191
|
+
expect(inputs.render_size.required).toBe(false);
|
|
192
|
+
expect(inputs.render_size.default).toBe(DEFAULT_RENDER_SIZE);
|
|
193
|
+
expect(inputs.scale.required).toBe(false);
|
|
194
|
+
expect(inputs.scale.default).toBe(DEFAULT_SCALE);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
describe('UiFidelityRenderStepExecutor', () => {
|
|
198
|
+
test('returns a node StepDef', async () => {
|
|
199
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
200
|
+
const step = await executor.execute({ package_path: './pkg', target: 'MyViews' }, {}, testConfig);
|
|
201
|
+
expect(step.kind).toBe('node');
|
|
202
|
+
expect(step.name).toBe('ui-fidelity-render');
|
|
203
|
+
expect(step.script.length).toBeGreaterThan(0);
|
|
204
|
+
});
|
|
205
|
+
test('bakes resolved defaults into the script config', async () => {
|
|
206
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
207
|
+
const step = await executor.execute({ package_path: './pkg', target: 'MyViews' }, {}, testConfig);
|
|
208
|
+
expect(step.script).toContain('"width":393');
|
|
209
|
+
expect(step.script).toContain('"height":852');
|
|
210
|
+
expect(step.script).toContain('"scale":2');
|
|
211
|
+
expect(step.script).toContain('"target":"MyViews"');
|
|
212
|
+
});
|
|
213
|
+
test('honors explicit render_size and scale', async () => {
|
|
214
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
215
|
+
const step = await executor.execute({ package_path: './pkg', target: 'MyViews', render_size: '430x932', scale: 3 }, {}, testConfig);
|
|
216
|
+
expect(step.script).toContain('"width":430');
|
|
217
|
+
expect(step.script).toContain('"height":932');
|
|
218
|
+
expect(step.script).toContain('"scale":3');
|
|
219
|
+
});
|
|
220
|
+
test('throws when package_path is missing', async () => {
|
|
221
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
222
|
+
await expect(executor.execute({ target: 'MyViews' }, {}, testConfig)).rejects.toThrow("Missing required input 'package_path'");
|
|
223
|
+
});
|
|
224
|
+
test('throws when target is missing', async () => {
|
|
225
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
226
|
+
await expect(executor.execute({ package_path: './pkg' }, {}, testConfig)).rejects.toThrow("Missing required input 'target'");
|
|
227
|
+
});
|
|
228
|
+
test('rejects a target that is not a Swift module identifier', async () => {
|
|
229
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
230
|
+
await expect(executor.execute({ package_path: './pkg', target: 'My Views' }, {}, testConfig)).rejects.toThrow(/target/i);
|
|
231
|
+
});
|
|
232
|
+
test('rejects malformed render_size', async () => {
|
|
233
|
+
const executor = new UiFidelityRenderStepExecutor();
|
|
234
|
+
await expect(executor.execute({ package_path: './pkg', target: 'MyViews', render_size: 'huge' }, {}, testConfig)).rejects.toThrow(/render_size/);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe('render size and scale parsing', () => {
|
|
238
|
+
test('default render size is 393x852 device points', () => {
|
|
239
|
+
expect(DEFAULT_RENDER_SIZE).toBe('393x852');
|
|
240
|
+
expect(parseRenderSize(DEFAULT_RENDER_SIZE)).toEqual({ width: 393, height: 852 });
|
|
241
|
+
});
|
|
242
|
+
test('default scale is 2', () => {
|
|
243
|
+
expect(DEFAULT_SCALE).toBe(2);
|
|
244
|
+
expect(parseScale(DEFAULT_SCALE)).toBe(2);
|
|
245
|
+
});
|
|
246
|
+
test('parses explicit sizes and string scales', () => {
|
|
247
|
+
expect(parseRenderSize('430x932')).toEqual({ width: 430, height: 932 });
|
|
248
|
+
expect(parseScale('3')).toBe(3);
|
|
249
|
+
});
|
|
250
|
+
test('rejects zero or malformed values', () => {
|
|
251
|
+
expect(() => parseRenderSize('0x852')).toThrow(/render_size/);
|
|
252
|
+
expect(() => parseRenderSize('393by852')).toThrow(/render_size/);
|
|
253
|
+
expect(() => parseScale('-1')).toThrow(/scale/);
|
|
254
|
+
expect(() => parseScale('big')).toThrow(/scale/);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
describe('harness source generation', () => {
|
|
258
|
+
const internals = getRenderScriptInternals();
|
|
259
|
+
const options = {
|
|
260
|
+
packagePath: '/abs/path/to/user-package',
|
|
261
|
+
packageRef: 'user-package',
|
|
262
|
+
target: 'FixtureViews',
|
|
263
|
+
width: 393,
|
|
264
|
+
height: 852,
|
|
265
|
+
scale: 2,
|
|
266
|
+
};
|
|
267
|
+
test('Package.swift declares one executable target and product per screen', () => {
|
|
268
|
+
const manifest = internals.generateHarnessPackageSwift(['HomeView', 'SettingsView'], options);
|
|
269
|
+
expect(manifest).toContain('.executableTarget(name: "Render_HomeView"');
|
|
270
|
+
expect(manifest).toContain('.executableTarget(name: "Render_SettingsView"');
|
|
271
|
+
expect(manifest).toContain('.executable(name: "Render_HomeView", targets: ["Render_HomeView"])');
|
|
272
|
+
expect(manifest).toContain('.executable(name: "Render_SettingsView", targets: ["Render_SettingsView"])');
|
|
273
|
+
expect(manifest).toContain('.executableTarget(name: "RenderProbe"');
|
|
274
|
+
expect(manifest).toContain('.package(path: "/abs/path/to/user-package")');
|
|
275
|
+
expect(manifest).toContain('.product(name: "FixtureViews", package: "user-package")');
|
|
276
|
+
expect(manifest).toContain('platforms: [.macOS(.v13)]');
|
|
277
|
+
});
|
|
278
|
+
test('screen source constructs the view with a parameterless init', () => {
|
|
279
|
+
const source = internals.generateScreenSwift('HomeView', options);
|
|
280
|
+
expect(source).toContain('import FixtureViews');
|
|
281
|
+
expect(source).toContain('import SwiftUI');
|
|
282
|
+
expect(source).toContain('let view = HomeView().frame(width: width, height: height)');
|
|
283
|
+
expect(source).toContain('let width: CGFloat = 393');
|
|
284
|
+
expect(source).toContain('let height: CGFloat = 852');
|
|
285
|
+
expect(source).toContain('renderer.scale = 2');
|
|
286
|
+
expect(source).toContain('ImageRenderer(content: view)');
|
|
287
|
+
expect(source).toContain('@main');
|
|
288
|
+
expect(source).toContain('@MainActor');
|
|
289
|
+
expect(source).toContain('CGImageDestinationFinalize');
|
|
290
|
+
// Regression: .utf8 must apply to the concatenated message, not the
|
|
291
|
+
// trailing variable (caught by a real swift build).
|
|
292
|
+
expect(source).toContain('Data(("could not write PNG to " + outputPath).utf8)');
|
|
293
|
+
expect(source).not.toContain('outputPath.utf8');
|
|
294
|
+
});
|
|
295
|
+
test('probe source imports the target without referencing screens', () => {
|
|
296
|
+
const source = internals.generateProbeSwift(options);
|
|
297
|
+
expect(source).toContain('import FixtureViews');
|
|
298
|
+
expect(source).not.toContain('HomeView');
|
|
299
|
+
});
|
|
300
|
+
test('swift string literals escape quotes and backslashes', () => {
|
|
301
|
+
expect(internals.swiftStringLiteral('plain')).toBe('"plain"');
|
|
302
|
+
expect(internals.swiftStringLiteral('a"b')).toBe('"a\\"b"');
|
|
303
|
+
expect(internals.swiftStringLiteral('a\\b')).toBe('"a\\\\b"');
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
describe('render script runtime', () => {
|
|
307
|
+
test('happy path renders every screen and exits 0', async () => {
|
|
308
|
+
const project = makeProject({
|
|
309
|
+
screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
|
|
310
|
+
});
|
|
311
|
+
writeReference(project, 'home.png');
|
|
312
|
+
writeReference(project, 'settings.png');
|
|
313
|
+
const script = await buildScript(project);
|
|
314
|
+
const run = runScript(project, script);
|
|
315
|
+
expect(run.status).toBe(0);
|
|
316
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
|
|
317
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
|
|
318
|
+
expect(isPng(artifact(project, 'ui-fidelity/references/HomeView.png'))).toBe(true);
|
|
319
|
+
expect(isPng(artifact(project, 'ui-fidelity/references/SettingsView.png'))).toBe(true);
|
|
320
|
+
expect(readResult(project)).toEqual({
|
|
321
|
+
renderer: 'imagerenderer-spm',
|
|
322
|
+
screens: [
|
|
323
|
+
{
|
|
324
|
+
screen: 'HomeView',
|
|
325
|
+
status: 'rendered',
|
|
326
|
+
error: null,
|
|
327
|
+
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
328
|
+
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
screen: 'SettingsView',
|
|
332
|
+
status: 'rendered',
|
|
333
|
+
error: null,
|
|
334
|
+
reference_image_path: 'ui-fidelity/references/SettingsView.png',
|
|
335
|
+
rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
test('result entries follow params.json order and use relative paths', async () => {
|
|
341
|
+
const project = makeProject({
|
|
342
|
+
screens: { ZetaView: 'zeta.png', AlphaView: 'alpha.png' },
|
|
343
|
+
});
|
|
344
|
+
writeReference(project, 'zeta.png');
|
|
345
|
+
writeReference(project, 'alpha.png');
|
|
346
|
+
const run = runScript(project, await buildScript(project));
|
|
347
|
+
expect(run.status).toBe(0);
|
|
348
|
+
const result = readResult(project);
|
|
349
|
+
expect(result.screens.map((s) => s.screen)).toEqual(['ZetaView', 'AlphaView']);
|
|
350
|
+
for (const screen of result.screens) {
|
|
351
|
+
expect(screen.reference_image_path).not.toMatch(/^\//);
|
|
352
|
+
expect(screen.rendered_image_path).not.toMatch(/^\//);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
test('a broken screen fails alone; other screens still render', async () => {
|
|
356
|
+
const project = makeProject({
|
|
357
|
+
screens: { HomeView: 'home.png', MissingView: 'missing-view.png' },
|
|
358
|
+
});
|
|
359
|
+
writeReference(project, 'home.png');
|
|
360
|
+
writeReference(project, 'missing-view.png');
|
|
361
|
+
const run = runScript(project, await buildScript(project), {
|
|
362
|
+
FAKE_SWIFT_COMPILE_FAIL: 'MissingView',
|
|
363
|
+
});
|
|
364
|
+
expect(run.status).not.toBe(0);
|
|
365
|
+
const result = readResult(project);
|
|
366
|
+
expect(result.screens).toHaveLength(2);
|
|
367
|
+
const [home, missing] = result.screens;
|
|
368
|
+
expect(home.screen).toBe('HomeView');
|
|
369
|
+
expect(home.status).toBe('rendered');
|
|
370
|
+
expect(home.error).toBeNull();
|
|
371
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
|
|
372
|
+
expect(missing.screen).toBe('MissingView');
|
|
373
|
+
expect(missing.status).toBe('render_failed');
|
|
374
|
+
expect(missing.error?.code).toBe('VIEW_COMPILE_FAILED');
|
|
375
|
+
expect(missing.error?.message).toContain('cannot find MissingView in scope');
|
|
376
|
+
expect(missing.rendered_image_path).toBeNull();
|
|
377
|
+
expect(existsSync(artifact(project, 'ui-fidelity/rendered/MissingView.png'))).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
test('a render-time crash marks only that screen RENDER_FAILED', async () => {
|
|
380
|
+
const project = makeProject({
|
|
381
|
+
screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
|
|
382
|
+
});
|
|
383
|
+
writeReference(project, 'home.png');
|
|
384
|
+
writeReference(project, 'settings.png');
|
|
385
|
+
const run = runScript(project, await buildScript(project), {
|
|
386
|
+
FAKE_SWIFT_RUN_FAIL: 'SettingsView',
|
|
387
|
+
});
|
|
388
|
+
expect(run.status).not.toBe(0);
|
|
389
|
+
const result = readResult(project);
|
|
390
|
+
const [home, settings] = result.screens;
|
|
391
|
+
expect(home.status).toBe('rendered');
|
|
392
|
+
expect(settings.status).toBe('render_failed');
|
|
393
|
+
expect(settings.error?.code).toBe('RENDER_FAILED');
|
|
394
|
+
expect(settings.error?.message).toContain('render failed for SettingsView');
|
|
395
|
+
});
|
|
396
|
+
test('two screens sharing one reference get two distinct copies', async () => {
|
|
397
|
+
const project = makeProject({
|
|
398
|
+
screens: { HomeView: 'shared.png', SettingsView: 'shared.png' },
|
|
399
|
+
});
|
|
400
|
+
writeReference(project, 'shared.png');
|
|
401
|
+
const run = runScript(project, await buildScript(project));
|
|
402
|
+
expect(run.status).toBe(0);
|
|
403
|
+
const homeRef = artifact(project, 'ui-fidelity/references/HomeView.png');
|
|
404
|
+
const settingsRef = artifact(project, 'ui-fidelity/references/SettingsView.png');
|
|
405
|
+
expect(existsSync(homeRef)).toBe(true);
|
|
406
|
+
expect(existsSync(settingsRef)).toBe(true);
|
|
407
|
+
expect(readFileSync(homeRef).equals(readFileSync(settingsRef))).toBe(true);
|
|
408
|
+
const result = readResult(project);
|
|
409
|
+
expect(result.screens[0].reference_image_path).toBe('ui-fidelity/references/HomeView.png');
|
|
410
|
+
expect(result.screens[1].reference_image_path).toBe('ui-fidelity/references/SettingsView.png');
|
|
411
|
+
});
|
|
412
|
+
test('a missing reference fails that screen with REFERENCE_MISSING', async () => {
|
|
413
|
+
const project = makeProject({
|
|
414
|
+
screens: { HomeView: 'home.png', SettingsView: 'nonexistent.png' },
|
|
415
|
+
});
|
|
416
|
+
writeReference(project, 'home.png');
|
|
417
|
+
const run = runScript(project, await buildScript(project));
|
|
418
|
+
expect(run.status).not.toBe(0);
|
|
419
|
+
const result = readResult(project);
|
|
420
|
+
const [home, settings] = result.screens;
|
|
421
|
+
expect(home.status).toBe('rendered');
|
|
422
|
+
expect(settings.status).toBe('render_failed');
|
|
423
|
+
expect(settings.error?.code).toBe('REFERENCE_MISSING');
|
|
424
|
+
expect(settings.reference_image_path).toBeNull();
|
|
425
|
+
expect(settings.rendered_image_path).toBeNull();
|
|
426
|
+
expect(existsSync(artifact(project, 'ui-fidelity/references/SettingsView.png'))).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
test("a reference of '..' or '.' fails only that screen with REFERENCE_MISSING", async () => {
|
|
429
|
+
const project = makeProject({
|
|
430
|
+
screens: { HomeView: '..', AboutView: '.', SettingsView: 'settings.png' },
|
|
431
|
+
});
|
|
432
|
+
writeReference(project, 'settings.png');
|
|
433
|
+
const run = runScript(project, await buildScript(project));
|
|
434
|
+
expect(run.status).not.toBe(0);
|
|
435
|
+
const [home, about, settings] = readResult(project).screens;
|
|
436
|
+
expect(home.status).toBe('render_failed');
|
|
437
|
+
expect(home.error?.code).toBe('REFERENCE_MISSING');
|
|
438
|
+
expect(about.status).toBe('render_failed');
|
|
439
|
+
expect(about.error?.code).toBe('REFERENCE_MISSING');
|
|
440
|
+
// Per-screen isolation: the valid screen still renders.
|
|
441
|
+
expect(settings.status).toBe('rendered');
|
|
442
|
+
expect(settings.error).toBeNull();
|
|
443
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
test('a reference that is a directory fails only that screen', async () => {
|
|
446
|
+
const project = makeProject({
|
|
447
|
+
screens: { HomeView: 'home-dir.png', SettingsView: 'settings.png' },
|
|
448
|
+
});
|
|
449
|
+
mkdirSync(join(project.dir, '.ci', 'inputs', 'home-dir.png'));
|
|
450
|
+
writeReference(project, 'settings.png');
|
|
451
|
+
const run = runScript(project, await buildScript(project));
|
|
452
|
+
expect(run.status).not.toBe(0);
|
|
453
|
+
const [home, settings] = readResult(project).screens;
|
|
454
|
+
expect(home.status).toBe('render_failed');
|
|
455
|
+
expect(home.error?.code).toBe('REFERENCE_MISSING');
|
|
456
|
+
expect(home.reference_image_path).toBeNull();
|
|
457
|
+
expect(settings.status).toBe('rendered');
|
|
458
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
|
|
459
|
+
});
|
|
460
|
+
test('a harness-level failure marks every screen RENDER_UNSUPPORTED', async () => {
|
|
461
|
+
const project = makeProject({
|
|
462
|
+
screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
|
|
463
|
+
});
|
|
464
|
+
writeReference(project, 'home.png');
|
|
465
|
+
writeReference(project, 'settings.png');
|
|
466
|
+
const run = runScript(project, await buildScript(project), {
|
|
467
|
+
FAKE_SWIFT_PROBE_FAIL: '1',
|
|
468
|
+
});
|
|
469
|
+
expect(run.status).not.toBe(0);
|
|
470
|
+
const result = readResult(project);
|
|
471
|
+
expect(result.screens).toHaveLength(2);
|
|
472
|
+
for (const screen of result.screens) {
|
|
473
|
+
expect(screen.status).toBe('render_failed');
|
|
474
|
+
expect(screen.error?.code).toBe('RENDER_UNSUPPORTED');
|
|
475
|
+
expect(screen.error?.message).toContain('could not resolve package dependencies');
|
|
476
|
+
expect(screen.rendered_image_path).toBeNull();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
test('a nonexistent package path marks every screen RENDER_UNSUPPORTED', async () => {
|
|
480
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
481
|
+
writeReference(project, 'home.png');
|
|
482
|
+
const script = await buildScript(project, {
|
|
483
|
+
package_path: join(project.dir, 'no-such-package'),
|
|
484
|
+
});
|
|
485
|
+
const run = runScript(project, script);
|
|
486
|
+
expect(run.status).not.toBe(0);
|
|
487
|
+
const result = readResult(project);
|
|
488
|
+
expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
|
|
489
|
+
});
|
|
490
|
+
test('absent params.json fails with an empty-screens result document', async () => {
|
|
491
|
+
const project = makeProject();
|
|
492
|
+
const run = runScript(project, await buildScript(project));
|
|
493
|
+
expect(run.status).not.toBe(0);
|
|
494
|
+
expect(run.stderr).toContain('params.json');
|
|
495
|
+
expect(readResult(project)).toEqual({ renderer: 'imagerenderer-spm', screens: [] });
|
|
496
|
+
});
|
|
497
|
+
test('malformed params.json fails with an empty-screens result document', async () => {
|
|
498
|
+
const project = makeProject('{ not json');
|
|
499
|
+
const run = runScript(project, await buildScript(project));
|
|
500
|
+
expect(run.status).not.toBe(0);
|
|
501
|
+
expect(run.stderr).toContain('params.json');
|
|
502
|
+
expect(readResult(project)).toEqual({ renderer: 'imagerenderer-spm', screens: [] });
|
|
503
|
+
});
|
|
504
|
+
test('params.json without a screens object fails the same way', async () => {
|
|
505
|
+
const project = makeProject({ views: { HomeView: 'home.png' } });
|
|
506
|
+
const run = runScript(project, await buildScript(project));
|
|
507
|
+
expect(run.status).not.toBe(0);
|
|
508
|
+
expect(readResult(project)).toEqual({ renderer: 'imagerenderer-spm', screens: [] });
|
|
509
|
+
});
|
|
510
|
+
test('screens given as an array fails like malformed params', async () => {
|
|
511
|
+
const project = makeProject({ screens: ['HomeView'] });
|
|
512
|
+
const run = runScript(project, await buildScript(project));
|
|
513
|
+
expect(run.status).not.toBe(0);
|
|
514
|
+
expect(readResult(project)).toEqual({ renderer: 'imagerenderer-spm', screens: [] });
|
|
515
|
+
});
|
|
516
|
+
test('an empty screens object succeeds with an empty result document', async () => {
|
|
517
|
+
const project = makeProject({ screens: {} });
|
|
518
|
+
const logPath = join(project.dir, 'swift-invocations.log');
|
|
519
|
+
const run = runScript(project, await buildScript(project), { FAKE_SWIFT_LOG: logPath });
|
|
520
|
+
expect(run.status).toBe(0);
|
|
521
|
+
expect(readResult(project)).toEqual({ renderer: 'imagerenderer-spm', screens: [] });
|
|
522
|
+
// No screens means the harness is never built: swift is never invoked.
|
|
523
|
+
expect(existsSync(logPath)).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
test('a screen name that is not a Swift identifier never reaches generated Swift', async () => {
|
|
526
|
+
const injection = 'HomeView()); }; import Foundation //';
|
|
527
|
+
const project = makeProject({
|
|
528
|
+
screens: { [injection]: 'evil.png', SettingsView: 'settings.png' },
|
|
529
|
+
});
|
|
530
|
+
writeReference(project, 'evil.png');
|
|
531
|
+
writeReference(project, 'settings.png');
|
|
532
|
+
const logPath = join(project.dir, 'swift-invocations.log');
|
|
533
|
+
const run = runScript(project, await buildScript(project), {
|
|
534
|
+
FAKE_SWIFT_LOG: logPath,
|
|
535
|
+
UI_FIDELITY_KEEP_HARNESS: '1',
|
|
536
|
+
});
|
|
537
|
+
expect(run.status).not.toBe(0);
|
|
538
|
+
const [bad, good] = readResult(project).screens;
|
|
539
|
+
expect(bad.screen).toBe(injection);
|
|
540
|
+
expect(bad.status).toBe('render_failed');
|
|
541
|
+
expect(bad.error?.code).toBe('INVALID_SCREEN_NAME');
|
|
542
|
+
expect(bad.rendered_image_path).toBeNull();
|
|
543
|
+
expect(good.status).toBe('rendered');
|
|
544
|
+
// The injected name never appears in any swift invocation, and the
|
|
545
|
+
// synthesized harness contains sources only for the valid screen.
|
|
546
|
+
const invocations = readFileSync(logPath, 'utf-8');
|
|
547
|
+
expect(invocations).not.toContain('import Foundation //');
|
|
548
|
+
const probeArgs = invocations.trim().split('\n')[0].split(' ');
|
|
549
|
+
const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
|
|
550
|
+
tempDirs.push(harnessDir);
|
|
551
|
+
expect(readdirSync(join(harnessDir, 'Sources')).sort()).toEqual([
|
|
552
|
+
'RenderProbe',
|
|
553
|
+
'Render_SettingsView',
|
|
554
|
+
]);
|
|
555
|
+
expect(readFileSync(join(harnessDir, 'Package.swift'), 'utf-8')).not.toContain('import Foundation //');
|
|
556
|
+
});
|
|
557
|
+
test('respects CIBUILD_ARTIFACTS_DIR', async () => {
|
|
558
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
559
|
+
writeReference(project, 'home.png');
|
|
560
|
+
const run = runScript(project, await buildScript(project), {
|
|
561
|
+
CIBUILD_ARTIFACTS_DIR: 'custom-artifacts',
|
|
562
|
+
});
|
|
563
|
+
expect(run.status).toBe(0);
|
|
564
|
+
const resultPath = join(project.dir, 'custom-artifacts', 'protocol-result.json');
|
|
565
|
+
expect(existsSync(resultPath)).toBe(true);
|
|
566
|
+
expect(isPng(join(project.dir, 'custom-artifacts', 'ui-fidelity', 'rendered', 'HomeView.png'))).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
test('synthesizes the harness on disk: probe first, then per-screen build and run', async () => {
|
|
569
|
+
const project = makeProject({
|
|
570
|
+
screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
|
|
571
|
+
});
|
|
572
|
+
writeReference(project, 'home.png');
|
|
573
|
+
writeReference(project, 'settings.png');
|
|
574
|
+
const logPath = join(project.dir, 'swift-invocations.log');
|
|
575
|
+
const run = runScript(project, await buildScript(project), {
|
|
576
|
+
FAKE_SWIFT_LOG: logPath,
|
|
577
|
+
UI_FIDELITY_KEEP_HARNESS: '1',
|
|
578
|
+
});
|
|
579
|
+
expect(run.status).toBe(0);
|
|
580
|
+
const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
581
|
+
expect(invocations[0]).toContain('--target RenderProbe');
|
|
582
|
+
expect(invocations[1]).toContain('--product Render_HomeView');
|
|
583
|
+
expect(invocations[2]).toMatch(/^run /);
|
|
584
|
+
expect(invocations[2]).toContain('Render_HomeView');
|
|
585
|
+
expect(invocations[3]).toContain('--product Render_SettingsView');
|
|
586
|
+
expect(invocations[4]).toContain('Render_SettingsView');
|
|
587
|
+
const probeArgs = invocations[0].split(' ');
|
|
588
|
+
const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
|
|
589
|
+
tempDirs.push(harnessDir);
|
|
590
|
+
expect(existsSync(join(harnessDir, 'Package.swift'))).toBe(true);
|
|
591
|
+
const manifest = readFileSync(join(harnessDir, 'Package.swift'), 'utf-8');
|
|
592
|
+
expect(manifest).toContain('.executableTarget(name: "Render_HomeView"');
|
|
593
|
+
expect(manifest).toContain('.executableTarget(name: "Render_SettingsView"');
|
|
594
|
+
const homeSource = readFileSync(join(harnessDir, 'Sources', 'Render_HomeView', 'Render.swift'), 'utf-8');
|
|
595
|
+
expect(homeSource).toContain('HomeView().frame(width: width, height: height)');
|
|
596
|
+
expect(existsSync(join(harnessDir, 'Sources', 'RenderProbe', 'Probe.swift'))).toBe(true);
|
|
597
|
+
});
|
|
598
|
+
test('a nonexistent relative package path is reported as configured, never resolved', async () => {
|
|
599
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
600
|
+
writeReference(project, 'home.png');
|
|
601
|
+
const script = await buildScript(project, { package_path: 'no-such-package' });
|
|
602
|
+
const run = runScript(project, script);
|
|
603
|
+
expect(run.status).not.toBe(0);
|
|
604
|
+
const result = readResult(project);
|
|
605
|
+
expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
|
|
606
|
+
expect(result.screens[0].error?.message).toContain('no-such-package');
|
|
607
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
608
|
+
});
|
|
609
|
+
test('an absolute package path is replaced by a placeholder in error messages', async () => {
|
|
610
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
611
|
+
writeReference(project, 'home.png');
|
|
612
|
+
const script = await buildScript(project, {
|
|
613
|
+
package_path: join(project.dir, 'no-such-package'),
|
|
614
|
+
});
|
|
615
|
+
const run = runScript(project, script);
|
|
616
|
+
expect(run.status).not.toBe(0);
|
|
617
|
+
const result = readResult(project);
|
|
618
|
+
expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
|
|
619
|
+
expect(result.screens[0].error?.message).toContain('<package_path>');
|
|
620
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
621
|
+
});
|
|
622
|
+
test('probe-failure diagnostics are scrubbed of absolute harness and package paths', async () => {
|
|
623
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
624
|
+
writeReference(project, 'home.png');
|
|
625
|
+
const script = await buildScript(project, { package_path: 'user-package' });
|
|
626
|
+
const run = runScript(project, script, { FAKE_SWIFT_PROBE_FAIL: '1' });
|
|
627
|
+
expect(run.status).not.toBe(0);
|
|
628
|
+
const result = readResult(project);
|
|
629
|
+
const message = result.screens[0].error?.message ?? '';
|
|
630
|
+
expect(result.screens[0].error?.code).toBe('RENDER_UNSUPPORTED');
|
|
631
|
+
expect(message).toContain('the package at user-package does not build');
|
|
632
|
+
expect(message).toContain('<harness>/Sources/RenderProbe/Probe.swift');
|
|
633
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
634
|
+
});
|
|
635
|
+
test('compiler diagnostics for a broken screen are scrubbed of absolute paths', async () => {
|
|
636
|
+
const project = makeProject({
|
|
637
|
+
screens: { HomeView: 'home.png', MissingView: 'missing-view.png' },
|
|
638
|
+
});
|
|
639
|
+
writeReference(project, 'home.png');
|
|
640
|
+
writeReference(project, 'missing-view.png');
|
|
641
|
+
const run = runScript(project, await buildScript(project), {
|
|
642
|
+
FAKE_SWIFT_COMPILE_FAIL: 'MissingView',
|
|
643
|
+
});
|
|
644
|
+
expect(run.status).not.toBe(0);
|
|
645
|
+
const result = readResult(project);
|
|
646
|
+
const missing = result.screens[1];
|
|
647
|
+
expect(missing.error?.code).toBe('VIEW_COMPILE_FAILED');
|
|
648
|
+
expect(missing.error?.message).toContain('<harness>/Sources/Render_MissingView/Render.swift');
|
|
649
|
+
expect(missing.error?.message).toContain('cannot find MissingView in scope');
|
|
650
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
651
|
+
});
|
|
652
|
+
test('a renderer that exits 0 without a PNG reports the artifacts-relative path', async () => {
|
|
653
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
654
|
+
writeReference(project, 'home.png');
|
|
655
|
+
const run = runScript(project, await buildScript(project), {
|
|
656
|
+
FAKE_SWIFT_RUN_SILENT: 'HomeView',
|
|
657
|
+
});
|
|
658
|
+
expect(run.status).not.toBe(0);
|
|
659
|
+
const result = readResult(project);
|
|
660
|
+
expect(result.screens[0].error?.code).toBe('RENDER_FAILED');
|
|
661
|
+
expect(result.screens[0].error?.message).toContain('.ci/artifacts/ui-fidelity/rendered/HomeView.png');
|
|
662
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
663
|
+
});
|
|
664
|
+
test('an absolute CIBUILD_ARTIFACTS_DIR is replaced by a placeholder in error messages', async () => {
|
|
665
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
666
|
+
writeReference(project, 'home.png');
|
|
667
|
+
const artifactsDir = join(project.dir, 'abs-artifacts');
|
|
668
|
+
const run = runScript(project, await buildScript(project), {
|
|
669
|
+
CIBUILD_ARTIFACTS_DIR: artifactsDir,
|
|
670
|
+
FAKE_SWIFT_RUN_SILENT: 'HomeView',
|
|
671
|
+
});
|
|
672
|
+
expect(run.status).not.toBe(0);
|
|
673
|
+
const resultPath = join(artifactsDir, 'protocol-result.json');
|
|
674
|
+
expect(existsSync(resultPath)).toBe(true);
|
|
675
|
+
const result = JSON.parse(readFileSync(resultPath, 'utf-8'));
|
|
676
|
+
expect(result.screens[0].error?.code).toBe('RENDER_FAILED');
|
|
677
|
+
expect(result.screens[0].error?.message).toContain('<artifacts>/ui-fidelity/rendered/HomeView.png');
|
|
678
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
679
|
+
});
|
|
680
|
+
test('generateRenderScript output is directly executable', () => {
|
|
681
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
682
|
+
writeReference(project, 'home.png');
|
|
683
|
+
const script = generateRenderScript({
|
|
684
|
+
packagePath: project.packageDir,
|
|
685
|
+
target: 'FixtureViews',
|
|
686
|
+
width: 393,
|
|
687
|
+
height: 852,
|
|
688
|
+
scale: 2,
|
|
689
|
+
});
|
|
690
|
+
const run = runScript(project, script);
|
|
691
|
+
expect(run.status).toBe(0);
|
|
692
|
+
expect(readResult(project).screens[0].status).toBe('rendered');
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
describe('committed fixture package', () => {
|
|
696
|
+
test('declares the FixtureViews library with the screens the example pipeline uses', () => {
|
|
697
|
+
const manifest = readFileSync(join(FIXTURE_PACKAGE, 'Package.swift'), 'utf-8');
|
|
698
|
+
expect(manifest).toContain('name: "ui-fidelity-package"');
|
|
699
|
+
expect(manifest).toContain('.library(name: "FixtureViews", targets: ["FixtureViews"])');
|
|
700
|
+
expect(manifest).toContain('.macOS(.v13)');
|
|
701
|
+
const sourcesDir = join(FIXTURE_PACKAGE, 'Sources', 'FixtureViews');
|
|
702
|
+
const home = readFileSync(join(sourcesDir, 'HomeView.swift'), 'utf-8');
|
|
703
|
+
expect(home).toContain('public struct HomeView: View');
|
|
704
|
+
expect(home).toContain('public init()');
|
|
705
|
+
const settings = readFileSync(join(sourcesDir, 'SettingsView.swift'), 'utf-8');
|
|
706
|
+
expect(settings).toContain('public struct SettingsView: View');
|
|
707
|
+
// BrokenView exists to prove per-screen isolation: its parameterless
|
|
708
|
+
// init is deliberately unavailable, so its harness target cannot compile.
|
|
709
|
+
const broken = readFileSync(join(sourcesDir, 'BrokenView.swift'), 'utf-8');
|
|
710
|
+
expect(broken).toContain('public struct BrokenView: View');
|
|
711
|
+
expect(broken).toContain('@available(*, unavailable');
|
|
712
|
+
});
|
|
713
|
+
test('wires through the harness as package_path (fake toolchain)', async () => {
|
|
714
|
+
const project = makeProject({
|
|
715
|
+
screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
|
|
716
|
+
});
|
|
717
|
+
writeReference(project, 'home.png');
|
|
718
|
+
writeReference(project, 'settings.png');
|
|
719
|
+
const script = await buildScript(project, { package_path: FIXTURE_PACKAGE });
|
|
720
|
+
const logPath = join(project.dir, 'swift-invocations.log');
|
|
721
|
+
const run = runScript(project, script, {
|
|
722
|
+
FAKE_SWIFT_LOG: logPath,
|
|
723
|
+
UI_FIDELITY_KEEP_HARNESS: '1',
|
|
724
|
+
});
|
|
725
|
+
expect(run.status).toBe(0);
|
|
726
|
+
const result = readResult(project);
|
|
727
|
+
expect(result.screens.map((s) => s.status)).toEqual(['rendered', 'rendered']);
|
|
728
|
+
// The synthesized harness depends on the real fixture package on disk.
|
|
729
|
+
const probeArgs = readFileSync(logPath, 'utf-8').trim().split('\n')[0].split(' ');
|
|
730
|
+
const harnessDir = probeArgs[probeArgs.indexOf('--package-path') + 1];
|
|
731
|
+
tempDirs.push(harnessDir);
|
|
732
|
+
const manifest = readFileSync(join(harnessDir, 'Package.swift'), 'utf-8');
|
|
733
|
+
expect(manifest).toContain('.package(path: "' + FIXTURE_PACKAGE + '")');
|
|
734
|
+
expect(manifest).toContain('.product(name: "FixtureViews", package: "ui-fidelity-package")');
|
|
735
|
+
});
|
|
736
|
+
// Opt-in: builds the fixture with the real Swift toolchain and renders
|
|
737
|
+
// through ImageRenderer. Requires macOS with Xcode; takes a SwiftPM
|
|
738
|
+
// build, so it is skipped unless explicitly requested.
|
|
739
|
+
const realSwiftTest = process.env.CIBUILD_UI_FIDELITY_REAL_SWIFT === '1' ? test : test.skip;
|
|
740
|
+
realSwiftTest('real toolchain: renders HomeView and isolates BrokenView with sanitized diagnostics', async () => {
|
|
741
|
+
const project = makeProject({
|
|
742
|
+
screens: { HomeView: 'home.png', BrokenView: 'broken.png' },
|
|
743
|
+
});
|
|
744
|
+
writeReference(project, 'home.png');
|
|
745
|
+
writeReference(project, 'broken.png');
|
|
746
|
+
const script = await buildScript(project, { package_path: FIXTURE_PACKAGE });
|
|
747
|
+
// No PATH shim: the real `swift` binary does the work.
|
|
748
|
+
const run = spawnSync('node', ['-e', script], {
|
|
749
|
+
cwd: project.dir,
|
|
750
|
+
encoding: 'utf-8',
|
|
751
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
752
|
+
});
|
|
753
|
+
expect(run.status).not.toBe(0);
|
|
754
|
+
const result = readResult(project);
|
|
755
|
+
const [home, broken] = result.screens;
|
|
756
|
+
expect(home.status).toBe('rendered');
|
|
757
|
+
expect(home.error).toBeNull();
|
|
758
|
+
const rendered = artifact(project, 'ui-fidelity/rendered/HomeView.png');
|
|
759
|
+
expect(isPng(rendered)).toBe(true);
|
|
760
|
+
expect(readFileSync(rendered).length).toBeGreaterThan(1000);
|
|
761
|
+
expect(broken.status).toBe('render_failed');
|
|
762
|
+
expect(broken.error?.code).toBe('VIEW_COMPILE_FAILED');
|
|
763
|
+
expect(broken.error?.message).toContain('BrokenView');
|
|
764
|
+
expect(broken.rendered_image_path).toBeNull();
|
|
765
|
+
// Real compiler diagnostics reference the harness temp dir; none of
|
|
766
|
+
// those absolute paths may reach the result document.
|
|
767
|
+
expectNoAbsolutePathLeaks(project, result);
|
|
768
|
+
}, 600_000);
|
|
769
|
+
});
|
|
770
|
+
//# sourceMappingURL=ui-fidelity-render.test.js.map
|