@invarn/cibuild 2.1.1 → 2.1.3

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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Structural-facts deriver — Signal A of the UI-fidelity structural pass.
3
+ *
4
+ * Pure and platform-agnostic: it takes a per-node layout dump (clipped +
5
+ * unclipped bounds in px, parent) emitted alongside the render and derives
6
+ * intent-agnostic geometric facts the fidelity judge should check instead of
7
+ * discovering them by eye:
8
+ *
9
+ * - `overflow` — a node extends past its parent's box (parent does
10
+ * NOT clip, so the excess is visibly sticking out).
11
+ * - `clipped_by_parent` — a node extends past a CLIPPING parent's box, so the
12
+ * excess is cut away (the motivating euro-coin case).
13
+ * - `off_screen` — a node extends past the root canvas.
14
+ * - `overlap` — two siblings overlap by a significant area.
15
+ *
16
+ * The deriver does NOT judge or score; it reports facts. The verdict stays the
17
+ * agent's. This is the brain of Signal A; the render-side emission + facts
18
+ * artifact wiring live in the render step (a separate slice).
19
+ */
20
+ export interface LayoutBounds {
21
+ left: number;
22
+ top: number;
23
+ right: number;
24
+ bottom: number;
25
+ }
26
+ export interface LayoutNode {
27
+ /** Stable id/label for the node within one dump. */
28
+ id: string;
29
+ /** Clipped root-space bounds in px (boundsInRoot — the visible region,
30
+ * clamped by any clipping ancestor). */
31
+ bounds: LayoutBounds;
32
+ /** Unclipped root-space bounds in px (positionInRoot + size — the true layout
33
+ * extent). Absent in legacy dumps emitted before the structural pass tracked
34
+ * it; when present, overflow/clip is derived from this so it survives a
35
+ * clamping parent (which would otherwise hide the excess in `bounds`). */
36
+ unclippedBounds?: LayoutBounds;
37
+ /** Parent node id, or null for a root node. */
38
+ parentId: string | null;
39
+ /** Legacy clip hint (parent clips its children). The current emitter writes a
40
+ * constant false; used only for the legacy (no-`unclippedBounds`) fallback. */
41
+ clipsChildren: boolean;
42
+ }
43
+ export interface LayoutDump {
44
+ /** The captured canvas / root bounds in px. */
45
+ canvas: LayoutBounds;
46
+ nodes: LayoutNode[];
47
+ }
48
+ export type Edge = 'left' | 'top' | 'right' | 'bottom';
49
+ export type StructuralFact = {
50
+ kind: 'overflow';
51
+ nodeId: string;
52
+ parentId: string;
53
+ edges: Edge[];
54
+ overflowPx: Partial<Record<Edge, number>>;
55
+ } | {
56
+ kind: 'clipped_by_parent';
57
+ nodeId: string;
58
+ clipperId: string;
59
+ edges: Edge[];
60
+ clippedPx: Partial<Record<Edge, number>>;
61
+ } | {
62
+ kind: 'off_screen';
63
+ nodeId: string;
64
+ edges: Edge[];
65
+ offscreenPx: Partial<Record<Edge, number>>;
66
+ } | {
67
+ kind: 'overlap';
68
+ nodeIds: [string, string];
69
+ overlapPx: {
70
+ width: number;
71
+ height: number;
72
+ };
73
+ ratio: number;
74
+ };
75
+ export interface DeriveStructuralFactsOptions {
76
+ /** Edge excesses / overlaps at or below this many px are ignored, so that
77
+ * exact-touching layout edges don't read as overflow or overlap. */
78
+ tolerancePx?: number;
79
+ /** Minimum (intersection area / smaller node area) for an overlap to be
80
+ * reported as significant. */
81
+ overlapMinRatio?: number;
82
+ }
83
+ export declare function deriveStructuralFacts(dump: LayoutDump, options?: DeriveStructuralFactsOptions): StructuralFact[];
84
+ /**
85
+ * Render-side embedded form of the deriver.
86
+ *
87
+ * The Android render runs as a single self-contained generated script (see
88
+ * `generateAndroidRenderScript`), so render-side pure logic is embedded as a
89
+ * JS-source string and concatenated in — exactly like `RENDER_SCRIPT_TRIM_STAGE`.
90
+ * The typed `deriveStructuralFacts` above stays the canonical, tested spec; the
91
+ * `parity` test asserts this embedded port produces identical facts on shared
92
+ * fixtures so the two cannot drift.
93
+ *
94
+ * `deriveStructuralFacts` here is pure; `deriveAndWriteFacts` references runtime
95
+ * globals (`fs`, `path`, `artifactsDir`, `log`) defined in the main script and
96
+ * is only invoked from there — harmless to define in the eval sandbox.
97
+ */
98
+ export declare const RENDER_SCRIPT_FACTS_STAGE: string;
99
+ /**
100
+ * Evaluates the embedded facts stage and returns its pure `deriveStructuralFacts`,
101
+ * so the parity test exercises exactly the code that ships in the runtime script
102
+ * (mirrors `getPostProcessorInternals`). `deriveAndWriteFacts` references runtime
103
+ * globals only when called, so defining it in the sandbox is harmless.
104
+ */
105
+ export declare function getStructuralFactsStageInternals(): {
106
+ deriveStructuralFacts: (dump: LayoutDump, options?: DeriveStructuralFactsOptions) => StructuralFact[];
107
+ };
108
+ //# sourceMappingURL=structural-facts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structural-facts.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/structural-facts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX;6CACyC;IACzC,MAAM,EAAE,YAAY,CAAC;IACrB;;;+EAG2E;IAC3E,eAAe,CAAC,EAAE,YAAY,CAAC;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;oFACgF;IAChF,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,+CAA+C;IAC/C,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEvD,MAAM,MAAM,cAAc,GACtB;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC3C,GACD;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC1C,GACD;IACE,IAAI,EAAE,YAAY,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,WAAW,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5C,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7C,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEN,MAAM,WAAW,4BAA4B;IAC3C;yEACqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;mCAC+B;IAC/B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAoCD,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,UAAU,EAChB,OAAO,GAAE,4BAAiC,GACzC,cAAc,EAAE,CAuHlB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,EAAE,MAgJvC,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,gCAAgC,IAAI;IAClD,qBAAqB,EAAE,CACrB,IAAI,EAAE,UAAU,EAChB,OAAO,CAAC,EAAE,4BAA4B,KACnC,cAAc,EAAE,CAAC;CACvB,CAWA"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pure unit tests for the structural-facts deriver (UI-fidelity structural
3
+ * pass, Signal A). Fixture-driven, no toolchain — the deriver turns a
4
+ * per-node layout dump into intent-agnostic geometric facts the fidelity
5
+ * judge checks instead of eyeballing.
6
+ *
7
+ * The canonical regression fixture is the motivating miss: a card clips an
8
+ * overflowing voucher tag; the deriver must surface the bottom clip as a
9
+ * fact so it is never again skipped by eye.
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=structural-facts.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structural-facts.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/structural-facts.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Pure unit tests for the structural-facts deriver (UI-fidelity structural
3
+ * pass, Signal A). Fixture-driven, no toolchain — the deriver turns a
4
+ * per-node layout dump into intent-agnostic geometric facts the fidelity
5
+ * judge checks instead of eyeballing.
6
+ *
7
+ * The canonical regression fixture is the motivating miss: a card clips an
8
+ * overflowing voucher tag; the deriver must surface the bottom clip as a
9
+ * fact so it is never again skipped by eye.
10
+ */
11
+ import { deriveStructuralFacts, getStructuralFactsStageInternals, } from './structural-facts.js';
12
+ // 200x400 canvas in px for all fixtures unless noted.
13
+ const CANVAS = { left: 0, top: 0, right: 200, bottom: 400 };
14
+ describe('deriveStructuralFacts — containment', () => {
15
+ test('a fully-contained child produces no clip/overflow fact', () => {
16
+ const dump = {
17
+ canvas: CANVAS,
18
+ nodes: [
19
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
20
+ { id: 'label', bounds: { left: 30, top: 30, right: 170, bottom: 60 }, parentId: 'card', clipsChildren: false },
21
+ ],
22
+ };
23
+ expect(deriveStructuralFacts(dump)).toEqual([]);
24
+ });
25
+ });
26
+ describe('deriveStructuralFacts — overflow vs clip', () => {
27
+ test('child overflowing a NON-clipping parent is reported as overflow, not clipped', () => {
28
+ const dump = {
29
+ canvas: CANVAS,
30
+ nodes: [
31
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
32
+ // sticks out 15px past the row's right edge
33
+ { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
34
+ ],
35
+ };
36
+ const facts = deriveStructuralFacts(dump);
37
+ expect(facts).toHaveLength(1);
38
+ expect(facts[0]).toMatchObject({
39
+ kind: 'overflow',
40
+ nodeId: 'chip',
41
+ parentId: 'row',
42
+ edges: ['right'],
43
+ overflowPx: { right: 15 },
44
+ });
45
+ });
46
+ test('child overflowing a CLIPPING parent is reported as clipped-by-parent with the clipped extent', () => {
47
+ const dump = {
48
+ canvas: CANVAS,
49
+ nodes: [
50
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
51
+ // overflows the card's bottom by 24px → that extent is clipped away
52
+ { id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
53
+ ],
54
+ };
55
+ const facts = deriveStructuralFacts(dump);
56
+ expect(facts).toHaveLength(1);
57
+ expect(facts[0]).toMatchObject({
58
+ kind: 'clipped_by_parent',
59
+ nodeId: 'tag',
60
+ clipperId: 'card',
61
+ edges: ['bottom'],
62
+ clippedPx: { bottom: 24 },
63
+ });
64
+ });
65
+ });
66
+ describe('deriveStructuralFacts — unclippedBounds path (real clamping renders)', () => {
67
+ // A real clipping parent CLAMPS the child's boundsInRoot to its own edge, so
68
+ // the clipped `bounds` show no excess — the overflow lives only in
69
+ // `unclippedBounds`. This is the case bounds-only v1 went blind on.
70
+ test('a clamped child is reported clipped-by-parent, recovered from unclippedBounds', () => {
71
+ const dump = {
72
+ canvas: { left: 0, top: 0, right: 360, bottom: 720 },
73
+ nodes: [
74
+ {
75
+ id: 'HubPromoBanner',
76
+ bounds: { left: 16, top: 100, right: 344, bottom: 262 },
77
+ unclippedBounds: { left: 16, top: 100, right: 344, bottom: 262 },
78
+ parentId: null,
79
+ clipsChildren: false,
80
+ },
81
+ {
82
+ // visible bounds CLAMPED to the card bottom (262); the true extent runs
83
+ // 18px past it → 18px cut away. The current emitter writes clipsChildren
84
+ // false, so only unclippedBounds can reveal this.
85
+ id: 'voucherTag',
86
+ bounds: { left: 250, top: 210, right: 330, bottom: 262 },
87
+ unclippedBounds: { left: 250, top: 210, right: 330, bottom: 280 },
88
+ parentId: 'HubPromoBanner',
89
+ clipsChildren: false,
90
+ },
91
+ ],
92
+ };
93
+ const facts = deriveStructuralFacts(dump);
94
+ expect(facts).toHaveLength(1);
95
+ expect(facts[0]).toMatchObject({
96
+ kind: 'clipped_by_parent',
97
+ nodeId: 'voucherTag',
98
+ clipperId: 'HubPromoBanner',
99
+ edges: ['bottom'],
100
+ clippedPx: { bottom: 18 },
101
+ });
102
+ });
103
+ test('a child sticking out of a non-clipping parent (bounds == unclippedBounds) is overflow', () => {
104
+ const dump = {
105
+ canvas: CANVAS,
106
+ nodes: [
107
+ {
108
+ id: 'row',
109
+ bounds: { left: 20, top: 20, right: 180, bottom: 100 },
110
+ unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 },
111
+ parentId: null,
112
+ clipsChildren: false,
113
+ },
114
+ {
115
+ // not clamped (bounds == unclipped) → the 15px excess is visible.
116
+ id: 'chip',
117
+ bounds: { left: 150, top: 30, right: 195, bottom: 80 },
118
+ unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 },
119
+ parentId: 'row',
120
+ clipsChildren: false,
121
+ },
122
+ ],
123
+ };
124
+ const facts = deriveStructuralFacts(dump);
125
+ expect(facts).toHaveLength(1);
126
+ expect(facts[0]).toMatchObject({
127
+ kind: 'overflow',
128
+ nodeId: 'chip',
129
+ parentId: 'row',
130
+ edges: ['right'],
131
+ overflowPx: { right: 15 },
132
+ });
133
+ });
134
+ });
135
+ describe('deriveStructuralFacts — off-screen', () => {
136
+ test('a node extending past the canvas is reported off-screen on those edges', () => {
137
+ const dump = {
138
+ canvas: CANVAS,
139
+ nodes: [
140
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
141
+ // bottom edge 30px below the canvas
142
+ { id: 'sheet', bounds: { left: 0, top: 360, right: 200, bottom: 430 }, parentId: 'root', clipsChildren: false },
143
+ ],
144
+ };
145
+ const facts = deriveStructuralFacts(dump);
146
+ const offscreen = facts.filter((f) => f.kind === 'off_screen');
147
+ expect(offscreen).toHaveLength(1);
148
+ expect(offscreen[0]).toMatchObject({
149
+ kind: 'off_screen',
150
+ nodeId: 'sheet',
151
+ edges: ['bottom'],
152
+ offscreenPx: { bottom: 30 },
153
+ });
154
+ });
155
+ });
156
+ describe('deriveStructuralFacts — overlap', () => {
157
+ test('two significantly overlapping siblings are reported as an overlap', () => {
158
+ const dump = {
159
+ canvas: CANVAS,
160
+ nodes: [
161
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
162
+ { id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
163
+ { id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
164
+ ],
165
+ };
166
+ const overlap = deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap');
167
+ expect(overlap).toHaveLength(1);
168
+ expect(overlap[0]).toMatchObject({ kind: 'overlap', nodeIds: ['a', 'b'] });
169
+ });
170
+ test('barely-touching siblings within tolerance are not an overlap', () => {
171
+ const dump = {
172
+ canvas: CANVAS,
173
+ nodes: [
174
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
175
+ { id: 'a', bounds: { left: 20, top: 20, right: 100, bottom: 100 }, parentId: 'root', clipsChildren: false },
176
+ { id: 'b', bounds: { left: 100, top: 20, right: 180, bottom: 100 }, parentId: 'root', clipsChildren: false },
177
+ ],
178
+ };
179
+ expect(deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap')).toEqual([]);
180
+ });
181
+ });
182
+ describe('embedded render-side stage parity', () => {
183
+ // The render script ships a JS-source port of the deriver. It must produce
184
+ // byte-identical facts to the canonical typed module, or render-side facts
185
+ // would silently diverge from what the tests guarantee.
186
+ const { deriveStructuralFacts: embedded } = getStructuralFactsStageInternals();
187
+ const fixtures = {
188
+ clip: {
189
+ canvas: CANVAS,
190
+ nodes: [
191
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
192
+ { id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
193
+ ],
194
+ },
195
+ overflow: {
196
+ canvas: CANVAS,
197
+ nodes: [
198
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
199
+ { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
200
+ ],
201
+ },
202
+ overlap: {
203
+ canvas: CANVAS,
204
+ nodes: [
205
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
206
+ { id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
207
+ { id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
208
+ ],
209
+ },
210
+ // New unclippedBounds path: a clamped child (clip) and a visible-excess child
211
+ // (overflow) — exercises the embedded port's new branch, not just the legacy one.
212
+ clipReal: {
213
+ canvas: CANVAS,
214
+ nodes: [
215
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: false },
216
+ { id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 200 }, unclippedBounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
217
+ ],
218
+ },
219
+ overflowReal: {
220
+ canvas: CANVAS,
221
+ nodes: [
222
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
223
+ { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
224
+ ],
225
+ },
226
+ };
227
+ for (const [name, dump] of Object.entries(fixtures)) {
228
+ test(`embedded port matches the module for the ${name} fixture`, () => {
229
+ expect(embedded(dump)).toEqual(deriveStructuralFacts(dump));
230
+ });
231
+ }
232
+ });
233
+ describe('deriveStructuralFacts — motivating regression (voucher tag bottom-clip)', () => {
234
+ // The render-side layout dump for the HubPromoBanner-style case: a card
235
+ // clips children, and the voucher tag (with its euro coin) overflows the
236
+ // card's bottom. The deriver MUST surface the bottom clip so the verdict
237
+ // can compare its extent to the design and never miss it by eye again.
238
+ test('reports the voucher tag as clipped by the card at the bottom, with the clipped px', () => {
239
+ const dump = {
240
+ canvas: { left: 0, top: 0, right: 360, bottom: 720 },
241
+ nodes: [
242
+ { id: 'HubPromoBanner', bounds: { left: 16, top: 100, right: 344, bottom: 262 }, parentId: null, clipsChildren: true },
243
+ { id: 'promoText', bounds: { left: 32, top: 116, right: 240, bottom: 180 }, parentId: 'HubPromoBanner', clipsChildren: false },
244
+ // voucher tag overflows the card bottom (262) down to 280 → 18px clipped
245
+ { id: 'voucherTag', bounds: { left: 250, top: 210, right: 330, bottom: 280 }, parentId: 'HubPromoBanner', clipsChildren: false },
246
+ ],
247
+ };
248
+ const facts = deriveStructuralFacts(dump);
249
+ const clip = facts.find((f) => f.kind === 'clipped_by_parent' && f.nodeId === 'voucherTag');
250
+ expect(clip).toMatchObject({
251
+ kind: 'clipped_by_parent',
252
+ nodeId: 'voucherTag',
253
+ clipperId: 'HubPromoBanner',
254
+ edges: ['bottom'],
255
+ clippedPx: { bottom: 18 },
256
+ });
257
+ });
258
+ });
259
+ //# sourceMappingURL=structural-facts.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-render-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAIpE,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,8EAA8E;AAC9E,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAmsBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAUrF;AAED;;;GAGG;AACH,qBAAa,mCAAoC,SAAQ,gBAAgB;IACvE,yBAAyB,CACvB,OAAO,EAAE,6BAA6B,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAepB,OAAO,CACX,MAAM,EAAE,6BAA6B,EACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CAgBpB"}
1
+ {"version":3,"file":"ui-fidelity-render-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAKpE,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,8EAA8E;AAC9E,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AA4uBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAWrF;AAED;;;GAGG;AACH,qBAAa,mCAAoC,SAAQ,gBAAgB;IACvE,yBAAyB,CACvB,OAAO,EAAE,6BAA6B,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAepB,OAAO,CACX,MAAM,EAAE,6BAA6B,EACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CAgBpB"}
@@ -50,6 +50,7 @@
50
50
  import { BaseStepExecutor } from './base.js';
51
51
  import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
52
52
  import { RENDER_SCRIPT_TRIM_STAGE } from './render-post-processor.js';
53
+ import { RENDER_SCRIPT_FACTS_STAGE } from './structural-facts.js';
53
54
  /**
54
55
  * Default Roborazzi record task. Run from the project root so Gradle resolves
55
56
  * it in whichever subproject declares it.
@@ -461,6 +462,21 @@ function roborazziPngMatches(basename, screen) {
461
462
  return method === screen || method === 'render' + screen;
462
463
  }
463
464
 
465
+ // The structural-pass layout dump (Signal A) is emitted next to the PNG in a
466
+ // roborazzi output dir, but Roborazzi controls the PNG's golden filename and
467
+ // ignores explicit capture names, so the dump can't reliably share the PNG's
468
+ // stem. Match it the same collision-safe way as the PNG instead — by the final
469
+ // dot-delimited segment of "<...>.layout.json" — so the emitter can write a
470
+ // simple "<Screen>.layout.json" regardless of the PNG's golden name.
471
+ function roborazziLayoutMatches(basename, screen) {
472
+ var suffix = '.layout.json';
473
+ if (basename.slice(-suffix.length) !== suffix) return false;
474
+ var stem = basename.slice(0, -suffix.length);
475
+ var lastDot = stem.lastIndexOf('.');
476
+ var seg = lastDot === -1 ? stem : stem.slice(lastDot + 1);
477
+ return seg === screen || seg === 'render' + screen;
478
+ }
479
+
464
480
  // Roborazzi writes PNGs to <module>/build/outputs/roborazzi/. Collect every
465
481
  // such directory in the project so a screen's PNG can be located regardless of
466
482
  // which module rendered it.
@@ -561,6 +577,28 @@ function renderScreens(renderable, projectRoot) {
561
577
  entry.error = null;
562
578
  log('rendered ' + entry.screen + ' -> ' + relative);
563
579
  trimRenderedImage(entry, outputPath, workDir);
580
+ // Signal A: the render emits a per-node layout dump into a roborazzi
581
+ // output dir (named "<Screen>.layout.json" or "...render<Screen>.layout.json").
582
+ // Locate it the same way as the PNG and derive structural facts; absent or
583
+ // malformed is a clean no-op.
584
+ var layoutSource = null;
585
+ for (var li = 0; li < roborazziDirs.length && layoutSource === null; li++) {
586
+ var lnames;
587
+ try {
588
+ lnames = fs.readdirSync(roborazziDirs[li]);
589
+ } catch (lReadError) {
590
+ lnames = [];
591
+ }
592
+ for (var lj = 0; lj < lnames.length; lj++) {
593
+ if (roborazziLayoutMatches(lnames[lj], entry.screen)) {
594
+ layoutSource = path.join(roborazziDirs[li], lnames[lj]);
595
+ break;
596
+ }
597
+ }
598
+ }
599
+ if (layoutSource !== null) {
600
+ deriveAndWriteFacts(entry, layoutSource);
601
+ }
564
602
  });
565
603
  } finally {
566
604
  if (process.env.UI_FIDELITY_KEEP_HARNESS) {
@@ -628,6 +666,8 @@ function main() {
628
666
  rendered_image_path: null,
629
667
  aligned_image_path: null,
630
668
  dimensions: null,
669
+ layout_path: null,
670
+ facts_path: null,
631
671
  };
632
672
  });
633
673
  log('screens: ' + (screens.join(', ') || '(none)'));
@@ -635,6 +675,8 @@ function main() {
635
675
  fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'rendered'), { recursive: true });
636
676
  fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'references'), { recursive: true });
637
677
  fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'aligned'), { recursive: true });
678
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'layout'), { recursive: true });
679
+ fs.mkdirSync(path.join(artifactsDir, 'ui-fidelity', 'facts'), { recursive: true });
638
680
 
639
681
  // 2. Per-screen validation + reference copies. Each screen gets its OWN copy
640
682
  // named by screen, even when two screens share a reference basename.
@@ -783,6 +825,7 @@ export function generateAndroidRenderScript(config) {
783
825
  'var RENDERER = ' + JSON.stringify(ANDROID_RENDERER) + ';',
784
826
  'var PACKAGE_ARCHIVE_NAME = ' + JSON.stringify(PACKAGE_ARCHIVE_BASENAME) + ';',
785
827
  RENDER_SCRIPT_TRIM_STAGE,
828
+ RENDER_SCRIPT_FACTS_STAGE,
786
829
  ANDROID_RENDER_SCRIPT_MAIN,
787
830
  ].join('\n');
788
831
  }
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
3
3
  import { gzipSync } from 'node:zlib';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
@@ -39,6 +39,16 @@ const FAKE_GRADLEW_LINES = [
39
39
  ' [ -z "$s" ] && continue',
40
40
  " printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out/$s.png\"",
41
41
  ' printf \'roborazzi:%s\' "$s" >> "$out/$s.png"',
42
+ // When FAKE_ROBORAZZI_LAYOUT is set, also emit the layout-dump sidecar next
43
+ // to the PNG (matching basename, .layout.json) — a clipping card with an
44
+ // overflowing tag, so the deriver yields one clipped-by-parent fact.
45
+ ' if [ -n "$FAKE_ROBORAZZI_LAYOUT" ]; then',
46
+ // Layout sidecar basename: flat "<screen>.layout.json" by default, or an
47
+ // explicit golden-style dotted name via FAKE_ROBORAZZI_LAYOUT_NAME to prove
48
+ // the consumer finds it by dot-segment match, decoupled from the PNG stem.
49
+ ' lname="${FAKE_ROBORAZZI_LAYOUT_NAME:-$s.layout.json}"',
50
+ ` printf '%s' '{"canvas":{"left":0,"top":0,"right":200,"bottom":400},"nodes":[{"id":"card","bounds":{"left":20,"top":20,"right":180,"bottom":200},"parentId":null,"clipsChildren":true},{"id":"tag","bounds":{"left":60,"top":150,"right":140,"bottom":224},"parentId":"card","clipsChildren":false}]}' > "$out/$lname"`,
51
+ ' fi',
42
52
  'done',
43
53
  'exit 0',
44
54
  ];
@@ -275,6 +285,67 @@ describe('android render runtime — render + trim (happy path)', () => {
275
285
  // The rendered file holds the trim helper's output, not the raw render.
276
286
  expect(readFileSync(artifact(project, 'ui-fidelity/rendered/XProof.png'), 'utf-8')).toContain('trimmed:XProof.png');
277
287
  });
288
+ test('derives structural facts from the layout dump and records layout_path + facts_path', async () => {
289
+ const project = makeProject({ screens: { XProof: 'x.png' } });
290
+ writeReference(project, 'x.png');
291
+ stageArchive(project, gradleProjectEntries());
292
+ const run = runScript(project, await buildScript(), {
293
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
294
+ FAKE_ROBORAZZI_LAYOUT: '1',
295
+ });
296
+ expect(run.status).toBe(0);
297
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
298
+ expect(screen?.status).toBe('rendered');
299
+ expect(screen?.layout_path).toBe('ui-fidelity/layout/XProof.json');
300
+ expect(screen?.facts_path).toBe('ui-fidelity/facts/XProof.json');
301
+ // The layout dump is copied verbatim as a build artifact.
302
+ expect(existsSync(artifact(project, 'ui-fidelity/layout/XProof.json'))).toBe(true);
303
+ // The facts artifact surfaces the bottom clip the eye would miss.
304
+ const facts = JSON.parse(readFileSync(artifact(project, 'ui-fidelity/facts/XProof.json'), 'utf-8'));
305
+ expect(facts).toEqual({
306
+ screen: 'XProof',
307
+ facts: [
308
+ {
309
+ kind: 'clipped_by_parent',
310
+ nodeId: 'tag',
311
+ clipperId: 'card',
312
+ edges: ['bottom'],
313
+ clippedPx: { bottom: 24 },
314
+ },
315
+ ],
316
+ });
317
+ });
318
+ test('finds the layout dump by dot-segment match even when its name differs from the PNG stem', async () => {
319
+ const project = makeProject({ screens: { XProof: 'x.png' } });
320
+ writeReference(project, 'x.png');
321
+ stageArchive(project, gradleProjectEntries());
322
+ // Golden-style dotted layout name (as real Roborazzi would name a sidecar),
323
+ // distinct from the flat PNG the fake writes — the consumer must still find it.
324
+ const run = runScript(project, await buildScript(), {
325
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
326
+ FAKE_ROBORAZZI_LAYOUT: '1',
327
+ FAKE_ROBORAZZI_LAYOUT_NAME: 'com.example.XProofTest.renderXProof.layout.json',
328
+ });
329
+ expect(run.status).toBe(0);
330
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
331
+ expect(screen?.facts_path).toBe('ui-fidelity/facts/XProof.json');
332
+ const facts = JSON.parse(readFileSync(artifact(project, 'ui-fidelity/facts/XProof.json'), 'utf-8'));
333
+ expect(facts.facts).toHaveLength(1);
334
+ expect(facts.facts[0].kind).toBe('clipped_by_parent');
335
+ });
336
+ test('degrades cleanly when no layout dump is emitted (no facts, render still succeeds)', async () => {
337
+ const project = makeProject({ screens: { XProof: 'x.png' } });
338
+ writeReference(project, 'x.png');
339
+ stageArchive(project, gradleProjectEntries());
340
+ // No FAKE_ROBORAZZI_LAYOUT → the render emits a PNG but no sidecar.
341
+ const run = runScript(project, await buildScript(), { FAKE_ROBORAZZI_SCREENS: 'XProof' });
342
+ expect(run.status).toBe(0);
343
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
344
+ expect(screen?.status).toBe('rendered');
345
+ expect(screen?.layout_path ?? null).toBeNull();
346
+ expect(screen?.facts_path ?? null).toBeNull();
347
+ expect(existsSync(artifact(project, 'ui-fidelity/facts/XProof.json'))).toBe(false);
348
+ });
278
349
  test('renders multiple screens independently in one Gradle run', async () => {
279
350
  const project = makeProject({ screens: { XProof: 'x.png', YProof: 'y.png' } });
280
351
  writeReference(project, 'x.png');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",