@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.
- package/dist/cli.cjs +192 -7
- package/dist/src/yaml/steps/structural-facts.d.ts +108 -0
- package/dist/src/yaml/steps/structural-facts.d.ts.map +1 -0
- package/dist/src/yaml/steps/structural-facts.js +0 -0
- package/dist/src/yaml/steps/structural-facts.test.d.ts +12 -0
- package/dist/src/yaml/steps/structural-facts.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/structural-facts.test.js +259 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +43 -0
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +72 -1
- package/package.json +1 -1
|
@@ -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"}
|
|
Binary file
|
|
@@ -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;
|
|
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');
|