@invarn/cibuild 1.9.9 → 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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * ui-fidelity-render step implementation.
3
+ *
4
+ * Renders parameterless SwiftUI views from a user's SwiftPM package to PNGs
5
+ * with SwiftUI's ImageRenderer on a macOS runner — no app build, no
6
+ * simulator. The step returns a self-contained node script that, at runtime:
7
+ *
8
+ * 1. Reads `.ci/inputs/params.json`
9
+ * ({ "screens": { "<ViewTypeName>": "<referenceFileBasename>" } });
10
+ * reference images live at `.ci/inputs/<basename>`.
11
+ * 2. Synthesizes a throwaway SwiftPM harness package that depends on the
12
+ * user's package and contains ONE executable target per screen, so a
13
+ * compile failure for one screen (unknown view type, unavailable init)
14
+ * never breaks the others. A screen-free probe target distinguishes
15
+ * "this screen is broken" from "the whole package does not build for
16
+ * macOS" (RENDER_UNSUPPORTED).
17
+ * 3. Builds and runs each per-screen executable, which constructs the view
18
+ * strictly as `<ViewTypeName>()`, renders at render_size x scale on the
19
+ * MainActor, and writes a PNG via CGImage + ImageIO.
20
+ * 4. Writes, inside the artifacts dir (${CIBUILD_ARTIFACTS_DIR:-.ci/artifacts}):
21
+ * - ui-fidelity/rendered/<Screen>.png
22
+ * - ui-fidelity/references/<Screen>.png (one copy per screen, even when
23
+ * two screens share a reference basename)
24
+ * - protocol-result.json (always written, even on partial/total failure;
25
+ * when params.json is absent/malformed the screens array is empty
26
+ * because screens cannot be enumerated)
27
+ * 5. Exits non-zero iff any screen is not "rendered".
28
+ *
29
+ * protocol-result.json contains relative paths only: image paths are relative
30
+ * to the artifacts dir, and error messages are sanitized so absolute runner
31
+ * paths (resolved package_path, harness temp dir, compiler diagnostics) are
32
+ * rewritten to relative paths or placeholders before they are stored.
33
+ */
34
+ import { BaseStepExecutor } from './base.js';
35
+ /**
36
+ * Default render size in device points. 393x852 is the iPhone-class portrait
37
+ * canvas shared by the iPhone 14 Pro / 15 / 16 — the most common modern
38
+ * iPhone logical resolution, so renders line up with design references by
39
+ * default.
40
+ */
41
+ export const DEFAULT_RENDER_SIZE = '393x852';
42
+ /** Default display scale (@2x), matching how references are typically exported. */
43
+ export const DEFAULT_SCALE = 2;
44
+ /**
45
+ * Parses a "<width>x<height>" render size string (device points).
46
+ * @throws Error when the value is malformed or non-positive
47
+ */
48
+ export function parseRenderSize(value) {
49
+ const match = /^(\d+)x(\d+)$/.exec(String(value).trim());
50
+ const width = match ? Number(match[1]) : 0;
51
+ const height = match ? Number(match[2]) : 0;
52
+ if (!match || width <= 0 || height <= 0) {
53
+ throw new Error(`Invalid render_size '${value}' for step 'ui-fidelity-render': ` +
54
+ `expected "<width>x<height>" in device points, e.g. "${DEFAULT_RENDER_SIZE}"`);
55
+ }
56
+ return { width, height };
57
+ }
58
+ /**
59
+ * Parses the scale input into a positive finite number.
60
+ * @throws Error when the value is not a positive number
61
+ */
62
+ export function parseScale(value) {
63
+ const scale = typeof value === 'number' ? value : Number(String(value).trim());
64
+ if (!Number.isFinite(scale) || scale <= 0) {
65
+ throw new Error(`Invalid scale '${value}' for step 'ui-fidelity-render': expected a positive number`);
66
+ }
67
+ return scale;
68
+ }
69
+ /**
70
+ * Swift source generators, written as plain dependency-free JS so the exact
71
+ * same code is embedded into the runtime node script AND directly evaluable
72
+ * in unit tests (see getRenderScriptInternals). String.raw keeps escape
73
+ * sequences literal; the body must not contain backticks or "${".
74
+ */
75
+ const RENDER_SCRIPT_GENERATORS = String.raw `
76
+ // ---- Swift source generation (pure string builders) ----
77
+
78
+ function swiftStringLiteral(value) {
79
+ var out = '"';
80
+ var s = String(value);
81
+ for (var i = 0; i < s.length; i++) {
82
+ var ch = s[i];
83
+ var code = s.charCodeAt(i);
84
+ if (ch === '\\') out += '\\\\';
85
+ else if (ch === '"') out += '\\"';
86
+ else if (ch === '\n') out += '\\n';
87
+ else if (ch === '\r') out += '\\r';
88
+ else if (ch === '\t') out += '\\t';
89
+ else if (code < 0x20) out += '\\u{' + code.toString(16) + '}';
90
+ else out += ch;
91
+ }
92
+ return out + '"';
93
+ }
94
+
95
+ // One executable target (and product) per screen: SwiftPM compiles the user
96
+ // package once, while a per-screen harness compile failure stays isolated to
97
+ // that screen. RenderProbe imports the user package without referencing any
98
+ // screen, so its failure means the package itself does not build for macOS.
99
+ function generateHarnessPackageSwift(screens, options) {
100
+ var dependency =
101
+ '.product(name: ' + swiftStringLiteral(options.target) +
102
+ ', package: ' + swiftStringLiteral(options.packageRef) + ')';
103
+ var lines = [];
104
+ lines.push('// swift-tools-version:5.9');
105
+ lines.push('import PackageDescription');
106
+ lines.push('');
107
+ lines.push('let package = Package(');
108
+ lines.push(' name: "UIFidelityRenderHarness",');
109
+ lines.push(' platforms: [.macOS(.v13)],');
110
+ lines.push(' products: [');
111
+ screens.forEach(function (screen) {
112
+ lines.push(
113
+ ' .executable(name: "Render_' + screen +
114
+ '", targets: ["Render_' + screen + '"]),'
115
+ );
116
+ });
117
+ lines.push(' ],');
118
+ lines.push(' dependencies: [');
119
+ lines.push(' .package(path: ' + swiftStringLiteral(options.packagePath) + ')');
120
+ lines.push(' ],');
121
+ lines.push(' targets: [');
122
+ lines.push(' .executableTarget(name: "RenderProbe", dependencies: [' + dependency + ']),');
123
+ screens.forEach(function (screen) {
124
+ lines.push(
125
+ ' .executableTarget(name: "Render_' + screen +
126
+ '", dependencies: [' + dependency + ']),'
127
+ );
128
+ });
129
+ lines.push(' ]');
130
+ lines.push(')');
131
+ return lines.join('\n') + '\n';
132
+ }
133
+
134
+ function generateProbeSwift(options) {
135
+ var lines = [];
136
+ lines.push('import ' + options.target);
137
+ lines.push('');
138
+ lines.push('@main');
139
+ lines.push('struct Probe {');
140
+ lines.push(' static func main() {}');
141
+ lines.push('}');
142
+ return lines.join('\n') + '\n';
143
+ }
144
+
145
+ // ImageRenderer is MainActor-isolated, so the @main entry point runs on the
146
+ // MainActor. The view is constructed strictly via the parameterless init.
147
+ function generateScreenSwift(screen, options) {
148
+ var lines = [];
149
+ lines.push('import Foundation');
150
+ lines.push('import ImageIO');
151
+ lines.push('import SwiftUI');
152
+ lines.push('import UniformTypeIdentifiers');
153
+ lines.push('import ' + options.target);
154
+ lines.push('');
155
+ lines.push('@main');
156
+ lines.push('struct RenderEntry {');
157
+ lines.push(' @MainActor');
158
+ lines.push(' static func main() {');
159
+ lines.push(' let arguments = CommandLine.arguments');
160
+ lines.push(' guard arguments.count >= 2 else {');
161
+ lines.push(' FileHandle.standardError.write(Data("usage: Render_' + screen + ' <output.png>".utf8))');
162
+ lines.push(' exit(64)');
163
+ lines.push(' }');
164
+ lines.push(' let outputPath = arguments[1]');
165
+ lines.push(' let width: CGFloat = ' + options.width);
166
+ lines.push(' let height: CGFloat = ' + options.height);
167
+ lines.push(' let view = ' + screen + '().frame(width: width, height: height)');
168
+ lines.push(' let renderer = ImageRenderer(content: view)');
169
+ lines.push(' renderer.scale = ' + options.scale);
170
+ lines.push(' renderer.proposedSize = ProposedViewSize(width: width, height: height)');
171
+ lines.push(' guard let image = renderer.cgImage else {');
172
+ lines.push(' FileHandle.standardError.write(Data("ImageRenderer produced no image for ' + screen + '".utf8))');
173
+ lines.push(' exit(65)');
174
+ lines.push(' }');
175
+ lines.push(' let url = URL(fileURLWithPath: outputPath)');
176
+ lines.push(' guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {');
177
+ lines.push(' FileHandle.standardError.write(Data(("could not create PNG destination at " + outputPath).utf8))');
178
+ lines.push(' exit(66)');
179
+ lines.push(' }');
180
+ lines.push(' CGImageDestinationAddImage(destination, image, nil)');
181
+ lines.push(' guard CGImageDestinationFinalize(destination) else {');
182
+ lines.push(' FileHandle.standardError.write(Data(("could not write PNG to " + outputPath).utf8))');
183
+ lines.push(' exit(66)');
184
+ lines.push(' }');
185
+ lines.push(' }');
186
+ lines.push('}');
187
+ return lines.join('\n') + '\n';
188
+ }
189
+ `;
190
+ /**
191
+ * Runtime body of the generated node script. Plain dependency-free CJS
192
+ * (the runner executes it via "node -e"). Must not contain backticks or
193
+ * "${" — String.raw keeps escape sequences literal.
194
+ */
195
+ const RENDER_SCRIPT_MAIN = String.raw `
196
+ // ---- runtime ----
197
+
198
+ var fs = require('fs');
199
+ var path = require('path');
200
+ var os = require('os');
201
+ var cp = require('child_process');
202
+
203
+ var RENDERER = 'imagerenderer-spm';
204
+ var SWIFT_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
205
+
206
+ var artifactsDir = process.env.CIBUILD_ARTIFACTS_DIR || '.ci/artifacts';
207
+ var inputsDir = path.join('.ci', 'inputs');
208
+ var paramsPath = path.join(inputsDir, 'params.json');
209
+ var resultPath = path.join(artifactsDir, 'protocol-result.json');
210
+
211
+ var currentEntries = [];
212
+
213
+ function log(message) {
214
+ console.log('[ui-fidelity-render] ' + message);
215
+ }
216
+
217
+ function logError(message) {
218
+ console.error('[ui-fidelity-render] ' + message);
219
+ }
220
+
221
+ // ---- result-document path hygiene ----
222
+ //
223
+ // protocol-result.json must contain relative paths only. Error messages can
224
+ // embed absolute runner paths (resolved package_path, harness temp dir,
225
+ // swift compiler diagnostics, copy failures), so every message stored in the
226
+ // result document is passed through sanitizeMessage(), which rewrites each
227
+ // known absolute directory -- and its symlink-resolved variant, since macOS
228
+ // tools print /var/folders/... as /private/var/folders/... -- to a relative
229
+ // path or a stable placeholder.
230
+
231
+ var pathReplacements = [];
232
+
233
+ function registerPathReplacement(absolutePath, replacement) {
234
+ if (!absolutePath || !path.isAbsolute(absolutePath)) return;
235
+ pathReplacements.push([absolutePath, replacement]);
236
+ try {
237
+ var real = fs.realpathSync(absolutePath);
238
+ if (real !== absolutePath) pathReplacements.push([real, replacement]);
239
+ } catch (realpathError) {
240
+ // path does not exist (yet) -- keep the literal form only
241
+ }
242
+ }
243
+
244
+ function sanitizeMessage(message) {
245
+ var sanitized = String(message);
246
+ pathReplacements
247
+ .slice()
248
+ .sort(function (a, b) { return b[0].length - a[0].length; })
249
+ .forEach(function (pair) {
250
+ sanitized = sanitized.split(pair[0]).join(pair[1]);
251
+ });
252
+ return sanitized;
253
+ }
254
+
255
+ var resolvedPackagePath = path.resolve(CONFIG.packagePath);
256
+ registerPathReplacement(
257
+ resolvedPackagePath,
258
+ path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath
259
+ );
260
+ registerPathReplacement(
261
+ path.resolve(artifactsDir),
262
+ path.isAbsolute(artifactsDir) ? '<artifacts>' : artifactsDir
263
+ );
264
+ registerPathReplacement(process.cwd(), '.');
265
+ registerPathReplacement(os.tmpdir(), '<tmp>');
266
+
267
+ // protocol-result.json is ALWAYS written, with one entry per screen in
268
+ // params.json order, even on partial or total failure. All paths inside it
269
+ // are relative to the artifacts dir.
270
+ function writeResult() {
271
+ fs.mkdirSync(artifactsDir, { recursive: true });
272
+ var doc = { renderer: RENDERER, screens: currentEntries };
273
+ fs.writeFileSync(resultPath, JSON.stringify(doc, null, 2) + '\n');
274
+ log('wrote ' + resultPath);
275
+ }
276
+
277
+ function setError(entry, code, message) {
278
+ entry.status = 'render_failed';
279
+ entry.error = { code: code, message: sanitizeMessage(message) };
280
+ }
281
+
282
+ function outputTail(result) {
283
+ var combined = ((result.stdout || '') + '\n' + (result.stderr || '')).trim();
284
+ if (combined.length > 2000) combined = '...' + combined.slice(-2000);
285
+ return combined;
286
+ }
287
+
288
+ function runSwift(args) {
289
+ log('swift ' + args.join(' '));
290
+ var result = cp.spawnSync('swift', args, {
291
+ encoding: 'utf-8',
292
+ maxBuffer: 16 * 1024 * 1024,
293
+ });
294
+ if (result.stdout) process.stdout.write(result.stdout);
295
+ if (result.stderr) process.stderr.write(result.stderr);
296
+ if (result.error) {
297
+ result.status = result.status == null ? 127 : result.status;
298
+ result.stderr =
299
+ (result.stderr || '') + String(result.error.message || result.error);
300
+ }
301
+ return result;
302
+ }
303
+
304
+ function renderWithHarness(renderable, packagePath) {
305
+ var harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-harness-'));
306
+ registerPathReplacement(harnessDir, '<harness>');
307
+ log('harness package: ' + harnessDir);
308
+ try {
309
+ var screens = renderable.map(function (entry) { return entry.screen; });
310
+ var generatorOptions = {
311
+ packagePath: packagePath,
312
+ packageRef: path.basename(packagePath),
313
+ target: CONFIG.target,
314
+ width: CONFIG.width,
315
+ height: CONFIG.height,
316
+ scale: CONFIG.scale,
317
+ };
318
+
319
+ fs.writeFileSync(
320
+ path.join(harnessDir, 'Package.swift'),
321
+ generateHarnessPackageSwift(screens, generatorOptions)
322
+ );
323
+ var probeDir = path.join(harnessDir, 'Sources', 'RenderProbe');
324
+ fs.mkdirSync(probeDir, { recursive: true });
325
+ fs.writeFileSync(path.join(probeDir, 'Probe.swift'), generateProbeSwift(generatorOptions));
326
+ screens.forEach(function (screen) {
327
+ var sourceDir = path.join(harnessDir, 'Sources', 'Render_' + screen);
328
+ fs.mkdirSync(sourceDir, { recursive: true });
329
+ fs.writeFileSync(
330
+ path.join(sourceDir, 'Render.swift'),
331
+ generateScreenSwift(screen, generatorOptions)
332
+ );
333
+ });
334
+
335
+ // Harness-level gate: the probe imports the user package without touching
336
+ // any screen. If it fails, the package does not resolve/compile for
337
+ // macOS and EVERY screen is marked RENDER_UNSUPPORTED.
338
+ var probe = runSwift(['build', '--package-path', harnessDir, '--target', 'RenderProbe']);
339
+ if (probe.status !== 0) {
340
+ var probeDetail = outputTail(probe);
341
+ currentEntries.forEach(function (entry) {
342
+ setError(
343
+ entry,
344
+ 'RENDER_UNSUPPORTED',
345
+ 'the package at ' + packagePath + ' does not build for macOS:\n' + probeDetail
346
+ );
347
+ });
348
+ return;
349
+ }
350
+
351
+ renderable.forEach(function (entry) {
352
+ var product = 'Render_' + entry.screen;
353
+ var build = runSwift(['build', '--package-path', harnessDir, '--product', product]);
354
+ if (build.status !== 0) {
355
+ setError(
356
+ entry,
357
+ 'VIEW_COMPILE_FAILED',
358
+ 'harness for ' + entry.screen + ' did not compile (is ' + entry.screen +
359
+ ' a public View with a parameterless init in ' + CONFIG.target + '?):\n' +
360
+ outputTail(build)
361
+ );
362
+ return;
363
+ }
364
+ var relative = 'ui-fidelity/rendered/' + entry.screen + '.png';
365
+ var outputPath = path.resolve(artifactsDir, relative);
366
+ var run = runSwift(['run', '--package-path', harnessDir, '--skip-build', product, outputPath]);
367
+ if (run.status !== 0) {
368
+ setError(
369
+ entry,
370
+ 'RENDER_FAILED',
371
+ 'renderer for ' + entry.screen + ' exited with code ' + run.status + ':\n' + outputTail(run)
372
+ );
373
+ return;
374
+ }
375
+ if (!fs.existsSync(outputPath)) {
376
+ setError(
377
+ entry,
378
+ 'RENDER_FAILED',
379
+ 'renderer for ' + entry.screen + ' exited 0 but produced no PNG at ' + outputPath
380
+ );
381
+ return;
382
+ }
383
+ entry.rendered_image_path = relative;
384
+ entry.status = 'rendered';
385
+ entry.error = null;
386
+ log('rendered ' + entry.screen + ' -> ' + relative);
387
+ });
388
+ } finally {
389
+ if (process.env.UI_FIDELITY_KEEP_HARNESS) {
390
+ log('keeping harness package (UI_FIDELITY_KEEP_HARNESS set)');
391
+ } else {
392
+ try {
393
+ fs.rmSync(harnessDir, { recursive: true, force: true });
394
+ } catch (cleanupError) {
395
+ // best effort
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ function main() {
402
+ // 1. Read and validate .ci/inputs/params.json.
403
+ var paramsProblem = null;
404
+ var params = null;
405
+ if (!fs.existsSync(paramsPath)) {
406
+ paramsProblem = 'not found at ' + paramsPath;
407
+ } else {
408
+ try {
409
+ params = JSON.parse(fs.readFileSync(paramsPath, 'utf-8'));
410
+ } catch (parseError) {
411
+ paramsProblem = 'is not valid JSON: ' +
412
+ (parseError && parseError.message ? parseError.message : String(parseError));
413
+ }
414
+ }
415
+ if (!paramsProblem) {
416
+ if (
417
+ !params || typeof params !== 'object' || Array.isArray(params) ||
418
+ !params.screens || typeof params.screens !== 'object' || Array.isArray(params.screens)
419
+ ) {
420
+ paramsProblem =
421
+ 'must have the shape { "screens": { "<ViewTypeName>": "<referenceFileBasename>" } }';
422
+ }
423
+ }
424
+ if (paramsProblem) {
425
+ // Screens cannot be enumerated, so the result document is written with
426
+ // an empty screens array and the step fails.
427
+ logError('params.json ' + paramsProblem);
428
+ writeResult();
429
+ return 1;
430
+ }
431
+
432
+ var screens = Object.keys(params.screens);
433
+ currentEntries = screens.map(function (screen) {
434
+ return {
435
+ screen: screen,
436
+ status: 'render_failed',
437
+ error: null,
438
+ reference_image_path: null,
439
+ rendered_image_path: null,
440
+ };
441
+ });
442
+ log('screens: ' + (screens.join(', ') || '(none)'));
443
+
444
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'rendered'), { recursive: true });
445
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'references'), { recursive: true });
446
+
447
+ // 2. Per-screen validation + reference copies. Each screen gets its OWN
448
+ // copy named by screen, even when two screens share a reference
449
+ // basename.
450
+ currentEntries.forEach(function (entry) {
451
+ if (!SWIFT_IDENTIFIER.test(entry.screen)) {
452
+ setError(
453
+ entry,
454
+ 'INVALID_SCREEN_NAME',
455
+ 'screen name ' + JSON.stringify(entry.screen) + ' is not a valid Swift type identifier'
456
+ );
457
+ return;
458
+ }
459
+ var basename = params.screens[entry.screen];
460
+ if (
461
+ typeof basename !== 'string' || basename === '' ||
462
+ basename === '.' || basename === '..' ||
463
+ basename !== path.basename(basename)
464
+ ) {
465
+ setError(
466
+ entry,
467
+ 'REFERENCE_MISSING',
468
+ 'reference for ' + entry.screen + ' must be a plain file basename inside ' +
469
+ inputsDir + ', got: ' + JSON.stringify(basename)
470
+ );
471
+ return;
472
+ }
473
+ var source = path.join(inputsDir, basename);
474
+ var sourceStat = null;
475
+ try {
476
+ sourceStat = fs.statSync(source);
477
+ } catch (statError) {
478
+ sourceStat = null;
479
+ }
480
+ if (!sourceStat || !sourceStat.isFile()) {
481
+ setError(
482
+ entry,
483
+ 'REFERENCE_MISSING',
484
+ sourceStat
485
+ ? 'reference for ' + entry.screen + ' is not a regular file: ' + source
486
+ : 'reference image not found: ' + source
487
+ );
488
+ return;
489
+ }
490
+ var relative = 'ui-fidelity/references/' + entry.screen + '.png';
491
+ // The copy is guarded per screen so an unexpected filesystem failure
492
+ // never escapes this forEach and takes the other screens down with it.
493
+ try {
494
+ fs.copyFileSync(source, path.join(artifactsDir, relative));
495
+ } catch (copyError) {
496
+ setError(
497
+ entry,
498
+ 'REFERENCE_MISSING',
499
+ 'could not copy reference for ' + entry.screen + ': ' +
500
+ (copyError && copyError.message ? copyError.message : String(copyError))
501
+ );
502
+ return;
503
+ }
504
+ entry.reference_image_path = relative;
505
+ });
506
+
507
+ // 3. Render the remaining screens through the synthesized harness.
508
+ var renderable = currentEntries.filter(function (entry) { return entry.error === null; });
509
+ if (renderable.length > 0) {
510
+ if (!fs.existsSync(resolvedPackagePath)) {
511
+ currentEntries.forEach(function (entry) {
512
+ setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);
513
+ });
514
+ } else {
515
+ renderWithHarness(renderable, resolvedPackagePath);
516
+ }
517
+ } else if (currentEntries.length > 0) {
518
+ log('no renderable screens, skipping harness build');
519
+ }
520
+
521
+ // 4. Exit code: zero only when every screen rendered.
522
+ writeResult();
523
+ var failed = currentEntries.filter(function (entry) { return entry.status !== 'rendered'; });
524
+ if (failed.length > 0) {
525
+ logError(failed.length + ' of ' + currentEntries.length + ' screen(s) not rendered');
526
+ return 1;
527
+ }
528
+ log('all ' + currentEntries.length + ' screen(s) rendered');
529
+ return 0;
530
+ }
531
+
532
+ var exitCode = 1;
533
+ try {
534
+ exitCode = main();
535
+ } catch (unexpectedError) {
536
+ logError(
537
+ 'unexpected failure: ' +
538
+ (unexpectedError && unexpectedError.stack ? unexpectedError.stack : String(unexpectedError))
539
+ );
540
+ try {
541
+ currentEntries.forEach(function (entry) {
542
+ if (entry.status !== 'rendered' && entry.error === null) {
543
+ setError(
544
+ entry,
545
+ 'INTERNAL_ERROR',
546
+ String(unexpectedError && unexpectedError.message ? unexpectedError.message : unexpectedError)
547
+ );
548
+ }
549
+ });
550
+ writeResult();
551
+ } catch (writeError) {
552
+ logError('could not write protocol-result.json: ' + writeError);
553
+ }
554
+ exitCode = 1;
555
+ }
556
+ process.exit(exitCode);
557
+ `;
558
+ /**
559
+ * Evaluates the embedded generator source and returns the real functions, so
560
+ * unit tests exercise exactly the code that ships inside the runtime script.
561
+ */
562
+ export function getRenderScriptInternals() {
563
+ const factory = new Function(RENDER_SCRIPT_GENERATORS +
564
+ '\nreturn { swiftStringLiteral: swiftStringLiteral,' +
565
+ ' generateHarnessPackageSwift: generateHarnessPackageSwift,' +
566
+ ' generateProbeSwift: generateProbeSwift,' +
567
+ ' generateScreenSwift: generateScreenSwift };');
568
+ return factory();
569
+ }
570
+ /**
571
+ * Generates the self-contained runtime node script for the step.
572
+ * Pure function of its config — exported so tests can execute the script.
573
+ */
574
+ export function generateRenderScript(config) {
575
+ return [
576
+ "'use strict';",
577
+ '// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
578
+ 'var CONFIG = ' + JSON.stringify(config) + ';',
579
+ RENDER_SCRIPT_GENERATORS,
580
+ RENDER_SCRIPT_MAIN,
581
+ ].join('\n');
582
+ }
583
+ /**
584
+ * ui-fidelity-render step executor.
585
+ * Returns a node-kind StepDef whose script performs the render at runtime on
586
+ * the macOS runner.
587
+ */
588
+ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
589
+ getValidationRequirements(inputs, _env, _config) {
590
+ return [
591
+ this.requireInput('package_path', inputs, 'Path to the SwiftPM package containing the screens'),
592
+ this.requireInput('target', inputs, 'SPM library product to import in the render harness'),
593
+ this.requireCommand('swift', 'Swift toolchain used to build and run the render harness', 'Install Xcode (or the Swift toolchain) on the runner'),
594
+ ];
595
+ }
596
+ async execute(inputs, _env, _config) {
597
+ const stepName = 'ui-fidelity-render';
598
+ const packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
599
+ const target = String(this.getRequiredInput(inputs, 'target', stepName));
600
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
601
+ throw new Error(`Invalid target '${target}' for step '${stepName}': ` +
602
+ 'must be a Swift module identifier (letters, digits, underscores)');
603
+ }
604
+ const { width, height } = parseRenderSize(String(this.getInput(inputs, 'render_size', DEFAULT_RENDER_SIZE)));
605
+ const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
606
+ const script = generateRenderScript({ packagePath, target, width, height, scale });
607
+ return this.createNodeStep(script, stepName);
608
+ }
609
+ }
610
+ //# sourceMappingURL=ui-fidelity-render.js.map
@@ -0,0 +1,18 @@
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
+ export {};
18
+ //# sourceMappingURL=ui-fidelity-render.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-fidelity-render.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG"}