@invarn/cibuild 2.0.3 → 2.0.5

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.
@@ -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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"}
@@ -910,29 +910,17 @@ function findAssetCatalogs(root) {
910
910
  return found.sort();
911
911
  }
912
912
 
913
- function compileAssetCatalogs(packageDir, harnessDir) {
914
- var catalogs = findAssetCatalogs(packageDir);
915
- if (catalogs.length === 0) return;
916
-
917
- var binResult = runSwift(['build', '--package-path', harnessDir, '--show-bin-path']);
918
- if (binResult.status !== 0) {
919
- logError('asset-catalog compile skipped: could not resolve the build bin path');
920
- return;
921
- }
922
- var binPath = String(binResult.stdout || '').trim().split('\n').pop().trim();
923
- if (!binPath) {
924
- logError('asset-catalog compile skipped: empty build bin path');
925
- return;
926
- }
913
+ // Run actool over the given catalogs, emitting Assets.car into outDir.
914
+ // Best-effort: a failure is logged (warn-and-continue) and rendering proceeds.
915
+ function compileCatalogsInto(catalogs, outDir, label) {
927
916
  try {
928
- fs.mkdirSync(binPath, { recursive: true });
917
+ fs.mkdirSync(outDir, { recursive: true });
929
918
  } catch (mkdirError) {
930
919
  // best effort -- actool reports a missing output dir itself
931
920
  }
932
-
933
921
  var args = ['actool'].concat(catalogs);
934
922
  args.push(
935
- '--compile', binPath,
923
+ '--compile', outDir,
936
924
  '--platform', 'macosx',
937
925
  '--minimum-deployment-target', ASSET_CATALOG_MIN_MACOS,
938
926
  '--output-format', 'human-readable-text'
@@ -948,12 +936,245 @@ function compileAssetCatalogs(packageDir, harnessDir) {
948
936
  var detail = (result.stderr || '') +
949
937
  (result.error ? String(result.error.message || result.error) : '');
950
938
  logError(
951
- 'asset-catalog compile failed (continuing; catalog images may not ' +
952
- 'resolve): ' + sanitizeMessage(detail.trim())
939
+ 'asset-catalog compile failed for ' + label + ' (continuing; catalog ' +
940
+ 'images may not resolve): ' + sanitizeMessage(detail.trim())
941
+ );
942
+ }
943
+ }
944
+
945
+ // SwiftUI resolves Image("name") against Bundle.main and
946
+ // Image("name", bundle: .module) against a target's own resource bundle.
947
+ // swift build copies a .process'd .xcassets into the module bundle but never
948
+ // compiles it, and a loose catalog is copied nowhere. So compile twice:
949
+ // the package's source catalogs into the bin dir (= Bundle.main), AND each
950
+ // module bundle's copied catalog in place (= Bundle.module). This covers both
951
+ // reference idioms, so the agent need not know which bundle the renderer uses.
952
+ function compileAssetCatalogs(packageDir, harnessDir) {
953
+ var sourceCatalogs = findAssetCatalogs(packageDir);
954
+ if (sourceCatalogs.length === 0) return;
955
+
956
+ var binResult = runSwift(['build', '--package-path', harnessDir, '--show-bin-path']);
957
+ if (binResult.status !== 0) {
958
+ logError('asset-catalog compile skipped: could not resolve the build bin path');
959
+ return;
960
+ }
961
+ var binPath = String(binResult.stdout || '').trim().split('\n').pop().trim();
962
+ if (!binPath) {
963
+ logError('asset-catalog compile skipped: empty build bin path');
964
+ return;
965
+ }
966
+
967
+ // Bundle.main: the package's source catalogs, merged into the bin dir.
968
+ compileCatalogsInto(sourceCatalogs, binPath, 'Bundle.main');
969
+
970
+ // Bundle.module: each *.bundle in the bin dir that carries a copied catalog,
971
+ // compiled in place so Image(..., bundle: .module) resolves.
972
+ var entries;
973
+ try {
974
+ entries = fs.readdirSync(binPath, { withFileTypes: true });
975
+ } catch (readError) {
976
+ entries = [];
977
+ }
978
+ for (var i = 0; i < entries.length; i++) {
979
+ var entry = entries[i];
980
+ if (!entry.isDirectory() || entry.name.slice(-7) !== '.bundle') continue;
981
+ var bundleDir = path.join(binPath, entry.name);
982
+ var bundleCatalogs = findAssetCatalogs(bundleDir);
983
+ if (bundleCatalogs.length > 0) {
984
+ compileCatalogsInto(bundleCatalogs, bundleDir, entry.name);
985
+ }
986
+ }
987
+ }
988
+ `;
989
+ /**
990
+ * Runtime support for trimming the rendered output (package_source variants).
991
+ *
992
+ * The render entry forces the view into a fixed device-sized frame, so a
993
+ * component smaller than the canvas renders with transparent margins. After a
994
+ * screen renders, crop the PNG in place to the bounding box of its
995
+ * non-transparent pixels, so the rendered artifact is the component itself,
996
+ * tight -- no canvas, no background ambiguity for the human comparing screens.
997
+ *
998
+ * The crop is done by a small CoreGraphics helper run in Swift's
999
+ * script-interpreter mode (so it needs no extra harness target and reuses the
1000
+ * one `swift` the runner already has). Best-effort: a trim failure is logged
1001
+ * (warn-and-continue) and the untrimmed render is kept -- a cosmetic post-step
1002
+ * never fails an otherwise good render. This is prep, not scoring: it produces
1003
+ * a tighter image for the human's judgment and computes nothing about
1004
+ * fidelity.
1005
+ */
1006
+ const RENDER_SCRIPT_TRIM_STAGE = String.raw `
1007
+ // ---- render trimming stage (package_source variants) ----
1008
+
1009
+ // CoreGraphics helper, run via 'swift <Trim.swift> <in.png> <out.png>'
1010
+ // (script-interpreter mode). Crops a PNG to the bounding box of its
1011
+ // non-transparent pixels and writes it to the output path, which may equal the
1012
+ // input path (in-place trim).
1013
+ var TRIM_HELPER_SWIFT = [
1014
+ 'import Foundation',
1015
+ 'import ImageIO',
1016
+ 'import CoreGraphics',
1017
+ 'import UniformTypeIdentifiers',
1018
+ '',
1019
+ 'func loadCGImage(_ path: String) -> CGImage? {',
1020
+ ' guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),',
1021
+ ' let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { return nil }',
1022
+ ' return img',
1023
+ '}',
1024
+ '',
1025
+ 'func writePNG(_ image: CGImage, to path: String) -> Bool {',
1026
+ ' guard let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else { return false }',
1027
+ ' CGImageDestinationAddImage(dest, image, nil)',
1028
+ ' return CGImageDestinationFinalize(dest)',
1029
+ '}',
1030
+ '',
1031
+ '// Redraw an image at an exact pixel size (used to match the reference).',
1032
+ 'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
1033
+ ' if w <= 0 || h <= 0 { return nil }',
1034
+ ' let bytesPerRow = w * 4',
1035
+ ' let space = CGColorSpaceCreateDeviceRGB()',
1036
+ ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
1037
+ ' ctx.interpolationQuality = .high',
1038
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
1039
+ ' return ctx.makeImage()',
1040
+ '}',
1041
+ '',
1042
+ '// Bounding box of pixels with non-zero alpha, in top-left image coordinates.',
1043
+ 'func alphaBoundingBox(_ image: CGImage) -> CGRect? {',
1044
+ ' let w = image.width, h = image.height',
1045
+ ' if w == 0 || h == 0 { return nil }',
1046
+ ' let bytesPerRow = w * 4',
1047
+ ' var data = [UInt8](repeating: 0, count: bytesPerRow * h)',
1048
+ ' let space = CGColorSpaceCreateDeviceRGB()',
1049
+ ' guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
1050
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
1051
+ ' var minX = w, minY = h, maxX = -1, maxY = -1',
1052
+ ' for y in 0..<h {',
1053
+ ' let rowBase = y * bytesPerRow',
1054
+ ' for x in 0..<w {',
1055
+ ' if data[rowBase + x * 4 + 3] > 0 {',
1056
+ ' if x < minX { minX = x }',
1057
+ ' if x > maxX { maxX = x }',
1058
+ ' if y < minY { minY = y }',
1059
+ ' if y > maxY { maxY = y }',
1060
+ ' }',
1061
+ ' }',
1062
+ ' }',
1063
+ ' if maxX < 0 { return nil }',
1064
+ ' return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)',
1065
+ '}',
1066
+ '',
1067
+ 'let arguments = CommandLine.arguments',
1068
+ 'guard arguments.count >= 3 else {',
1069
+ ' FileHandle.standardError.write(Data("usage: Trim <in.png> <out.png>".utf8))',
1070
+ ' exit(64)',
1071
+ '}',
1072
+ 'let inputPath = arguments[1]',
1073
+ 'let outputPath = arguments[2]',
1074
+ 'guard let image = loadCGImage(inputPath) else {',
1075
+ ' FileHandle.standardError.write(Data(("could not read image at " + inputPath).utf8))',
1076
+ ' exit(65)',
1077
+ '}',
1078
+ 'guard let box = alphaBoundingBox(image) else {',
1079
+ ' FileHandle.standardError.write(Data("image is fully transparent; nothing to trim".utf8))',
1080
+ ' exit(66)',
1081
+ '}',
1082
+ 'guard let cropped = image.cropping(to: box) else {',
1083
+ ' FileHandle.standardError.write(Data("could not crop image".utf8))',
1084
+ ' exit(67)',
1085
+ '}',
1086
+ 'let croppedWidth = cropped.width',
1087
+ 'let croppedHeight = cropped.height',
1088
+ 'guard writePNG(cropped, to: outputPath) else {',
1089
+ ' FileHandle.standardError.write(Data(("could not write trimmed PNG to " + outputPath).utf8))',
1090
+ ' exit(68)',
1091
+ '}',
1092
+ '',
1093
+ '// Optional aligned output: the trimmed image resized to the reference pixel',
1094
+ '// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
1095
+ 'var referenceWidth = 0',
1096
+ 'var referenceHeight = 0',
1097
+ 'if arguments.count >= 5 {',
1098
+ ' let alignedPath = arguments[3]',
1099
+ ' let referencePath = arguments[4]',
1100
+ ' if let reference = loadCGImage(referencePath) {',
1101
+ ' referenceWidth = reference.width',
1102
+ ' referenceHeight = reference.height',
1103
+ ' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
1104
+ ' _ = writePNG(aligned, to: alignedPath)',
1105
+ ' }',
1106
+ ' }',
1107
+ '}',
1108
+ '',
1109
+ '// Report: rendered (trimmed) px then reference px (0 0 when unavailable).',
1110
+ 'print("TRIMDIMS " + String(croppedWidth) + " " + String(croppedHeight) + " " + String(referenceWidth) + " " + String(referenceHeight))'
1111
+ ].join('\n') + '\n';
1112
+
1113
+ // Write the helper into the harness dir once (idempotent across screens).
1114
+ function ensureTrimHelper(harnessDir) {
1115
+ var helperPath = path.join(harnessDir, 'Trim.swift');
1116
+ if (!fs.existsSync(helperPath)) {
1117
+ fs.writeFileSync(helperPath, TRIM_HELPER_SWIFT);
1118
+ }
1119
+ return helperPath;
1120
+ }
1121
+
1122
+ // Round to two decimals, keeping the value a plain number.
1123
+ function roundTwo(value) {
1124
+ return Math.round(value * 100) / 100;
1125
+ }
1126
+
1127
+ // Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH>" line (last one wins).
1128
+ function parseTrimDims(stdout) {
1129
+ var lines = String(stdout || '').split('\n');
1130
+ for (var i = lines.length - 1; i >= 0; i--) {
1131
+ var parts = lines[i].trim().split(/\s+/);
1132
+ if (parts[0] === 'TRIMDIMS' && parts.length >= 5) {
1133
+ var nums = [Number(parts[1]), Number(parts[2]), Number(parts[3]), Number(parts[4])];
1134
+ if (nums.every(function (n) { return Number.isFinite(n); })) {
1135
+ return { rendered: [nums[0], nums[1]], reference: [nums[2], nums[3]] };
1136
+ }
1137
+ }
1138
+ }
1139
+ return null;
1140
+ }
1141
+
1142
+ // Crop the rendered PNG in place to its content bounding box, write an aligned
1143
+ // copy resized to the reference, and record the dimensions on the entry.
1144
+ // Best-effort: a failure is logged and the untrimmed render is kept.
1145
+ function trimRenderedImage(entry, renderedPath, harnessDir) {
1146
+ var helperPath = ensureTrimHelper(harnessDir);
1147
+ var alignedRelative = 'ui-fidelity/aligned/' + entry.screen + '.png';
1148
+ var alignedPath = path.resolve(artifactsDir, alignedRelative);
1149
+ var referencePath =
1150
+ entry.reference_image_path === null
1151
+ ? ''
1152
+ : path.resolve(artifactsDir, entry.reference_image_path);
1153
+ try {
1154
+ fs.mkdirSync(path.dirname(alignedPath), { recursive: true });
1155
+ } catch (mkdirError) {
1156
+ // best effort -- the helper reports a missing output dir itself
1157
+ }
1158
+ var result = runSwift([helperPath, renderedPath, renderedPath, alignedPath, referencePath]);
1159
+ if (result.status !== 0) {
1160
+ logError(
1161
+ 'trim failed for ' + renderedPath +
1162
+ ' (continuing with the untrimmed render): ' + sanitizeMessage(outputTail(result))
953
1163
  );
954
1164
  return;
955
1165
  }
956
- log('compiled ' + catalogs.length + ' asset catalog(s) for macOS');
1166
+ var dims = parseTrimDims(result.stdout);
1167
+ if (dims !== null) {
1168
+ var scale = CONFIG.scale;
1169
+ entry.dimensions = {
1170
+ rendered_px: dims.rendered,
1171
+ logical_pt: [roundTwo(dims.rendered[0] / scale), roundTwo(dims.rendered[1] / scale)],
1172
+ reference_px: dims.reference[0] > 0 && dims.reference[1] > 0 ? dims.reference : null,
1173
+ };
1174
+ }
1175
+ if (fs.existsSync(alignedPath)) {
1176
+ entry.aligned_image_path = alignedRelative;
1177
+ }
957
1178
  }
958
1179
  `;
959
1180
  /**
@@ -1063,6 +1284,21 @@ function buildPackageSourceMain() {
1063
1284
  main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_ASSET_CATALOG_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
1064
1285
  main = replaceOnce(main, ' renderable.forEach(function (entry) {', ' compileAssetCatalogs(packagePath, harnessDir);\n\n' +
1065
1286
  ' renderable.forEach(function (entry) {');
1287
+ // Render-trimming stage: define the helpers ahead of renderWithHarness, then
1288
+ // crop each rendered PNG to its content bounding box right after it renders.
1289
+ main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_TRIM_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
1290
+ main = replaceOnce(main, " log('rendered ' + entry.screen + ' -> ' + relative);", " log('rendered ' + entry.screen + ' -> ' + relative);\n" +
1291
+ ' trimRenderedImage(entry, outputPath, harnessDir);');
1292
+ // Per-screen result entries gain the trimmed-render fields: the aligned
1293
+ // (reference-sized) image and the rendered/logical/reference dimensions.
1294
+ // They stay null until a screen renders and is trimmed.
1295
+ main = replaceOnce(main, ' reference_image_path: null,\n' +
1296
+ ' rendered_image_path: null,\n' +
1297
+ ' };', ' reference_image_path: null,\n' +
1298
+ ' rendered_image_path: null,\n' +
1299
+ ' aligned_image_path: null,\n' +
1300
+ ' dimensions: null,\n' +
1301
+ ' };');
1066
1302
  return main;
1067
1303
  }
1068
1304
  const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
@@ -68,7 +68,11 @@ const FAKE_SWIFT_LINES = [
68
68
  ' *) shift ;;',
69
69
  ' esac',
70
70
  ' done',
71
- ' if [ -n "$showbin" ]; then echo "$pkg/.build/debug"; exit 0; fi',
71
+ ' if [ -n "$showbin" ]; then',
72
+ ' bp="$pkg/.build/debug"',
73
+ ' [ -n "$FAKE_SWIFT_MODULE_CATALOG" ] && mkdir -p "$bp/$FAKE_SWIFT_MODULE_CATALOG.bundle/Media.xcassets"',
74
+ ' echo "$bp"; exit 0',
75
+ ' fi',
72
76
  ' name="${product:-$target}"',
73
77
  ' if [ "$name" = "RenderProbe" ]; then',
74
78
  ' if [ -n "$FAKE_SWIFT_PROBE_FAIL" ]; then',
@@ -107,6 +111,22 @@ const FAKE_SWIFT_LINES = [
107
111
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out\"",
108
112
  ' printf \'fake-render:%s\' "$screen" >> "$out"',
109
113
  ' exit 0 ;;',
114
+ ' *.swift)',
115
+ // Script-interpreter mode (swift <Trim.swift> <in> <out> <aligned> <ref>):
116
+ // stands in for the CoreGraphics trim helper. Overwrites <out> with a PNG
117
+ // marked "trimmed:<in basename>" and writes an "aligned:<in basename>" PNG so
118
+ // tests can prove both outputs. Emits a fixed TRIMDIMS line (rendered
119
+ // 320x640, reference 160x320). FAKE_TRIM_FAIL exercises warn-and-continue.
120
+ ' if [ -n "$FAKE_TRIM_FAIL" ]; then echo "trim: simulated failure" >&2; exit 1; fi',
121
+ ' in_png="$1"; out_png="$2"; aligned_png="$3"',
122
+ " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out_png\"",
123
+ ' printf \'trimmed:%s\' "$(basename "$in_png")" >> "$out_png"',
124
+ ' if [ -n "$aligned_png" ]; then',
125
+ " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$aligned_png\"",
126
+ ' printf \'aligned:%s\' "$(basename "$in_png")" >> "$aligned_png"',
127
+ ' fi',
128
+ ' echo "TRIMDIMS 320 640 160 320"',
129
+ ' exit 0 ;;',
110
130
  ' *)',
111
131
  ' exit 0 ;;',
112
132
  'esac',
@@ -892,6 +912,12 @@ describe('render script runtime (package_source: repo)', () => {
892
912
  error: null,
893
913
  reference_image_path: 'ui-fidelity/references/HomeView.png',
894
914
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
915
+ aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
916
+ dimensions: {
917
+ rendered_px: [320, 640],
918
+ logical_pt: [160, 320],
919
+ reference_px: [160, 320],
920
+ },
895
921
  },
896
922
  ],
897
923
  });
@@ -915,6 +941,29 @@ describe('render script runtime (package_source: repo)', () => {
915
941
  expect(xcrunCalls).toContain('--compile');
916
942
  expect(xcrunCalls).toContain('--platform macosx');
917
943
  });
944
+ test('also compiles catalogs in module bundles for Bundle.module references', async () => {
945
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
946
+ writeReference(project, 'home.png');
947
+ // Source catalog drives the main-bundle (Bundle.main) compile. The module
948
+ // bundle — which the fake toolchain materializes to mimic `swift build`
949
+ // copying a .process resource — must ALSO be compiled in place, or an
950
+ // Image("name", bundle: .module) reference finds only the raw catalog.
951
+ const catalog = join(project.packageDir, 'Assets', 'Media.xcassets');
952
+ mkdirSync(catalog, { recursive: true });
953
+ writeFileSync(join(catalog, 'Contents.json'), '{}');
954
+ const script = await buildScript(project, { package_source: 'repo' });
955
+ const xcrunLog = join(project.dir, 'xcrun-invocations.log');
956
+ const run = runScript(project, script, {
957
+ FAKE_XCRUN_LOG: xcrunLog,
958
+ FAKE_SWIFT_MODULE_CATALOG: 'FixtureViews',
959
+ });
960
+ expect(run.status).toBe(0);
961
+ const calls = readFileSync(xcrunLog, 'utf-8').trim().split('\n');
962
+ // The module bundle's own catalog is compiled into the bundle directory.
963
+ const moduleCompile = calls.find((c) => c.includes('.bundle/Media.xcassets'));
964
+ expect(moduleCompile).toBeTruthy();
965
+ expect(moduleCompile).toMatch(/--compile \S+\.bundle(\s|$)/);
966
+ });
918
967
  test('does not invoke actool when the package has no asset catalogs', async () => {
919
968
  const project = makeProject({ screens: { HomeView: 'home.png' } });
920
969
  writeReference(project, 'home.png');
@@ -938,6 +987,73 @@ describe('render script runtime (package_source: repo)', () => {
938
987
  expect(run.stderr).toContain('asset-catalog compile failed');
939
988
  });
940
989
  });
990
+ describe('render output trimming (package_source variants)', () => {
991
+ // The renderer forces the view into a fixed device-sized frame, so a small
992
+ // component lands in a large canvas with transparent margins. After each
993
+ // screen renders, the trim helper crops the PNG in place to its content
994
+ // bounding box so the rendered artifact IS the component, tight.
995
+ function readArtifact(project, relative) {
996
+ return readFileSync(artifact(project, relative), 'utf-8');
997
+ }
998
+ test('trims the rendered PNG in place via the Swift helper', async () => {
999
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1000
+ writeReference(project, 'home.png');
1001
+ const script = await buildScript(project, { package_source: 'repo' });
1002
+ const logPath = join(project.dir, 'swift-invocations.log');
1003
+ const run = runScript(project, script, { FAKE_SWIFT_LOG: logPath });
1004
+ expect(run.status).toBe(0);
1005
+ // The helper is invoked in script mode with the rendered PNG as in and out
1006
+ // (in-place overwrite), after the render run.
1007
+ const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
1008
+ const trimCall = invocations.find((c) => c.includes('Trim.swift'));
1009
+ expect(trimCall).toBeTruthy();
1010
+ expect(trimCall).toContain('ui-fidelity/rendered/HomeView.png');
1011
+ // The rendered file now holds the trim helper's output, not the raw render.
1012
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
1013
+ expect(readArtifact(project, 'ui-fidelity/rendered/HomeView.png')).toContain('trimmed:HomeView.png');
1014
+ });
1015
+ test('writes an aligned image and records rendered/logical/reference dimensions', async () => {
1016
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1017
+ writeReference(project, 'home.png');
1018
+ const run = runScript(project, await buildScript(project, { package_source: 'repo' }));
1019
+ expect(run.status).toBe(0);
1020
+ // The aligned (reference-sized) image is a separate artifact.
1021
+ expect(isPng(artifact(project, 'ui-fidelity/aligned/HomeView.png'))).toBe(true);
1022
+ expect(readArtifact(project, 'ui-fidelity/aligned/HomeView.png')).toContain('aligned:HomeView.png');
1023
+ // Dimensions are explicit: rendered px from the helper, logical points
1024
+ // derived from the scale (320/2, 640/2), reference px from the helper.
1025
+ const screen = readResult(project).screens[0];
1026
+ expect(screen.aligned_image_path).toBe('ui-fidelity/aligned/HomeView.png');
1027
+ expect(screen.dimensions).toEqual({
1028
+ rendered_px: [320, 640],
1029
+ logical_pt: [160, 320],
1030
+ reference_px: [160, 320],
1031
+ });
1032
+ });
1033
+ test('a failing trim warns but still renders (warn-and-continue)', async () => {
1034
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1035
+ writeReference(project, 'home.png');
1036
+ const script = await buildScript(project, { package_source: 'repo' });
1037
+ const run = runScript(project, script, { FAKE_TRIM_FAIL: '1' });
1038
+ // A trim failure never fails the step: the untrimmed render survives, with
1039
+ // no aligned image and no dimensions.
1040
+ expect(run.status).toBe(0);
1041
+ expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
1042
+ const screen = readResult(project).screens[0];
1043
+ expect(screen.status).toBe('rendered');
1044
+ expect(screen.aligned_image_path).toBeNull();
1045
+ expect(screen.dimensions).toBeNull();
1046
+ expect(run.stderr).toContain('trim failed');
1047
+ });
1048
+ test('trims renders shipped via package_source: inputs too', async () => {
1049
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1050
+ writeReference(project, 'home.png');
1051
+ stageArchive(project, shippedPackageEntries('pkg'));
1052
+ const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
1053
+ expect(run.status).toBe(0);
1054
+ expect(readArtifact(project, 'ui-fidelity/rendered/HomeView.png')).toContain('trimmed:HomeView.png');
1055
+ });
1056
+ });
941
1057
  describe('render script runtime (package_source: inputs)', () => {
942
1058
  test('renders every screen from a shipped archive with a single package root', async () => {
943
1059
  const project = makeProject({
@@ -966,6 +1082,12 @@ describe('render script runtime (package_source: inputs)', () => {
966
1082
  error: null,
967
1083
  reference_image_path: 'ui-fidelity/references/HomeView.png',
968
1084
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
1085
+ aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
1086
+ dimensions: {
1087
+ rendered_px: [320, 640],
1088
+ logical_pt: [160, 320],
1089
+ reference_px: [160, 320],
1090
+ },
969
1091
  },
970
1092
  {
971
1093
  screen: 'SettingsView',
@@ -973,6 +1095,12 @@ describe('render script runtime (package_source: inputs)', () => {
973
1095
  error: null,
974
1096
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
975
1097
  rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
1098
+ aligned_image_path: 'ui-fidelity/aligned/SettingsView.png',
1099
+ dimensions: {
1100
+ rendered_px: [320, 640],
1101
+ logical_pt: [160, 320],
1102
+ reference_px: [160, 320],
1103
+ },
976
1104
  },
977
1105
  ],
978
1106
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",