@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,340 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ViewScript Visual Demo</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ body {
14
+ width: 800px;
15
+ height: 600px;
16
+ overflow: hidden;
17
+ background: #1a1a2e;
18
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
19
+ }
20
+ #vs-canvas {
21
+ position: absolute;
22
+ top: 0;
23
+ left: 0;
24
+ width: 800px;
25
+ height: 600px;
26
+ }
27
+ #vs-dom-layer {
28
+ position: absolute;
29
+ top: 0;
30
+ left: 0;
31
+ width: 800px;
32
+ height: 600px;
33
+ pointer-events: auto;
34
+ }
35
+ .vs-dom-element {
36
+ position: absolute;
37
+ pointer-events: auto;
38
+ will-change: transform;
39
+ }
40
+ #info-overlay {
41
+ position: absolute;
42
+ bottom: 10px;
43
+ left: 10px;
44
+ color: rgba(255, 255, 255, 0.7);
45
+ font-size: 12px;
46
+ font-family: monospace;
47
+ z-index: 1000;
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <canvas id="vs-canvas" width="800" height="600"></canvas>
53
+ <div id="vs-dom-layer"></div>
54
+ <div id="info-overlay"></div>
55
+
56
+ <script type="module">
57
+ /**
58
+ * ViewScript Visual Demo
59
+ *
60
+ * Demonstrates the complete rendering pipeline:
61
+ * 1. IR (Intermediate Representation) construction
62
+ * 2. Constraint-based P-dimension positioning
63
+ * 3. T-vector driven animations
64
+ * 4. Canvas + DOM bilayer rendering
65
+ */
66
+
67
+ const canvas = document.getElementById('vs-canvas');
68
+ const ctx = canvas.getContext('2d');
69
+ const domLayer = document.getElementById('vs-dom-layer');
70
+ const infoOverlay = document.getElementById('info-overlay');
71
+
72
+ // ==========================================================================
73
+ // IR Construction (Simulating vsc CLI output)
74
+ // ==========================================================================
75
+
76
+ /**
77
+ * This IR represents what `vsc build` would produce.
78
+ * Structure matches the vsc-core IR schema.
79
+ */
80
+ const ir = {
81
+ schema_version: 1,
82
+ project: {
83
+ name: "visual-demo",
84
+ version: "0.1.0"
85
+ },
86
+ entities: [
87
+ // Title text
88
+ {
89
+ id: 1,
90
+ type: "text",
91
+ content: "ViewScript P-Dimension Demo",
92
+ bounds: { x: 50, y: 50, width: 400, height: 40 },
93
+ fill: "#ffffff",
94
+ font: { family: "sans-serif", size: 28, weight: "bold" }
95
+ },
96
+ // Main rectangle (constrained to center)
97
+ {
98
+ id: 2,
99
+ type: "rect",
100
+ bounds: { x: 300, y: 200, width: 200, height: 200 },
101
+ fill: "#6366f1",
102
+ interactive: true,
103
+ stroke: { color: "#818cf8", width: 2 }
104
+ },
105
+ // Satellite rectangles (constrained relative to main)
106
+ {
107
+ id: 3,
108
+ type: "rect",
109
+ bounds: { x: 150, y: 250, width: 80, height: 80 },
110
+ fill: "#22c55e",
111
+ interactive: true
112
+ },
113
+ {
114
+ id: 4,
115
+ type: "rect",
116
+ bounds: { x: 570, y: 250, width: 80, height: 80 },
117
+ fill: "#f59e0b",
118
+ interactive: true
119
+ },
120
+ // Constraint visualization lines
121
+ {
122
+ id: 5,
123
+ type: "line",
124
+ from: { x: 230, y: 290 },
125
+ to: { x: 300, y: 300 },
126
+ stroke: { color: "rgba(255, 255, 255, 0.3)", width: 2, dash: [5, 5] }
127
+ },
128
+ {
129
+ id: 6,
130
+ type: "line",
131
+ from: { x: 500, y: 300 },
132
+ to: { x: 570, y: 290 },
133
+ stroke: { color: "rgba(255, 255, 255, 0.3)", width: 2, dash: [5, 5] }
134
+ },
135
+ // Labels
136
+ {
137
+ id: 7,
138
+ type: "text",
139
+ content: "Entity #2",
140
+ bounds: { x: 350, y: 420, width: 100, height: 20 },
141
+ fill: "rgba(255, 255, 255, 0.6)",
142
+ font: { family: "monospace", size: 12 }
143
+ },
144
+ {
145
+ id: 8,
146
+ type: "text",
147
+ content: "Entity #3",
148
+ bounds: { x: 150, y: 350, width: 80, height: 20 },
149
+ fill: "rgba(255, 255, 255, 0.6)",
150
+ font: { family: "monospace", size: 12 }
151
+ },
152
+ {
153
+ id: 9,
154
+ type: "text",
155
+ content: "Entity #4",
156
+ bounds: { x: 570, y: 350, width: 80, height: 20 },
157
+ fill: "rgba(255, 255, 255, 0.6)",
158
+ font: { family: "monospace", size: 12 }
159
+ },
160
+ // Constraint info box
161
+ {
162
+ id: 10,
163
+ type: "rect",
164
+ bounds: { x: 50, y: 450, width: 700, height: 120 },
165
+ fill: "rgba(255, 255, 255, 0.05)",
166
+ stroke: { color: "rgba(255, 255, 255, 0.2)", width: 1 }
167
+ },
168
+ {
169
+ id: 11,
170
+ type: "text",
171
+ content: "Active Constraints (P-Dimension):",
172
+ bounds: { x: 70, y: 475, width: 300, height: 20 },
173
+ fill: "#a5b4fc",
174
+ font: { family: "monospace", size: 14 }
175
+ },
176
+ {
177
+ id: 12,
178
+ type: "text",
179
+ content: "E2.x = 300/1 | E3.x = E2.x - 150/1 | E4.x = E2.x + 270/1",
180
+ bounds: { x: 70, y: 500, width: 600, height: 16 },
181
+ fill: "rgba(255, 255, 255, 0.7)",
182
+ font: { family: "monospace", size: 13 }
183
+ },
184
+ {
185
+ id: 13,
186
+ type: "text",
187
+ content: "T-Vector State: hover=0, animation_t=0.00",
188
+ bounds: { x: 70, y: 530, width: 400, height: 16 },
189
+ fill: "rgba(255, 255, 255, 0.5)",
190
+ font: { family: "monospace", size: 12 }
191
+ }
192
+ ],
193
+ constraints: [
194
+ // E2 (main) positioned at center
195
+ { id: 1, target: 2, component: "x", relation: "eq", term: { type: "const", value: "300/1" } },
196
+ { id: 2, target: 2, component: "y", relation: "eq", term: { type: "const", value: "200/1" } },
197
+ // E3 positioned relative to E2
198
+ { id: 3, target: 3, component: "x", relation: "eq", term: { type: "linear", coefficient: "1/1", entity_id: 2, component: "x", offset: "-150/1" } },
199
+ // E4 positioned relative to E2
200
+ { id: 4, target: 4, component: "x", relation: "eq", term: { type: "linear", coefficient: "1/1", entity_id: 2, component: "x", offset: "270/1" } }
201
+ ],
202
+ containments: []
203
+ };
204
+
205
+ // ==========================================================================
206
+ // Rendering Pipeline
207
+ // ==========================================================================
208
+
209
+ function render() {
210
+ // Clear canvas
211
+ ctx.fillStyle = '#1a1a2e';
212
+ ctx.fillRect(0, 0, 800, 600);
213
+
214
+ // Render grid background
215
+ renderGrid();
216
+
217
+ // Render each entity
218
+ for (const entity of ir.entities) {
219
+ renderEntity(entity);
220
+ }
221
+
222
+ // Update info overlay
223
+ updateInfo();
224
+ }
225
+
226
+ function renderGrid() {
227
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
228
+ ctx.lineWidth = 1;
229
+
230
+ // Vertical lines
231
+ for (let x = 0; x <= 800; x += 50) {
232
+ ctx.beginPath();
233
+ ctx.moveTo(x, 0);
234
+ ctx.lineTo(x, 600);
235
+ ctx.stroke();
236
+ }
237
+
238
+ // Horizontal lines
239
+ for (let y = 0; y <= 600; y += 50) {
240
+ ctx.beginPath();
241
+ ctx.moveTo(0, y);
242
+ ctx.lineTo(800, y);
243
+ ctx.stroke();
244
+ }
245
+ }
246
+
247
+ function renderEntity(entity) {
248
+ const { type, bounds, fill, stroke, content, font } = entity;
249
+
250
+ switch (type) {
251
+ case 'rect':
252
+ // Fill
253
+ ctx.fillStyle = fill || '#ffffff';
254
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
255
+
256
+ // Stroke
257
+ if (stroke) {
258
+ ctx.strokeStyle = stroke.color;
259
+ ctx.lineWidth = stroke.width;
260
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
261
+ }
262
+ break;
263
+
264
+ case 'text':
265
+ ctx.fillStyle = fill || '#ffffff';
266
+ const fontSize = font?.size || 16;
267
+ const fontFamily = font?.family || 'sans-serif';
268
+ const fontWeight = font?.weight || 'normal';
269
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
270
+ ctx.fillText(content, bounds.x, bounds.y + fontSize);
271
+ break;
272
+
273
+ case 'line':
274
+ ctx.strokeStyle = entity.stroke?.color || '#ffffff';
275
+ ctx.lineWidth = entity.stroke?.width || 1;
276
+ if (entity.stroke?.dash) {
277
+ ctx.setLineDash(entity.stroke.dash);
278
+ }
279
+ ctx.beginPath();
280
+ ctx.moveTo(entity.from.x, entity.from.y);
281
+ ctx.lineTo(entity.to.x, entity.to.y);
282
+ ctx.stroke();
283
+ ctx.setLineDash([]);
284
+ break;
285
+ }
286
+ }
287
+
288
+ function updateInfo() {
289
+ const now = new Date().toISOString();
290
+ infoOverlay.textContent = `ViewScript Renderer | Entities: ${ir.entities.length} | Constraints: ${ir.constraints.length} | ${now}`;
291
+ }
292
+
293
+ // ==========================================================================
294
+ // Animation Loop (T-Vector driven)
295
+ // ==========================================================================
296
+
297
+ let animationT = 0;
298
+ let hoverState = 0;
299
+
300
+ function animate(timestamp) {
301
+ animationT = (timestamp / 1000) % 10; // 10-second cycle
302
+
303
+ // Update T-vector display
304
+ const tVectorText = ir.entities.find(e => e.id === 13);
305
+ if (tVectorText) {
306
+ tVectorText.content = `T-Vector State: hover=${hoverState}, animation_t=${animationT.toFixed(2)}`;
307
+ }
308
+
309
+ // Animate main rectangle color based on T
310
+ const mainRect = ir.entities.find(e => e.id === 2);
311
+ if (mainRect) {
312
+ const hue = (animationT / 10) * 360;
313
+ mainRect.fill = `hsl(${hue}, 70%, 60%)`;
314
+ }
315
+
316
+ // Render
317
+ render();
318
+
319
+ requestAnimationFrame(animate);
320
+ }
321
+
322
+ // ==========================================================================
323
+ // Initialize
324
+ // ==========================================================================
325
+
326
+ // Initial render
327
+ render();
328
+
329
+ // Start animation
330
+ requestAnimationFrame(animate);
331
+
332
+ // Signal ready for screenshot
333
+ window.__VS_DEMO_READY__ = true;
334
+
335
+ console.log('[ViewScript] Visual Demo initialized');
336
+ console.log('[ViewScript] IR entities:', ir.entities.length);
337
+ console.log('[ViewScript] Constraints:', ir.constraints.length);
338
+ </script>
339
+ </body>
340
+ </html>
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Deterministic Visual Regression Tests
3
+ *
4
+ * This module ensures bit-perfect reproducibility of Canvas rendering
5
+ * by forcing the GPU renderer into CPU-only software rendering mode.
6
+ *
7
+ * ## The Problem: GPU Non-Determinism
8
+ *
9
+ * Different GPUs (NVIDIA vs AMD vs Intel) produce subtly different
10
+ * anti-aliasing patterns. Even the same GPU can produce different
11
+ * results across driver versions. This is unacceptable for a
12
+ * mathematically rigorous GUI framework.
13
+ *
14
+ * ## Solution: Software Rendering + Hash Comparison
15
+ *
16
+ * 1. Force wgpu renderer to use CPU rasterizer (no GPU)
17
+ * 2. Disable subpixel text positioning
18
+ * 3. Use fixed-width fonts for text tests
19
+ * 4. Compare output against golden snapshots via SHA-256 hash
20
+ *
21
+ * ## Test Artifact Storage
22
+ *
23
+ * Golden snapshots are stored in:
24
+ * tests/e2e/golden/<test-name>-<platform>.png
25
+ *
26
+ * Failed diffs are output to:
27
+ * tests/e2e/failures/<test-name>-diff.png
28
+ */
29
+
30
+ import { test, expect, type Page } from '@playwright/test';
31
+ import * as crypto from 'crypto';
32
+ import * as fs from 'fs';
33
+ import * as path from 'path';
34
+ import { fileURLToPath } from 'url';
35
+
36
+ // =============================================================================
37
+ // Test Configuration
38
+ // =============================================================================
39
+
40
+ const __filename = fileURLToPath(import.meta.url);
41
+ const __dirname = path.dirname(__filename);
42
+
43
+ const GOLDEN_DIR = path.join(__dirname, 'golden');
44
+ const FAILURE_DIR = path.join(__dirname, 'failures');
45
+
46
+ /**
47
+ * GPU renderer initialization options for deterministic rendering.
48
+ */
49
+ const WGPU_DETERMINISTIC_CONFIG = {
50
+ // Force CPU-only rendering (no WebGL)
51
+ disableWebGL: true,
52
+
53
+ // Use software rasterizer
54
+ preferLowPowerToHighPerformance: false,
55
+
56
+ // Disable subpixel antialiasing (font-dependent)
57
+ useSubpixelText: false,
58
+
59
+ // Fixed DPI for consistent sizing
60
+ devicePixelRatio: 1.0,
61
+ };
62
+
63
+ // =============================================================================
64
+ // Test Fixtures
65
+ // =============================================================================
66
+
67
+ test.describe('Visual Regression: Bit-Perfect Rendering', () => {
68
+ test.beforeAll(async () => {
69
+ // Ensure directories exist
70
+ if (!fs.existsSync(GOLDEN_DIR)) {
71
+ fs.mkdirSync(GOLDEN_DIR, { recursive: true });
72
+ }
73
+ if (!fs.existsSync(FAILURE_DIR)) {
74
+ fs.mkdirSync(FAILURE_DIR, { recursive: true });
75
+ }
76
+ });
77
+
78
+ /**
79
+ * Test: Simple Rectangle Rendering
80
+ *
81
+ * Constraint Graph (IR):
82
+ * Entity #1: rect at (10, 10) size (100, 50), fill: #FF0000
83
+ *
84
+ * Expected: Red rectangle, bit-perfect match with golden snapshot.
85
+ */
86
+ test('renders simple rectangle with bit-perfect hash match', async ({ page }) => {
87
+ // Setup: Load VS renderer in deterministic mode
88
+ await setupDeterministicRenderer(page);
89
+
90
+ // Act: Render a simple constraint graph
91
+ const pixelBuffer = await renderConstraintGraph(page, {
92
+ entities: [
93
+ {
94
+ id: 1,
95
+ type: 'rect',
96
+ bounds: { x: 10, y: 10, width: 100, height: 50 },
97
+ fill: '#FF0000',
98
+ },
99
+ ],
100
+ constraints: [],
101
+ });
102
+
103
+ // Assert: Hash match
104
+ const hash = computeHash(pixelBuffer);
105
+ const goldenHash = loadGoldenHash('simple-rectangle');
106
+
107
+ if (goldenHash === null) {
108
+ // First run: save as golden
109
+ saveGolden('simple-rectangle', pixelBuffer, hash);
110
+ console.log(`[GOLDEN] Saved new golden snapshot: simple-rectangle (${hash})`);
111
+ } else {
112
+ expect(hash).toBe(goldenHash);
113
+ }
114
+ });
115
+
116
+ /**
117
+ * Test: Adjacent Rectangles (Topology Preservation)
118
+ *
119
+ * This tests the core topology-preserving rounding:
120
+ * Three adjacent rects of 33.333...px width in 100px container.
121
+ *
122
+ * Expected: No gaps, no overlaps, bit-perfect.
123
+ */
124
+ test('renders adjacent rectangles without subpixel gaps', async ({ page }) => {
125
+ await setupDeterministicRenderer(page);
126
+
127
+ const pixelBuffer = await renderConstraintGraph(page, {
128
+ entities: [
129
+ // Container
130
+ { id: 0, type: 'rect', bounds: { x: 0, y: 0, width: 100, height: 30 }, fill: '#000000' },
131
+ // Three children with irrational widths
132
+ { id: 1, type: 'rect', bounds: { x: 0, y: 0, width: 100/3, height: 30 }, fill: '#FF0000' },
133
+ { id: 2, type: 'rect', bounds: { x: 100/3, y: 0, width: 100/3, height: 30 }, fill: '#00FF00' },
134
+ { id: 3, type: 'rect', bounds: { x: 200/3, y: 0, width: 100/3, height: 30 }, fill: '#0000FF' },
135
+ ],
136
+ constraints: [
137
+ { type: 'adjacent', a: { entityId: 1, edge: 'right' }, b: { entityId: 2, edge: 'left' } },
138
+ { type: 'adjacent', a: { entityId: 2, edge: 'right' }, b: { entityId: 3, edge: 'left' } },
139
+ ],
140
+ containments: [
141
+ { parentId: 0, childIds: [1, 2, 3], axis: 'horizontal' },
142
+ ],
143
+ });
144
+
145
+ // Verify no black pixels (gaps) between colored rectangles
146
+ const hasGaps = detectGaps(pixelBuffer, 100, 30);
147
+ expect(hasGaps).toBe(false);
148
+
149
+ // Hash comparison
150
+ const hash = computeHash(pixelBuffer);
151
+ const goldenHash = loadGoldenHash('adjacent-rectangles');
152
+
153
+ if (goldenHash === null) {
154
+ saveGolden('adjacent-rectangles', pixelBuffer, hash);
155
+ } else {
156
+ expect(hash).toBe(goldenHash);
157
+ }
158
+ });
159
+
160
+ /**
161
+ * Test: Text Rendering (Font Determinism)
162
+ *
163
+ * CRITICAL: Uses embedded WOFF2 font to guarantee cross-platform consistency.
164
+ * Generic font families (monospace, serif) resolve to different fonts per OS.
165
+ */
166
+ test('renders text with deterministic glyph placement', async ({ page }) => {
167
+ await setupDeterministicRenderer(page);
168
+
169
+ // Load embedded font before rendering
170
+ await page.evaluate(async () => {
171
+ // Wait for VS embedded font to load
172
+ await (window as any).__VS_RENDERER__.loadFont('vs-mono');
173
+ await document.fonts.ready;
174
+ });
175
+
176
+ const pixelBuffer = await renderConstraintGraph(page, {
177
+ entities: [
178
+ {
179
+ id: 1,
180
+ type: 'text',
181
+ bounds: { x: 10, y: 10, width: 200, height: 20 },
182
+ content: 'ViewScript',
183
+ font: {
184
+ family: 'vs-mono', // Embedded font for determinism
185
+ size: 16,
186
+ weight: 400,
187
+ },
188
+ fill: '#000000',
189
+ },
190
+ ],
191
+ constraints: [],
192
+ });
193
+
194
+ const hash = computeHash(pixelBuffer);
195
+ const goldenHash = loadGoldenHash(`text-deterministic-${process.platform}`);
196
+
197
+ if (goldenHash === null) {
198
+ saveGolden(`text-deterministic-${process.platform}`, pixelBuffer, hash);
199
+ } else {
200
+ expect(hash).toBe(goldenHash);
201
+ }
202
+ });
203
+ });
204
+
205
+ // =============================================================================
206
+ // Helper Functions
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Setup Playwright page with deterministic wgpu renderer configuration.
211
+ */
212
+ async function setupDeterministicRenderer(page: Page): Promise<void> {
213
+ // Navigate to test harness (follow redirect if needed)
214
+ await page.goto('/test-harness.html', { waitUntil: 'networkidle' });
215
+
216
+ // Inject deterministic configuration
217
+ await page.evaluate((config) => {
218
+ (window as any).__VS_RENDERER_CONFIG__ = config;
219
+ }, WGPU_DETERMINISTIC_CONFIG);
220
+
221
+ // Wait for renderer initialization
222
+ await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
223
+ timeout: 10000,
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Render a constraint graph and return the pixel buffer.
229
+ */
230
+ async function renderConstraintGraph(
231
+ page: Page,
232
+ ir: unknown,
233
+ ): Promise<Uint8Array> {
234
+ const base64 = await page.evaluate(async (constraintGraph) => {
235
+ const renderer = (window as any).__VS_RENDERER__;
236
+
237
+ // Render the constraint graph
238
+ await renderer.render(constraintGraph);
239
+
240
+ // Force flush and extract pixel buffer
241
+ const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
242
+ const ctx = canvas.getContext('2d');
243
+
244
+ if (!ctx) throw new Error('No 2D context');
245
+
246
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
247
+ const buffer = imageData.data;
248
+
249
+ // Convert to base64 in chunks to avoid stack overflow
250
+ let binary = '';
251
+ const chunkSize = 8192;
252
+ for (let i = 0; i < buffer.length; i += chunkSize) {
253
+ const chunk = buffer.subarray(i, Math.min(i + chunkSize, buffer.length));
254
+ binary += String.fromCharCode.apply(null, Array.from(chunk));
255
+ }
256
+ return btoa(binary);
257
+ }, ir);
258
+
259
+ // Decode base64 to Uint8Array
260
+ const binary = atob(base64);
261
+ const bytes = new Uint8Array(binary.length);
262
+ for (let i = 0; i < binary.length; i++) {
263
+ bytes[i] = binary.charCodeAt(i);
264
+ }
265
+ return bytes;
266
+ }
267
+
268
+ /**
269
+ * Compute SHA-256 hash of pixel buffer.
270
+ */
271
+ function computeHash(buffer: Uint8Array): string {
272
+ return crypto.createHash('sha256').update(buffer).digest('hex');
273
+ }
274
+
275
+ /**
276
+ * Load golden hash from file.
277
+ */
278
+ function loadGoldenHash(testName: string): string | null {
279
+ const hashFile = path.join(GOLDEN_DIR, `${testName}.sha256`);
280
+ if (fs.existsSync(hashFile)) {
281
+ return fs.readFileSync(hashFile, 'utf-8').trim();
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * Save golden snapshot and hash.
288
+ */
289
+ function saveGolden(testName: string, buffer: Uint8Array, hash: string): void {
290
+ const pngFile = path.join(GOLDEN_DIR, `${testName}.raw`);
291
+ const hashFile = path.join(GOLDEN_DIR, `${testName}.sha256`);
292
+
293
+ fs.writeFileSync(pngFile, buffer);
294
+ fs.writeFileSync(hashFile, hash);
295
+ }
296
+
297
+ /**
298
+ * Detect gaps between adjacent colored rectangles.
299
+ *
300
+ * Strategy: Scan horizontal lines and detect transitions.
301
+ * A gap exists if we see: [Color A] -> [Black] -> [Color B]
302
+ * The container background is expected to be black, so we only flag
303
+ * black pixels that appear BETWEEN colored regions.
304
+ */
305
+ function detectGaps(buffer: Uint8Array, width: number, height: number): boolean {
306
+ // In RGBA format, check each row for gap patterns
307
+ for (let y = 0; y < height; y++) {
308
+ let inColoredRegion = false;
309
+ let sawBlackAfterColor = false;
310
+
311
+ for (let x = 0; x < width; x++) {
312
+ const offset = (y * width + x) * 4;
313
+ const r = buffer[offset];
314
+ const g = buffer[offset + 1];
315
+ const b = buffer[offset + 2];
316
+ const a = buffer[offset + 3];
317
+
318
+ const isBlack = r === 0 && g === 0 && b === 0 && a === 255;
319
+ const isColored = !isBlack && a === 255;
320
+
321
+ if (isColored) {
322
+ if (sawBlackAfterColor) {
323
+ // Found: [Color] -> [Black] -> [Color] = gap!
324
+ console.warn(`[GAP DETECTED] black pixel between colors at row ${y}, near x=${x}`);
325
+ return true;
326
+ }
327
+ inColoredRegion = true;
328
+ sawBlackAfterColor = false;
329
+ } else if (isBlack && inColoredRegion) {
330
+ sawBlackAfterColor = true;
331
+ }
332
+ }
333
+ }
334
+ return false;
335
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "strict": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src"
10
+ },
11
+ "include": ["src"]
12
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ environment: 'node',
7
+ },
8
+ });