@invarn/cibuild 2.2.7 → 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/ui-fidelity-render-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +24 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +28 -0
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +23 -0
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface PackageFile {
|
|
2
|
+
/** POSIX, package-relative path. Part of the digest so a rename is a change. */
|
|
3
|
+
path: string;
|
|
4
|
+
/** File contents. */
|
|
5
|
+
bytes: Buffer;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Compute the input digest for one render package: its source files plus the
|
|
9
|
+
* reference image the screen is judged against. Files are folded in sorted by
|
|
10
|
+
* path so iteration order can't affect the result; each is folded in as its git
|
|
11
|
+
* blob id (see the module header) so the digest is reproducible from a Git Trees
|
|
12
|
+
* listing with no blob content. The path is length-prefixed and the blob id is
|
|
13
|
+
* fixed-width, so boundaries are unambiguous.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sourceDigest(packageFiles: PackageFile[], referenceBytes: Buffer | null): string;
|
|
16
|
+
/**
|
|
17
|
+
* True when a render package is self-contained — no Gradle script reaches
|
|
18
|
+
* outside the package root. Render packages are hermetic by construction; this
|
|
19
|
+
* guard fails a non-hermetic package OUT of the cacheable set (its digest is not
|
|
20
|
+
* emitted / not trusted for skip) so a change in an external module it depends
|
|
21
|
+
* on can never replay a stale verdict. Pure: a function of the package files
|
|
22
|
+
* only, so a dispatch-time caller can apply the identical check.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isHermeticPackage(packageFiles: PackageFile[]): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Runtime twin of {@link sourceDigest}, spliced into the self-contained render
|
|
27
|
+
* script that runs on the macOS runner (which cannot import this module). It
|
|
28
|
+
* walks the built package directory, reads the paired reference, and computes
|
|
29
|
+
* the identical digest — a parity test pins it to `sourceDigest` so the twin
|
|
30
|
+
* cannot drift.
|
|
31
|
+
*
|
|
32
|
+
* Exposes `computeSourceDigest(projectRoot, referencePath)`. Build outputs and
|
|
33
|
+
* VCS/tooling dirs are excluded so they never perturb the content key. Reads
|
|
34
|
+
* are best-effort: an unreadable reference folds in as "absent", never throws.
|
|
35
|
+
*/
|
|
36
|
+
export declare const RENDER_SCRIPT_SOURCE_DIGEST_STAGE: string;
|
|
37
|
+
//# sourceMappingURL=fidelity-input-digest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fidelity-input-digest.d.ts","sourceRoot":"","sources":["../../../src/yaml/fidelity-input-digest.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AAuBD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,YAAY,EAAE,WAAW,EAAE,EAC3B,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,MAAM,CAoBR;AAwBD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,WAAW,EAAE,GAAG,OAAO,CAOtE;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAiF/C,CAAC"}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic per-package input digest.
|
|
3
|
+
*
|
|
4
|
+
* A content key over a render package's inputs — the committed wrapper source
|
|
5
|
+
* files plus the paired reference image — so a consumer can tell when a render
|
|
6
|
+
* would be byte-identical to one already produced and skip re-rendering. Pure:
|
|
7
|
+
* a function of the input bytes only, never of absolute paths, timestamps, or
|
|
8
|
+
* commit ids, so the same inputs yield the same digest on any machine or
|
|
9
|
+
* branch.
|
|
10
|
+
*
|
|
11
|
+
* Scope: package-local inputs. Render packages are self-contained by
|
|
12
|
+
* construction — a parameterless wrapper that reproduces one screen from
|
|
13
|
+
* hardcoded fixtures, shipped with every dependency version pinned inline and
|
|
14
|
+
* its own assets vendored — so the package IS its own dependency closure and a
|
|
15
|
+
* package-local content key already invalidates on anything that can shift the
|
|
16
|
+
* render. (A package that escapes its own root — an `includeBuild`, an external
|
|
17
|
+
* `project(":…")` dependency, or a shared version catalog — would break that
|
|
18
|
+
* property; such a package is not safely cacheable and is caught by a separate
|
|
19
|
+
* hermeticity guard rather than folded into this digest.)
|
|
20
|
+
*
|
|
21
|
+
* Digest basis: each file is folded in as its **git blob id**
|
|
22
|
+
* (`sha1("blob " + byteLength + "\0" + content)`) rather than its raw bytes.
|
|
23
|
+
* That is the exact id the GitHub Git Trees API returns for a committed file, so
|
|
24
|
+
* a caller holding only a tree listing — a webhook dispatch with no checkout —
|
|
25
|
+
* can reproduce this digest from `(path, blobSha)` pairs WITHOUT fetching any
|
|
26
|
+
* blob content. The framing is: sha256 over, for each file sorted by path,
|
|
27
|
+
* `len(path) ‖ path ‖ blobSha(content)`, then the reference folded last as
|
|
28
|
+
* `blobSha(reference)` (or a presence-distinct `len(0xffffffff)` marker when
|
|
29
|
+
* there is no reference). `blobSha` is a fixed 20 bytes, so file boundaries stay
|
|
30
|
+
* unambiguous with only the path length-prefixed.
|
|
31
|
+
*/
|
|
32
|
+
import { createHash } from 'crypto';
|
|
33
|
+
/** 4-byte big-endian length prefix, so concatenated fields can't alias
|
|
34
|
+
* (`["ab","c"]` must not hash equal to `["a","bc"]`). */
|
|
35
|
+
function len(n) {
|
|
36
|
+
const b = Buffer.allocUnsafe(4);
|
|
37
|
+
b.writeUInt32BE(n >>> 0, 0);
|
|
38
|
+
return b;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Git blob object id for a file's content: `sha1("blob " + len + "\0" + bytes)`.
|
|
42
|
+
* The identical id the GitHub Git Trees API returns for the committed blob, so a
|
|
43
|
+
* dispatch-time caller can read it from a tree listing instead of hashing bytes
|
|
44
|
+
* it never fetched. Returned as 20 raw bytes (folded straight into the digest).
|
|
45
|
+
*/
|
|
46
|
+
function gitBlobSha(bytes) {
|
|
47
|
+
const h = createHash('sha1');
|
|
48
|
+
h.update(`blob ${bytes.length}\0`);
|
|
49
|
+
h.update(bytes);
|
|
50
|
+
return h.digest();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Compute the input digest for one render package: its source files plus the
|
|
54
|
+
* reference image the screen is judged against. Files are folded in sorted by
|
|
55
|
+
* path so iteration order can't affect the result; each is folded in as its git
|
|
56
|
+
* blob id (see the module header) so the digest is reproducible from a Git Trees
|
|
57
|
+
* listing with no blob content. The path is length-prefixed and the blob id is
|
|
58
|
+
* fixed-width, so boundaries are unambiguous.
|
|
59
|
+
*/
|
|
60
|
+
export function sourceDigest(packageFiles, referenceBytes) {
|
|
61
|
+
const sorted = [...packageFiles].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
62
|
+
const h = createHash('sha256');
|
|
63
|
+
for (const file of sorted) {
|
|
64
|
+
const pathBytes = Buffer.from(file.path, 'utf8');
|
|
65
|
+
h.update(len(pathBytes.length));
|
|
66
|
+
h.update(pathBytes);
|
|
67
|
+
h.update(gitBlobSha(file.bytes));
|
|
68
|
+
}
|
|
69
|
+
// Reference is folded in as its blob id, with a presence-distinct marker so
|
|
70
|
+
// "no reference" differs from "empty reference".
|
|
71
|
+
if (referenceBytes === null) {
|
|
72
|
+
h.update(len(0xffffffff));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
h.update(gitBlobSha(referenceBytes));
|
|
76
|
+
}
|
|
77
|
+
return h.digest('hex');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A Gradle build script — the only files that can reach outside the package
|
|
81
|
+
* root (source files and assets can mention `..` in prose without affecting the
|
|
82
|
+
* build graph).
|
|
83
|
+
*/
|
|
84
|
+
function isGradleScript(path) {
|
|
85
|
+
return path.endsWith('.gradle') || path.endsWith('.gradle.kts');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Tokens by which a Gradle script reaches outside its own package root and so
|
|
89
|
+
* breaks the "package is its own dependency closure" invariant this digest
|
|
90
|
+
* relies on:
|
|
91
|
+
* - `includeBuild` — a composite/included build (another whole build).
|
|
92
|
+
* - `projectDir` — a module relocated to a directory outside the layout.
|
|
93
|
+
* - `../` — any parent-path reference (external file, script, or
|
|
94
|
+
* version catalog).
|
|
95
|
+
* Biased toward over-detection: a false positive only forces a re-render (safe),
|
|
96
|
+
* whereas a miss would let a shared external change replay a stale verdict.
|
|
97
|
+
*/
|
|
98
|
+
const GRADLE_ESCAPE = /includeBuild|projectDir|\.\.\//;
|
|
99
|
+
/**
|
|
100
|
+
* True when a render package is self-contained — no Gradle script reaches
|
|
101
|
+
* outside the package root. Render packages are hermetic by construction; this
|
|
102
|
+
* guard fails a non-hermetic package OUT of the cacheable set (its digest is not
|
|
103
|
+
* emitted / not trusted for skip) so a change in an external module it depends
|
|
104
|
+
* on can never replay a stale verdict. Pure: a function of the package files
|
|
105
|
+
* only, so a dispatch-time caller can apply the identical check.
|
|
106
|
+
*/
|
|
107
|
+
export function isHermeticPackage(packageFiles) {
|
|
108
|
+
for (const file of packageFiles) {
|
|
109
|
+
if (isGradleScript(file.path) && GRADLE_ESCAPE.test(file.bytes.toString('utf8'))) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Runtime twin of {@link sourceDigest}, spliced into the self-contained render
|
|
117
|
+
* script that runs on the macOS runner (which cannot import this module). It
|
|
118
|
+
* walks the built package directory, reads the paired reference, and computes
|
|
119
|
+
* the identical digest — a parity test pins it to `sourceDigest` so the twin
|
|
120
|
+
* cannot drift.
|
|
121
|
+
*
|
|
122
|
+
* Exposes `computeSourceDigest(projectRoot, referencePath)`. Build outputs and
|
|
123
|
+
* VCS/tooling dirs are excluded so they never perturb the content key. Reads
|
|
124
|
+
* are best-effort: an unreadable reference folds in as "absent", never throws.
|
|
125
|
+
*/
|
|
126
|
+
export const RENDER_SCRIPT_SOURCE_DIGEST_STAGE = String.raw `
|
|
127
|
+
// ---- source-digest stage: deterministic package input content key ----
|
|
128
|
+
function __fidLen(n) {
|
|
129
|
+
var b = Buffer.allocUnsafe(4);
|
|
130
|
+
b.writeUInt32BE(n >>> 0, 0);
|
|
131
|
+
return b;
|
|
132
|
+
}
|
|
133
|
+
var __FID_SKIP_DIRS = { build: 1, '.gradle': 1, '.git': 1, '.idea': 1, 'node_modules': 1, DerivedData: 1 };
|
|
134
|
+
function __fidCollectPackageFiles(root) {
|
|
135
|
+
var fs = require('fs');
|
|
136
|
+
var path = require('path');
|
|
137
|
+
var out = [];
|
|
138
|
+
function walk(dir, rel) {
|
|
139
|
+
var names;
|
|
140
|
+
try { names = fs.readdirSync(dir); } catch (e) { return; }
|
|
141
|
+
names.sort();
|
|
142
|
+
for (var i = 0; i < names.length; i++) {
|
|
143
|
+
var name = names[i];
|
|
144
|
+
var full = path.join(dir, name);
|
|
145
|
+
var relPath = rel ? rel + '/' + name : name;
|
|
146
|
+
var st;
|
|
147
|
+
try { st = fs.statSync(full); } catch (e) { continue; }
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
if (__FID_SKIP_DIRS[name]) continue;
|
|
150
|
+
walk(full, relPath);
|
|
151
|
+
} else if (st.isFile()) {
|
|
152
|
+
out.push({ path: relPath, bytes: fs.readFileSync(full) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
walk(root, '');
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function __fidBlobSha(bytes) {
|
|
160
|
+
var crypto = require('crypto');
|
|
161
|
+
var h = crypto.createHash('sha1');
|
|
162
|
+
h.update('blob ' + bytes.length + '\0');
|
|
163
|
+
h.update(bytes);
|
|
164
|
+
return h.digest();
|
|
165
|
+
}
|
|
166
|
+
function __fidSourceDigest(files, referenceBytes) {
|
|
167
|
+
var crypto = require('crypto');
|
|
168
|
+
var sorted = files.slice().sort(function (a, b) {
|
|
169
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
170
|
+
});
|
|
171
|
+
var h = crypto.createHash('sha256');
|
|
172
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
173
|
+
var pathBytes = Buffer.from(sorted[i].path, 'utf8');
|
|
174
|
+
h.update(__fidLen(pathBytes.length));
|
|
175
|
+
h.update(pathBytes);
|
|
176
|
+
h.update(__fidBlobSha(sorted[i].bytes));
|
|
177
|
+
}
|
|
178
|
+
if (referenceBytes === null || referenceBytes === undefined) {
|
|
179
|
+
h.update(__fidLen(0xffffffff));
|
|
180
|
+
} else {
|
|
181
|
+
h.update(__fidBlobSha(referenceBytes));
|
|
182
|
+
}
|
|
183
|
+
return h.digest('hex');
|
|
184
|
+
}
|
|
185
|
+
function computeSourceDigest(projectRoot, referencePath) {
|
|
186
|
+
var fs = require('fs');
|
|
187
|
+
var files = __fidCollectPackageFiles(projectRoot);
|
|
188
|
+
var ref = null;
|
|
189
|
+
try { if (referencePath) ref = fs.readFileSync(referencePath); } catch (e) { ref = null; }
|
|
190
|
+
return __fidSourceDigest(files, ref);
|
|
191
|
+
}
|
|
192
|
+
// Twin of isHermeticPackage: a package is cacheable only when no Gradle script
|
|
193
|
+
// reaches outside its own root (includeBuild / projectDir / a parent path).
|
|
194
|
+
var __FID_GRADLE_ESCAPE = /includeBuild|projectDir|\.\.\//;
|
|
195
|
+
function __fidIsGradleScript(p) {
|
|
196
|
+
return p.slice(-7) === '.gradle' || p.slice(-11) === '.gradle.kts';
|
|
197
|
+
}
|
|
198
|
+
function __fidIsHermetic(files) {
|
|
199
|
+
for (var i = 0; i < files.length; i++) {
|
|
200
|
+
if (__fidIsGradleScript(files[i].path) &&
|
|
201
|
+
__FID_GRADLE_ESCAPE.test(files[i].bytes.toString('utf8'))) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
//# sourceMappingURL=fidelity-input-digest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fidelity-input-digest.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/fidelity-input-digest.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the deterministic per-package input digest.
|
|
3
|
+
*
|
|
4
|
+
* The digest is the content key the dashboard matches on to decide whether a
|
|
5
|
+
* render would be identical to one it already has — so it must be a pure
|
|
6
|
+
* function of the package's input bytes (wrapper source + reference), stable
|
|
7
|
+
* across machines/branches, and never derived from paths-as-location, mtimes,
|
|
8
|
+
* or commit ids.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from '@jest/globals';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import { sourceDigest, isHermeticPackage, RENDER_SCRIPT_SOURCE_DIGEST_STAGE, } from './fidelity-input-digest.js';
|
|
14
|
+
function f(path, content) {
|
|
15
|
+
return { path, bytes: Buffer.from(content, 'utf8') };
|
|
16
|
+
}
|
|
17
|
+
/** Git blob object id: sha1('blob <len>\0<content>') — the SAME id the GitHub
|
|
18
|
+
* Git Trees API returns for a committed file. The digest is defined over these
|
|
19
|
+
* ids so a caller with only a tree listing (no blob content) can reproduce it. */
|
|
20
|
+
function gitBlobSha(bytes) {
|
|
21
|
+
const h = createHash('sha1');
|
|
22
|
+
h.update(`blob ${bytes.length}\0`);
|
|
23
|
+
h.update(bytes);
|
|
24
|
+
return h.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
/** 4-byte big-endian length prefix — mirrors the module-private `len`. */
|
|
27
|
+
function len(n) {
|
|
28
|
+
const b = Buffer.allocUnsafe(4);
|
|
29
|
+
b.writeUInt32BE(n >>> 0, 0);
|
|
30
|
+
return b;
|
|
31
|
+
}
|
|
32
|
+
/** Extract the runner-side twin from the spliced stage source so a parity test
|
|
33
|
+
* can pin it to the pure function. `require` is injected because the stage
|
|
34
|
+
* runs in the runner's CJS scope, not this ESM test module. */
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const twin = new Function('require', RENDER_SCRIPT_SOURCE_DIGEST_STAGE +
|
|
37
|
+
'\nreturn { __fidSourceDigest: __fidSourceDigest, __fidIsHermetic: __fidIsHermetic };')(require);
|
|
38
|
+
describe('sourceDigest', () => {
|
|
39
|
+
test('is deterministic: identical inputs produce the same 64-hex digest', () => {
|
|
40
|
+
const files = [f('Proof.kt', 'fun Proof() {}'), f('build.gradle.kts', 'plugins {}')];
|
|
41
|
+
const ref = Buffer.from('reference-image-bytes');
|
|
42
|
+
const first = sourceDigest(files, ref);
|
|
43
|
+
expect(first).toMatch(/^[0-9a-f]{64}$/);
|
|
44
|
+
expect(sourceDigest(files, ref)).toBe(first);
|
|
45
|
+
});
|
|
46
|
+
test('changes when any package file byte changes', () => {
|
|
47
|
+
const ref = Buffer.from('ref');
|
|
48
|
+
const before = sourceDigest([f('Proof.kt', 'fun Proof() {}')], ref);
|
|
49
|
+
const after = sourceDigest([f('Proof.kt', 'fun Proof() { Box() }')], ref);
|
|
50
|
+
expect(after).not.toBe(before);
|
|
51
|
+
});
|
|
52
|
+
test('changes when only the reference image changes', () => {
|
|
53
|
+
const files = [f('Proof.kt', 'fun Proof() {}')];
|
|
54
|
+
const withA = sourceDigest(files, Buffer.from('reference-A'));
|
|
55
|
+
const withB = sourceDigest(files, Buffer.from('reference-B'));
|
|
56
|
+
expect(withB).not.toBe(withA);
|
|
57
|
+
});
|
|
58
|
+
test('is independent of file iteration order', () => {
|
|
59
|
+
const ref = Buffer.from('ref');
|
|
60
|
+
const a = f('a.kt', 'A');
|
|
61
|
+
const b = f('b.kt', 'B');
|
|
62
|
+
expect(sourceDigest([a, b], ref)).toBe(sourceDigest([b, a], ref));
|
|
63
|
+
});
|
|
64
|
+
test('is unambiguous across file boundaries (path/content cannot alias)', () => {
|
|
65
|
+
const ref = Buffer.from('ref');
|
|
66
|
+
// Same concatenated bytes, different split: must not collide.
|
|
67
|
+
const split = sourceDigest([f('ab', 'x'), f('', 'y')], ref);
|
|
68
|
+
const joined = sourceDigest([f('a', 'bxy')], ref);
|
|
69
|
+
expect(split).not.toBe(joined);
|
|
70
|
+
});
|
|
71
|
+
test('is reconstructible from git blob shas alone (dashboard tree-API parity)', () => {
|
|
72
|
+
// The dashboard computes this SAME digest at webhook time from a Git Trees
|
|
73
|
+
// API listing — which carries each committed file's blob sha but NOT its
|
|
74
|
+
// content. Pin the exact framing so that reconstruction (path + blob sha,
|
|
75
|
+
// no bytes) reproduces the digest byte-for-byte.
|
|
76
|
+
const files = [f('b.kt', 'B'), f('a.kt', 'A')];
|
|
77
|
+
const ref = Buffer.from('reference-image-bytes');
|
|
78
|
+
const sorted = [...files].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
|
79
|
+
const h = createHash('sha256');
|
|
80
|
+
for (const file of sorted) {
|
|
81
|
+
const p = Buffer.from(file.path, 'utf8');
|
|
82
|
+
h.update(len(p.length));
|
|
83
|
+
h.update(p);
|
|
84
|
+
h.update(Buffer.from(gitBlobSha(file.bytes), 'hex'));
|
|
85
|
+
}
|
|
86
|
+
h.update(Buffer.from(gitBlobSha(ref), 'hex'));
|
|
87
|
+
expect(sourceDigest(files, ref)).toBe(h.digest('hex'));
|
|
88
|
+
});
|
|
89
|
+
test('matches the cross-consumer golden vector (pins the wire format)', () => {
|
|
90
|
+
// A fixed known-answer vector shared verbatim with the dashboard's
|
|
91
|
+
// dispatch-time digest, so the two implementations cannot drift apart
|
|
92
|
+
// silently. If this changes, the dashboard golden must change in lockstep.
|
|
93
|
+
const files = [f('a.kt', 'A'), f('proof/build.gradle.kts', 'plugins {}')];
|
|
94
|
+
expect(sourceDigest(files, Buffer.from('ref-bytes'))).toBe('7fc57f481eb011299d34824e0a506345d15e96946e345beac882f26adab47374');
|
|
95
|
+
expect(sourceDigest(files, null)).toBe('2596ecea7f591c23f9397707a92b008dabedbd1fd4acc0efb2440849e97ddd3c');
|
|
96
|
+
});
|
|
97
|
+
test('folds a null reference in with a presence-distinct marker (not a blob sha)', () => {
|
|
98
|
+
const files = [f('a.kt', 'A')];
|
|
99
|
+
const sorted = files;
|
|
100
|
+
const h = createHash('sha256');
|
|
101
|
+
for (const file of sorted) {
|
|
102
|
+
const p = Buffer.from(file.path, 'utf8');
|
|
103
|
+
h.update(len(p.length));
|
|
104
|
+
h.update(p);
|
|
105
|
+
h.update(Buffer.from(gitBlobSha(file.bytes), 'hex'));
|
|
106
|
+
}
|
|
107
|
+
h.update(len(0xffffffff));
|
|
108
|
+
expect(sourceDigest(files, null)).toBe(h.digest('hex'));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('isHermeticPackage', () => {
|
|
112
|
+
const hermetic = [
|
|
113
|
+
f('settings.gradle.kts', 'rootProject.name = "p"\ninclude(":proof")\n'),
|
|
114
|
+
f('build.gradle.kts', 'plugins { id("com.android.library") version "8.13.2" }'),
|
|
115
|
+
f('proof/build.gradle.kts', 'dependencies { implementation("androidx.compose.ui:ui") }'),
|
|
116
|
+
f('proof/src/main/kotlin/Proof.kt', 'fun Proof() {}'),
|
|
117
|
+
];
|
|
118
|
+
test('accepts a self-contained package with everything pinned inline', () => {
|
|
119
|
+
expect(isHermeticPackage(hermetic)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test('rejects a composite build (includeBuild reaches another build)', () => {
|
|
122
|
+
const files = [...hermetic, f('settings.gradle.kts', 'includeBuild("../design-system")')];
|
|
123
|
+
expect(isHermeticPackage(files)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
test('rejects a module relocated outside the root via projectDir', () => {
|
|
126
|
+
const files = [
|
|
127
|
+
f('settings.gradle.kts', 'include(":ds")\nproject(":ds").projectDir = file("../ds")'),
|
|
128
|
+
];
|
|
129
|
+
expect(isHermeticPackage(files)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
test('rejects a parent-path escape (external file / version catalog)', () => {
|
|
132
|
+
const files = [
|
|
133
|
+
f('settings.gradle.kts', 'versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } }'),
|
|
134
|
+
];
|
|
135
|
+
expect(isHermeticPackage(files)).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
test('ignores escape tokens outside Gradle scripts (only build scripts can escape)', () => {
|
|
138
|
+
// A source file or asset mentioning "../" is not a build-graph escape.
|
|
139
|
+
const files = [
|
|
140
|
+
f('settings.gradle.kts', 'include(":proof")'),
|
|
141
|
+
f('proof/src/main/kotlin/Doc.kt', '// see ../design-system for the original'),
|
|
142
|
+
];
|
|
143
|
+
expect(isHermeticPackage(files)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('runtime twin parity', () => {
|
|
147
|
+
const files = [f('Proof.kt', 'fun Proof() { Box() }'), f('build.gradle.kts', 'plugins {}')];
|
|
148
|
+
test('the spliced runner-side digest matches the pure sourceDigest (with reference)', () => {
|
|
149
|
+
const ref = Buffer.from('reference-image-bytes');
|
|
150
|
+
expect(twin.__fidSourceDigest(files, ref)).toBe(sourceDigest(files, ref));
|
|
151
|
+
});
|
|
152
|
+
test('the spliced runner-side digest matches the pure sourceDigest (no reference)', () => {
|
|
153
|
+
expect(twin.__fidSourceDigest(files, null)).toBe(sourceDigest(files, null));
|
|
154
|
+
});
|
|
155
|
+
test('the spliced runner-side hermeticity check matches isHermeticPackage', () => {
|
|
156
|
+
const hermetic = [
|
|
157
|
+
f('settings.gradle.kts', 'include(":proof")'),
|
|
158
|
+
f('proof/src/main/kotlin/Doc.kt', '// mentions ../elsewhere harmlessly'),
|
|
159
|
+
];
|
|
160
|
+
const escaping = [f('settings.gradle', 'includeBuild("../other")')];
|
|
161
|
+
expect(twin.__fidIsHermetic(hermetic)).toBe(isHermeticPackage(hermetic));
|
|
162
|
+
expect(twin.__fidIsHermetic(escaping)).toBe(isHermeticPackage(escaping));
|
|
163
|
+
expect(twin.__fidIsHermetic(hermetic)).toBe(true);
|
|
164
|
+
expect(twin.__fidIsHermetic(escaping)).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
//# sourceMappingURL=fidelity-input-digest.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui-fidelity-render-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;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-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;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;AAMpE,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAAwB,CAAC;AAE9D,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,6BAA8B,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,eAA0B,CAAC;AAElE,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,8EAA8E;AAC9E,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AA26BD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAqBrF;AAED;;;GAGG;AACH,qBAAa,mCAAoC,SAAQ,gBAAgB;IACvE,yBAAyB,CACvB,OAAO,EAAE,6BAA6B,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAepB,OAAO,CACX,MAAM,EAAE,6BAA6B,EACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CA0EpB"}
|
|
@@ -51,6 +51,7 @@ 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.
|
|
@@ -547,6 +548,14 @@ function findRoborazziDirs(root) {
|
|
|
547
548
|
// screen — the analog of the iOS probe gate). A screen whose PNG is absent
|
|
548
549
|
// after a successful build is RENDER_FAILED on its own.
|
|
549
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);
|
|
550
559
|
var gradle = runGradle(projectRoot, CONFIG.gradleTask);
|
|
551
560
|
if (gradle.status !== 0) {
|
|
552
561
|
var detail = outputTail(gradle);
|
|
@@ -606,6 +615,20 @@ function renderScreens(renderable, projectRoot) {
|
|
|
606
615
|
entry.rendered_image_path = relative;
|
|
607
616
|
entry.status = 'rendered';
|
|
608
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
|
+
}
|
|
609
632
|
log('rendered ' + entry.screen + ' -> ' + relative);
|
|
610
633
|
trimRenderedImage(entry, outputPath, workDir);
|
|
611
634
|
// Signal A: the render emits a per-node layout dump into a roborazzi
|
|
@@ -1003,6 +1026,7 @@ export function generateAndroidRenderScript(config) {
|
|
|
1003
1026
|
RENDER_SCRIPT_TRIM_STAGE,
|
|
1004
1027
|
RENDER_SCRIPT_FACTS_STAGE,
|
|
1005
1028
|
RENDER_SCRIPT_GEOMETRY_STAGE,
|
|
1029
|
+
RENDER_SCRIPT_SOURCE_DIGEST_STAGE,
|
|
1006
1030
|
main,
|
|
1007
1031
|
].join('\n');
|
|
1008
1032
|
}
|
|
@@ -360,6 +360,34 @@ describe('android render runtime — render + trim (happy path)', () => {
|
|
|
360
360
|
],
|
|
361
361
|
});
|
|
362
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
|
+
});
|
|
363
391
|
test('assembles the inline structural block (facts) onto the screen entry', async () => {
|
|
364
392
|
const project = makeProject({ screens: { XProof: 'x.png' } });
|
|
365
393
|
writeReference(project, 'x.png');
|
|
@@ -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":"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;AAIpE;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IACnC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IACrC,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C,mFAAmF;AACnF,eAAO,MAAM,aAAa,IAAI,CAAC;AAE/B,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D,sEAAsE;AACtE,eAAO,MAAM,sBAAsB,EAAE,aAAsB,CAAC;AAE5D,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,6BAA8B,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,EAAE,eAA0B,CAAC;AAElE,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,QAAmB,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAWzD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAQzD;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED,4DAA4D;AAC5D,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IACzF,kBAAkB,CAAC,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IAC7D,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;CAC/E;AAmnCD;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,qBAAqB,CAShE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAkBvE;AAED;;;;GAIG;AACH,qBAAa,4BAA6B,SAAQ,gBAAgB;IAChE,yBAAyB,CACvB,MAAM,EAAE,sBAAsB,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAoCpB,OAAO,CACX,MAAM,EAAE,sBAAsB,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CAmFpB"}
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { BaseStepExecutor } from './base.js';
|
|
52
52
|
import { RENDER_SCRIPT_TRIM_STAGE } from './render-post-processor.js';
|
|
53
|
+
import { RENDER_SCRIPT_SOURCE_DIGEST_STAGE } from '../fidelity-input-digest.js';
|
|
53
54
|
/**
|
|
54
55
|
* Default render size in device points. 393x852 is the iPhone-class portrait
|
|
55
56
|
* canvas shared by the iPhone 14 Pro / 15 / 16 — the most common modern
|
|
@@ -339,6 +340,13 @@ function runSwift(args) {
|
|
|
339
340
|
}
|
|
340
341
|
|
|
341
342
|
function renderWithHarness(renderable, packagePath) {
|
|
343
|
+
// Snapshot the user package's input files BEFORE any Swift build, so build
|
|
344
|
+
// products (.build, DerivedData) never perturb the content key. A package is
|
|
345
|
+
// safely cacheable only when it is hermetic — its own files are its whole
|
|
346
|
+
// dependency closure; a non-hermetic one emits no digest and so is never
|
|
347
|
+
// skipped by a consumer.
|
|
348
|
+
var packageFiles = __fidCollectPackageFiles(packagePath);
|
|
349
|
+
var packageHermetic = __fidIsHermetic(packageFiles);
|
|
342
350
|
var harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-harness-'));
|
|
343
351
|
registerPathReplacement(harnessDir, '<harness>');
|
|
344
352
|
log('harness package: ' + harnessDir);
|
|
@@ -420,6 +428,20 @@ function renderWithHarness(renderable, packagePath) {
|
|
|
420
428
|
entry.rendered_image_path = relative;
|
|
421
429
|
entry.status = 'rendered';
|
|
422
430
|
entry.error = null;
|
|
431
|
+
// Content key over the package source + this screen's reference: lets a
|
|
432
|
+
// consumer skip a re-render whose inputs are unchanged. Emitted only for a
|
|
433
|
+
// hermetic package; otherwise omitted so no stale verdict can be replayed.
|
|
434
|
+
if (packageHermetic) {
|
|
435
|
+
var referenceBytes = null;
|
|
436
|
+
try {
|
|
437
|
+
if (entry.reference_image_path) {
|
|
438
|
+
referenceBytes = fs.readFileSync(path.resolve(artifactsDir, entry.reference_image_path));
|
|
439
|
+
}
|
|
440
|
+
} catch (digestReferenceError) {
|
|
441
|
+
referenceBytes = null;
|
|
442
|
+
}
|
|
443
|
+
entry.source_digest = __fidSourceDigest(packageFiles, referenceBytes);
|
|
444
|
+
}
|
|
423
445
|
log('rendered ' + entry.screen + ' -> ' + relative);
|
|
424
446
|
});
|
|
425
447
|
} finally {
|
|
@@ -1185,6 +1207,7 @@ export function generateRenderScript(config) {
|
|
|
1185
1207
|
'// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
|
|
1186
1208
|
'var CONFIG = ' + JSON.stringify(config) + ';',
|
|
1187
1209
|
RENDER_SCRIPT_GENERATORS,
|
|
1210
|
+
RENDER_SCRIPT_SOURCE_DIGEST_STAGE,
|
|
1188
1211
|
main,
|
|
1189
1212
|
].join('\n');
|
|
1190
1213
|
}
|
|
@@ -531,6 +531,7 @@ describe('render script runtime', () => {
|
|
|
531
531
|
error: null,
|
|
532
532
|
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
533
533
|
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
534
|
+
source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
534
535
|
},
|
|
535
536
|
{
|
|
536
537
|
screen: 'SettingsView',
|
|
@@ -538,6 +539,7 @@ describe('render script runtime', () => {
|
|
|
538
539
|
error: null,
|
|
539
540
|
reference_image_path: 'ui-fidelity/references/SettingsView.png',
|
|
540
541
|
rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
|
|
542
|
+
source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
541
543
|
},
|
|
542
544
|
],
|
|
543
545
|
});
|
|
@@ -647,6 +649,17 @@ describe('render script runtime', () => {
|
|
|
647
649
|
expect(settings.error).toBeNull();
|
|
648
650
|
expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
|
|
649
651
|
});
|
|
652
|
+
test('emits a source_digest content key on a rendered screen entry', async () => {
|
|
653
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
654
|
+
writeReference(project, 'home.png');
|
|
655
|
+
// A committed package file so the digest is over real package source.
|
|
656
|
+
writeFileSync(join(project.packageDir, 'Package.swift'), '// swift-tools-version:5.9\nimport PackageDescription\n');
|
|
657
|
+
const run = runScript(project, await buildScript(project));
|
|
658
|
+
expect(run.status).toBe(0);
|
|
659
|
+
const home = readResult(project).screens[0];
|
|
660
|
+
expect(home.status).toBe('rendered');
|
|
661
|
+
expect(home.source_digest).toMatch(/^[0-9a-f]{64}$/);
|
|
662
|
+
});
|
|
650
663
|
test('a reference that is a directory fails only that screen', async () => {
|
|
651
664
|
const project = makeProject({
|
|
652
665
|
screens: { HomeView: 'home-dir.png', SettingsView: 'settings.png' },
|
|
@@ -916,6 +929,7 @@ describe('render script runtime (package_source: repo)', () => {
|
|
|
916
929
|
error: null,
|
|
917
930
|
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
918
931
|
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
932
|
+
source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
919
933
|
aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
|
|
920
934
|
dimensions: {
|
|
921
935
|
rendered_px: [320, 640],
|
|
@@ -1095,6 +1109,7 @@ describe('render script runtime (package_source: inputs)', () => {
|
|
|
1095
1109
|
error: null,
|
|
1096
1110
|
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
1097
1111
|
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
1112
|
+
source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
1098
1113
|
aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
|
|
1099
1114
|
dimensions: {
|
|
1100
1115
|
rendered_px: [320, 640],
|
|
@@ -1108,6 +1123,7 @@ describe('render script runtime (package_source: inputs)', () => {
|
|
|
1108
1123
|
error: null,
|
|
1109
1124
|
reference_image_path: 'ui-fidelity/references/SettingsView.png',
|
|
1110
1125
|
rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
|
|
1126
|
+
source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
1111
1127
|
aligned_image_path: 'ui-fidelity/aligned/SettingsView.png',
|
|
1112
1128
|
dimensions: {
|
|
1113
1129
|
rendered_px: [320, 640],
|