@invarn/cibuild 2.2.6 → 2.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -63,6 +64,10 @@ export const DEFAULT_SCALE = 2;
63
64
  export const PACKAGE_SOURCES = ['repo', 'inputs'];
64
65
  /** Default package source: the package lives in the repo checkout. */
65
66
  export const DEFAULT_PACKAGE_SOURCE = 'repo';
67
+ /** Valid values for the reference_source input. */
68
+ export const REFERENCE_SOURCES = ['repo', 'inputs'];
69
+ /** Default reference source: references arrive as run inputs (v1 behaviour). */
70
+ export const DEFAULT_REFERENCE_SOURCE = 'inputs';
66
71
  /** Basename of the shipped package archive inside the run-inputs directory. */
67
72
  export const PACKAGE_ARCHIVE_BASENAME = 'package.tar.gz';
68
73
  /**
@@ -335,6 +340,13 @@ function runSwift(args) {
335
340
  }
336
341
 
337
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);
338
350
  var harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-harness-'));
339
351
  registerPathReplacement(harnessDir, '<harness>');
340
352
  log('harness package: ' + harnessDir);
@@ -416,6 +428,20 @@ function renderWithHarness(renderable, packagePath) {
416
428
  entry.rendered_image_path = relative;
417
429
  entry.status = 'rendered';
418
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
+ }
419
445
  log('rendered ' + entry.screen + ' -> ' + relative);
420
446
  });
421
447
  } finally {
@@ -1112,6 +1138,41 @@ function buildPackageSourceMain() {
1112
1138
  return main;
1113
1139
  }
1114
1140
  const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
1141
+ /**
1142
+ * Patches the reference-sourcing block to read each screen's reference from a
1143
+ * repo-relative path (resolved against the checkout) instead of a plain
1144
+ * basename under `.ci/inputs/`. Applied on top of EITHER base main (v1 or the
1145
+ * package_source variant) — the package patches never touch this block, so the
1146
+ * anchor is present and unique in both. The downstream stat/copy logic is
1147
+ * unchanged; only how `source` is computed differs.
1148
+ */
1149
+ function buildReferenceRepoMain(base) {
1150
+ return replaceOnce(base, 'var basename = params.screens[entry.screen];\n' +
1151
+ ' if (\n' +
1152
+ " typeof basename !== 'string' || basename === '' ||\n" +
1153
+ " basename === '.' || basename === '..' ||\n" +
1154
+ ' basename !== path.basename(basename)\n' +
1155
+ ' ) {\n' +
1156
+ ' setError(\n' +
1157
+ ' entry,\n' +
1158
+ " 'REFERENCE_MISSING',\n" +
1159
+ " 'reference for ' + entry.screen + ' must be a plain file basename inside ' +\n" +
1160
+ " inputsDir + ', got: ' + JSON.stringify(basename)\n" +
1161
+ ' );\n' +
1162
+ ' return;\n' +
1163
+ ' }\n' +
1164
+ ' var source = path.join(inputsDir, basename);', 'var refPath = params.screens[entry.screen];\n' +
1165
+ " if (typeof refPath !== 'string' || refPath === '') {\n" +
1166
+ ' setError(\n' +
1167
+ ' entry,\n' +
1168
+ " 'REFERENCE_MISSING',\n" +
1169
+ " 'reference for ' + entry.screen + ' must be a non-empty repo-relative path, got: ' +\n" +
1170
+ ' JSON.stringify(refPath)\n' +
1171
+ ' );\n' +
1172
+ ' return;\n' +
1173
+ ' }\n' +
1174
+ ' var source = path.resolve(refPath);');
1175
+ }
1115
1176
  /**
1116
1177
  * Evaluates the embedded generator source and returns the real functions, so
1117
1178
  * unit tests exercise exactly the code that ships inside the runtime script.
@@ -1133,14 +1194,21 @@ export function getRenderScriptInternals() {
1133
1194
  * unpatched v1 runtime is used).
1134
1195
  */
1135
1196
  export function generateRenderScript(config) {
1197
+ let main = config.packageSource === undefined
1198
+ ? RENDER_SCRIPT_MAIN
1199
+ : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE;
1200
+ // Reference-from-repo is an independent dimension layered on top of whichever
1201
+ // package base applies. Absent → byte-identical reference block (v1).
1202
+ if (config.referenceSource === 'repo') {
1203
+ main = buildReferenceRepoMain(main);
1204
+ }
1136
1205
  return [
1137
1206
  "'use strict';",
1138
1207
  '// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
1139
1208
  'var CONFIG = ' + JSON.stringify(config) + ';',
1140
1209
  RENDER_SCRIPT_GENERATORS,
1141
- config.packageSource === undefined
1142
- ? RENDER_SCRIPT_MAIN
1143
- : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE,
1210
+ RENDER_SCRIPT_SOURCE_DIGEST_STAGE,
1211
+ main,
1144
1212
  ].join('\n');
1145
1213
  }
1146
1214
  /**
@@ -1196,12 +1264,30 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
1196
1264
  throw new Error(`Invalid target '${target}' for step '${stepName}': ` +
1197
1265
  'must be a Swift module identifier (letters, digits, underscores)');
1198
1266
  }
1267
+ // reference_source mirrors package_source: validate when provided, but only
1268
+ // record (and patch the script for) the non-default "repo" so omitting it —
1269
+ // or setting the default "inputs" — keeps the v1 reference block intact.
1270
+ const rawReferenceSource = this.getInput(inputs, 'reference_source', undefined);
1271
+ let referenceSource;
1272
+ if (rawReferenceSource !== undefined) {
1273
+ const value = String(rawReferenceSource);
1274
+ if (!REFERENCE_SOURCES.includes(value)) {
1275
+ throw new Error(`Invalid reference_source '${value}' for step '${stepName}': ` +
1276
+ `expected one of: ${REFERENCE_SOURCES.join(', ')}`);
1277
+ }
1278
+ if (value === 'repo') {
1279
+ referenceSource = 'repo';
1280
+ }
1281
+ }
1199
1282
  const { width, height } = parseRenderSize(String(this.getInput(inputs, 'render_size', DEFAULT_RENDER_SIZE)));
1200
1283
  const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
1201
1284
  const config = { packagePath, target, width, height, scale };
1202
1285
  if (packageSource !== undefined) {
1203
1286
  config.packageSource = packageSource;
1204
1287
  }
1288
+ if (referenceSource !== undefined) {
1289
+ config.referenceSource = referenceSource;
1290
+ }
1205
1291
  const script = generateRenderScript(config);
1206
1292
  return this.createNodeStep(script, stepName);
1207
1293
  }
@@ -319,10 +319,13 @@ describe('registry integration', () => {
319
319
  expect(Object.keys(inputs).sort()).toEqual([
320
320
  'package_path',
321
321
  'package_source',
322
+ 'reference_source',
322
323
  'render_size',
323
324
  'scale',
324
325
  'target',
325
326
  ]);
327
+ expect(inputs.reference_source.required).toBe(false);
328
+ expect(inputs.reference_source.default).toBe('inputs');
326
329
  // package_path/target are enforced by the executor in repo mode (the
327
330
  // default); a package_source:inputs config is valid without either, so
328
331
  // neither is unconditionally required at the schema level.
@@ -528,6 +531,7 @@ describe('render script runtime', () => {
528
531
  error: null,
529
532
  reference_image_path: 'ui-fidelity/references/HomeView.png',
530
533
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
534
+ source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
531
535
  },
532
536
  {
533
537
  screen: 'SettingsView',
@@ -535,6 +539,7 @@ describe('render script runtime', () => {
535
539
  error: null,
536
540
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
537
541
  rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
542
+ source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
538
543
  },
539
544
  ],
540
545
  });
@@ -644,6 +649,17 @@ describe('render script runtime', () => {
644
649
  expect(settings.error).toBeNull();
645
650
  expect(isPng(artifact(project, 'ui-fidelity/rendered/SettingsView.png'))).toBe(true);
646
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
+ });
647
663
  test('a reference that is a directory fails only that screen', async () => {
648
664
  const project = makeProject({
649
665
  screens: { HomeView: 'home-dir.png', SettingsView: 'settings.png' },
@@ -913,6 +929,7 @@ describe('render script runtime (package_source: repo)', () => {
913
929
  error: null,
914
930
  reference_image_path: 'ui-fidelity/references/HomeView.png',
915
931
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
932
+ source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
916
933
  aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
917
934
  dimensions: {
918
935
  rendered_px: [320, 640],
@@ -1092,6 +1109,7 @@ describe('render script runtime (package_source: inputs)', () => {
1092
1109
  error: null,
1093
1110
  reference_image_path: 'ui-fidelity/references/HomeView.png',
1094
1111
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
1112
+ source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
1095
1113
  aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
1096
1114
  dimensions: {
1097
1115
  rendered_px: [320, 640],
@@ -1105,6 +1123,7 @@ describe('render script runtime (package_source: inputs)', () => {
1105
1123
  error: null,
1106
1124
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
1107
1125
  rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
1126
+ source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
1108
1127
  aligned_image_path: 'ui-fidelity/aligned/SettingsView.png',
1109
1128
  dimensions: {
1110
1129
  rendered_px: [320, 640],
@@ -1509,4 +1528,56 @@ describe('committed fixture package', () => {
1509
1528
  expectNoAbsolutePathLeaks(project, result);
1510
1529
  }, 600_000);
1511
1530
  });
1531
+ describe('reference_source: repo', () => {
1532
+ test('rejects an unknown reference_source value', async () => {
1533
+ const executor = new UiFidelityRenderStepExecutor();
1534
+ await expect(executor.execute({ package_path: './pkg', target: 'MyViews', reference_source: 'artifact' }, {}, testConfig)).rejects.toThrow(/reference_source/);
1535
+ });
1536
+ test('omitting reference_source bakes no referenceSource into the script config', async () => {
1537
+ const script = await buildScript(makeProject());
1538
+ const configLine = script
1539
+ .split('\n')
1540
+ .find((l) => l.startsWith('var CONFIG = '));
1541
+ expect(configLine).toBeDefined();
1542
+ expect(configLine).not.toContain('referenceSource');
1543
+ });
1544
+ test('reads each screen reference from its repo-relative path in the checkout', async () => {
1545
+ const project = makeProject({
1546
+ screens: { HomeView: 'refs/home.png', SettingsView: 'design/settings.png' },
1547
+ });
1548
+ // Committed references live at repo-relative paths (resolved against cwd),
1549
+ // NOT in .ci/inputs.
1550
+ mkdirSync(join(project.dir, 'refs'), { recursive: true });
1551
+ mkdirSync(join(project.dir, 'design'), { recursive: true });
1552
+ writeFileSync(join(project.dir, 'refs', 'home.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:home')]));
1553
+ writeFileSync(join(project.dir, 'design', 'settings.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:settings')]));
1554
+ const script = await buildScript(project, { reference_source: 'repo' });
1555
+ const run = runScript(project, script);
1556
+ expect(run.status).toBe(0);
1557
+ expect(isPng(artifact(project, 'ui-fidelity/references/HomeView.png'))).toBe(true);
1558
+ expect(isPng(artifact(project, 'ui-fidelity/references/SettingsView.png'))).toBe(true);
1559
+ const result = readResult(project);
1560
+ expect(result.screens.map((s) => s.status)).toEqual(['rendered', 'rendered']);
1561
+ expect(result.screens.map((s) => s.reference_image_path)).toEqual([
1562
+ 'ui-fidelity/references/HomeView.png',
1563
+ 'ui-fidelity/references/SettingsView.png',
1564
+ ]);
1565
+ });
1566
+ test('fails one screen when its committed reference is missing, renders the rest', async () => {
1567
+ const project = makeProject({
1568
+ screens: { HomeView: 'refs/home.png', GhostView: 'refs/ghost.png' },
1569
+ });
1570
+ mkdirSync(join(project.dir, 'refs'), { recursive: true });
1571
+ writeFileSync(join(project.dir, 'refs', 'home.png'), Buffer.concat([PNG_MAGIC, Buffer.from('ref:home')]));
1572
+ // refs/ghost.png intentionally absent.
1573
+ const run = runScript(project, await buildScript(project, { reference_source: 'repo' }));
1574
+ expect(run.status).not.toBe(0);
1575
+ const result = readResult(project);
1576
+ const home = result.screens.find((s) => s.screen === 'HomeView');
1577
+ const ghost = result.screens.find((s) => s.screen === 'GhostView');
1578
+ expect(home?.status).toBe('rendered');
1579
+ expect(ghost?.status).toBe('render_failed');
1580
+ expect(ghost?.error?.code).toBe('REFERENCE_MISSING');
1581
+ });
1582
+ });
1512
1583
  //# sourceMappingURL=ui-fidelity-render.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.2.6",
3
+ "version": "2.2.8",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",