@invarn/cibuild 2.2.7 → 2.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1156,6 +1156,139 @@ describe('buildAppearanceReferenceFromParams — union of geometry/text/color id
1156
1156
  ]);
1157
1157
  });
1158
1158
  });
1159
+ describe('buildStructuralBlock — design-frame anchor (composition runs)', () => {
1160
+ // 686x384px at density 2 → a 343×192dp captured root.
1161
+ const FRAME_DUMP = {
1162
+ canvas: { left: 0, top: 0, right: 686, bottom: 384 },
1163
+ nodes: [
1164
+ { id: 'voucher', bounds: { left: 32, top: 48, right: 194, bottom: 210 }, parentId: null, clipsChildren: false },
1165
+ ],
1166
+ };
1167
+ const GEOM = { voucher: { w: 81, h: 81, x: 16, y: 24 } };
1168
+ test('root ≈ declared frame → no mismatch fact, placement is not withheld', () => {
1169
+ const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 192 });
1170
+ expect(block.facts.filter((f) => f.kind === 'frame_mismatch')).toEqual([]);
1171
+ expect(block.placement).toEqual({ withheld: false, facts: [] });
1172
+ });
1173
+ test('sub-tolerance drift (≤1dp) does not read as a mismatch', () => {
1174
+ const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 342.5, h: 192.5 });
1175
+ expect(block.facts.filter((f) => f.kind === 'frame_mismatch')).toEqual([]);
1176
+ expect(block.placement).toEqual({ withheld: false, facts: [] });
1177
+ });
1178
+ test('a mis-sized root → frame_mismatch fact with declared/captured dims, placement withheld', () => {
1179
+ const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 100 });
1180
+ const mismatches = block.facts.filter((f) => f.kind === 'frame_mismatch');
1181
+ expect(mismatches).toEqual([
1182
+ {
1183
+ kind: 'frame_mismatch',
1184
+ declared: { w: 343, h: 100 },
1185
+ captured: { w: 343, h: 192 },
1186
+ deltas: [{ dimension: 'h', delta: 92 }],
1187
+ },
1188
+ ]);
1189
+ expect(block.placement).toEqual({ withheld: true, reason: 'frame_mismatch' });
1190
+ // A mis-sized wrapper root is not a clean render.
1191
+ expect(block.summary.clean).toBe(false);
1192
+ });
1193
+ test('the mismatch review row reads as a mis-sized wrapper root, never a screen defect', () => {
1194
+ const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 100 });
1195
+ const scaffold = buildReviewScaffold(block);
1196
+ const row = scaffold.rows.find((r) => r.fact.includes('wrapper root'));
1197
+ expect(row).toBeDefined();
1198
+ expect(row.fact).toContain('343×192');
1199
+ expect(row.fact).toContain('343×100');
1200
+ expect(row.fact.toLowerCase()).toContain('size the wrapper root');
1201
+ });
1202
+ test('no declared frame → no placement container, block unchanged from today', () => {
1203
+ const withoutFrame = buildStructuralBlock(FRAME_DUMP, GEOM, 2);
1204
+ expect(withoutFrame.placement).toBeUndefined();
1205
+ expect(buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, null)).toEqual(withoutFrame);
1206
+ });
1207
+ test('embedded stage matches the module for match and mismatch alike (parity)', () => {
1208
+ const embedded = getGeometryStageInternals();
1209
+ for (const frame of [{ w: 343, h: 192 }, { w: 343, h: 100 }, null]) {
1210
+ expect(embedded.buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, frame)).toEqual(buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, frame));
1211
+ }
1212
+ });
1213
+ });
1214
+ describe('buildStructuralBlock — placement facts (composition runs)', () => {
1215
+ // 686x384px at density 2 → a 343×192dp captured root; frame matches.
1216
+ const FRAME = { w: 343, h: 192 };
1217
+ // voucher: design places it at (16, 24); rendered root-space (unclipped)
1218
+ // bounds put it at (16, 24) → in place, or shifted for the drift cases.
1219
+ function dumpWithVoucherAt(leftPx, topPx) {
1220
+ return {
1221
+ canvas: { left: 0, top: 0, right: 686, bottom: 384 },
1222
+ nodes: [
1223
+ {
1224
+ id: 'voucher',
1225
+ bounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
1226
+ unclippedBounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
1227
+ parentId: null,
1228
+ clipsChildren: false,
1229
+ },
1230
+ ],
1231
+ };
1232
+ }
1233
+ const GEOM = { voucher: { w: 81, h: 81, x: 16, y: 24 } };
1234
+ test('a member root shifted beyond tolerance → a placement fact with expected/actual/delta', () => {
1235
+ // Rendered at (20, 100)dp = (40, 200)px — design says (16, 24)dp.
1236
+ const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, FRAME);
1237
+ expect(block.placement).toEqual({
1238
+ withheld: false,
1239
+ facts: [
1240
+ {
1241
+ elementId: 'voucher',
1242
+ expected: { x: 16, y: 24 },
1243
+ actual: { x: 20, y: 100 },
1244
+ deltas: [
1245
+ { dimension: 'x', delta: 4 },
1246
+ { dimension: 'y', delta: 76 },
1247
+ ],
1248
+ },
1249
+ ],
1250
+ });
1251
+ // Placement drift is load-bearing on a composition State — not clean.
1252
+ expect(block.summary.clean).toBe(false);
1253
+ });
1254
+ test('within tolerance (≤2dp) → measured but no fact, still clean', () => {
1255
+ // Rendered at (17, 25)dp = (34, 50)px — 1dp off design (16, 24).
1256
+ const block = buildStructuralBlock(dumpWithVoucherAt(34, 50), GEOM, 2, undefined, undefined, FRAME);
1257
+ expect(block.placement).toEqual({ withheld: false, facts: [] });
1258
+ expect(block.summary.clean).toBe(true);
1259
+ });
1260
+ test('an element without design x/y contributes no placement fact', () => {
1261
+ const geomNoXY = { voucher: { w: 81, h: 81 } };
1262
+ const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), geomNoXY, 2, undefined, undefined, FRAME);
1263
+ expect(block.placement).toEqual({ withheld: false, facts: [] });
1264
+ });
1265
+ test('frame mismatch → placement facts withheld with the reason, none computed', () => {
1266
+ const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, { w: 343, h: 100 });
1267
+ expect(block.placement).toEqual({ withheld: true, reason: 'frame_mismatch' });
1268
+ expect(block.placement && 'facts' in block.placement).toBe(false);
1269
+ });
1270
+ test('no declared frame (component runs) → no placement facts, identical deltas stay advisory', () => {
1271
+ const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2);
1272
+ expect(block.placement).toBeUndefined();
1273
+ // x/y deltas still reported (advisory) on the geometry element, unflagged.
1274
+ const el = block.geometry.elements.find((e) => e.id === 'voucher');
1275
+ expect(el.deltas.some((d) => d.dimension === 'x' || d.dimension === 'y')).toBe(true);
1276
+ expect(el.sizeFlag).toBe(false);
1277
+ expect(block.summary.clean).toBe(true);
1278
+ });
1279
+ test('embedded stage matches the module for drift, clean, and withheld (parity)', () => {
1280
+ const embedded = getGeometryStageInternals();
1281
+ const cases = [
1282
+ [dumpWithVoucherAt(40, 200), FRAME],
1283
+ [dumpWithVoucherAt(34, 50), FRAME],
1284
+ [dumpWithVoucherAt(40, 200), { w: 343, h: 100 }],
1285
+ [dumpWithVoucherAt(40, 200), null],
1286
+ ];
1287
+ for (const [dump, frame] of cases) {
1288
+ expect(embedded.buildStructuralBlock(dump, GEOM, 2, undefined, undefined, frame)).toEqual(buildStructuralBlock(dump, GEOM, 2, undefined, undefined, frame));
1289
+ }
1290
+ });
1291
+ });
1159
1292
  describe('embedded geometry stage parity', () => {
1160
1293
  // The render ships a JS-source port of the differ + assembler. It must produce
1161
1294
  // byte-identical blocks to the canonical typed module, or run-side structural
@@ -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;AAKpE,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;AAq5BD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAoBrF;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"}
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;AA47BD;;;;;;;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.
@@ -122,6 +123,14 @@ var referenceColor = null;
122
123
  // Marks whether the appearance reference is the design (divergences) or the
123
124
  // previous catalog render (regressions/drift, the no-Figma path).
124
125
  var referenceBaseline = null;
126
+ // params.reference_frame (composition runs: the design frame's { w, h } per
127
+ // screen, design px treated 1:1 with dp), set in main() and read by the
128
+ // structural assembler, which asserts the captured root against it. Null when
129
+ // none declared → no frame assertion, no placement gate.
130
+ var referenceFrame = null;
131
+ // params.proof_kind ('component' default | 'composition'), set in main(). The
132
+ // frame assertion and the placement gate only apply to composition runs.
133
+ var proofKind = 'component';
125
134
 
126
135
  function log(message) {
127
136
  console.log('[ui-fidelity-render-android] ' + message);
@@ -547,6 +556,14 @@ function findRoborazziDirs(root) {
547
556
  // screen — the analog of the iOS probe gate). A screen whose PNG is absent
548
557
  // after a successful build is RENDER_FAILED on its own.
549
558
  function renderScreens(renderable, projectRoot) {
559
+ // Snapshot the package's input files BEFORE the build runs, so the digest is
560
+ // over the pristine committed source and never perturbed by build outputs.
561
+ var packageFiles = __fidCollectPackageFiles(projectRoot);
562
+ // A package is safely cacheable only when it is hermetic — no Gradle script
563
+ // reaches outside its own root. Then the package IS its whole dependency
564
+ // closure, so the content key cannot miss an external change. A non-hermetic
565
+ // package emits no digest, so a later dispatch never skips it.
566
+ var packageHermetic = __fidIsHermetic(packageFiles);
550
567
  var gradle = runGradle(projectRoot, CONFIG.gradleTask);
551
568
  if (gradle.status !== 0) {
552
569
  var detail = outputTail(gradle);
@@ -606,6 +623,20 @@ function renderScreens(renderable, projectRoot) {
606
623
  entry.rendered_image_path = relative;
607
624
  entry.status = 'rendered';
608
625
  entry.error = null;
626
+ // Content key over the package source + this screen's reference: lets a
627
+ // consumer skip a re-render whose inputs are unchanged. Emitted only for a
628
+ // hermetic package; otherwise omitted so no stale verdict can be replayed.
629
+ if (packageHermetic) {
630
+ var referenceBytes = null;
631
+ try {
632
+ if (entry.reference_image_path) {
633
+ referenceBytes = fs.readFileSync(path.resolve(artifactsDir, entry.reference_image_path));
634
+ }
635
+ } catch (digestReferenceError) {
636
+ referenceBytes = null;
637
+ }
638
+ entry.source_digest = __fidSourceDigest(packageFiles, referenceBytes);
639
+ }
609
640
  log('rendered ' + entry.screen + ' -> ' + relative);
610
641
  trimRenderedImage(entry, outputPath, workDir);
611
642
  // Signal A: the render emits a per-node layout dump into a roborazzi
@@ -634,7 +665,7 @@ function renderScreens(renderable, projectRoot) {
634
665
  // geometry deltas vs reference_geometry) onto the screen entry, so the
635
666
  // judge reads the signals from protocol-result.json without fetching
636
667
  // artifacts. CONFIG.scale is the render density (dp = px / scale).
637
- assembleStructural(entry, layoutSource, referenceGeometry, CONFIG.scale, referenceText, referenceColor, referenceBaseline);
668
+ assembleStructural(entry, layoutSource, referenceGeometry, CONFIG.scale, referenceText, referenceColor, referenceBaseline, referenceFrame, proofKind);
638
669
  }
639
670
  });
640
671
  } finally {
@@ -720,6 +751,15 @@ function main() {
720
751
  // sourced from the previous catalog render (no-Figma drift path), else 'figma'.
721
752
  referenceBaseline =
722
753
  params.reference_baseline === 'previous_render' ? 'previous_render' : 'figma';
754
+ // Composition-run inputs: the declared kind gates the frame assertion; the
755
+ // per-screen design frame is what the captured root is asserted against.
756
+ proofKind = params.proof_kind === 'composition' ? 'composition' : 'component';
757
+ referenceFrame =
758
+ params.reference_frame &&
759
+ typeof params.reference_frame === 'object' &&
760
+ !Array.isArray(params.reference_frame)
761
+ ? params.reference_frame
762
+ : null;
723
763
 
724
764
  var screens = Object.keys(params.screens);
725
765
  currentEntries = screens.map(function (screen) {
@@ -1003,6 +1043,7 @@ export function generateAndroidRenderScript(config) {
1003
1043
  RENDER_SCRIPT_TRIM_STAGE,
1004
1044
  RENDER_SCRIPT_FACTS_STAGE,
1005
1045
  RENDER_SCRIPT_GEOMETRY_STAGE,
1046
+ RENDER_SCRIPT_SOURCE_DIGEST_STAGE,
1006
1047
  main,
1007
1048
  ].join('\n');
1008
1049
  }
@@ -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":"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"}
1
+ {"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"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"}
@@ -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],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.2.7",
3
+ "version": "2.2.9",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",