@invarn/cibuild 2.0.8 → 2.1.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.
Files changed (29) hide show
  1. package/dist/cli.cjs +688 -69
  2. package/dist/src/lib.d.ts +2 -0
  3. package/dist/src/lib.d.ts.map +1 -1
  4. package/dist/src/lib.js +1 -0
  5. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  6. package/dist/src/yaml/steps/index.js +22 -0
  7. package/dist/src/yaml/steps/render-post-processor.d.ts +73 -0
  8. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -0
  9. package/dist/src/yaml/steps/render-post-processor.js +303 -0
  10. package/dist/src/yaml/steps/render-post-processor.test.d.ts +16 -0
  11. package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -0
  12. package/dist/src/yaml/steps/render-post-processor.test.js +238 -0
  13. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +66 -0
  14. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -0
  15. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +249 -0
  16. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +13 -0
  17. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +1 -0
  18. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +299 -0
  19. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +92 -0
  20. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -0
  21. package/dist/src/yaml/steps/ui-fidelity-render-android.js +726 -0
  22. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +2 -0
  23. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +1 -0
  24. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +377 -0
  25. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -28
  26. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
  27. package/dist/src/yaml/steps/ui-fidelity-render.js +1 -259
  28. package/dist/src/yaml/steps/ui-fidelity-render.test.js +1 -57
  29. package/package.json +1 -1
@@ -0,0 +1,726 @@
1
+ /**
2
+ * ui-fidelity-render-android step implementation.
3
+ *
4
+ * Renders parameterless Jetpack Compose wrappers from a self-contained Gradle
5
+ * project to PNGs via Roborazzi + Robolectric on the JVM — no device, no
6
+ * emulator — on a macOS runner. The step returns a self-contained node script
7
+ * that, at runtime:
8
+ *
9
+ * 1. Reads `.ci/inputs/params.json`
10
+ * ({ "screens": { "<ScreenName>": "<referenceFileBasename>" } });
11
+ * reference images live at `.ci/inputs/<basename>`.
12
+ * 2. Extracts and validates the shipped Gradle project shipped as
13
+ * `.ci/inputs/package.tar.gz` (single project root carrying a settings
14
+ * script, bounded extracted size, safe member paths).
15
+ * 3. Runs the project's Roborazzi record task (default
16
+ * `recordRoborazziDebug`) once. A build/compile failure means the project
17
+ * does not render and every screen is marked RENDER_UNSUPPORTED — the
18
+ * Android analog of the iOS probe gate (one Gradle module compiles as a
19
+ * unit, so a wrapper compile error cannot be isolated to one screen).
20
+ * 4. Locates each screen's Roborazzi golden inside the project (recorded as
21
+ * `build/outputs/roborazzi/...render<ScreenName>.png` from a test method
22
+ * named render<ScreenName>, or a flat `<ScreenName>.png`), runs it through
23
+ * the shared render-output
24
+ * post-processor (content trim, reference alignment, dimension
25
+ * reporting), and writes, inside the artifacts dir
26
+ * (${CIBUILD_ARTIFACTS_DIR:-.ci/artifacts}):
27
+ * - ui-fidelity/rendered/<Screen>.png
28
+ * - ui-fidelity/references/<Screen>.png
29
+ * - ui-fidelity/aligned/<Screen>.png
30
+ * - protocol-result.json (always written, even on partial/total failure)
31
+ * 5. Exits non-zero iff the archive was invalid or any screen is not
32
+ * "rendered".
33
+ *
34
+ * The result payload and artifact layout are identical in shape to the iOS
35
+ * ui-fidelity-render step, so the dashboard compare view, the artifact
36
+ * download tool, and the agent's compare flow are reused unchanged. Image
37
+ * paths in protocol-result.json are relative to the artifacts dir, and error
38
+ * messages are sanitized so absolute runner paths are rewritten before they
39
+ * are stored.
40
+ */
41
+ import { BaseStepExecutor } from './base.js';
42
+ import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
43
+ import { RENDER_SCRIPT_TRIM_STAGE } from './render-post-processor.js';
44
+ /**
45
+ * Default Roborazzi record task. Run from the project root so Gradle resolves
46
+ * it in whichever subproject declares it.
47
+ */
48
+ export const DEFAULT_GRADLE_TASK = 'recordRoborazziDebug';
49
+ /** Basename of the shipped Gradle-project archive inside the run-inputs dir. */
50
+ export const PACKAGE_ARCHIVE_BASENAME = 'package.tar.gz';
51
+ /** Renderer identifier recorded in protocol-result.json. */
52
+ export const ANDROID_RENDERER = 'roborazzi-robolectric';
53
+ /**
54
+ * Validates a Gradle task name: a non-empty token of letters, digits, and the
55
+ * separators Gradle allows in a task path (`:`, `-`, `_`). Rejects shell
56
+ * metacharacters so the task is safe to pass to the runner.
57
+ */
58
+ export function isValidGradleTask(task) {
59
+ return /^[A-Za-z0-9_:-]+$/.test(task);
60
+ }
61
+ /**
62
+ * Runtime body of the generated node script. Plain dependency-free CJS (the
63
+ * runner executes it via "node -e"). Must not contain backticks or "${" —
64
+ * String.raw keeps escape sequences literal. The renderer id and archive name
65
+ * are concatenated in as JS string literals by generateAndroidRenderScript.
66
+ */
67
+ const ANDROID_RENDER_SCRIPT_MAIN = String.raw `
68
+ // ---- runtime ----
69
+
70
+ var fs = require('fs');
71
+ var path = require('path');
72
+ var os = require('os');
73
+ var cp = require('child_process');
74
+
75
+ var SCREEN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
76
+
77
+ var artifactsDir = process.env.CIBUILD_ARTIFACTS_DIR || '.ci/artifacts';
78
+ var inputsDir = path.join('.ci', 'inputs');
79
+ var paramsPath = path.join(inputsDir, 'params.json');
80
+ var resultPath = path.join(artifactsDir, 'protocol-result.json');
81
+
82
+ var currentEntries = [];
83
+ var buildError = null;
84
+
85
+ function log(message) {
86
+ console.log('[ui-fidelity-render-android] ' + message);
87
+ }
88
+
89
+ function logError(message) {
90
+ console.error('[ui-fidelity-render-android] ' + message);
91
+ }
92
+
93
+ // ---- result-document path hygiene ----
94
+ //
95
+ // protocol-result.json must contain relative paths only. Error messages can
96
+ // embed absolute runner paths (extracted project dir, gradle diagnostics), so
97
+ // every message stored in the result document is passed through
98
+ // sanitizeMessage(), which rewrites each known absolute directory -- and its
99
+ // symlink-resolved variant -- to a relative path or a stable placeholder.
100
+
101
+ var pathReplacements = [];
102
+
103
+ function registerPathReplacement(absolutePath, replacement) {
104
+ if (!absolutePath || !path.isAbsolute(absolutePath)) return;
105
+ pathReplacements.push([absolutePath, replacement]);
106
+ try {
107
+ var real = fs.realpathSync(absolutePath);
108
+ if (real !== absolutePath) pathReplacements.push([real, replacement]);
109
+ } catch (realpathError) {
110
+ // path does not exist (yet) -- keep the literal form only
111
+ }
112
+ }
113
+
114
+ function sanitizeMessage(message) {
115
+ var sanitized = String(message);
116
+ pathReplacements
117
+ .slice()
118
+ .sort(function (a, b) { return b[0].length - a[0].length; })
119
+ .forEach(function (pair) {
120
+ sanitized = sanitized.split(pair[0]).join(pair[1]);
121
+ });
122
+ return sanitized;
123
+ }
124
+
125
+ registerPathReplacement(path.resolve(artifactsDir), path.isAbsolute(artifactsDir) ? '<artifacts>' : artifactsDir);
126
+ registerPathReplacement(process.cwd(), '.');
127
+ registerPathReplacement(os.tmpdir(), '<tmp>');
128
+
129
+ function setBuildError(code, message) {
130
+ buildError = { code: code, message: sanitizeMessage(message) };
131
+ logError(code + ': ' + buildError.message);
132
+ }
133
+
134
+ function setError(entry, code, message) {
135
+ entry.status = 'render_failed';
136
+ entry.error = { code: code, message: sanitizeMessage(message) };
137
+ }
138
+
139
+ function outputTail(result) {
140
+ var combined = ((result.stdout || '') + '\n' + (result.stderr || '')).trim();
141
+ if (combined.length > 2000) combined = '...' + combined.slice(-2000);
142
+ return combined;
143
+ }
144
+
145
+ // protocol-result.json is ALWAYS written, with one entry per screen in
146
+ // params.json order, even on partial or total failure. All paths inside it
147
+ // are relative to the artifacts dir.
148
+ function writeResult() {
149
+ fs.mkdirSync(artifactsDir, { recursive: true });
150
+ var doc = {
151
+ renderer: RENDERER,
152
+ package_source: 'inputs',
153
+ error: buildError,
154
+ screens: currentEntries,
155
+ };
156
+ fs.writeFileSync(resultPath, JSON.stringify(doc, null, 2) + '\n');
157
+ log('wrote ' + resultPath);
158
+ }
159
+
160
+ // ---- shipped Gradle-project stage ----
161
+ //
162
+ // The caller ships a self-contained Gradle project as
163
+ // .ci/inputs/package.tar.gz. Before any render it is located, extracted into a
164
+ // scratch directory, and validated. Failures here are PER-BUILD structured
165
+ // errors recorded as the result document's top-level error object. The
166
+ // extracted size is bounded as a decompression-bomb guard.
167
+
168
+ var DEFAULT_MAX_EXTRACTED_BYTES = 64 * 1024 * 1024;
169
+ var maxExtractedBytes =
170
+ Number(process.env.UI_FIDELITY_MAX_PACKAGE_BYTES || '') || DEFAULT_MAX_EXTRACTED_BYTES;
171
+ var shippedExtractDir = null;
172
+
173
+ function runTar(args) {
174
+ var result = cp.spawnSync('tar', args, { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
175
+ if (result.error) {
176
+ result.status = result.status == null ? 127 : result.status;
177
+ result.stderr = (result.stderr || '') + String(result.error.message || result.error);
178
+ }
179
+ return result;
180
+ }
181
+
182
+ function isUnsafeArchiveMember(name) {
183
+ if (name.charAt(0) === '/') return true;
184
+ var segments = name.split('/');
185
+ for (var i = 0; i < segments.length; i++) {
186
+ if (segments[i] === '..') return true;
187
+ }
188
+ return false;
189
+ }
190
+
191
+ function extractedSize(dir) {
192
+ var total = 0;
193
+ var stack = [dir];
194
+ while (stack.length > 0) {
195
+ var current = stack.pop();
196
+ var names = fs.readdirSync(current);
197
+ for (var i = 0; i < names.length; i++) {
198
+ var entryPath = path.join(current, names[i]);
199
+ var stat = fs.lstatSync(entryPath);
200
+ if (stat.isDirectory()) stack.push(entryPath);
201
+ else total += stat.size;
202
+ }
203
+ }
204
+ return total;
205
+ }
206
+
207
+ // Entries macOS archiving tools add but that say nothing about the project
208
+ // layout are ignored when locating the project root.
209
+ function isJunkEntry(name) {
210
+ return name === '.DS_Store' || name === '__MACOSX' || name.indexOf('._') === 0;
211
+ }
212
+
213
+ // A Gradle project root is identified by a settings script at its top.
214
+ function hasSettings(dir) {
215
+ try {
216
+ if (fs.statSync(path.join(dir, 'settings.gradle.kts')).isFile()) return true;
217
+ } catch (e1) {
218
+ // not the .kts variant
219
+ }
220
+ try {
221
+ return fs.statSync(path.join(dir, 'settings.gradle')).isFile();
222
+ } catch (e2) {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ // Project-root rule: a settings script at the extraction root wins; otherwise
228
+ // exactly one top-level directory with a settings script at its top is the
229
+ // root; anything else is a structured per-build error.
230
+ function findProjectRoot(extractDir) {
231
+ if (hasSettings(extractDir)) return extractDir;
232
+ var entries = fs.readdirSync(extractDir).filter(function (name) {
233
+ return !isJunkEntry(name);
234
+ });
235
+ if (entries.length === 0) {
236
+ setBuildError('ANDROID_ARCHIVE_INVALID', PACKAGE_ARCHIVE_NAME + ' is empty');
237
+ return null;
238
+ }
239
+ if (entries.length > 1) {
240
+ setBuildError(
241
+ 'ANDROID_ARCHIVE_INVALID',
242
+ PACKAGE_ARCHIVE_NAME + ' must contain a single project root; found ' +
243
+ entries.length + ' top-level entries (' + entries.sort().join(', ') +
244
+ ') and no settings.gradle(.kts) at the archive root'
245
+ );
246
+ return null;
247
+ }
248
+ var rootPath = path.join(extractDir, entries[0]);
249
+ if (!fs.statSync(rootPath).isDirectory()) {
250
+ setBuildError(
251
+ 'ANDROID_ARCHIVE_INVALID',
252
+ PACKAGE_ARCHIVE_NAME + ' must contain a project directory; its only ' +
253
+ 'top-level entry (' + entries[0] + ') is not a directory'
254
+ );
255
+ return null;
256
+ }
257
+ if (!hasSettings(rootPath)) {
258
+ setBuildError(
259
+ 'ANDROID_PROJECT_ROOT_MISSING',
260
+ 'no settings.gradle(.kts) at the top of the project root (' + entries[0] + ') in ' +
261
+ PACKAGE_ARCHIVE_NAME
262
+ );
263
+ return null;
264
+ }
265
+ return rootPath;
266
+ }
267
+
268
+ // Locates, extracts, and validates the shipped archive. Returns the project
269
+ // root to build from, or null after recording a structured build error.
270
+ function prepareShippedProject() {
271
+ var archivePath = path.join(inputsDir, PACKAGE_ARCHIVE_NAME);
272
+ var archiveStat = null;
273
+ try {
274
+ archiveStat = fs.statSync(archivePath);
275
+ } catch (statError) {
276
+ archiveStat = null;
277
+ }
278
+ if (!archiveStat || !archiveStat.isFile()) {
279
+ setBuildError(
280
+ 'ANDROID_ARCHIVE_MISSING',
281
+ 'no ' + PACKAGE_ARCHIVE_NAME + ' in ' + inputsDir + '; ship the Gradle ' +
282
+ 'render project as a run input named ' + PACKAGE_ARCHIVE_NAME
283
+ );
284
+ return null;
285
+ }
286
+
287
+ var listing = runTar(['-tzf', archivePath]);
288
+ if (listing.status !== 0) {
289
+ setBuildError(
290
+ 'ANDROID_ARCHIVE_INVALID',
291
+ PACKAGE_ARCHIVE_NAME + ' is not a readable gzipped tar archive:\n' + outputTail(listing)
292
+ );
293
+ return null;
294
+ }
295
+ var members = (listing.stdout || '').split('\n').filter(function (name) { return name !== ''; });
296
+ var unsafe = members.filter(isUnsafeArchiveMember);
297
+ if (unsafe.length > 0) {
298
+ setBuildError(
299
+ 'ANDROID_ARCHIVE_INVALID',
300
+ PACKAGE_ARCHIVE_NAME + ' contains unsafe member paths (absolute or dot-dot): ' +
301
+ unsafe.slice(0, 5).join(', ')
302
+ );
303
+ return null;
304
+ }
305
+
306
+ shippedExtractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-android-project-'));
307
+ registerPathReplacement(shippedExtractDir, '<project>');
308
+ log('extracting ' + PACKAGE_ARCHIVE_NAME + ' (' + archiveStat.size + ' bytes)');
309
+ var extraction = runTar(['-xzf', archivePath, '-C', shippedExtractDir]);
310
+ if (extraction.status !== 0) {
311
+ setBuildError(
312
+ 'ANDROID_ARCHIVE_INVALID',
313
+ PACKAGE_ARCHIVE_NAME + ' could not be extracted:\n' + outputTail(extraction)
314
+ );
315
+ return null;
316
+ }
317
+
318
+ var totalBytes = extractedSize(shippedExtractDir);
319
+ if (totalBytes > maxExtractedBytes) {
320
+ setBuildError(
321
+ 'ANDROID_ARCHIVE_TOO_LARGE',
322
+ PACKAGE_ARCHIVE_NAME + ' extracts to ' + totalBytes + ' bytes, over the ' +
323
+ maxExtractedBytes + '-byte limit; ship a leaner project'
324
+ );
325
+ return null;
326
+ }
327
+
328
+ var projectRoot = findProjectRoot(shippedExtractDir);
329
+ if (projectRoot === null) return null;
330
+ var rootLabel = path.relative(shippedExtractDir, projectRoot);
331
+ log('project root: ' + (rootLabel === '' ? '.' : rootLabel));
332
+ return projectRoot;
333
+ }
334
+
335
+ function cleanupShippedProject() {
336
+ if (shippedExtractDir === null) return;
337
+ if (process.env.UI_FIDELITY_KEEP_HARNESS) {
338
+ log('keeping extracted project (UI_FIDELITY_KEEP_HARNESS set)');
339
+ return;
340
+ }
341
+ try {
342
+ fs.rmSync(shippedExtractDir, { recursive: true, force: true });
343
+ } catch (cleanupError) {
344
+ // best effort
345
+ }
346
+ shippedExtractDir = null;
347
+ }
348
+
349
+ // ---- render stage ----
350
+ //
351
+ // runSwift drives the shared CoreGraphics trim/align helper in script-
352
+ // interpreter mode (the same helper iOS uses); it is available because the
353
+ // render runs on the macOS runner fleet regardless of the JVM render engine.
354
+
355
+ function runSwift(args) {
356
+ var result = cp.spawnSync('swift', args, { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
357
+ if (result.stdout) process.stdout.write(result.stdout);
358
+ if (result.stderr) process.stderr.write(result.stderr);
359
+ if (result.error) {
360
+ result.status = result.status == null ? 127 : result.status;
361
+ result.stderr = (result.stderr || '') + String(result.error.message || result.error);
362
+ }
363
+ return result;
364
+ }
365
+
366
+ // Runs the project's Roborazzi record task once, from the project root, so
367
+ // Gradle resolves the task in whichever module declares it. Prefers the shipped
368
+ // wrapper; falls back to a gradle on PATH.
369
+ function runGradle(projectRoot, task) {
370
+ var wrapper = path.join(projectRoot, 'gradlew');
371
+ var hasWrapper = false;
372
+ try {
373
+ hasWrapper = fs.statSync(wrapper).isFile();
374
+ } catch (statError) {
375
+ hasWrapper = false;
376
+ }
377
+ var cmd = hasWrapper ? wrapper : 'gradle';
378
+ log((hasWrapper ? './gradlew ' : 'gradle ') + task);
379
+ var result = cp.spawnSync(cmd, [task], {
380
+ cwd: projectRoot,
381
+ encoding: 'utf-8',
382
+ maxBuffer: 64 * 1024 * 1024,
383
+ });
384
+ if (result.stdout) process.stdout.write(result.stdout);
385
+ if (result.stderr) process.stderr.write(result.stderr);
386
+ if (result.error) {
387
+ result.status = result.status == null ? 127 : result.status;
388
+ result.stderr = (result.stderr || '') + String(result.error.message || result.error);
389
+ }
390
+ return result;
391
+ }
392
+
393
+ // The golden's trailing method segment that renders a screen: Roborazzi's
394
+ // record task names goldens <package>.<Class>.<method>.png and ignores an
395
+ // explicit capture filename, so the contract is a test method named
396
+ // render<ScreenName> (or a flat <ScreenName>.png). Matching the final
397
+ // dot-delimited segment keeps it collision-safe (renderXProof never matches
398
+ // screen "Proof").
399
+ function roborazziPngMatches(basename, screen) {
400
+ if (basename.slice(-4) !== '.png') return false;
401
+ var stem = basename.slice(0, -4);
402
+ var lastDot = stem.lastIndexOf('.');
403
+ var method = lastDot === -1 ? stem : stem.slice(lastDot + 1);
404
+ return method === screen || method === 'render' + screen;
405
+ }
406
+
407
+ // Roborazzi writes PNGs to <module>/build/outputs/roborazzi/. Collect every
408
+ // such directory in the project so a screen's PNG can be located regardless of
409
+ // which module rendered it.
410
+ function findRoborazziDirs(root) {
411
+ var found = [];
412
+ var stack = [root];
413
+ while (stack.length > 0) {
414
+ var dir = stack.pop();
415
+ var entries;
416
+ try {
417
+ entries = fs.readdirSync(dir, { withFileTypes: true });
418
+ } catch (readError) {
419
+ continue;
420
+ }
421
+ for (var i = 0; i < entries.length; i++) {
422
+ var entry = entries[i];
423
+ if (!entry.isDirectory()) continue;
424
+ var full = path.join(dir, entry.name);
425
+ if (
426
+ entry.name === 'roborazzi' &&
427
+ path.basename(path.dirname(full)) === 'outputs' &&
428
+ path.basename(path.dirname(path.dirname(full))) === 'build'
429
+ ) {
430
+ found.push(full);
431
+ } else {
432
+ stack.push(full);
433
+ }
434
+ }
435
+ }
436
+ return found;
437
+ }
438
+
439
+ // Builds the project once and pairs each screen's rendered PNG with its
440
+ // reference via the shared post-processor. A build failure means the project
441
+ // does not render and EVERY screen is RENDER_UNSUPPORTED (one Gradle module is
442
+ // one compile unit, so a wrapper compile error cannot be isolated to a single
443
+ // screen — the analog of the iOS probe gate). A screen whose PNG is absent
444
+ // after a successful build is RENDER_FAILED on its own.
445
+ function renderScreens(renderable, projectRoot) {
446
+ var gradle = runGradle(projectRoot, CONFIG.gradleTask);
447
+ if (gradle.status !== 0) {
448
+ var detail = outputTail(gradle);
449
+ currentEntries.forEach(function (entry) {
450
+ setError(
451
+ entry,
452
+ 'RENDER_UNSUPPORTED',
453
+ 'the Gradle render project did not build:\n' + detail
454
+ );
455
+ });
456
+ return;
457
+ }
458
+
459
+ var workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-android-trim-'));
460
+ registerPathReplacement(workDir, '<work>');
461
+ try {
462
+ var roborazziDirs = findRoborazziDirs(projectRoot);
463
+ renderable.forEach(function (entry) {
464
+ var source = null;
465
+ for (var i = 0; i < roborazziDirs.length && source === null; i++) {
466
+ var names;
467
+ try {
468
+ names = fs.readdirSync(roborazziDirs[i]);
469
+ } catch (readError) {
470
+ names = [];
471
+ }
472
+ for (var j = 0; j < names.length; j++) {
473
+ if (roborazziPngMatches(names[j], entry.screen)) {
474
+ source = path.join(roborazziDirs[i], names[j]);
475
+ break;
476
+ }
477
+ }
478
+ }
479
+ if (source === null) {
480
+ setError(
481
+ entry,
482
+ 'RENDER_FAILED',
483
+ 'no PNG was produced for ' + entry.screen + '; the render test must record a ' +
484
+ 'golden named render' + entry.screen + ' (build/outputs/roborazzi/...render' +
485
+ entry.screen + '.png)'
486
+ );
487
+ return;
488
+ }
489
+ var relative = 'ui-fidelity/rendered/' + entry.screen + '.png';
490
+ var outputPath = path.resolve(artifactsDir, relative);
491
+ try {
492
+ fs.copyFileSync(source, outputPath);
493
+ } catch (copyError) {
494
+ setError(
495
+ entry,
496
+ 'RENDER_FAILED',
497
+ 'could not copy the render for ' + entry.screen + ': ' +
498
+ (copyError && copyError.message ? copyError.message : String(copyError))
499
+ );
500
+ return;
501
+ }
502
+ entry.rendered_image_path = relative;
503
+ entry.status = 'rendered';
504
+ entry.error = null;
505
+ log('rendered ' + entry.screen + ' -> ' + relative);
506
+ trimRenderedImage(entry, outputPath, workDir);
507
+ });
508
+ } finally {
509
+ if (process.env.UI_FIDELITY_KEEP_HARNESS) {
510
+ log('keeping trim work dir (UI_FIDELITY_KEEP_HARNESS set)');
511
+ } else {
512
+ try {
513
+ fs.rmSync(workDir, { recursive: true, force: true });
514
+ } catch (cleanupError) {
515
+ // best effort
516
+ }
517
+ }
518
+ }
519
+ }
520
+
521
+ function main() {
522
+ // 1. Read and validate .ci/inputs/params.json.
523
+ var paramsProblem = null;
524
+ var params = null;
525
+ if (!fs.existsSync(paramsPath)) {
526
+ paramsProblem = 'not found at ' + paramsPath;
527
+ } else {
528
+ try {
529
+ params = JSON.parse(fs.readFileSync(paramsPath, 'utf-8'));
530
+ } catch (parseError) {
531
+ paramsProblem = 'is not valid JSON: ' +
532
+ (parseError && parseError.message ? parseError.message : String(parseError));
533
+ }
534
+ }
535
+ if (!paramsProblem) {
536
+ if (
537
+ !params || typeof params !== 'object' || Array.isArray(params) ||
538
+ !params.screens || typeof params.screens !== 'object' || Array.isArray(params.screens)
539
+ ) {
540
+ paramsProblem =
541
+ 'must have the shape { "screens": { "<ScreenName>": "<referenceFileBasename>" } }';
542
+ }
543
+ }
544
+ if (paramsProblem) {
545
+ // Screens cannot be enumerated, so the result document is written with an
546
+ // empty screens array and the step fails.
547
+ logError('params.json ' + paramsProblem);
548
+ writeResult();
549
+ return 1;
550
+ }
551
+
552
+ var screens = Object.keys(params.screens);
553
+ currentEntries = screens.map(function (screen) {
554
+ return {
555
+ screen: screen,
556
+ status: 'render_failed',
557
+ error: null,
558
+ reference_image_path: null,
559
+ rendered_image_path: null,
560
+ aligned_image_path: null,
561
+ dimensions: null,
562
+ };
563
+ });
564
+ log('screens: ' + (screens.join(', ') || '(none)'));
565
+
566
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'rendered'), { recursive: true });
567
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'references'), { recursive: true });
568
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'aligned'), { recursive: true });
569
+
570
+ // 2. Per-screen validation + reference copies. Each screen gets its OWN copy
571
+ // named by screen, even when two screens share a reference basename.
572
+ currentEntries.forEach(function (entry) {
573
+ if (!SCREEN_NAME.test(entry.screen)) {
574
+ setError(
575
+ entry,
576
+ 'INVALID_SCREEN_NAME',
577
+ 'screen name ' + JSON.stringify(entry.screen) +
578
+ ' is not a valid identifier (letters, digits, underscores)'
579
+ );
580
+ return;
581
+ }
582
+ var basename = params.screens[entry.screen];
583
+ if (
584
+ typeof basename !== 'string' || basename === '' ||
585
+ basename === '.' || basename === '..' ||
586
+ basename !== path.basename(basename)
587
+ ) {
588
+ setError(
589
+ entry,
590
+ 'REFERENCE_MISSING',
591
+ 'reference for ' + entry.screen + ' must be a plain file basename inside ' +
592
+ inputsDir + ', got: ' + JSON.stringify(basename)
593
+ );
594
+ return;
595
+ }
596
+ var source = path.join(inputsDir, basename);
597
+ var sourceStat = null;
598
+ try {
599
+ sourceStat = fs.statSync(source);
600
+ } catch (statError) {
601
+ sourceStat = null;
602
+ }
603
+ if (!sourceStat || !sourceStat.isFile()) {
604
+ setError(
605
+ entry,
606
+ 'REFERENCE_MISSING',
607
+ sourceStat
608
+ ? 'reference for ' + entry.screen + ' is not a regular file: ' + source
609
+ : 'reference image not found: ' + source
610
+ );
611
+ return;
612
+ }
613
+ var relative = 'ui-fidelity/references/' + entry.screen + '.png';
614
+ try {
615
+ fs.copyFileSync(source, path.join(artifactsDir, relative));
616
+ } catch (copyError) {
617
+ setError(
618
+ entry,
619
+ 'REFERENCE_MISSING',
620
+ 'could not copy reference for ' + entry.screen + ': ' +
621
+ (copyError && copyError.message ? copyError.message : String(copyError))
622
+ );
623
+ return;
624
+ }
625
+ entry.reference_image_path = relative;
626
+ });
627
+
628
+ // 3. Extract + validate the shipped Gradle project, then render the
629
+ // renderable screens. The project stage runs even when no screen is
630
+ // renderable, so packaging mistakes are always reported.
631
+ var renderable = currentEntries.filter(function (entry) { return entry.error === null; });
632
+ try {
633
+ var projectRoot = prepareShippedProject();
634
+ if (projectRoot !== null) {
635
+ if (renderable.length > 0) {
636
+ renderScreens(renderable, projectRoot);
637
+ } else if (currentEntries.length > 0) {
638
+ log('no renderable screens, skipping render');
639
+ }
640
+ }
641
+ } finally {
642
+ cleanupShippedProject();
643
+ }
644
+
645
+ // 4. Exit code: zero only when the shipped project (if any) was valid and
646
+ // every screen rendered.
647
+ writeResult();
648
+ if (buildError !== null) {
649
+ logError('archive validation failed: ' + buildError.code);
650
+ return 1;
651
+ }
652
+ var failed = currentEntries.filter(function (entry) { return entry.status !== 'rendered'; });
653
+ if (failed.length > 0) {
654
+ logError(failed.length + ' of ' + currentEntries.length + ' screen(s) not rendered');
655
+ return 1;
656
+ }
657
+ log('all ' + currentEntries.length + ' screen(s) rendered');
658
+ return 0;
659
+ }
660
+
661
+ var exitCode = 1;
662
+ try {
663
+ exitCode = main();
664
+ } catch (unexpectedError) {
665
+ logError(
666
+ 'unexpected failure: ' +
667
+ (unexpectedError && unexpectedError.stack ? unexpectedError.stack : String(unexpectedError))
668
+ );
669
+ try {
670
+ currentEntries.forEach(function (entry) {
671
+ if (entry.status !== 'rendered' && entry.error === null) {
672
+ setError(
673
+ entry,
674
+ 'INTERNAL_ERROR',
675
+ String(unexpectedError && unexpectedError.message ? unexpectedError.message : unexpectedError)
676
+ );
677
+ }
678
+ });
679
+ writeResult();
680
+ } catch (writeError) {
681
+ logError('could not write protocol-result.json: ' + writeError);
682
+ }
683
+ exitCode = 1;
684
+ }
685
+ process.exit(exitCode);
686
+ `;
687
+ /**
688
+ * Generates the self-contained runtime node script for the step. Pure function
689
+ * of its config — exported so tests can execute the script directly.
690
+ */
691
+ export function generateAndroidRenderScript(config) {
692
+ return [
693
+ "'use strict';",
694
+ '// ui-fidelity-render-android runtime script (generated by cibuild at YAML conversion time)',
695
+ 'var CONFIG = ' + JSON.stringify(config) + ';',
696
+ 'var RENDERER = ' + JSON.stringify(ANDROID_RENDERER) + ';',
697
+ 'var PACKAGE_ARCHIVE_NAME = ' + JSON.stringify(PACKAGE_ARCHIVE_BASENAME) + ';',
698
+ RENDER_SCRIPT_TRIM_STAGE,
699
+ ANDROID_RENDER_SCRIPT_MAIN,
700
+ ].join('\n');
701
+ }
702
+ /**
703
+ * ui-fidelity-render-android step executor. Returns a node-kind StepDef whose
704
+ * script performs the render at runtime on the macOS runner.
705
+ */
706
+ export class UiFidelityRenderAndroidStepExecutor extends BaseStepExecutor {
707
+ getValidationRequirements(_inputs, _env, _config) {
708
+ return [
709
+ this.requireCommand('swift', 'Swift toolchain used to run the CoreGraphics trim/align helper', 'Install Xcode (or the Swift toolchain) on the runner'),
710
+ this.requireCommand('tar', 'Archive tool used to extract the shipped Gradle project', 'Install tar on the runner'),
711
+ ];
712
+ }
713
+ async execute(inputs, _env, _config) {
714
+ const stepName = 'ui-fidelity-render-android';
715
+ const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
716
+ const gradleTask = String(this.getInput(inputs, 'gradle_task', DEFAULT_GRADLE_TASK));
717
+ if (!isValidGradleTask(gradleTask)) {
718
+ throw new Error(`Invalid gradle_task '${gradleTask}' for step '${stepName}': ` +
719
+ 'expected a Gradle task path (letters, digits, and : - _)');
720
+ }
721
+ const config = { scale, gradleTask };
722
+ const script = generateAndroidRenderScript(config);
723
+ return this.createNodeStep(script, stepName);
724
+ }
725
+ }
726
+ //# sourceMappingURL=ui-fidelity-render-android.js.map