@invarn/cibuild 2.0.7 → 2.0.9

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 (35) hide show
  1. package/dist/cli.cjs +688 -69
  2. package/dist/src/lib.d.ts +4 -0
  3. package/dist/src/lib.d.ts.map +1 -1
  4. package/dist/src/lib.js +3 -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 +290 -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 +194 -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 +216 -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 +247 -0
  19. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +99 -0
  20. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -0
  21. package/dist/src/yaml/steps/ui-fidelity-preview.js +152 -0
  22. package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts +11 -0
  23. package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts.map +1 -0
  24. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +168 -0
  25. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +92 -0
  26. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -0
  27. package/dist/src/yaml/steps/ui-fidelity-render-android.js +726 -0
  28. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +2 -0
  29. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +1 -0
  30. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +377 -0
  31. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -28
  32. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
  33. package/dist/src/yaml/steps/ui-fidelity-render.js +1 -259
  34. package/dist/src/yaml/steps/ui-fidelity-render.test.js +1 -57
  35. package/package.json +1 -1
package/dist/src/lib.d.ts CHANGED
@@ -19,6 +19,10 @@ export { loadYAMLPipeline, getWorkflowNames, hasWorkflow, YAMLParseError, YAMLVa
19
19
  export { convertYAMLToPipelineDef, getAvailableWorkflows, YAMLConversionError, } from './yaml/converter.js';
20
20
  export type { ConversionResult } from './yaml/converter.js';
21
21
  export { pipeline } from './pipeline.js';
22
+ export { renderUiFidelityPreview } from './yaml/steps/ui-fidelity-preview.js';
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';
22
26
  export { getStepExecutor, hasStep, getAvailableSteps, registerStep, getStepMetadata, } from './yaml/steps/registry.js';
23
27
  export type { StepDef, PipelineDef, CIConfig, PipelineFactory, PipelineContext, StepBuilders, } from './types.js';
24
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,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
@@ -25,6 +25,9 @@ export { loadYAMLPipeline, getWorkflowNames, hasWorkflow, YAMLParseError, YAMLVa
25
25
  export { convertYAMLToPipelineDef, getAvailableWorkflows, YAMLConversionError, } from './yaml/converter.js';
26
26
  // ─── Pipeline DSL factory ───────────────────────────────────────────────
27
27
  export { pipeline } from './pipeline.js';
28
+ // ─── UI fidelity local preview ──────────────────────────────────────────
29
+ export { renderUiFidelityPreview } from './yaml/steps/ui-fidelity-preview.js';
30
+ export { renderUiFidelityPreviewAndroid, ANDROID_TOOLCHAIN_MISSING, } from './yaml/steps/ui-fidelity-preview-android.js';
28
31
  // ─── Step registry ──────────────────────────────────────────────────────
29
32
  export { getStepExecutor, hasStep, getAvailableSteps, registerStep, getStepMetadata, } from './yaml/steps/registry.js';
30
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,MAiJpB,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,290 @@
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
+ '// Redraw an image at an exact pixel size (used to match the reference).',
65
+ 'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
66
+ ' if w <= 0 || h <= 0 { return nil }',
67
+ ' let bytesPerRow = w * 4',
68
+ ' let space = CGColorSpaceCreateDeviceRGB()',
69
+ ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
70
+ ' ctx.interpolationQuality = .high',
71
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
72
+ ' return ctx.makeImage()',
73
+ '}',
74
+ '',
75
+ '// Bounding box of content: pixels that are neither transparent nor part of a',
76
+ '// uniform solid background. Two passes, because the renderer forces the view',
77
+ '// into a fixed device frame whose margins are transparent: (1) bound the',
78
+ '// non-transparent pixels, then (2) detect a uniform opaque background from the',
79
+ '// CORNERS OF THAT OPAQUE BOX -- not the raw frame, whose corners are the',
80
+ '// transparent device margin -- and bound everything that is not that',
81
+ '// background. A wrapper that fills the canvas with .background(.white) is thus',
82
+ '// trimmed to its real content. When the opaque corners disagree, only',
83
+ '// transparency is trimmed. Top-left image coordinates.',
84
+ 'func contentBoundingBox(_ image: CGImage) -> CGRect? {',
85
+ ' let w = image.width, h = image.height',
86
+ ' if w == 0 || h == 0 { return nil }',
87
+ ' let bytesPerRow = w * 4',
88
+ ' var data = [UInt8](repeating: 0, count: bytesPerRow * h)',
89
+ ' let space = CGColorSpaceCreateDeviceRGB()',
90
+ ' guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
91
+ ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
92
+ ' // Tight tolerance: exact solid fills match, near-colored content does not.',
93
+ ' let tolerance = 4',
94
+ ' func sample(_ x: Int, _ y: Int) -> (Int, Int, Int, Int) {',
95
+ ' let i = y * bytesPerRow + x * 4',
96
+ ' return (Int(data[i]), Int(data[i + 1]), Int(data[i + 2]), Int(data[i + 3]))',
97
+ ' }',
98
+ ' // Pass 1: bounding box of all non-transparent pixels.',
99
+ ' var aMinX = w, aMinY = h, aMaxX = -1, aMaxY = -1',
100
+ ' for y in 0..<h {',
101
+ ' let rowBase = y * bytesPerRow',
102
+ ' for x in 0..<w {',
103
+ ' if data[rowBase + x * 4 + 3] > 0 {',
104
+ ' if x < aMinX { aMinX = x }',
105
+ ' if x > aMaxX { aMaxX = x }',
106
+ ' if y < aMinY { aMinY = y }',
107
+ ' if y > aMaxY { aMaxY = y }',
108
+ ' }',
109
+ ' }',
110
+ ' }',
111
+ ' if aMaxX < 0 { return nil }',
112
+ ' let opaqueBox = CGRect(x: aMinX, y: aMinY, width: aMaxX - aMinX + 1, height: aMaxY - aMinY + 1)',
113
+ ' // Detect a uniform opaque background from the corners of the opaque box.',
114
+ ' let corners = [sample(aMinX, aMinY), sample(aMaxX, aMinY), sample(aMinX, aMaxY), sample(aMaxX, aMaxY)]',
115
+ ' var background: (Int, Int, Int, Int)? = corners[0]',
116
+ ' for c in corners {',
117
+ ' if let bg = background {',
118
+ ' 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 {',
119
+ ' background = nil',
120
+ ' }',
121
+ ' }',
122
+ ' }',
123
+ ' // No uniform background: the opaque box is the content.',
124
+ ' guard let bg = background else { return opaqueBox }',
125
+ ' // Pass 2: within the opaque box, bound everything that is not the background.',
126
+ ' var minX = aMaxX + 1, minY = aMaxY + 1, maxX = aMinX - 1, maxY = aMinY - 1',
127
+ ' for y in aMinY...aMaxY {',
128
+ ' let rowBase = y * bytesPerRow',
129
+ ' for x in aMinX...aMaxX {',
130
+ ' let idx = rowBase + x * 4',
131
+ ' let a = Int(data[idx + 3])',
132
+ ' if a == 0 { continue }',
133
+ ' let r = Int(data[idx]), g = Int(data[idx + 1]), b = Int(data[idx + 2])',
134
+ ' if abs(r - bg.0) <= tolerance && abs(g - bg.1) <= tolerance && abs(b - bg.2) <= tolerance && abs(a - bg.3) <= tolerance { continue }',
135
+ ' if x < minX { minX = x }',
136
+ ' if x > maxX { maxX = x }',
137
+ ' if y < minY { minY = y }',
138
+ ' if y > maxY { maxY = y }',
139
+ ' }',
140
+ ' }',
141
+ ' // All background, no distinct content: keep the opaque box, never crop to nothing.',
142
+ ' if maxX < minX { return opaqueBox }',
143
+ ' return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)',
144
+ '}',
145
+ '',
146
+ 'let arguments = CommandLine.arguments',
147
+ 'guard arguments.count >= 3 else {',
148
+ ' FileHandle.standardError.write(Data("usage: Trim <in.png> <out.png>".utf8))',
149
+ ' exit(64)',
150
+ '}',
151
+ 'let inputPath = arguments[1]',
152
+ 'let outputPath = arguments[2]',
153
+ 'guard let image = loadCGImage(inputPath) else {',
154
+ ' FileHandle.standardError.write(Data(("could not read image at " + inputPath).utf8))',
155
+ ' exit(65)',
156
+ '}',
157
+ 'guard let box = contentBoundingBox(image) else {',
158
+ ' FileHandle.standardError.write(Data("no content to trim (image is uniform)".utf8))',
159
+ ' exit(66)',
160
+ '}',
161
+ 'guard let cropped = image.cropping(to: box) else {',
162
+ ' FileHandle.standardError.write(Data("could not crop image".utf8))',
163
+ ' exit(67)',
164
+ '}',
165
+ 'let croppedWidth = cropped.width',
166
+ 'let croppedHeight = cropped.height',
167
+ 'guard writePNG(cropped, to: outputPath) else {',
168
+ ' FileHandle.standardError.write(Data(("could not write trimmed PNG to " + outputPath).utf8))',
169
+ ' exit(68)',
170
+ '}',
171
+ '',
172
+ '// Optional aligned output: the trimmed image resized to the reference pixel',
173
+ '// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
174
+ 'var referenceWidth = 0',
175
+ 'var referenceHeight = 0',
176
+ 'if arguments.count >= 5 {',
177
+ ' let alignedPath = arguments[3]',
178
+ ' let referencePath = arguments[4]',
179
+ ' if let reference = loadCGImage(referencePath) {',
180
+ ' referenceWidth = reference.width',
181
+ ' referenceHeight = reference.height',
182
+ ' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
183
+ ' _ = writePNG(aligned, to: alignedPath)',
184
+ ' }',
185
+ ' }',
186
+ '}',
187
+ '',
188
+ '// Report: rendered (trimmed) px, reference px (0 0 when unavailable), then',
189
+ '// the pre-trim source px so the caller can log how much margin was removed.',
190
+ 'print("TRIMDIMS " + String(croppedWidth) + " " + String(croppedHeight) + " " + String(referenceWidth) + " " + String(referenceHeight) + " " + String(image.width) + " " + String(image.height))'
191
+ ].join('\n') + '\n';
192
+ export const RENDER_SCRIPT_TRIM_STAGE = '\n// ---- render trimming stage (package_source variants) ----\n' +
193
+ 'var TRIM_HELPER_SWIFT = ' +
194
+ JSON.stringify(TRIM_HELPER_SWIFT_SOURCE) +
195
+ ';\n' +
196
+ String.raw `
197
+ // Write the helper into the harness dir once (idempotent across screens).
198
+ function ensureTrimHelper(harnessDir) {
199
+ var helperPath = path.join(harnessDir, 'Trim.swift');
200
+ if (!fs.existsSync(helperPath)) {
201
+ fs.writeFileSync(helperPath, TRIM_HELPER_SWIFT);
202
+ }
203
+ return helperPath;
204
+ }
205
+
206
+ // Round to two decimals, keeping the value a plain number.
207
+ function roundTwo(value) {
208
+ return Math.round(value * 100) / 100;
209
+ }
210
+
211
+ // Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH> <srcW> <srcH>" line
212
+ // (rendered/reference/source px; last one wins).
213
+ function parseTrimDims(stdout) {
214
+ var lines = String(stdout || '').split('\n');
215
+ for (var i = lines.length - 1; i >= 0; i--) {
216
+ var parts = lines[i].trim().split(/\s+/);
217
+ if (parts[0] === 'TRIMDIMS' && parts.length >= 7) {
218
+ var nums = [
219
+ Number(parts[1]), Number(parts[2]), Number(parts[3]),
220
+ Number(parts[4]), Number(parts[5]), Number(parts[6]),
221
+ ];
222
+ if (nums.every(function (n) { return Number.isFinite(n); })) {
223
+ return {
224
+ rendered: [nums[0], nums[1]],
225
+ reference: [nums[2], nums[3]],
226
+ source: [nums[4], nums[5]],
227
+ };
228
+ }
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ // Crop the rendered PNG in place to its content bounding box, write an aligned
235
+ // copy resized to the reference, and record the dimensions on the entry.
236
+ // Best-effort: a failure is logged and the untrimmed render is kept.
237
+ function trimRenderedImage(entry, renderedPath, harnessDir) {
238
+ var helperPath = ensureTrimHelper(harnessDir);
239
+ var alignedRelative = 'ui-fidelity/aligned/' + entry.screen + '.png';
240
+ var alignedPath = path.resolve(artifactsDir, alignedRelative);
241
+ var referencePath =
242
+ entry.reference_image_path === null
243
+ ? ''
244
+ : path.resolve(artifactsDir, entry.reference_image_path);
245
+ try {
246
+ fs.mkdirSync(path.dirname(alignedPath), { recursive: true });
247
+ } catch (mkdirError) {
248
+ // best effort -- the helper reports a missing output dir itself
249
+ }
250
+ var result = runSwift([helperPath, renderedPath, renderedPath, alignedPath, referencePath]);
251
+ if (result.status !== 0) {
252
+ logError(
253
+ 'trim failed for ' + renderedPath +
254
+ ' (continuing with the untrimmed render): ' + sanitizeMessage(outputTail(result))
255
+ );
256
+ return;
257
+ }
258
+ var dims = parseTrimDims(result.stdout);
259
+ if (dims !== null) {
260
+ var scale = CONFIG.scale;
261
+ entry.dimensions = {
262
+ rendered_px: dims.rendered,
263
+ logical_pt: [roundTwo(dims.rendered[0] / scale), roundTwo(dims.rendered[1] / scale)],
264
+ reference_px: dims.reference[0] > 0 && dims.reference[1] > 0 ? dims.reference : null,
265
+ };
266
+ // Surface how much margin was removed -- a no-op (source == rendered) means
267
+ // the view fills an opaque canvas with no transparent or uniform margin.
268
+ log(
269
+ 'trimmed ' + entry.screen + ': ' + dims.source[0] + 'x' + dims.source[1] +
270
+ ' -> ' + dims.rendered[0] + 'x' + dims.rendered[1]
271
+ );
272
+ }
273
+ if (fs.existsSync(alignedPath)) {
274
+ entry.aligned_image_path = alignedRelative;
275
+ }
276
+ }
277
+ `;
278
+ /**
279
+ * Evaluates the embedded trim-stage source and returns its pure helpers, so
280
+ * unit tests exercise exactly the code that ships inside the runtime script
281
+ * (mirrors getRenderScriptInternals in ui-fidelity-render.ts). The other
282
+ * trim-stage functions reference runtime globals (fs, path, runSwift, ...)
283
+ * only when called, so defining them here is harmless.
284
+ */
285
+ export function getPostProcessorInternals() {
286
+ const factory = new Function(RENDER_SCRIPT_TRIM_STAGE +
287
+ '\nreturn { parseTrimDims: parseTrimDims, roundTwo: roundTwo };');
288
+ return factory();
289
+ }
290
+ //# 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"}
@@ -0,0 +1,194 @@
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
+ import { spawnSync } from 'node:child_process';
16
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { getPostProcessorInternals, TRIM_HELPER_SWIFT_SOURCE, } from './render-post-processor.js';
20
+ describe('dimension reporting (TRIMDIMS parsing)', () => {
21
+ const { parseTrimDims } = getPostProcessorInternals();
22
+ test('parses a well-formed TRIMDIMS line into rendered/reference/source px', () => {
23
+ const dims = parseTrimDims('TRIMDIMS 320 640 160 320 400 800\n');
24
+ expect(dims).toEqual({
25
+ rendered: [320, 640],
26
+ reference: [160, 320],
27
+ source: [400, 800],
28
+ });
29
+ });
30
+ test('returns null when no TRIMDIMS line is present', () => {
31
+ expect(parseTrimDims('rendered HomeView\nsome other log line\n')).toBeNull();
32
+ expect(parseTrimDims('')).toBeNull();
33
+ });
34
+ test('takes the last TRIMDIMS line and ignores surrounding log noise', () => {
35
+ const stdout = [
36
+ 'TRIMDIMS 10 10 10 10 10 10',
37
+ 'helper: warming up',
38
+ 'TRIMDIMS 320 640 160 320 400 800',
39
+ 'helper: done',
40
+ ].join('\n');
41
+ expect(parseTrimDims(stdout)).toEqual({
42
+ rendered: [320, 640],
43
+ reference: [160, 320],
44
+ source: [400, 800],
45
+ });
46
+ });
47
+ test('returns null for a truncated or non-numeric TRIMDIMS line', () => {
48
+ expect(parseTrimDims('TRIMDIMS 320 640 160 320\n')).toBeNull();
49
+ expect(parseTrimDims('TRIMDIMS 320 x 160 320 400 800\n')).toBeNull();
50
+ });
51
+ test('reports a zero reference pair when no reference was available', () => {
52
+ // The helper prints "0 0" for the reference when it cannot load one; the
53
+ // render step maps that to a null reference_px when reporting dimensions.
54
+ const dims = parseTrimDims('TRIMDIMS 320 640 0 0 400 800\n');
55
+ expect(dims?.reference).toEqual([0, 0]);
56
+ });
57
+ });
58
+ describe('logical-pt rounding (roundTwo)', () => {
59
+ const { roundTwo } = getPostProcessorInternals();
60
+ test('rounds a logical-point value to two decimals', () => {
61
+ expect(roundTwo(360 / 2)).toBe(180);
62
+ expect(roundTwo(343 / 2)).toBe(171.5);
63
+ expect(roundTwo(100 / 3)).toBe(33.33);
64
+ });
65
+ });
66
+ // The CoreGraphics bounding-box/resize logic cannot run under a fake `swift`,
67
+ // so it is exercised over crafted PNGs with the real toolchain. This is the
68
+ // regression guard for the bug where a wrapper that fills its canvas with an
69
+ // opaque .background(...) was not trimmed to its content: the renderer forces
70
+ // the view into a fixed device frame whose margins are transparent, so the
71
+ // helper must detect a solid background from the corners of the opaque region,
72
+ // not the raw frame.
73
+ describe('trim helper pixel logic (real toolchain)', () => {
74
+ const realSwiftTest = process.env.CIBUILD_UI_FIDELITY_REAL_SWIFT === '1' ? test : test.skip;
75
+ const { parseTrimDims } = getPostProcessorInternals();
76
+ const dirs = [];
77
+ afterAll(() => {
78
+ for (const dir of dirs) {
79
+ try {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ catch {
83
+ // best effort
84
+ }
85
+ }
86
+ });
87
+ // PNG IHDR carries width/height as big-endian uint32 at byte offsets 16/20.
88
+ function pngSize(filePath) {
89
+ const buf = readFileSync(filePath);
90
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
91
+ }
92
+ // A parameterized fixture maker: paints opaque rectangles (colors in 0..1)
93
+ // onto an otherwise transparent canvas, so a single Swift program can craft
94
+ // every input shape the bounding-box logic must handle.
95
+ const MAKE_FIXTURE_SWIFT = [
96
+ 'import Foundation',
97
+ 'import ImageIO',
98
+ 'import CoreGraphics',
99
+ 'import UniformTypeIdentifiers',
100
+ 'let out = CommandLine.arguments[1]',
101
+ 'let spec = try! JSONSerialization.jsonObject(with: CommandLine.arguments[2].data(using: .utf8)!) as! [String: Any]',
102
+ 'let w = spec["w"] as! Int, h = spec["h"] as! Int',
103
+ 'let cs = CGColorSpaceCreateDeviceRGB()',
104
+ 'let c = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!',
105
+ 'c.clear(CGRect(x: 0, y: 0, width: w, height: h))',
106
+ 'func num(_ v: Any) -> Double { return Double("\\(v)")! }',
107
+ 'for r in spec["rects"] as! [[String: Any]] {',
108
+ ' c.setFillColor(red: num(r["r"]!), green: num(r["g"]!), blue: num(r["b"]!), alpha: num(r["a"]!))',
109
+ ' c.fill(CGRect(x: num(r["x"]!), y: num(r["y"]!), width: num(r["w"]!), height: num(r["h"]!)))',
110
+ '}',
111
+ 'let d = CGImageDestinationCreateWithURL(URL(fileURLWithPath: out) as CFURL, UTType.png.identifier as CFString, 1, nil)!',
112
+ 'CGImageDestinationAddImage(d, c.makeImage()!, nil)',
113
+ '_ = CGImageDestinationFinalize(d)',
114
+ ].join('\n') + '\n';
115
+ const RED = { r: 1, g: 0, b: 0, a: 1 };
116
+ const BLUE = { r: 0, g: 0, b: 1, a: 1 };
117
+ const WHITE = { r: 1, g: 1, b: 1, a: 1 };
118
+ const PINK = { r: 1, g: 240 / 255, b: 238 / 255, a: 1 };
119
+ function scene() {
120
+ const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-postproc-'));
121
+ dirs.push(dir);
122
+ writeFileSync(join(dir, 'Make.swift'), MAKE_FIXTURE_SWIFT);
123
+ writeFileSync(join(dir, 'Trim.swift'), TRIM_HELPER_SWIFT_SOURCE);
124
+ return dir;
125
+ }
126
+ function craft(dir, name, w, h, rects) {
127
+ const out = join(dir, name);
128
+ const make = spawnSync('swift', [join(dir, 'Make.swift'), out, JSON.stringify({ w, h, rects })], { encoding: 'utf-8' });
129
+ expect(make.stderr + '').toBe('');
130
+ expect(make.status).toBe(0);
131
+ return out;
132
+ }
133
+ function trim(dir, input, output, aligned, reference) {
134
+ const args = [join(dir, 'Trim.swift'), input, output];
135
+ if (aligned && reference)
136
+ args.push(aligned, reference);
137
+ const result = spawnSync('swift', args, { encoding: 'utf-8' });
138
+ expect(result.status).toBe(0);
139
+ return result;
140
+ }
141
+ realSwiftTest('trims a transparent frame down to its single content rectangle', () => {
142
+ const dir = scene();
143
+ const input = craft(dir, 'in.png', 200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]);
144
+ const out = join(dir, 'out.png');
145
+ trim(dir, input, out);
146
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
147
+ }, 600_000);
148
+ realSwiftTest('crops an opaque uniform-background wrapper to its content card', () => {
149
+ const dir = scene();
150
+ // 420x320 transparent canvas, 375x200 white wrapper, 343x168 pink card.
151
+ const input = craft(dir, 'in.png', 420, 320, [
152
+ { x: 20, y: 40, w: 375, h: 200, ...WHITE },
153
+ { x: 36, y: 56, w: 343, h: 168, ...PINK },
154
+ ]);
155
+ const out = join(dir, 'out.png');
156
+ trim(dir, input, out);
157
+ // Lands on the content card, NOT the white wrapper (375x200) or the raw
158
+ // canvas (420x320).
159
+ expect(pngSize(out)).toEqual({ width: 343, height: 168 });
160
+ }, 600_000);
161
+ realSwiftTest('trims only transparency when the opaque corners disagree (fallback)', () => {
162
+ const dir = scene();
163
+ // Two differently-colored halves: the opaque bounding box is 80x80 but its
164
+ // corners are not one uniform color, so no background is detected and only
165
+ // the transparent margin is removed.
166
+ const input = craft(dir, 'in.png', 120, 120, [
167
+ { x: 20, y: 20, w: 40, h: 80, ...RED },
168
+ { x: 60, y: 20, w: 40, h: 80, ...BLUE },
169
+ ]);
170
+ const out = join(dir, 'out.png');
171
+ trim(dir, input, out);
172
+ expect(pngSize(out)).toEqual({ width: 80, height: 80 });
173
+ }, 600_000);
174
+ realSwiftTest('writes a reference-sized aligned image and reports all three dimensions', () => {
175
+ const dir = scene();
176
+ const input = craft(dir, 'in.png', 200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]);
177
+ const reference = craft(dir, 'ref.png', 100, 50, [{ x: 0, y: 0, w: 100, h: 50, ...BLUE }]);
178
+ const out = join(dir, 'out.png');
179
+ const aligned = join(dir, 'aligned.png');
180
+ const result = trim(dir, input, out, aligned, reference);
181
+ // The trimmed render is the content; the aligned copy is resized to the
182
+ // reference pixel size for a 1:1 overlay.
183
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
184
+ expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
185
+ // The TRIMDIMS line reports rendered (trimmed) / reference / pre-trim
186
+ // source px, parsed by the same helper the runtime uses.
187
+ expect(parseTrimDims(result.stdout)).toEqual({
188
+ rendered: [60, 40],
189
+ reference: [100, 50],
190
+ source: [200, 200],
191
+ });
192
+ }, 600_000);
193
+ });
194
+ //# sourceMappingURL=render-post-processor.test.js.map