@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.
Files changed (89) hide show
  1. package/dist/ast/types.d.ts +403 -0
  2. package/dist/ast/types.js +33 -0
  3. package/dist/compiler/chunk-splitter.d.ts +98 -0
  4. package/dist/compiler/chunk-splitter.js +361 -0
  5. package/dist/index.d.ts +55 -0
  6. package/dist/index.js +17 -0
  7. package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
  8. package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
  9. package/dist/rasterizer/canvas-mapper.d.ts +280 -0
  10. package/dist/rasterizer/canvas-mapper.js +414 -0
  11. package/dist/rasterizer/error-distribution.d.ts +143 -0
  12. package/dist/rasterizer/error-distribution.js +231 -0
  13. package/dist/rasterizer/gradient-mapper.d.ts +223 -0
  14. package/dist/rasterizer/gradient-mapper.js +352 -0
  15. package/dist/rasterizer/topology-rounding.d.ts +151 -0
  16. package/dist/rasterizer/topology-rounding.js +347 -0
  17. package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
  18. package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
  19. package/dist/runtime/event-backpressure.d.ts +393 -0
  20. package/dist/runtime/event-backpressure.js +458 -0
  21. package/dist/runtime/render-loop.d.ts +277 -0
  22. package/dist/runtime/render-loop.js +435 -0
  23. package/dist/runtime/wasm-resource-manager.d.ts +122 -0
  24. package/dist/runtime/wasm-resource-manager.js +253 -0
  25. package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
  26. package/dist/runtime/wgpu-renderer-adapter.js +230 -0
  27. package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
  28. package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
  29. package/dist/semantic/semantic-translator.d.ts +229 -0
  30. package/dist/semantic/semantic-translator.js +398 -0
  31. package/package.json +28 -0
  32. package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
  33. package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
  34. package/playwright-report/index.html +90 -0
  35. package/playwright.config.ts +160 -0
  36. package/screenshot-chrome.png +0 -0
  37. package/screenshots/visual-demo-verification.png +0 -0
  38. package/screenshots/visual-demo.png +0 -0
  39. package/src/ast/types.ts +473 -0
  40. package/src/compiler/chunk-splitter.ts +534 -0
  41. package/src/index.ts +62 -0
  42. package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
  43. package/src/rasterizer/canvas-mapper.ts +677 -0
  44. package/src/rasterizer/error-distribution.ts +344 -0
  45. package/src/rasterizer/gradient-mapper.ts +563 -0
  46. package/src/rasterizer/topology-rounding.ts +499 -0
  47. package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
  48. package/src/runtime/event-backpressure.ts +622 -0
  49. package/src/runtime/render-loop.ts +660 -0
  50. package/src/runtime/wasm-resource-manager.ts +349 -0
  51. package/src/runtime/wgpu-renderer-adapter.ts +318 -0
  52. package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
  53. package/src/semantic/semantic-translator.ts +637 -0
  54. package/test-results/.last-run.json +4 -0
  55. package/tests/e2e/async-race.spec.ts +612 -0
  56. package/tests/e2e/bilayer-sync.spec.ts +405 -0
  57. package/tests/e2e/failures/.gitkeep +0 -0
  58. package/tests/e2e/fullstack.spec.ts +681 -0
  59. package/tests/e2e/g1-continuity.spec.ts +703 -0
  60. package/tests/e2e/golden/.gitkeep +0 -0
  61. package/tests/e2e/golden/conic-color-wheel.raw +0 -0
  62. package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
  63. package/tests/e2e/golden/conic-rotated.raw +0 -0
  64. package/tests/e2e/golden/conic-rotated.sha256 +1 -0
  65. package/tests/e2e/golden/linear-45deg.raw +0 -0
  66. package/tests/e2e/golden/linear-45deg.sha256 +1 -0
  67. package/tests/e2e/golden/linear-horizontal.raw +0 -0
  68. package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
  69. package/tests/e2e/golden/linear-multi-stop.raw +0 -0
  70. package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
  71. package/tests/e2e/golden/radial-circle-center.raw +0 -0
  72. package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
  73. package/tests/e2e/golden/radial-offset.raw +0 -0
  74. package/tests/e2e/golden/radial-offset.sha256 +1 -0
  75. package/tests/e2e/golden/tile-mirror.raw +0 -0
  76. package/tests/e2e/golden/tile-mirror.sha256 +1 -0
  77. package/tests/e2e/golden/tile-repeat.raw +0 -0
  78. package/tests/e2e/golden/tile-repeat.sha256 +1 -0
  79. package/tests/e2e/gradient-animation.spec.ts +606 -0
  80. package/tests/e2e/memory-stability.spec.ts +396 -0
  81. package/tests/e2e/path-topology.spec.ts +674 -0
  82. package/tests/e2e/performance-profile.spec.ts +501 -0
  83. package/tests/e2e/screenshot.spec.ts +60 -0
  84. package/tests/e2e/test-harness.html +1005 -0
  85. package/tests/e2e/text-layout.spec.ts +451 -0
  86. package/tests/e2e/visual-demo.html +340 -0
  87. package/tests/e2e/visual-regression.spec.ts +335 -0
  88. package/tsconfig.json +12 -0
  89. 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
+ }