@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.
- package/dist/cli.cjs +688 -69
- package/dist/src/lib.d.ts +2 -0
- package/dist/src/lib.d.ts.map +1 -1
- package/dist/src/lib.js +1 -0
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +22 -0
- package/dist/src/yaml/steps/render-post-processor.d.ts +73 -0
- package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -0
- package/dist/src/yaml/steps/render-post-processor.js +303 -0
- package/dist/src/yaml/steps/render-post-processor.test.d.ts +16 -0
- package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/render-post-processor.test.js +238 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +66 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.js +249 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +13 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +299 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +92 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +726 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +2 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +377 -0
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -28
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +1 -259
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +1 -57
- 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"}
|