@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,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"}
|