@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
@@ -0,0 +1,238 @@
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
+ // Prints "<r> <g> <b> <a>" (0..255) for one pixel, so a test can tell padding
120
+ // (transparent) apart from content (opaque) in an aligned image.
121
+ const PROBE_SWIFT = [
122
+ 'import Foundation',
123
+ 'import ImageIO',
124
+ 'import CoreGraphics',
125
+ 'let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: CommandLine.arguments[1]) as CFURL, nil)!',
126
+ 'let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!',
127
+ 'let x = Int(CommandLine.arguments[2])!, y = Int(CommandLine.arguments[3])!',
128
+ 'let w = img.width, h = img.height',
129
+ 'var data = [UInt8](repeating: 0, count: w * h * 4)',
130
+ 'let cs = CGColorSpaceCreateDeviceRGB()',
131
+ 'let c = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!',
132
+ 'c.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))',
133
+ 'let i = y * w * 4 + x * 4',
134
+ 'print("\\(data[i]) \\(data[i+1]) \\(data[i+2]) \\(data[i+3])")',
135
+ ].join('\n') + '\n';
136
+ function scene() {
137
+ const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-postproc-'));
138
+ dirs.push(dir);
139
+ writeFileSync(join(dir, 'Make.swift'), MAKE_FIXTURE_SWIFT);
140
+ writeFileSync(join(dir, 'Trim.swift'), TRIM_HELPER_SWIFT_SOURCE);
141
+ writeFileSync(join(dir, 'Probe.swift'), PROBE_SWIFT);
142
+ return dir;
143
+ }
144
+ // Alpha (0..255) of one pixel in a PNG.
145
+ function alphaAt(dir, filePath, x, y) {
146
+ const probe = spawnSync('swift', [join(dir, 'Probe.swift'), filePath, String(x), String(y)], {
147
+ encoding: 'utf-8',
148
+ });
149
+ expect(probe.status).toBe(0);
150
+ return Number(probe.stdout.trim().split(/\s+/)[3]);
151
+ }
152
+ function craft(dir, name, w, h, rects) {
153
+ const out = join(dir, name);
154
+ const make = spawnSync('swift', [join(dir, 'Make.swift'), out, JSON.stringify({ w, h, rects })], { encoding: 'utf-8' });
155
+ expect(make.stderr + '').toBe('');
156
+ expect(make.status).toBe(0);
157
+ return out;
158
+ }
159
+ function trim(dir, input, output, aligned, reference) {
160
+ const args = [join(dir, 'Trim.swift'), input, output];
161
+ if (aligned && reference)
162
+ args.push(aligned, reference);
163
+ const result = spawnSync('swift', args, { encoding: 'utf-8' });
164
+ expect(result.status).toBe(0);
165
+ return result;
166
+ }
167
+ realSwiftTest('trims a transparent frame down to its single content rectangle', () => {
168
+ const dir = scene();
169
+ const input = craft(dir, 'in.png', 200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]);
170
+ const out = join(dir, 'out.png');
171
+ trim(dir, input, out);
172
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
173
+ }, 600_000);
174
+ realSwiftTest('crops an opaque uniform-background wrapper to its content card', () => {
175
+ const dir = scene();
176
+ // 420x320 transparent canvas, 375x200 white wrapper, 343x168 pink card.
177
+ const input = craft(dir, 'in.png', 420, 320, [
178
+ { x: 20, y: 40, w: 375, h: 200, ...WHITE },
179
+ { x: 36, y: 56, w: 343, h: 168, ...PINK },
180
+ ]);
181
+ const out = join(dir, 'out.png');
182
+ trim(dir, input, out);
183
+ // Lands on the content card, NOT the white wrapper (375x200) or the raw
184
+ // canvas (420x320).
185
+ expect(pngSize(out)).toEqual({ width: 343, height: 168 });
186
+ }, 600_000);
187
+ realSwiftTest('trims only transparency when the opaque corners disagree (fallback)', () => {
188
+ const dir = scene();
189
+ // Two differently-colored halves: the opaque bounding box is 80x80 but its
190
+ // corners are not one uniform color, so no background is detected and only
191
+ // the transparent margin is removed.
192
+ const input = craft(dir, 'in.png', 120, 120, [
193
+ { x: 20, y: 20, w: 40, h: 80, ...RED },
194
+ { x: 60, y: 20, w: 40, h: 80, ...BLUE },
195
+ ]);
196
+ const out = join(dir, 'out.png');
197
+ trim(dir, input, out);
198
+ expect(pngSize(out)).toEqual({ width: 80, height: 80 });
199
+ }, 600_000);
200
+ realSwiftTest('writes a reference-sized aligned image and reports all three dimensions', () => {
201
+ const dir = scene();
202
+ const input = craft(dir, 'in.png', 200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]);
203
+ const reference = craft(dir, 'ref.png', 100, 50, [{ x: 0, y: 0, w: 100, h: 50, ...BLUE }]);
204
+ const out = join(dir, 'out.png');
205
+ const aligned = join(dir, 'aligned.png');
206
+ const result = trim(dir, input, out, aligned, reference);
207
+ // The trimmed render is the content; the aligned copy is fit onto a
208
+ // reference-sized canvas (preserving aspect) for a 1:1 overlay.
209
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
210
+ expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
211
+ // The TRIMDIMS line reports rendered (trimmed) / reference / pre-trim
212
+ // source px, parsed by the same helper the runtime uses.
213
+ expect(parseTrimDims(result.stdout)).toEqual({
214
+ rendered: [60, 40],
215
+ reference: [100, 50],
216
+ source: [200, 200],
217
+ });
218
+ }, 600_000);
219
+ realSwiftTest('fits a wide render into a square reference without distortion (transparent letterbox)', () => {
220
+ const dir = scene();
221
+ // Content fills its canvas: a 2:1 render (80x40), all opaque red.
222
+ const input = craft(dir, 'in.png', 80, 40, [{ x: 0, y: 0, w: 80, h: 40, ...RED }]);
223
+ // Square 1:1 reference (50x50) — a deliberately different aspect ratio.
224
+ const reference = craft(dir, 'ref.png', 50, 50, [{ x: 0, y: 0, w: 50, h: 50, ...BLUE }]);
225
+ const out = join(dir, 'out.png');
226
+ const aligned = join(dir, 'aligned.png');
227
+ trim(dir, input, out, aligned, reference);
228
+ // Aligned stays exactly reference-sized (overlay pair holds).
229
+ expect(pngSize(aligned)).toEqual({ width: 50, height: 50 });
230
+ // Aspect preserved: the 2:1 content fits by width (50x25) and is centered,
231
+ // so both letterbox bands are transparent while the center is opaque
232
+ // content. A stretch-to-fit would make every one of these opaque.
233
+ expect(alphaAt(dir, aligned, 25, 3)).toBe(0); // top band
234
+ expect(alphaAt(dir, aligned, 25, 46)).toBe(0); // bottom band
235
+ expect(alphaAt(dir, aligned, 25, 25)).toBe(255); // content (center)
236
+ }, 600_000);
237
+ });
238
+ //# sourceMappingURL=render-post-processor.test.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Local Android ui-fidelity render preview.
3
+ *
4
+ * The Android sibling of `renderUiFidelityPreview` (iOS). It runs the EXACT same
5
+ * render path the runner uses -- the generated `ui-fidelity-render-android` node
6
+ * script in `package_source: "inputs"` mode (archive extract/validate, one
7
+ * Roborazzi record task, golden location, content trim, reference alignment,
8
+ * dimension reporting) -- but locally, against a self-contained Gradle render
9
+ * project the caller already has on disk. This lets an agent eyeball the Compose
10
+ * wrapper in seconds before dispatching a remote run, so the remote pass becomes
11
+ * a one-shot authoritative render rather than the inner loop of an iterate cycle.
12
+ *
13
+ * It is a faithful mirror, not a re-implementation: the project directory is
14
+ * archived to a temp `package.tar.gz` and fed through the same
15
+ * `generateAndroidRenderScript` output, so the produced rendered/aligned images
16
+ * and dimensions match what a remote run would produce on the same toolchain.
17
+ *
18
+ * The result shape is identical to the iOS preview (`UiFidelityPreviewResult`),
19
+ * so callers (and the MCP handler) treat both platforms uniformly.
20
+ */
21
+ import type { UiFidelityPreviewResult, UiFidelityPreviewScreen } from './ui-fidelity-preview.js';
22
+ export interface UiFidelityPreviewAndroidOptions {
23
+ /**
24
+ * Local Gradle render-project directory to render. Archived to a temp
25
+ * package.tar.gz and rendered in inputs mode. Mutually exclusive with
26
+ * packageArchive.
27
+ */
28
+ packagePath?: string;
29
+ /**
30
+ * A prebuilt package.tar.gz to render as-is (preview the exact artifact a
31
+ * remote run would receive). Mutually exclusive with packagePath.
32
+ */
33
+ packageArchive?: string;
34
+ /** Screens to render, each mapped to a reference image path. */
35
+ screens: UiFidelityPreviewScreen[];
36
+ /**
37
+ * Display scale used when reporting logical points (default 2). Must match the
38
+ * density qualifier the render test pins (xhdpi = 2x).
39
+ */
40
+ scale?: number | string;
41
+ /** Gradle record task to run (default "recordRoborazziDebug"). */
42
+ gradleTask?: string;
43
+ /** Directory to receive the artifacts; a temp dir is used when omitted. */
44
+ outDir?: string;
45
+ /**
46
+ * Extra entries prepended to PATH for the render child process, so callers
47
+ * (and tests) can supply a specific java/gradle/swift/tar toolchain. Optional.
48
+ */
49
+ toolchainPath?: string;
50
+ }
51
+ /**
52
+ * Build error returned when the local Android toolchain is absent, so the caller
53
+ * can fall back to a remote run instead of a confusing build failure. Generic
54
+ * wording on purpose -- this engine ships in the public build tooling and must
55
+ * not name product-specific tools.
56
+ */
57
+ export declare const ANDROID_TOOLCHAIN_MISSING = "ANDROID_TOOLCHAIN_MISSING";
58
+ /**
59
+ * Render the requested screens locally and return the produced image paths and
60
+ * dimensions. Throws only on caller errors (bad options) or when the render
61
+ * script could not run at all; per-screen and per-build render failures (and a
62
+ * missing local toolchain) are reported in the returned result, mirroring the
63
+ * remote result document and the iOS preview engine.
64
+ */
65
+ export declare function renderUiFidelityPreviewAndroid(options: UiFidelityPreviewAndroidOptions): UiFidelityPreviewResult;
66
+ //# sourceMappingURL=ui-fidelity-preview-android.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAsBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAExB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAkBD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CA6KzB"}
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Local Android ui-fidelity render preview.
3
+ *
4
+ * The Android sibling of `renderUiFidelityPreview` (iOS). It runs the EXACT same
5
+ * render path the runner uses -- the generated `ui-fidelity-render-android` node
6
+ * script in `package_source: "inputs"` mode (archive extract/validate, one
7
+ * Roborazzi record task, golden location, content trim, reference alignment,
8
+ * dimension reporting) -- but locally, against a self-contained Gradle render
9
+ * project the caller already has on disk. This lets an agent eyeball the Compose
10
+ * wrapper in seconds before dispatching a remote run, so the remote pass becomes
11
+ * a one-shot authoritative render rather than the inner loop of an iterate cycle.
12
+ *
13
+ * It is a faithful mirror, not a re-implementation: the project directory is
14
+ * archived to a temp `package.tar.gz` and fed through the same
15
+ * `generateAndroidRenderScript` output, so the produced rendered/aligned images
16
+ * and dimensions match what a remote run would produce on the same toolchain.
17
+ *
18
+ * The result shape is identical to the iOS preview (`UiFidelityPreviewResult`),
19
+ * so callers (and the MCP handler) treat both platforms uniformly.
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
23
+ import { homedir, tmpdir } from 'node:os';
24
+ import { basename, join, resolve } from 'node:path';
25
+ import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
26
+ import { DEFAULT_GRADLE_TASK, PACKAGE_ARCHIVE_BASENAME, generateAndroidRenderScript, isValidGradleTask, } from './ui-fidelity-render-android.js';
27
+ /**
28
+ * Build error returned when the local Android toolchain is absent, so the caller
29
+ * can fall back to a remote run instead of a confusing build failure. Generic
30
+ * wording on purpose -- this engine ships in the public build tooling and must
31
+ * not name product-specific tools.
32
+ */
33
+ export const ANDROID_TOOLCHAIN_MISSING = 'ANDROID_TOOLCHAIN_MISSING';
34
+ function runTar(args, pathPrepend) {
35
+ const env = pathPrepend
36
+ ? { ...process.env, PATH: pathPrepend + ':' + (process.env.PATH ?? '') }
37
+ : process.env;
38
+ const result = spawnSync('tar', args, { encoding: 'utf-8', env, maxBuffer: 64 * 1024 * 1024 });
39
+ if (result.status !== 0) {
40
+ const detail = (result.stderr || result.error?.message || '').toString().trim();
41
+ throw new Error(`failed to archive the project (tar exited ${result.status}): ${detail}`);
42
+ }
43
+ }
44
+ /** True if `command` resolves on the (optionally prepended) PATH. */
45
+ function commandExists(command, pathPrepend) {
46
+ const env = pathPrepend
47
+ ? { ...process.env, PATH: pathPrepend + ':' + (process.env.PATH ?? '') }
48
+ : process.env;
49
+ const probe = process.platform === 'win32'
50
+ ? spawnSync('where', [command], { encoding: 'utf-8', env })
51
+ : spawnSync(`command -v ${command}`, { encoding: 'utf-8', env, shell: true });
52
+ return probe.status === 0 && (probe.stdout || '').trim() !== '';
53
+ }
54
+ /**
55
+ * Resolve the Android SDK directory: prefer an explicit `ANDROID_HOME` /
56
+ * `ANDROID_SDK_ROOT` env var (when it points at a real directory), otherwise
57
+ * fall back to the platform's default install location. This mirrors the
58
+ * auto-detect the Android build step already does, so a dev machine with a
59
+ * standard Android Studio install previews locally without the caller (or the
60
+ * MCP server's launch environment) having to export `ANDROID_HOME`. Returns the
61
+ * resolved path, or null when no SDK can be found.
62
+ */
63
+ function resolveAndroidSdk() {
64
+ const fromEnv = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '';
65
+ if (fromEnv && existsSync(fromEnv))
66
+ return fromEnv;
67
+ const home = process.env.HOME || homedir();
68
+ const candidates = [
69
+ join(home, 'Library', 'Android', 'sdk'), // macOS
70
+ join(home, 'Android', 'Sdk'), // Linux
71
+ '/usr/local/android-sdk',
72
+ ];
73
+ for (const candidate of candidates) {
74
+ if (existsSync(candidate))
75
+ return candidate;
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * Report which env-level Android prerequisites are missing: the JVM (`java`) and
81
+ * the SDK (a resolved [resolveAndroidSdk] path). Gradle itself ships inside the
82
+ * render project (gradlew), so it is not an env-level prerequisite. Returns an
83
+ * empty array when the toolchain is present.
84
+ */
85
+ function missingToolchain(sdk, pathPrepend) {
86
+ const missing = [];
87
+ if (!commandExists('java', pathPrepend))
88
+ missing.push('a JDK (java not on PATH)');
89
+ if (!sdk) {
90
+ missing.push('the Android SDK (ANDROID_HOME not set and no SDK at the default location)');
91
+ }
92
+ return missing;
93
+ }
94
+ /** Resolve an artifact-relative path to absolute, or null. */
95
+ function absOrNull(artifactsDir, relative) {
96
+ return typeof relative === 'string' && relative !== '' ? resolve(artifactsDir, relative) : null;
97
+ }
98
+ /**
99
+ * Render the requested screens locally and return the produced image paths and
100
+ * dimensions. Throws only on caller errors (bad options) or when the render
101
+ * script could not run at all; per-screen and per-build render failures (and a
102
+ * missing local toolchain) are reported in the returned result, mirroring the
103
+ * remote result document and the iOS preview engine.
104
+ */
105
+ export function renderUiFidelityPreviewAndroid(options) {
106
+ if (!options || typeof options !== 'object') {
107
+ throw new Error('renderUiFidelityPreviewAndroid: options are required');
108
+ }
109
+ const hasDir = typeof options.packagePath === 'string' && options.packagePath !== '';
110
+ const hasArchive = typeof options.packageArchive === 'string' && options.packageArchive !== '';
111
+ if (hasDir === hasArchive) {
112
+ throw new Error('renderUiFidelityPreviewAndroid: provide exactly one of packagePath or packageArchive');
113
+ }
114
+ if (!Array.isArray(options.screens) || options.screens.length === 0) {
115
+ throw new Error('renderUiFidelityPreviewAndroid: at least one screen is required');
116
+ }
117
+ const scale = parseScale(options.scale ?? DEFAULT_SCALE);
118
+ const gradleTask = String(options.gradleTask ?? DEFAULT_GRADLE_TASK);
119
+ if (!isValidGradleTask(gradleTask)) {
120
+ throw new Error(`renderUiFidelityPreviewAndroid: invalid gradleTask '${gradleTask}' ` +
121
+ '(expected a Gradle task path: letters, digits, and : - _)');
122
+ }
123
+ const workDir = mkdtempSync(join(tmpdir(), 'ui-fidelity-android-preview-'));
124
+ const inputsDir = join(workDir, '.ci', 'inputs');
125
+ mkdirSync(inputsDir, { recursive: true });
126
+ // Stage references by basename and build the screens -> basename map, exactly
127
+ // as a remote caller would pass params.screens.
128
+ const screensParam = {};
129
+ for (const entry of options.screens) {
130
+ if (!entry || typeof entry.screen !== 'string' || entry.screen === '') {
131
+ throw new Error('renderUiFidelityPreviewAndroid: each screen needs a non-empty screen name');
132
+ }
133
+ if (typeof entry.reference !== 'string' || entry.reference === '') {
134
+ throw new Error(`renderUiFidelityPreviewAndroid: screen ${entry.screen} needs a reference path`);
135
+ }
136
+ if (!existsSync(entry.reference) || !statSync(entry.reference).isFile()) {
137
+ throw new Error(`renderUiFidelityPreviewAndroid: reference for ${entry.screen} not found: ${entry.reference}`);
138
+ }
139
+ const base = basename(entry.reference);
140
+ copyFileSync(entry.reference, join(inputsDir, base));
141
+ screensParam[entry.screen] = base;
142
+ }
143
+ const artifactsDir = options.outDir
144
+ ? resolve(options.outDir)
145
+ : join(workDir, '.ci', 'artifacts');
146
+ mkdirSync(artifactsDir, { recursive: true });
147
+ // A render is a heavy Gradle/Robolectric build; if the dev machine has no
148
+ // local Android toolchain at all, fall back to a remote run with a clear
149
+ // message instead of letting the build fail confusingly.
150
+ const androidSdk = resolveAndroidSdk();
151
+ const missing = missingToolchain(androidSdk, options.toolchainPath);
152
+ if (missing.length > 0) {
153
+ return {
154
+ packageSource: 'inputs',
155
+ buildError: {
156
+ code: ANDROID_TOOLCHAIN_MISSING,
157
+ message: 'the local Android toolchain is unavailable (' +
158
+ missing.join(', ') +
159
+ '); render this Compose wrapper on a runner instead of locally',
160
+ },
161
+ screens: [],
162
+ artifactsDir,
163
+ ok: false,
164
+ };
165
+ }
166
+ const archivePath = join(inputsDir, PACKAGE_ARCHIVE_BASENAME);
167
+ if (hasArchive) {
168
+ const src = options.packageArchive;
169
+ if (!existsSync(src) || !statSync(src).isFile()) {
170
+ throw new Error(`renderUiFidelityPreviewAndroid: packageArchive not found: ${src}`);
171
+ }
172
+ copyFileSync(src, archivePath);
173
+ }
174
+ else {
175
+ const dir = options.packagePath;
176
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
177
+ throw new Error(`renderUiFidelityPreviewAndroid: packagePath is not a directory: ${dir}`);
178
+ }
179
+ // Archive with the settings script at the archive root; exclude Gradle/IDE/VCS
180
+ // build noise so the upload stays lean and matches what a careful caller would
181
+ // ship. gradlew is kept so the runner uses the pinned wrapper.
182
+ runTar([
183
+ '--exclude',
184
+ './build',
185
+ '--exclude',
186
+ '*/build',
187
+ '--exclude',
188
+ './.gradle',
189
+ '--exclude',
190
+ '*/.gradle',
191
+ '--exclude',
192
+ '.git',
193
+ '--exclude',
194
+ '.idea',
195
+ '--exclude',
196
+ '.DS_Store',
197
+ '--exclude',
198
+ 'local.properties',
199
+ '-czf',
200
+ archivePath,
201
+ '-C',
202
+ dir,
203
+ '.',
204
+ ], options.toolchainPath);
205
+ }
206
+ // The standard params.json the render script reads: screen -> reference basename.
207
+ writeFileSync(join(inputsDir, 'params.json'), JSON.stringify({ screens: screensParam }, null, 2) + '\n');
208
+ const script = generateAndroidRenderScript({ scale, gradleTask });
209
+ const env = {
210
+ ...process.env,
211
+ CIBUILD_ARTIFACTS_DIR: artifactsDir,
212
+ };
213
+ if (options.toolchainPath) {
214
+ env.PATH = options.toolchainPath + ':' + (process.env.PATH ?? '');
215
+ }
216
+ // Export the resolved SDK so gradlew sees it even when ANDROID_HOME was
217
+ // auto-detected (env unset) rather than already present in process.env.
218
+ if (androidSdk) {
219
+ env.ANDROID_HOME = androidSdk;
220
+ if (!process.env.ANDROID_SDK_ROOT)
221
+ env.ANDROID_SDK_ROOT = androidSdk;
222
+ }
223
+ const run = spawnSync('node', ['-e', script], {
224
+ cwd: workDir,
225
+ env,
226
+ encoding: 'utf-8',
227
+ maxBuffer: 64 * 1024 * 1024,
228
+ });
229
+ const resultPath = join(artifactsDir, 'protocol-result.json');
230
+ if (!existsSync(resultPath)) {
231
+ const detail = ((run.stdout || '') + '\n' + (run.stderr || '')).trim();
232
+ throw new Error(`renderUiFidelityPreviewAndroid: render script produced no result document` +
233
+ (detail ? `:\n${detail}` : ''));
234
+ }
235
+ const raw = JSON.parse(readFileSync(resultPath, 'utf-8'));
236
+ const screens = (raw.screens || []).map((s) => ({
237
+ screen: s.screen,
238
+ status: s.status,
239
+ error: s.error ?? null,
240
+ referenceImagePath: absOrNull(artifactsDir, s.reference_image_path),
241
+ renderedImagePath: absOrNull(artifactsDir, s.rendered_image_path),
242
+ alignedImagePath: absOrNull(artifactsDir, s.aligned_image_path),
243
+ dimensions: s.dimensions ?? null,
244
+ }));
245
+ const buildError = raw.error ?? null;
246
+ const ok = buildError === null && screens.length > 0 && screens.every((s) => s.status === 'rendered');
247
+ return { packageSource: 'inputs', buildError, screens, artifactsDir, ok };
248
+ }
249
+ //# sourceMappingURL=ui-fidelity-preview-android.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Tests for the local Android ui-fidelity render preview engine.
3
+ *
4
+ * The orchestration (archive, params, result shaping) and the toolchain-missing
5
+ * guard run with a PATH-shimmed fake `java`/`gradlew`/`swift` toolchain: the
6
+ * engine spawns the real generated Android render script, which spawns the
7
+ * toolchain. The actual Roborazzi/Robolectric render and the CoreGraphics trim
8
+ * are covered by an opt-in real-toolchain test, gated on
9
+ * CIBUILD_UI_FIDELITY_REAL_ANDROID, since the fake toolchain cannot exercise the
10
+ * real pixel render/trim.
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=ui-fidelity-preview-android.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-fidelity-preview-android.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}