@invarn/cibuild 2.0.9 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli.cjs +86 -8
  2. package/dist/src/lib.d.ts +2 -0
  3. package/dist/src/lib.d.ts.map +1 -1
  4. package/dist/src/lib.js +2 -0
  5. package/dist/src/yaml/fidelity-manifest.d.ts +126 -0
  6. package/dist/src/yaml/fidelity-manifest.d.ts.map +1 -0
  7. package/dist/src/yaml/fidelity-manifest.js +134 -0
  8. package/dist/src/yaml/fidelity-manifest.test.d.ts +12 -0
  9. package/dist/src/yaml/fidelity-manifest.test.d.ts.map +1 -0
  10. package/dist/src/yaml/fidelity-manifest.test.js +251 -0
  11. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts +53 -0
  12. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts.map +1 -0
  13. package/dist/src/yaml/steps/fidelity-aspect-guard.js +72 -0
  14. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts +11 -0
  15. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts.map +1 -0
  16. package/dist/src/yaml/steps/fidelity-aspect-guard.test.js +50 -0
  17. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts +45 -0
  18. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts.map +1 -0
  19. package/dist/src/yaml/steps/fidelity-reference-provenance.js +69 -0
  20. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts +14 -0
  21. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts.map +1 -0
  22. package/dist/src/yaml/steps/fidelity-reference-provenance.test.js +54 -0
  23. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -1
  24. package/dist/src/yaml/steps/render-post-processor.js +19 -6
  25. package/dist/src/yaml/steps/render-post-processor.test.js +46 -2
  26. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -1
  27. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +42 -16
  28. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +53 -1
  29. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +26 -0
  30. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -1
  31. package/dist/src/yaml/steps/ui-fidelity-preview.js +21 -9
  32. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +31 -1
  33. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +9 -0
  34. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
  35. package/dist/src/yaml/steps/ui-fidelity-render-android.js +88 -1
  36. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +98 -0
  37. package/package.json +1 -1
@@ -189,8 +189,13 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
189
189
  test('reports a clear toolchain-missing build error instead of crashing', () => {
190
190
  const savedHome = process.env.ANDROID_HOME;
191
191
  const savedRoot = process.env.ANDROID_SDK_ROOT;
192
+ const savedUserHome = process.env.HOME;
192
193
  delete process.env.ANDROID_HOME;
193
194
  delete process.env.ANDROID_SDK_ROOT;
195
+ // Point HOME at an empty dir so the default-location auto-detect
196
+ // (~/Library/Android/sdk, ~/Android/Sdk) finds nothing — otherwise a dev
197
+ // machine with the SDK installed would resolve one and this guard wouldn't fire.
198
+ process.env.HOME = tempDir('ui-fidelity-android-emptyhome-');
194
199
  try {
195
200
  const result = renderUiFidelityPreviewAndroid({
196
201
  packagePath: gradleProject(),
@@ -211,6 +216,52 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
211
216
  delete process.env.ANDROID_SDK_ROOT;
212
217
  else
213
218
  process.env.ANDROID_SDK_ROOT = savedRoot;
219
+ if (savedUserHome === undefined)
220
+ delete process.env.HOME;
221
+ else
222
+ process.env.HOME = savedUserHome;
223
+ }
224
+ });
225
+ test('auto-detects the SDK at the default install location when ANDROID_HOME is unset', () => {
226
+ const savedHome = process.env.ANDROID_HOME;
227
+ const savedRoot = process.env.ANDROID_SDK_ROOT;
228
+ const savedUserHome = process.env.HOME;
229
+ const savedScreens = process.env.FAKE_ROBORAZZI_SCREENS;
230
+ delete process.env.ANDROID_HOME;
231
+ delete process.env.ANDROID_SDK_ROOT;
232
+ // A clean HOME with an SDK at the macOS default location, but no ANDROID_HOME.
233
+ const fakeHome = tempDir('ui-fidelity-android-home-');
234
+ mkdirSync(join(fakeHome, 'Library', 'Android', 'sdk'), { recursive: true });
235
+ process.env.HOME = fakeHome;
236
+ process.env.FAKE_ROBORAZZI_SCREENS = 'XProof';
237
+ try {
238
+ const result = renderUiFidelityPreviewAndroid({
239
+ packagePath: gradleProject(),
240
+ screens: [{ screen: 'XProof', reference: writeReference() }],
241
+ toolchainPath: fakeToolchain(),
242
+ });
243
+ // Auto-detected the SDK and rendered, rather than bailing with TOOLCHAIN_MISSING.
244
+ expect(result.buildError).toBeNull();
245
+ expect(result.ok).toBe(true);
246
+ expect(result.screens[0]?.status).toBe('rendered');
247
+ }
248
+ finally {
249
+ if (savedHome === undefined)
250
+ delete process.env.ANDROID_HOME;
251
+ else
252
+ process.env.ANDROID_HOME = savedHome;
253
+ if (savedRoot === undefined)
254
+ delete process.env.ANDROID_SDK_ROOT;
255
+ else
256
+ process.env.ANDROID_SDK_ROOT = savedRoot;
257
+ if (savedUserHome === undefined)
258
+ delete process.env.HOME;
259
+ else
260
+ process.env.HOME = savedUserHome;
261
+ if (savedScreens === undefined)
262
+ delete process.env.FAKE_ROBORAZZI_SCREENS;
263
+ else
264
+ process.env.FAKE_ROBORAZZI_SCREENS = savedScreens;
214
265
  }
215
266
  });
216
267
  });
@@ -218,7 +269,8 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
218
269
  // toolchain and asserts a non-blank paired render at the expected size. Requires
219
270
  // the Android toolchain (JDK + SDK) and warm Roborazzi caches; gated behind
220
271
  // CIBUILD_UI_FIDELITY_REAL_ANDROID=1 (the Android analog of the iOS real-Swift
221
- // gate). It needs ANDROID_HOME set on the dev machine.
272
+ // gate). ANDROID_HOME need not be exported a standard SDK install at the
273
+ // default location (~/Library/Android/sdk etc.) is auto-detected.
222
274
  describe('renderUiFidelityPreviewAndroid — real toolchain', () => {
223
275
  const realAndroidTest = process.env.CIBUILD_UI_FIDELITY_REAL_ANDROID === '1' ? test : test.skip;
224
276
  // A real, decodable 1x1 PNG so the trim helper's reference load/align succeeds.
@@ -73,6 +73,12 @@ export interface UiFidelityPreviewScreenResult {
73
73
  /** Absolute path to the reference-aligned image, or null. */
74
74
  alignedImagePath: string | null;
75
75
  dimensions: UiFidelityPreviewDimensions | null;
76
+ /**
77
+ * Advisory warning (never a failure) when the rendered aspect ratio diverges
78
+ * sharply from the reference's — the early signal that several states were
79
+ * stacked into one composable. Null when in tolerance or not comparable.
80
+ */
81
+ aspectWarning: string | null;
76
82
  }
77
83
  export interface UiFidelityPreviewResult {
78
84
  /** Always "inputs": the preview mirrors a shipped-package remote run. */
@@ -89,6 +95,26 @@ export interface UiFidelityPreviewResult {
89
95
  /** True only when every screen rendered and there was no build error. */
90
96
  ok: boolean;
91
97
  }
98
+ /** Shape of the protocol-result.json the render script writes. */
99
+ /** One screen as the render script writes it into protocol-result.json. */
100
+ export interface RawPreviewScreen {
101
+ screen: string;
102
+ status: string;
103
+ error: {
104
+ code: string;
105
+ message: string;
106
+ } | null;
107
+ reference_image_path: string | null;
108
+ rendered_image_path: string | null;
109
+ aligned_image_path?: string | null;
110
+ dimensions?: UiFidelityPreviewDimensions | null;
111
+ }
112
+ /**
113
+ * Map one raw render-script screen to a preview result, resolving image paths
114
+ * and attaching the aspect-ratio warning. Shared by the iOS and Android preview
115
+ * engines so the two paths produce identical result shapes and warnings.
116
+ */
117
+ export declare function buildPreviewScreenResult(raw: RawPreviewScreen, artifactsDir: string): UiFidelityPreviewScreenResult;
92
118
  /**
93
119
  * Render the requested screens locally and return the produced image paths and
94
120
  * dimensions. Throws only on caller errors (bad options) or when the render
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-preview.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAwBH,+EAA+E;AAC/E,MAAM,WAAW,uBAAuB;IACtC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAChD,iEAAiE;IACjE,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,4DAA4D;IAC5D,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,6DAA6D;IAC7D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,EAAE,2BAA2B,GAAG,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,aAAa,EAAE,QAAQ,CAAC;IACxB,oEAAoE;IACpE,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACrD,wDAAwD;IACxD,OAAO,EAAE,6BAA6B,EAAE,CAAC;IACzC,mEAAmE;IACnE,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,EAAE,EAAE,OAAO,CAAC;CACb;AAkCD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,wBAAwB,GAChC,uBAAuB,CAoIzB"}
1
+ {"version":3,"file":"ui-fidelity-preview.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AA4BH,+EAA+E;AAC/E,MAAM,WAAW,uBAAuB;IACtC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAChD,iEAAiE;IACjE,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,4DAA4D;IAC5D,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,6DAA6D;IAC7D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,EAAE,2BAA2B,GAAG,IAAI,CAAC;IAC/C;;;;OAIG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,aAAa,EAAE,QAAQ,CAAC;IACxB,oEAAoE;IACpE,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACrD,wDAAwD;IACxD,OAAO,EAAE,6BAA6B,EAAE,CAAC;IACzC,mEAAmE;IACnE,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,EAAE,EAAE,OAAO,CAAC;CACb;AAED,kEAAkE;AAClE,2EAA2E;AAC3E,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAChD,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,UAAU,CAAC,EAAE,2BAA2B,GAAG,IAAI,CAAC;CACjD;AAuBD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,gBAAgB,EACrB,YAAY,EAAE,MAAM,GACnB,6BAA6B,CAgB/B;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,wBAAwB,GAChC,uBAAuB,CA8HzB"}
@@ -20,6 +20,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSyn
20
20
  import { tmpdir } from 'node:os';
21
21
  import { basename, join, resolve } from 'node:path';
22
22
  import { DEFAULT_RENDER_SIZE, DEFAULT_SCALE, generateRenderScript, parseRenderSize, parseScale, } from './ui-fidelity-render.js';
23
+ import { evaluateAspectRatio, formatAspectRatioWarning, } from './fidelity-aspect-guard.js';
23
24
  function runTar(args, pathPrepend) {
24
25
  const env = pathPrepend
25
26
  ? { ...process.env, PATH: pathPrepend + ':' + (process.env.PATH ?? '') }
@@ -34,6 +35,25 @@ function runTar(args, pathPrepend) {
34
35
  function absOrNull(artifactsDir, relative) {
35
36
  return typeof relative === 'string' && relative !== '' ? resolve(artifactsDir, relative) : null;
36
37
  }
38
+ /**
39
+ * Map one raw render-script screen to a preview result, resolving image paths
40
+ * and attaching the aspect-ratio warning. Shared by the iOS and Android preview
41
+ * engines so the two paths produce identical result shapes and warnings.
42
+ */
43
+ export function buildPreviewScreenResult(raw, artifactsDir) {
44
+ const dimensions = raw.dimensions ?? null;
45
+ const aspect = evaluateAspectRatio(dimensions?.rendered_px ?? null, dimensions?.reference_px ?? null);
46
+ return {
47
+ screen: raw.screen,
48
+ status: raw.status,
49
+ error: raw.error ?? null,
50
+ referenceImagePath: absOrNull(artifactsDir, raw.reference_image_path),
51
+ renderedImagePath: absOrNull(artifactsDir, raw.rendered_image_path),
52
+ alignedImagePath: absOrNull(artifactsDir, raw.aligned_image_path),
53
+ dimensions,
54
+ aspectWarning: formatAspectRatioWarning(raw.screen, aspect),
55
+ };
56
+ }
37
57
  /**
38
58
  * Render the requested screens locally and return the produced image paths and
39
59
  * dimensions. Throws only on caller errors (bad options) or when the render
@@ -136,15 +156,7 @@ export function renderUiFidelityPreview(options) {
136
156
  (detail ? `:\n${detail}` : ''));
137
157
  }
138
158
  const raw = JSON.parse(readFileSync(resultPath, 'utf-8'));
139
- const screens = (raw.screens || []).map((s) => ({
140
- screen: s.screen,
141
- status: s.status,
142
- error: s.error ?? null,
143
- referenceImagePath: absOrNull(artifactsDir, s.reference_image_path),
144
- renderedImagePath: absOrNull(artifactsDir, s.rendered_image_path),
145
- alignedImagePath: absOrNull(artifactsDir, s.aligned_image_path),
146
- dimensions: s.dimensions ?? null,
147
- }));
159
+ const screens = (raw.screens || []).map((s) => buildPreviewScreenResult(s, artifactsDir));
148
160
  const buildError = raw.error ?? null;
149
161
  const ok = buildError === null && screens.length > 0 && screens.every((s) => s.status === 'rendered');
150
162
  return { packageSource: 'inputs', buildError, screens, artifactsDir, ok };
@@ -12,7 +12,7 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync
12
12
  import { tmpdir } from 'node:os';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { renderUiFidelityPreview } from './ui-fidelity-preview.js';
15
+ import { renderUiFidelityPreview, buildPreviewScreenResult, } from './ui-fidelity-preview.js';
16
16
  const FIXTURE_PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../test/fixtures/ui-fidelity-package');
17
17
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
18
18
  // A real, decodable 1x1 transparent PNG, used as the reference for the
@@ -100,6 +100,36 @@ describe('renderUiFidelityPreview — option validation', () => {
100
100
  })).toThrow(/reference for HomeView not found/);
101
101
  });
102
102
  });
103
+ describe('buildPreviewScreenResult — aspect-ratio warning wiring', () => {
104
+ function rawScreen(rendered, reference) {
105
+ return {
106
+ screen: 'PromoBanner',
107
+ status: 'rendered',
108
+ error: null,
109
+ reference_image_path: 'references/PromoBanner.png',
110
+ rendered_image_path: 'rendered/PromoBanner.png',
111
+ aligned_image_path: 'aligned/PromoBanner.png',
112
+ dimensions: {
113
+ rendered_px: rendered,
114
+ logical_pt: [rendered[0] / 2, rendered[1] / 2],
115
+ reference_px: reference,
116
+ },
117
+ };
118
+ }
119
+ test('attaches a warning when a stacked render diverges from its reference', () => {
120
+ const result = buildPreviewScreenResult(rawScreen([880, 400], [952, 200]), '/tmp/art');
121
+ expect(result.aspectWarning).toMatch(/divergence/);
122
+ expect(result.aspectWarning).toContain('PromoBanner');
123
+ });
124
+ test('leaves no warning when the render matches the reference aspect ratio', () => {
125
+ const result = buildPreviewScreenResult(rawScreen([720, 1280], [360, 640]), '/tmp/art');
126
+ expect(result.aspectWarning).toBeNull();
127
+ });
128
+ test('leaves no warning when there is no reference to compare', () => {
129
+ const result = buildPreviewScreenResult(rawScreen([720, 1280], null), '/tmp/art');
130
+ expect(result.aspectWarning).toBeNull();
131
+ });
132
+ });
103
133
  describe('renderUiFidelityPreview — fake toolchain', () => {
104
134
  test('archives the package, renders in inputs mode, and returns image paths + dims', () => {
105
135
  const result = renderUiFidelityPreview({
@@ -9,6 +9,15 @@
9
9
  * 1. Reads `.ci/inputs/params.json`
10
10
  * ({ "screens": { "<ScreenName>": "<referenceFileBasename>" } });
11
11
  * reference images live at `.ci/inputs/<basename>`.
12
+ * Optional reference-provenance sidecar (manifest-driven multi-state path):
13
+ * `"render_locale": "<locale>"` plus
14
+ * `"provenance": { "<ScreenName>": { figmaNode, locale, resolved, exportedAt } }`.
15
+ * When `render_locale` is present, each screen's reference is gated on its
16
+ * provenance METADATA (never pixels/OCR): absent, unprovenanced,
17
+ * `resolved == false`, or `locale != render_locale` → status
18
+ * `unverifiable` for that screen, so a rotten reference fails loud instead
19
+ * of yielding a confident-but-false PASS. Content differences (copy,
20
+ * geometry) are NOT gated here — they stay soft, judged findings.
12
21
  * 2. Extracts and validates the shipped Gradle project shipped as
13
22
  * `.ci/inputs/package.tar.gz` (single project root carrying a settings
14
23
  * script, bounded extracted size, safe member paths).
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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,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,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;CACpB;AAqnBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAUrF;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;CAgBpB"}
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;AAIpE,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,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;CACpB;AAmsBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAUrF;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;CAgBpB"}
@@ -9,6 +9,15 @@
9
9
  * 1. Reads `.ci/inputs/params.json`
10
10
  * ({ "screens": { "<ScreenName>": "<referenceFileBasename>" } });
11
11
  * reference images live at `.ci/inputs/<basename>`.
12
+ * Optional reference-provenance sidecar (manifest-driven multi-state path):
13
+ * `"render_locale": "<locale>"` plus
14
+ * `"provenance": { "<ScreenName>": { figmaNode, locale, resolved, exportedAt } }`.
15
+ * When `render_locale` is present, each screen's reference is gated on its
16
+ * provenance METADATA (never pixels/OCR): absent, unprovenanced,
17
+ * `resolved == false`, or `locale != render_locale` → status
18
+ * `unverifiable` for that screen, so a rotten reference fails loud instead
19
+ * of yielding a confident-but-false PASS. Content differences (copy,
20
+ * geometry) are NOT gated here — they stay soft, judged findings.
12
21
  * 2. Extracts and validates the shipped Gradle project shipped as
13
22
  * `.ci/inputs/package.tar.gz` (single project root carrying a settings
14
23
  * script, bounded extracted size, safe member paths).
@@ -136,6 +145,54 @@ function setError(entry, code, message) {
136
145
  entry.error = { code: code, message: sanitizeMessage(message) };
137
146
  }
138
147
 
148
+ // A reference the gate cannot honestly judge: a hard, mechanical metadata
149
+ // verdict (never pixels/OCR). Distinct status so a rotten reference fails loud
150
+ // instead of producing a confident-but-false PASS.
151
+ function setUnverifiable(entry, code, message) {
152
+ entry.status = 'unverifiable';
153
+ entry.error = { code: code, message: sanitizeMessage(message) };
154
+ }
155
+
156
+ // Inlined twin of src/yaml/steps/fidelity-reference-provenance.ts (this script
157
+ // ships self-contained and cannot import it). The render-step integration tests
158
+ // exercise BOTH against the same fixtures so they cannot silently drift. Returns
159
+ // a { code, message } fault, or null when the reference is verifiable.
160
+ function provenanceFault(screen, provenance, renderLocale, referenceExists) {
161
+ var hasProvenance =
162
+ provenance != null &&
163
+ typeof provenance === 'object' &&
164
+ typeof provenance.locale === 'string' &&
165
+ typeof provenance.resolved === 'boolean';
166
+ if (!hasProvenance) {
167
+ return {
168
+ code: 'NO_PROVENANCE',
169
+ message:
170
+ 'reference for ' + screen + ' has no export provenance; a reference is ' +
171
+ 'verifiable only when exported through the deterministic path',
172
+ };
173
+ }
174
+ if (provenance.resolved === false) {
175
+ return {
176
+ code: 'UNRESOLVED_REFERENCE',
177
+ message:
178
+ 'reference for ' + screen + ' still has unresolved copy placeholders ' +
179
+ '(resolved=false) — export it with the copy filled in',
180
+ };
181
+ }
182
+ if (provenance.locale !== renderLocale) {
183
+ return {
184
+ code: 'LOCALE_MISMATCH',
185
+ message:
186
+ 'reference for ' + screen + ' is locale "' + provenance.locale +
187
+ '" but the render locale is "' + renderLocale + '"',
188
+ };
189
+ }
190
+ if (!referenceExists) {
191
+ return { code: 'REFERENCE_ABSENT', message: 'reference image for ' + screen + ' is absent' };
192
+ }
193
+ return null;
194
+ }
195
+
139
196
  function outputTail(result) {
140
197
  var combined = ((result.stdout || '') + '\n' + (result.stderr || '')).trim();
141
198
  if (combined.length > 2000) combined = '...' + combined.slice(-2000);
@@ -549,6 +606,18 @@ function main() {
549
606
  return 1;
550
607
  }
551
608
 
609
+ // Reference-provenance sidecar. Enforcement is ON only when the run declares
610
+ // its render locale (the manifest-driven multi-state path always does);
611
+ // legacy single-reference runs without it keep their existing behaviour.
612
+ var renderLocale =
613
+ typeof params.render_locale === 'string' && params.render_locale !== ''
614
+ ? params.render_locale
615
+ : null;
616
+ var provenanceMap =
617
+ params.provenance && typeof params.provenance === 'object' && !Array.isArray(params.provenance)
618
+ ? params.provenance
619
+ : {};
620
+
552
621
  var screens = Object.keys(params.screens);
553
622
  currentEntries = screens.map(function (screen) {
554
623
  return {
@@ -600,7 +669,25 @@ function main() {
600
669
  } catch (statError) {
601
670
  sourceStat = null;
602
671
  }
603
- if (!sourceStat || !sourceStat.isFile()) {
672
+ var referenceExists = !!(sourceStat && sourceStat.isFile());
673
+
674
+ if (renderLocale) {
675
+ // Provenance enforcement: a reference that is absent, unprovenanced,
676
+ // unresolved, or in the wrong locale is UNVERIFIABLE (hard, loud) — not a
677
+ // render_failed and never a silent PASS.
678
+ var fault = provenanceFault(
679
+ entry.screen,
680
+ provenanceMap[entry.screen],
681
+ renderLocale,
682
+ referenceExists
683
+ );
684
+ if (fault) {
685
+ setUnverifiable(entry, fault.code, fault.message);
686
+ return;
687
+ }
688
+ } else if (!referenceExists) {
689
+ // Legacy (no render locale declared): an absent reference stays a
690
+ // render_failed REFERENCE_MISSING, exactly as before.
604
691
  setError(
605
692
  entry,
606
693
  'REFERENCE_MISSING',
@@ -286,6 +286,104 @@ describe('android render runtime — render + trim (happy path)', () => {
286
286
  expect(doc.screens.map((s) => s.status)).toEqual(['rendered', 'rendered']);
287
287
  });
288
288
  });
289
+ describe('android render runtime — reference provenance (UNVERIFIABLE)', () => {
290
+ const GOOD_PROV = {
291
+ figmaNode: '12:345',
292
+ locale: 'en',
293
+ resolved: true,
294
+ exportedAt: '2026-06-18T10:00:00Z',
295
+ };
296
+ // Stage a valid project so per-build archive validation passes and the only
297
+ // signal under test is the per-screen provenance verdict.
298
+ function provProject(params) {
299
+ const project = makeProject(params);
300
+ stageArchive(project, gradleProjectEntries());
301
+ return project;
302
+ }
303
+ test('a reference with no provenance is unverifiable', async () => {
304
+ const project = provProject({
305
+ screens: { XProof: 'x.png' },
306
+ render_locale: 'en',
307
+ provenance: {},
308
+ });
309
+ writeReference(project, 'x.png');
310
+ runScript(project, await buildScript());
311
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
312
+ expect(screen?.status).toBe('unverifiable');
313
+ expect(screen?.error?.code).toBe('NO_PROVENANCE');
314
+ });
315
+ test('a placeholder reference (resolved=false) is unverifiable', async () => {
316
+ const project = provProject({
317
+ screens: { XProof: 'x.png' },
318
+ render_locale: 'en',
319
+ provenance: { XProof: { ...GOOD_PROV, resolved: false } },
320
+ });
321
+ writeReference(project, 'x.png');
322
+ runScript(project, await buildScript());
323
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
324
+ expect(screen?.status).toBe('unverifiable');
325
+ expect(screen?.error?.code).toBe('UNRESOLVED_REFERENCE');
326
+ });
327
+ test('an Italian reference against an English render is unverifiable', async () => {
328
+ const project = provProject({
329
+ screens: { XProof: 'x.png' },
330
+ render_locale: 'en',
331
+ provenance: { XProof: { ...GOOD_PROV, locale: 'it' } },
332
+ });
333
+ writeReference(project, 'x.png');
334
+ runScript(project, await buildScript());
335
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
336
+ expect(screen?.status).toBe('unverifiable');
337
+ expect(screen?.error?.code).toBe('LOCALE_MISMATCH');
338
+ });
339
+ test('an absent reference with good provenance is unverifiable', async () => {
340
+ const project = provProject({
341
+ screens: { XProof: 'x.png' },
342
+ render_locale: 'en',
343
+ provenance: { XProof: GOOD_PROV },
344
+ });
345
+ // No writeReference — the file is absent.
346
+ runScript(project, await buildScript());
347
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
348
+ expect(screen?.status).toBe('unverifiable');
349
+ expect(screen?.error?.code).toBe('REFERENCE_ABSENT');
350
+ });
351
+ test('a good provenanced reference renders normally (a content diff is left to the judge)', async () => {
352
+ const project = provProject({
353
+ screens: { XProof: 'x.png' },
354
+ render_locale: 'en',
355
+ provenance: { XProof: GOOD_PROV },
356
+ });
357
+ writeReference(project, 'x.png');
358
+ const run = runScript(project, await buildScript(), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
359
+ expect(run.status).toBe(0);
360
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
361
+ expect(screen?.status).toBe('rendered');
362
+ expect(screen?.error).toBeNull();
363
+ });
364
+ test('independent verdicts: a sibling stays rendered while one state is unverifiable', async () => {
365
+ const project = provProject({
366
+ screens: { XProof: 'x.png', YProof: 'y.png' },
367
+ render_locale: 'en',
368
+ provenance: { XProof: GOOD_PROV, YProof: { ...GOOD_PROV, resolved: false } },
369
+ });
370
+ writeReference(project, 'x.png');
371
+ writeReference(project, 'y.png');
372
+ runScript(project, await buildScript(), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
373
+ const doc = readResult(project);
374
+ expect(doc.screens.find((s) => s.screen === 'XProof')?.status).toBe('rendered');
375
+ const y = doc.screens.find((s) => s.screen === 'YProof');
376
+ expect(y?.status).toBe('unverifiable');
377
+ expect(y?.error?.code).toBe('UNRESOLVED_REFERENCE');
378
+ });
379
+ test('without a render_locale, provenance is not enforced (legacy behaviour)', async () => {
380
+ const project = provProject({ screens: { XProof: 'x.png' } });
381
+ writeReference(project, 'x.png');
382
+ runScript(project, await buildScript(), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
383
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
384
+ expect(screen?.status).toBe('rendered');
385
+ });
386
+ });
289
387
  describe('android render runtime — error isolation', () => {
290
388
  function twoScreenProject() {
291
389
  const project = makeProject({ screens: { XProof: 'x.png', YProof: 'y.png' } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.0.9",
3
+ "version": "2.1.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",