@invarn/cibuild 2.1.1 → 2.1.2

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,101 @@
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 (bounds in px,
5
+ * parent, clip flag) emitted alongside the render and derives intent-agnostic
6
+ * geometric facts the fidelity judge should check instead of discovering them
7
+ * 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
+ /** Root-space bounds in px. */
30
+ bounds: LayoutBounds;
31
+ /** Parent node id, or null for a root node. */
32
+ parentId: string | null;
33
+ /** Whether this node clips its children to its bounds. */
34
+ clipsChildren: boolean;
35
+ }
36
+ export interface LayoutDump {
37
+ /** The captured canvas / root bounds in px. */
38
+ canvas: LayoutBounds;
39
+ nodes: LayoutNode[];
40
+ }
41
+ export type Edge = 'left' | 'top' | 'right' | 'bottom';
42
+ export type StructuralFact = {
43
+ kind: 'overflow';
44
+ nodeId: string;
45
+ parentId: string;
46
+ edges: Edge[];
47
+ overflowPx: Partial<Record<Edge, number>>;
48
+ } | {
49
+ kind: 'clipped_by_parent';
50
+ nodeId: string;
51
+ clipperId: string;
52
+ edges: Edge[];
53
+ clippedPx: Partial<Record<Edge, number>>;
54
+ } | {
55
+ kind: 'off_screen';
56
+ nodeId: string;
57
+ edges: Edge[];
58
+ offscreenPx: Partial<Record<Edge, number>>;
59
+ } | {
60
+ kind: 'overlap';
61
+ nodeIds: [string, string];
62
+ overlapPx: {
63
+ width: number;
64
+ height: number;
65
+ };
66
+ ratio: number;
67
+ };
68
+ export interface DeriveStructuralFactsOptions {
69
+ /** Edge excesses / overlaps at or below this many px are ignored, so that
70
+ * exact-touching layout edges don't read as overflow or overlap. */
71
+ tolerancePx?: number;
72
+ /** Minimum (intersection area / smaller node area) for an overlap to be
73
+ * reported as significant. */
74
+ overlapMinRatio?: number;
75
+ }
76
+ export declare function deriveStructuralFacts(dump: LayoutDump, options?: DeriveStructuralFactsOptions): StructuralFact[];
77
+ /**
78
+ * Render-side embedded form of the deriver.
79
+ *
80
+ * The Android render runs as a single self-contained generated script (see
81
+ * `generateAndroidRenderScript`), so render-side pure logic is embedded as a
82
+ * JS-source string and concatenated in — exactly like `RENDER_SCRIPT_TRIM_STAGE`.
83
+ * The typed `deriveStructuralFacts` above stays the canonical, tested spec; the
84
+ * `parity` test asserts this embedded port produces identical facts on shared
85
+ * fixtures so the two cannot drift.
86
+ *
87
+ * `deriveStructuralFacts` here is pure; `deriveAndWriteFacts` references runtime
88
+ * globals (`fs`, `path`, `artifactsDir`, `log`) defined in the main script and
89
+ * is only invoked from there — harmless to define in the eval sandbox.
90
+ */
91
+ export declare const RENDER_SCRIPT_FACTS_STAGE: string;
92
+ /**
93
+ * Evaluates the embedded facts stage and returns its pure `deriveStructuralFacts`,
94
+ * so the parity test exercises exactly the code that ships in the runtime script
95
+ * (mirrors `getPostProcessorInternals`). `deriveAndWriteFacts` references runtime
96
+ * globals only when called, so defining it in the sandbox is harmless.
97
+ */
98
+ export declare function getStructuralFactsStageInternals(): {
99
+ deriveStructuralFacts: (dump: LayoutDump, options?: DeriveStructuralFactsOptions) => StructuralFact[];
100
+ };
101
+ //# 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,+BAA+B;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,0DAA0D;IAC1D,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,CA2ElB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,EAAE,MAuHvC,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,174 @@
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 — off-screen', () => {
67
+ test('a node extending past the canvas is reported off-screen on those edges', () => {
68
+ const dump = {
69
+ canvas: CANVAS,
70
+ nodes: [
71
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
72
+ // bottom edge 30px below the canvas
73
+ { id: 'sheet', bounds: { left: 0, top: 360, right: 200, bottom: 430 }, parentId: 'root', clipsChildren: false },
74
+ ],
75
+ };
76
+ const facts = deriveStructuralFacts(dump);
77
+ const offscreen = facts.filter((f) => f.kind === 'off_screen');
78
+ expect(offscreen).toHaveLength(1);
79
+ expect(offscreen[0]).toMatchObject({
80
+ kind: 'off_screen',
81
+ nodeId: 'sheet',
82
+ edges: ['bottom'],
83
+ offscreenPx: { bottom: 30 },
84
+ });
85
+ });
86
+ });
87
+ describe('deriveStructuralFacts — overlap', () => {
88
+ test('two significantly overlapping siblings are reported as an overlap', () => {
89
+ const dump = {
90
+ canvas: CANVAS,
91
+ nodes: [
92
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
93
+ { id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
94
+ { id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
95
+ ],
96
+ };
97
+ const overlap = deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap');
98
+ expect(overlap).toHaveLength(1);
99
+ expect(overlap[0]).toMatchObject({ kind: 'overlap', nodeIds: ['a', 'b'] });
100
+ });
101
+ test('barely-touching siblings within tolerance are not an overlap', () => {
102
+ const dump = {
103
+ canvas: CANVAS,
104
+ nodes: [
105
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
106
+ { id: 'a', bounds: { left: 20, top: 20, right: 100, bottom: 100 }, parentId: 'root', clipsChildren: false },
107
+ { id: 'b', bounds: { left: 100, top: 20, right: 180, bottom: 100 }, parentId: 'root', clipsChildren: false },
108
+ ],
109
+ };
110
+ expect(deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap')).toEqual([]);
111
+ });
112
+ });
113
+ describe('embedded render-side stage parity', () => {
114
+ // The render script ships a JS-source port of the deriver. It must produce
115
+ // byte-identical facts to the canonical typed module, or render-side facts
116
+ // would silently diverge from what the tests guarantee.
117
+ const { deriveStructuralFacts: embedded } = getStructuralFactsStageInternals();
118
+ const fixtures = {
119
+ clip: {
120
+ canvas: CANVAS,
121
+ nodes: [
122
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
123
+ { id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
124
+ ],
125
+ },
126
+ overflow: {
127
+ canvas: CANVAS,
128
+ nodes: [
129
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
130
+ { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
131
+ ],
132
+ },
133
+ overlap: {
134
+ canvas: CANVAS,
135
+ nodes: [
136
+ { id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
137
+ { id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
138
+ { id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
139
+ ],
140
+ },
141
+ };
142
+ for (const [name, dump] of Object.entries(fixtures)) {
143
+ test(`embedded port matches the module for the ${name} fixture`, () => {
144
+ expect(embedded(dump)).toEqual(deriveStructuralFacts(dump));
145
+ });
146
+ }
147
+ });
148
+ describe('deriveStructuralFacts — motivating regression (voucher tag bottom-clip)', () => {
149
+ // The render-side layout dump for the HubPromoBanner-style case: a card
150
+ // clips children, and the voucher tag (with its euro coin) overflows the
151
+ // card's bottom. The deriver MUST surface the bottom clip so the verdict
152
+ // can compare its extent to the design and never miss it by eye again.
153
+ test('reports the voucher tag as clipped by the card at the bottom, with the clipped px', () => {
154
+ const dump = {
155
+ canvas: { left: 0, top: 0, right: 360, bottom: 720 },
156
+ nodes: [
157
+ { id: 'HubPromoBanner', bounds: { left: 16, top: 100, right: 344, bottom: 262 }, parentId: null, clipsChildren: true },
158
+ { id: 'promoText', bounds: { left: 32, top: 116, right: 240, bottom: 180 }, parentId: 'HubPromoBanner', clipsChildren: false },
159
+ // voucher tag overflows the card bottom (262) down to 280 → 18px clipped
160
+ { id: 'voucherTag', bounds: { left: 250, top: 210, right: 330, bottom: 280 }, parentId: 'HubPromoBanner', clipsChildren: false },
161
+ ],
162
+ };
163
+ const facts = deriveStructuralFacts(dump);
164
+ const clip = facts.find((f) => f.kind === 'clipped_by_parent' && f.nodeId === 'voucherTag');
165
+ expect(clip).toMatchObject({
166
+ kind: 'clipped_by_parent',
167
+ nodeId: 'voucherTag',
168
+ clipperId: 'HubPromoBanner',
169
+ edges: ['bottom'],
170
+ clippedPx: { bottom: 18 },
171
+ });
172
+ });
173
+ });
174
+ //# 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.2",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",