@invarn/cibuild 2.3.8 → 2.4.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 (36) 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.map +1 -1
  32. package/dist/src/yaml/steps/ui-fidelity-render-android.js +7 -16
  33. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +9 -9
  34. package/dist/src/yaml/steps/ui-fidelity-render.js +1 -1
  35. package/dist/src/yaml/steps/ui-fidelity-render.test.js +25 -20
  36. package/package.json +3 -1
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Pure-JS trim/align helper — the CoreGraphics port (fidelity-linux 06).
3
+ *
4
+ * This is a PORT of the embedded Swift helper that used to live in
5
+ * render-post-processor.ts (`TRIM_HELPER_SWIFT_SOURCE`), not a redesign: the
6
+ * bounding-box semantics (alpha pass, corner-derived uniform background,
7
+ * tolerance 4, never-crop-to-nothing fallback), the fit-pad geometry
8
+ * (aspect-preserving scale, centered, transparent letterbox) and the TRIMDIMS
9
+ * contract are byte-for-byte the Swift behavior. CoreGraphics/ImageIO do not
10
+ * exist on Linux, so the pixel work moved into the ci binary itself (pngjs is
11
+ * pure JS and bundles via bundle.mjs) — iOS and Android now run the SAME bytes,
12
+ * and output pixels can only change with our lockfile, never with a runner OS
13
+ * upgrade.
14
+ *
15
+ * Port-fidelity notes, load-bearing for the corpus gate:
16
+ * - The Swift helper ANALYZED pixels through a premultiplied-alpha CGContext
17
+ * but CROPPED the original (straight-alpha) image. We mirror both: the
18
+ * bounding box is computed over premultiplied values, the trimmed output is
19
+ * a raw copy of the source pixels.
20
+ * - The Swift aligned output was drawn through a premultiplied context and
21
+ * unpremultiplied on PNG encode, which is lossy on semi-transparent pixels;
22
+ * we reproduce that roundtrip. Where CoreGraphics RESAMPLED (trimmed size ≠
23
+ * reference size), its proprietary `.high` interpolation is not bit-exactly
24
+ * reproducible — we use bilinear filtering in premultiplied space. The
25
+ * trimmed image and TRIMDIMS never depend on resampling and are the
26
+ * pixel-identical surface the corpus gate enforces.
27
+ */
28
+ import { readFileSync, writeFileSync } from 'node:fs';
29
+ import { PNG } from 'pngjs';
30
+ /** Mirror of the Swift helper's exit codes. */
31
+ export const TRIM_EXIT_USAGE = 64;
32
+ export const TRIM_EXIT_UNREADABLE = 65;
33
+ export const TRIM_EXIT_NO_CONTENT = 66;
34
+ export const TRIM_EXIT_WRITE_FAILED = 68;
35
+ // Tight tolerance: exact solid fills match, near-colored content does not.
36
+ // (Same constant, same meaning as the Swift helper.)
37
+ const TOLERANCE = 4;
38
+ /**
39
+ * Premultiply one straight-alpha channel value, matching the rounding
40
+ * CoreGraphics/vImage applies when drawing into a premultiplied context:
41
+ * (v * a + 127) / 255, truncated.
42
+ */
43
+ function premultiply(value, alpha) {
44
+ return ((value * alpha + 127) / 255) | 0;
45
+ }
46
+ /**
47
+ * Un-premultiply one channel value, matching ImageIO's PNG encode of a
48
+ * premultiplied bitmap. Transparent pixels collapse to zero (which is also
49
+ * why the Swift aligned output zeroes the RGB of fully-transparent pixels).
50
+ */
51
+ function unpremultiply(value, alpha) {
52
+ if (alpha === 0)
53
+ return 0;
54
+ const v = Math.round((value * 255) / alpha);
55
+ return v > 255 ? 255 : v;
56
+ }
57
+ /**
58
+ * Bounding box of content: pixels that are neither transparent nor part of a
59
+ * uniform solid background. Two passes, because the renderer forces the view
60
+ * into a fixed device frame whose margins are transparent: (1) bound the
61
+ * non-transparent pixels, then (2) detect a uniform opaque background from the
62
+ * CORNERS OF THAT OPAQUE BOX — not the raw frame, whose corners are the
63
+ * transparent device margin — and bound everything that is not that
64
+ * background. A wrapper that fills the canvas with .background(.white) is thus
65
+ * trimmed to its real content. When the opaque corners disagree, only
66
+ * transparency is trimmed. Top-left image coordinates.
67
+ *
68
+ * `data` is straight-alpha RGBA8 (as pngjs decodes); the color comparisons run
69
+ * on premultiplied values to mirror the Swift helper's analysis context.
70
+ */
71
+ export function contentBoundingBox(data, width, height) {
72
+ if (width === 0 || height === 0)
73
+ return null;
74
+ const bytesPerRow = width * 4;
75
+ // Pass 1: bounding box of all non-transparent pixels.
76
+ let aMinX = width;
77
+ let aMinY = height;
78
+ let aMaxX = -1;
79
+ let aMaxY = -1;
80
+ for (let y = 0; y < height; y++) {
81
+ const rowBase = y * bytesPerRow;
82
+ for (let x = 0; x < width; x++) {
83
+ if (data[rowBase + x * 4 + 3] > 0) {
84
+ if (x < aMinX)
85
+ aMinX = x;
86
+ if (x > aMaxX)
87
+ aMaxX = x;
88
+ if (y < aMinY)
89
+ aMinY = y;
90
+ if (y > aMaxY)
91
+ aMaxY = y;
92
+ }
93
+ }
94
+ }
95
+ if (aMaxX < 0)
96
+ return null;
97
+ const opaqueBox = {
98
+ x: aMinX,
99
+ y: aMinY,
100
+ width: aMaxX - aMinX + 1,
101
+ height: aMaxY - aMinY + 1,
102
+ };
103
+ // Premultiplied RGBA of one pixel — the analysis view of the image.
104
+ function sample(x, y) {
105
+ const i = y * bytesPerRow + x * 4;
106
+ const a = data[i + 3];
107
+ return [
108
+ premultiply(data[i], a),
109
+ premultiply(data[i + 1], a),
110
+ premultiply(data[i + 2], a),
111
+ a,
112
+ ];
113
+ }
114
+ // Detect a uniform opaque background from the corners of the opaque box.
115
+ // As in the Swift helper, every corner is compared against the FIRST corner
116
+ // (background only ever holds corners[0] or null).
117
+ const corners = [
118
+ sample(aMinX, aMinY),
119
+ sample(aMaxX, aMinY),
120
+ sample(aMinX, aMaxY),
121
+ sample(aMaxX, aMaxY),
122
+ ];
123
+ let background = corners[0];
124
+ for (const c of corners) {
125
+ if (background !== null) {
126
+ if (c[3] === 0 ||
127
+ Math.abs(c[0] - background[0]) > TOLERANCE ||
128
+ Math.abs(c[1] - background[1]) > TOLERANCE ||
129
+ Math.abs(c[2] - background[2]) > TOLERANCE ||
130
+ Math.abs(c[3] - background[3]) > TOLERANCE) {
131
+ background = null;
132
+ }
133
+ }
134
+ }
135
+ // No uniform background: the opaque box is the content.
136
+ if (background === null)
137
+ return opaqueBox;
138
+ const bg = background;
139
+ // Pass 2: within the opaque box, bound everything that is not the background.
140
+ let minX = aMaxX + 1;
141
+ let minY = aMaxY + 1;
142
+ let maxX = aMinX - 1;
143
+ let maxY = aMinY - 1;
144
+ for (let y = aMinY; y <= aMaxY; y++) {
145
+ const rowBase = y * bytesPerRow;
146
+ for (let x = aMinX; x <= aMaxX; x++) {
147
+ const idx = rowBase + x * 4;
148
+ const a = data[idx + 3];
149
+ if (a === 0)
150
+ continue;
151
+ const r = premultiply(data[idx], a);
152
+ const g = premultiply(data[idx + 1], a);
153
+ const b = premultiply(data[idx + 2], a);
154
+ if (Math.abs(r - bg[0]) <= TOLERANCE &&
155
+ Math.abs(g - bg[1]) <= TOLERANCE &&
156
+ Math.abs(b - bg[2]) <= TOLERANCE &&
157
+ Math.abs(a - bg[3]) <= TOLERANCE) {
158
+ continue;
159
+ }
160
+ if (x < minX)
161
+ minX = x;
162
+ if (x > maxX)
163
+ maxX = x;
164
+ if (y < minY)
165
+ minY = y;
166
+ if (y > maxY)
167
+ maxY = y;
168
+ }
169
+ }
170
+ // All background, no distinct content: keep the opaque box, never crop to nothing.
171
+ if (maxX < minX)
172
+ return opaqueBox;
173
+ return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
174
+ }
175
+ /** Raw pixel copy of a region — the Swift `image.cropping(to:)` equivalent. */
176
+ export function cropImage(src, box) {
177
+ const out = new PNG({ width: box.width, height: box.height });
178
+ for (let y = 0; y < box.height; y++) {
179
+ const srcStart = ((box.y + y) * src.width + box.x) * 4;
180
+ src.data.copy(out.data, y * box.width * 4, srcStart, srcStart + box.width * 4);
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * Draw `src` centered on a transparent reference-sized canvas, scaled to fit
186
+ * while PRESERVING its aspect ratio. Unlike a stretch-to-fit (which distorts
187
+ * proportions when the aspect ratios differ), this keeps the rendered shape
188
+ * honest: an aspect mismatch shows as transparent letterbox/pillarbox padding
189
+ * rather than a squished image, while the canvas stays exactly reference-sized
190
+ * so the pair still overlays 1:1.
191
+ *
192
+ * Compositing happens in premultiplied space with an unpremultiply on the way
193
+ * out, mirroring the Swift context roundtrip. Resampling is bilinear (see the
194
+ * module docblock for why CG's `.high` filter is not reproducible).
195
+ */
196
+ export function fitPad(src, width, height) {
197
+ if (width <= 0 || height <= 0)
198
+ return null;
199
+ const out = new PNG({ width, height }); // zero-initialized: transparent canvas
200
+ const scale = Math.min(width / src.width, height / src.height);
201
+ const drawW = src.width * scale;
202
+ const drawH = src.height * scale;
203
+ const originX = (width - drawW) / 2;
204
+ const originY = (height - drawH) / 2;
205
+ // Premultiplied source, sampled by the filter below.
206
+ const pre = Buffer.alloc(src.width * src.height * 4);
207
+ for (let i = 0; i < src.width * src.height * 4; i += 4) {
208
+ const a = src.data[i + 3];
209
+ pre[i] = premultiply(src.data[i], a);
210
+ pre[i + 1] = premultiply(src.data[i + 1], a);
211
+ pre[i + 2] = premultiply(src.data[i + 2], a);
212
+ pre[i + 3] = a;
213
+ }
214
+ const identity = scale === 1 && Number.isInteger(originX) && Number.isInteger(originY);
215
+ for (let dy = 0; dy < height; dy++) {
216
+ for (let dx = 0; dx < width; dx++) {
217
+ const cx = dx + 0.5;
218
+ const cy = dy + 0.5;
219
+ if (cx < originX || cx > originX + drawW || cy < originY || cy > originY + drawH) {
220
+ continue; // transparent padding
221
+ }
222
+ const di = (dy * width + dx) * 4;
223
+ if (identity) {
224
+ // 1:1 placement — no filtering, but still the premultiply roundtrip
225
+ // the Swift context applied.
226
+ const si = ((dy - originY) * src.width + (dx - originX)) * 4;
227
+ const a = pre[si + 3];
228
+ out.data[di] = unpremultiply(pre[si], a);
229
+ out.data[di + 1] = unpremultiply(pre[si + 1], a);
230
+ out.data[di + 2] = unpremultiply(pre[si + 2], a);
231
+ out.data[di + 3] = a;
232
+ continue;
233
+ }
234
+ // Bilinear sample at the source-space position of this dest pixel center,
235
+ // clamped to the source edges.
236
+ const sx = (cx - originX) / scale - 0.5;
237
+ const sy = (cy - originY) / scale - 0.5;
238
+ const x0 = Math.floor(sx);
239
+ const y0 = Math.floor(sy);
240
+ const fx = sx - x0;
241
+ const fy = sy - y0;
242
+ const clampX = (v) => (v < 0 ? 0 : v >= src.width ? src.width - 1 : v);
243
+ const clampY = (v) => (v < 0 ? 0 : v >= src.height ? src.height - 1 : v);
244
+ const i00 = (clampY(y0) * src.width + clampX(x0)) * 4;
245
+ const i10 = (clampY(y0) * src.width + clampX(x0 + 1)) * 4;
246
+ const i01 = (clampY(y0 + 1) * src.width + clampX(x0)) * 4;
247
+ const i11 = (clampY(y0 + 1) * src.width + clampX(x0 + 1)) * 4;
248
+ const w00 = (1 - fx) * (1 - fy);
249
+ const w10 = fx * (1 - fy);
250
+ const w01 = (1 - fx) * fy;
251
+ const w11 = fx * fy;
252
+ const blend = (offset) => pre[i00 + offset] * w00 +
253
+ pre[i10 + offset] * w10 +
254
+ pre[i01 + offset] * w01 +
255
+ pre[i11 + offset] * w11;
256
+ const a = Math.round(blend(3));
257
+ out.data[di] = unpremultiply(Math.round(blend(0)), a);
258
+ out.data[di + 1] = unpremultiply(Math.round(blend(1)), a);
259
+ out.data[di + 2] = unpremultiply(Math.round(blend(2)), a);
260
+ out.data[di + 3] = a;
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+ /**
266
+ * The helper entry point, argv-compatible with the retired Swift helper:
267
+ * `[in.png, out.png]` or `[in.png, out.png, aligned.png, reference.png]`.
268
+ * Crops the input to its content bounding box, optionally writes a
269
+ * reference-sized aligned copy (best-effort), and reports a TRIMDIMS line.
270
+ * Same exit codes, same stdout/stderr shape as the Swift helper.
271
+ */
272
+ export function runTrimAlign(argv) {
273
+ if (argv.length < 2) {
274
+ return {
275
+ status: TRIM_EXIT_USAGE,
276
+ stdout: '',
277
+ stderr: 'usage: fidelity-trim <in.png> <out.png> [<aligned.png> <reference.png>]',
278
+ };
279
+ }
280
+ const [inputPath, outputPath] = argv;
281
+ let image;
282
+ try {
283
+ image = PNG.sync.read(readFileSync(inputPath));
284
+ }
285
+ catch {
286
+ return {
287
+ status: TRIM_EXIT_UNREADABLE,
288
+ stdout: '',
289
+ stderr: 'could not read image at ' + inputPath,
290
+ };
291
+ }
292
+ const box = contentBoundingBox(image.data, image.width, image.height);
293
+ if (box === null) {
294
+ return {
295
+ status: TRIM_EXIT_NO_CONTENT,
296
+ stdout: '',
297
+ stderr: 'no content to trim (image is uniform)',
298
+ };
299
+ }
300
+ const cropped = cropImage(image, box);
301
+ try {
302
+ writeFileSync(outputPath, PNG.sync.write(cropped));
303
+ }
304
+ catch {
305
+ return {
306
+ status: TRIM_EXIT_WRITE_FAILED,
307
+ stdout: '',
308
+ stderr: 'could not write trimmed PNG to ' + outputPath,
309
+ };
310
+ }
311
+ // Optional aligned output: the trimmed image fit onto a reference-sized
312
+ // canvas, preserving aspect ratio (transparent letterbox/pillarbox padding
313
+ // when aspect ratios differ). A reference-sized, undistorted overlay pair.
314
+ // Best-effort; dimensions are reported either way.
315
+ let referenceWidth = 0;
316
+ let referenceHeight = 0;
317
+ if (argv.length >= 4) {
318
+ const alignedPath = argv[2];
319
+ const referencePath = argv[3];
320
+ let reference = null;
321
+ try {
322
+ reference = PNG.sync.read(readFileSync(referencePath));
323
+ }
324
+ catch {
325
+ reference = null;
326
+ }
327
+ if (reference !== null) {
328
+ referenceWidth = reference.width;
329
+ referenceHeight = reference.height;
330
+ const aligned = fitPad(cropped, referenceWidth, referenceHeight);
331
+ if (aligned !== null) {
332
+ try {
333
+ writeFileSync(alignedPath, PNG.sync.write(aligned));
334
+ }
335
+ catch {
336
+ // best effort, as in the Swift helper
337
+ }
338
+ }
339
+ }
340
+ }
341
+ // Report: rendered (trimmed) px, reference px (0 0 when unavailable), then
342
+ // the pre-trim source px so the caller can log how much margin was removed.
343
+ const stdout = 'TRIMDIMS ' +
344
+ [box.width, box.height, referenceWidth, referenceHeight, image.width, image.height].join(' ') +
345
+ '\n';
346
+ return { status: 0, stdout, stderr: '' };
347
+ }
348
+ //# sourceMappingURL=trim-align.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Unit tests for the pure-JS trim/align helper (the CoreGraphics port).
3
+ *
4
+ * These exercise the pixel logic directly with pngjs-crafted images — no
5
+ * `swift`, no real toolchain — so the bounding-box/crop/pad semantics that
6
+ * previously needed CIBUILD_UI_FIDELITY_REAL_SWIFT=1 now run in every CI pass.
7
+ * The Swift-vs-JS equivalence itself is guarded by the corpus gate in
8
+ * trim-align-corpus.test.ts (real toolchain, gated).
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=trim-align.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trim-align.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/trim-align.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Unit tests for the pure-JS trim/align helper (the CoreGraphics port).
3
+ *
4
+ * These exercise the pixel logic directly with pngjs-crafted images — no
5
+ * `swift`, no real toolchain — so the bounding-box/crop/pad semantics that
6
+ * previously needed CIBUILD_UI_FIDELITY_REAL_SWIFT=1 now run in every CI pass.
7
+ * The Swift-vs-JS equivalence itself is guarded by the corpus gate in
8
+ * trim-align-corpus.test.ts (real toolchain, gated).
9
+ */
10
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { PNG } from 'pngjs';
14
+ import { describe, test, expect, afterAll } from '@jest/globals';
15
+ import { runTrimAlign } from './trim-align.js';
16
+ const RED = { r: 255, g: 0, b: 0, a: 255 };
17
+ const BLUE = { r: 0, g: 0, b: 255, a: 255 };
18
+ const WHITE = { r: 255, g: 255, b: 255, a: 255 };
19
+ const PINK = { r: 255, g: 240, b: 238, a: 255 };
20
+ /** Paint opaque rects onto a transparent canvas, top-left coordinates. */
21
+ function craftPng(w, h, rects) {
22
+ const png = new PNG({ width: w, height: h });
23
+ for (const rect of rects) {
24
+ for (let y = rect.y; y < rect.y + rect.h; y++) {
25
+ for (let x = rect.x; x < rect.x + rect.w; x++) {
26
+ const i = (y * w + x) * 4;
27
+ png.data[i] = rect.r;
28
+ png.data[i + 1] = rect.g;
29
+ png.data[i + 2] = rect.b;
30
+ png.data[i + 3] = rect.a;
31
+ }
32
+ }
33
+ }
34
+ return PNG.sync.write(png);
35
+ }
36
+ function pngSize(filePath) {
37
+ const buf = readFileSync(filePath);
38
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
39
+ }
40
+ function pixelAt(filePath, x, y) {
41
+ const png = PNG.sync.read(readFileSync(filePath));
42
+ const i = (y * png.width + x) * 4;
43
+ return [png.data[i], png.data[i + 1], png.data[i + 2], png.data[i + 3]];
44
+ }
45
+ const dirs = [];
46
+ function scene() {
47
+ const dir = mkdtempSync(join(tmpdir(), 'trim-align-js-'));
48
+ dirs.push(dir);
49
+ return dir;
50
+ }
51
+ afterAll(() => {
52
+ for (const dir of dirs) {
53
+ try {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ catch {
57
+ // best effort
58
+ }
59
+ }
60
+ });
61
+ // ---- trim ----------------------------------------------------------------------
62
+ describe('runTrimAlign — trim', () => {
63
+ test('trims a transparent frame down to its single content rectangle', () => {
64
+ const dir = scene();
65
+ const input = join(dir, 'in.png');
66
+ writeFileSync(input, craftPng(200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]));
67
+ const out = join(dir, 'out.png');
68
+ const result = runTrimAlign([input, out]);
69
+ expect(result.stderr).toBe('');
70
+ expect(result.status).toBe(0);
71
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
72
+ // The crop is a raw pixel copy of the content region.
73
+ expect(pixelAt(out, 0, 0)).toEqual([255, 0, 0, 255]);
74
+ expect(pixelAt(out, 59, 39)).toEqual([255, 0, 0, 255]);
75
+ // TRIMDIMS: rendered (trimmed) px, reference px (0 0 without one), source px.
76
+ expect(result.stdout.trim()).toBe('TRIMDIMS 60 40 0 0 200 200');
77
+ });
78
+ test('crops an opaque uniform-background wrapper to its content card', () => {
79
+ const dir = scene();
80
+ // 420x320 transparent canvas, 375x200 white wrapper, 343x168 pink card.
81
+ const input = join(dir, 'in.png');
82
+ writeFileSync(input, craftPng(420, 320, [
83
+ { x: 20, y: 40, w: 375, h: 200, ...WHITE },
84
+ { x: 36, y: 56, w: 343, h: 168, ...PINK },
85
+ ]));
86
+ const out = join(dir, 'out.png');
87
+ const result = runTrimAlign([input, out]);
88
+ expect(result.status).toBe(0);
89
+ // Lands on the content card, NOT the white wrapper (375x200) or the raw
90
+ // canvas (420x320).
91
+ expect(pngSize(out)).toEqual({ width: 343, height: 168 });
92
+ });
93
+ test('trims only transparency when the opaque corners disagree (fallback)', () => {
94
+ const dir = scene();
95
+ // Two differently-colored halves: the opaque bounding box is 80x80 but its
96
+ // corners are not one uniform color, so no background is detected and only
97
+ // the transparent margin is removed.
98
+ const input = join(dir, 'in.png');
99
+ writeFileSync(input, craftPng(120, 120, [
100
+ { x: 20, y: 20, w: 40, h: 80, ...RED },
101
+ { x: 60, y: 20, w: 40, h: 80, ...BLUE },
102
+ ]));
103
+ const out = join(dir, 'out.png');
104
+ const result = runTrimAlign([input, out]);
105
+ expect(result.status).toBe(0);
106
+ expect(pngSize(out)).toEqual({ width: 80, height: 80 });
107
+ });
108
+ test('keeps a fully-opaque uniform image whole (never crops to nothing)', () => {
109
+ const dir = scene();
110
+ const input = join(dir, 'in.png');
111
+ writeFileSync(input, craftPng(64, 48, [{ x: 0, y: 0, w: 64, h: 48, ...WHITE }]));
112
+ const out = join(dir, 'out.png');
113
+ const result = runTrimAlign([input, out]);
114
+ // All four corners agree (uniform background) and pass 2 finds no distinct
115
+ // content — the opaque box (the whole image) is kept, exit 0.
116
+ expect(result.status).toBe(0);
117
+ expect(pngSize(out)).toEqual({ width: 64, height: 48 });
118
+ expect(result.stdout.trim()).toBe('TRIMDIMS 64 48 0 0 64 48');
119
+ });
120
+ test('is a no-op crop when content touches every edge', () => {
121
+ const dir = scene();
122
+ // Content fills the canvas with differing corner colors: nothing to trim.
123
+ const input = join(dir, 'in.png');
124
+ writeFileSync(input, craftPng(50, 30, [
125
+ { x: 0, y: 0, w: 25, h: 30, ...RED },
126
+ { x: 25, y: 0, w: 25, h: 30, ...BLUE },
127
+ ]));
128
+ const out = join(dir, 'out.png');
129
+ const result = runTrimAlign([input, out]);
130
+ expect(result.status).toBe(0);
131
+ expect(pngSize(out)).toEqual({ width: 50, height: 30 });
132
+ // Raw copy: pixels survive untouched.
133
+ expect(pixelAt(out, 0, 0)).toEqual([255, 0, 0, 255]);
134
+ expect(pixelAt(out, 49, 29)).toEqual([0, 0, 255, 255]);
135
+ });
136
+ });
137
+ // ---- align ---------------------------------------------------------------------
138
+ describe('runTrimAlign — aligned output', () => {
139
+ test('writes a reference-sized aligned image and reports all three dimensions', () => {
140
+ const dir = scene();
141
+ const input = join(dir, 'in.png');
142
+ writeFileSync(input, craftPng(200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]));
143
+ const reference = join(dir, 'ref.png');
144
+ writeFileSync(reference, craftPng(100, 50, [{ x: 0, y: 0, w: 100, h: 50, ...BLUE }]));
145
+ const out = join(dir, 'out.png');
146
+ const aligned = join(dir, 'aligned.png');
147
+ const result = runTrimAlign([input, out, aligned, reference]);
148
+ expect(result.status).toBe(0);
149
+ // The trimmed render is the content; the aligned copy is fit onto a
150
+ // reference-sized canvas (preserving aspect) for a 1:1 overlay.
151
+ expect(pngSize(out)).toEqual({ width: 60, height: 40 });
152
+ expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
153
+ expect(result.stdout.trim()).toBe('TRIMDIMS 60 40 100 50 200 200');
154
+ });
155
+ test('fits a wide render into a square reference without distortion (transparent letterbox)', () => {
156
+ const dir = scene();
157
+ // Content fills its canvas: a 2:1 render (80x40), all opaque red.
158
+ const input = join(dir, 'in.png');
159
+ writeFileSync(input, craftPng(80, 40, [{ x: 0, y: 0, w: 80, h: 40, ...RED }]));
160
+ // Square 1:1 reference (50x50) — a deliberately different aspect ratio.
161
+ const reference = join(dir, 'ref.png');
162
+ writeFileSync(reference, craftPng(50, 50, [{ x: 0, y: 0, w: 50, h: 50, ...BLUE }]));
163
+ const out = join(dir, 'out.png');
164
+ const aligned = join(dir, 'aligned.png');
165
+ runTrimAlign([input, out, aligned, reference]);
166
+ // Aligned stays exactly reference-sized (overlay pair holds).
167
+ expect(pngSize(aligned)).toEqual({ width: 50, height: 50 });
168
+ // Aspect preserved: the 2:1 content fits by width (50x25) and is centered,
169
+ // so both letterbox bands are transparent while the center is opaque
170
+ // content. A stretch-to-fit would make every one of these opaque.
171
+ expect(pixelAt(aligned, 25, 3)[3]).toBe(0); // top band
172
+ expect(pixelAt(aligned, 25, 46)[3]).toBe(0); // bottom band
173
+ expect(pixelAt(aligned, 25, 25)[3]).toBe(255); // content (center)
174
+ });
175
+ test('upscales into a larger reference and downscales into a smaller one', () => {
176
+ const dir = scene();
177
+ const input = join(dir, 'in.png');
178
+ writeFileSync(input, craftPng(40, 40, [{ x: 0, y: 0, w: 40, h: 40, ...RED }]));
179
+ const out = join(dir, 'out.png');
180
+ // Reference smaller than the render: content is downscaled to fit.
181
+ const smallRef = join(dir, 'ref-small.png');
182
+ writeFileSync(smallRef, craftPng(20, 20, [{ x: 0, y: 0, w: 20, h: 20, ...BLUE }]));
183
+ const alignedSmall = join(dir, 'aligned-small.png');
184
+ let result = runTrimAlign([input, out, alignedSmall, smallRef]);
185
+ expect(result.status).toBe(0);
186
+ expect(pngSize(alignedSmall)).toEqual({ width: 20, height: 20 });
187
+ expect(pixelAt(alignedSmall, 10, 10)).toEqual([255, 0, 0, 255]);
188
+ // Reference larger than the render: content is upscaled to fit.
189
+ const bigRef = join(dir, 'ref-big.png');
190
+ writeFileSync(bigRef, craftPng(80, 80, [{ x: 0, y: 0, w: 80, h: 80, ...BLUE }]));
191
+ const alignedBig = join(dir, 'aligned-big.png');
192
+ result = runTrimAlign([input, out, alignedBig, bigRef]);
193
+ expect(result.status).toBe(0);
194
+ expect(pngSize(alignedBig)).toEqual({ width: 80, height: 80 });
195
+ expect(pixelAt(alignedBig, 40, 40)).toEqual([255, 0, 0, 255]);
196
+ });
197
+ test('an identity-size reference yields a pixel-identical aligned copy for opaque content', () => {
198
+ const dir = scene();
199
+ const input = join(dir, 'in.png');
200
+ writeFileSync(input, craftPng(30, 20, [
201
+ { x: 0, y: 0, w: 15, h: 20, ...RED },
202
+ { x: 15, y: 0, w: 15, h: 20, ...BLUE },
203
+ ]));
204
+ const reference = join(dir, 'ref.png');
205
+ writeFileSync(reference, craftPng(30, 20, [{ x: 0, y: 0, w: 30, h: 20, ...WHITE }]));
206
+ const out = join(dir, 'out.png');
207
+ const aligned = join(dir, 'aligned.png');
208
+ const result = runTrimAlign([input, out, aligned, reference]);
209
+ expect(result.status).toBe(0);
210
+ const outPng = PNG.sync.read(readFileSync(out));
211
+ const alignedPng = PNG.sync.read(readFileSync(aligned));
212
+ expect(alignedPng.width).toBe(outPng.width);
213
+ expect(alignedPng.height).toBe(outPng.height);
214
+ expect(Buffer.compare(alignedPng.data, outPng.data)).toBe(0);
215
+ });
216
+ test('a missing reference is best-effort: no aligned image, zero reference dims', () => {
217
+ const dir = scene();
218
+ const input = join(dir, 'in.png');
219
+ writeFileSync(input, craftPng(100, 100, [{ x: 10, y: 10, w: 50, h: 30, ...RED }]));
220
+ const out = join(dir, 'out.png');
221
+ const aligned = join(dir, 'aligned.png');
222
+ const result = runTrimAlign([input, out, aligned, join(dir, 'nope.png')]);
223
+ expect(result.status).toBe(0);
224
+ expect(existsSync(aligned)).toBe(false);
225
+ expect(result.stdout.trim()).toBe('TRIMDIMS 50 30 0 0 100 100');
226
+ });
227
+ });
228
+ // ---- failure contract ------------------------------------------------------------
229
+ describe('runTrimAlign — failure contract (Swift helper exit codes)', () => {
230
+ test('usage error without two paths', () => {
231
+ const result = runTrimAlign(['only-one.png']);
232
+ expect(result.status).toBe(64);
233
+ expect(result.stderr).toContain('usage:');
234
+ });
235
+ test('unreadable input exits 65', () => {
236
+ const dir = scene();
237
+ const result = runTrimAlign([join(dir, 'missing.png'), join(dir, 'out.png')]);
238
+ expect(result.status).toBe(65);
239
+ expect(result.stderr).toContain('could not read image at');
240
+ });
241
+ test('a fully-transparent image has no content and exits 66', () => {
242
+ const dir = scene();
243
+ const input = join(dir, 'in.png');
244
+ writeFileSync(input, craftPng(40, 40, []));
245
+ const result = runTrimAlign([input, join(dir, 'out.png')]);
246
+ expect(result.status).toBe(66);
247
+ expect(result.stderr).toContain('no content to trim');
248
+ });
249
+ test('an unwritable output path exits 68', () => {
250
+ const dir = scene();
251
+ const input = join(dir, 'in.png');
252
+ writeFileSync(input, craftPng(40, 40, [{ x: 0, y: 0, w: 10, h: 10, ...RED }]));
253
+ const result = runTrimAlign([input, join(dir, 'no-such-dir', 'out.png')]);
254
+ expect(result.status).toBe(68);
255
+ expect(result.stderr).toContain('could not write trimmed PNG');
256
+ });
257
+ });
258
+ //# sourceMappingURL=trim-align.test.js.map
@@ -44,7 +44,7 @@ export interface UiFidelityPreviewAndroidOptions {
44
44
  outDir?: string;
45
45
  /**
46
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.
47
+ * (and tests) can supply a specific java/gradle/ci/tar toolchain. Optional.
48
48
  */
49
49
  toolchainPath?: string;
50
50
  }
@@ -1 +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;AAuBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAGxB,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;AAQD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CAuKzB"}
1
+ {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAwBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAGxB,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;AAQD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CA8KzB"}
@@ -25,6 +25,7 @@ import { basename, join, resolve } from 'node:path';
25
25
  import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
26
26
  import { DEFAULT_GRADLE_TASK, PACKAGE_ARCHIVE_BASENAME, generateAndroidRenderScript, isValidGradleTask, } from './ui-fidelity-render-android.js';
27
27
  import { buildPreviewScreenResult } from './ui-fidelity-preview.js';
28
+ import { resolveCliJsPath } from './render-post-processor.js';
28
29
  /**
29
30
  * Build error returned when the local Android toolchain is absent, so the caller
30
31
  * can fall back to a remote run instead of a confusing build failure. Generic
@@ -221,6 +222,14 @@ export function renderUiFidelityPreviewAndroid(options) {
221
222
  if (!process.env.ANDROID_SDK_ROOT)
222
223
  env.ANDROID_SDK_ROOT = androidSdk;
223
224
  }
225
+ // Hand the script this installation's CLI entrypoint so its trim stage can
226
+ // spawn `fidelity-trim` without a ci on PATH (library callers like the
227
+ // Invarn CLI). Never override an explicitly-set entrypoint.
228
+ if (!env.CIBUILD_CLI_JS) {
229
+ const cliJs = resolveCliJsPath();
230
+ if (cliJs !== null)
231
+ env.CIBUILD_CLI_JS = cliJs;
232
+ }
224
233
  const run = spawnSync('node', ['-e', script], {
225
234
  cwd: workDir,
226
235
  env,