@invarn/cibuild 2.3.8 → 2.4.1

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 (39) hide show
  1. package/dist/cli.cjs +54 -37
  2. package/dist/src/cli.js +8 -0
  3. package/dist/src/commands/fidelity-trim.d.ts +13 -0
  4. package/dist/src/commands/fidelity-trim.d.ts.map +1 -0
  5. package/dist/src/commands/fidelity-trim.js +21 -0
  6. package/dist/src/commands/fidelity-trim.test.d.ts +8 -0
  7. package/dist/src/commands/fidelity-trim.test.d.ts.map +1 -0
  8. package/dist/src/commands/fidelity-trim.test.js +79 -0
  9. package/dist/src/yaml/steps/render-post-processor.d.ts +52 -32
  10. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -1
  11. package/dist/src/yaml/steps/render-post-processor.js +104 -205
  12. package/dist/src/yaml/steps/render-post-processor.test.d.ts +7 -10
  13. package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -1
  14. package/dist/src/yaml/steps/render-post-processor.test.js +80 -183
  15. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +28 -0
  16. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +1 -0
  17. package/dist/src/yaml/steps/trim-align-corpus.test.js +383 -0
  18. package/dist/src/yaml/steps/trim-align.d.ts +85 -0
  19. package/dist/src/yaml/steps/trim-align.d.ts.map +1 -0
  20. package/dist/src/yaml/steps/trim-align.js +348 -0
  21. package/dist/src/yaml/steps/trim-align.test.d.ts +11 -0
  22. package/dist/src/yaml/steps/trim-align.test.d.ts.map +1 -0
  23. package/dist/src/yaml/steps/trim-align.test.js +258 -0
  24. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +1 -1
  25. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -1
  26. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +9 -0
  27. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +4 -3
  28. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -1
  29. package/dist/src/yaml/steps/ui-fidelity-preview.js +9 -0
  30. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +13 -3
  31. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +16 -5
  32. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
  33. package/dist/src/yaml/steps/ui-fidelity-render-android.js +190 -26
  34. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +165 -10
  35. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +20 -4
  36. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
  37. package/dist/src/yaml/steps/ui-fidelity-render.js +186 -10
  38. package/dist/src/yaml/steps/ui-fidelity-render.test.js +180 -22
  39. package/package.json +3 -1
package/dist/src/cli.js CHANGED
@@ -77,6 +77,14 @@ Examples:
77
77
  `);
78
78
  process.exit(0);
79
79
  }
80
+ // Handle fidelity-trim — the internal trim/align helper spawned by the
81
+ // generated UI-fidelity render scripts (kept flag-free and fast: it must
82
+ // behave identically whether invoked as `ci`, the pkg binary re-exec, or
83
+ // `node dist/cli.cjs`).
84
+ if (args[0] === "fidelity-trim") {
85
+ const { handleFidelityTrimCommand } = await import("./commands/fidelity-trim.js");
86
+ process.exit(handleFidelityTrimCommand(args.slice(1)));
87
+ }
80
88
  // Handle envman subcommand — delegates to envman CLI
81
89
  if (args[0] === "envman") {
82
90
  // Re-write process.argv so envman sees its own args
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `ci fidelity-trim <in.png> <out.png> [<aligned.png> <reference.png>]`
3
+ *
4
+ * Internal command: the trim/align helper the generated UI-fidelity render
5
+ * scripts (iOS + Android) spawn after each screen renders. It crops a render
6
+ * to its content bounding box in place, optionally writes a reference-sized
7
+ * aligned copy, and prints a `TRIMDIMS` line — the exact argv/stdout/exit-code
8
+ * contract of the retired embedded Swift (CoreGraphics) helper, now pure JS so
9
+ * the same bytes run on macOS and Linux workers.
10
+ */
11
+ /** Runs the helper and returns its exit status (0 on success). */
12
+ export declare function handleFidelityTrimCommand(args: string[]): number;
13
+ //# sourceMappingURL=fidelity-trim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fidelity-trim.d.ts","sourceRoot":"","sources":["../../../src/commands/fidelity-trim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,kEAAkE;AAClE,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAKhE"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `ci fidelity-trim <in.png> <out.png> [<aligned.png> <reference.png>]`
3
+ *
4
+ * Internal command: the trim/align helper the generated UI-fidelity render
5
+ * scripts (iOS + Android) spawn after each screen renders. It crops a render
6
+ * to its content bounding box in place, optionally writes a reference-sized
7
+ * aligned copy, and prints a `TRIMDIMS` line — the exact argv/stdout/exit-code
8
+ * contract of the retired embedded Swift (CoreGraphics) helper, now pure JS so
9
+ * the same bytes run on macOS and Linux workers.
10
+ */
11
+ import { runTrimAlign } from '../yaml/steps/trim-align.js';
12
+ /** Runs the helper and returns its exit status (0 on success). */
13
+ export function handleFidelityTrimCommand(args) {
14
+ const result = runTrimAlign(args);
15
+ if (result.stdout)
16
+ process.stdout.write(result.stdout);
17
+ if (result.stderr)
18
+ process.stderr.write(result.stderr);
19
+ return result.status;
20
+ }
21
+ //# sourceMappingURL=fidelity-trim.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tests for the `ci fidelity-trim` command — the process seam over the pure
3
+ * trim/align core. The generated fidelity render scripts (iOS + Android) spawn
4
+ * this instead of the retired Swift helper, so the argv contract, TRIMDIMS
5
+ * stdout line, and exit codes are load-bearing.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=fidelity-trim.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fidelity-trim.test.d.ts","sourceRoot":"","sources":["../../../src/commands/fidelity-trim.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tests for the `ci fidelity-trim` command — the process seam over the pure
3
+ * trim/align core. The generated fidelity render scripts (iOS + Android) spawn
4
+ * this instead of the retired Swift helper, so the argv contract, TRIMDIMS
5
+ * stdout line, and exit codes are load-bearing.
6
+ */
7
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { PNG } from 'pngjs';
11
+ import { describe, test, expect, afterAll, jest } from '@jest/globals';
12
+ import { handleFidelityTrimCommand } from './fidelity-trim.js';
13
+ const dirs = [];
14
+ function scene() {
15
+ const dir = mkdtempSync(join(tmpdir(), 'fidelity-trim-cmd-'));
16
+ dirs.push(dir);
17
+ return dir;
18
+ }
19
+ afterAll(() => {
20
+ for (const dir of dirs) {
21
+ try {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ catch {
25
+ // best effort
26
+ }
27
+ }
28
+ });
29
+ function craftOpaqueRect(w, h, x, y, rw, rh) {
30
+ const png = new PNG({ width: w, height: h });
31
+ for (let yy = y; yy < y + rh; yy++) {
32
+ for (let xx = x; xx < x + rw; xx++) {
33
+ const i = (yy * w + xx) * 4;
34
+ png.data[i] = 255;
35
+ png.data[i + 3] = 255;
36
+ }
37
+ }
38
+ return PNG.sync.write(png);
39
+ }
40
+ describe('handleFidelityTrimCommand', () => {
41
+ test('trims in place, prints TRIMDIMS to stdout, returns 0', () => {
42
+ const dir = scene();
43
+ const input = join(dir, 'in.png');
44
+ writeFileSync(input, craftOpaqueRect(100, 80, 10, 20, 30, 40));
45
+ const out = join(dir, 'out.png');
46
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
47
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
48
+ try {
49
+ const status = handleFidelityTrimCommand([input, out]);
50
+ expect(status).toBe(0);
51
+ const printed = stdout.mock.calls.map((c) => String(c[0])).join('');
52
+ expect(printed).toBe('TRIMDIMS 30 40 0 0 100 80\n');
53
+ expect(stderr).not.toHaveBeenCalled();
54
+ }
55
+ finally {
56
+ stdout.mockRestore();
57
+ stderr.mockRestore();
58
+ }
59
+ const trimmed = PNG.sync.read(readFileSync(out));
60
+ expect([trimmed.width, trimmed.height]).toEqual([30, 40]);
61
+ });
62
+ test('propagates the helper exit code and stderr on failure', () => {
63
+ const dir = scene();
64
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
65
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
66
+ try {
67
+ const status = handleFidelityTrimCommand([join(dir, 'missing.png'), join(dir, 'out.png')]);
68
+ expect(status).toBe(65);
69
+ const printed = stderr.mock.calls.map((c) => String(c[0])).join('');
70
+ expect(printed).toContain('could not read image at');
71
+ expect(stdout).not.toHaveBeenCalled();
72
+ }
73
+ finally {
74
+ stdout.mockRestore();
75
+ stderr.mockRestore();
76
+ }
77
+ });
78
+ });
79
+ //# sourceMappingURL=fidelity-trim.test.js.map
@@ -6,15 +6,20 @@
6
6
  * reference-sized aligned copy, and the reported dimensions (rendered px /
7
7
  * logical pt / reference px). It operates on PNGs regardless of how they were
8
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.
9
+ * so crop/align/dimension behavior cannot drift between platforms.
13
10
  *
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.
11
+ * The pixel work is the pure-JS trim/align helper in trim-align.ts, invoked
12
+ * through the ci binary (`ci fidelity-trim`) -- the one artifact present on
13
+ * every backend -- so iOS and Android run the SAME bytes on macOS and Linux
14
+ * alike. It replaced an embedded CoreGraphics/ImageIO helper that ran via
15
+ * `swift` in script-interpreter mode and therefore could not run on Linux
16
+ * workers (fidelity-linux 06); the bounding-box, fit-pad, and TRIMDIMS
17
+ * semantics are a faithful port of that helper, guarded by a pixel-identity
18
+ * corpus gate (trim-align-corpus.test.ts).
19
+ *
20
+ * This module is consumed by `ui-fidelity-render.ts` (iOS) and
21
+ * `ui-fidelity-render-android.ts`; the surrounding JS orchestration is spliced
22
+ * into each platform's generated runtime script.
18
23
  */
19
24
  /**
20
25
  * Runtime support for trimming the rendered output (package_source variants).
@@ -25,25 +30,15 @@
25
30
  * non-transparent pixels, so the rendered artifact is the component itself,
26
31
  * tight -- no canvas, no background ambiguity for the human comparing screens.
27
32
  *
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.
33
+ * Best-effort: a trim failure is logged (warn-and-continue) and the untrimmed
34
+ * render is kept -- a cosmetic post-step never fails an otherwise good render.
35
+ * This is prep, not scoring: it produces a tighter image for the human's
36
+ * judgment and computes nothing about fidelity.
41
37
  *
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.
38
+ * NOTE: the comments inside the stage string ship inside generated runtime
39
+ * scripts. Keep them free of platform-toolchain references -- there is a unit
40
+ * gate asserting the stage never mentions the old interpreter.
45
41
  */
46
- export declare const TRIM_HELPER_SWIFT_SOURCE: string;
47
42
  export declare const RENDER_SCRIPT_TRIM_STAGE: string;
48
43
  /** A rendered/reference/source dimension pair in pixels, as TRIMDIMS reports. */
49
44
  export type TrimDimsPair = [number, number];
@@ -53,21 +48,46 @@ export interface TrimDims {
53
48
  reference: TrimDimsPair;
54
49
  source: TrimDimsPair;
55
50
  }
51
+ /** How the stage will spawn the fidelity-trim helper. */
52
+ export interface TrimHelperInvocation {
53
+ cmd: string;
54
+ args: string[];
55
+ }
56
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.
57
+ * The platform-neutral helpers embedded in the trim-stage script.
58
+ * `parseTrimDims` reads the helper's TRIMDIMS stdout line; `roundTwo` is the
59
+ * logical-pt rounding primitive; `trimHelperInvocation` resolves how the
60
+ * generated script reaches `ci fidelity-trim` at run time, and
61
+ * `runTrimHelper` spawns it.
60
62
  */
61
63
  export interface PostProcessorInternals {
62
64
  parseTrimDims(stdout: string): TrimDims | null;
63
65
  roundTwo(value: number): number;
66
+ trimHelperInvocation(args: string[]): TrimHelperInvocation;
67
+ runTrimHelper(args: string[]): {
68
+ status: number | null;
69
+ stdout: string;
70
+ stderr: string;
71
+ };
64
72
  }
65
73
  /**
66
74
  * Evaluates the embedded trim-stage source and returns its pure helpers, so
67
75
  * 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.
76
+ * (mirrors getRenderScriptInternals in ui-fidelity-render.ts). `cp` and
77
+ * `process` are injectable so the invocation/spawn seams are testable; the
78
+ * other trim-stage functions reference runtime globals (fs, path, ...) only
79
+ * when called, so defining them here is harmless.
80
+ */
81
+ export declare function getPostProcessorInternals(overrides?: {
82
+ cp?: unknown;
83
+ processRef?: unknown;
84
+ }): PostProcessorInternals;
85
+ /**
86
+ * Resolves the on-disk JS entrypoint of this cibuild installation's CLI
87
+ * (dist/cli.cjs), for callers that spawn a generated render script directly
88
+ * (the preview harnesses) and must hand it a working CIBUILD_CLI_JS. Returns
89
+ * null when no built CLI exists nearby (e.g. running from src under jest) --
90
+ * the script then falls back to `ci` on PATH.
71
91
  */
72
- export declare function getPostProcessorInternals(): PostProcessorInternals;
92
+ export declare function resolveCliJsPath(): string | null;
73
93
  //# sourceMappingURL=render-post-processor.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAMH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,wBAAwB,EAAE,MA+GtC,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,yDAAyD;AACzD,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED;;;;;;GAMG;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;IAChC,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,oBAAoB,CAAC;IAC3D,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1F;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,CAAC,EAAE;IACpD,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,GAAG,sBAAsB,CASzB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAqBhD"}
@@ -6,16 +6,24 @@
6
6
  * reference-sized aligned copy, and the reported dimensions (rendered px /
7
7
  * logical pt / reference px). It operates on PNGs regardless of how they were
8
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.
9
+ * so crop/align/dimension behavior cannot drift between platforms.
13
10
  *
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.
11
+ * The pixel work is the pure-JS trim/align helper in trim-align.ts, invoked
12
+ * through the ci binary (`ci fidelity-trim`) -- the one artifact present on
13
+ * every backend -- so iOS and Android run the SAME bytes on macOS and Linux
14
+ * alike. It replaced an embedded CoreGraphics/ImageIO helper that ran via
15
+ * `swift` in script-interpreter mode and therefore could not run on Linux
16
+ * workers (fidelity-linux 06); the bounding-box, fit-pad, and TRIMDIMS
17
+ * semantics are a faithful port of that helper, guarded by a pixel-identity
18
+ * corpus gate (trim-align-corpus.test.ts).
19
+ *
20
+ * This module is consumed by `ui-fidelity-render.ts` (iOS) and
21
+ * `ui-fidelity-render-android.ts`; the surrounding JS orchestration is spliced
22
+ * into each platform's generated runtime script.
18
23
  */
24
+ import { existsSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
19
27
  /**
20
28
  * Runtime support for trimming the rendered output (package_source variants).
21
29
  *
@@ -25,197 +33,17 @@
25
33
  * non-transparent pixels, so the rendered artifact is the component itself,
26
34
  * tight -- no canvas, no background ambiguity for the human comparing screens.
27
35
  *
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.
36
+ * Best-effort: a trim failure is logged (warn-and-continue) and the untrimmed
37
+ * render is kept -- a cosmetic post-step never fails an otherwise good render.
38
+ * This is prep, not scoring: it produces a tighter image for the human's
39
+ * judgment and computes nothing about fidelity.
41
40
  *
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.
41
+ * NOTE: the comments inside the stage string ship inside generated runtime
42
+ * scripts. Keep them free of platform-toolchain references -- there is a unit
43
+ * gate asserting the stage never mentions the old interpreter.
45
44
  */
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
45
  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
46
  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
47
  // Round to two decimals, keeping the value a plain number.
220
48
  function roundTwo(value) {
221
49
  return Math.round(value * 100) / 100;
@@ -244,11 +72,48 @@ function parseTrimDims(stdout) {
244
72
  return null;
245
73
  }
246
74
 
75
+ // Resolve how to reach the ci CLI's fidelity-trim helper from inside this
76
+ // generated script. Three homes, in order:
77
+ // 1. CIBUILD_CLI_JS -- an explicit JS entrypoint (preview harnesses, tests),
78
+ // run under the same node executing this script.
79
+ // 2. The pkg-built ci binary re-executing itself -- the production runner
80
+ // path, where this script runs under the pkg bootstrap and
81
+ // process.execPath IS the ci binary.
82
+ // 3. A ci on PATH (brew/npm installs).
83
+ function trimHelperInvocation(args) {
84
+ if (process.env.CIBUILD_CLI_JS) {
85
+ return {
86
+ cmd: process.execPath,
87
+ args: [process.env.CIBUILD_CLI_JS, 'fidelity-trim'].concat(args),
88
+ };
89
+ }
90
+ if (process.pkg) {
91
+ return { cmd: process.execPath, args: ['fidelity-trim'].concat(args) };
92
+ }
93
+ return { cmd: 'ci', args: ['fidelity-trim'].concat(args) };
94
+ }
95
+
96
+ // Run the trim/align helper (pure JS inside the ci binary; the argv, TRIMDIMS
97
+ // stdout line, and exit codes are the retired CoreGraphics helper's contract).
98
+ function runTrimHelper(args) {
99
+ var invocation = trimHelperInvocation(args);
100
+ var result = cp.spawnSync(invocation.cmd, invocation.args, {
101
+ encoding: 'utf-8',
102
+ maxBuffer: 16 * 1024 * 1024,
103
+ });
104
+ if (result.stdout) process.stdout.write(result.stdout);
105
+ if (result.stderr) process.stderr.write(result.stderr);
106
+ if (result.error) {
107
+ result.status = result.status == null ? 127 : result.status;
108
+ result.stderr = (result.stderr || '') + String(result.error.message || result.error);
109
+ }
110
+ return result;
111
+ }
112
+
247
113
  // Crop the rendered PNG in place to its content bounding box, write an aligned
248
114
  // copy resized to the reference, and record the dimensions on the entry.
249
115
  // Best-effort: a failure is logged and the untrimmed render is kept.
250
- function trimRenderedImage(entry, renderedPath, harnessDir) {
251
- var helperPath = ensureTrimHelper(harnessDir);
116
+ function trimRenderedImage(entry, renderedPath) {
252
117
  var alignedRelative = 'ui-fidelity/aligned/' + entry.screen + '.png';
253
118
  var alignedPath = path.resolve(artifactsDir, alignedRelative);
254
119
  var referencePath =
@@ -260,7 +125,7 @@ function trimRenderedImage(entry, renderedPath, harnessDir) {
260
125
  } catch (mkdirError) {
261
126
  // best effort -- the helper reports a missing output dir itself
262
127
  }
263
- var result = runSwift([helperPath, renderedPath, renderedPath, alignedPath, referencePath]);
128
+ var result = runTrimHelper([renderedPath, renderedPath, alignedPath, referencePath]);
264
129
  if (result.status !== 0) {
265
130
  logError(
266
131
  'trim failed for ' + renderedPath +
@@ -291,13 +156,47 @@ function trimRenderedImage(entry, renderedPath, harnessDir) {
291
156
  /**
292
157
  * Evaluates the embedded trim-stage source and returns its pure helpers, so
293
158
  * 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.
159
+ * (mirrors getRenderScriptInternals in ui-fidelity-render.ts). `cp` and
160
+ * `process` are injectable so the invocation/spawn seams are testable; the
161
+ * other trim-stage functions reference runtime globals (fs, path, ...) only
162
+ * when called, so defining them here is harmless.
163
+ */
164
+ export function getPostProcessorInternals(overrides) {
165
+ const factory = new Function('cp', 'process', RENDER_SCRIPT_TRIM_STAGE +
166
+ '\nreturn { parseTrimDims: parseTrimDims, roundTwo: roundTwo, ' +
167
+ 'trimHelperInvocation: trimHelperInvocation, runTrimHelper: runTrimHelper };');
168
+ return factory(overrides?.cp, overrides?.processRef ?? process);
169
+ }
170
+ /**
171
+ * Resolves the on-disk JS entrypoint of this cibuild installation's CLI
172
+ * (dist/cli.cjs), for callers that spawn a generated render script directly
173
+ * (the preview harnesses) and must hand it a working CIBUILD_CLI_JS. Returns
174
+ * null when no built CLI exists nearby (e.g. running from src under jest) --
175
+ * the script then falls back to `ci` on PATH.
297
176
  */
298
- export function getPostProcessorInternals() {
299
- const factory = new Function(RENDER_SCRIPT_TRIM_STAGE +
300
- '\nreturn { parseTrimDims: parseTrimDims, roundTwo: roundTwo };');
301
- return factory();
177
+ export function resolveCliJsPath() {
178
+ let moduleDir;
179
+ try {
180
+ moduleDir = dirname(fileURLToPath(import.meta.url));
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ const candidates = [
186
+ // Bundled: import.meta.url is shimmed to dist/cli.cjs itself.
187
+ join(moduleDir, 'cli.cjs'),
188
+ // Compiled layout: dist/src/yaml/steps/*.js -> dist/cli.cjs.
189
+ join(moduleDir, '..', '..', '..', 'cli.cjs'),
190
+ ];
191
+ for (const candidate of candidates) {
192
+ try {
193
+ if (existsSync(candidate))
194
+ return candidate;
195
+ }
196
+ catch {
197
+ // keep looking
198
+ }
199
+ }
200
+ return null;
302
201
  }
303
202
  //# sourceMappingURL=render-post-processor.js.map