@invarn/cibuild 2.0.5 → 2.0.7

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.
@@ -157,6 +157,34 @@ export interface RenderScriptInternals {
157
157
  generateProbeSwift(options: HarnessGeneratorOptions): string;
158
158
  generateScreenSwift(screen: string, options: HarnessGeneratorOptions): string;
159
159
  }
160
+ /**
161
+ * Runtime support for trimming the rendered output (package_source variants).
162
+ *
163
+ * The render entry forces the view into a fixed device-sized frame, so a
164
+ * component smaller than the canvas renders with transparent margins. After a
165
+ * screen renders, crop the PNG in place to the bounding box of its
166
+ * non-transparent pixels, so the rendered artifact is the component itself,
167
+ * tight -- no canvas, no background ambiguity for the human comparing screens.
168
+ *
169
+ * The crop is done by a small CoreGraphics helper run in Swift's
170
+ * script-interpreter mode (so it needs no extra harness target and reuses the
171
+ * one `swift` the runner already has). Best-effort: a trim failure is logged
172
+ * (warn-and-continue) and the untrimmed render is kept -- a cosmetic post-step
173
+ * never fails an otherwise good render. This is prep, not scoring: it produces
174
+ * a tighter image for the human's judgment and computes nothing about
175
+ * fidelity.
176
+ */
177
+ /**
178
+ * The CoreGraphics trim/align helper source, run via
179
+ * `swift <Trim.swift> <in> <out> <aligned> <ref>` (script-interpreter mode).
180
+ * Crops a PNG to its content bounding box (transparent or uniform-background
181
+ * margins), writes a reference-sized aligned copy, and prints a TRIMDIMS line.
182
+ *
183
+ * Exported so a real-toolchain test can exercise the pixel logic directly --
184
+ * the fake `swift` in the unit tests cannot, which is why a background-detection
185
+ * bug shipped twice undetected before a real-pixel guard existed.
186
+ */
187
+ export declare const TRIM_HELPER_SWIFT_SOURCE: string;
160
188
  /**
161
189
  * Evaluates the embedded generator source and returns the real functions, so
162
190
  * unit tests exercise exactly the code that ships inside the runtime script.
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;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;AAEpE;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IACnC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C,mFAAmF;AACnF,eAAO,MAAM,aAAa,IAAI,CAAC;AAE/B,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D,sEAAsE;AACtE,eAAO,MAAM,sBAAsB,EAAE,aAAsB,CAAC;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,QAAmB,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAWzD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAQzD;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED,4DAA4D;AAC5D,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IACzF,kBAAkB,CAAC,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IAC7D,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;CAC/E;AAsvCD;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,qBAAqB,CAShE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAUvE;AAED;;;;GAIG;AACH,qBAAa,4BAA6B,SAAQ,gBAAgB;IAChE,yBAAyB,CACvB,MAAM,EAAE,sBAAsB,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAoCpB,OAAO,CACX,MAAM,EAAE,sBAAsB,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CA0DpB"}
1
+ {"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"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"}
@@ -1003,113 +1003,167 @@ function compileAssetCatalogs(packageDir, harnessDir) {
1003
1003
  * a tighter image for the human's judgment and computes nothing about
1004
1004
  * fidelity.
1005
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))'
1006
+ /**
1007
+ * The CoreGraphics trim/align helper source, run via
1008
+ * `swift <Trim.swift> <in> <out> <aligned> <ref>` (script-interpreter mode).
1009
+ * Crops a PNG to its content bounding box (transparent or uniform-background
1010
+ * margins), writes a reference-sized aligned copy, and prints a TRIMDIMS line.
1011
+ *
1012
+ * Exported so a real-toolchain test can exercise the pixel logic directly --
1013
+ * the fake `swift` in the unit tests cannot, which is why a background-detection
1014
+ * bug shipped twice undetected before a real-pixel guard existed.
1015
+ */
1016
+ export const TRIM_HELPER_SWIFT_SOURCE = [
1017
+ 'import Foundation',
1018
+ 'import ImageIO',
1019
+ 'import CoreGraphics',
1020
+ 'import UniformTypeIdentifiers',
1021
+ '',
1022
+ 'func loadCGImage(_ path: String) -> CGImage? {',
1023
+ ' guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),',
1024
+ ' let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { return nil }',
1025
+ ' return img',
1026
+ '}',
1027
+ '',
1028
+ 'func writePNG(_ image: CGImage, to path: String) -> Bool {',
1029
+ ' guard let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else { return false }',
1030
+ ' CGImageDestinationAddImage(dest, image, nil)',
1031
+ ' return CGImageDestinationFinalize(dest)',
1032
+ '}',
1033
+ '',
1034
+ '// Redraw an image at an exact pixel size (used to match the reference).',
1035
+ 'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
1036
+ ' if w <= 0 || h <= 0 { return nil }',
1037
+ ' let bytesPerRow = w * 4',
1038
+ ' let space = CGColorSpaceCreateDeviceRGB()',
1039
+ ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
1040
+ ' ctx.interpolationQuality = .high',
1041
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
1042
+ ' return ctx.makeImage()',
1043
+ '}',
1044
+ '',
1045
+ '// Bounding box of content: pixels that are neither transparent nor part of a',
1046
+ '// uniform solid background. Two passes, because the renderer forces the view',
1047
+ '// into a fixed device frame whose margins are transparent: (1) bound the',
1048
+ '// non-transparent pixels, then (2) detect a uniform opaque background from the',
1049
+ '// CORNERS OF THAT OPAQUE BOX -- not the raw frame, whose corners are the',
1050
+ '// transparent device margin -- and bound everything that is not that',
1051
+ '// background. A wrapper that fills the canvas with .background(.white) is thus',
1052
+ '// trimmed to its real content. When the opaque corners disagree, only',
1053
+ '// transparency is trimmed. Top-left image coordinates.',
1054
+ 'func contentBoundingBox(_ image: CGImage) -> CGRect? {',
1055
+ ' let w = image.width, h = image.height',
1056
+ ' if w == 0 || h == 0 { return nil }',
1057
+ ' let bytesPerRow = w * 4',
1058
+ ' var data = [UInt8](repeating: 0, count: bytesPerRow * h)',
1059
+ ' let space = CGColorSpaceCreateDeviceRGB()',
1060
+ ' guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
1061
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
1062
+ ' // Tight tolerance: exact solid fills match, near-colored content does not.',
1063
+ ' let tolerance = 4',
1064
+ ' func sample(_ x: Int, _ y: Int) -> (Int, Int, Int, Int) {',
1065
+ ' let i = y * bytesPerRow + x * 4',
1066
+ ' return (Int(data[i]), Int(data[i + 1]), Int(data[i + 2]), Int(data[i + 3]))',
1067
+ ' }',
1068
+ ' // Pass 1: bounding box of all non-transparent pixels.',
1069
+ ' var aMinX = w, aMinY = h, aMaxX = -1, aMaxY = -1',
1070
+ ' for y in 0..<h {',
1071
+ ' let rowBase = y * bytesPerRow',
1072
+ ' for x in 0..<w {',
1073
+ ' if data[rowBase + x * 4 + 3] > 0 {',
1074
+ ' if x < aMinX { aMinX = x }',
1075
+ ' if x > aMaxX { aMaxX = x }',
1076
+ ' if y < aMinY { aMinY = y }',
1077
+ ' if y > aMaxY { aMaxY = y }',
1078
+ ' }',
1079
+ ' }',
1080
+ ' }',
1081
+ ' if aMaxX < 0 { return nil }',
1082
+ ' let opaqueBox = CGRect(x: aMinX, y: aMinY, width: aMaxX - aMinX + 1, height: aMaxY - aMinY + 1)',
1083
+ ' // Detect a uniform opaque background from the corners of the opaque box.',
1084
+ ' let corners = [sample(aMinX, aMinY), sample(aMaxX, aMinY), sample(aMinX, aMaxY), sample(aMaxX, aMaxY)]',
1085
+ ' var background: (Int, Int, Int, Int)? = corners[0]',
1086
+ ' for c in corners {',
1087
+ ' if let bg = background {',
1088
+ ' if c.3 == 0 || abs(c.0 - bg.0) > tolerance || abs(c.1 - bg.1) > tolerance || abs(c.2 - bg.2) > tolerance || abs(c.3 - bg.3) > tolerance {',
1089
+ ' background = nil',
1090
+ ' }',
1091
+ ' }',
1092
+ ' }',
1093
+ ' // No uniform background: the opaque box is the content.',
1094
+ ' guard let bg = background else { return opaqueBox }',
1095
+ ' // Pass 2: within the opaque box, bound everything that is not the background.',
1096
+ ' var minX = aMaxX + 1, minY = aMaxY + 1, maxX = aMinX - 1, maxY = aMinY - 1',
1097
+ ' for y in aMinY...aMaxY {',
1098
+ ' let rowBase = y * bytesPerRow',
1099
+ ' for x in aMinX...aMaxX {',
1100
+ ' let idx = rowBase + x * 4',
1101
+ ' let a = Int(data[idx + 3])',
1102
+ ' if a == 0 { continue }',
1103
+ ' let r = Int(data[idx]), g = Int(data[idx + 1]), b = Int(data[idx + 2])',
1104
+ ' if abs(r - bg.0) <= tolerance && abs(g - bg.1) <= tolerance && abs(b - bg.2) <= tolerance && abs(a - bg.3) <= tolerance { continue }',
1105
+ ' if x < minX { minX = x }',
1106
+ ' if x > maxX { maxX = x }',
1107
+ ' if y < minY { minY = y }',
1108
+ ' if y > maxY { maxY = y }',
1109
+ ' }',
1110
+ ' }',
1111
+ ' // All background, no distinct content: keep the opaque box, never crop to nothing.',
1112
+ ' if maxX < minX { return opaqueBox }',
1113
+ ' return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)',
1114
+ '}',
1115
+ '',
1116
+ 'let arguments = CommandLine.arguments',
1117
+ 'guard arguments.count >= 3 else {',
1118
+ ' FileHandle.standardError.write(Data("usage: Trim <in.png> <out.png>".utf8))',
1119
+ ' exit(64)',
1120
+ '}',
1121
+ 'let inputPath = arguments[1]',
1122
+ 'let outputPath = arguments[2]',
1123
+ 'guard let image = loadCGImage(inputPath) else {',
1124
+ ' FileHandle.standardError.write(Data(("could not read image at " + inputPath).utf8))',
1125
+ ' exit(65)',
1126
+ '}',
1127
+ 'guard let box = contentBoundingBox(image) else {',
1128
+ ' FileHandle.standardError.write(Data("no content to trim (image is uniform)".utf8))',
1129
+ ' exit(66)',
1130
+ '}',
1131
+ 'guard let cropped = image.cropping(to: box) else {',
1132
+ ' FileHandle.standardError.write(Data("could not crop image".utf8))',
1133
+ ' exit(67)',
1134
+ '}',
1135
+ 'let croppedWidth = cropped.width',
1136
+ 'let croppedHeight = cropped.height',
1137
+ 'guard writePNG(cropped, to: outputPath) else {',
1138
+ ' FileHandle.standardError.write(Data(("could not write trimmed PNG to " + outputPath).utf8))',
1139
+ ' exit(68)',
1140
+ '}',
1141
+ '',
1142
+ '// Optional aligned output: the trimmed image resized to the reference pixel',
1143
+ '// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
1144
+ 'var referenceWidth = 0',
1145
+ 'var referenceHeight = 0',
1146
+ 'if arguments.count >= 5 {',
1147
+ ' let alignedPath = arguments[3]',
1148
+ ' let referencePath = arguments[4]',
1149
+ ' if let reference = loadCGImage(referencePath) {',
1150
+ ' referenceWidth = reference.width',
1151
+ ' referenceHeight = reference.height',
1152
+ ' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
1153
+ ' _ = writePNG(aligned, to: alignedPath)',
1154
+ ' }',
1155
+ ' }',
1156
+ '}',
1157
+ '',
1158
+ '// Report: rendered (trimmed) px, reference px (0 0 when unavailable), then',
1159
+ '// the pre-trim source px so the caller can log how much margin was removed.',
1160
+ 'print("TRIMDIMS " + String(croppedWidth) + " " + String(croppedHeight) + " " + String(referenceWidth) + " " + String(referenceHeight) + " " + String(image.width) + " " + String(image.height))'
1111
1161
  ].join('\n') + '\n';
1112
-
1162
+ const RENDER_SCRIPT_TRIM_STAGE = '\n// ---- render trimming stage (package_source variants) ----\n' +
1163
+ 'var TRIM_HELPER_SWIFT = ' +
1164
+ JSON.stringify(TRIM_HELPER_SWIFT_SOURCE) +
1165
+ ';\n' +
1166
+ String.raw `
1113
1167
  // Write the helper into the harness dir once (idempotent across screens).
1114
1168
  function ensureTrimHelper(harnessDir) {
1115
1169
  var helperPath = path.join(harnessDir, 'Trim.swift');
@@ -1124,15 +1178,23 @@ function roundTwo(value) {
1124
1178
  return Math.round(value * 100) / 100;
1125
1179
  }
1126
1180
 
1127
- // Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH>" line (last one wins).
1181
+ // Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH> <srcW> <srcH>" line
1182
+ // (rendered/reference/source px; last one wins).
1128
1183
  function parseTrimDims(stdout) {
1129
1184
  var lines = String(stdout || '').split('\n');
1130
1185
  for (var i = lines.length - 1; i >= 0; i--) {
1131
1186
  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])];
1187
+ if (parts[0] === 'TRIMDIMS' && parts.length >= 7) {
1188
+ var nums = [
1189
+ Number(parts[1]), Number(parts[2]), Number(parts[3]),
1190
+ Number(parts[4]), Number(parts[5]), Number(parts[6]),
1191
+ ];
1134
1192
  if (nums.every(function (n) { return Number.isFinite(n); })) {
1135
- return { rendered: [nums[0], nums[1]], reference: [nums[2], nums[3]] };
1193
+ return {
1194
+ rendered: [nums[0], nums[1]],
1195
+ reference: [nums[2], nums[3]],
1196
+ source: [nums[4], nums[5]],
1197
+ };
1136
1198
  }
1137
1199
  }
1138
1200
  }
@@ -1171,6 +1233,12 @@ function trimRenderedImage(entry, renderedPath, harnessDir) {
1171
1233
  logical_pt: [roundTwo(dims.rendered[0] / scale), roundTwo(dims.rendered[1] / scale)],
1172
1234
  reference_px: dims.reference[0] > 0 && dims.reference[1] > 0 ? dims.reference : null,
1173
1235
  };
1236
+ // Surface how much margin was removed -- a no-op (source == rendered) means
1237
+ // the view fills an opaque canvas with no transparent or uniform margin.
1238
+ log(
1239
+ 'trimmed ' + entry.screen + ': ' + dims.source[0] + 'x' + dims.source[1] +
1240
+ ' -> ' + dims.rendered[0] + 'x' + dims.rendered[1]
1241
+ );
1174
1242
  }
1175
1243
  if (fs.existsSync(alignedPath)) {
1176
1244
  entry.aligned_image_path = alignedRelative;
@@ -21,7 +21,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSyn
21
21
  import { tmpdir } from 'node:os';
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
- import { DEFAULT_RENDER_SIZE, DEFAULT_SCALE, UiFidelityRenderStepExecutor, generateRenderScript, getRenderScriptInternals, parseRenderSize, parseScale, } from './ui-fidelity-render.js';
24
+ import { DEFAULT_RENDER_SIZE, DEFAULT_SCALE, UiFidelityRenderStepExecutor, generateRenderScript, getRenderScriptInternals, parseRenderSize, parseScale, TRIM_HELPER_SWIFT_SOURCE, } from './ui-fidelity-render.js';
25
25
  import { clearRegistry, getStepMetadata, hasStep } from './registry.js';
26
26
  import { initializeStepRegistry } from './index.js';
27
27
  import { testConfig } from './test-config.js';
@@ -116,7 +116,8 @@ const FAKE_SWIFT_LINES = [
116
116
  // stands in for the CoreGraphics trim helper. Overwrites <out> with a PNG
117
117
  // marked "trimmed:<in basename>" and writes an "aligned:<in basename>" PNG so
118
118
  // tests can prove both outputs. Emits a fixed TRIMDIMS line (rendered
119
- // 320x640, reference 160x320). FAKE_TRIM_FAIL exercises warn-and-continue.
119
+ // 320x640, reference 160x320, source 400x800). FAKE_TRIM_FAIL exercises
120
+ // warn-and-continue.
120
121
  ' if [ -n "$FAKE_TRIM_FAIL" ]; then echo "trim: simulated failure" >&2; exit 1; fi',
121
122
  ' in_png="$1"; out_png="$2"; aligned_png="$3"',
122
123
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out_png\"",
@@ -125,7 +126,7 @@ const FAKE_SWIFT_LINES = [
125
126
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$aligned_png\"",
126
127
  ' printf \'aligned:%s\' "$(basename "$in_png")" >> "$aligned_png"',
127
128
  ' fi',
128
- ' echo "TRIMDIMS 320 640 160 320"',
129
+ ' echo "TRIMDIMS 320 640 160 320 400 800"',
129
130
  ' exit 0 ;;',
130
131
  ' *)',
131
132
  ' exit 0 ;;',
@@ -1030,6 +1031,15 @@ describe('render output trimming (package_source variants)', () => {
1030
1031
  reference_px: [160, 320],
1031
1032
  });
1032
1033
  });
1034
+ test('logs how much margin the trim removed (source -> rendered)', async () => {
1035
+ const project = makeProject({ screens: { HomeView: 'home.png' } });
1036
+ writeReference(project, 'home.png');
1037
+ const run = runScript(project, await buildScript(project, { package_source: 'repo' }));
1038
+ expect(run.status).toBe(0);
1039
+ // The pre-trim source size and the trimmed size are both surfaced, so a
1040
+ // no-op trim (an opaque-canvas wrapper) is visible in the build log.
1041
+ expect(run.stdout).toContain('trimmed HomeView: 400x800 -> 320x640');
1042
+ });
1033
1043
  test('a failing trim warns but still renders (warn-and-continue)', async () => {
1034
1044
  const project = makeProject({ screens: { HomeView: 'home.png' } });
1035
1045
  writeReference(project, 'home.png');
@@ -1499,4 +1509,60 @@ describe('committed fixture package', () => {
1499
1509
  expectNoAbsolutePathLeaks(project, result);
1500
1510
  }, 600_000);
1501
1511
  });
1512
+ // The fake `swift` cannot exercise the trim helper's CoreGraphics pixel logic,
1513
+ // so the bounding-box behavior is guarded here with the real toolchain. This
1514
+ // is the regression test for the bug where a wrapper that fills the canvas with
1515
+ // an opaque .background(...) was not trimmed to its content: the renderer forces
1516
+ // the view into a fixed device frame whose margins are transparent, so the
1517
+ // helper must detect the solid background from the corners of the opaque region,
1518
+ // not the raw frame.
1519
+ describe('trim helper pixel logic (real toolchain)', () => {
1520
+ const realSwiftTest = process.env.CIBUILD_UI_FIDELITY_REAL_SWIFT === '1' ? test : test.skip;
1521
+ // PNG IHDR carries width/height as big-endian uint32 at byte offsets 16/20.
1522
+ function pngSize(filePath) {
1523
+ const buf = readFileSync(filePath);
1524
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
1525
+ }
1526
+ // Swift program that writes a fixture mimicking a real render: a transparent
1527
+ // device frame, an opaque white wrapper inset within it, and a distinct-color
1528
+ // "content" rectangle inside the white. Crop should land on the content.
1529
+ const MAKE_FIXTURE_SWIFT = [
1530
+ 'import Foundation',
1531
+ 'import ImageIO',
1532
+ 'import CoreGraphics',
1533
+ 'import UniformTypeIdentifiers',
1534
+ 'let w = 420, h = 320, cs = CGColorSpaceCreateDeviceRGB()',
1535
+ 'let c = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!',
1536
+ 'c.clear(CGRect(x: 0, y: 0, width: w, height: h))',
1537
+ 'c.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)',
1538
+ 'c.fill(CGRect(x: 20, y: 40, width: 375, height: 200))',
1539
+ 'c.setFillColor(red: 1, green: 240.0/255, blue: 238.0/255, alpha: 1)',
1540
+ 'c.fill(CGRect(x: 36, y: 56, width: 343, height: 168))',
1541
+ 'let out = CommandLine.arguments[1]',
1542
+ 'let d = CGImageDestinationCreateWithURL(URL(fileURLWithPath: out) as CFURL, UTType.png.identifier as CFString, 1, nil)!',
1543
+ 'CGImageDestinationAddImage(d, c.makeImage()!, nil)',
1544
+ '_ = CGImageDestinationFinalize(d)',
1545
+ ].join('\n') + '\n';
1546
+ realSwiftTest('crops an opaque-background wrapper inside a transparent frame to its content', () => {
1547
+ const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-trim-real-'));
1548
+ tempDirs.push(dir);
1549
+ const makePath = join(dir, 'Make.swift');
1550
+ const trimPath = join(dir, 'Trim.swift');
1551
+ const inputPath = join(dir, 'input.png');
1552
+ const outputPath = join(dir, 'out.png');
1553
+ writeFileSync(makePath, MAKE_FIXTURE_SWIFT);
1554
+ writeFileSync(trimPath, TRIM_HELPER_SWIFT_SOURCE);
1555
+ const make = spawnSync('swift', [makePath, inputPath], { encoding: 'utf-8' });
1556
+ expect(make.status).toBe(0);
1557
+ // 420x320 transparent canvas, 375x200 white wrapper, 343x168 pink card.
1558
+ expect(pngSize(inputPath)).toEqual({ width: 420, height: 320 });
1559
+ const trim = spawnSync('swift', [trimPath, inputPath, outputPath], {
1560
+ encoding: 'utf-8',
1561
+ });
1562
+ expect(trim.status).toBe(0);
1563
+ // The crop lands on the content card, NOT the white wrapper (375x200) and
1564
+ // NOT the raw canvas (420x320).
1565
+ expect(pngSize(outputPath)).toEqual({ width: 343, height: 168 });
1566
+ }, 600_000);
1567
+ });
1502
1568
  //# 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.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",