@invarn/cibuild 2.0.4 → 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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"}
@@ -986,6 +986,197 @@ function compileAssetCatalogs(packageDir, harnessDir) {
986
986
  }
987
987
  }
988
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))
1163
+ );
1164
+ return;
1165
+ }
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
+ }
1178
+ }
1179
+ `;
989
1180
  /**
990
1181
  * Replaces exactly one occurrence of `anchor` in `source`. Throws when the
991
1182
  * anchor is missing or ambiguous, so any drift between the v1 runtime text
@@ -1093,6 +1284,21 @@ function buildPackageSourceMain() {
1093
1284
  main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_ASSET_CATALOG_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
1094
1285
  main = replaceOnce(main, ' renderable.forEach(function (entry) {', ' compileAssetCatalogs(packagePath, harnessDir);\n\n' +
1095
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
+ ' };');
1096
1302
  return main;
1097
1303
  }
1098
1304
  const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
@@ -111,6 +111,22 @@ const FAKE_SWIFT_LINES = [
111
111
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out\"",
112
112
  ' printf \'fake-render:%s\' "$screen" >> "$out"',
113
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 ;;',
114
130
  ' *)',
115
131
  ' exit 0 ;;',
116
132
  'esac',
@@ -896,6 +912,12 @@ describe('render script runtime (package_source: repo)', () => {
896
912
  error: null,
897
913
  reference_image_path: 'ui-fidelity/references/HomeView.png',
898
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
+ },
899
921
  },
900
922
  ],
901
923
  });
@@ -965,6 +987,73 @@ describe('render script runtime (package_source: repo)', () => {
965
987
  expect(run.stderr).toContain('asset-catalog compile failed');
966
988
  });
967
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
+ });
968
1057
  describe('render script runtime (package_source: inputs)', () => {
969
1058
  test('renders every screen from a shipped archive with a single package root', async () => {
970
1059
  const project = makeProject({
@@ -993,6 +1082,12 @@ describe('render script runtime (package_source: inputs)', () => {
993
1082
  error: null,
994
1083
  reference_image_path: 'ui-fidelity/references/HomeView.png',
995
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
+ },
996
1091
  },
997
1092
  {
998
1093
  screen: 'SettingsView',
@@ -1000,6 +1095,12 @@ describe('render script runtime (package_source: inputs)', () => {
1000
1095
  error: null,
1001
1096
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
1002
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
+ },
1003
1104
  },
1004
1105
  ],
1005
1106
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.0.4",
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",