@invarn/cibuild 2.2.6 → 2.2.8
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 +133 -9
- package/dist/src/yaml/fidelity-input-digest.d.ts +37 -0
- package/dist/src/yaml/fidelity-input-digest.d.ts.map +1 -0
- package/dist/src/yaml/fidelity-input-digest.js +208 -0
- package/dist/src/yaml/fidelity-input-digest.test.d.ts +2 -0
- package/dist/src/yaml/fidelity-input-digest.test.d.ts.map +1 -0
- package/dist/src/yaml/fidelity-input-digest.test.js +167 -0
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +29 -2
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +38 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +195 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +207 -1
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +21 -0
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +89 -3
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +71 -0
- package/package.json +1 -1
|
@@ -51,11 +51,27 @@ import { BaseStepExecutor } from './base.js';
|
|
|
51
51
|
import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
|
|
52
52
|
import { RENDER_SCRIPT_TRIM_STAGE } from './render-post-processor.js';
|
|
53
53
|
import { RENDER_SCRIPT_FACTS_STAGE, RENDER_SCRIPT_GEOMETRY_STAGE } from './structural-facts.js';
|
|
54
|
+
import { RENDER_SCRIPT_SOURCE_DIGEST_STAGE } from '../fidelity-input-digest.js';
|
|
54
55
|
/**
|
|
55
56
|
* Default Roborazzi record task. Run from the project root so Gradle resolves
|
|
56
57
|
* it in whichever subproject declares it.
|
|
57
58
|
*/
|
|
58
59
|
export const DEFAULT_GRADLE_TASK = 'recordRoborazziDebug';
|
|
60
|
+
/** Valid values for the package_source input. */
|
|
61
|
+
export const PACKAGE_SOURCES = ['repo', 'inputs'];
|
|
62
|
+
/**
|
|
63
|
+
* Default package source: the Gradle project ships as package.tar.gz in the
|
|
64
|
+
* run-inputs directory (the agent-upload path). "repo" instead renders the
|
|
65
|
+
* committed project at package_path, for a triggered (agent-less) run.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_PACKAGE_SOURCE = 'inputs';
|
|
68
|
+
/** Valid values for the reference_source input. */
|
|
69
|
+
export const REFERENCE_SOURCES = ['repo', 'inputs'];
|
|
70
|
+
/**
|
|
71
|
+
* Default reference source: references arrive as run inputs (a plain basename
|
|
72
|
+
* under `.ci/inputs`), matching the agent-upload path.
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_REFERENCE_SOURCE = 'inputs';
|
|
59
75
|
/** Basename of the shipped Gradle-project archive inside the run-inputs dir. */
|
|
60
76
|
export const PACKAGE_ARCHIVE_BASENAME = 'package.tar.gz';
|
|
61
77
|
/** Renderer identifier recorded in protocol-result.json. */
|
|
@@ -532,6 +548,14 @@ function findRoborazziDirs(root) {
|
|
|
532
548
|
// screen — the analog of the iOS probe gate). A screen whose PNG is absent
|
|
533
549
|
// after a successful build is RENDER_FAILED on its own.
|
|
534
550
|
function renderScreens(renderable, projectRoot) {
|
|
551
|
+
// Snapshot the package's input files BEFORE the build runs, so the digest is
|
|
552
|
+
// over the pristine committed source and never perturbed by build outputs.
|
|
553
|
+
var packageFiles = __fidCollectPackageFiles(projectRoot);
|
|
554
|
+
// A package is safely cacheable only when it is hermetic — no Gradle script
|
|
555
|
+
// reaches outside its own root. Then the package IS its whole dependency
|
|
556
|
+
// closure, so the content key cannot miss an external change. A non-hermetic
|
|
557
|
+
// package emits no digest, so a later dispatch never skips it.
|
|
558
|
+
var packageHermetic = __fidIsHermetic(packageFiles);
|
|
535
559
|
var gradle = runGradle(projectRoot, CONFIG.gradleTask);
|
|
536
560
|
if (gradle.status !== 0) {
|
|
537
561
|
var detail = outputTail(gradle);
|
|
@@ -591,6 +615,20 @@ function renderScreens(renderable, projectRoot) {
|
|
|
591
615
|
entry.rendered_image_path = relative;
|
|
592
616
|
entry.status = 'rendered';
|
|
593
617
|
entry.error = null;
|
|
618
|
+
// Content key over the package source + this screen's reference: lets a
|
|
619
|
+
// consumer skip a re-render whose inputs are unchanged. Emitted only for a
|
|
620
|
+
// hermetic package; otherwise omitted so no stale verdict can be replayed.
|
|
621
|
+
if (packageHermetic) {
|
|
622
|
+
var referenceBytes = null;
|
|
623
|
+
try {
|
|
624
|
+
if (entry.reference_image_path) {
|
|
625
|
+
referenceBytes = fs.readFileSync(path.resolve(artifactsDir, entry.reference_image_path));
|
|
626
|
+
}
|
|
627
|
+
} catch (digestReferenceError) {
|
|
628
|
+
referenceBytes = null;
|
|
629
|
+
}
|
|
630
|
+
entry.source_digest = __fidSourceDigest(packageFiles, referenceBytes);
|
|
631
|
+
}
|
|
594
632
|
log('rendered ' + entry.screen + ' -> ' + relative);
|
|
595
633
|
trimRenderedImage(entry, outputPath, workDir);
|
|
596
634
|
// Signal A: the render emits a per-node layout dump into a roborazzi
|
|
@@ -863,11 +901,122 @@ try {
|
|
|
863
901
|
}
|
|
864
902
|
process.exit(exitCode);
|
|
865
903
|
`;
|
|
904
|
+
/**
|
|
905
|
+
* Replaces exactly one occurrence of `anchor` in `source`. Throws when the
|
|
906
|
+
* anchor is missing or ambiguous, so any drift between the default runtime text
|
|
907
|
+
* and a *_source variant fails loudly at module load.
|
|
908
|
+
*/
|
|
909
|
+
function replaceOnce(source, anchor, replacement) {
|
|
910
|
+
const first = source.indexOf(anchor);
|
|
911
|
+
if (first === -1 || source.indexOf(anchor, first + anchor.length) !== -1) {
|
|
912
|
+
throw new Error('ui-fidelity-render-android: expected exactly one occurrence of anchor: ' + anchor);
|
|
913
|
+
}
|
|
914
|
+
return source.slice(0, first) + replacement + source.slice(first + anchor.length);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Builds the runtime main for package_source "repo" by patching the default
|
|
918
|
+
* (inputs) runtime text: the result document records package_source "repo", and
|
|
919
|
+
* the project is resolved from the committed checkout at package_path instead of
|
|
920
|
+
* being extracted from a shipped tarball. ANDROID_RENDER_SCRIPT_MAIN itself is
|
|
921
|
+
* never modified, so omitting package_source keeps the generated script
|
|
922
|
+
* byte-identical to the default. Mirrors the iOS package_source patch.
|
|
923
|
+
*/
|
|
924
|
+
function buildAndroidPackageRepoMain(base) {
|
|
925
|
+
let main = base;
|
|
926
|
+
// The result document advertises the repo package source.
|
|
927
|
+
main = replaceOnce(main, "package_source: 'inputs',", "package_source: 'repo',");
|
|
928
|
+
// Render directly from the committed project at package_path; no archive
|
|
929
|
+
// extract/validate. A missing or non-Gradle directory is a clean per-build
|
|
930
|
+
// error (the same exit-on-buildError path the inputs archive validation uses).
|
|
931
|
+
main = replaceOnce(main, ' // 3. Extract + validate the shipped Gradle project, then render the\n' +
|
|
932
|
+
' // renderable screens. The project stage runs even when no screen is\n' +
|
|
933
|
+
' // renderable, so packaging mistakes are always reported.\n' +
|
|
934
|
+
' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
|
|
935
|
+
' try {\n' +
|
|
936
|
+
' var projectRoot = prepareShippedProject();\n' +
|
|
937
|
+
' if (projectRoot !== null) {\n' +
|
|
938
|
+
' if (renderable.length > 0) {\n' +
|
|
939
|
+
' renderScreens(renderable, projectRoot);\n' +
|
|
940
|
+
' } else if (currentEntries.length > 0) {\n' +
|
|
941
|
+
" log('no renderable screens, skipping render');\n" +
|
|
942
|
+
' }\n' +
|
|
943
|
+
' }\n' +
|
|
944
|
+
' } finally {\n' +
|
|
945
|
+
' cleanupShippedProject();\n' +
|
|
946
|
+
' }', ' // 3. Resolve the committed Gradle project at package_path and render the\n' +
|
|
947
|
+
' // renderable screens directly from the checkout (no archive extract).\n' +
|
|
948
|
+
' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
|
|
949
|
+
' var projectRoot = path.resolve(CONFIG.packagePath);\n' +
|
|
950
|
+
' registerPathReplacement(\n' +
|
|
951
|
+
' projectRoot,\n' +
|
|
952
|
+
" path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
|
|
953
|
+
' );\n' +
|
|
954
|
+
' if (!fs.existsSync(projectRoot)) {\n' +
|
|
955
|
+
" setBuildError('ANDROID_PROJECT_MISSING', 'package_path does not exist: ' + projectRoot);\n" +
|
|
956
|
+
' } else if (!hasSettings(projectRoot)) {\n' +
|
|
957
|
+
' setBuildError(\n' +
|
|
958
|
+
" 'ANDROID_PROJECT_ROOT_MISSING',\n" +
|
|
959
|
+
" 'no settings.gradle(.kts) at the top of package_path: ' + projectRoot\n" +
|
|
960
|
+
' );\n' +
|
|
961
|
+
' } else if (renderable.length > 0) {\n' +
|
|
962
|
+
' renderScreens(renderable, projectRoot);\n' +
|
|
963
|
+
' } else if (currentEntries.length > 0) {\n' +
|
|
964
|
+
" log('no renderable screens, skipping render');\n" +
|
|
965
|
+
' }');
|
|
966
|
+
return main;
|
|
967
|
+
}
|
|
968
|
+
const ANDROID_RENDER_SCRIPT_MAIN_REPO = buildAndroidPackageRepoMain(ANDROID_RENDER_SCRIPT_MAIN);
|
|
969
|
+
/**
|
|
970
|
+
* Patches the reference-sourcing block to read each screen's reference from a
|
|
971
|
+
* repo-relative path (resolved against the checkout) instead of a plain basename
|
|
972
|
+
* under `.ci/inputs/`. Mirrors the iOS reference_source: repo patch. The
|
|
973
|
+
* downstream stat/copy/provenance logic is unchanged; only how `source` is
|
|
974
|
+
* computed differs, so a triggered (agent-less) run can point at a committed
|
|
975
|
+
* reference that lives in the cloned repo.
|
|
976
|
+
*/
|
|
977
|
+
function buildAndroidReferenceRepoMain(base) {
|
|
978
|
+
return replaceOnce(base, 'var basename = params.screens[entry.screen];\n' +
|
|
979
|
+
' if (\n' +
|
|
980
|
+
" typeof basename !== 'string' || basename === '' ||\n" +
|
|
981
|
+
" basename === '.' || basename === '..' ||\n" +
|
|
982
|
+
' basename !== path.basename(basename)\n' +
|
|
983
|
+
' ) {\n' +
|
|
984
|
+
' setError(\n' +
|
|
985
|
+
' entry,\n' +
|
|
986
|
+
" 'REFERENCE_MISSING',\n" +
|
|
987
|
+
" 'reference for ' + entry.screen + ' must be a plain file basename inside ' +\n" +
|
|
988
|
+
" inputsDir + ', got: ' + JSON.stringify(basename)\n" +
|
|
989
|
+
' );\n' +
|
|
990
|
+
' return;\n' +
|
|
991
|
+
' }\n' +
|
|
992
|
+
' var source = path.join(inputsDir, basename);', 'var refPath = params.screens[entry.screen];\n' +
|
|
993
|
+
" if (typeof refPath !== 'string' || refPath === '') {\n" +
|
|
994
|
+
' setError(\n' +
|
|
995
|
+
' entry,\n' +
|
|
996
|
+
" 'REFERENCE_MISSING',\n" +
|
|
997
|
+
" 'reference for ' + entry.screen + ' must be a non-empty repo-relative path, got: ' +\n" +
|
|
998
|
+
' JSON.stringify(refPath)\n' +
|
|
999
|
+
' );\n' +
|
|
1000
|
+
' return;\n' +
|
|
1001
|
+
' }\n' +
|
|
1002
|
+
' var source = path.resolve(refPath);');
|
|
1003
|
+
}
|
|
866
1004
|
/**
|
|
867
1005
|
* Generates the self-contained runtime node script for the step. Pure function
|
|
868
1006
|
* of its config — exported so tests can execute the script directly.
|
|
1007
|
+
*
|
|
1008
|
+
* When config.referenceSource is absent the reference block is byte-identical
|
|
1009
|
+
* to the default (JSON.stringify drops the undefined key, and the unpatched
|
|
1010
|
+
* runtime is used).
|
|
869
1011
|
*/
|
|
870
1012
|
export function generateAndroidRenderScript(config) {
|
|
1013
|
+
let main = config.packageSource === 'repo' ? ANDROID_RENDER_SCRIPT_MAIN_REPO : ANDROID_RENDER_SCRIPT_MAIN;
|
|
1014
|
+
// Reference-from-repo is an independent dimension layered on top of whichever
|
|
1015
|
+
// package base applies. Absent → byte-identical reference block (default
|
|
1016
|
+
// basename behaviour).
|
|
1017
|
+
if (config.referenceSource === 'repo') {
|
|
1018
|
+
main = buildAndroidReferenceRepoMain(main);
|
|
1019
|
+
}
|
|
871
1020
|
return [
|
|
872
1021
|
"'use strict';",
|
|
873
1022
|
'// ui-fidelity-render-android runtime script (generated by cibuild at YAML conversion time)',
|
|
@@ -877,7 +1026,8 @@ export function generateAndroidRenderScript(config) {
|
|
|
877
1026
|
RENDER_SCRIPT_TRIM_STAGE,
|
|
878
1027
|
RENDER_SCRIPT_FACTS_STAGE,
|
|
879
1028
|
RENDER_SCRIPT_GEOMETRY_STAGE,
|
|
880
|
-
|
|
1029
|
+
RENDER_SCRIPT_SOURCE_DIGEST_STAGE,
|
|
1030
|
+
main,
|
|
881
1031
|
].join('\n');
|
|
882
1032
|
}
|
|
883
1033
|
/**
|
|
@@ -899,7 +1049,51 @@ export class UiFidelityRenderAndroidStepExecutor extends BaseStepExecutor {
|
|
|
899
1049
|
throw new Error(`Invalid gradle_task '${gradleTask}' for step '${stepName}': ` +
|
|
900
1050
|
'expected a Gradle task path (letters, digits, and : - _)');
|
|
901
1051
|
}
|
|
1052
|
+
// package_source: validate when provided, but only record (and patch the
|
|
1053
|
+
// script for) the non-default "repo" so omitting it — or setting the default
|
|
1054
|
+
// "inputs" — keeps the default archive-extract script byte-identical. In
|
|
1055
|
+
// repo mode package_path is required (the committed project to render).
|
|
1056
|
+
const rawPackageSource = this.getInput(inputs, 'package_source', undefined);
|
|
1057
|
+
let packageSource;
|
|
1058
|
+
if (rawPackageSource !== undefined) {
|
|
1059
|
+
const value = String(rawPackageSource);
|
|
1060
|
+
if (!PACKAGE_SOURCES.includes(value)) {
|
|
1061
|
+
throw new Error(`Invalid package_source '${value}' for step '${stepName}': ` +
|
|
1062
|
+
`expected one of: ${PACKAGE_SOURCES.join(', ')}`);
|
|
1063
|
+
}
|
|
1064
|
+
if (value === 'repo') {
|
|
1065
|
+
packageSource = 'repo';
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
let packagePath;
|
|
1069
|
+
if (packageSource === 'repo') {
|
|
1070
|
+
packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
|
|
1071
|
+
}
|
|
1072
|
+
// reference_source: validate when provided, but only record (and patch the
|
|
1073
|
+
// script for) the non-default "repo" so omitting it — or setting the default
|
|
1074
|
+
// "inputs" — keeps the default basename reference block intact.
|
|
1075
|
+
const rawReferenceSource = this.getInput(inputs, 'reference_source', undefined);
|
|
1076
|
+
let referenceSource;
|
|
1077
|
+
if (rawReferenceSource !== undefined) {
|
|
1078
|
+
const value = String(rawReferenceSource);
|
|
1079
|
+
if (!REFERENCE_SOURCES.includes(value)) {
|
|
1080
|
+
throw new Error(`Invalid reference_source '${value}' for step '${stepName}': ` +
|
|
1081
|
+
`expected one of: ${REFERENCE_SOURCES.join(', ')}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (value === 'repo') {
|
|
1084
|
+
referenceSource = 'repo';
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
902
1087
|
const config = { scale, gradleTask };
|
|
1088
|
+
if (packageSource !== undefined) {
|
|
1089
|
+
config.packageSource = packageSource;
|
|
1090
|
+
}
|
|
1091
|
+
if (packagePath !== undefined) {
|
|
1092
|
+
config.packagePath = packagePath;
|
|
1093
|
+
}
|
|
1094
|
+
if (referenceSource !== undefined) {
|
|
1095
|
+
config.referenceSource = referenceSource;
|
|
1096
|
+
}
|
|
903
1097
|
const script = generateAndroidRenderScript(config);
|
|
904
1098
|
return this.createNodeStep(script, stepName);
|
|
905
1099
|
}
|
|
@@ -4,7 +4,10 @@ import { gzipSync } from 'node:zlib';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { ANDROID_RENDERER, UiFidelityRenderAndroidStepExecutor, } from './ui-fidelity-render-android.js';
|
|
7
|
+
import { ANDROID_RENDERER, DEFAULT_GRADLE_TASK, UiFidelityRenderAndroidStepExecutor, } from './ui-fidelity-render-android.js';
|
|
8
|
+
import { DEFAULT_SCALE } from './ui-fidelity-render.js';
|
|
9
|
+
import { clearRegistry, getStepMetadata, hasStep } from './registry.js';
|
|
10
|
+
import { initializeStepRegistry } from './index.js';
|
|
8
11
|
import { testConfig } from './test-config.js';
|
|
9
12
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
10
13
|
const tempDirs = [];
|
|
@@ -166,6 +169,48 @@ function runScript(project, script, env = {}) {
|
|
|
166
169
|
function readResult(project) {
|
|
167
170
|
return JSON.parse(readFileSync(join(project.dir, '.ci', 'artifacts', 'protocol-result.json'), 'utf-8'));
|
|
168
171
|
}
|
|
172
|
+
describe('registry integration', () => {
|
|
173
|
+
test('ui-fidelity-render-android is registered with its input schema', () => {
|
|
174
|
+
clearRegistry();
|
|
175
|
+
initializeStepRegistry();
|
|
176
|
+
expect(hasStep('ui-fidelity-render-android')).toBe(true);
|
|
177
|
+
const metadata = getStepMetadata('ui-fidelity-render-android');
|
|
178
|
+
expect(metadata?.platform).toBe('android');
|
|
179
|
+
const inputs = metadata?.inputs ?? {};
|
|
180
|
+
expect(Object.keys(inputs).sort()).toEqual([
|
|
181
|
+
'gradle_task',
|
|
182
|
+
'package_path',
|
|
183
|
+
'package_source',
|
|
184
|
+
'reference_source',
|
|
185
|
+
'scale',
|
|
186
|
+
]);
|
|
187
|
+
expect(inputs.package_source.required).toBe(false);
|
|
188
|
+
expect(inputs.package_source.default).toBe('inputs');
|
|
189
|
+
expect(inputs.reference_source.required).toBe(false);
|
|
190
|
+
expect(inputs.reference_source.default).toBe('inputs');
|
|
191
|
+
// package_path is enforced by the executor only in repo mode; the default
|
|
192
|
+
// (inputs) config is valid without it, so it is not unconditionally required.
|
|
193
|
+
expect(inputs.package_path.required).toBe(false);
|
|
194
|
+
expect(inputs.scale.required).toBe(false);
|
|
195
|
+
expect(inputs.scale.default).toBe(DEFAULT_SCALE);
|
|
196
|
+
expect(inputs.gradle_task.required).toBe(false);
|
|
197
|
+
expect(inputs.gradle_task.default).toBe(DEFAULT_GRADLE_TASK);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('default byte compatibility', () => {
|
|
201
|
+
// Snapshot of the script generated for the default inputs (no package_source,
|
|
202
|
+
// no reference_source), captured from the executor with everything else at its
|
|
203
|
+
// default. Omitting both *_source inputs must keep the generated script
|
|
204
|
+
// byte-identical to this snapshot, so existing protocols are provably
|
|
205
|
+
// untouched. Update the snapshot only for a deliberate, reviewed change to the
|
|
206
|
+
// default runtime.
|
|
207
|
+
const DEFAULT_SCRIPT_SNAPSHOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-render-android-v1-script.txt');
|
|
208
|
+
test('omitting package_source and reference_source generates a byte-identical script', async () => {
|
|
209
|
+
const executor = new UiFidelityRenderAndroidStepExecutor();
|
|
210
|
+
const step = await executor.execute({}, {}, testConfig);
|
|
211
|
+
expect(step.script).toBe(readFileSync(DEFAULT_SCRIPT_SNAPSHOT, 'utf-8'));
|
|
212
|
+
});
|
|
213
|
+
});
|
|
169
214
|
describe('UiFidelityRenderAndroidStepExecutor', () => {
|
|
170
215
|
test('returns a node StepDef named ui-fidelity-render-android', async () => {
|
|
171
216
|
const executor = new UiFidelityRenderAndroidStepExecutor();
|
|
@@ -315,6 +360,34 @@ describe('android render runtime — render + trim (happy path)', () => {
|
|
|
315
360
|
],
|
|
316
361
|
});
|
|
317
362
|
});
|
|
363
|
+
test('emits a source_digest content key on a rendered screen entry', async () => {
|
|
364
|
+
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
365
|
+
writeReference(project, 'x.png');
|
|
366
|
+
stageArchive(project, gradleProjectEntries());
|
|
367
|
+
const run = runScript(project, await buildScript(), {
|
|
368
|
+
FAKE_ROBORAZZI_SCREENS: 'XProof',
|
|
369
|
+
});
|
|
370
|
+
expect(run.status).toBe(0);
|
|
371
|
+
const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
|
|
372
|
+
expect(screen?.status).toBe('rendered');
|
|
373
|
+
expect(screen?.source_digest).toMatch(/^[0-9a-f]{64}$/);
|
|
374
|
+
});
|
|
375
|
+
test('omits source_digest for a non-hermetic package (Gradle script escapes the root)', async () => {
|
|
376
|
+
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
377
|
+
writeReference(project, 'x.png');
|
|
378
|
+
// A settings script that pulls in a sibling build via a parent path — the
|
|
379
|
+
// package is no longer its own dependency closure, so it must not be
|
|
380
|
+
// cacheable: it still renders, but emits no content key.
|
|
381
|
+
const entries = gradleProjectEntries().map((e) => e.name.endsWith('settings.gradle.kts')
|
|
382
|
+
? { ...e, content: 'rootProject.name = "p"\ninclude(":proof")\nincludeBuild("../design-system")\n' }
|
|
383
|
+
: e);
|
|
384
|
+
stageArchive(project, entries);
|
|
385
|
+
const run = runScript(project, await buildScript(), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
|
|
386
|
+
expect(run.status).toBe(0);
|
|
387
|
+
const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
|
|
388
|
+
expect(screen?.status).toBe('rendered');
|
|
389
|
+
expect(screen?.source_digest == null).toBe(true);
|
|
390
|
+
});
|
|
318
391
|
test('assembles the inline structural block (facts) onto the screen entry', async () => {
|
|
319
392
|
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
320
393
|
writeReference(project, 'x.png');
|
|
@@ -541,6 +614,139 @@ describe('android render runtime — error isolation', () => {
|
|
|
541
614
|
expect(y?.error?.code).toBe('RENDER_FAILED');
|
|
542
615
|
});
|
|
543
616
|
});
|
|
617
|
+
/** Materialize a well-formed Gradle render project (fake gradlew) on disk. */
|
|
618
|
+
function writeGradleProjectDir(dir) {
|
|
619
|
+
mkdirSync(join(dir, 'proof'), { recursive: true });
|
|
620
|
+
writeFileSync(join(dir, 'settings.gradle.kts'), 'rootProject.name = "p"\ninclude(":proof")\n');
|
|
621
|
+
const gradlew = join(dir, 'gradlew');
|
|
622
|
+
writeFileSync(gradlew, FAKE_GRADLEW_LINES.join('\n') + '\n');
|
|
623
|
+
chmodSync(gradlew, 0o755);
|
|
624
|
+
writeFileSync(join(dir, 'proof', 'build.gradle.kts'), '// module\n');
|
|
625
|
+
}
|
|
626
|
+
describe('package_source: repo', () => {
|
|
627
|
+
test('rejects an unknown package_source value', async () => {
|
|
628
|
+
const executor = new UiFidelityRenderAndroidStepExecutor();
|
|
629
|
+
await expect(executor.execute({ package_source: 'cloud' }, {}, testConfig)).rejects.toThrow(/package_source/);
|
|
630
|
+
});
|
|
631
|
+
test('package_source repo requires package_path', async () => {
|
|
632
|
+
const executor = new UiFidelityRenderAndroidStepExecutor();
|
|
633
|
+
await expect(executor.execute({ package_source: 'repo' }, {}, testConfig)).rejects.toThrow(/package_path/);
|
|
634
|
+
});
|
|
635
|
+
test('omitting package_source bakes no packageSource/packagePath into the config', async () => {
|
|
636
|
+
const script = await buildScript();
|
|
637
|
+
const configLine = script.split('\n').find((l) => l.startsWith('var CONFIG = '));
|
|
638
|
+
expect(configLine).toBeDefined();
|
|
639
|
+
expect(configLine).not.toContain('packageSource');
|
|
640
|
+
expect(configLine).not.toContain('packagePath');
|
|
641
|
+
});
|
|
642
|
+
test('renders the checked-out Gradle project at package_path (no archive)', async () => {
|
|
643
|
+
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
644
|
+
writeReference(project, 'x.png');
|
|
645
|
+
// The committed project lives at a repo-relative path; no package.tar.gz.
|
|
646
|
+
writeGradleProjectDir(join(project.dir, 'android-app'));
|
|
647
|
+
const run = runScript(project, await buildScript({ package_source: 'repo', package_path: 'android-app' }), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
|
|
648
|
+
expect(run.status).toBe(0);
|
|
649
|
+
const doc = readResult(project);
|
|
650
|
+
expect(doc.package_source).toBe('repo');
|
|
651
|
+
expect(doc.error).toBeNull();
|
|
652
|
+
const screen = doc.screens.find((s) => s.screen === 'XProof');
|
|
653
|
+
expect(screen?.status).toBe('rendered');
|
|
654
|
+
expect(screen?.rendered_image_path).toBe('ui-fidelity/rendered/XProof.png');
|
|
655
|
+
// The render came from the checkout, not an extracted tarball.
|
|
656
|
+
expect(existsSync(join(project.dir, '.ci', 'inputs', 'package.tar.gz'))).toBe(false);
|
|
657
|
+
});
|
|
658
|
+
test('a missing package_path directory fails cleanly with a per-build error', async () => {
|
|
659
|
+
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
660
|
+
writeReference(project, 'x.png');
|
|
661
|
+
// No project at the path.
|
|
662
|
+
const run = runScript(project, await buildScript({ package_source: 'repo', package_path: 'nope' }), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
|
|
663
|
+
expect(run.status).toBe(1);
|
|
664
|
+
const doc = readResult(project);
|
|
665
|
+
expect(doc.package_source).toBe('repo');
|
|
666
|
+
expect(doc.error?.code).toBe('ANDROID_PROJECT_MISSING');
|
|
667
|
+
});
|
|
668
|
+
test('a package_path without a settings script fails cleanly', async () => {
|
|
669
|
+
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
670
|
+
writeReference(project, 'x.png');
|
|
671
|
+
// A directory that is not a Gradle project root.
|
|
672
|
+
mkdirSync(join(project.dir, 'not-a-project'), { recursive: true });
|
|
673
|
+
writeFileSync(join(project.dir, 'not-a-project', 'README.md'), '# nope\n');
|
|
674
|
+
const run = runScript(project, await buildScript({ package_source: 'repo', package_path: 'not-a-project' }), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
|
|
675
|
+
expect(run.status).toBe(1);
|
|
676
|
+
expect(readResult(project).error?.code).toBe('ANDROID_PROJECT_ROOT_MISSING');
|
|
677
|
+
});
|
|
678
|
+
test('composes with reference_source repo (project + reference both from the checkout)', async () => {
|
|
679
|
+
const project = makeProject({ screens: { XProof: 'refs/x.png' } });
|
|
680
|
+
mkdirSync(join(project.dir, 'refs'), { recursive: true });
|
|
681
|
+
writeFileSync(join(project.dir, 'refs', 'x.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:x')]));
|
|
682
|
+
writeGradleProjectDir(join(project.dir, 'android-app'));
|
|
683
|
+
const run = runScript(project, await buildScript({
|
|
684
|
+
package_source: 'repo',
|
|
685
|
+
package_path: 'android-app',
|
|
686
|
+
reference_source: 'repo',
|
|
687
|
+
}), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
|
|
688
|
+
expect(run.status).toBe(0);
|
|
689
|
+
const doc = readResult(project);
|
|
690
|
+
expect(doc.package_source).toBe('repo');
|
|
691
|
+
const screen = doc.screens.find((s) => s.screen === 'XProof');
|
|
692
|
+
expect(screen?.status).toBe('rendered');
|
|
693
|
+
expect(screen?.reference_image_path).toBe('ui-fidelity/references/XProof.png');
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
describe('reference_source: repo', () => {
|
|
697
|
+
test('rejects an unknown reference_source value', async () => {
|
|
698
|
+
const executor = new UiFidelityRenderAndroidStepExecutor();
|
|
699
|
+
await expect(executor.execute({ reference_source: 'artifact' }, {}, testConfig)).rejects.toThrow(/reference_source/);
|
|
700
|
+
});
|
|
701
|
+
test('omitting reference_source bakes no referenceSource into the script config', async () => {
|
|
702
|
+
const script = await buildScript();
|
|
703
|
+
const configLine = script.split('\n').find((l) => l.startsWith('var CONFIG = '));
|
|
704
|
+
expect(configLine).toBeDefined();
|
|
705
|
+
expect(configLine).not.toContain('referenceSource');
|
|
706
|
+
});
|
|
707
|
+
test('reads each screen reference from its repo-relative path in the checkout', async () => {
|
|
708
|
+
const project = makeProject({
|
|
709
|
+
screens: { XProof: 'refs/x.png', YProof: 'design/y.png' },
|
|
710
|
+
});
|
|
711
|
+
// Committed references live at repo-relative paths (resolved against cwd),
|
|
712
|
+
// NOT in .ci/inputs.
|
|
713
|
+
mkdirSync(join(project.dir, 'refs'), { recursive: true });
|
|
714
|
+
mkdirSync(join(project.dir, 'design'), { recursive: true });
|
|
715
|
+
writeFileSync(join(project.dir, 'refs', 'x.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:x')]));
|
|
716
|
+
writeFileSync(join(project.dir, 'design', 'y.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:y')]));
|
|
717
|
+
stageArchive(project, gradleProjectEntries());
|
|
718
|
+
const run = runScript(project, await buildScript({ reference_source: 'repo' }), {
|
|
719
|
+
FAKE_ROBORAZZI_SCREENS: 'XProof,YProof',
|
|
720
|
+
});
|
|
721
|
+
expect(run.status).toBe(0);
|
|
722
|
+
const doc = readResult(project);
|
|
723
|
+
expect(doc.screens.map((s) => s.status)).toEqual(['rendered', 'rendered']);
|
|
724
|
+
expect(doc.screens.map((s) => s.reference_image_path)).toEqual([
|
|
725
|
+
'ui-fidelity/references/XProof.png',
|
|
726
|
+
'ui-fidelity/references/YProof.png',
|
|
727
|
+
]);
|
|
728
|
+
expect(isPng(artifact(project, 'ui-fidelity/references/XProof.png'))).toBe(true);
|
|
729
|
+
expect(isPng(artifact(project, 'ui-fidelity/references/YProof.png'))).toBe(true);
|
|
730
|
+
});
|
|
731
|
+
test('fails one screen when its committed reference is missing, renders the rest', async () => {
|
|
732
|
+
const project = makeProject({
|
|
733
|
+
screens: { XProof: 'refs/x.png', GhostProof: 'refs/ghost.png' },
|
|
734
|
+
});
|
|
735
|
+
mkdirSync(join(project.dir, 'refs'), { recursive: true });
|
|
736
|
+
writeFileSync(join(project.dir, 'refs', 'x.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:x')]));
|
|
737
|
+
// refs/ghost.png intentionally absent.
|
|
738
|
+
stageArchive(project, gradleProjectEntries());
|
|
739
|
+
const run = runScript(project, await buildScript({ reference_source: 'repo' }), {
|
|
740
|
+
FAKE_ROBORAZZI_SCREENS: 'XProof',
|
|
741
|
+
});
|
|
742
|
+
expect(run.status).not.toBe(0);
|
|
743
|
+
const doc = readResult(project);
|
|
744
|
+
expect(doc.screens.find((s) => s.screen === 'XProof')?.status).toBe('rendered');
|
|
745
|
+
const ghost = doc.screens.find((s) => s.screen === 'GhostProof');
|
|
746
|
+
expect(ghost?.status).toBe('render_failed');
|
|
747
|
+
expect(ghost?.error?.code).toBe('REFERENCE_MISSING');
|
|
748
|
+
});
|
|
749
|
+
});
|
|
544
750
|
// The fake gradlew/swift cannot exercise the real Roborazzi/Robolectric render
|
|
545
751
|
// or the CoreGraphics trim. This end-to-end guard ships the committed Roborazzi
|
|
546
752
|
// fixture as the render package and runs the whole step with the real
|
|
@@ -75,6 +75,16 @@ export interface UiFidelityRenderInputs {
|
|
|
75
75
|
* product.
|
|
76
76
|
*/
|
|
77
77
|
target?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Where each screen's reference image comes from (default "inputs"):
|
|
80
|
+
* - "inputs" (default / v1): `params.screens[<Screen>]` is a plain file
|
|
81
|
+
* basename read from `.ci/inputs/<basename>` (an agent-uploaded run input).
|
|
82
|
+
* - "repo": `params.screens[<Screen>]` is a repo-relative path to a COMMITTED
|
|
83
|
+
* reference image, resolved against the checkout. Lets a triggered
|
|
84
|
+
* (agent-less) run point at a reference that lives in the cloned repo.
|
|
85
|
+
* Omitting reference_source generates a byte-identical v1 script.
|
|
86
|
+
*/
|
|
87
|
+
reference_source?: 'repo' | 'inputs';
|
|
78
88
|
/** Render size in device points, formatted "<width>x<height>" (default 393x852) */
|
|
79
89
|
render_size?: string;
|
|
80
90
|
/** Display scale factor applied to the render (default 2) */
|
|
@@ -94,6 +104,11 @@ export declare const PACKAGE_SOURCES: readonly ["repo", "inputs"];
|
|
|
94
104
|
export type PackageSource = (typeof PACKAGE_SOURCES)[number];
|
|
95
105
|
/** Default package source: the package lives in the repo checkout. */
|
|
96
106
|
export declare const DEFAULT_PACKAGE_SOURCE: PackageSource;
|
|
107
|
+
/** Valid values for the reference_source input. */
|
|
108
|
+
export declare const REFERENCE_SOURCES: readonly ["repo", "inputs"];
|
|
109
|
+
export type ReferenceSource = (typeof REFERENCE_SOURCES)[number];
|
|
110
|
+
/** Default reference source: references arrive as run inputs (v1 behaviour). */
|
|
111
|
+
export declare const DEFAULT_REFERENCE_SOURCE: ReferenceSource;
|
|
97
112
|
/** Basename of the shipped package archive inside the run-inputs directory. */
|
|
98
113
|
export declare const PACKAGE_ARCHIVE_BASENAME = "package.tar.gz";
|
|
99
114
|
/**
|
|
@@ -140,6 +155,12 @@ export interface RenderScriptConfig {
|
|
|
140
155
|
height: number;
|
|
141
156
|
scale: number;
|
|
142
157
|
packageSource?: PackageSource;
|
|
158
|
+
/**
|
|
159
|
+
* Only present (and only ever "repo") when reference_source was explicitly set
|
|
160
|
+
* to "repo". When absent, references are read from `.ci/inputs/<basename>` and
|
|
161
|
+
* the generated reference-sourcing code is byte-identical to v1.
|
|
162
|
+
*/
|
|
163
|
+
referenceSource?: ReferenceSource;
|
|
143
164
|
}
|
|
144
165
|
/** Options consumed by the pure Swift-source generators. */
|
|
145
166
|
export interface HarnessGeneratorOptions {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"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"}
|