@invarn/cibuild 2.0.8 → 2.1.0

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.
Files changed (29) hide show
  1. package/dist/cli.cjs +688 -69
  2. package/dist/src/lib.d.ts +2 -0
  3. package/dist/src/lib.d.ts.map +1 -1
  4. package/dist/src/lib.js +1 -0
  5. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  6. package/dist/src/yaml/steps/index.js +22 -0
  7. package/dist/src/yaml/steps/render-post-processor.d.ts +73 -0
  8. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -0
  9. package/dist/src/yaml/steps/render-post-processor.js +303 -0
  10. package/dist/src/yaml/steps/render-post-processor.test.d.ts +16 -0
  11. package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -0
  12. package/dist/src/yaml/steps/render-post-processor.test.js +238 -0
  13. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +66 -0
  14. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -0
  15. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +249 -0
  16. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +13 -0
  17. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +1 -0
  18. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +299 -0
  19. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +92 -0
  20. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -0
  21. package/dist/src/yaml/steps/ui-fidelity-render-android.js +726 -0
  22. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +2 -0
  23. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +1 -0
  24. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +377 -0
  25. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -28
  26. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
  27. package/dist/src/yaml/steps/ui-fidelity-render.js +1 -259
  28. package/dist/src/yaml/steps/ui-fidelity-render.test.js +1 -57
  29. package/package.json +1 -1
package/dist/src/lib.d.ts CHANGED
@@ -21,6 +21,8 @@ export type { ConversionResult } from './yaml/converter.js';
21
21
  export { pipeline } from './pipeline.js';
22
22
  export { renderUiFidelityPreview } from './yaml/steps/ui-fidelity-preview.js';
23
23
  export type { UiFidelityPreviewOptions, UiFidelityPreviewScreen, UiFidelityPreviewResult, UiFidelityPreviewScreenResult, UiFidelityPreviewDimensions, } from './yaml/steps/ui-fidelity-preview.js';
24
+ export { renderUiFidelityPreviewAndroid, ANDROID_TOOLCHAIN_MISSING, } from './yaml/steps/ui-fidelity-preview-android.js';
25
+ export type { UiFidelityPreviewAndroidOptions } from './yaml/steps/ui-fidelity-preview-android.js';
24
26
  export { getStepExecutor, hasStep, getAvailableSteps, registerStep, getStepMetadata, } from './yaml/steps/registry.js';
25
27
  export type { StepDef, PipelineDef, CIConfig, PipelineFactory, PipelineContext, StepBuilders, } from './types.js';
26
28
  export type { StepExecutor, StepMetadata, StepPlatform, StepExecutionContext, } from './yaml/steps/base.js';
@@ -1 +1 @@
1
- {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/lib.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,uBAAuB,CAAC;AAI/B,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIjE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzC,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAI1B,OAAO,EACL,wBAAwB,EACxB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAI5D,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAIzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAC;AAC9E,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,6BAA6B,EAC7B,2BAA2B,GAC5B,MAAM,qCAAqC,CAAC;AAI7C,OAAO,EACL,eAAe,EACf,OAAO,EACP,iBAAiB,EACjB,YAAY,EACZ,eAAe,GAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EACV,OAAO,EACP,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,YAAY,GACb,MAAM,YAAY,CAAC;AAIpB,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAI9B,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,QAAQ,EACR,cAAc,EACd,UAAU,EACV,OAAO,EACP,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,WAAW,GACZ,MAAM,iBAAiB,CAAC;AAIzB,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/lib.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,uBAAuB,CAAC;AAI/B,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIjE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzC,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAI1B,OAAO,EACL,wBAAwB,EACxB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAI5D,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAIzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAC;AAC9E,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,6BAA6B,EAC7B,2BAA2B,GAC5B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,6CAA6C,CAAC;AACrD,YAAY,EAAE,+BAA+B,EAAE,MAAM,6CAA6C,CAAC;AAInG,OAAO,EACL,eAAe,EACf,OAAO,EACP,iBAAiB,EACjB,YAAY,EACZ,eAAe,GAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EACV,OAAO,EACP,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,YAAY,GACb,MAAM,YAAY,CAAC;AAIpB,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAI9B,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,QAAQ,EACR,cAAc,EACd,UAAU,EACV,OAAO,EACP,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,WAAW,GACZ,MAAM,iBAAiB,CAAC;AAIzB,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/src/lib.js CHANGED
@@ -27,6 +27,7 @@ export { convertYAMLToPipelineDef, getAvailableWorkflows, YAMLConversionError, }
27
27
  export { pipeline } from './pipeline.js';
28
28
  // ─── UI fidelity local preview ──────────────────────────────────────────
29
29
  export { renderUiFidelityPreview } from './yaml/steps/ui-fidelity-preview.js';
30
+ export { renderUiFidelityPreviewAndroid, ANDROID_TOOLCHAIN_MISSING, } from './yaml/steps/ui-fidelity-preview-android.js';
30
31
  // ─── Step registry ──────────────────────────────────────────────────────
31
32
  export { getStepExecutor, hasStep, getAvailableSteps, registerStep, getStepMetadata, } from './yaml/steps/registry.js';
32
33
  // ─── Error types ────────────────────────────────────────────────────────
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAqCH;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CA63C7C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAyCH;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAs5C7C"}
@@ -25,6 +25,7 @@ import { BitriseApkInfoStepExecutor } from './bitrise-apk-info.js';
25
25
  import { GooglePlayDeployStepExecutor } from './google-play-deploy.js';
26
26
  import { AppStoreDeployStepExecutor } from './app-store-deploy.js';
27
27
  import { UiFidelityRenderStepExecutor, DEFAULT_RENDER_SIZE, DEFAULT_SCALE, DEFAULT_PACKAGE_SOURCE, } from './ui-fidelity-render.js';
28
+ import { UiFidelityRenderAndroidStepExecutor, DEFAULT_GRADLE_TASK, } from './ui-fidelity-render-android.js';
28
29
  /**
29
30
  * Initializes the step registry with all available steps
30
31
  * This function should be called once at application startup
@@ -795,6 +796,27 @@ export function initializeStepRegistry() {
795
796
  },
796
797
  },
797
798
  });
799
+ // UI fidelity — render Jetpack Compose wrappers to PNGs via Roborazzi +
800
+ // Robolectric on the JVM (no device/emulator), on a macOS runner
801
+ registerStep('ui-fidelity-render-android', new UiFidelityRenderAndroidStepExecutor(), {
802
+ name: 'ui-fidelity-render-android',
803
+ description: 'Render parameterless Jetpack Compose wrappers from a shipped Gradle project to PNGs ' +
804
+ 'via Roborazzi + Robolectric on the JVM (no device, no emulator)',
805
+ platform: 'android',
806
+ inputs: {
807
+ scale: {
808
+ description: 'Display scale factor used when reporting logical points; must match the ' +
809
+ 'density qualifier the render test pins (xhdpi = 2)',
810
+ required: false,
811
+ default: DEFAULT_SCALE,
812
+ },
813
+ gradle_task: {
814
+ description: 'Gradle task that records the Roborazzi PNGs, run from the project root',
815
+ required: false,
816
+ default: DEFAULT_GRADLE_TASK,
817
+ },
818
+ },
819
+ });
798
820
  // Export xcarchive
799
821
  registerStep('export-xcarchive', new ExportXcarchiveStepExecutor(), {
800
822
  name: 'export-xcarchive',
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared render-output post-processor.
3
+ *
4
+ * The single source of truth for turning a freshly rendered PNG into the
5
+ * artifacts the fidelity loop compares: a content-trimmed image, a
6
+ * reference-sized aligned copy, and the reported dimensions (rendered px /
7
+ * logical pt / reference px). It operates on PNGs regardless of how they were
8
+ * produced -- a SwiftUI `ImageRenderer` render or a Compose/Roborazzi render --
9
+ * so crop/align/dimension behavior cannot drift between platforms. Both render
10
+ * paths run on the macOS runner fleet, so the pixel work is one CoreGraphics
11
+ * helper invoked in Swift's script-interpreter mode; the surrounding JS
12
+ * orchestration is spliced into each platform's generated runtime script.
13
+ *
14
+ * This module is consumed by `ui-fidelity-render.ts` (iOS) and, once it lands,
15
+ * the Android render step. The iOS step embeds these exact strings, so its
16
+ * generated script -- and therefore its rendered output -- stays byte-identical
17
+ * to before this extraction.
18
+ */
19
+ /**
20
+ * Runtime support for trimming the rendered output (package_source variants).
21
+ *
22
+ * The render entry forces the view into a fixed device-sized frame, so a
23
+ * component smaller than the canvas renders with transparent margins. After a
24
+ * screen renders, crop the PNG in place to the bounding box of its
25
+ * non-transparent pixels, so the rendered artifact is the component itself,
26
+ * tight -- no canvas, no background ambiguity for the human comparing screens.
27
+ *
28
+ * The crop is done by a small CoreGraphics helper run in Swift's
29
+ * script-interpreter mode (so it needs no extra harness target and reuses the
30
+ * one `swift` the runner already has). Best-effort: a trim failure is logged
31
+ * (warn-and-continue) and the untrimmed render is kept -- a cosmetic post-step
32
+ * never fails an otherwise good render. This is prep, not scoring: it produces
33
+ * a tighter image for the human's judgment and computes nothing about
34
+ * fidelity.
35
+ */
36
+ /**
37
+ * The CoreGraphics trim/align helper source, run via
38
+ * `swift <Trim.swift> <in> <out> <aligned> <ref>` (script-interpreter mode).
39
+ * Crops a PNG to its content bounding box (transparent or uniform-background
40
+ * margins), writes a reference-sized aligned copy, and prints a TRIMDIMS line.
41
+ *
42
+ * Exported so a real-toolchain test can exercise the pixel logic directly --
43
+ * the fake `swift` in the unit tests cannot, which is why a background-detection
44
+ * bug shipped twice undetected before a real-pixel guard existed.
45
+ */
46
+ export declare const TRIM_HELPER_SWIFT_SOURCE: string;
47
+ export declare const RENDER_SCRIPT_TRIM_STAGE: string;
48
+ /** A rendered/reference/source dimension pair in pixels, as TRIMDIMS reports. */
49
+ export type TrimDimsPair = [number, number];
50
+ /** Parsed TRIMDIMS line: rendered (trimmed) / reference / pre-trim source px. */
51
+ export interface TrimDims {
52
+ rendered: TrimDimsPair;
53
+ reference: TrimDimsPair;
54
+ source: TrimDimsPair;
55
+ }
56
+ /**
57
+ * The pure, platform-neutral dimension-reporting helpers embedded in the
58
+ * trim-stage script. `parseTrimDims` reads the helper's TRIMDIMS stdout line;
59
+ * `roundTwo` is the logical-pt rounding primitive.
60
+ */
61
+ export interface PostProcessorInternals {
62
+ parseTrimDims(stdout: string): TrimDims | null;
63
+ roundTwo(value: number): number;
64
+ }
65
+ /**
66
+ * Evaluates the embedded trim-stage source and returns its pure helpers, so
67
+ * unit tests exercise exactly the code that ships inside the runtime script
68
+ * (mirrors getRenderScriptInternals in ui-fidelity-render.ts). The other
69
+ * trim-stage functions reference runtime globals (fs, path, runSwift, ...)
70
+ * only when called, so defining them here is harmless.
71
+ */
72
+ export declare function getPostProcessorInternals(): PostProcessorInternals;
73
+ //# sourceMappingURL=render-post-processor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,EAAE,MA8JpB,CAAC;AAEpB,eAAO,MAAM,wBAAwB,EAAE,MAsFtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;IACxB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,sBAAsB,CAMlE"}
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Shared render-output post-processor.
3
+ *
4
+ * The single source of truth for turning a freshly rendered PNG into the
5
+ * artifacts the fidelity loop compares: a content-trimmed image, a
6
+ * reference-sized aligned copy, and the reported dimensions (rendered px /
7
+ * logical pt / reference px). It operates on PNGs regardless of how they were
8
+ * produced -- a SwiftUI `ImageRenderer` render or a Compose/Roborazzi render --
9
+ * so crop/align/dimension behavior cannot drift between platforms. Both render
10
+ * paths run on the macOS runner fleet, so the pixel work is one CoreGraphics
11
+ * helper invoked in Swift's script-interpreter mode; the surrounding JS
12
+ * orchestration is spliced into each platform's generated runtime script.
13
+ *
14
+ * This module is consumed by `ui-fidelity-render.ts` (iOS) and, once it lands,
15
+ * the Android render step. The iOS step embeds these exact strings, so its
16
+ * generated script -- and therefore its rendered output -- stays byte-identical
17
+ * to before this extraction.
18
+ */
19
+ /**
20
+ * Runtime support for trimming the rendered output (package_source variants).
21
+ *
22
+ * The render entry forces the view into a fixed device-sized frame, so a
23
+ * component smaller than the canvas renders with transparent margins. After a
24
+ * screen renders, crop the PNG in place to the bounding box of its
25
+ * non-transparent pixels, so the rendered artifact is the component itself,
26
+ * tight -- no canvas, no background ambiguity for the human comparing screens.
27
+ *
28
+ * The crop is done by a small CoreGraphics helper run in Swift's
29
+ * script-interpreter mode (so it needs no extra harness target and reuses the
30
+ * one `swift` the runner already has). Best-effort: a trim failure is logged
31
+ * (warn-and-continue) and the untrimmed render is kept -- a cosmetic post-step
32
+ * never fails an otherwise good render. This is prep, not scoring: it produces
33
+ * a tighter image for the human's judgment and computes nothing about
34
+ * fidelity.
35
+ */
36
+ /**
37
+ * The CoreGraphics trim/align helper source, run via
38
+ * `swift <Trim.swift> <in> <out> <aligned> <ref>` (script-interpreter mode).
39
+ * Crops a PNG to its content bounding box (transparent or uniform-background
40
+ * margins), writes a reference-sized aligned copy, and prints a TRIMDIMS line.
41
+ *
42
+ * Exported so a real-toolchain test can exercise the pixel logic directly --
43
+ * the fake `swift` in the unit tests cannot, which is why a background-detection
44
+ * bug shipped twice undetected before a real-pixel guard existed.
45
+ */
46
+ export const TRIM_HELPER_SWIFT_SOURCE = [
47
+ 'import Foundation',
48
+ 'import ImageIO',
49
+ 'import CoreGraphics',
50
+ 'import UniformTypeIdentifiers',
51
+ '',
52
+ 'func loadCGImage(_ path: String) -> CGImage? {',
53
+ ' guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),',
54
+ ' let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { return nil }',
55
+ ' return img',
56
+ '}',
57
+ '',
58
+ 'func writePNG(_ image: CGImage, to path: String) -> Bool {',
59
+ ' guard let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else { return false }',
60
+ ' CGImageDestinationAddImage(dest, image, nil)',
61
+ ' return CGImageDestinationFinalize(dest)',
62
+ '}',
63
+ '',
64
+ '// Draw `image` centered on a transparent reference-sized canvas, scaled to fit',
65
+ '// while PRESERVING its aspect ratio. Unlike `resize` (which stretches to exact',
66
+ '// dimensions and distorts proportions when the aspect ratios differ), this',
67
+ '// keeps the rendered shape honest: an aspect mismatch shows as transparent',
68
+ '// letterbox/pillarbox padding rather than a squished image, while the canvas',
69
+ '// stays exactly reference-sized so the pair still overlays 1:1.',
70
+ 'func fitPad(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
71
+ ' if w <= 0 || h <= 0 { return nil }',
72
+ ' let bytesPerRow = w * 4',
73
+ ' let space = CGColorSpaceCreateDeviceRGB()',
74
+ ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
75
+ ' ctx.clear(CGRect(x: 0, y: 0, width: w, height: h))',
76
+ ' ctx.interpolationQuality = .high',
77
+ ' let scale = min(Double(w) / Double(image.width), Double(h) / Double(image.height))',
78
+ ' let drawW = Double(image.width) * scale',
79
+ ' let drawH = Double(image.height) * scale',
80
+ ' let originX = (Double(w) - drawW) / 2.0',
81
+ ' let originY = (Double(h) - drawH) / 2.0',
82
+ ' ctx.draw(image, in: CGRect(x: originX, y: originY, width: drawW, height: drawH))',
83
+ ' return ctx.makeImage()',
84
+ '}',
85
+ '',
86
+ '// Bounding box of content: pixels that are neither transparent nor part of a',
87
+ '// uniform solid background. Two passes, because the renderer forces the view',
88
+ '// into a fixed device frame whose margins are transparent: (1) bound the',
89
+ '// non-transparent pixels, then (2) detect a uniform opaque background from the',
90
+ '// CORNERS OF THAT OPAQUE BOX -- not the raw frame, whose corners are the',
91
+ '// transparent device margin -- and bound everything that is not that',
92
+ '// background. A wrapper that fills the canvas with .background(.white) is thus',
93
+ '// trimmed to its real content. When the opaque corners disagree, only',
94
+ '// transparency is trimmed. Top-left image coordinates.',
95
+ 'func contentBoundingBox(_ image: CGImage) -> CGRect? {',
96
+ ' let w = image.width, h = image.height',
97
+ ' if w == 0 || h == 0 { return nil }',
98
+ ' let bytesPerRow = w * 4',
99
+ ' var data = [UInt8](repeating: 0, count: bytesPerRow * h)',
100
+ ' let space = CGColorSpaceCreateDeviceRGB()',
101
+ ' guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
102
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
103
+ ' // Tight tolerance: exact solid fills match, near-colored content does not.',
104
+ ' let tolerance = 4',
105
+ ' func sample(_ x: Int, _ y: Int) -> (Int, Int, Int, Int) {',
106
+ ' let i = y * bytesPerRow + x * 4',
107
+ ' return (Int(data[i]), Int(data[i + 1]), Int(data[i + 2]), Int(data[i + 3]))',
108
+ ' }',
109
+ ' // Pass 1: bounding box of all non-transparent pixels.',
110
+ ' var aMinX = w, aMinY = h, aMaxX = -1, aMaxY = -1',
111
+ ' for y in 0..<h {',
112
+ ' let rowBase = y * bytesPerRow',
113
+ ' for x in 0..<w {',
114
+ ' if data[rowBase + x * 4 + 3] > 0 {',
115
+ ' if x < aMinX { aMinX = x }',
116
+ ' if x > aMaxX { aMaxX = x }',
117
+ ' if y < aMinY { aMinY = y }',
118
+ ' if y > aMaxY { aMaxY = y }',
119
+ ' }',
120
+ ' }',
121
+ ' }',
122
+ ' if aMaxX < 0 { return nil }',
123
+ ' let opaqueBox = CGRect(x: aMinX, y: aMinY, width: aMaxX - aMinX + 1, height: aMaxY - aMinY + 1)',
124
+ ' // Detect a uniform opaque background from the corners of the opaque box.',
125
+ ' let corners = [sample(aMinX, aMinY), sample(aMaxX, aMinY), sample(aMinX, aMaxY), sample(aMaxX, aMaxY)]',
126
+ ' var background: (Int, Int, Int, Int)? = corners[0]',
127
+ ' for c in corners {',
128
+ ' if let bg = background {',
129
+ ' 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 {',
130
+ ' background = nil',
131
+ ' }',
132
+ ' }',
133
+ ' }',
134
+ ' // No uniform background: the opaque box is the content.',
135
+ ' guard let bg = background else { return opaqueBox }',
136
+ ' // Pass 2: within the opaque box, bound everything that is not the background.',
137
+ ' var minX = aMaxX + 1, minY = aMaxY + 1, maxX = aMinX - 1, maxY = aMinY - 1',
138
+ ' for y in aMinY...aMaxY {',
139
+ ' let rowBase = y * bytesPerRow',
140
+ ' for x in aMinX...aMaxX {',
141
+ ' let idx = rowBase + x * 4',
142
+ ' let a = Int(data[idx + 3])',
143
+ ' if a == 0 { continue }',
144
+ ' let r = Int(data[idx]), g = Int(data[idx + 1]), b = Int(data[idx + 2])',
145
+ ' if abs(r - bg.0) <= tolerance && abs(g - bg.1) <= tolerance && abs(b - bg.2) <= tolerance && abs(a - bg.3) <= tolerance { continue }',
146
+ ' if x < minX { minX = x }',
147
+ ' if x > maxX { maxX = x }',
148
+ ' if y < minY { minY = y }',
149
+ ' if y > maxY { maxY = y }',
150
+ ' }',
151
+ ' }',
152
+ ' // All background, no distinct content: keep the opaque box, never crop to nothing.',
153
+ ' if maxX < minX { return opaqueBox }',
154
+ ' return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)',
155
+ '}',
156
+ '',
157
+ 'let arguments = CommandLine.arguments',
158
+ 'guard arguments.count >= 3 else {',
159
+ ' FileHandle.standardError.write(Data("usage: Trim <in.png> <out.png>".utf8))',
160
+ ' exit(64)',
161
+ '}',
162
+ 'let inputPath = arguments[1]',
163
+ 'let outputPath = arguments[2]',
164
+ 'guard let image = loadCGImage(inputPath) else {',
165
+ ' FileHandle.standardError.write(Data(("could not read image at " + inputPath).utf8))',
166
+ ' exit(65)',
167
+ '}',
168
+ 'guard let box = contentBoundingBox(image) else {',
169
+ ' FileHandle.standardError.write(Data("no content to trim (image is uniform)".utf8))',
170
+ ' exit(66)',
171
+ '}',
172
+ 'guard let cropped = image.cropping(to: box) else {',
173
+ ' FileHandle.standardError.write(Data("could not crop image".utf8))',
174
+ ' exit(67)',
175
+ '}',
176
+ 'let croppedWidth = cropped.width',
177
+ 'let croppedHeight = cropped.height',
178
+ 'guard writePNG(cropped, to: outputPath) else {',
179
+ ' FileHandle.standardError.write(Data(("could not write trimmed PNG to " + outputPath).utf8))',
180
+ ' exit(68)',
181
+ '}',
182
+ '',
183
+ '// Optional aligned output: the trimmed image fit onto a reference-sized',
184
+ '// canvas, preserving aspect ratio (transparent letterbox/pillarbox padding',
185
+ '// when aspect ratios differ). A reference-sized, undistorted overlay pair.',
186
+ '// Best-effort; dimensions are reported either way.',
187
+ 'var referenceWidth = 0',
188
+ 'var referenceHeight = 0',
189
+ 'if arguments.count >= 5 {',
190
+ ' let alignedPath = arguments[3]',
191
+ ' let referencePath = arguments[4]',
192
+ ' if let reference = loadCGImage(referencePath) {',
193
+ ' referenceWidth = reference.width',
194
+ ' referenceHeight = reference.height',
195
+ ' if let aligned = fitPad(cropped, toWidth: referenceWidth, height: referenceHeight) {',
196
+ ' _ = writePNG(aligned, to: alignedPath)',
197
+ ' }',
198
+ ' }',
199
+ '}',
200
+ '',
201
+ '// Report: rendered (trimmed) px, reference px (0 0 when unavailable), then',
202
+ '// the pre-trim source px so the caller can log how much margin was removed.',
203
+ 'print("TRIMDIMS " + String(croppedWidth) + " " + String(croppedHeight) + " " + String(referenceWidth) + " " + String(referenceHeight) + " " + String(image.width) + " " + String(image.height))'
204
+ ].join('\n') + '\n';
205
+ export const RENDER_SCRIPT_TRIM_STAGE = '\n// ---- render trimming stage (package_source variants) ----\n' +
206
+ 'var TRIM_HELPER_SWIFT = ' +
207
+ JSON.stringify(TRIM_HELPER_SWIFT_SOURCE) +
208
+ ';\n' +
209
+ String.raw `
210
+ // Write the helper into the harness dir once (idempotent across screens).
211
+ function ensureTrimHelper(harnessDir) {
212
+ var helperPath = path.join(harnessDir, 'Trim.swift');
213
+ if (!fs.existsSync(helperPath)) {
214
+ fs.writeFileSync(helperPath, TRIM_HELPER_SWIFT);
215
+ }
216
+ return helperPath;
217
+ }
218
+
219
+ // Round to two decimals, keeping the value a plain number.
220
+ function roundTwo(value) {
221
+ return Math.round(value * 100) / 100;
222
+ }
223
+
224
+ // Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH> <srcW> <srcH>" line
225
+ // (rendered/reference/source px; last one wins).
226
+ function parseTrimDims(stdout) {
227
+ var lines = String(stdout || '').split('\n');
228
+ for (var i = lines.length - 1; i >= 0; i--) {
229
+ var parts = lines[i].trim().split(/\s+/);
230
+ if (parts[0] === 'TRIMDIMS' && parts.length >= 7) {
231
+ var nums = [
232
+ Number(parts[1]), Number(parts[2]), Number(parts[3]),
233
+ Number(parts[4]), Number(parts[5]), Number(parts[6]),
234
+ ];
235
+ if (nums.every(function (n) { return Number.isFinite(n); })) {
236
+ return {
237
+ rendered: [nums[0], nums[1]],
238
+ reference: [nums[2], nums[3]],
239
+ source: [nums[4], nums[5]],
240
+ };
241
+ }
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
247
+ // Crop the rendered PNG in place to its content bounding box, write an aligned
248
+ // copy resized to the reference, and record the dimensions on the entry.
249
+ // Best-effort: a failure is logged and the untrimmed render is kept.
250
+ function trimRenderedImage(entry, renderedPath, harnessDir) {
251
+ var helperPath = ensureTrimHelper(harnessDir);
252
+ var alignedRelative = 'ui-fidelity/aligned/' + entry.screen + '.png';
253
+ var alignedPath = path.resolve(artifactsDir, alignedRelative);
254
+ var referencePath =
255
+ entry.reference_image_path === null
256
+ ? ''
257
+ : path.resolve(artifactsDir, entry.reference_image_path);
258
+ try {
259
+ fs.mkdirSync(path.dirname(alignedPath), { recursive: true });
260
+ } catch (mkdirError) {
261
+ // best effort -- the helper reports a missing output dir itself
262
+ }
263
+ var result = runSwift([helperPath, renderedPath, renderedPath, alignedPath, referencePath]);
264
+ if (result.status !== 0) {
265
+ logError(
266
+ 'trim failed for ' + renderedPath +
267
+ ' (continuing with the untrimmed render): ' + sanitizeMessage(outputTail(result))
268
+ );
269
+ return;
270
+ }
271
+ var dims = parseTrimDims(result.stdout);
272
+ if (dims !== null) {
273
+ var scale = CONFIG.scale;
274
+ entry.dimensions = {
275
+ rendered_px: dims.rendered,
276
+ logical_pt: [roundTwo(dims.rendered[0] / scale), roundTwo(dims.rendered[1] / scale)],
277
+ reference_px: dims.reference[0] > 0 && dims.reference[1] > 0 ? dims.reference : null,
278
+ };
279
+ // Surface how much margin was removed -- a no-op (source == rendered) means
280
+ // the view fills an opaque canvas with no transparent or uniform margin.
281
+ log(
282
+ 'trimmed ' + entry.screen + ': ' + dims.source[0] + 'x' + dims.source[1] +
283
+ ' -> ' + dims.rendered[0] + 'x' + dims.rendered[1]
284
+ );
285
+ }
286
+ if (fs.existsSync(alignedPath)) {
287
+ entry.aligned_image_path = alignedRelative;
288
+ }
289
+ }
290
+ `;
291
+ /**
292
+ * Evaluates the embedded trim-stage source and returns its pure helpers, so
293
+ * unit tests exercise exactly the code that ships inside the runtime script
294
+ * (mirrors getRenderScriptInternals in ui-fidelity-render.ts). The other
295
+ * trim-stage functions reference runtime globals (fs, path, runSwift, ...)
296
+ * only when called, so defining them here is harmless.
297
+ */
298
+ export function getPostProcessorInternals() {
299
+ const factory = new Function(RENDER_SCRIPT_TRIM_STAGE +
300
+ '\nreturn { parseTrimDims: parseTrimDims, roundTwo: roundTwo };');
301
+ return factory();
302
+ }
303
+ //# sourceMappingURL=render-post-processor.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Direct tests for the shared render-output post-processor.
3
+ *
4
+ * Two layers, matching how the module is built:
5
+ * - The platform-neutral dimension-reporting logic (TRIMDIMS parsing, the
6
+ * logical-pt rounding primitive) is pure JS embedded in the trim-stage
7
+ * script. It is evaluated out of the shipped string via
8
+ * getPostProcessorInternals() and tested directly, so these run in every
9
+ * CI pass.
10
+ * - The CoreGraphics pixel logic (content trim, uniform-background crop, the
11
+ * transparent-frame fallback, align-to-reference) cannot run under a fake
12
+ * `swift`, so it is exercised over crafted PNGs with the real toolchain,
13
+ * gated behind CIBUILD_UI_FIDELITY_REAL_SWIFT=1 (the same gate iOS uses).
14
+ */
15
+ export {};
16
+ //# sourceMappingURL=render-post-processor.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-post-processor.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}