@invarn/cibuild 2.0.7 → 2.0.9
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 +688 -69
- package/dist/src/lib.d.ts +4 -0
- package/dist/src/lib.d.ts.map +1 -1
- package/dist/src/lib.js +3 -0
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +22 -0
- package/dist/src/yaml/steps/render-post-processor.d.ts +73 -0
- package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -0
- package/dist/src/yaml/steps/render-post-processor.js +290 -0
- package/dist/src/yaml/steps/render-post-processor.test.d.ts +16 -0
- package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/render-post-processor.test.js +194 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +66 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.js +216 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +13 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +247 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +99 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.js +152 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts +11 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.test.js +168 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +92 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +726 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +2 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +377 -0
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -28
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +1 -259
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +1 -57
- 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
|