@react-three-dom/playwright 0.1.2 → 0.3.0

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/index.js CHANGED
@@ -1,22 +1,119 @@
1
- import { test as test$1, expect as expect$1 } from '@playwright/test';
1
+ import { test as test$1, expect } from '@playwright/test';
2
2
 
3
3
  // src/fixtures.ts
4
4
 
5
+ // src/diffSnapshots.ts
6
+ var FIELDS_TO_COMPARE = [
7
+ "name",
8
+ "type",
9
+ "testId",
10
+ "visible",
11
+ "position",
12
+ "rotation",
13
+ "scale"
14
+ ];
15
+ function flattenTree(node) {
16
+ const map = /* @__PURE__ */ new Map();
17
+ function walk(n) {
18
+ map.set(n.uuid, n);
19
+ n.children.forEach(walk);
20
+ }
21
+ walk(node);
22
+ return map;
23
+ }
24
+ function valueEqual(a, b) {
25
+ if (a === b) return true;
26
+ if (Array.isArray(a) && Array.isArray(b)) {
27
+ if (a.length !== b.length) return false;
28
+ return a.every((v, i) => valueEqual(v, b[i]));
29
+ }
30
+ return false;
31
+ }
32
+ function diffSnapshots(before, after) {
33
+ const beforeMap = flattenTree(before.tree);
34
+ const afterMap = flattenTree(after.tree);
35
+ const added = [];
36
+ const removed = [];
37
+ const changed = [];
38
+ for (const [uuid, node] of afterMap) {
39
+ if (!beforeMap.has(uuid)) added.push(node);
40
+ }
41
+ for (const [uuid, node] of beforeMap) {
42
+ if (!afterMap.has(uuid)) removed.push(node);
43
+ }
44
+ for (const [uuid, afterNode] of afterMap) {
45
+ const beforeNode = beforeMap.get(uuid);
46
+ if (!beforeNode) continue;
47
+ for (const field of FIELDS_TO_COMPARE) {
48
+ const from = beforeNode[field];
49
+ const to = afterNode[field];
50
+ if (!valueEqual(from, to)) {
51
+ changed.push({ uuid, field, from, to });
52
+ }
53
+ }
54
+ }
55
+ return { added, removed, changed };
56
+ }
57
+
5
58
  // src/waiters.ts
59
+ async function waitForReadyBridge(page, timeout, canvasId) {
60
+ const deadline = Date.now() + timeout;
61
+ const pollMs = 100;
62
+ while (Date.now() < deadline) {
63
+ const state = await page.evaluate((cid) => {
64
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
65
+ if (!api) return { exists: false };
66
+ return {
67
+ exists: true,
68
+ ready: api._ready,
69
+ error: api._error ?? null,
70
+ count: api.getCount()
71
+ };
72
+ }, canvasId ?? null);
73
+ if (state.exists && state.ready) {
74
+ return;
75
+ }
76
+ if (state.exists && !state.ready && state.error) {
77
+ throw new Error(
78
+ `[react-three-dom] Bridge initialization failed: ${state.error}
79
+ The <ThreeDom> component mounted but threw during setup. Check the browser console for the full stack trace.`
80
+ );
81
+ }
82
+ await page.waitForTimeout(pollMs);
83
+ }
84
+ const finalState = await page.evaluate((cid) => {
85
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
86
+ if (!api) return { exists: false, ready: false, error: null };
87
+ return { exists: true, ready: api._ready, error: api._error ?? null };
88
+ }, canvasId ?? null);
89
+ if (finalState.exists && finalState.error) {
90
+ throw new Error(
91
+ `[react-three-dom] Bridge initialization failed: ${finalState.error}
92
+ The <ThreeDom> component mounted but threw during setup.`
93
+ );
94
+ }
95
+ throw new Error(
96
+ `[react-three-dom] Timed out after ${timeout}ms waiting for the bridge to be ready.
97
+ Bridge exists: ${finalState.exists}, ready: ${finalState.ready}.
98
+ Ensure <ThreeDom> is mounted inside your <Canvas> component.`
99
+ );
100
+ }
6
101
  async function waitForSceneReady(page, options = {}) {
7
102
  const {
8
103
  stableChecks = 3,
9
104
  pollIntervalMs = 100,
10
- timeout = 1e4
105
+ timeout = 1e4,
106
+ canvasId
11
107
  } = options;
12
108
  const deadline = Date.now() + timeout;
13
- await page.waitForFunction(() => typeof window.__R3F_DOM__ !== "undefined", void 0, {
14
- timeout
15
- });
109
+ await waitForReadyBridge(page, timeout, canvasId);
16
110
  let lastCount = -1;
17
111
  let stableRuns = 0;
18
112
  while (Date.now() < deadline) {
19
- const count = await page.evaluate(() => window.__R3F_DOM__.getCount());
113
+ const count = await page.evaluate((cid) => {
114
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
115
+ return api.getCount();
116
+ }, canvasId ?? null);
20
117
  if (count === lastCount && count > 0) {
21
118
  stableRuns++;
22
119
  if (stableRuns >= stableChecks) return;
@@ -26,43 +123,76 @@ async function waitForSceneReady(page, options = {}) {
26
123
  lastCount = count;
27
124
  await page.waitForTimeout(pollIntervalMs);
28
125
  }
126
+ const state = await page.evaluate((cid) => {
127
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
128
+ if (!api)
129
+ return { bridgeFound: false, ready: false, error: null, objectCount: 0 };
130
+ return {
131
+ bridgeFound: true,
132
+ ready: api._ready,
133
+ error: api._error ?? null,
134
+ objectCount: api.getCount()
135
+ };
136
+ }, canvasId ?? null);
137
+ const stateLine = state.bridgeFound ? `Bridge found: yes, _ready: ${state.ready}, _error: ${state.error ?? "none"}, objectCount: ${state.objectCount}` : "Bridge found: no (ensure <ThreeDom /> is mounted inside <Canvas> and refresh)";
29
138
  throw new Error(
30
- `waitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`
139
+ `waitForSceneReady timed out after ${timeout}ms. ${stateLine}. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}.`
31
140
  );
32
141
  }
33
142
  async function waitForObject(page, idOrUuid, options = {}) {
34
143
  const {
35
144
  bridgeTimeout = 3e4,
36
145
  objectTimeout = 4e4,
37
- pollIntervalMs = 200
146
+ pollIntervalMs = 200,
147
+ canvasId
38
148
  } = options;
39
- await page.waitForFunction(() => typeof window.__R3F_DOM__ !== "undefined", void 0, {
40
- timeout: bridgeTimeout
41
- });
149
+ await waitForReadyBridge(page, bridgeTimeout, canvasId);
42
150
  const deadline = Date.now() + objectTimeout;
43
151
  while (Date.now() < deadline) {
44
152
  const found = await page.evaluate(
45
- (id) => {
46
- const api = window.__R3F_DOM__;
47
- if (!api) return false;
153
+ ([id, cid]) => {
154
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
155
+ if (!api || !api._ready) return false;
48
156
  return (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
49
157
  },
50
- idOrUuid
158
+ [idOrUuid, canvasId ?? null]
51
159
  );
52
160
  if (found) return;
53
161
  await page.waitForTimeout(pollIntervalMs);
54
162
  }
55
- throw new Error(
56
- `waitForObject("${idOrUuid}") timed out after ${objectTimeout}ms. Is the object rendered with userData.testId or this uuid?`
163
+ const diagnostics = await page.evaluate(
164
+ ([id, cid]) => {
165
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
166
+ if (!api) return { bridgeExists: false, ready: false, count: 0, error: null, suggestions: [] };
167
+ const suggestions = typeof api.fuzzyFind === "function" ? api.fuzzyFind(id, 5).map((m) => ({ testId: m.testId, name: m.name, uuid: m.uuid })) : [];
168
+ return {
169
+ bridgeExists: true,
170
+ ready: api._ready,
171
+ count: api.getCount(),
172
+ error: api._error ?? null,
173
+ suggestions
174
+ };
175
+ },
176
+ [idOrUuid, canvasId ?? null]
57
177
  );
178
+ let msg = `waitForObject("${idOrUuid}") timed out after ${objectTimeout}ms. Bridge: ${diagnostics.bridgeExists ? "exists" : "missing"}, ready: ${diagnostics.ready}, objectCount: ${diagnostics.count}` + (diagnostics.error ? `, error: ${diagnostics.error}` : "") + `. Is the object rendered with userData.testId="${idOrUuid}" or uuid="${idOrUuid}"?`;
179
+ if (diagnostics.suggestions.length > 0) {
180
+ msg += "\nDid you mean:\n" + diagnostics.suggestions.map((s) => {
181
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
182
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
183
+ }).join("\n");
184
+ }
185
+ throw new Error(msg);
58
186
  }
59
187
  async function waitForIdle(page, options = {}) {
60
188
  const {
61
189
  idleFrames = 10,
62
- timeout = 1e4
190
+ timeout = 1e4,
191
+ canvasId
63
192
  } = options;
193
+ await waitForReadyBridge(page, timeout, canvasId);
64
194
  const settled = await page.evaluate(
65
- ([frames, timeoutMs]) => {
195
+ ([frames, timeoutMs, cid]) => {
66
196
  return new Promise((resolve) => {
67
197
  const deadline = Date.now() + timeoutMs;
68
198
  let lastJson = "";
@@ -72,8 +202,17 @@ async function waitForIdle(page, options = {}) {
72
202
  resolve(false);
73
203
  return;
74
204
  }
75
- const snap = window.__R3F_DOM__?.snapshot();
76
- const json = snap ? JSON.stringify(snap.tree) : "";
205
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
206
+ if (!api || !api._ready) {
207
+ if (api && api._error) {
208
+ resolve(`Bridge error: ${api._error}`);
209
+ return;
210
+ }
211
+ requestAnimationFrame(check);
212
+ return;
213
+ }
214
+ const snap = api.snapshot();
215
+ const json = JSON.stringify(snap.tree);
77
216
  if (json === lastJson && json !== "") {
78
217
  stableCount++;
79
218
  if (stableCount >= frames) {
@@ -89,140 +228,792 @@ async function waitForIdle(page, options = {}) {
89
228
  requestAnimationFrame(check);
90
229
  });
91
230
  },
92
- [idleFrames, timeout]
231
+ [idleFrames, timeout, canvasId ?? null]
93
232
  );
233
+ if (typeof settled === "string") {
234
+ throw new Error(`waitForIdle failed: ${settled}`);
235
+ }
94
236
  if (!settled) {
95
237
  throw new Error(`waitForIdle timed out after ${timeout}ms`);
96
238
  }
97
239
  }
98
- async function click(page, idOrUuid) {
99
- await page.evaluate((id) => {
100
- const api = window.__R3F_DOM__;
101
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
240
+ async function waitForNewObject(page, options = {}) {
241
+ const {
242
+ type,
243
+ nameContains,
244
+ pollIntervalMs = 100,
245
+ timeout = 1e4,
246
+ canvasId
247
+ } = options;
248
+ const baselineUuids = await page.evaluate((cid) => {
249
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
250
+ if (!api) return [];
251
+ const snap = api.snapshot();
252
+ const uuids = [];
253
+ function collect(node) {
254
+ uuids.push(node.uuid);
255
+ for (const child of node.children) {
256
+ collect(child);
257
+ }
258
+ }
259
+ collect(snap.tree);
260
+ return uuids;
261
+ }, canvasId ?? null);
262
+ const deadline = Date.now() + timeout;
263
+ while (Date.now() < deadline) {
264
+ await page.waitForTimeout(pollIntervalMs);
265
+ const result = await page.evaluate(
266
+ ([filterType, filterName, knownUuids, cid]) => {
267
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
268
+ if (!api) return null;
269
+ const snap = api.snapshot();
270
+ const known = new Set(knownUuids);
271
+ const newObjects = [];
272
+ function scan(node) {
273
+ if (!known.has(node.uuid)) {
274
+ const typeMatch = !filterType || node.type === filterType;
275
+ const nameMatch = !filterName || node.name.includes(filterName);
276
+ if (typeMatch && nameMatch) {
277
+ newObjects.push({
278
+ uuid: node.uuid,
279
+ name: node.name,
280
+ type: node.type,
281
+ visible: node.visible,
282
+ testId: node.testId,
283
+ position: node.position,
284
+ rotation: node.rotation,
285
+ scale: node.scale
286
+ });
287
+ }
288
+ }
289
+ for (const child of node.children) {
290
+ scan(child);
291
+ }
292
+ }
293
+ scan(snap.tree);
294
+ if (newObjects.length === 0) return null;
295
+ return {
296
+ newObjects,
297
+ newUuids: newObjects.map((o) => o.uuid),
298
+ count: newObjects.length
299
+ };
300
+ },
301
+ [type ?? null, nameContains ?? null, baselineUuids, canvasId ?? null]
302
+ );
303
+ if (result) {
304
+ return result;
305
+ }
306
+ }
307
+ const filterDesc = [
308
+ type ? `type="${type}"` : null,
309
+ nameContains ? `nameContains="${nameContains}"` : null
310
+ ].filter(Boolean).join(", ");
311
+ throw new Error(
312
+ `waitForNewObject timed out after ${timeout}ms. No new objects appeared${filterDesc ? ` matching ${filterDesc}` : ""}. Baseline had ${baselineUuids.length} objects.`
313
+ );
314
+ }
315
+ async function waitForObjectRemoved(page, idOrUuid, options = {}) {
316
+ const {
317
+ bridgeTimeout = 3e4,
318
+ pollIntervalMs = 100,
319
+ timeout = 1e4,
320
+ canvasId
321
+ } = options;
322
+ await waitForReadyBridge(page, bridgeTimeout, canvasId);
323
+ const deadline = Date.now() + timeout;
324
+ while (Date.now() < deadline) {
325
+ const stillPresent = await page.evaluate(([id, cid]) => {
326
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
327
+ if (!api) return true;
328
+ const meta = api.getByTestId(id) ?? api.getByUuid(id);
329
+ return meta !== null;
330
+ }, [idOrUuid, canvasId ?? null]);
331
+ if (!stillPresent) return;
332
+ await page.waitForTimeout(pollIntervalMs);
333
+ }
334
+ throw new Error(
335
+ `waitForObjectRemoved timed out after ${timeout}ms. Object "${idOrUuid}" is still in the scene.`
336
+ );
337
+ }
338
+
339
+ // src/interactions.ts
340
+ var DEFAULT_AUTO_WAIT_TIMEOUT = 5e3;
341
+ var AUTO_WAIT_POLL_MS = 100;
342
+ async function autoWaitForObject(page, idOrUuid, timeout = DEFAULT_AUTO_WAIT_TIMEOUT, canvasId) {
343
+ const deadline = Date.now() + timeout;
344
+ while (Date.now() < deadline) {
345
+ const state = await page.evaluate(
346
+ ([id, cid]) => {
347
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
348
+ if (!api) return { bridge: "missing" };
349
+ if (!api._ready) {
350
+ return {
351
+ bridge: "not-ready",
352
+ error: api._error ?? null
353
+ };
354
+ }
355
+ const found = (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
356
+ return { bridge: "ready", found };
357
+ },
358
+ [idOrUuid, canvasId ?? null]
359
+ );
360
+ if (state.bridge === "ready" && state.found) {
361
+ return;
362
+ }
363
+ if (state.bridge === "not-ready" && state.error) {
364
+ throw new Error(
365
+ `[react-three-dom] Bridge initialization failed: ${state.error}
366
+ Cannot perform interaction on "${idOrUuid}"${canvasId ? ` (canvas: "${canvasId}")` : ""}.`
367
+ );
368
+ }
369
+ await page.waitForTimeout(AUTO_WAIT_POLL_MS);
370
+ }
371
+ const finalState = await page.evaluate(
372
+ ([id, cid]) => {
373
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
374
+ if (!api) return { bridge: false, ready: false, count: 0, error: null, found: false, suggestions: [] };
375
+ const suggestions = typeof api.fuzzyFind === "function" ? api.fuzzyFind(id, 5).map((m) => ({ testId: m.testId, name: m.name, uuid: m.uuid })) : [];
376
+ return {
377
+ bridge: true,
378
+ ready: api._ready,
379
+ count: api.getCount(),
380
+ error: api._error ?? null,
381
+ found: (api.getByTestId(id) ?? api.getByUuid(id)) !== null,
382
+ suggestions
383
+ };
384
+ },
385
+ [idOrUuid, canvasId ?? null]
386
+ );
387
+ if (!finalState.bridge) {
388
+ throw new Error(
389
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not found${canvasId ? ` (canvas: "${canvasId}")` : ""}.
390
+ Ensure <ThreeDom${canvasId ? ` canvasId="${canvasId}"` : ""}> is mounted inside your <Canvas> component.`
391
+ );
392
+ }
393
+ let msg = `[react-three-dom] Auto-wait timed out after ${timeout}ms: object "${idOrUuid}" not found${canvasId ? ` (canvas: "${canvasId}")` : ""}.
394
+ Bridge: ready=${finalState.ready}, objectCount=${finalState.count}` + (finalState.error ? `, error=${finalState.error}` : "") + `.
395
+ Ensure the object has userData.testId="${idOrUuid}" or uuid="${idOrUuid}".`;
396
+ if (finalState.suggestions.length > 0) {
397
+ msg += "\nDid you mean:\n" + finalState.suggestions.map((s) => {
398
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
399
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
400
+ }).join("\n");
401
+ }
402
+ throw new Error(msg);
403
+ }
404
+ async function autoWaitForBridge(page, timeout = DEFAULT_AUTO_WAIT_TIMEOUT, canvasId) {
405
+ const deadline = Date.now() + timeout;
406
+ while (Date.now() < deadline) {
407
+ const state = await page.evaluate((cid) => {
408
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
409
+ if (!api) return { exists: false };
410
+ return {
411
+ exists: true,
412
+ ready: api._ready,
413
+ error: api._error ?? null
414
+ };
415
+ }, canvasId ?? null);
416
+ if (state.exists && state.ready) return;
417
+ if (state.exists && !state.ready && state.error) {
418
+ throw new Error(
419
+ `[react-three-dom] Bridge initialization failed: ${state.error}`
420
+ );
421
+ }
422
+ await page.waitForTimeout(AUTO_WAIT_POLL_MS);
423
+ }
424
+ throw new Error(
425
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not ready${canvasId ? ` (canvas: "${canvasId}")` : ""}.
426
+ Ensure <ThreeDom${canvasId ? ` canvasId="${canvasId}"` : ""}> is mounted inside your <Canvas> component.`
427
+ );
428
+ }
429
+ async function click(page, idOrUuid, timeout, canvasId) {
430
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
431
+ await page.evaluate(([id, cid]) => {
432
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
102
433
  api.click(id);
103
- }, idOrUuid);
434
+ }, [idOrUuid, canvasId ?? null]);
104
435
  }
105
- async function doubleClick(page, idOrUuid) {
106
- await page.evaluate((id) => {
107
- const api = window.__R3F_DOM__;
108
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
436
+ async function doubleClick(page, idOrUuid, timeout, canvasId) {
437
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
438
+ await page.evaluate(([id, cid]) => {
439
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
109
440
  api.doubleClick(id);
110
- }, idOrUuid);
441
+ }, [idOrUuid, canvasId ?? null]);
111
442
  }
112
- async function contextMenu(page, idOrUuid) {
113
- await page.evaluate((id) => {
114
- const api = window.__R3F_DOM__;
115
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
443
+ async function contextMenu(page, idOrUuid, timeout, canvasId) {
444
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
445
+ await page.evaluate(([id, cid]) => {
446
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
116
447
  api.contextMenu(id);
117
- }, idOrUuid);
448
+ }, [idOrUuid, canvasId ?? null]);
118
449
  }
119
- async function hover(page, idOrUuid) {
120
- await page.evaluate((id) => {
121
- const api = window.__R3F_DOM__;
122
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
450
+ async function hover(page, idOrUuid, timeout, canvasId) {
451
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
452
+ await page.evaluate(([id, cid]) => {
453
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
123
454
  api.hover(id);
124
- }, idOrUuid);
455
+ }, [idOrUuid, canvasId ?? null]);
456
+ }
457
+ async function unhover(page, timeout, canvasId) {
458
+ await autoWaitForBridge(page, timeout, canvasId);
459
+ await page.evaluate((cid) => {
460
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
461
+ api.unhover();
462
+ }, canvasId ?? null);
125
463
  }
126
- async function drag(page, idOrUuid, delta) {
464
+ async function drag(page, idOrUuid, delta, timeout, canvasId) {
465
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
127
466
  await page.evaluate(
128
- ([id, d]) => {
129
- const api = window.__R3F_DOM__;
130
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
131
- api.drag(id, d);
467
+ async ([id, d, cid]) => {
468
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
469
+ await api.drag(id, d);
132
470
  },
133
- [idOrUuid, delta]
471
+ [idOrUuid, delta, canvasId ?? null]
134
472
  );
135
473
  }
136
- async function wheel(page, idOrUuid, options) {
474
+ async function wheel(page, idOrUuid, options, timeout, canvasId) {
475
+ await autoWaitForObject(page, idOrUuid, timeout, canvasId);
137
476
  await page.evaluate(
138
- ([id, opts]) => {
139
- const api = window.__R3F_DOM__;
140
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
477
+ ([id, opts, cid]) => {
478
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
141
479
  api.wheel(id, opts);
142
480
  },
143
- [idOrUuid, options]
481
+ [idOrUuid, options, canvasId ?? null]
144
482
  );
145
483
  }
146
- async function pointerMiss(page) {
147
- await page.evaluate(() => {
148
- const api = window.__R3F_DOM__;
149
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
484
+ async function pointerMiss(page, timeout, canvasId) {
485
+ await autoWaitForBridge(page, timeout, canvasId);
486
+ await page.evaluate((cid) => {
487
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
150
488
  api.pointerMiss();
151
- });
489
+ }, canvasId ?? null);
490
+ }
491
+ async function drawPathOnCanvas(page, points, options, timeout, canvasId) {
492
+ await autoWaitForBridge(page, timeout, canvasId);
493
+ return page.evaluate(
494
+ async ([pts, opts, cid]) => {
495
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
496
+ return api.drawPath(pts, opts ?? void 0);
497
+ },
498
+ [points, options ?? null, canvasId ?? null]
499
+ );
152
500
  }
501
+ async function getCameraState(page, timeout, canvasId) {
502
+ await autoWaitForBridge(page, timeout, canvasId);
503
+ return page.evaluate((cid) => {
504
+ const api = cid ? window.__R3F_DOM_INSTANCES__[cid] : window.__R3F_DOM__;
505
+ return api.getCameraState();
506
+ }, canvasId ?? null);
507
+ }
508
+
509
+ // src/reporter.ts
510
+ var RESET = "\x1B[0m";
511
+ var BOLD = "\x1B[1m";
512
+ var DIM = "\x1B[2m";
513
+ var GREEN = "\x1B[32m";
514
+ var RED = "\x1B[31m";
515
+ var YELLOW = "\x1B[33m";
516
+ var CYAN = "\x1B[36m";
517
+ var MAGENTA = "\x1B[35m";
518
+ var TAG = `${CYAN}[r3f-dom]${RESET}`;
519
+ function ok(msg) {
520
+ return `${TAG} ${GREEN}\u2713${RESET} ${msg}`;
521
+ }
522
+ function fail(msg) {
523
+ return `${TAG} ${RED}\u2717${RESET} ${msg}`;
524
+ }
525
+ function warn(msg) {
526
+ return `${TAG} ${YELLOW}\u26A0${RESET} ${msg}`;
527
+ }
528
+ function info(msg) {
529
+ return `${TAG} ${DIM}${msg}${RESET}`;
530
+ }
531
+ function heading(msg) {
532
+ return `
533
+ ${TAG} ${BOLD}${MAGENTA}${msg}${RESET}`;
534
+ }
535
+ var R3FReporter = class {
536
+ constructor(_page, enabled = true, canvasId) {
537
+ this._page = _page;
538
+ this._enabled = true;
539
+ this._enabled = enabled;
540
+ this._canvasId = canvasId;
541
+ }
542
+ // -----------------------------------------------------------------------
543
+ // Lifecycle events
544
+ // -----------------------------------------------------------------------
545
+ logBridgeWaiting() {
546
+ if (!this._enabled) return;
547
+ console.log(info("Waiting for bridge (window.__R3F_DOM__)..."));
548
+ }
549
+ logBridgeConnected(diag) {
550
+ if (!this._enabled) return;
551
+ if (diag) {
552
+ console.log(ok(`Bridge connected \u2014 v${diag.version}, ${diag.objectCount} objects, ${diag.meshCount} meshes`));
553
+ console.log(info(` Canvas: ${diag.canvasWidth}\xD7${diag.canvasHeight} GPU: ${diag.webglRenderer}`));
554
+ console.log(info(` DOM nodes: ${diag.materializedDomNodes}/${diag.maxDomNodes} Dirty queue: ${diag.dirtyQueueSize}`));
555
+ } else {
556
+ console.log(ok("Bridge connected"));
557
+ }
558
+ }
559
+ logBridgeError(error) {
560
+ if (!this._enabled) return;
561
+ console.log(fail(`Bridge error: ${error}`));
562
+ }
563
+ logSceneReady(objectCount) {
564
+ if (!this._enabled) return;
565
+ console.log(ok(`Scene ready \u2014 ${objectCount} objects stabilized`));
566
+ }
567
+ logObjectFound(idOrUuid, type, name) {
568
+ if (!this._enabled) return;
569
+ const label = name ? `"${name}" (${type})` : type;
570
+ console.log(ok(`Object found: "${idOrUuid}" \u2192 ${label}`));
571
+ }
572
+ logObjectNotFound(idOrUuid, suggestions) {
573
+ if (!this._enabled) return;
574
+ console.log(fail(`Object not found: "${idOrUuid}"`));
575
+ if (suggestions && suggestions.length > 0) {
576
+ console.log(warn("Did you mean:"));
577
+ for (const s of suggestions.slice(0, 5)) {
578
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
579
+ console.log(info(` \u2192 ${s.name || "(unnamed)"} [${id}]`));
580
+ }
581
+ }
582
+ }
583
+ // -----------------------------------------------------------------------
584
+ // Interaction events
585
+ // -----------------------------------------------------------------------
586
+ logInteraction(action, idOrUuid, extra) {
587
+ if (!this._enabled) return;
588
+ const suffix = extra ? ` ${DIM}${extra}${RESET}` : "";
589
+ console.log(info(`${action}("${idOrUuid}")${suffix}`));
590
+ }
591
+ logInteractionDone(action, idOrUuid, durationMs) {
592
+ if (!this._enabled) return;
593
+ console.log(ok(`${action}("${idOrUuid}") \u2014 ${durationMs}ms`));
594
+ }
595
+ // -----------------------------------------------------------------------
596
+ // Assertion context
597
+ // -----------------------------------------------------------------------
598
+ logAssertionFailure(matcherName, id, detail, diag) {
599
+ if (!this._enabled) return;
600
+ console.log(heading(`Assertion failed: ${matcherName}("${id}")`));
601
+ console.log(fail(detail));
602
+ if (diag) {
603
+ this._printDiagnosticsSummary(diag);
604
+ }
605
+ }
606
+ // -----------------------------------------------------------------------
607
+ // Full diagnostics dump
608
+ // -----------------------------------------------------------------------
609
+ async logDiagnostics() {
610
+ if (!this._enabled) return;
611
+ const diag = await this.fetchDiagnostics();
612
+ if (!diag) {
613
+ console.log(fail("Cannot fetch diagnostics \u2014 bridge not available"));
614
+ return;
615
+ }
616
+ console.log(heading("Bridge Diagnostics"));
617
+ this._printDiagnosticsFull(diag);
618
+ }
619
+ async fetchDiagnostics() {
620
+ return this._page.evaluate((cid) => {
621
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
622
+ if (!api || typeof api.getDiagnostics !== "function") return null;
623
+ return api.getDiagnostics();
624
+ }, this._canvasId ?? null);
625
+ }
626
+ async fetchFuzzyMatches(query, limit = 5) {
627
+ return this._page.evaluate(
628
+ ({ q, lim, cid }) => {
629
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
630
+ if (!api || typeof api.fuzzyFind !== "function") return [];
631
+ return api.fuzzyFind(q, lim);
632
+ },
633
+ { q: query, lim: limit, cid: this._canvasId ?? null }
634
+ );
635
+ }
636
+ // -----------------------------------------------------------------------
637
+ // Private formatting
638
+ // -----------------------------------------------------------------------
639
+ _printDiagnosticsSummary(d) {
640
+ console.log(info(` Bridge: v${d.version} ready=${d.ready}${d.error ? ` error="${d.error}"` : ""}`));
641
+ console.log(info(` Scene: ${d.objectCount} objects (${d.meshCount} meshes, ${d.groupCount} groups, ${d.lightCount} lights)`));
642
+ console.log(info(` Canvas: ${d.canvasWidth}\xD7${d.canvasHeight} GPU: ${d.webglRenderer}`));
643
+ }
644
+ _printDiagnosticsFull(d) {
645
+ const status = d.ready ? `${GREEN}READY${RESET}` : `${RED}NOT READY${RESET}`;
646
+ console.log(` ${BOLD}Status:${RESET} ${status} v${d.version}`);
647
+ if (d.error) console.log(` ${BOLD}Error:${RESET} ${RED}${d.error}${RESET}`);
648
+ console.log(` ${BOLD}Objects:${RESET} ${d.objectCount} total`);
649
+ console.log(` ${d.meshCount} meshes, ${d.groupCount} groups, ${d.lightCount} lights, ${d.cameraCount} cameras`);
650
+ console.log(` ${BOLD}DOM:${RESET} ${d.materializedDomNodes}/${d.maxDomNodes} materialized`);
651
+ console.log(` ${BOLD}Canvas:${RESET} ${d.canvasWidth}\xD7${d.canvasHeight}`);
652
+ console.log(` ${BOLD}GPU:${RESET} ${d.webglRenderer}`);
653
+ console.log(` ${BOLD}Dirty:${RESET} ${d.dirtyQueueSize} queued updates`);
654
+ }
655
+ };
153
656
 
154
657
  // src/fixtures.ts
155
- var R3FFixture = class {
156
- constructor(_page) {
658
+ var R3FFixture = class _R3FFixture {
659
+ constructor(_page, opts) {
157
660
  this._page = _page;
661
+ this._debugListenerAttached = false;
662
+ this.canvasId = opts?.canvasId;
663
+ this._reporter = new R3FReporter(_page, opts?.report !== false, this.canvasId);
664
+ if (opts?.debug) {
665
+ this._attachDebugListener();
666
+ }
158
667
  }
159
668
  /** The underlying Playwright Page. */
160
669
  get page() {
161
670
  return this._page;
162
671
  }
672
+ /** Access the reporter for custom diagnostic logging. */
673
+ get reporter() {
674
+ return this._reporter;
675
+ }
676
+ /**
677
+ * Create a scoped fixture targeting a specific canvas instance.
678
+ * All queries, interactions, and assertions on the returned fixture
679
+ * will use `window.__R3F_DOM_INSTANCES__[canvasId]` instead of
680
+ * `window.__R3F_DOM__`.
681
+ *
682
+ * @example
683
+ * ```typescript
684
+ * const mainR3f = r3f.forCanvas('main-viewport');
685
+ * const minimapR3f = r3f.forCanvas('minimap');
686
+ * await mainR3f.click('building-42');
687
+ * await expect(minimapR3f).toExist('building-42-marker');
688
+ * ```
689
+ */
690
+ forCanvas(canvasId) {
691
+ return new _R3FFixture(this._page, {
692
+ canvasId,
693
+ report: this._reporter !== null
694
+ });
695
+ }
696
+ /**
697
+ * List all active canvas IDs registered on the page.
698
+ * Returns an empty array if only the default (unnamed) bridge is active.
699
+ */
700
+ async getCanvasIds() {
701
+ return this._page.evaluate(() => {
702
+ const instances = window.__R3F_DOM_INSTANCES__;
703
+ return instances ? Object.keys(instances) : [];
704
+ });
705
+ }
706
+ // -----------------------------------------------------------------------
707
+ // Debug logging
708
+ // -----------------------------------------------------------------------
709
+ /**
710
+ * Enable debug logging. Turns on `window.__R3F_DOM_DEBUG__` in the browser
711
+ * and forwards all `[r3f-dom:*]` console messages to the Node.js test terminal.
712
+ *
713
+ * Call before `page.goto()` to capture setup logs, or after to capture
714
+ * interaction logs.
715
+ */
716
+ async enableDebug() {
717
+ this._attachDebugListener();
718
+ await this._page.evaluate(() => {
719
+ window.__R3F_DOM_DEBUG__ = true;
720
+ });
721
+ }
722
+ _attachDebugListener() {
723
+ if (this._debugListenerAttached) return;
724
+ this._debugListenerAttached = true;
725
+ this._page.on("console", (msg) => {
726
+ const text = msg.text();
727
+ if (text.startsWith("[r3f-dom:")) {
728
+ console.log(text);
729
+ }
730
+ });
731
+ }
163
732
  // -----------------------------------------------------------------------
164
733
  // Queries
165
734
  // -----------------------------------------------------------------------
166
735
  /** Get object metadata by testId or uuid. Returns null if not found. */
167
736
  async getObject(idOrUuid) {
168
- return this._page.evaluate((id) => {
169
- const api = window.__R3F_DOM__;
737
+ return this._page.evaluate(([id, cid]) => {
738
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
170
739
  if (!api) return null;
171
740
  return api.getByTestId(id) ?? api.getByUuid(id) ?? null;
172
- }, idOrUuid);
741
+ }, [idOrUuid, this.canvasId ?? null]);
742
+ }
743
+ /** Get object metadata by testId (userData.testId). Returns null if not found. */
744
+ async getByTestId(testId) {
745
+ return this._page.evaluate(([id, cid]) => {
746
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
747
+ return api ? api.getByTestId(id) : null;
748
+ }, [testId, this.canvasId ?? null]);
749
+ }
750
+ /** Get object metadata by UUID. Returns null if not found. */
751
+ async getByUuid(uuid) {
752
+ return this._page.evaluate(([u, cid]) => {
753
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
754
+ return api ? api.getByUuid(u) : null;
755
+ }, [uuid, this.canvasId ?? null]);
756
+ }
757
+ /** Get all objects with the given name (names are not unique in Three.js). */
758
+ async getByName(name) {
759
+ return this._page.evaluate(([n, cid]) => {
760
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
761
+ return api ? api.getByName(n) : [];
762
+ }, [name, this.canvasId ?? null]);
173
763
  }
174
- /** Get heavy inspection data (Tier 2) by testId or uuid. */
175
- async inspect(idOrUuid) {
176
- return this._page.evaluate((id) => {
177
- const api = window.__R3F_DOM__;
764
+ /** Get direct children of an object by testId or uuid. */
765
+ async getChildren(idOrUuid) {
766
+ return this._page.evaluate(([id, cid]) => {
767
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
768
+ return api ? api.getChildren(id) : [];
769
+ }, [idOrUuid, this.canvasId ?? null]);
770
+ }
771
+ /** Get parent of an object by testId or uuid. Returns null if root or not found. */
772
+ async getParent(idOrUuid) {
773
+ return this._page.evaluate(([id, cid]) => {
774
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
775
+ return api ? api.getParent(id) : null;
776
+ }, [idOrUuid, this.canvasId ?? null]);
777
+ }
778
+ /** Get heavy inspection data (Tier 2) by testId or uuid. Pass { includeGeometryData: true } to include vertex positions and triangle indices. */
779
+ async inspect(idOrUuid, options) {
780
+ return this._page.evaluate(
781
+ ({ id, opts, cid }) => {
782
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
783
+ if (!api) return null;
784
+ return api.inspect(id, opts);
785
+ },
786
+ { id: idOrUuid, opts: options, cid: this.canvasId ?? null }
787
+ );
788
+ }
789
+ /**
790
+ * Get world-space position [x, y, z] of an object (from its world matrix).
791
+ * Use for nested objects where local position differs from world position.
792
+ */
793
+ async getWorldPosition(idOrUuid) {
794
+ return this._page.evaluate(([id, cid]) => {
795
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
178
796
  if (!api) return null;
179
- return api.inspect(id);
180
- }, idOrUuid);
797
+ const insp = api.inspect(id);
798
+ if (!insp?.worldMatrix || insp.worldMatrix.length < 15) return null;
799
+ const m = insp.worldMatrix;
800
+ return [m[12], m[13], m[14]];
801
+ }, [idOrUuid, this.canvasId ?? null]);
181
802
  }
182
803
  /** Take a full scene snapshot. */
183
804
  async snapshot() {
184
- return this._page.evaluate(() => {
185
- const api = window.__R3F_DOM__;
805
+ return this._page.evaluate((cid) => {
806
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
186
807
  return api ? api.snapshot() : null;
187
- });
808
+ }, this.canvasId ?? null);
809
+ }
810
+ /**
811
+ * Compare two scene snapshots: returns added nodes, removed nodes, and
812
+ * property changes (name, type, testId, visible, position, rotation, scale).
813
+ * Use after taking snapshots before/after an action to assert on scene changes.
814
+ */
815
+ diffSnapshots(before, after) {
816
+ return diffSnapshots(before, after);
817
+ }
818
+ /**
819
+ * Run an async action and return how many objects were added and removed
820
+ * compared to before the action. Uses snapshots before/after so add and
821
+ * remove are both counted correctly when both happen.
822
+ */
823
+ async trackObjectCount(action) {
824
+ const before = await this.snapshot();
825
+ if (!before) throw new Error("trackObjectCount: no snapshot before (bridge not ready?)");
826
+ await action();
827
+ const after = await this.snapshot();
828
+ if (!after) throw new Error("trackObjectCount: no snapshot after (bridge not ready?)");
829
+ const diff = diffSnapshots(before, after);
830
+ return { added: diff.added.length, removed: diff.removed.length };
188
831
  }
189
832
  /** Get the total number of tracked objects. */
190
833
  async getCount() {
191
- return this._page.evaluate(() => {
192
- const api = window.__R3F_DOM__;
834
+ return this._page.evaluate((cid) => {
835
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
193
836
  return api ? api.getCount() : 0;
194
- });
837
+ }, this.canvasId ?? null);
838
+ }
839
+ /**
840
+ * Return a Playwright locator for the R3F canvas element the bridge is attached to.
841
+ * The canvas has `data-r3f-canvas` set by the bridge (value is the canvasId or "true").
842
+ */
843
+ getCanvasLocator() {
844
+ if (this.canvasId) {
845
+ return this._page.locator(`[data-r3f-canvas="${this.canvasId}"]`);
846
+ }
847
+ return this._page.locator("[data-r3f-canvas]");
848
+ }
849
+ /**
850
+ * Get all objects of a given Three.js type (e.g. "Mesh", "Group", "Line").
851
+ */
852
+ async getByType(type) {
853
+ return this._page.evaluate(([t, cid]) => {
854
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
855
+ return api ? api.getByType(t) : [];
856
+ }, [type, this.canvasId ?? null]);
857
+ }
858
+ /**
859
+ * Get all objects with a given geometry type (e.g. "BoxGeometry", "BufferGeometry").
860
+ */
861
+ async getByGeometryType(type) {
862
+ return this._page.evaluate(([t, cid]) => {
863
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
864
+ return api ? api.getByGeometryType(t) : [];
865
+ }, [type, this.canvasId ?? null]);
866
+ }
867
+ /**
868
+ * Get all objects with a given material type (e.g. "MeshStandardMaterial").
869
+ */
870
+ async getByMaterialType(type) {
871
+ return this._page.evaluate(([t, cid]) => {
872
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
873
+ return api ? api.getByMaterialType(t) : [];
874
+ }, [type, this.canvasId ?? null]);
875
+ }
876
+ /**
877
+ * Get objects that have a specific userData key (and optionally matching value).
878
+ */
879
+ async getByUserData(key, value) {
880
+ return this._page.evaluate(({ k, v, cid }) => {
881
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
882
+ return api ? api.getByUserData(k, v) : [];
883
+ }, { k: key, v: value, cid: this.canvasId ?? null });
884
+ }
885
+ /**
886
+ * Count objects of a given Three.js type.
887
+ */
888
+ async getCountByType(type) {
889
+ return this._page.evaluate(([t, cid]) => {
890
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
891
+ return api ? api.getCountByType(t) : 0;
892
+ }, [type, this.canvasId ?? null]);
893
+ }
894
+ /**
895
+ * Batch lookup: get metadata for multiple objects by testId or uuid in a
896
+ * single browser round-trip.
897
+ */
898
+ async getObjects(ids) {
899
+ return this._page.evaluate(([idList, cid]) => {
900
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
901
+ if (!api) {
902
+ const result = {};
903
+ for (const id of idList) result[id] = null;
904
+ return result;
905
+ }
906
+ return api.getObjects(idList);
907
+ }, [ids, this.canvasId ?? null]);
908
+ }
909
+ /**
910
+ * Log the scene tree to the test terminal for debugging.
911
+ *
912
+ * Prints a human-readable tree like:
913
+ * ```
914
+ * Scene "root"
915
+ * ├─ Mesh "chair-primary" [testId: chair-primary] visible
916
+ * │ └─ BoxGeometry
917
+ * ├─ DirectionalLight "sun-light" [testId: sun-light] visible
918
+ * └─ Group "furniture"
919
+ * ├─ Mesh "table-top" [testId: table-top] visible
920
+ * └─ Mesh "vase" [testId: vase] visible
921
+ * ```
922
+ */
923
+ async logScene() {
924
+ const snap = await this.snapshot();
925
+ if (!snap) {
926
+ console.log("[r3f-dom] logScene: no scene snapshot available (bridge not ready?)");
927
+ return;
928
+ }
929
+ const lines = formatSceneTree(snap.tree);
930
+ console.log(`
931
+ [r3f-dom] Scene tree (${snap.objectCount} objects):
932
+ ${lines}
933
+ `);
195
934
  }
196
935
  // -----------------------------------------------------------------------
197
936
  // Interactions
198
937
  // -----------------------------------------------------------------------
199
- /** Click a 3D object by testId or uuid. */
200
- async click(idOrUuid) {
201
- return click(this._page, idOrUuid);
938
+ /**
939
+ * Click a 3D object by testId or uuid.
940
+ * Auto-waits for the bridge and the object to exist before clicking.
941
+ * @param timeout Optional auto-wait timeout in ms. Default: 5000
942
+ */
943
+ async click(idOrUuid, timeout) {
944
+ return click(this._page, idOrUuid, timeout, this.canvasId);
945
+ }
946
+ /**
947
+ * Double-click a 3D object by testId or uuid.
948
+ * Auto-waits for the object to exist.
949
+ */
950
+ async doubleClick(idOrUuid, timeout) {
951
+ return doubleClick(this._page, idOrUuid, timeout, this.canvasId);
202
952
  }
203
- /** Double-click a 3D object by testId or uuid. */
204
- async doubleClick(idOrUuid) {
205
- return doubleClick(this._page, idOrUuid);
953
+ /**
954
+ * Right-click / context-menu a 3D object by testId or uuid.
955
+ * Auto-waits for the object to exist.
956
+ */
957
+ async contextMenu(idOrUuid, timeout) {
958
+ return contextMenu(this._page, idOrUuid, timeout, this.canvasId);
959
+ }
960
+ /**
961
+ * Hover over a 3D object by testId or uuid.
962
+ * Auto-waits for the object to exist.
963
+ */
964
+ async hover(idOrUuid, timeout) {
965
+ return hover(this._page, idOrUuid, timeout, this.canvasId);
966
+ }
967
+ /**
968
+ * Unhover / pointer-leave — resets hover state by moving pointer off-canvas.
969
+ * Auto-waits for the bridge to be ready.
970
+ */
971
+ async unhover(timeout) {
972
+ return unhover(this._page, timeout, this.canvasId);
206
973
  }
207
- /** Right-click / context-menu a 3D object by testId or uuid. */
208
- async contextMenu(idOrUuid) {
209
- return contextMenu(this._page, idOrUuid);
974
+ /**
975
+ * Drag a 3D object with a world-space delta vector.
976
+ * Auto-waits for the object to exist.
977
+ */
978
+ async drag(idOrUuid, delta, timeout) {
979
+ return drag(this._page, idOrUuid, delta, timeout, this.canvasId);
210
980
  }
211
- /** Hover over a 3D object by testId or uuid. */
212
- async hover(idOrUuid) {
213
- return hover(this._page, idOrUuid);
981
+ /**
982
+ * Dispatch a wheel/scroll event on a 3D object.
983
+ * Auto-waits for the object to exist.
984
+ */
985
+ async wheel(idOrUuid, options, timeout) {
986
+ return wheel(this._page, idOrUuid, options, timeout, this.canvasId);
214
987
  }
215
- /** Drag a 3D object with a world-space delta vector. */
216
- async drag(idOrUuid, delta) {
217
- return drag(this._page, idOrUuid, delta);
988
+ /**
989
+ * Click empty space to trigger onPointerMissed handlers.
990
+ * Auto-waits for the bridge to be ready.
991
+ */
992
+ async pointerMiss(timeout) {
993
+ return pointerMiss(this._page, timeout, this.canvasId);
218
994
  }
219
- /** Dispatch a wheel/scroll event on a 3D object. */
220
- async wheel(idOrUuid, options) {
221
- return wheel(this._page, idOrUuid, options);
995
+ /**
996
+ * Draw a freeform path on the canvas. Dispatches pointerdown → N × pointermove → pointerup.
997
+ * Designed for canvas drawing/annotation/whiteboard apps.
998
+ * Auto-waits for the bridge to be ready.
999
+ *
1000
+ * @param points Array of screen-space points (min 2). { x, y } in CSS pixels relative to canvas.
1001
+ * @param options Drawing options (stepDelayMs, pointerType, clickAtEnd)
1002
+ * @param timeout Optional auto-wait timeout in ms. Default: 5000
1003
+ * @returns { eventCount, pointCount }
1004
+ */
1005
+ async drawPath(points, options, timeout) {
1006
+ return drawPathOnCanvas(this._page, points, options, timeout, this.canvasId);
222
1007
  }
223
- /** Click empty space to trigger onPointerMissed handlers. */
224
- async pointerMiss() {
225
- return pointerMiss(this._page);
1008
+ // -----------------------------------------------------------------------
1009
+ // Camera
1010
+ // -----------------------------------------------------------------------
1011
+ /**
1012
+ * Get the current camera state (position, rotation, fov, near, far, zoom, target).
1013
+ * Auto-waits for the bridge to be ready.
1014
+ */
1015
+ async getCameraState(timeout) {
1016
+ return getCameraState(this._page, timeout, this.canvasId);
226
1017
  }
227
1018
  // -----------------------------------------------------------------------
228
1019
  // Waiters
@@ -230,9 +1021,22 @@ var R3FFixture = class {
230
1021
  /**
231
1022
  * Wait until the scene is ready — `window.__R3F_DOM__` is available and
232
1023
  * the object count has stabilised across several consecutive checks.
1024
+ * Logs bridge connection and scene readiness to the terminal.
233
1025
  */
234
1026
  async waitForSceneReady(options) {
235
- return waitForSceneReady(this._page, options);
1027
+ this._reporter.logBridgeWaiting();
1028
+ try {
1029
+ await waitForSceneReady(this._page, { ...options, canvasId: this.canvasId });
1030
+ const diag = await this._reporter.fetchDiagnostics();
1031
+ if (diag) {
1032
+ this._reporter.logBridgeConnected(diag);
1033
+ this._reporter.logSceneReady(diag.objectCount);
1034
+ }
1035
+ } catch (e) {
1036
+ const diag = await this._reporter.fetchDiagnostics();
1037
+ if (diag?.error) this._reporter.logBridgeError(diag.error);
1038
+ throw e;
1039
+ }
236
1040
  }
237
1041
  /**
238
1042
  * Wait until the bridge is available and an object with the given testId or
@@ -240,173 +1044,1299 @@ var R3FFixture = class {
240
1044
  * never stabilizes (e.g. async model loading, continuous animations).
241
1045
  */
242
1046
  async waitForObject(idOrUuid, options) {
243
- return waitForObject(this._page, idOrUuid, options);
1047
+ this._reporter.logBridgeWaiting();
1048
+ try {
1049
+ await waitForObject(this._page, idOrUuid, { ...options, canvasId: this.canvasId });
1050
+ const meta = await this.getObject(idOrUuid);
1051
+ if (meta) {
1052
+ this._reporter.logObjectFound(idOrUuid, meta.type, meta.name || void 0);
1053
+ }
1054
+ } catch (e) {
1055
+ const suggestions = await this._reporter.fetchFuzzyMatches(idOrUuid);
1056
+ this._reporter.logObjectNotFound(idOrUuid, suggestions);
1057
+ throw e;
1058
+ }
244
1059
  }
245
1060
  /**
246
1061
  * Wait until no object properties have changed for a number of consecutive
247
1062
  * animation frames. Useful after triggering interactions or animations.
248
1063
  */
249
1064
  async waitForIdle(options) {
250
- return waitForIdle(this._page, options);
1065
+ return waitForIdle(this._page, { ...options, canvasId: this.canvasId });
1066
+ }
1067
+ /**
1068
+ * Wait until one or more new objects appear in the scene that were not
1069
+ * present when this call was made. Perfect for drawing apps where
1070
+ * `drawPath()` creates new geometry asynchronously.
1071
+ *
1072
+ * @param options Filter by type, nameContains, timeout, pollInterval
1073
+ * @returns Metadata of the newly added object(s)
1074
+ */
1075
+ async waitForNewObject(options) {
1076
+ return waitForNewObject(this._page, { ...options, canvasId: this.canvasId });
1077
+ }
1078
+ /**
1079
+ * Wait until an object (by testId or uuid) is no longer in the scene.
1080
+ * Use for delete flows: trigger removal, then wait until the object is gone.
1081
+ */
1082
+ async waitForObjectRemoved(idOrUuid, options) {
1083
+ return waitForObjectRemoved(this._page, idOrUuid, { ...options, canvasId: this.canvasId });
251
1084
  }
252
1085
  // -----------------------------------------------------------------------
253
1086
  // Selection (for inspector integration)
254
1087
  // -----------------------------------------------------------------------
255
1088
  /** Select a 3D object by testId or uuid (highlights in scene). */
256
1089
  async select(idOrUuid) {
257
- await this._page.evaluate((id) => {
258
- const api = window.__R3F_DOM__;
1090
+ await this._page.evaluate(([id, cid]) => {
1091
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
259
1092
  if (!api) throw new Error("react-three-dom bridge not found");
260
1093
  api.select(id);
261
- }, idOrUuid);
1094
+ }, [idOrUuid, this.canvasId ?? null]);
262
1095
  }
263
1096
  /** Clear the current selection. */
264
1097
  async clearSelection() {
265
- await this._page.evaluate(() => {
266
- const api = window.__R3F_DOM__;
1098
+ await this._page.evaluate((cid) => {
1099
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
267
1100
  if (api) api.clearSelection();
268
- });
1101
+ }, this.canvasId ?? null);
1102
+ }
1103
+ // -----------------------------------------------------------------------
1104
+ // Diagnostics
1105
+ // -----------------------------------------------------------------------
1106
+ /**
1107
+ * Fetch full bridge diagnostics (version, object counts, GPU info, etc.).
1108
+ * Returns null if the bridge is not available.
1109
+ */
1110
+ async getDiagnostics() {
1111
+ return this._reporter.fetchDiagnostics();
1112
+ }
1113
+ /**
1114
+ * Print a full diagnostics report to the terminal.
1115
+ * Useful at the start of a test suite or when debugging failures.
1116
+ */
1117
+ async logDiagnostics() {
1118
+ return this._reporter.logDiagnostics();
269
1119
  }
270
1120
  };
1121
+ function formatSceneTree(node, prefix = "", isLast = true) {
1122
+ const connector = prefix === "" ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
1123
+ const childPrefix = prefix === "" ? "" : prefix + (isLast ? " " : "\u2502 ");
1124
+ let label = node.type;
1125
+ if (node.name) label += ` "${node.name}"`;
1126
+ if (node.testId) label += ` [testId: ${node.testId}]`;
1127
+ label += node.visible ? " visible" : " HIDDEN";
1128
+ let result = prefix + connector + label + "\n";
1129
+ for (let i = 0; i < node.children.length; i++) {
1130
+ const child = node.children[i];
1131
+ const last = i === node.children.length - 1;
1132
+ result += formatSceneTree(child, childPrefix, last);
1133
+ }
1134
+ return result;
1135
+ }
271
1136
  var test = test$1.extend({
272
1137
  r3f: async ({ page }, use) => {
273
1138
  const fixture = new R3FFixture(page);
274
1139
  await use(fixture);
275
1140
  }
276
1141
  });
277
- var expect = expect$1.extend({
278
- // -----------------------------------------------------------------------
279
- // toExist verify an object with the given testId/uuid exists in the scene
280
- // -----------------------------------------------------------------------
281
- async toExist(r3f, idOrUuid) {
282
- const meta = await r3f.getObject(idOrUuid);
283
- const pass = meta !== null;
284
- return {
285
- pass,
286
- message: () => pass ? `Expected object "${idOrUuid}" to NOT exist in the scene, but it does` : `Expected object "${idOrUuid}" to exist in the scene, but it was not found`,
287
- name: "toExist",
288
- expected: idOrUuid,
289
- actual: meta
290
- };
291
- },
292
- // -----------------------------------------------------------------------
293
- // toBeVisible — verify an object is visible (object.visible === true)
294
- // -----------------------------------------------------------------------
295
- async toBeVisible(r3f, idOrUuid) {
296
- const meta = await r3f.getObject(idOrUuid);
297
- if (!meta) {
298
- return {
299
- pass: false,
300
- message: () => `Expected object "${idOrUuid}" to be visible, but it was not found in the scene`,
301
- name: "toBeVisible"
302
- };
1142
+ function createR3FTest(options) {
1143
+ return test$1.extend({
1144
+ r3f: async ({ page }, use) => {
1145
+ const fixture = new R3FFixture(page, options);
1146
+ await use(fixture);
1147
+ }
1148
+ });
1149
+ }
1150
+ var DEFAULT_TIMEOUT = 5e3;
1151
+ var DEFAULT_INTERVAL = 100;
1152
+ async function fetchSceneCount(page, canvasId) {
1153
+ return page.evaluate((cid) => {
1154
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1155
+ return api ? api.getCount() : 0;
1156
+ }, canvasId ?? null);
1157
+ }
1158
+ async function fetchCountByType(page, type, canvasId) {
1159
+ return page.evaluate(([t, cid]) => {
1160
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1161
+ return api ? api.getCountByType(t) : 0;
1162
+ }, [type, canvasId ?? null]);
1163
+ }
1164
+ async function fetchTotalTriangles(page, canvasId) {
1165
+ return page.evaluate((cid) => {
1166
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1167
+ if (!api) return 0;
1168
+ const bridge = api;
1169
+ const snap = bridge.snapshot();
1170
+ let total = 0;
1171
+ function walk(node) {
1172
+ const meta = bridge.getByUuid(node.uuid);
1173
+ if (meta && meta.triangleCount) total += meta.triangleCount;
1174
+ for (const child of node.children) {
1175
+ walk(child);
1176
+ }
1177
+ }
1178
+ walk(snap.tree);
1179
+ return total;
1180
+ }, canvasId ?? null);
1181
+ }
1182
+ async function fetchMeta(page, id, canvasId) {
1183
+ return page.evaluate(([i, cid]) => {
1184
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1185
+ if (!api) return null;
1186
+ return api.getByTestId(i) ?? api.getByUuid(i) ?? null;
1187
+ }, [id, canvasId ?? null]);
1188
+ }
1189
+ async function fetchInsp(page, id, canvasId) {
1190
+ return page.evaluate(([i, cid]) => {
1191
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1192
+ if (!api) return null;
1193
+ return api.inspect(i);
1194
+ }, [id, canvasId ?? null]);
1195
+ }
1196
+ async function fetchWorldPosition(page, id, canvasId) {
1197
+ return page.evaluate(([i, cid]) => {
1198
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1199
+ if (!api) return null;
1200
+ const insp = api.inspect(i);
1201
+ if (!insp || !insp.worldMatrix || insp.worldMatrix.length < 15) return null;
1202
+ const m = insp.worldMatrix;
1203
+ return [m[12], m[13], m[14]];
1204
+ }, [id, canvasId ?? null]);
1205
+ }
1206
+ function parseTol(v, def) {
1207
+ const o = typeof v === "number" ? { tolerance: v } : v ?? {};
1208
+ return {
1209
+ timeout: o.timeout ?? DEFAULT_TIMEOUT,
1210
+ interval: o.interval ?? DEFAULT_INTERVAL,
1211
+ tolerance: o.tolerance ?? def
1212
+ };
1213
+ }
1214
+ async function fetchFuzzyHints(page, query, canvasId) {
1215
+ try {
1216
+ const suggestions = await page.evaluate(
1217
+ ({ q, cid }) => {
1218
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1219
+ if (!api || typeof api.fuzzyFind !== "function") return [];
1220
+ return api.fuzzyFind(q, 5).map((m) => ({
1221
+ testId: m.testId,
1222
+ name: m.name,
1223
+ uuid: m.uuid
1224
+ }));
1225
+ },
1226
+ { q: query, cid: canvasId ?? null }
1227
+ );
1228
+ if (suggestions.length === 0) return "";
1229
+ return "\nDid you mean:\n" + suggestions.map((s) => {
1230
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
1231
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
1232
+ }).join("\n");
1233
+ } catch {
1234
+ return "";
1235
+ }
1236
+ }
1237
+ async function fetchDiagnosticHint(page, canvasId) {
1238
+ try {
1239
+ const diag = await page.evaluate((cid) => {
1240
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
1241
+ if (!api || typeof api.getDiagnostics !== "function") return null;
1242
+ return api.getDiagnostics();
1243
+ }, canvasId ?? null);
1244
+ if (!diag) return "";
1245
+ return `
1246
+ Bridge: v${diag.version} ready=${diag.ready}, ${diag.objectCount} objects (${diag.meshCount} meshes)`;
1247
+ } catch {
1248
+ return "";
1249
+ }
1250
+ }
1251
+ async function notFoundAsync(page, name, id, detail, timeout, canvasId) {
1252
+ const [fuzzy, diag] = await Promise.all([
1253
+ fetchFuzzyHints(page, id, canvasId),
1254
+ fetchDiagnosticHint(page, canvasId)
1255
+ ]);
1256
+ return {
1257
+ pass: false,
1258
+ message: () => `Expected object "${id}" ${detail}, but it was not found (waited ${timeout}ms)${diag}${fuzzy}`,
1259
+ name
1260
+ };
1261
+ }
1262
+ var r3fMatchers = {
1263
+ // ========================= TIER 1 — Metadata ============================
1264
+ // --- toExist ---
1265
+ async toExist(r3f, id, opts) {
1266
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1267
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1268
+ const isNot = this.isNot;
1269
+ let meta = null;
1270
+ try {
1271
+ await expect.poll(async () => {
1272
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1273
+ return meta !== null;
1274
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1275
+ } catch {
303
1276
  }
304
- const pass = meta.visible;
1277
+ const pass = meta !== null;
305
1278
  return {
306
1279
  pass,
307
- message: () => pass ? `Expected object "${idOrUuid}" to NOT be visible, but it is` : `Expected object "${idOrUuid}" to be visible, but visible=${meta.visible}`,
1280
+ message: () => pass ? `Expected object "${id}" to NOT exist, but it does` : `Expected object "${id}" to exist, but it was not found (waited ${timeout}ms)`,
1281
+ name: "toExist",
1282
+ expected: id,
1283
+ actual: meta
1284
+ };
1285
+ },
1286
+ // --- toBeVisible ---
1287
+ async toBeVisible(r3f, id, opts) {
1288
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1289
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1290
+ const isNot = this.isNot;
1291
+ let meta = null;
1292
+ try {
1293
+ await expect.poll(async () => {
1294
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1295
+ return meta?.visible ?? false;
1296
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1297
+ } catch {
1298
+ }
1299
+ if (!meta) return notFoundAsync(r3f.page, "toBeVisible", id, "to be visible", timeout, r3f.canvasId);
1300
+ const m = meta;
1301
+ return {
1302
+ pass: m.visible,
1303
+ message: () => m.visible ? `Expected "${id}" to NOT be visible, but it is` : `Expected "${id}" to be visible, but visible=${m.visible} (waited ${timeout}ms)`,
308
1304
  name: "toBeVisible",
309
1305
  expected: true,
310
- actual: meta.visible
1306
+ actual: m.visible
311
1307
  };
312
1308
  },
313
- // -----------------------------------------------------------------------
314
- // toBeInFrustum verify an object's bounding box intersects the camera
315
- // frustum (i.e. it is potentially on-screen). Uses inspect() for bounds.
316
- // -----------------------------------------------------------------------
317
- async toBeInFrustum(r3f, idOrUuid) {
318
- const inspection = await r3f.inspect(idOrUuid);
319
- if (!inspection) {
320
- return {
321
- pass: false,
322
- message: () => `Expected object "${idOrUuid}" to be in frustum, but it was not found in the scene`,
323
- name: "toBeInFrustum"
324
- };
1309
+ // --- toHavePosition ---
1310
+ async toHavePosition(r3f, id, expected, tolOpts) {
1311
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1312
+ const isNot = this.isNot;
1313
+ let meta = null;
1314
+ let pass = false;
1315
+ let delta = [0, 0, 0];
1316
+ try {
1317
+ await expect.poll(async () => {
1318
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1319
+ if (!meta) return false;
1320
+ delta = [Math.abs(meta.position[0] - expected[0]), Math.abs(meta.position[1] - expected[1]), Math.abs(meta.position[2] - expected[2])];
1321
+ pass = delta.every((d) => d <= tolerance);
1322
+ return pass;
1323
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1324
+ } catch {
325
1325
  }
326
- const { bounds } = inspection;
327
- const isFinite = (v) => v.every(Number.isFinite);
328
- const pass = isFinite(bounds.min) && isFinite(bounds.max);
1326
+ if (!meta) return notFoundAsync(r3f.page, "toHavePosition", id, `to have position [${expected}]`, timeout, r3f.canvasId);
1327
+ const m = meta;
329
1328
  return {
330
1329
  pass,
331
- message: () => pass ? `Expected object "${idOrUuid}" to NOT be in the camera frustum` : `Expected object "${idOrUuid}" to be in the camera frustum, but its bounds are invalid`,
332
- name: "toBeInFrustum",
333
- expected: "finite bounds",
334
- actual: bounds
1330
+ message: () => pass ? `Expected "${id}" to NOT be at [${expected}] (\xB1${tolerance})` : `Expected "${id}" at [${expected}] (\xB1${tolerance}), got [${m.position}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
1331
+ name: "toHavePosition",
1332
+ expected,
1333
+ actual: m.position
335
1334
  };
336
1335
  },
337
- // -----------------------------------------------------------------------
338
- // toHavePosition verify object position within tolerance
339
- // -----------------------------------------------------------------------
340
- async toHavePosition(r3f, idOrUuid, expected, tolerance = 0.01) {
341
- const meta = await r3f.getObject(idOrUuid);
342
- if (!meta) {
343
- return {
344
- pass: false,
345
- message: () => `Expected object "${idOrUuid}" to have position [${expected}], but it was not found`,
346
- name: "toHavePosition"
347
- };
1336
+ // --- toHaveWorldPosition ---
1337
+ async toHaveWorldPosition(r3f, id, expected, tolOpts) {
1338
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1339
+ const isNot = this.isNot;
1340
+ let worldPos = null;
1341
+ let pass = false;
1342
+ let delta = [0, 0, 0];
1343
+ try {
1344
+ await expect.poll(async () => {
1345
+ worldPos = await fetchWorldPosition(r3f.page, id, r3f.canvasId);
1346
+ if (!worldPos) return false;
1347
+ delta = [Math.abs(worldPos[0] - expected[0]), Math.abs(worldPos[1] - expected[1]), Math.abs(worldPos[2] - expected[2])];
1348
+ pass = delta.every((d) => d <= tolerance);
1349
+ return pass;
1350
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1351
+ } catch {
348
1352
  }
349
- const [ex, ey, ez] = expected;
350
- const [ax, ay, az] = meta.position;
351
- const dx = Math.abs(ax - ex);
352
- const dy = Math.abs(ay - ey);
353
- const dz = Math.abs(az - ez);
354
- const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;
1353
+ if (!worldPos) return notFoundAsync(r3f.page, "toHaveWorldPosition", id, `to have world position [${expected}]`, timeout, r3f.canvasId);
1354
+ const actualWorldPos = worldPos;
355
1355
  return {
356
1356
  pass,
357
- message: () => pass ? `Expected object "${idOrUuid}" to NOT be at position [${expected}] (\xB1${tolerance})` : `Expected object "${idOrUuid}" to be at position [${expected}] (\xB1${tolerance}), but it is at [${meta.position}] (delta: [${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}])`,
358
- name: "toHavePosition",
1357
+ message: () => pass ? `Expected "${id}" to NOT have world position [${expected}] (\xB1${tolerance})` : `Expected "${id}" world position [${expected}] (\xB1${tolerance}), got [${actualWorldPos[0].toFixed(4)}, ${actualWorldPos[1].toFixed(4)}, ${actualWorldPos[2].toFixed(4)}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
1358
+ name: "toHaveWorldPosition",
359
1359
  expected,
360
- actual: meta.position
1360
+ actual: actualWorldPos
361
1361
  };
362
1362
  },
363
- // -----------------------------------------------------------------------
364
- // toHaveBounds verify object bounding box (world-space)
365
- // -----------------------------------------------------------------------
366
- async toHaveBounds(r3f, idOrUuid, expected, tolerance = 0.1) {
367
- const inspection = await r3f.inspect(idOrUuid);
368
- if (!inspection) {
369
- return {
370
- pass: false,
371
- message: () => `Expected object "${idOrUuid}" to have specific bounds, but it was not found`,
372
- name: "toHaveBounds"
373
- };
1363
+ // --- toHaveRotation ---
1364
+ async toHaveRotation(r3f, id, expected, tolOpts) {
1365
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1366
+ const isNot = this.isNot;
1367
+ let meta = null;
1368
+ let pass = false;
1369
+ let delta = [0, 0, 0];
1370
+ try {
1371
+ await expect.poll(async () => {
1372
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1373
+ if (!meta) return false;
1374
+ delta = [Math.abs(meta.rotation[0] - expected[0]), Math.abs(meta.rotation[1] - expected[1]), Math.abs(meta.rotation[2] - expected[2])];
1375
+ pass = delta.every((d) => d <= tolerance);
1376
+ return pass;
1377
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1378
+ } catch {
374
1379
  }
375
- const { bounds } = inspection;
376
- const withinTolerance = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
377
- const pass = withinTolerance(bounds.min, expected.min) && withinTolerance(bounds.max, expected.max);
1380
+ if (!meta) return notFoundAsync(r3f.page, "toHaveRotation", id, `to have rotation [${expected}]`, timeout, r3f.canvasId);
1381
+ const m = meta;
378
1382
  return {
379
1383
  pass,
380
- message: () => pass ? `Expected object "${idOrUuid}" to NOT have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}` : `Expected object "${idOrUuid}" to have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}, but got min:${JSON.stringify(bounds.min)} max:${JSON.stringify(bounds.max)}`,
381
- name: "toHaveBounds",
1384
+ message: () => pass ? `Expected "${id}" to NOT have rotation [${expected}] (\xB1${tolerance})` : `Expected "${id}" rotation [${expected}] (\xB1${tolerance}), got [${m.rotation}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
1385
+ name: "toHaveRotation",
382
1386
  expected,
383
- actual: bounds
1387
+ actual: m.rotation
384
1388
  };
385
1389
  },
386
- // -----------------------------------------------------------------------
387
- // toHaveInstanceCount verify InstancedMesh instance count
388
- // -----------------------------------------------------------------------
389
- async toHaveInstanceCount(r3f, idOrUuid, expectedCount) {
390
- const meta = await r3f.getObject(idOrUuid);
391
- if (!meta) {
392
- return {
393
- pass: false,
394
- message: () => `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it was not found`,
395
- name: "toHaveInstanceCount"
396
- };
1390
+ // --- toHaveScale ---
1391
+ async toHaveScale(r3f, id, expected, tolOpts) {
1392
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1393
+ const isNot = this.isNot;
1394
+ let meta = null;
1395
+ let pass = false;
1396
+ let delta = [0, 0, 0];
1397
+ try {
1398
+ await expect.poll(async () => {
1399
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1400
+ if (!meta) return false;
1401
+ delta = [Math.abs(meta.scale[0] - expected[0]), Math.abs(meta.scale[1] - expected[1]), Math.abs(meta.scale[2] - expected[2])];
1402
+ pass = delta.every((d) => d <= tolerance);
1403
+ return pass;
1404
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1405
+ } catch {
1406
+ }
1407
+ if (!meta) return notFoundAsync(r3f.page, "toHaveScale", id, `to have scale [${expected}]`, timeout, r3f.canvasId);
1408
+ const m = meta;
1409
+ return {
1410
+ pass,
1411
+ message: () => pass ? `Expected "${id}" to NOT have scale [${expected}] (\xB1${tolerance})` : `Expected "${id}" scale [${expected}] (\xB1${tolerance}), got [${m.scale}] (\u0394 [${delta.map((d) => d.toFixed(4))}]) (waited ${timeout}ms)`,
1412
+ name: "toHaveScale",
1413
+ expected,
1414
+ actual: m.scale
1415
+ };
1416
+ },
1417
+ // --- toHaveType ---
1418
+ async toHaveType(r3f, id, expectedType, opts) {
1419
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1420
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1421
+ const isNot = this.isNot;
1422
+ let meta = null;
1423
+ let pass = false;
1424
+ try {
1425
+ await expect.poll(async () => {
1426
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1427
+ if (!meta) return false;
1428
+ pass = meta.type === expectedType;
1429
+ return pass;
1430
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1431
+ } catch {
1432
+ }
1433
+ if (!meta) return notFoundAsync(r3f.page, "toHaveType", id, `to have type "${expectedType}"`, timeout, r3f.canvasId);
1434
+ const m = meta;
1435
+ return {
1436
+ pass,
1437
+ message: () => pass ? `Expected "${id}" to NOT have type "${expectedType}"` : `Expected "${id}" type "${expectedType}", got "${m.type}" (waited ${timeout}ms)`,
1438
+ name: "toHaveType",
1439
+ expected: expectedType,
1440
+ actual: m.type
1441
+ };
1442
+ },
1443
+ // --- toHaveName ---
1444
+ async toHaveName(r3f, id, expectedName, opts) {
1445
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1446
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1447
+ const isNot = this.isNot;
1448
+ let meta = null;
1449
+ let pass = false;
1450
+ try {
1451
+ await expect.poll(async () => {
1452
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1453
+ if (!meta) return false;
1454
+ pass = meta.name === expectedName;
1455
+ return pass;
1456
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1457
+ } catch {
1458
+ }
1459
+ if (!meta) return notFoundAsync(r3f.page, "toHaveName", id, `to have name "${expectedName}"`, timeout, r3f.canvasId);
1460
+ const m = meta;
1461
+ return {
1462
+ pass,
1463
+ message: () => pass ? `Expected "${id}" to NOT have name "${expectedName}"` : `Expected "${id}" name "${expectedName}", got "${m.name}" (waited ${timeout}ms)`,
1464
+ name: "toHaveName",
1465
+ expected: expectedName,
1466
+ actual: m.name
1467
+ };
1468
+ },
1469
+ // --- toHaveGeometryType ---
1470
+ async toHaveGeometryType(r3f, id, expectedGeo, opts) {
1471
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1472
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1473
+ const isNot = this.isNot;
1474
+ let meta = null;
1475
+ let pass = false;
1476
+ try {
1477
+ await expect.poll(async () => {
1478
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1479
+ if (!meta) return false;
1480
+ pass = meta.geometryType === expectedGeo;
1481
+ return pass;
1482
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1483
+ } catch {
1484
+ }
1485
+ if (!meta) return notFoundAsync(r3f.page, "toHaveGeometryType", id, `to have geometry "${expectedGeo}"`, timeout, r3f.canvasId);
1486
+ const m = meta;
1487
+ return {
1488
+ pass,
1489
+ message: () => pass ? `Expected "${id}" to NOT have geometry "${expectedGeo}"` : `Expected "${id}" geometry "${expectedGeo}", got "${m.geometryType ?? "none"}" (waited ${timeout}ms)`,
1490
+ name: "toHaveGeometryType",
1491
+ expected: expectedGeo,
1492
+ actual: m.geometryType
1493
+ };
1494
+ },
1495
+ // --- toHaveMaterialType ---
1496
+ async toHaveMaterialType(r3f, id, expectedMat, opts) {
1497
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1498
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1499
+ const isNot = this.isNot;
1500
+ let meta = null;
1501
+ let pass = false;
1502
+ try {
1503
+ await expect.poll(async () => {
1504
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1505
+ if (!meta) return false;
1506
+ pass = meta.materialType === expectedMat;
1507
+ return pass;
1508
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1509
+ } catch {
1510
+ }
1511
+ if (!meta) return notFoundAsync(r3f.page, "toHaveMaterialType", id, `to have material "${expectedMat}"`, timeout, r3f.canvasId);
1512
+ const m = meta;
1513
+ return {
1514
+ pass,
1515
+ message: () => pass ? `Expected "${id}" to NOT have material "${expectedMat}"` : `Expected "${id}" material "${expectedMat}", got "${m.materialType ?? "none"}" (waited ${timeout}ms)`,
1516
+ name: "toHaveMaterialType",
1517
+ expected: expectedMat,
1518
+ actual: m.materialType
1519
+ };
1520
+ },
1521
+ // --- toHaveChildCount ---
1522
+ async toHaveChildCount(r3f, id, expectedCount, opts) {
1523
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1524
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1525
+ const isNot = this.isNot;
1526
+ let meta = null;
1527
+ let actual = 0;
1528
+ try {
1529
+ await expect.poll(async () => {
1530
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1531
+ if (!meta) return false;
1532
+ actual = meta.childrenUuids.length;
1533
+ return actual === expectedCount;
1534
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1535
+ } catch {
1536
+ }
1537
+ if (!meta) return notFoundAsync(r3f.page, "toHaveChildCount", id, `to have ${expectedCount} children`, timeout, r3f.canvasId);
1538
+ const pass = actual === expectedCount;
1539
+ return {
1540
+ pass,
1541
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} children` : `Expected "${id}" ${expectedCount} children, got ${actual} (waited ${timeout}ms)`,
1542
+ name: "toHaveChildCount",
1543
+ expected: expectedCount,
1544
+ actual
1545
+ };
1546
+ },
1547
+ // --- toHaveParent ---
1548
+ async toHaveParent(r3f, id, expectedParent, opts) {
1549
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1550
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1551
+ const isNot = this.isNot;
1552
+ let meta = null;
1553
+ let parentMeta = null;
1554
+ let pass = false;
1555
+ try {
1556
+ await expect.poll(async () => {
1557
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1558
+ if (!meta?.parentUuid) return false;
1559
+ parentMeta = await fetchMeta(r3f.page, meta.parentUuid, r3f.canvasId);
1560
+ if (!parentMeta) return false;
1561
+ pass = parentMeta.uuid === expectedParent || parentMeta.testId === expectedParent || parentMeta.name === expectedParent;
1562
+ return pass;
1563
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1564
+ } catch {
1565
+ }
1566
+ if (!meta) return notFoundAsync(r3f.page, "toHaveParent", id, `to have parent "${expectedParent}"`, timeout, r3f.canvasId);
1567
+ const m = meta;
1568
+ const pm = parentMeta;
1569
+ const parentLabel = pm?.testId ?? pm?.name ?? m.parentUuid;
1570
+ return {
1571
+ pass,
1572
+ message: () => pass ? `Expected "${id}" to NOT have parent "${expectedParent}"` : `Expected "${id}" parent "${expectedParent}", got "${parentLabel}" (waited ${timeout}ms)`,
1573
+ name: "toHaveParent",
1574
+ expected: expectedParent,
1575
+ actual: parentLabel
1576
+ };
1577
+ },
1578
+ // --- toHaveInstanceCount ---
1579
+ async toHaveInstanceCount(r3f, id, expectedCount, opts) {
1580
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1581
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1582
+ const isNot = this.isNot;
1583
+ let meta = null;
1584
+ let actual = 0;
1585
+ try {
1586
+ await expect.poll(async () => {
1587
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1588
+ actual = meta?.instanceCount ?? 0;
1589
+ return actual === expectedCount;
1590
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1591
+ } catch {
397
1592
  }
398
- const actual = meta.instanceCount ?? 0;
1593
+ if (!meta) return notFoundAsync(r3f.page, "toHaveInstanceCount", id, `to have instance count ${expectedCount}`, timeout, r3f.canvasId);
399
1594
  const pass = actual === expectedCount;
400
1595
  return {
401
1596
  pass,
402
- message: () => pass ? `Expected object "${idOrUuid}" to NOT have instance count ${expectedCount}` : `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it has ${actual}`,
1597
+ message: () => pass ? `Expected "${id}" to NOT have instance count ${expectedCount}` : `Expected "${id}" instance count ${expectedCount}, got ${actual} (waited ${timeout}ms)`,
403
1598
  name: "toHaveInstanceCount",
404
1599
  expected: expectedCount,
405
1600
  actual
406
1601
  };
1602
+ },
1603
+ // ========================= TIER 2 — Inspection ==========================
1604
+ // --- toBeInFrustum ---
1605
+ async toBeInFrustum(r3f, id, opts) {
1606
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1607
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1608
+ const isNot = this.isNot;
1609
+ let insp = null;
1610
+ let pass = false;
1611
+ try {
1612
+ await expect.poll(async () => {
1613
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1614
+ if (!insp) return false;
1615
+ const fin = (v) => v.every(Number.isFinite);
1616
+ pass = fin(insp.bounds.min) && fin(insp.bounds.max);
1617
+ return pass;
1618
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1619
+ } catch {
1620
+ }
1621
+ if (!insp) return notFoundAsync(r3f.page, "toBeInFrustum", id, "to be in frustum", timeout, r3f.canvasId);
1622
+ const i = insp;
1623
+ return {
1624
+ pass,
1625
+ message: () => pass ? `Expected "${id}" to NOT be in the camera frustum` : `Expected "${id}" in frustum, but bounds are invalid (waited ${timeout}ms)`,
1626
+ name: "toBeInFrustum",
1627
+ expected: "finite bounds",
1628
+ actual: i.bounds
1629
+ };
1630
+ },
1631
+ // --- toHaveBounds ---
1632
+ async toHaveBounds(r3f, id, expected, tolOpts) {
1633
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.1);
1634
+ const isNot = this.isNot;
1635
+ let insp = null;
1636
+ let pass = false;
1637
+ try {
1638
+ await expect.poll(async () => {
1639
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1640
+ if (!insp) return false;
1641
+ const w = (a, b) => a.every((v, j) => Math.abs(v - b[j]) <= tolerance);
1642
+ pass = w(insp.bounds.min, expected.min) && w(insp.bounds.max, expected.max);
1643
+ return pass;
1644
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1645
+ } catch {
1646
+ }
1647
+ if (!insp) return notFoundAsync(r3f.page, "toHaveBounds", id, "to have specific bounds", timeout, r3f.canvasId);
1648
+ const i = insp;
1649
+ return {
1650
+ pass,
1651
+ message: () => pass ? `Expected "${id}" to NOT have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}` : `Expected "${id}" bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}, got min:${JSON.stringify(i.bounds.min)} max:${JSON.stringify(i.bounds.max)} (waited ${timeout}ms)`,
1652
+ name: "toHaveBounds",
1653
+ expected,
1654
+ actual: i.bounds
1655
+ };
1656
+ },
1657
+ // --- toHaveColor ---
1658
+ async toHaveColor(r3f, id, expectedColor, opts) {
1659
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1660
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1661
+ const isNot = this.isNot;
1662
+ const norm = expectedColor.startsWith("#") ? expectedColor.toLowerCase() : `#${expectedColor.toLowerCase()}`;
1663
+ let insp = null;
1664
+ let actual;
1665
+ let pass = false;
1666
+ try {
1667
+ await expect.poll(async () => {
1668
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1669
+ if (!insp?.material?.color) return false;
1670
+ actual = insp.material.color.toLowerCase();
1671
+ pass = actual === norm;
1672
+ return pass;
1673
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1674
+ } catch {
1675
+ }
1676
+ if (!insp) return notFoundAsync(r3f.page, "toHaveColor", id, `to have color "${norm}"`, timeout, r3f.canvasId);
1677
+ return {
1678
+ pass,
1679
+ message: () => pass ? `Expected "${id}" to NOT have color "${norm}"` : `Expected "${id}" color "${norm}", got "${actual ?? "no color"}" (waited ${timeout}ms)`,
1680
+ name: "toHaveColor",
1681
+ expected: norm,
1682
+ actual
1683
+ };
1684
+ },
1685
+ // --- toHaveOpacity ---
1686
+ async toHaveOpacity(r3f, id, expectedOpacity, tolOpts) {
1687
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1688
+ const isNot = this.isNot;
1689
+ let insp = null;
1690
+ let actual;
1691
+ let pass = false;
1692
+ try {
1693
+ await expect.poll(async () => {
1694
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1695
+ if (!insp?.material) return false;
1696
+ actual = insp.material.opacity;
1697
+ pass = actual !== void 0 && Math.abs(actual - expectedOpacity) <= tolerance;
1698
+ return pass;
1699
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1700
+ } catch {
1701
+ }
1702
+ if (!insp) return notFoundAsync(r3f.page, "toHaveOpacity", id, `to have opacity ${expectedOpacity}`, timeout, r3f.canvasId);
1703
+ return {
1704
+ pass,
1705
+ message: () => pass ? `Expected "${id}" to NOT have opacity ${expectedOpacity} (\xB1${tolerance})` : `Expected "${id}" opacity ${expectedOpacity} (\xB1${tolerance}), got ${actual ?? "no material"} (waited ${timeout}ms)`,
1706
+ name: "toHaveOpacity",
1707
+ expected: expectedOpacity,
1708
+ actual
1709
+ };
1710
+ },
1711
+ // --- toBeTransparent ---
1712
+ async toBeTransparent(r3f, id, opts) {
1713
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1714
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1715
+ const isNot = this.isNot;
1716
+ let insp = null;
1717
+ let pass = false;
1718
+ try {
1719
+ await expect.poll(async () => {
1720
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1721
+ if (!insp?.material) return false;
1722
+ pass = insp.material.transparent === true;
1723
+ return pass;
1724
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1725
+ } catch {
1726
+ }
1727
+ if (!insp) return notFoundAsync(r3f.page, "toBeTransparent", id, "to be transparent", timeout, r3f.canvasId);
1728
+ const i = insp;
1729
+ return {
1730
+ pass,
1731
+ message: () => pass ? `Expected "${id}" to NOT be transparent` : `Expected "${id}" transparent=true, got ${i.material?.transparent ?? "no material"} (waited ${timeout}ms)`,
1732
+ name: "toBeTransparent",
1733
+ expected: true,
1734
+ actual: i.material?.transparent
1735
+ };
1736
+ },
1737
+ // --- toHaveVertexCount ---
1738
+ async toHaveVertexCount(r3f, id, expectedCount, opts) {
1739
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1740
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1741
+ const isNot = this.isNot;
1742
+ let meta = null;
1743
+ let actual = 0;
1744
+ try {
1745
+ await expect.poll(async () => {
1746
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1747
+ actual = meta?.vertexCount ?? 0;
1748
+ return actual === expectedCount;
1749
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1750
+ } catch {
1751
+ }
1752
+ if (!meta) return notFoundAsync(r3f.page, "toHaveVertexCount", id, `to have ${expectedCount} vertices`, timeout, r3f.canvasId);
1753
+ const pass = actual === expectedCount;
1754
+ return {
1755
+ pass,
1756
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} vertices` : `Expected "${id}" ${expectedCount} vertices, got ${actual} (waited ${timeout}ms)`,
1757
+ name: "toHaveVertexCount",
1758
+ expected: expectedCount,
1759
+ actual
1760
+ };
1761
+ },
1762
+ // --- toHaveTriangleCount ---
1763
+ async toHaveTriangleCount(r3f, id, expectedCount, opts) {
1764
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1765
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1766
+ const isNot = this.isNot;
1767
+ let meta = null;
1768
+ let actual = 0;
1769
+ try {
1770
+ await expect.poll(async () => {
1771
+ meta = await fetchMeta(r3f.page, id, r3f.canvasId);
1772
+ actual = meta?.triangleCount ?? 0;
1773
+ return actual === expectedCount;
1774
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1775
+ } catch {
1776
+ }
1777
+ if (!meta) return notFoundAsync(r3f.page, "toHaveTriangleCount", id, `to have ${expectedCount} triangles`, timeout, r3f.canvasId);
1778
+ const pass = actual === expectedCount;
1779
+ return {
1780
+ pass,
1781
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} triangles` : `Expected "${id}" ${expectedCount} triangles, got ${actual} (waited ${timeout}ms)`,
1782
+ name: "toHaveTriangleCount",
1783
+ expected: expectedCount,
1784
+ actual
1785
+ };
1786
+ },
1787
+ // --- toHaveUserData ---
1788
+ async toHaveUserData(r3f, id, key, expectedValue, opts) {
1789
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1790
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1791
+ const isNot = this.isNot;
1792
+ let insp = null;
1793
+ let actual;
1794
+ let pass = false;
1795
+ try {
1796
+ await expect.poll(async () => {
1797
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1798
+ if (!insp) return false;
1799
+ if (!(key in insp.userData)) return false;
1800
+ if (expectedValue === void 0) {
1801
+ pass = true;
1802
+ return true;
1803
+ }
1804
+ actual = insp.userData[key];
1805
+ pass = JSON.stringify(actual) === JSON.stringify(expectedValue);
1806
+ return pass;
1807
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1808
+ } catch {
1809
+ }
1810
+ if (!insp) return notFoundAsync(r3f.page, "toHaveUserData", id, `to have userData.${key}`, timeout, r3f.canvasId);
1811
+ return {
1812
+ pass,
1813
+ message: () => {
1814
+ if (expectedValue === void 0) {
1815
+ return pass ? `Expected "${id}" to NOT have userData key "${key}"` : `Expected "${id}" to have userData key "${key}", but missing (waited ${timeout}ms)`;
1816
+ }
1817
+ return pass ? `Expected "${id}" to NOT have userData.${key} = ${JSON.stringify(expectedValue)}` : `Expected "${id}" userData.${key} = ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actual)} (waited ${timeout}ms)`;
1818
+ },
1819
+ name: "toHaveUserData",
1820
+ expected: expectedValue ?? `key "${key}"`,
1821
+ actual
1822
+ };
1823
+ },
1824
+ // --- toHaveMapTexture ---
1825
+ async toHaveMapTexture(r3f, id, expectedName, opts) {
1826
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1827
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1828
+ const isNot = this.isNot;
1829
+ let insp = null;
1830
+ let actual;
1831
+ let pass = false;
1832
+ try {
1833
+ await expect.poll(async () => {
1834
+ insp = await fetchInsp(r3f.page, id, r3f.canvasId);
1835
+ if (!insp?.material?.map) return false;
1836
+ actual = insp.material.map;
1837
+ if (!expectedName) {
1838
+ pass = true;
1839
+ return true;
1840
+ }
1841
+ pass = actual === expectedName;
1842
+ return pass;
1843
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1844
+ } catch {
1845
+ }
1846
+ if (!insp) return notFoundAsync(r3f.page, "toHaveMapTexture", id, "to have a map texture", timeout, r3f.canvasId);
1847
+ return {
1848
+ pass,
1849
+ message: () => {
1850
+ if (!expectedName) {
1851
+ return pass ? `Expected "${id}" to NOT have a map texture` : `Expected "${id}" to have a map texture, but none found (waited ${timeout}ms)`;
1852
+ }
1853
+ return pass ? `Expected "${id}" to NOT have map "${expectedName}"` : `Expected "${id}" map "${expectedName}", got "${actual ?? "none"}" (waited ${timeout}ms)`;
1854
+ },
1855
+ name: "toHaveMapTexture",
1856
+ expected: expectedName ?? "any map",
1857
+ actual
1858
+ };
1859
+ },
1860
+ // =========================================================================
1861
+ // Scene-level matchers (no object ID — operate on the whole scene)
1862
+ // =========================================================================
1863
+ /**
1864
+ * Assert the total number of objects in the scene.
1865
+ * Auto-retries until the count matches or timeout.
1866
+ *
1867
+ * @example expect(r3f).toHaveObjectCount(42);
1868
+ * @example expect(r3f).toHaveObjectCount(42, { timeout: 10_000 });
1869
+ */
1870
+ async toHaveObjectCount(r3f, expected, options) {
1871
+ const isNot = this.isNot;
1872
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1873
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1874
+ let actual = -1;
1875
+ let pass = false;
1876
+ try {
1877
+ await expect.poll(async () => {
1878
+ actual = await fetchSceneCount(r3f.page, r3f.canvasId);
1879
+ pass = actual === expected;
1880
+ return pass;
1881
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1882
+ } catch {
1883
+ }
1884
+ return {
1885
+ pass,
1886
+ message: () => pass ? `Expected scene to NOT have ${expected} objects, but it does` : `Expected scene to have ${expected} objects, got ${actual} (waited ${timeout}ms)`,
1887
+ name: "toHaveObjectCount",
1888
+ expected,
1889
+ actual
1890
+ };
1891
+ },
1892
+ /**
1893
+ * Assert the total number of objects is at least `min`.
1894
+ * Useful for BIM scenes where the exact count may vary slightly.
1895
+ *
1896
+ * @example expect(r3f).toHaveObjectCountGreaterThan(10);
1897
+ */
1898
+ async toHaveObjectCountGreaterThan(r3f, min, options) {
1899
+ const isNot = this.isNot;
1900
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1901
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1902
+ let actual = -1;
1903
+ let pass = false;
1904
+ try {
1905
+ await expect.poll(async () => {
1906
+ actual = await fetchSceneCount(r3f.page, r3f.canvasId);
1907
+ pass = actual > min;
1908
+ return pass;
1909
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1910
+ } catch {
1911
+ }
1912
+ return {
1913
+ pass,
1914
+ message: () => pass ? `Expected scene to have at most ${min} objects, but has ${actual}` : `Expected scene to have more than ${min} objects, got ${actual} (waited ${timeout}ms)`,
1915
+ name: "toHaveObjectCountGreaterThan",
1916
+ expected: `> ${min}`,
1917
+ actual
1918
+ };
1919
+ },
1920
+ /**
1921
+ * Assert the count of objects of a specific Three.js type.
1922
+ * Auto-retries until the count matches or timeout.
1923
+ *
1924
+ * @example expect(r3f).toHaveCountByType('Mesh', 5);
1925
+ * @example expect(r3f).toHaveCountByType('Line', 10, { timeout: 10_000 });
1926
+ */
1927
+ async toHaveCountByType(r3f, type, expected, options) {
1928
+ const isNot = this.isNot;
1929
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1930
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1931
+ let actual = -1;
1932
+ let pass = false;
1933
+ try {
1934
+ await expect.poll(async () => {
1935
+ actual = await fetchCountByType(r3f.page, type, r3f.canvasId);
1936
+ pass = actual === expected;
1937
+ return pass;
1938
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1939
+ } catch {
1940
+ }
1941
+ return {
1942
+ pass,
1943
+ message: () => pass ? `Expected scene to NOT have ${expected} "${type}" objects, but it does` : `Expected ${expected} "${type}" objects, got ${actual} (waited ${timeout}ms)`,
1944
+ name: "toHaveCountByType",
1945
+ expected,
1946
+ actual
1947
+ };
1948
+ },
1949
+ /**
1950
+ * Assert the total triangle count across all meshes in the scene.
1951
+ * Use as a performance budget guard — fail if the scene exceeds a threshold.
1952
+ *
1953
+ * @example expect(r3f).toHaveTotalTriangleCount(50000);
1954
+ * @example expect(r3f).not.toHaveTotalTriangleCountGreaterThan(100000); // budget guard
1955
+ */
1956
+ async toHaveTotalTriangleCount(r3f, expected, options) {
1957
+ const isNot = this.isNot;
1958
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1959
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1960
+ let actual = -1;
1961
+ let pass = false;
1962
+ try {
1963
+ await expect.poll(async () => {
1964
+ actual = await fetchTotalTriangles(r3f.page, r3f.canvasId);
1965
+ pass = actual === expected;
1966
+ return pass;
1967
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1968
+ } catch {
1969
+ }
1970
+ return {
1971
+ pass,
1972
+ message: () => pass ? `Expected scene to NOT have ${expected} total triangles, but it does` : `Expected ${expected} total triangles, got ${actual} (waited ${timeout}ms)`,
1973
+ name: "toHaveTotalTriangleCount",
1974
+ expected,
1975
+ actual
1976
+ };
1977
+ },
1978
+ /**
1979
+ * Assert the total triangle count is at most `max`.
1980
+ * Perfect as a performance budget guard to prevent scene bloat.
1981
+ *
1982
+ * @example expect(r3f).toHaveTotalTriangleCountLessThan(100_000);
1983
+ */
1984
+ async toHaveTotalTriangleCountLessThan(r3f, max, options) {
1985
+ const isNot = this.isNot;
1986
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1987
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1988
+ let actual = -1;
1989
+ let pass = false;
1990
+ try {
1991
+ await expect.poll(async () => {
1992
+ actual = await fetchTotalTriangles(r3f.page, r3f.canvasId);
1993
+ pass = actual < max;
1994
+ return pass;
1995
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1996
+ } catch {
1997
+ }
1998
+ return {
1999
+ pass,
2000
+ message: () => pass ? `Expected scene to have at least ${max} triangles, but has ${actual}` : `Expected scene to have fewer than ${max} triangles, got ${actual} (waited ${timeout}ms)`,
2001
+ name: "toHaveTotalTriangleCountLessThan",
2002
+ expected: `< ${max}`,
2003
+ actual
2004
+ };
2005
+ },
2006
+ // ===================== CAMERA STATE ASSERTIONS ===========================
2007
+ /**
2008
+ * Assert the camera position is close to the expected [x, y, z].
2009
+ *
2010
+ * @example expect(r3f).toHaveCameraPosition([0, 5, 10], 0.1);
2011
+ */
2012
+ async toHaveCameraPosition(r3f, expected, tolOpts) {
2013
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.1);
2014
+ const isNot = this.isNot;
2015
+ let actual = [0, 0, 0];
2016
+ let pass = false;
2017
+ try {
2018
+ await expect.poll(async () => {
2019
+ const cam = await r3f.page.evaluate((cid) => {
2020
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2021
+ return api.getCameraState();
2022
+ }, r3f.canvasId ?? null);
2023
+ actual = cam.position;
2024
+ pass = Math.abs(actual[0] - expected[0]) <= tolerance && Math.abs(actual[1] - expected[1]) <= tolerance && Math.abs(actual[2] - expected[2]) <= tolerance;
2025
+ return pass;
2026
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2027
+ } catch {
2028
+ }
2029
+ return {
2030
+ pass,
2031
+ message: () => pass ? `Expected camera NOT at [${expected}], but it is` : `Expected camera at [${expected}], got [${actual}] (tol=${tolerance}, waited ${timeout}ms)`,
2032
+ name: "toHaveCameraPosition",
2033
+ expected,
2034
+ actual
2035
+ };
2036
+ },
2037
+ /**
2038
+ * Assert the camera field of view (PerspectiveCamera only).
2039
+ *
2040
+ * @example expect(r3f).toHaveCameraFov(75);
2041
+ */
2042
+ async toHaveCameraFov(r3f, expected, tolOpts) {
2043
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.1);
2044
+ const isNot = this.isNot;
2045
+ let actual;
2046
+ let pass = false;
2047
+ try {
2048
+ await expect.poll(async () => {
2049
+ const cam = await r3f.page.evaluate((cid) => {
2050
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2051
+ return api.getCameraState();
2052
+ }, r3f.canvasId ?? null);
2053
+ actual = cam.fov;
2054
+ pass = actual !== void 0 && Math.abs(actual - expected) <= tolerance;
2055
+ return pass;
2056
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2057
+ } catch {
2058
+ }
2059
+ return {
2060
+ pass,
2061
+ message: () => pass ? `Expected camera NOT to have fov ${expected}, but it does` : `Expected camera fov ${expected}, got ${actual ?? "N/A"} (tol=${tolerance}, waited ${timeout}ms)`,
2062
+ name: "toHaveCameraFov",
2063
+ expected,
2064
+ actual
2065
+ };
2066
+ },
2067
+ /**
2068
+ * Assert the camera near clipping plane.
2069
+ *
2070
+ * @example expect(r3f).toHaveCameraNear(0.1);
2071
+ */
2072
+ async toHaveCameraNear(r3f, expected, tolOpts) {
2073
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
2074
+ const isNot = this.isNot;
2075
+ let actual = 0;
2076
+ let pass = false;
2077
+ try {
2078
+ await expect.poll(async () => {
2079
+ const cam = await r3f.page.evaluate((cid) => {
2080
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2081
+ return api.getCameraState();
2082
+ }, r3f.canvasId ?? null);
2083
+ actual = cam.near;
2084
+ pass = Math.abs(actual - expected) <= tolerance;
2085
+ return pass;
2086
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2087
+ } catch {
2088
+ }
2089
+ return {
2090
+ pass,
2091
+ message: () => pass ? `Expected camera near NOT ${expected}, but it is` : `Expected camera near ${expected}, got ${actual} (tol=${tolerance}, waited ${timeout}ms)`,
2092
+ name: "toHaveCameraNear",
2093
+ expected,
2094
+ actual
2095
+ };
2096
+ },
2097
+ /**
2098
+ * Assert the camera far clipping plane.
2099
+ *
2100
+ * @example expect(r3f).toHaveCameraFar(1000);
2101
+ */
2102
+ async toHaveCameraFar(r3f, expected, tolOpts) {
2103
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 1);
2104
+ const isNot = this.isNot;
2105
+ let actual = 0;
2106
+ let pass = false;
2107
+ try {
2108
+ await expect.poll(async () => {
2109
+ const cam = await r3f.page.evaluate((cid) => {
2110
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2111
+ return api.getCameraState();
2112
+ }, r3f.canvasId ?? null);
2113
+ actual = cam.far;
2114
+ pass = Math.abs(actual - expected) <= tolerance;
2115
+ return pass;
2116
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2117
+ } catch {
2118
+ }
2119
+ return {
2120
+ pass,
2121
+ message: () => pass ? `Expected camera far NOT ${expected}, but it is` : `Expected camera far ${expected}, got ${actual} (tol=${tolerance}, waited ${timeout}ms)`,
2122
+ name: "toHaveCameraFar",
2123
+ expected,
2124
+ actual
2125
+ };
2126
+ },
2127
+ /**
2128
+ * Assert the camera zoom level.
2129
+ *
2130
+ * @example expect(r3f).toHaveCameraZoom(1);
2131
+ */
2132
+ async toHaveCameraZoom(r3f, expected, tolOpts) {
2133
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
2134
+ const isNot = this.isNot;
2135
+ let actual = 0;
2136
+ let pass = false;
2137
+ try {
2138
+ await expect.poll(async () => {
2139
+ const cam = await r3f.page.evaluate((cid) => {
2140
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2141
+ return api.getCameraState();
2142
+ }, r3f.canvasId ?? null);
2143
+ actual = cam.zoom;
2144
+ pass = Math.abs(actual - expected) <= tolerance;
2145
+ return pass;
2146
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2147
+ } catch {
2148
+ }
2149
+ return {
2150
+ pass,
2151
+ message: () => pass ? `Expected camera zoom NOT ${expected}, but it is` : `Expected camera zoom ${expected}, got ${actual} (tol=${tolerance}, waited ${timeout}ms)`,
2152
+ name: "toHaveCameraZoom",
2153
+ expected,
2154
+ actual
2155
+ };
2156
+ },
2157
+ // ======================== BATCH ASSERTIONS ==============================
2158
+ /**
2159
+ * Assert that ALL given objects exist in the scene.
2160
+ * Accepts an array of testIds/uuids or a glob pattern (e.g. "wall-*").
2161
+ *
2162
+ * @example expect(r3f).toAllExist(['wall-1', 'wall-2', 'floor']);
2163
+ * @example expect(r3f).toAllExist('wall-*');
2164
+ */
2165
+ async toAllExist(r3f, idsOrPattern, opts) {
2166
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
2167
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
2168
+ const isNot = this.isNot;
2169
+ let missing = [];
2170
+ let pass = false;
2171
+ try {
2172
+ await expect.poll(async () => {
2173
+ const ids = typeof idsOrPattern === "string" ? await resolvePattern(r3f.page, idsOrPattern, r3f.canvasId) : idsOrPattern;
2174
+ missing = [];
2175
+ for (const id of ids) {
2176
+ const m = await r3f.getObject(id);
2177
+ if (!m) missing.push(id);
2178
+ }
2179
+ pass = missing.length === 0 && ids.length > 0;
2180
+ return pass;
2181
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2182
+ } catch {
2183
+ }
2184
+ return {
2185
+ pass,
2186
+ message: () => pass ? `Expected some objects to NOT exist, but all do` : `Objects not found: [${missing.join(", ")}] (waited ${timeout}ms)`,
2187
+ name: "toAllExist",
2188
+ expected: idsOrPattern,
2189
+ actual: { missing }
2190
+ };
2191
+ },
2192
+ /**
2193
+ * Assert that ALL given objects are visible.
2194
+ *
2195
+ * @example expect(r3f).toAllBeVisible(['wall-1', 'wall-2', 'floor']);
2196
+ * @example expect(r3f).toAllBeVisible('wall-*');
2197
+ */
2198
+ async toAllBeVisible(r3f, idsOrPattern, opts) {
2199
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
2200
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
2201
+ const isNot = this.isNot;
2202
+ let hidden = [];
2203
+ let pass = false;
2204
+ try {
2205
+ await expect.poll(async () => {
2206
+ const ids = typeof idsOrPattern === "string" ? await resolvePattern(r3f.page, idsOrPattern, r3f.canvasId) : idsOrPattern;
2207
+ hidden = [];
2208
+ for (const id of ids) {
2209
+ const m = await r3f.getObject(id);
2210
+ if (!m || !m.visible) hidden.push(id);
2211
+ }
2212
+ pass = hidden.length === 0 && ids.length > 0;
2213
+ return pass;
2214
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2215
+ } catch {
2216
+ }
2217
+ return {
2218
+ pass,
2219
+ message: () => pass ? `Expected some objects to NOT be visible, but all are` : `Objects not visible: [${hidden.join(", ")}] (waited ${timeout}ms)`,
2220
+ name: "toAllBeVisible",
2221
+ expected: idsOrPattern,
2222
+ actual: { hidden }
2223
+ };
2224
+ },
2225
+ /**
2226
+ * Assert that NONE of the given objects exist in the scene.
2227
+ *
2228
+ * @example expect(r3f).toNoneExist(['deleted-wall', 'old-floor']);
2229
+ * @example expect(r3f).toNoneExist('temp-*');
2230
+ */
2231
+ async toNoneExist(r3f, idsOrPattern, opts) {
2232
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
2233
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
2234
+ const isNot = this.isNot;
2235
+ let found = [];
2236
+ let pass = false;
2237
+ try {
2238
+ await expect.poll(async () => {
2239
+ const ids = typeof idsOrPattern === "string" ? await resolvePattern(r3f.page, idsOrPattern, r3f.canvasId) : idsOrPattern;
2240
+ found = [];
2241
+ for (const id of ids) {
2242
+ const m = await r3f.getObject(id);
2243
+ if (m) found.push(id);
2244
+ }
2245
+ pass = found.length === 0;
2246
+ return pass;
2247
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
2248
+ } catch {
2249
+ }
2250
+ return {
2251
+ pass,
2252
+ message: () => pass ? `Expected some objects to exist, but none do` : `Objects still exist: [${found.join(", ")}] (waited ${timeout}ms)`,
2253
+ name: "toNoneExist",
2254
+ expected: idsOrPattern,
2255
+ actual: { found }
2256
+ };
407
2257
  }
408
- });
2258
+ };
2259
+ async function resolvePattern(page, pattern, canvasId) {
2260
+ return page.evaluate(([p, cid]) => {
2261
+ const api = cid ? window.__R3F_DOM_INSTANCES__?.[cid] : window.__R3F_DOM__;
2262
+ if (!api) return [];
2263
+ const snap = api.snapshot();
2264
+ const ids = [];
2265
+ const regex = new RegExp("^" + p.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
2266
+ function walk(node) {
2267
+ const testId = node.testId ?? node.name;
2268
+ if (regex.test(testId) || regex.test(node.uuid)) ids.push(testId || node.uuid);
2269
+ for (const child of node.children ?? []) walk(child);
2270
+ }
2271
+ walk(snap.tree);
2272
+ return ids;
2273
+ }, [pattern, canvasId ?? null]);
2274
+ }
2275
+
2276
+ // src/pathGenerators.ts
2277
+ function linePath(start, end, steps = 10, pressure = 0.5) {
2278
+ const points = [];
2279
+ const totalSteps = steps + 1;
2280
+ for (let i = 0; i <= totalSteps; i++) {
2281
+ const t = i / totalSteps;
2282
+ points.push({
2283
+ x: start.x + (end.x - start.x) * t,
2284
+ y: start.y + (end.y - start.y) * t,
2285
+ pressure
2286
+ });
2287
+ }
2288
+ return points;
2289
+ }
2290
+ function curvePath(start, control, end, steps = 20, pressure = 0.5) {
2291
+ const points = [];
2292
+ for (let i = 0; i <= steps; i++) {
2293
+ const t = i / steps;
2294
+ const invT = 1 - t;
2295
+ points.push({
2296
+ x: invT * invT * start.x + 2 * invT * t * control.x + t * t * end.x,
2297
+ y: invT * invT * start.y + 2 * invT * t * control.y + t * t * end.y,
2298
+ pressure
2299
+ });
2300
+ }
2301
+ return points;
2302
+ }
2303
+ function rectPath(topLeft, bottomRight, pointsPerSide = 5, pressure = 0.5) {
2304
+ const topRight = { x: bottomRight.x, y: topLeft.y };
2305
+ const bottomLeft = { x: topLeft.x, y: bottomRight.y };
2306
+ const sides = [
2307
+ [topLeft, topRight],
2308
+ [topRight, bottomRight],
2309
+ [bottomRight, bottomLeft],
2310
+ [bottomLeft, topLeft]
2311
+ ];
2312
+ const points = [];
2313
+ for (const [from, to] of sides) {
2314
+ for (let i = 0; i < pointsPerSide; i++) {
2315
+ const t = i / pointsPerSide;
2316
+ points.push({
2317
+ x: from.x + (to.x - from.x) * t,
2318
+ y: from.y + (to.y - from.y) * t,
2319
+ pressure
2320
+ });
2321
+ }
2322
+ }
2323
+ points.push({ x: topLeft.x, y: topLeft.y, pressure });
2324
+ return points;
2325
+ }
2326
+ function circlePath(center, radiusX, radiusY, steps = 36, pressure = 0.5) {
2327
+ const ry = radiusY ?? radiusX;
2328
+ const points = [];
2329
+ for (let i = 0; i <= steps; i++) {
2330
+ const angle = i / steps * Math.PI * 2;
2331
+ points.push({
2332
+ x: center.x + Math.cos(angle) * radiusX,
2333
+ y: center.y + Math.sin(angle) * ry,
2334
+ pressure
2335
+ });
2336
+ }
2337
+ return points;
2338
+ }
409
2339
 
410
- export { R3FFixture, click, contextMenu, doubleClick, drag, expect, hover, pointerMiss, test, waitForIdle, waitForObject, waitForSceneReady, wheel };
2340
+ export { R3FFixture, R3FReporter, circlePath, click, contextMenu, createR3FTest, curvePath, diffSnapshots, doubleClick, drag, drawPathOnCanvas, getCameraState, hover, linePath, pointerMiss, r3fMatchers, rectPath, test, unhover, waitForIdle, waitForNewObject, waitForObject, waitForObjectRemoved, waitForSceneReady, wheel };
411
2341
  //# sourceMappingURL=index.js.map
412
2342
  //# sourceMappingURL=index.js.map