@react-three-dom/playwright 0.1.1 → 0.2.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.cjs CHANGED
@@ -5,6 +5,48 @@ var test$1 = require('@playwright/test');
5
5
  // src/fixtures.ts
6
6
 
7
7
  // src/waiters.ts
8
+ async function waitForReadyBridge(page, timeout) {
9
+ const deadline = Date.now() + timeout;
10
+ const pollMs = 100;
11
+ while (Date.now() < deadline) {
12
+ const state = await page.evaluate(() => {
13
+ const api = window.__R3F_DOM__;
14
+ if (!api) return { exists: false };
15
+ return {
16
+ exists: true,
17
+ ready: api._ready,
18
+ error: api._error ?? null,
19
+ count: api.getCount()
20
+ };
21
+ });
22
+ if (state.exists && state.ready) {
23
+ return;
24
+ }
25
+ if (state.exists && !state.ready && state.error) {
26
+ throw new Error(
27
+ `[react-three-dom] Bridge initialization failed: ${state.error}
28
+ The <ThreeDom> component mounted but threw during setup. Check the browser console for the full stack trace.`
29
+ );
30
+ }
31
+ await page.waitForTimeout(pollMs);
32
+ }
33
+ const finalState = await page.evaluate(() => {
34
+ const api = window.__R3F_DOM__;
35
+ if (!api) return { exists: false, ready: false, error: null };
36
+ return { exists: true, ready: api._ready, error: api._error ?? null };
37
+ });
38
+ if (finalState.exists && finalState.error) {
39
+ throw new Error(
40
+ `[react-three-dom] Bridge initialization failed: ${finalState.error}
41
+ The <ThreeDom> component mounted but threw during setup.`
42
+ );
43
+ }
44
+ throw new Error(
45
+ `[react-three-dom] Timed out after ${timeout}ms waiting for the bridge to be ready.
46
+ Bridge exists: ${finalState.exists}, ready: ${finalState.ready}.
47
+ Ensure <ThreeDom> is mounted inside your <Canvas> component.`
48
+ );
49
+ }
8
50
  async function waitForSceneReady(page, options = {}) {
9
51
  const {
10
52
  stableChecks = 3,
@@ -12,9 +54,7 @@ async function waitForSceneReady(page, options = {}) {
12
54
  timeout = 1e4
13
55
  } = options;
14
56
  const deadline = Date.now() + timeout;
15
- await page.waitForFunction(() => typeof window.__R3F_DOM__ !== "undefined", void 0, {
16
- timeout
17
- });
57
+ await waitForReadyBridge(page, timeout);
18
58
  let lastCount = -1;
19
59
  let stableRuns = 0;
20
60
  while (Date.now() < deadline) {
@@ -32,11 +72,46 @@ async function waitForSceneReady(page, options = {}) {
32
72
  `waitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`
33
73
  );
34
74
  }
75
+ async function waitForObject(page, idOrUuid, options = {}) {
76
+ const {
77
+ bridgeTimeout = 3e4,
78
+ objectTimeout = 4e4,
79
+ pollIntervalMs = 200
80
+ } = options;
81
+ await waitForReadyBridge(page, bridgeTimeout);
82
+ const deadline = Date.now() + objectTimeout;
83
+ while (Date.now() < deadline) {
84
+ const found = await page.evaluate(
85
+ (id) => {
86
+ const api = window.__R3F_DOM__;
87
+ if (!api || !api._ready) return false;
88
+ return (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
89
+ },
90
+ idOrUuid
91
+ );
92
+ if (found) return;
93
+ await page.waitForTimeout(pollIntervalMs);
94
+ }
95
+ const diagnostics = await page.evaluate(() => {
96
+ const api = window.__R3F_DOM__;
97
+ if (!api) return { bridgeExists: false, ready: false, count: 0, error: null };
98
+ return {
99
+ bridgeExists: true,
100
+ ready: api._ready,
101
+ count: api.getCount(),
102
+ error: api._error ?? null
103
+ };
104
+ });
105
+ throw new Error(
106
+ `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}"?`
107
+ );
108
+ }
35
109
  async function waitForIdle(page, options = {}) {
36
110
  const {
37
111
  idleFrames = 10,
38
112
  timeout = 1e4
39
113
  } = options;
114
+ await waitForReadyBridge(page, timeout);
40
115
  const settled = await page.evaluate(
41
116
  ([frames, timeoutMs]) => {
42
117
  return new Promise((resolve) => {
@@ -48,8 +123,17 @@ async function waitForIdle(page, options = {}) {
48
123
  resolve(false);
49
124
  return;
50
125
  }
51
- const snap = window.__R3F_DOM__?.snapshot();
52
- const json = snap ? JSON.stringify(snap.tree) : "";
126
+ const api = window.__R3F_DOM__;
127
+ if (!api || !api._ready) {
128
+ if (api && api._error) {
129
+ resolve(`Bridge error: ${api._error}`);
130
+ return;
131
+ }
132
+ requestAnimationFrame(check);
133
+ return;
134
+ }
135
+ const snap = api.snapshot();
136
+ const json = JSON.stringify(snap.tree);
53
137
  if (json === lastJson && json !== "") {
54
138
  stableCount++;
55
139
  if (stableCount >= frames) {
@@ -67,76 +151,269 @@ async function waitForIdle(page, options = {}) {
67
151
  },
68
152
  [idleFrames, timeout]
69
153
  );
154
+ if (typeof settled === "string") {
155
+ throw new Error(`waitForIdle failed: ${settled}`);
156
+ }
70
157
  if (!settled) {
71
158
  throw new Error(`waitForIdle timed out after ${timeout}ms`);
72
159
  }
73
160
  }
74
- async function click(page, idOrUuid) {
75
- await page.evaluate((id) => {
161
+ async function waitForNewObject(page, options = {}) {
162
+ const {
163
+ type,
164
+ nameContains,
165
+ pollIntervalMs = 100,
166
+ timeout = 1e4
167
+ } = options;
168
+ const baselineUuids = await page.evaluate(() => {
76
169
  const api = window.__R3F_DOM__;
77
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
78
- api.click(id);
170
+ if (!api) return [];
171
+ const snap = api.snapshot();
172
+ const uuids = [];
173
+ function collect(node) {
174
+ uuids.push(node.uuid);
175
+ for (const child of node.children) {
176
+ collect(child);
177
+ }
178
+ }
179
+ collect(snap.tree);
180
+ return uuids;
181
+ });
182
+ const deadline = Date.now() + timeout;
183
+ while (Date.now() < deadline) {
184
+ await page.waitForTimeout(pollIntervalMs);
185
+ const result = await page.evaluate(
186
+ ([filterType, filterName, knownUuids]) => {
187
+ const api = window.__R3F_DOM__;
188
+ if (!api) return null;
189
+ const snap = api.snapshot();
190
+ const known = new Set(knownUuids);
191
+ const newObjects = [];
192
+ function scan(node) {
193
+ if (!known.has(node.uuid)) {
194
+ const typeMatch = !filterType || node.type === filterType;
195
+ const nameMatch = !filterName || node.name.includes(filterName);
196
+ if (typeMatch && nameMatch) {
197
+ newObjects.push({
198
+ uuid: node.uuid,
199
+ name: node.name,
200
+ type: node.type,
201
+ visible: node.visible,
202
+ testId: node.testId,
203
+ position: node.position,
204
+ rotation: node.rotation,
205
+ scale: node.scale
206
+ });
207
+ }
208
+ }
209
+ for (const child of node.children) {
210
+ scan(child);
211
+ }
212
+ }
213
+ scan(snap.tree);
214
+ if (newObjects.length === 0) return null;
215
+ return {
216
+ newObjects,
217
+ newUuids: newObjects.map((o) => o.uuid),
218
+ count: newObjects.length
219
+ };
220
+ },
221
+ [type ?? null, nameContains ?? null, baselineUuids]
222
+ );
223
+ if (result) {
224
+ return result;
225
+ }
226
+ }
227
+ const filterDesc = [
228
+ type ? `type="${type}"` : null,
229
+ nameContains ? `nameContains="${nameContains}"` : null
230
+ ].filter(Boolean).join(", ");
231
+ throw new Error(
232
+ `waitForNewObject timed out after ${timeout}ms. No new objects appeared${filterDesc ? ` matching ${filterDesc}` : ""}. Baseline had ${baselineUuids.length} objects.`
233
+ );
234
+ }
235
+
236
+ // src/interactions.ts
237
+ var DEFAULT_AUTO_WAIT_TIMEOUT = 5e3;
238
+ var AUTO_WAIT_POLL_MS = 100;
239
+ async function autoWaitForObject(page, idOrUuid, timeout = DEFAULT_AUTO_WAIT_TIMEOUT) {
240
+ const deadline = Date.now() + timeout;
241
+ while (Date.now() < deadline) {
242
+ const state = await page.evaluate(
243
+ (id) => {
244
+ const api = window.__R3F_DOM__;
245
+ if (!api) return { bridge: "missing" };
246
+ if (!api._ready) {
247
+ return {
248
+ bridge: "not-ready",
249
+ error: api._error ?? null
250
+ };
251
+ }
252
+ const found = (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
253
+ return { bridge: "ready", found };
254
+ },
255
+ idOrUuid
256
+ );
257
+ if (state.bridge === "ready" && state.found) {
258
+ return;
259
+ }
260
+ if (state.bridge === "not-ready" && state.error) {
261
+ throw new Error(
262
+ `[react-three-dom] Bridge initialization failed: ${state.error}
263
+ Cannot perform interaction on "${idOrUuid}".`
264
+ );
265
+ }
266
+ await page.waitForTimeout(AUTO_WAIT_POLL_MS);
267
+ }
268
+ const finalState = await page.evaluate(
269
+ (id) => {
270
+ const api = window.__R3F_DOM__;
271
+ if (!api) return { bridge: false, ready: false, count: 0, error: null, found: false };
272
+ return {
273
+ bridge: true,
274
+ ready: api._ready,
275
+ count: api.getCount(),
276
+ error: api._error ?? null,
277
+ found: (api.getByTestId(id) ?? api.getByUuid(id)) !== null
278
+ };
279
+ },
280
+ idOrUuid
281
+ );
282
+ if (!finalState.bridge) {
283
+ throw new Error(
284
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not found.
285
+ Ensure <ThreeDom> is mounted inside your <Canvas> component.`
286
+ );
287
+ }
288
+ throw new Error(
289
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: object "${idOrUuid}" not found.
290
+ Bridge: ready=${finalState.ready}, objectCount=${finalState.count}` + (finalState.error ? `, error=${finalState.error}` : "") + `.
291
+ Ensure the object has userData.testId="${idOrUuid}" or uuid="${idOrUuid}".`
292
+ );
293
+ }
294
+ async function autoWaitForBridge(page, timeout = DEFAULT_AUTO_WAIT_TIMEOUT) {
295
+ const deadline = Date.now() + timeout;
296
+ while (Date.now() < deadline) {
297
+ const state = await page.evaluate(() => {
298
+ const api = window.__R3F_DOM__;
299
+ if (!api) return { exists: false };
300
+ return {
301
+ exists: true,
302
+ ready: api._ready,
303
+ error: api._error ?? null
304
+ };
305
+ });
306
+ if (state.exists && state.ready) return;
307
+ if (state.exists && !state.ready && state.error) {
308
+ throw new Error(
309
+ `[react-three-dom] Bridge initialization failed: ${state.error}`
310
+ );
311
+ }
312
+ await page.waitForTimeout(AUTO_WAIT_POLL_MS);
313
+ }
314
+ throw new Error(
315
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not ready.
316
+ Ensure <ThreeDom> is mounted inside your <Canvas> component.`
317
+ );
318
+ }
319
+ async function click(page, idOrUuid, timeout) {
320
+ await autoWaitForObject(page, idOrUuid, timeout);
321
+ await page.evaluate((id) => {
322
+ window.__R3F_DOM__.click(id);
79
323
  }, idOrUuid);
80
324
  }
81
- async function doubleClick(page, idOrUuid) {
325
+ async function doubleClick(page, idOrUuid, timeout) {
326
+ await autoWaitForObject(page, idOrUuid, timeout);
82
327
  await page.evaluate((id) => {
83
- const api = window.__R3F_DOM__;
84
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
85
- api.doubleClick(id);
328
+ window.__R3F_DOM__.doubleClick(id);
86
329
  }, idOrUuid);
87
330
  }
88
- async function contextMenu(page, idOrUuid) {
331
+ async function contextMenu(page, idOrUuid, timeout) {
332
+ await autoWaitForObject(page, idOrUuid, timeout);
89
333
  await page.evaluate((id) => {
90
- const api = window.__R3F_DOM__;
91
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
92
- api.contextMenu(id);
334
+ window.__R3F_DOM__.contextMenu(id);
93
335
  }, idOrUuid);
94
336
  }
95
- async function hover(page, idOrUuid) {
337
+ async function hover(page, idOrUuid, timeout) {
338
+ await autoWaitForObject(page, idOrUuid, timeout);
96
339
  await page.evaluate((id) => {
97
- const api = window.__R3F_DOM__;
98
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
99
- api.hover(id);
340
+ window.__R3F_DOM__.hover(id);
100
341
  }, idOrUuid);
101
342
  }
102
- async function drag(page, idOrUuid, delta) {
343
+ async function drag(page, idOrUuid, delta, timeout) {
344
+ await autoWaitForObject(page, idOrUuid, timeout);
103
345
  await page.evaluate(
104
- ([id, d]) => {
105
- const api = window.__R3F_DOM__;
106
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
107
- api.drag(id, d);
346
+ async ([id, d]) => {
347
+ await window.__R3F_DOM__.drag(id, d);
108
348
  },
109
349
  [idOrUuid, delta]
110
350
  );
111
351
  }
112
- async function wheel(page, idOrUuid, options) {
352
+ async function wheel(page, idOrUuid, options, timeout) {
353
+ await autoWaitForObject(page, idOrUuid, timeout);
113
354
  await page.evaluate(
114
355
  ([id, opts]) => {
115
- const api = window.__R3F_DOM__;
116
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
117
- api.wheel(id, opts);
356
+ window.__R3F_DOM__.wheel(id, opts);
118
357
  },
119
358
  [idOrUuid, options]
120
359
  );
121
360
  }
122
- async function pointerMiss(page) {
361
+ async function pointerMiss(page, timeout) {
362
+ await autoWaitForBridge(page, timeout);
123
363
  await page.evaluate(() => {
124
- const api = window.__R3F_DOM__;
125
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
126
- api.pointerMiss();
364
+ window.__R3F_DOM__.pointerMiss();
127
365
  });
128
366
  }
367
+ async function drawPathOnCanvas(page, points, options, timeout) {
368
+ await autoWaitForBridge(page, timeout);
369
+ return page.evaluate(
370
+ async ([pts, opts]) => {
371
+ return window.__R3F_DOM__.drawPath(pts, opts ?? void 0);
372
+ },
373
+ [points, options ?? null]
374
+ );
375
+ }
129
376
 
130
377
  // src/fixtures.ts
131
378
  var R3FFixture = class {
132
- constructor(_page) {
379
+ constructor(_page, opts) {
133
380
  this._page = _page;
381
+ this._debugListenerAttached = false;
382
+ if (opts?.debug) {
383
+ this._attachDebugListener();
384
+ }
134
385
  }
135
386
  /** The underlying Playwright Page. */
136
387
  get page() {
137
388
  return this._page;
138
389
  }
139
390
  // -----------------------------------------------------------------------
391
+ // Debug logging
392
+ // -----------------------------------------------------------------------
393
+ /**
394
+ * Enable debug logging. Turns on `window.__R3F_DOM_DEBUG__` in the browser
395
+ * and forwards all `[r3f-dom:*]` console messages to the Node.js test terminal.
396
+ *
397
+ * Call before `page.goto()` to capture setup logs, or after to capture
398
+ * interaction logs.
399
+ */
400
+ async enableDebug() {
401
+ this._attachDebugListener();
402
+ await this._page.evaluate(() => {
403
+ window.__R3F_DOM_DEBUG__ = true;
404
+ });
405
+ }
406
+ _attachDebugListener() {
407
+ if (this._debugListenerAttached) return;
408
+ this._debugListenerAttached = true;
409
+ this._page.on("console", (msg) => {
410
+ const text = msg.text();
411
+ if (text.startsWith("[r3f-dom:")) {
412
+ console.log(text);
413
+ }
414
+ });
415
+ }
416
+ // -----------------------------------------------------------------------
140
417
  // Queries
141
418
  // -----------------------------------------------------------------------
142
419
  /** Get object metadata by testId or uuid. Returns null if not found. */
@@ -169,36 +446,153 @@ var R3FFixture = class {
169
446
  return api ? api.getCount() : 0;
170
447
  });
171
448
  }
449
+ /**
450
+ * Get all objects of a given Three.js type (e.g. "Mesh", "Group", "Line").
451
+ * Useful for BIM/CAD apps to find all walls, doors, etc. by object type.
452
+ */
453
+ async getByType(type) {
454
+ return this._page.evaluate((t) => {
455
+ const api = window.__R3F_DOM__;
456
+ return api ? api.getByType(t) : [];
457
+ }, type);
458
+ }
459
+ /**
460
+ * Get objects that have a specific userData key (and optionally matching value).
461
+ * Useful for BIM/CAD apps where objects are tagged with metadata like
462
+ * `userData.category = "wall"` or `userData.floorId = 2`.
463
+ */
464
+ async getByUserData(key, value) {
465
+ return this._page.evaluate(({ k, v }) => {
466
+ const api = window.__R3F_DOM__;
467
+ return api ? api.getByUserData(k, v) : [];
468
+ }, { k: key, v: value });
469
+ }
470
+ /**
471
+ * Count objects of a given Three.js type.
472
+ * More efficient than `getByType(type).then(arr => arr.length)`.
473
+ */
474
+ async getCountByType(type) {
475
+ return this._page.evaluate((t) => {
476
+ const api = window.__R3F_DOM__;
477
+ return api ? api.getCountByType(t) : 0;
478
+ }, type);
479
+ }
480
+ /**
481
+ * Batch lookup: get metadata for multiple objects by testId or uuid in a
482
+ * single browser round-trip. Returns a record from id to metadata (or null).
483
+ *
484
+ * Much more efficient than calling `getObject()` in a loop for BIM/CAD
485
+ * scenes with many objects.
486
+ *
487
+ * @example
488
+ * ```typescript
489
+ * const results = await r3f.getObjects(['wall-1', 'door-2', 'window-3']);
490
+ * expect(results['wall-1']).not.toBeNull();
491
+ * expect(results['door-2']?.type).toBe('Mesh');
492
+ * ```
493
+ */
494
+ async getObjects(ids) {
495
+ return this._page.evaluate((idList) => {
496
+ const api = window.__R3F_DOM__;
497
+ if (!api) {
498
+ const result = {};
499
+ for (const id of idList) result[id] = null;
500
+ return result;
501
+ }
502
+ return api.getObjects(idList);
503
+ }, ids);
504
+ }
505
+ /**
506
+ * Log the scene tree to the test terminal for debugging.
507
+ *
508
+ * Prints a human-readable tree like:
509
+ * ```
510
+ * Scene "root"
511
+ * ├─ Mesh "chair-primary" [testId: chair-primary] visible
512
+ * │ └─ BoxGeometry
513
+ * ├─ DirectionalLight "sun-light" [testId: sun-light] visible
514
+ * └─ Group "furniture"
515
+ * ├─ Mesh "table-top" [testId: table-top] visible
516
+ * └─ Mesh "vase" [testId: vase] visible
517
+ * ```
518
+ */
519
+ async logScene() {
520
+ const snap = await this.snapshot();
521
+ if (!snap) {
522
+ console.log("[r3f-dom] logScene: no scene snapshot available (bridge not ready?)");
523
+ return;
524
+ }
525
+ const lines = formatSceneTree(snap.tree);
526
+ console.log(`
527
+ [r3f-dom] Scene tree (${snap.objectCount} objects):
528
+ ${lines}
529
+ `);
530
+ }
172
531
  // -----------------------------------------------------------------------
173
532
  // Interactions
174
533
  // -----------------------------------------------------------------------
175
- /** Click a 3D object by testId or uuid. */
176
- async click(idOrUuid) {
177
- return click(this._page, idOrUuid);
534
+ /**
535
+ * Click a 3D object by testId or uuid.
536
+ * Auto-waits for the bridge and the object to exist before clicking.
537
+ * @param timeout Optional auto-wait timeout in ms. Default: 5000
538
+ */
539
+ async click(idOrUuid, timeout) {
540
+ return click(this._page, idOrUuid, timeout);
178
541
  }
179
- /** Double-click a 3D object by testId or uuid. */
180
- async doubleClick(idOrUuid) {
181
- return doubleClick(this._page, idOrUuid);
542
+ /**
543
+ * Double-click a 3D object by testId or uuid.
544
+ * Auto-waits for the object to exist.
545
+ */
546
+ async doubleClick(idOrUuid, timeout) {
547
+ return doubleClick(this._page, idOrUuid, timeout);
548
+ }
549
+ /**
550
+ * Right-click / context-menu a 3D object by testId or uuid.
551
+ * Auto-waits for the object to exist.
552
+ */
553
+ async contextMenu(idOrUuid, timeout) {
554
+ return contextMenu(this._page, idOrUuid, timeout);
182
555
  }
183
- /** Right-click / context-menu a 3D object by testId or uuid. */
184
- async contextMenu(idOrUuid) {
185
- return contextMenu(this._page, idOrUuid);
556
+ /**
557
+ * Hover over a 3D object by testId or uuid.
558
+ * Auto-waits for the object to exist.
559
+ */
560
+ async hover(idOrUuid, timeout) {
561
+ return hover(this._page, idOrUuid, timeout);
186
562
  }
187
- /** Hover over a 3D object by testId or uuid. */
188
- async hover(idOrUuid) {
189
- return hover(this._page, idOrUuid);
563
+ /**
564
+ * Drag a 3D object with a world-space delta vector.
565
+ * Auto-waits for the object to exist.
566
+ */
567
+ async drag(idOrUuid, delta, timeout) {
568
+ return drag(this._page, idOrUuid, delta, timeout);
190
569
  }
191
- /** Drag a 3D object with a world-space delta vector. */
192
- async drag(idOrUuid, delta) {
193
- return drag(this._page, idOrUuid, delta);
570
+ /**
571
+ * Dispatch a wheel/scroll event on a 3D object.
572
+ * Auto-waits for the object to exist.
573
+ */
574
+ async wheel(idOrUuid, options, timeout) {
575
+ return wheel(this._page, idOrUuid, options, timeout);
194
576
  }
195
- /** Dispatch a wheel/scroll event on a 3D object. */
196
- async wheel(idOrUuid, options) {
197
- return wheel(this._page, idOrUuid, options);
577
+ /**
578
+ * Click empty space to trigger onPointerMissed handlers.
579
+ * Auto-waits for the bridge to be ready.
580
+ */
581
+ async pointerMiss(timeout) {
582
+ return pointerMiss(this._page, timeout);
198
583
  }
199
- /** Click empty space to trigger onPointerMissed handlers. */
200
- async pointerMiss() {
201
- return pointerMiss(this._page);
584
+ /**
585
+ * Draw a freeform path on the canvas. Dispatches pointerdown → N × pointermove → pointerup.
586
+ * Designed for canvas drawing/annotation/whiteboard apps.
587
+ * Auto-waits for the bridge to be ready.
588
+ *
589
+ * @param points Array of screen-space points (min 2). { x, y } in CSS pixels relative to canvas.
590
+ * @param options Drawing options (stepDelayMs, pointerType, clickAtEnd)
591
+ * @param timeout Optional auto-wait timeout in ms. Default: 5000
592
+ * @returns { eventCount, pointCount }
593
+ */
594
+ async drawPath(points, options, timeout) {
595
+ return drawPathOnCanvas(this._page, points, options, timeout);
202
596
  }
203
597
  // -----------------------------------------------------------------------
204
598
  // Waiters
@@ -210,6 +604,14 @@ var R3FFixture = class {
210
604
  async waitForSceneReady(options) {
211
605
  return waitForSceneReady(this._page, options);
212
606
  }
607
+ /**
608
+ * Wait until the bridge is available and an object with the given testId or
609
+ * uuid exists. Use this instead of waitForSceneReady when the scene count
610
+ * never stabilizes (e.g. async model loading, continuous animations).
611
+ */
612
+ async waitForObject(idOrUuid, options) {
613
+ return waitForObject(this._page, idOrUuid, options);
614
+ }
213
615
  /**
214
616
  * Wait until no object properties have changed for a number of consecutive
215
617
  * animation frames. Useful after triggering interactions or animations.
@@ -217,6 +619,17 @@ var R3FFixture = class {
217
619
  async waitForIdle(options) {
218
620
  return waitForIdle(this._page, options);
219
621
  }
622
+ /**
623
+ * Wait until one or more new objects appear in the scene that were not
624
+ * present when this call was made. Perfect for drawing apps where
625
+ * `drawPath()` creates new geometry asynchronously.
626
+ *
627
+ * @param options Filter by type, nameContains, timeout, pollInterval
628
+ * @returns Metadata of the newly added object(s)
629
+ */
630
+ async waitForNewObject(options) {
631
+ return waitForNewObject(this._page, options);
632
+ }
220
633
  // -----------------------------------------------------------------------
221
634
  // Selection (for inspector integration)
222
635
  // -----------------------------------------------------------------------
@@ -236,155 +649,897 @@ var R3FFixture = class {
236
649
  });
237
650
  }
238
651
  };
652
+ function formatSceneTree(node, prefix = "", isLast = true) {
653
+ const connector = prefix === "" ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
654
+ const childPrefix = prefix === "" ? "" : prefix + (isLast ? " " : "\u2502 ");
655
+ let label = node.type;
656
+ if (node.name) label += ` "${node.name}"`;
657
+ if (node.testId) label += ` [testId: ${node.testId}]`;
658
+ label += node.visible ? " visible" : " HIDDEN";
659
+ let result = prefix + connector + label + "\n";
660
+ for (let i = 0; i < node.children.length; i++) {
661
+ const child = node.children[i];
662
+ const last = i === node.children.length - 1;
663
+ result += formatSceneTree(child, childPrefix, last);
664
+ }
665
+ return result;
666
+ }
239
667
  var test = test$1.test.extend({
240
668
  r3f: async ({ page }, use) => {
241
669
  const fixture = new R3FFixture(page);
242
670
  await use(fixture);
243
671
  }
244
672
  });
673
+ function createR3FTest(options) {
674
+ return test$1.test.extend({
675
+ r3f: async ({ page }, use) => {
676
+ const fixture = new R3FFixture(page, options);
677
+ await use(fixture);
678
+ }
679
+ });
680
+ }
681
+ var DEFAULT_TIMEOUT = 5e3;
682
+ var DEFAULT_INTERVAL = 100;
683
+ async function fetchSceneCount(page) {
684
+ return page.evaluate(() => {
685
+ const api = window.__R3F_DOM__;
686
+ return api ? api.getCount() : 0;
687
+ });
688
+ }
689
+ async function fetchCountByType(page, type) {
690
+ return page.evaluate((t) => {
691
+ const api = window.__R3F_DOM__;
692
+ return api ? api.getCountByType(t) : 0;
693
+ }, type);
694
+ }
695
+ async function fetchTotalTriangles(page) {
696
+ return page.evaluate(() => {
697
+ const api = window.__R3F_DOM__;
698
+ if (!api) return 0;
699
+ const bridge = api;
700
+ const snap = bridge.snapshot();
701
+ let total = 0;
702
+ function walk(node) {
703
+ const meta = bridge.getByUuid(node.uuid);
704
+ if (meta && meta.triangleCount) total += meta.triangleCount;
705
+ for (const child of node.children) {
706
+ walk(child);
707
+ }
708
+ }
709
+ walk(snap.tree);
710
+ return total;
711
+ });
712
+ }
713
+ async function fetchMeta(page, id) {
714
+ return page.evaluate((i) => {
715
+ const api = window.__R3F_DOM__;
716
+ if (!api) return null;
717
+ return api.getByTestId(i) ?? api.getByUuid(i) ?? null;
718
+ }, id);
719
+ }
720
+ async function fetchInsp(page, id) {
721
+ return page.evaluate((i) => {
722
+ const api = window.__R3F_DOM__;
723
+ if (!api) return null;
724
+ return api.inspect(i);
725
+ }, id);
726
+ }
727
+ function parseTol(v, def) {
728
+ const o = typeof v === "number" ? { tolerance: v } : v ?? {};
729
+ return {
730
+ timeout: o.timeout ?? DEFAULT_TIMEOUT,
731
+ interval: o.interval ?? DEFAULT_INTERVAL,
732
+ tolerance: o.tolerance ?? def
733
+ };
734
+ }
735
+ function notFound(name, id, detail, timeout) {
736
+ return {
737
+ pass: false,
738
+ message: () => `Expected object "${id}" ${detail}, but it was not found (waited ${timeout}ms)`,
739
+ name
740
+ };
741
+ }
245
742
  var expect = test$1.expect.extend({
246
- // -----------------------------------------------------------------------
247
- // toExist — verify an object with the given testId/uuid exists in the scene
248
- // -----------------------------------------------------------------------
249
- async toExist(r3f, idOrUuid) {
250
- const meta = await r3f.getObject(idOrUuid);
743
+ // ========================= TIER 1 — Metadata ============================
744
+ // --- toExist ---
745
+ async toExist(r3f, id, opts) {
746
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
747
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
748
+ const isNot = this.isNot;
749
+ let meta = null;
750
+ try {
751
+ await test$1.expect.poll(async () => {
752
+ meta = await fetchMeta(r3f.page, id);
753
+ return meta !== null;
754
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
755
+ } catch {
756
+ }
251
757
  const pass = meta !== null;
252
758
  return {
253
759
  pass,
254
- 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`,
760
+ message: () => pass ? `Expected object "${id}" to NOT exist, but it does` : `Expected object "${id}" to exist, but it was not found (waited ${timeout}ms)`,
255
761
  name: "toExist",
256
- expected: idOrUuid,
762
+ expected: id,
257
763
  actual: meta
258
764
  };
259
765
  },
260
- // -----------------------------------------------------------------------
261
- // toBeVisible — verify an object is visible (object.visible === true)
262
- // -----------------------------------------------------------------------
263
- async toBeVisible(r3f, idOrUuid) {
264
- const meta = await r3f.getObject(idOrUuid);
265
- if (!meta) {
266
- return {
267
- pass: false,
268
- message: () => `Expected object "${idOrUuid}" to be visible, but it was not found in the scene`,
269
- name: "toBeVisible"
270
- };
766
+ // --- toBeVisible ---
767
+ async toBeVisible(r3f, id, opts) {
768
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
769
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
770
+ const isNot = this.isNot;
771
+ let meta = null;
772
+ try {
773
+ await test$1.expect.poll(async () => {
774
+ meta = await fetchMeta(r3f.page, id);
775
+ return meta?.visible ?? false;
776
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
777
+ } catch {
271
778
  }
272
- const pass = meta.visible;
779
+ if (!meta) return notFound("toBeVisible", id, "to be visible", timeout);
780
+ const m = meta;
273
781
  return {
274
- pass,
275
- message: () => pass ? `Expected object "${idOrUuid}" to NOT be visible, but it is` : `Expected object "${idOrUuid}" to be visible, but visible=${meta.visible}`,
782
+ pass: m.visible,
783
+ message: () => m.visible ? `Expected "${id}" to NOT be visible, but it is` : `Expected "${id}" to be visible, but visible=${m.visible} (waited ${timeout}ms)`,
276
784
  name: "toBeVisible",
277
785
  expected: true,
278
- actual: meta.visible
786
+ actual: m.visible
279
787
  };
280
788
  },
281
- // -----------------------------------------------------------------------
282
- // toBeInFrustum verify an object's bounding box intersects the camera
283
- // frustum (i.e. it is potentially on-screen). Uses inspect() for bounds.
284
- // -----------------------------------------------------------------------
285
- async toBeInFrustum(r3f, idOrUuid) {
286
- const inspection = await r3f.inspect(idOrUuid);
287
- if (!inspection) {
288
- return {
289
- pass: false,
290
- message: () => `Expected object "${idOrUuid}" to be in frustum, but it was not found in the scene`,
291
- name: "toBeInFrustum"
292
- };
789
+ // --- toHavePosition ---
790
+ async toHavePosition(r3f, id, expected, tolOpts) {
791
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
792
+ const isNot = this.isNot;
793
+ let meta = null;
794
+ let pass = false;
795
+ let delta = [0, 0, 0];
796
+ try {
797
+ await test$1.expect.poll(async () => {
798
+ meta = await fetchMeta(r3f.page, id);
799
+ if (!meta) return false;
800
+ delta = [Math.abs(meta.position[0] - expected[0]), Math.abs(meta.position[1] - expected[1]), Math.abs(meta.position[2] - expected[2])];
801
+ pass = delta.every((d) => d <= tolerance);
802
+ return pass;
803
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
804
+ } catch {
293
805
  }
294
- const { bounds } = inspection;
295
- const isFinite = (v) => v.every(Number.isFinite);
296
- const pass = isFinite(bounds.min) && isFinite(bounds.max);
806
+ if (!meta) return notFound("toHavePosition", id, `to have position [${expected}]`, timeout);
807
+ const m = meta;
297
808
  return {
298
809
  pass,
299
- 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`,
300
- name: "toBeInFrustum",
301
- expected: "finite bounds",
302
- actual: bounds
810
+ 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)`,
811
+ name: "toHavePosition",
812
+ expected,
813
+ actual: m.position
303
814
  };
304
815
  },
305
- // -----------------------------------------------------------------------
306
- // toHavePosition verify object position within tolerance
307
- // -----------------------------------------------------------------------
308
- async toHavePosition(r3f, idOrUuid, expected, tolerance = 0.01) {
309
- const meta = await r3f.getObject(idOrUuid);
310
- if (!meta) {
311
- return {
312
- pass: false,
313
- message: () => `Expected object "${idOrUuid}" to have position [${expected}], but it was not found`,
314
- name: "toHavePosition"
315
- };
816
+ // --- toHaveRotation ---
817
+ async toHaveRotation(r3f, id, expected, tolOpts) {
818
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
819
+ const isNot = this.isNot;
820
+ let meta = null;
821
+ let pass = false;
822
+ let delta = [0, 0, 0];
823
+ try {
824
+ await test$1.expect.poll(async () => {
825
+ meta = await fetchMeta(r3f.page, id);
826
+ if (!meta) return false;
827
+ delta = [Math.abs(meta.rotation[0] - expected[0]), Math.abs(meta.rotation[1] - expected[1]), Math.abs(meta.rotation[2] - expected[2])];
828
+ pass = delta.every((d) => d <= tolerance);
829
+ return pass;
830
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
831
+ } catch {
316
832
  }
317
- const [ex, ey, ez] = expected;
318
- const [ax, ay, az] = meta.position;
319
- const dx = Math.abs(ax - ex);
320
- const dy = Math.abs(ay - ey);
321
- const dz = Math.abs(az - ez);
322
- const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;
833
+ if (!meta) return notFound("toHaveRotation", id, `to have rotation [${expected}]`, timeout);
834
+ const m = meta;
323
835
  return {
324
836
  pass,
325
- 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)}])`,
326
- name: "toHavePosition",
837
+ 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)`,
838
+ name: "toHaveRotation",
327
839
  expected,
328
- actual: meta.position
840
+ actual: m.rotation
329
841
  };
330
842
  },
331
- // -----------------------------------------------------------------------
332
- // toHaveBounds verify object bounding box (world-space)
333
- // -----------------------------------------------------------------------
334
- async toHaveBounds(r3f, idOrUuid, expected, tolerance = 0.1) {
335
- const inspection = await r3f.inspect(idOrUuid);
336
- if (!inspection) {
337
- return {
338
- pass: false,
339
- message: () => `Expected object "${idOrUuid}" to have specific bounds, but it was not found`,
340
- name: "toHaveBounds"
341
- };
843
+ // --- toHaveScale ---
844
+ async toHaveScale(r3f, id, expected, tolOpts) {
845
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
846
+ const isNot = this.isNot;
847
+ let meta = null;
848
+ let pass = false;
849
+ let delta = [0, 0, 0];
850
+ try {
851
+ await test$1.expect.poll(async () => {
852
+ meta = await fetchMeta(r3f.page, id);
853
+ if (!meta) return false;
854
+ delta = [Math.abs(meta.scale[0] - expected[0]), Math.abs(meta.scale[1] - expected[1]), Math.abs(meta.scale[2] - expected[2])];
855
+ pass = delta.every((d) => d <= tolerance);
856
+ return pass;
857
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
858
+ } catch {
342
859
  }
343
- const { bounds } = inspection;
344
- const withinTolerance = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
345
- const pass = withinTolerance(bounds.min, expected.min) && withinTolerance(bounds.max, expected.max);
860
+ if (!meta) return notFound("toHaveScale", id, `to have scale [${expected}]`, timeout);
861
+ const m = meta;
346
862
  return {
347
863
  pass,
348
- 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)}`,
349
- name: "toHaveBounds",
864
+ 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)`,
865
+ name: "toHaveScale",
350
866
  expected,
351
- actual: bounds
867
+ actual: m.scale
352
868
  };
353
869
  },
354
- // -----------------------------------------------------------------------
355
- // toHaveInstanceCount verify InstancedMesh instance count
356
- // -----------------------------------------------------------------------
357
- async toHaveInstanceCount(r3f, idOrUuid, expectedCount) {
358
- const meta = await r3f.getObject(idOrUuid);
359
- if (!meta) {
360
- return {
361
- pass: false,
362
- message: () => `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it was not found`,
363
- name: "toHaveInstanceCount"
364
- };
870
+ // --- toHaveType ---
871
+ async toHaveType(r3f, id, expectedType, opts) {
872
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
873
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
874
+ const isNot = this.isNot;
875
+ let meta = null;
876
+ let pass = false;
877
+ try {
878
+ await test$1.expect.poll(async () => {
879
+ meta = await fetchMeta(r3f.page, id);
880
+ if (!meta) return false;
881
+ pass = meta.type === expectedType;
882
+ return pass;
883
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
884
+ } catch {
885
+ }
886
+ if (!meta) return notFound("toHaveType", id, `to have type "${expectedType}"`, timeout);
887
+ const m = meta;
888
+ return {
889
+ pass,
890
+ message: () => pass ? `Expected "${id}" to NOT have type "${expectedType}"` : `Expected "${id}" type "${expectedType}", got "${m.type}" (waited ${timeout}ms)`,
891
+ name: "toHaveType",
892
+ expected: expectedType,
893
+ actual: m.type
894
+ };
895
+ },
896
+ // --- toHaveName ---
897
+ async toHaveName(r3f, id, expectedName, opts) {
898
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
899
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
900
+ const isNot = this.isNot;
901
+ let meta = null;
902
+ let pass = false;
903
+ try {
904
+ await test$1.expect.poll(async () => {
905
+ meta = await fetchMeta(r3f.page, id);
906
+ if (!meta) return false;
907
+ pass = meta.name === expectedName;
908
+ return pass;
909
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
910
+ } catch {
911
+ }
912
+ if (!meta) return notFound("toHaveName", id, `to have name "${expectedName}"`, timeout);
913
+ const m = meta;
914
+ return {
915
+ pass,
916
+ message: () => pass ? `Expected "${id}" to NOT have name "${expectedName}"` : `Expected "${id}" name "${expectedName}", got "${m.name}" (waited ${timeout}ms)`,
917
+ name: "toHaveName",
918
+ expected: expectedName,
919
+ actual: m.name
920
+ };
921
+ },
922
+ // --- toHaveGeometryType ---
923
+ async toHaveGeometryType(r3f, id, expectedGeo, opts) {
924
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
925
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
926
+ const isNot = this.isNot;
927
+ let meta = null;
928
+ let pass = false;
929
+ try {
930
+ await test$1.expect.poll(async () => {
931
+ meta = await fetchMeta(r3f.page, id);
932
+ if (!meta) return false;
933
+ pass = meta.geometryType === expectedGeo;
934
+ return pass;
935
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
936
+ } catch {
937
+ }
938
+ if (!meta) return notFound("toHaveGeometryType", id, `to have geometry "${expectedGeo}"`, timeout);
939
+ const m = meta;
940
+ return {
941
+ pass,
942
+ message: () => pass ? `Expected "${id}" to NOT have geometry "${expectedGeo}"` : `Expected "${id}" geometry "${expectedGeo}", got "${m.geometryType ?? "none"}" (waited ${timeout}ms)`,
943
+ name: "toHaveGeometryType",
944
+ expected: expectedGeo,
945
+ actual: m.geometryType
946
+ };
947
+ },
948
+ // --- toHaveMaterialType ---
949
+ async toHaveMaterialType(r3f, id, expectedMat, opts) {
950
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
951
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
952
+ const isNot = this.isNot;
953
+ let meta = null;
954
+ let pass = false;
955
+ try {
956
+ await test$1.expect.poll(async () => {
957
+ meta = await fetchMeta(r3f.page, id);
958
+ if (!meta) return false;
959
+ pass = meta.materialType === expectedMat;
960
+ return pass;
961
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
962
+ } catch {
963
+ }
964
+ if (!meta) return notFound("toHaveMaterialType", id, `to have material "${expectedMat}"`, timeout);
965
+ const m = meta;
966
+ return {
967
+ pass,
968
+ message: () => pass ? `Expected "${id}" to NOT have material "${expectedMat}"` : `Expected "${id}" material "${expectedMat}", got "${m.materialType ?? "none"}" (waited ${timeout}ms)`,
969
+ name: "toHaveMaterialType",
970
+ expected: expectedMat,
971
+ actual: m.materialType
972
+ };
973
+ },
974
+ // --- toHaveChildCount ---
975
+ async toHaveChildCount(r3f, id, expectedCount, opts) {
976
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
977
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
978
+ const isNot = this.isNot;
979
+ let meta = null;
980
+ let actual = 0;
981
+ try {
982
+ await test$1.expect.poll(async () => {
983
+ meta = await fetchMeta(r3f.page, id);
984
+ if (!meta) return false;
985
+ actual = meta.childrenUuids.length;
986
+ return actual === expectedCount;
987
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
988
+ } catch {
365
989
  }
366
- const actual = meta.instanceCount ?? 0;
990
+ if (!meta) return notFound("toHaveChildCount", id, `to have ${expectedCount} children`, timeout);
367
991
  const pass = actual === expectedCount;
368
992
  return {
369
993
  pass,
370
- message: () => pass ? `Expected object "${idOrUuid}" to NOT have instance count ${expectedCount}` : `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it has ${actual}`,
994
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} children` : `Expected "${id}" ${expectedCount} children, got ${actual} (waited ${timeout}ms)`,
995
+ name: "toHaveChildCount",
996
+ expected: expectedCount,
997
+ actual
998
+ };
999
+ },
1000
+ // --- toHaveParent ---
1001
+ async toHaveParent(r3f, id, expectedParent, opts) {
1002
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1003
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1004
+ const isNot = this.isNot;
1005
+ let meta = null;
1006
+ let parentMeta = null;
1007
+ let pass = false;
1008
+ try {
1009
+ await test$1.expect.poll(async () => {
1010
+ meta = await fetchMeta(r3f.page, id);
1011
+ if (!meta?.parentUuid) return false;
1012
+ parentMeta = await fetchMeta(r3f.page, meta.parentUuid);
1013
+ if (!parentMeta) return false;
1014
+ pass = parentMeta.uuid === expectedParent || parentMeta.testId === expectedParent || parentMeta.name === expectedParent;
1015
+ return pass;
1016
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1017
+ } catch {
1018
+ }
1019
+ if (!meta) return notFound("toHaveParent", id, `to have parent "${expectedParent}"`, timeout);
1020
+ const m = meta;
1021
+ const pm = parentMeta;
1022
+ const parentLabel = pm?.testId ?? pm?.name ?? m.parentUuid;
1023
+ return {
1024
+ pass,
1025
+ message: () => pass ? `Expected "${id}" to NOT have parent "${expectedParent}"` : `Expected "${id}" parent "${expectedParent}", got "${parentLabel}" (waited ${timeout}ms)`,
1026
+ name: "toHaveParent",
1027
+ expected: expectedParent,
1028
+ actual: parentLabel
1029
+ };
1030
+ },
1031
+ // --- toHaveInstanceCount ---
1032
+ async toHaveInstanceCount(r3f, id, expectedCount, opts) {
1033
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1034
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1035
+ const isNot = this.isNot;
1036
+ let meta = null;
1037
+ let actual = 0;
1038
+ try {
1039
+ await test$1.expect.poll(async () => {
1040
+ meta = await fetchMeta(r3f.page, id);
1041
+ actual = meta?.instanceCount ?? 0;
1042
+ return actual === expectedCount;
1043
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1044
+ } catch {
1045
+ }
1046
+ if (!meta) return notFound("toHaveInstanceCount", id, `to have instance count ${expectedCount}`, timeout);
1047
+ const pass = actual === expectedCount;
1048
+ return {
1049
+ pass,
1050
+ message: () => pass ? `Expected "${id}" to NOT have instance count ${expectedCount}` : `Expected "${id}" instance count ${expectedCount}, got ${actual} (waited ${timeout}ms)`,
371
1051
  name: "toHaveInstanceCount",
372
1052
  expected: expectedCount,
373
1053
  actual
374
1054
  };
1055
+ },
1056
+ // ========================= TIER 2 — Inspection ==========================
1057
+ // --- toBeInFrustum ---
1058
+ async toBeInFrustum(r3f, id, opts) {
1059
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1060
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1061
+ const isNot = this.isNot;
1062
+ let insp = null;
1063
+ let pass = false;
1064
+ try {
1065
+ await test$1.expect.poll(async () => {
1066
+ insp = await fetchInsp(r3f.page, id);
1067
+ if (!insp) return false;
1068
+ const fin = (v) => v.every(Number.isFinite);
1069
+ pass = fin(insp.bounds.min) && fin(insp.bounds.max);
1070
+ return pass;
1071
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1072
+ } catch {
1073
+ }
1074
+ if (!insp) return notFound("toBeInFrustum", id, "to be in frustum", timeout);
1075
+ const i = insp;
1076
+ return {
1077
+ pass,
1078
+ message: () => pass ? `Expected "${id}" to NOT be in the camera frustum` : `Expected "${id}" in frustum, but bounds are invalid (waited ${timeout}ms)`,
1079
+ name: "toBeInFrustum",
1080
+ expected: "finite bounds",
1081
+ actual: i.bounds
1082
+ };
1083
+ },
1084
+ // --- toHaveBounds ---
1085
+ async toHaveBounds(r3f, id, expected, tolOpts) {
1086
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.1);
1087
+ const isNot = this.isNot;
1088
+ let insp = null;
1089
+ let pass = false;
1090
+ try {
1091
+ await test$1.expect.poll(async () => {
1092
+ insp = await fetchInsp(r3f.page, id);
1093
+ if (!insp) return false;
1094
+ const w = (a, b) => a.every((v, j) => Math.abs(v - b[j]) <= tolerance);
1095
+ pass = w(insp.bounds.min, expected.min) && w(insp.bounds.max, expected.max);
1096
+ return pass;
1097
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1098
+ } catch {
1099
+ }
1100
+ if (!insp) return notFound("toHaveBounds", id, "to have specific bounds", timeout);
1101
+ const i = insp;
1102
+ return {
1103
+ pass,
1104
+ 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)`,
1105
+ name: "toHaveBounds",
1106
+ expected,
1107
+ actual: i.bounds
1108
+ };
1109
+ },
1110
+ // --- toHaveColor ---
1111
+ async toHaveColor(r3f, id, expectedColor, opts) {
1112
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1113
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1114
+ const isNot = this.isNot;
1115
+ const norm = expectedColor.startsWith("#") ? expectedColor.toLowerCase() : `#${expectedColor.toLowerCase()}`;
1116
+ let insp = null;
1117
+ let actual;
1118
+ let pass = false;
1119
+ try {
1120
+ await test$1.expect.poll(async () => {
1121
+ insp = await fetchInsp(r3f.page, id);
1122
+ if (!insp?.material?.color) return false;
1123
+ actual = insp.material.color.toLowerCase();
1124
+ pass = actual === norm;
1125
+ return pass;
1126
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1127
+ } catch {
1128
+ }
1129
+ if (!insp) return notFound("toHaveColor", id, `to have color "${norm}"`, timeout);
1130
+ return {
1131
+ pass,
1132
+ message: () => pass ? `Expected "${id}" to NOT have color "${norm}"` : `Expected "${id}" color "${norm}", got "${actual ?? "no color"}" (waited ${timeout}ms)`,
1133
+ name: "toHaveColor",
1134
+ expected: norm,
1135
+ actual
1136
+ };
1137
+ },
1138
+ // --- toHaveOpacity ---
1139
+ async toHaveOpacity(r3f, id, expectedOpacity, tolOpts) {
1140
+ const { timeout, interval, tolerance } = parseTol(tolOpts, 0.01);
1141
+ const isNot = this.isNot;
1142
+ let insp = null;
1143
+ let actual;
1144
+ let pass = false;
1145
+ try {
1146
+ await test$1.expect.poll(async () => {
1147
+ insp = await fetchInsp(r3f.page, id);
1148
+ if (!insp?.material) return false;
1149
+ actual = insp.material.opacity;
1150
+ pass = actual !== void 0 && Math.abs(actual - expectedOpacity) <= tolerance;
1151
+ return pass;
1152
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1153
+ } catch {
1154
+ }
1155
+ if (!insp) return notFound("toHaveOpacity", id, `to have opacity ${expectedOpacity}`, timeout);
1156
+ return {
1157
+ pass,
1158
+ message: () => pass ? `Expected "${id}" to NOT have opacity ${expectedOpacity} (\xB1${tolerance})` : `Expected "${id}" opacity ${expectedOpacity} (\xB1${tolerance}), got ${actual ?? "no material"} (waited ${timeout}ms)`,
1159
+ name: "toHaveOpacity",
1160
+ expected: expectedOpacity,
1161
+ actual
1162
+ };
1163
+ },
1164
+ // --- toBeTransparent ---
1165
+ async toBeTransparent(r3f, id, opts) {
1166
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1167
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1168
+ const isNot = this.isNot;
1169
+ let insp = null;
1170
+ let pass = false;
1171
+ try {
1172
+ await test$1.expect.poll(async () => {
1173
+ insp = await fetchInsp(r3f.page, id);
1174
+ if (!insp?.material) return false;
1175
+ pass = insp.material.transparent === true;
1176
+ return pass;
1177
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1178
+ } catch {
1179
+ }
1180
+ if (!insp) return notFound("toBeTransparent", id, "to be transparent", timeout);
1181
+ const i = insp;
1182
+ return {
1183
+ pass,
1184
+ message: () => pass ? `Expected "${id}" to NOT be transparent` : `Expected "${id}" transparent=true, got ${i.material?.transparent ?? "no material"} (waited ${timeout}ms)`,
1185
+ name: "toBeTransparent",
1186
+ expected: true,
1187
+ actual: i.material?.transparent
1188
+ };
1189
+ },
1190
+ // --- toHaveVertexCount ---
1191
+ async toHaveVertexCount(r3f, id, expectedCount, opts) {
1192
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1193
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1194
+ const isNot = this.isNot;
1195
+ let meta = null;
1196
+ let actual = 0;
1197
+ try {
1198
+ await test$1.expect.poll(async () => {
1199
+ meta = await fetchMeta(r3f.page, id);
1200
+ actual = meta?.vertexCount ?? 0;
1201
+ return actual === expectedCount;
1202
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1203
+ } catch {
1204
+ }
1205
+ if (!meta) return notFound("toHaveVertexCount", id, `to have ${expectedCount} vertices`, timeout);
1206
+ const pass = actual === expectedCount;
1207
+ return {
1208
+ pass,
1209
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} vertices` : `Expected "${id}" ${expectedCount} vertices, got ${actual} (waited ${timeout}ms)`,
1210
+ name: "toHaveVertexCount",
1211
+ expected: expectedCount,
1212
+ actual
1213
+ };
1214
+ },
1215
+ // --- toHaveTriangleCount ---
1216
+ async toHaveTriangleCount(r3f, id, expectedCount, opts) {
1217
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1218
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1219
+ const isNot = this.isNot;
1220
+ let meta = null;
1221
+ let actual = 0;
1222
+ try {
1223
+ await test$1.expect.poll(async () => {
1224
+ meta = await fetchMeta(r3f.page, id);
1225
+ actual = meta?.triangleCount ?? 0;
1226
+ return actual === expectedCount;
1227
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1228
+ } catch {
1229
+ }
1230
+ if (!meta) return notFound("toHaveTriangleCount", id, `to have ${expectedCount} triangles`, timeout);
1231
+ const pass = actual === expectedCount;
1232
+ return {
1233
+ pass,
1234
+ message: () => pass ? `Expected "${id}" to NOT have ${expectedCount} triangles` : `Expected "${id}" ${expectedCount} triangles, got ${actual} (waited ${timeout}ms)`,
1235
+ name: "toHaveTriangleCount",
1236
+ expected: expectedCount,
1237
+ actual
1238
+ };
1239
+ },
1240
+ // --- toHaveUserData ---
1241
+ async toHaveUserData(r3f, id, key, expectedValue, opts) {
1242
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1243
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1244
+ const isNot = this.isNot;
1245
+ let insp = null;
1246
+ let actual;
1247
+ let pass = false;
1248
+ try {
1249
+ await test$1.expect.poll(async () => {
1250
+ insp = await fetchInsp(r3f.page, id);
1251
+ if (!insp) return false;
1252
+ if (!(key in insp.userData)) return false;
1253
+ if (expectedValue === void 0) {
1254
+ pass = true;
1255
+ return true;
1256
+ }
1257
+ actual = insp.userData[key];
1258
+ pass = JSON.stringify(actual) === JSON.stringify(expectedValue);
1259
+ return pass;
1260
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1261
+ } catch {
1262
+ }
1263
+ if (!insp) return notFound("toHaveUserData", id, `to have userData.${key}`, timeout);
1264
+ return {
1265
+ pass,
1266
+ message: () => {
1267
+ if (expectedValue === void 0) {
1268
+ return pass ? `Expected "${id}" to NOT have userData key "${key}"` : `Expected "${id}" to have userData key "${key}", but missing (waited ${timeout}ms)`;
1269
+ }
1270
+ 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)`;
1271
+ },
1272
+ name: "toHaveUserData",
1273
+ expected: expectedValue ?? `key "${key}"`,
1274
+ actual
1275
+ };
1276
+ },
1277
+ // --- toHaveMapTexture ---
1278
+ async toHaveMapTexture(r3f, id, expectedName, opts) {
1279
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
1280
+ const interval = opts?.interval ?? DEFAULT_INTERVAL;
1281
+ const isNot = this.isNot;
1282
+ let insp = null;
1283
+ let actual;
1284
+ let pass = false;
1285
+ try {
1286
+ await test$1.expect.poll(async () => {
1287
+ insp = await fetchInsp(r3f.page, id);
1288
+ if (!insp?.material?.map) return false;
1289
+ actual = insp.material.map;
1290
+ if (!expectedName) {
1291
+ pass = true;
1292
+ return true;
1293
+ }
1294
+ pass = actual === expectedName;
1295
+ return pass;
1296
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1297
+ } catch {
1298
+ }
1299
+ if (!insp) return notFound("toHaveMapTexture", id, "to have a map texture", timeout);
1300
+ return {
1301
+ pass,
1302
+ message: () => {
1303
+ if (!expectedName) {
1304
+ return pass ? `Expected "${id}" to NOT have a map texture` : `Expected "${id}" to have a map texture, but none found (waited ${timeout}ms)`;
1305
+ }
1306
+ return pass ? `Expected "${id}" to NOT have map "${expectedName}"` : `Expected "${id}" map "${expectedName}", got "${actual ?? "none"}" (waited ${timeout}ms)`;
1307
+ },
1308
+ name: "toHaveMapTexture",
1309
+ expected: expectedName ?? "any map",
1310
+ actual
1311
+ };
1312
+ },
1313
+ // =========================================================================
1314
+ // Scene-level matchers (no object ID — operate on the whole scene)
1315
+ // =========================================================================
1316
+ /**
1317
+ * Assert the total number of objects in the scene.
1318
+ * Auto-retries until the count matches or timeout.
1319
+ *
1320
+ * @example expect(r3f).toHaveObjectCount(42);
1321
+ * @example expect(r3f).toHaveObjectCount(42, { timeout: 10_000 });
1322
+ */
1323
+ async toHaveObjectCount(r3f, expected, options) {
1324
+ const isNot = this.isNot;
1325
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1326
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1327
+ let actual = -1;
1328
+ let pass = false;
1329
+ try {
1330
+ await test$1.expect.poll(async () => {
1331
+ actual = await fetchSceneCount(r3f.page);
1332
+ pass = actual === expected;
1333
+ return pass;
1334
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1335
+ } catch {
1336
+ }
1337
+ return {
1338
+ pass,
1339
+ message: () => pass ? `Expected scene to NOT have ${expected} objects, but it does` : `Expected scene to have ${expected} objects, got ${actual} (waited ${timeout}ms)`,
1340
+ name: "toHaveObjectCount",
1341
+ expected,
1342
+ actual
1343
+ };
1344
+ },
1345
+ /**
1346
+ * Assert the total number of objects is at least `min`.
1347
+ * Useful for BIM scenes where the exact count may vary slightly.
1348
+ *
1349
+ * @example expect(r3f).toHaveObjectCountGreaterThan(10);
1350
+ */
1351
+ async toHaveObjectCountGreaterThan(r3f, min, options) {
1352
+ const isNot = this.isNot;
1353
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1354
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1355
+ let actual = -1;
1356
+ let pass = false;
1357
+ try {
1358
+ await test$1.expect.poll(async () => {
1359
+ actual = await fetchSceneCount(r3f.page);
1360
+ pass = actual > min;
1361
+ return pass;
1362
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1363
+ } catch {
1364
+ }
1365
+ return {
1366
+ pass,
1367
+ 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)`,
1368
+ name: "toHaveObjectCountGreaterThan",
1369
+ expected: `> ${min}`,
1370
+ actual
1371
+ };
1372
+ },
1373
+ /**
1374
+ * Assert the count of objects of a specific Three.js type.
1375
+ * Auto-retries until the count matches or timeout.
1376
+ *
1377
+ * @example expect(r3f).toHaveCountByType('Mesh', 5);
1378
+ * @example expect(r3f).toHaveCountByType('Line', 10, { timeout: 10_000 });
1379
+ */
1380
+ async toHaveCountByType(r3f, type, expected, options) {
1381
+ const isNot = this.isNot;
1382
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1383
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1384
+ let actual = -1;
1385
+ let pass = false;
1386
+ try {
1387
+ await test$1.expect.poll(async () => {
1388
+ actual = await fetchCountByType(r3f.page, type);
1389
+ pass = actual === expected;
1390
+ return pass;
1391
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1392
+ } catch {
1393
+ }
1394
+ return {
1395
+ pass,
1396
+ message: () => pass ? `Expected scene to NOT have ${expected} "${type}" objects, but it does` : `Expected ${expected} "${type}" objects, got ${actual} (waited ${timeout}ms)`,
1397
+ name: "toHaveCountByType",
1398
+ expected,
1399
+ actual
1400
+ };
1401
+ },
1402
+ /**
1403
+ * Assert the total triangle count across all meshes in the scene.
1404
+ * Use as a performance budget guard — fail if the scene exceeds a threshold.
1405
+ *
1406
+ * @example expect(r3f).toHaveTotalTriangleCount(50000);
1407
+ * @example expect(r3f).not.toHaveTotalTriangleCountGreaterThan(100000); // budget guard
1408
+ */
1409
+ async toHaveTotalTriangleCount(r3f, expected, options) {
1410
+ const isNot = this.isNot;
1411
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1412
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1413
+ let actual = -1;
1414
+ let pass = false;
1415
+ try {
1416
+ await test$1.expect.poll(async () => {
1417
+ actual = await fetchTotalTriangles(r3f.page);
1418
+ pass = actual === expected;
1419
+ return pass;
1420
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1421
+ } catch {
1422
+ }
1423
+ return {
1424
+ pass,
1425
+ message: () => pass ? `Expected scene to NOT have ${expected} total triangles, but it does` : `Expected ${expected} total triangles, got ${actual} (waited ${timeout}ms)`,
1426
+ name: "toHaveTotalTriangleCount",
1427
+ expected,
1428
+ actual
1429
+ };
1430
+ },
1431
+ /**
1432
+ * Assert the total triangle count is at most `max`.
1433
+ * Perfect as a performance budget guard to prevent scene bloat.
1434
+ *
1435
+ * @example expect(r3f).toHaveTotalTriangleCountLessThan(100_000);
1436
+ */
1437
+ async toHaveTotalTriangleCountLessThan(r3f, max, options) {
1438
+ const isNot = this.isNot;
1439
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1440
+ const interval = options?.interval ?? DEFAULT_INTERVAL;
1441
+ let actual = -1;
1442
+ let pass = false;
1443
+ try {
1444
+ await test$1.expect.poll(async () => {
1445
+ actual = await fetchTotalTriangles(r3f.page);
1446
+ pass = actual < max;
1447
+ return pass;
1448
+ }, { timeout, intervals: [interval] }).toBe(!isNot);
1449
+ } catch {
1450
+ }
1451
+ return {
1452
+ pass,
1453
+ 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)`,
1454
+ name: "toHaveTotalTriangleCountLessThan",
1455
+ expected: `< ${max}`,
1456
+ actual
1457
+ };
375
1458
  }
376
1459
  });
377
1460
 
1461
+ // src/pathGenerators.ts
1462
+ function linePath(start, end, steps = 10, pressure = 0.5) {
1463
+ const points = [];
1464
+ const totalSteps = steps + 1;
1465
+ for (let i = 0; i <= totalSteps; i++) {
1466
+ const t = i / totalSteps;
1467
+ points.push({
1468
+ x: start.x + (end.x - start.x) * t,
1469
+ y: start.y + (end.y - start.y) * t,
1470
+ pressure
1471
+ });
1472
+ }
1473
+ return points;
1474
+ }
1475
+ function curvePath(start, control, end, steps = 20, pressure = 0.5) {
1476
+ const points = [];
1477
+ for (let i = 0; i <= steps; i++) {
1478
+ const t = i / steps;
1479
+ const invT = 1 - t;
1480
+ points.push({
1481
+ x: invT * invT * start.x + 2 * invT * t * control.x + t * t * end.x,
1482
+ y: invT * invT * start.y + 2 * invT * t * control.y + t * t * end.y,
1483
+ pressure
1484
+ });
1485
+ }
1486
+ return points;
1487
+ }
1488
+ function rectPath(topLeft, bottomRight, pointsPerSide = 5, pressure = 0.5) {
1489
+ const topRight = { x: bottomRight.x, y: topLeft.y };
1490
+ const bottomLeft = { x: topLeft.x, y: bottomRight.y };
1491
+ const sides = [
1492
+ [topLeft, topRight],
1493
+ [topRight, bottomRight],
1494
+ [bottomRight, bottomLeft],
1495
+ [bottomLeft, topLeft]
1496
+ ];
1497
+ const points = [];
1498
+ for (const [from, to] of sides) {
1499
+ for (let i = 0; i < pointsPerSide; i++) {
1500
+ const t = i / pointsPerSide;
1501
+ points.push({
1502
+ x: from.x + (to.x - from.x) * t,
1503
+ y: from.y + (to.y - from.y) * t,
1504
+ pressure
1505
+ });
1506
+ }
1507
+ }
1508
+ points.push({ x: topLeft.x, y: topLeft.y, pressure });
1509
+ return points;
1510
+ }
1511
+ function circlePath(center, radiusX, radiusY, steps = 36, pressure = 0.5) {
1512
+ const ry = radiusY ?? radiusX;
1513
+ const points = [];
1514
+ for (let i = 0; i <= steps; i++) {
1515
+ const angle = i / steps * Math.PI * 2;
1516
+ points.push({
1517
+ x: center.x + Math.cos(angle) * radiusX,
1518
+ y: center.y + Math.sin(angle) * ry,
1519
+ pressure
1520
+ });
1521
+ }
1522
+ return points;
1523
+ }
1524
+
378
1525
  exports.R3FFixture = R3FFixture;
1526
+ exports.circlePath = circlePath;
379
1527
  exports.click = click;
380
1528
  exports.contextMenu = contextMenu;
1529
+ exports.createR3FTest = createR3FTest;
1530
+ exports.curvePath = curvePath;
381
1531
  exports.doubleClick = doubleClick;
382
1532
  exports.drag = drag;
1533
+ exports.drawPathOnCanvas = drawPathOnCanvas;
383
1534
  exports.expect = expect;
384
1535
  exports.hover = hover;
1536
+ exports.linePath = linePath;
385
1537
  exports.pointerMiss = pointerMiss;
1538
+ exports.rectPath = rectPath;
386
1539
  exports.test = test;
387
1540
  exports.waitForIdle = waitForIdle;
1541
+ exports.waitForNewObject = waitForNewObject;
1542
+ exports.waitForObject = waitForObject;
388
1543
  exports.waitForSceneReady = waitForSceneReady;
389
1544
  exports.wheel = wheel;
390
1545
  //# sourceMappingURL=index.cjs.map