@viewscript/renderer 0.1.0-202605140639
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/ast/types.d.ts +403 -0
- package/dist/ast/types.js +33 -0
- package/dist/compiler/chunk-splitter.d.ts +98 -0
- package/dist/compiler/chunk-splitter.js +361 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +17 -0
- package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
- package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
- package/dist/rasterizer/canvas-mapper.d.ts +280 -0
- package/dist/rasterizer/canvas-mapper.js +414 -0
- package/dist/rasterizer/error-distribution.d.ts +143 -0
- package/dist/rasterizer/error-distribution.js +231 -0
- package/dist/rasterizer/gradient-mapper.d.ts +223 -0
- package/dist/rasterizer/gradient-mapper.js +352 -0
- package/dist/rasterizer/topology-rounding.d.ts +151 -0
- package/dist/rasterizer/topology-rounding.js +347 -0
- package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
- package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
- package/dist/runtime/event-backpressure.d.ts +393 -0
- package/dist/runtime/event-backpressure.js +458 -0
- package/dist/runtime/render-loop.d.ts +277 -0
- package/dist/runtime/render-loop.js +435 -0
- package/dist/runtime/wasm-resource-manager.d.ts +122 -0
- package/dist/runtime/wasm-resource-manager.js +253 -0
- package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
- package/dist/runtime/wgpu-renderer-adapter.js +230 -0
- package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
- package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
- package/dist/semantic/semantic-translator.d.ts +229 -0
- package/dist/semantic/semantic-translator.js +398 -0
- package/package.json +28 -0
- package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
- package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
- package/playwright-report/index.html +90 -0
- package/playwright.config.ts +160 -0
- package/screenshot-chrome.png +0 -0
- package/screenshots/visual-demo-verification.png +0 -0
- package/screenshots/visual-demo.png +0 -0
- package/src/ast/types.ts +473 -0
- package/src/compiler/chunk-splitter.ts +534 -0
- package/src/index.ts +62 -0
- package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
- package/src/rasterizer/canvas-mapper.ts +677 -0
- package/src/rasterizer/error-distribution.ts +344 -0
- package/src/rasterizer/gradient-mapper.ts +563 -0
- package/src/rasterizer/topology-rounding.ts +499 -0
- package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
- package/src/runtime/event-backpressure.ts +622 -0
- package/src/runtime/render-loop.ts +660 -0
- package/src/runtime/wasm-resource-manager.ts +349 -0
- package/src/runtime/wgpu-renderer-adapter.ts +318 -0
- package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
- package/src/semantic/semantic-translator.ts +637 -0
- package/test-results/.last-run.json +4 -0
- package/tests/e2e/async-race.spec.ts +612 -0
- package/tests/e2e/bilayer-sync.spec.ts +405 -0
- package/tests/e2e/failures/.gitkeep +0 -0
- package/tests/e2e/fullstack.spec.ts +681 -0
- package/tests/e2e/g1-continuity.spec.ts +703 -0
- package/tests/e2e/golden/.gitkeep +0 -0
- package/tests/e2e/golden/conic-color-wheel.raw +0 -0
- package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
- package/tests/e2e/golden/conic-rotated.raw +0 -0
- package/tests/e2e/golden/conic-rotated.sha256 +1 -0
- package/tests/e2e/golden/linear-45deg.raw +0 -0
- package/tests/e2e/golden/linear-45deg.sha256 +1 -0
- package/tests/e2e/golden/linear-horizontal.raw +0 -0
- package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
- package/tests/e2e/golden/linear-multi-stop.raw +0 -0
- package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
- package/tests/e2e/golden/radial-circle-center.raw +0 -0
- package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
- package/tests/e2e/golden/radial-offset.raw +0 -0
- package/tests/e2e/golden/radial-offset.sha256 +1 -0
- package/tests/e2e/golden/tile-mirror.raw +0 -0
- package/tests/e2e/golden/tile-mirror.sha256 +1 -0
- package/tests/e2e/golden/tile-repeat.raw +0 -0
- package/tests/e2e/golden/tile-repeat.sha256 +1 -0
- package/tests/e2e/gradient-animation.spec.ts +606 -0
- package/tests/e2e/memory-stability.spec.ts +396 -0
- package/tests/e2e/path-topology.spec.ts +674 -0
- package/tests/e2e/performance-profile.spec.ts +501 -0
- package/tests/e2e/screenshot.spec.ts +60 -0
- package/tests/e2e/test-harness.html +1005 -0
- package/tests/e2e/text-layout.spec.ts +451 -0
- package/tests/e2e/visual-demo.html +340 -0
- package/tests/e2e/visual-regression.spec.ts +335 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-Stack E2E Tests: CLI -> Solver -> HMR -> Renderer Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Phase 16: Comprehensive integration tests verifying the complete
|
|
5
|
+
* ViewScript pipeline from CODL command execution to visual rendering.
|
|
6
|
+
*
|
|
7
|
+
* ## Test Architecture
|
|
8
|
+
*
|
|
9
|
+
* These tests verify end-to-end correctness across all system layers:
|
|
10
|
+
*
|
|
11
|
+
* 1. CLI Layer: CODL command parsing and constraint generation
|
|
12
|
+
* 2. Solver Layer: Constraint graph evaluation in P-dimension
|
|
13
|
+
* 3. HMR Layer: Hot module replacement with T-vector preservation
|
|
14
|
+
* 4. Renderer Layer: Bilayer atomic updates (Canvas + DOM)
|
|
15
|
+
*
|
|
16
|
+
* ## Zero-Flakiness Strategy
|
|
17
|
+
*
|
|
18
|
+
* - Q-dimension isolation: Mock font metrics, fixed viewport, manual frame stepping
|
|
19
|
+
* - Deterministic synchronization: Double rAF wait for stable frame
|
|
20
|
+
* - No time-based assertions: Query actual state, not assumed timing
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Test Configuration
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
interface EntityBounds {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Constraint {
|
|
37
|
+
id: number;
|
|
38
|
+
target: number;
|
|
39
|
+
component: 'x' | 'y' | 'width' | 'height';
|
|
40
|
+
term: ConstraintTerm;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type ConstraintTerm =
|
|
44
|
+
| { type: 'const'; value: number }
|
|
45
|
+
| { type: 'linear'; coefficient: number; offset: number; tState: string }
|
|
46
|
+
| { type: 'ref'; entityId: number; component: string };
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Scenario A: CODL Command -> Constraint Propagation -> Visual Update
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
test.describe('Scenario A: CODL Execution to Visual Update', () => {
|
|
53
|
+
/**
|
|
54
|
+
* Test: CODL stack_horizontal command produces correct visual layout
|
|
55
|
+
*
|
|
56
|
+
* Initial State:
|
|
57
|
+
* - box_a at x=100 (fixed)
|
|
58
|
+
* - box_b unconstrained
|
|
59
|
+
*
|
|
60
|
+
* Trigger:
|
|
61
|
+
* - Execute CODL: stack_horizontal(instances=[box_a, box_b], gap=20)
|
|
62
|
+
*
|
|
63
|
+
* Expected:
|
|
64
|
+
* - box_b.x = box_a.x + box_a.width + gap
|
|
65
|
+
* - Visual positions match constraint evaluation
|
|
66
|
+
*/
|
|
67
|
+
test('CODL stack_horizontal produces correct visual positions', async ({ page }) => {
|
|
68
|
+
await setupTestPage(page);
|
|
69
|
+
|
|
70
|
+
// Initial state: two boxes
|
|
71
|
+
const boxA: EntityBounds = { x: 100, y: 100, width: 80, height: 50 };
|
|
72
|
+
const boxB: EntityBounds = { x: 0, y: 100, width: 80, height: 50 };
|
|
73
|
+
|
|
74
|
+
await renderEntities(page, [
|
|
75
|
+
{ id: 1, bounds: boxA, fill: '#0064C8' },
|
|
76
|
+
{ id: 2, bounds: boxB, fill: '#C86400' },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// Verify initial state
|
|
80
|
+
let boundsA = await getEntityBounds(page, 1);
|
|
81
|
+
let boundsB = await getEntityBounds(page, 2);
|
|
82
|
+
expect(boundsA.x).toBe(100);
|
|
83
|
+
expect(boundsB.x).toBe(0);
|
|
84
|
+
|
|
85
|
+
// Simulate CODL execution: apply stack_horizontal constraint
|
|
86
|
+
// This mimics: vs run stack_horizontal --instances [1, 2] --gap 20
|
|
87
|
+
const gap = 20;
|
|
88
|
+
const constraints: Constraint[] = [
|
|
89
|
+
{
|
|
90
|
+
id: 1001,
|
|
91
|
+
target: 2,
|
|
92
|
+
component: 'x',
|
|
93
|
+
term: { type: 'const', value: boxA.x + boxA.width + gap },
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
await applyConstraints(page, constraints);
|
|
98
|
+
await waitForStableFrame(page);
|
|
99
|
+
|
|
100
|
+
// Verify constraint propagation
|
|
101
|
+
boundsB = await getEntityBounds(page, 2);
|
|
102
|
+
const expectedX = boxA.x + boxA.width + gap; // 100 + 80 + 20 = 200
|
|
103
|
+
expect(boundsB.x).toBe(expectedX);
|
|
104
|
+
|
|
105
|
+
// Verify visual matches (sample pixel at expected position)
|
|
106
|
+
const pixelColor = await samplePixel(page, expectedX + 10, boxB.y + 10);
|
|
107
|
+
expect(pixelColor.r).toBeGreaterThan(150); // Orange has high R
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Test: Transactional atomicity - rollback on overconstrained graph
|
|
112
|
+
*
|
|
113
|
+
* If applying a CODL command would create an overconstrained graph,
|
|
114
|
+
* the entire transaction must roll back with no visual changes.
|
|
115
|
+
*/
|
|
116
|
+
test('overconstrained command rolls back without visual change', async ({ page }) => {
|
|
117
|
+
await setupTestPage(page);
|
|
118
|
+
|
|
119
|
+
// Setup: box with two conflicting hard constraints
|
|
120
|
+
const boxA: EntityBounds = { x: 100, y: 100, width: 80, height: 50 };
|
|
121
|
+
|
|
122
|
+
await renderEntities(page, [
|
|
123
|
+
{ id: 1, bounds: boxA, fill: '#0064C8' },
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// Apply conflicting constraints (simulating rigidity error)
|
|
127
|
+
// In production, this would be caught by check_rigidity_for_codl_batch
|
|
128
|
+
const conflictingConstraints: Constraint[] = [
|
|
129
|
+
{ id: 1001, target: 1, component: 'x', term: { type: 'const', value: 100 } },
|
|
130
|
+
{ id: 1002, target: 1, component: 'x', term: { type: 'const', value: 200 } },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// Capture state before
|
|
134
|
+
const boundsBefore = await getEntityBounds(page, 1);
|
|
135
|
+
|
|
136
|
+
// Attempt to apply (should fail and rollback)
|
|
137
|
+
const success = await applyConstraintsWithRollback(page, conflictingConstraints);
|
|
138
|
+
expect(success).toBe(false);
|
|
139
|
+
|
|
140
|
+
// Verify no visual change
|
|
141
|
+
const boundsAfter = await getEntityBounds(page, 1);
|
|
142
|
+
expect(boundsAfter.x).toBe(boundsBefore.x);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// =============================================================================
|
|
147
|
+
// Scenario C: HMR Hot Reload -> T-Vector Preservation
|
|
148
|
+
// =============================================================================
|
|
149
|
+
|
|
150
|
+
test.describe('Scenario C: HMR with T-Vector Preservation', () => {
|
|
151
|
+
/**
|
|
152
|
+
* Test: User-dragged position preserved during HMR reload
|
|
153
|
+
*
|
|
154
|
+
* Critical invariant: If user has dragged an element (modifying T-vector),
|
|
155
|
+
* and source file changes trigger HMR, the dragged position MUST be
|
|
156
|
+
* preserved if the new constraints are satisfiable with current T-vector.
|
|
157
|
+
*
|
|
158
|
+
* Initial State:
|
|
159
|
+
* - 3 boxes stacked vertically with gap=20
|
|
160
|
+
* - User drags box_b to custom position
|
|
161
|
+
*
|
|
162
|
+
* Trigger:
|
|
163
|
+
* - Source file change: gap updated from 20 to 40
|
|
164
|
+
* - HMR fires constraint update
|
|
165
|
+
*
|
|
166
|
+
* Expected:
|
|
167
|
+
* - box_b retains user-dragged position (T-vector preserved)
|
|
168
|
+
* - box_c moves to accommodate new gap
|
|
169
|
+
*/
|
|
170
|
+
test('HMR preserves user-dragged T-vector when satisfiable', async ({ page }) => {
|
|
171
|
+
await setupTestPage(page);
|
|
172
|
+
|
|
173
|
+
// Setup: 3 boxes with vertical stack constraints
|
|
174
|
+
const boxes = [
|
|
175
|
+
{ id: 1, bounds: { x: 100, y: 100, width: 80, height: 50 }, fill: '#FF0000' },
|
|
176
|
+
{ id: 2, bounds: { x: 100, y: 170, width: 80, height: 50 }, fill: '#00FF00' },
|
|
177
|
+
{ id: 3, bounds: { x: 100, y: 240, width: 80, height: 50 }, fill: '#0000FF' },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
await renderEntities(page, boxes);
|
|
181
|
+
|
|
182
|
+
// Apply initial constraints (gap = 20)
|
|
183
|
+
const initialConstraints: Constraint[] = [
|
|
184
|
+
{ id: 1001, target: 2, component: 'y', term: { type: 'const', value: 170 } }, // 100 + 50 + 20
|
|
185
|
+
{ id: 1002, target: 3, component: 'y', term: { type: 'const', value: 240 } }, // 170 + 50 + 20
|
|
186
|
+
];
|
|
187
|
+
await applyConstraints(page, initialConstraints);
|
|
188
|
+
await waitForStableFrame(page);
|
|
189
|
+
|
|
190
|
+
// Simulate user drag: modify box_b's T-vector state
|
|
191
|
+
const userDraggedY = 200; // User dragged box_b down
|
|
192
|
+
await simulateUserDrag(page, 2, { x: 100, y: userDraggedY });
|
|
193
|
+
await waitForStableFrame(page);
|
|
194
|
+
|
|
195
|
+
// Verify dragged position
|
|
196
|
+
let boundsB = await getEntityBounds(page, 2);
|
|
197
|
+
expect(boundsB.y).toBe(userDraggedY);
|
|
198
|
+
|
|
199
|
+
// Simulate HMR: source file change updates gap from 20 to 40
|
|
200
|
+
// New constraints would be:
|
|
201
|
+
// box_b.y = box_a.y + box_a.height + 40 = 100 + 50 + 40 = 190
|
|
202
|
+
// box_c.y = box_b.y + box_b.height + 40 = (user position) + 50 + 40
|
|
203
|
+
//
|
|
204
|
+
// T-vector satisfiability check: Is userDraggedY compatible with new constraints?
|
|
205
|
+
// In this case, we have soft constraints, so T-vector is preserved.
|
|
206
|
+
await simulateHMRUpdate(page, {
|
|
207
|
+
preserveTVector: true,
|
|
208
|
+
newConstraints: [
|
|
209
|
+
{ id: 1001, target: 2, component: 'y', term: { type: 'const', value: 190 } },
|
|
210
|
+
{ id: 1002, target: 3, component: 'y', term: { type: 'const', value: userDraggedY + 50 + 40 } },
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
await waitForStableFrame(page);
|
|
214
|
+
|
|
215
|
+
// Verify: box_b retains user-dragged position
|
|
216
|
+
boundsB = await getEntityBounds(page, 2);
|
|
217
|
+
expect(boundsB.y).toBe(userDraggedY);
|
|
218
|
+
|
|
219
|
+
// Verify: box_c moved to new position based on preserved box_b position
|
|
220
|
+
const boundsC = await getEntityBounds(page, 3);
|
|
221
|
+
expect(boundsC.y).toBe(userDraggedY + 50 + 40); // 290
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Test: T-vector reset when new constraints are unsatisfiable
|
|
226
|
+
*
|
|
227
|
+
* If HMR produces constraints that conflict with current T-vector,
|
|
228
|
+
* the solver must recompute T-vector from scratch (no preservation).
|
|
229
|
+
*/
|
|
230
|
+
test('HMR recomputes T-vector when constraints unsatisfiable', async ({ page }) => {
|
|
231
|
+
await setupTestPage(page);
|
|
232
|
+
|
|
233
|
+
// Setup: single box with constraint
|
|
234
|
+
await renderEntities(page, [
|
|
235
|
+
{ id: 1, bounds: { x: 100, y: 100, width: 80, height: 50 }, fill: '#FF0000' },
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
// Initial constraint
|
|
239
|
+
await applyConstraints(page, [
|
|
240
|
+
{ id: 1001, target: 1, component: 'x', term: { type: 'const', value: 100 } },
|
|
241
|
+
]);
|
|
242
|
+
await waitForStableFrame(page);
|
|
243
|
+
|
|
244
|
+
// Simulate user drag (soft override)
|
|
245
|
+
await simulateUserDrag(page, 1, { x: 200, y: 100 });
|
|
246
|
+
await waitForStableFrame(page);
|
|
247
|
+
|
|
248
|
+
// Verify drag applied
|
|
249
|
+
let bounds = await getEntityBounds(page, 1);
|
|
250
|
+
expect(bounds.x).toBe(200);
|
|
251
|
+
|
|
252
|
+
// HMR with hard constraint that conflicts with dragged position
|
|
253
|
+
// preserveTVector: false forces recomputation
|
|
254
|
+
await simulateHMRUpdate(page, {
|
|
255
|
+
preserveTVector: false,
|
|
256
|
+
newConstraints: [
|
|
257
|
+
{ id: 1001, target: 1, component: 'x', term: { type: 'const', value: 50 } },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
await waitForStableFrame(page);
|
|
261
|
+
|
|
262
|
+
// Verify: position reset to new constraint value
|
|
263
|
+
bounds = await getEntityBounds(page, 1);
|
|
264
|
+
expect(bounds.x).toBe(50);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// =============================================================================
|
|
269
|
+
// Task 4: Bilayer Synchronization Atomicity
|
|
270
|
+
// =============================================================================
|
|
271
|
+
|
|
272
|
+
test.describe('Bilayer Sync: Atomic Canvas + DOM Updates', () => {
|
|
273
|
+
/**
|
|
274
|
+
* Test: Canvas and DOM update in same rAF cycle
|
|
275
|
+
*
|
|
276
|
+
* Critical invariant: When a constraint update occurs, both the
|
|
277
|
+
* Canvas visual and DOM hit region must update atomically within
|
|
278
|
+
* the same requestAnimationFrame cycle.
|
|
279
|
+
*
|
|
280
|
+
* Verification method:
|
|
281
|
+
* - Instrument rAF to capture both Canvas state and DOM state
|
|
282
|
+
* - Assert they are updated in the same frame
|
|
283
|
+
*/
|
|
284
|
+
test('constraint update applies to Canvas and DOM in same frame', async ({ page }) => {
|
|
285
|
+
await setupTestPage(page);
|
|
286
|
+
|
|
287
|
+
// Setup: interactive box
|
|
288
|
+
await renderInteractiveEntities(page, [
|
|
289
|
+
{ id: 1, bounds: { x: 100, y: 100, width: 80, height: 50 }, fill: '#0064C8' },
|
|
290
|
+
]);
|
|
291
|
+
await waitForStableFrame(page);
|
|
292
|
+
|
|
293
|
+
// Instrument frame capture
|
|
294
|
+
await page.evaluate(() => {
|
|
295
|
+
(window as any).__VS_FRAME_CAPTURES__ = [];
|
|
296
|
+
|
|
297
|
+
const originalRAF = window.requestAnimationFrame;
|
|
298
|
+
window.requestAnimationFrame = (callback) => {
|
|
299
|
+
return originalRAF((timestamp) => {
|
|
300
|
+
// Capture state BEFORE callback
|
|
301
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
302
|
+
const canvasBounds = renderer.getEntityBounds(1);
|
|
303
|
+
const domEl = document.querySelector('[data-entity-id="1"]') as HTMLElement;
|
|
304
|
+
const domTransform = domEl?.style.transform || '';
|
|
305
|
+
|
|
306
|
+
(window as any).__VS_FRAME_CAPTURES__.push({
|
|
307
|
+
timestamp,
|
|
308
|
+
canvasX: canvasBounds?.x,
|
|
309
|
+
domTransform,
|
|
310
|
+
phase: 'before',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
callback(timestamp);
|
|
314
|
+
|
|
315
|
+
// Capture state AFTER callback
|
|
316
|
+
const canvasBoundsAfter = renderer.getEntityBounds(1);
|
|
317
|
+
const domTransformAfter = domEl?.style.transform || '';
|
|
318
|
+
|
|
319
|
+
(window as any).__VS_FRAME_CAPTURES__.push({
|
|
320
|
+
timestamp,
|
|
321
|
+
canvasX: canvasBoundsAfter?.x,
|
|
322
|
+
domTransform: domTransformAfter,
|
|
323
|
+
phase: 'after',
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Wait one frame so the render loop re-registers itself through the wrapper.
|
|
330
|
+
// Without this, the first render loop rAF was queued before the wrapper was installed
|
|
331
|
+
// and would fire unwrapped, causing the constraint-apply frame to go undetected.
|
|
332
|
+
await waitForStableFrame(page);
|
|
333
|
+
|
|
334
|
+
// Apply constraint that moves entity
|
|
335
|
+
await applyConstraints(page, [
|
|
336
|
+
{ id: 1001, target: 1, component: 'x', term: { type: 'const', value: 300 } },
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
// Wait for update to process (constraint is applied in the next rAF cycle)
|
|
340
|
+
await waitForStableFrame(page);
|
|
341
|
+
|
|
342
|
+
// Analyze frame captures
|
|
343
|
+
const captures = await page.evaluate(() => (window as any).__VS_FRAME_CAPTURES__);
|
|
344
|
+
|
|
345
|
+
// Find the frame where Canvas changed
|
|
346
|
+
let canvasChangeFrame: number | null = null;
|
|
347
|
+
let domChangeFrame: number | null = null;
|
|
348
|
+
|
|
349
|
+
for (let i = 1; i < captures.length; i++) {
|
|
350
|
+
const prev = captures[i - 1];
|
|
351
|
+
const curr = captures[i];
|
|
352
|
+
|
|
353
|
+
if (prev.canvasX !== curr.canvasX && curr.canvasX === 300) {
|
|
354
|
+
canvasChangeFrame = curr.timestamp;
|
|
355
|
+
}
|
|
356
|
+
if (prev.domTransform !== curr.domTransform && curr.domTransform.includes('300')) {
|
|
357
|
+
domChangeFrame = curr.timestamp;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Assert: Both changes happened in the same frame
|
|
362
|
+
expect(canvasChangeFrame).not.toBeNull();
|
|
363
|
+
expect(domChangeFrame).not.toBeNull();
|
|
364
|
+
expect(canvasChangeFrame).toBe(domChangeFrame);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Test: No intermediate frame with desynchronized layers
|
|
369
|
+
*
|
|
370
|
+
* Verify that there is never a frame where Canvas shows new position
|
|
371
|
+
* but DOM still has old position (or vice versa).
|
|
372
|
+
*/
|
|
373
|
+
test('no frame with desynchronized Canvas and DOM positions', async ({ page }) => {
|
|
374
|
+
await setupTestPage(page);
|
|
375
|
+
|
|
376
|
+
// Setup
|
|
377
|
+
await renderInteractiveEntities(page, [
|
|
378
|
+
{ id: 1, bounds: { x: 100, y: 100, width: 80, height: 50 }, fill: '#0064C8' },
|
|
379
|
+
]);
|
|
380
|
+
await waitForStableFrame(page);
|
|
381
|
+
|
|
382
|
+
// Instrument detailed frame capture
|
|
383
|
+
await page.evaluate(() => {
|
|
384
|
+
(window as any).__VS_SYNC_VIOLATIONS__ = [];
|
|
385
|
+
|
|
386
|
+
const originalRAF = window.requestAnimationFrame;
|
|
387
|
+
window.requestAnimationFrame = (callback) => {
|
|
388
|
+
return originalRAF((timestamp) => {
|
|
389
|
+
callback(timestamp);
|
|
390
|
+
|
|
391
|
+
// After each frame, check synchronization
|
|
392
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
393
|
+
const canvasBounds = renderer.getEntityBounds(1);
|
|
394
|
+
const domEl = document.querySelector('[data-entity-id="1"]') as HTMLElement;
|
|
395
|
+
|
|
396
|
+
if (canvasBounds && domEl) {
|
|
397
|
+
// Extract X from transform
|
|
398
|
+
const match = domEl.style.transform.match(/translate3d\((\d+)px/);
|
|
399
|
+
const domX = match ? parseInt(match[1], 10) : null;
|
|
400
|
+
|
|
401
|
+
if (domX !== null && canvasBounds.x !== domX) {
|
|
402
|
+
(window as any).__VS_SYNC_VIOLATIONS__.push({
|
|
403
|
+
timestamp,
|
|
404
|
+
canvasX: canvasBounds.x,
|
|
405
|
+
domX,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Apply multiple rapid constraint updates
|
|
414
|
+
for (let i = 0; i < 5; i++) {
|
|
415
|
+
await applyConstraints(page, [
|
|
416
|
+
{ id: 1001, target: 1, component: 'x', term: { type: 'const', value: 100 + i * 50 } },
|
|
417
|
+
]);
|
|
418
|
+
await page.waitForTimeout(16); // ~1 frame
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
await waitForStableFrame(page);
|
|
422
|
+
|
|
423
|
+
// Check for violations
|
|
424
|
+
const violations = await page.evaluate(() => (window as any).__VS_SYNC_VIOLATIONS__);
|
|
425
|
+
expect(violations).toHaveLength(0);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// =============================================================================
|
|
430
|
+
// Q-Dimension Isolation (Zero-Flakiness)
|
|
431
|
+
// =============================================================================
|
|
432
|
+
|
|
433
|
+
test.describe('Q-Dimension Isolation', () => {
|
|
434
|
+
/**
|
|
435
|
+
* Test: Mocked measureText returns deterministic values
|
|
436
|
+
*
|
|
437
|
+
* Font metrics are Q-dimension (non-deterministic). This test verifies
|
|
438
|
+
* that our mock produces consistent results across runs.
|
|
439
|
+
*/
|
|
440
|
+
test('mocked measureText returns deterministic width', async ({ page }) => {
|
|
441
|
+
await setupTestPage(page);
|
|
442
|
+
|
|
443
|
+
// Inject deterministic font metric mock via evaluate (page is already loaded)
|
|
444
|
+
// addInitScript would not apply after page.goto, so we use page.evaluate instead
|
|
445
|
+
await page.evaluate(() => {
|
|
446
|
+
CanvasRenderingContext2D.prototype.measureText = function(text: string) {
|
|
447
|
+
// Deterministic: 8px per character
|
|
448
|
+
return {
|
|
449
|
+
width: text.length * 8,
|
|
450
|
+
actualBoundingBoxAscent: 12,
|
|
451
|
+
actualBoundingBoxDescent: 3,
|
|
452
|
+
fontBoundingBoxAscent: 14,
|
|
453
|
+
fontBoundingBoxDescent: 4,
|
|
454
|
+
actualBoundingBoxLeft: 0,
|
|
455
|
+
actualBoundingBoxRight: text.length * 8,
|
|
456
|
+
} as unknown as TextMetrics;
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Measure same text multiple times
|
|
461
|
+
const results: number[] = [];
|
|
462
|
+
for (let i = 0; i < 10; i++) {
|
|
463
|
+
const width = await page.evaluate(() => {
|
|
464
|
+
const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
465
|
+
const ctx = canvas.getContext('2d')!;
|
|
466
|
+
return ctx.measureText('Hello, ViewScript!').width;
|
|
467
|
+
});
|
|
468
|
+
results.push(width);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// All measurements must be identical
|
|
472
|
+
const firstResult = results[0];
|
|
473
|
+
for (const result of results) {
|
|
474
|
+
expect(result).toBe(firstResult);
|
|
475
|
+
}
|
|
476
|
+
expect(firstResult).toBe(18 * 8); // "Hello, ViewScript!" = 18 chars * 8px
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// =============================================================================
|
|
481
|
+
// Helper Functions
|
|
482
|
+
// =============================================================================
|
|
483
|
+
|
|
484
|
+
async function setupTestPage(page: Page): Promise<void> {
|
|
485
|
+
await page.goto('/test-harness.html');
|
|
486
|
+
await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
487
|
+
timeout: 10000,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
interface EntitySpec {
|
|
492
|
+
id: number;
|
|
493
|
+
bounds: EntityBounds;
|
|
494
|
+
fill: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function renderEntities(page: Page, entities: EntitySpec[]): Promise<void> {
|
|
498
|
+
await page.evaluate((ents) => {
|
|
499
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
500
|
+
renderer.render({
|
|
501
|
+
entities: ents.map(e => ({
|
|
502
|
+
id: e.id,
|
|
503
|
+
type: 'rect',
|
|
504
|
+
bounds: e.bounds,
|
|
505
|
+
fill: e.fill,
|
|
506
|
+
interactive: false,
|
|
507
|
+
})),
|
|
508
|
+
constraints: [],
|
|
509
|
+
});
|
|
510
|
+
}, entities);
|
|
511
|
+
await waitForStableFrame(page);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function renderInteractiveEntities(page: Page, entities: EntitySpec[]): Promise<void> {
|
|
515
|
+
await page.evaluate((ents) => {
|
|
516
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
517
|
+
renderer.render({
|
|
518
|
+
entities: ents.map(e => ({
|
|
519
|
+
id: e.id,
|
|
520
|
+
type: 'rect',
|
|
521
|
+
bounds: e.bounds,
|
|
522
|
+
fill: e.fill,
|
|
523
|
+
interactive: true,
|
|
524
|
+
})),
|
|
525
|
+
constraints: [],
|
|
526
|
+
});
|
|
527
|
+
}, entities);
|
|
528
|
+
await waitForStableFrame(page);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function applyConstraints(page: Page, constraints: Constraint[]): Promise<void> {
|
|
532
|
+
await page.evaluate((cs) => {
|
|
533
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
534
|
+
|
|
535
|
+
// Use setConstraints to avoid recreating DOM elements (which would
|
|
536
|
+
// invalidate any cached DOM element references in frame-capture tests).
|
|
537
|
+
// The render loop's evaluateConstraints() will apply constraints each rAF cycle.
|
|
538
|
+
if (renderer.setConstraints) {
|
|
539
|
+
renderer.setConstraints(cs);
|
|
540
|
+
} else {
|
|
541
|
+
// Fallback: re-render preserving all entities
|
|
542
|
+
const allEntities = renderer.getAllEntities ? renderer.getAllEntities() : [];
|
|
543
|
+
renderer.render({ entities: allEntities, constraints: cs });
|
|
544
|
+
}
|
|
545
|
+
}, constraints);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function applyConstraintsWithRollback(page: Page, constraints: Constraint[]): Promise<boolean> {
|
|
549
|
+
return await page.evaluate((cs) => {
|
|
550
|
+
// Check for conflicts
|
|
551
|
+
const targetComponents = new Map<string, number>();
|
|
552
|
+
for (const c of cs) {
|
|
553
|
+
const key = `${c.target}:${c.component}`;
|
|
554
|
+
const count = (targetComponents.get(key) || 0) + 1;
|
|
555
|
+
targetComponents.set(key, count);
|
|
556
|
+
|
|
557
|
+
if (count > 1) {
|
|
558
|
+
// Conflict detected - rollback (don't apply)
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// No conflict - apply
|
|
564
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
565
|
+
for (const c of cs) {
|
|
566
|
+
const entity = renderer.getEntityBounds(c.target);
|
|
567
|
+
if (entity && c.term.type === 'const') {
|
|
568
|
+
entity[c.component] = c.term.value;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}, constraints);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function getEntityBounds(page: Page, entityId: number): Promise<EntityBounds> {
|
|
576
|
+
return await page.evaluate((id) => {
|
|
577
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
578
|
+
return renderer.getEntityBounds(id);
|
|
579
|
+
}, entityId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function simulateUserDrag(
|
|
583
|
+
page: Page,
|
|
584
|
+
entityId: number,
|
|
585
|
+
newPosition: { x: number; y: number },
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
await page.evaluate(({ id, pos }) => {
|
|
588
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
589
|
+
|
|
590
|
+
// Use updateEntityBounds to modify the actual stored entity (not a stale copy)
|
|
591
|
+
renderer.updateEntityBounds(id, { x: pos.x, y: pos.y });
|
|
592
|
+
|
|
593
|
+
// Update T-vector state to mark as user-dragged
|
|
594
|
+
const tState = renderer.getTVectorState?.();
|
|
595
|
+
if (tState && tState[id]) {
|
|
596
|
+
tState[id].drag_progress = 1;
|
|
597
|
+
}
|
|
598
|
+
}, { id: entityId, pos: newPosition });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
interface HMRUpdateConfig {
|
|
602
|
+
preserveTVector: boolean;
|
|
603
|
+
newConstraints: Constraint[];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function simulateHMRUpdate(page: Page, config: HMRUpdateConfig): Promise<void> {
|
|
607
|
+
await page.evaluate((cfg) => {
|
|
608
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
609
|
+
|
|
610
|
+
if (!cfg.preserveTVector) {
|
|
611
|
+
// Reset T-vector state
|
|
612
|
+
const tState = renderer.getTVectorState?.();
|
|
613
|
+
if (tState) {
|
|
614
|
+
for (const id of Object.keys(tState)) {
|
|
615
|
+
tState[id] = {
|
|
616
|
+
hover: 0,
|
|
617
|
+
pressed: 0,
|
|
618
|
+
focused: 0,
|
|
619
|
+
scroll_x: 0,
|
|
620
|
+
scroll_y: 0,
|
|
621
|
+
drag_progress: 0,
|
|
622
|
+
animation_t: 0,
|
|
623
|
+
gesture_phase: 0,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Register new constraints so the render loop evaluates them each rAF
|
|
630
|
+
renderer.setConstraints(cfg.newConstraints);
|
|
631
|
+
|
|
632
|
+
// Apply bounds immediately for non-dragged entities
|
|
633
|
+
for (const c of cfg.newConstraints) {
|
|
634
|
+
if (c.term.type !== 'const') continue;
|
|
635
|
+
|
|
636
|
+
const tState = renderer.getTVectorState?.();
|
|
637
|
+
const isDragged = cfg.preserveTVector && (tState?.[c.target]?.drag_progress > 0);
|
|
638
|
+
|
|
639
|
+
if (!isDragged) {
|
|
640
|
+
renderer.updateEntityBounds(c.target, { [c.component]: c.term.value });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}, config);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function waitForStableFrame(page: Page): Promise<void> {
|
|
647
|
+
await page.evaluate(() => {
|
|
648
|
+
return new Promise<void>((resolve) => {
|
|
649
|
+
requestAnimationFrame(() => {
|
|
650
|
+
requestAnimationFrame(() => resolve());
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function samplePixel(
|
|
657
|
+
page: Page,
|
|
658
|
+
x: number,
|
|
659
|
+
y: number,
|
|
660
|
+
): Promise<{ r: number; g: number; b: number }> {
|
|
661
|
+
return await page.evaluate(({ px, py }) => {
|
|
662
|
+
const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
663
|
+
const ctx = canvas.getContext('2d');
|
|
664
|
+
if (!ctx) throw new Error('No 2D context');
|
|
665
|
+
|
|
666
|
+
const rect = canvas.getBoundingClientRect();
|
|
667
|
+
const canvasX = px - rect.left;
|
|
668
|
+
const canvasY = py - rect.top;
|
|
669
|
+
|
|
670
|
+
const dpr = window.devicePixelRatio || 1;
|
|
671
|
+
const backingX = Math.floor(canvasX * dpr);
|
|
672
|
+
const backingY = Math.floor(canvasY * dpr);
|
|
673
|
+
|
|
674
|
+
const imageData = ctx.getImageData(backingX, backingY, 1, 1);
|
|
675
|
+
return {
|
|
676
|
+
r: imageData.data[0],
|
|
677
|
+
g: imageData.data[1],
|
|
678
|
+
b: imageData.data[2],
|
|
679
|
+
};
|
|
680
|
+
}, { px: x, py: y });
|
|
681
|
+
}
|