@react-three-dom/playwright 0.1.2 → 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) {
@@ -38,15 +78,13 @@ async function waitForObject(page, idOrUuid, options = {}) {
38
78
  objectTimeout = 4e4,
39
79
  pollIntervalMs = 200
40
80
  } = options;
41
- await page.waitForFunction(() => typeof window.__R3F_DOM__ !== "undefined", void 0, {
42
- timeout: bridgeTimeout
43
- });
81
+ await waitForReadyBridge(page, bridgeTimeout);
44
82
  const deadline = Date.now() + objectTimeout;
45
83
  while (Date.now() < deadline) {
46
84
  const found = await page.evaluate(
47
85
  (id) => {
48
86
  const api = window.__R3F_DOM__;
49
- if (!api) return false;
87
+ if (!api || !api._ready) return false;
50
88
  return (api.getByTestId(id) ?? api.getByUuid(id)) !== null;
51
89
  },
52
90
  idOrUuid
@@ -54,8 +92,18 @@ async function waitForObject(page, idOrUuid, options = {}) {
54
92
  if (found) return;
55
93
  await page.waitForTimeout(pollIntervalMs);
56
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
+ });
57
105
  throw new Error(
58
- `waitForObject("${idOrUuid}") timed out after ${objectTimeout}ms. Is the object rendered with userData.testId or this uuid?`
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}"?`
59
107
  );
60
108
  }
61
109
  async function waitForIdle(page, options = {}) {
@@ -63,6 +111,7 @@ async function waitForIdle(page, options = {}) {
63
111
  idleFrames = 10,
64
112
  timeout = 1e4
65
113
  } = options;
114
+ await waitForReadyBridge(page, timeout);
66
115
  const settled = await page.evaluate(
67
116
  ([frames, timeoutMs]) => {
68
117
  return new Promise((resolve) => {
@@ -74,8 +123,17 @@ async function waitForIdle(page, options = {}) {
74
123
  resolve(false);
75
124
  return;
76
125
  }
77
- const snap = window.__R3F_DOM__?.snapshot();
78
- 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);
79
137
  if (json === lastJson && json !== "") {
80
138
  stableCount++;
81
139
  if (stableCount >= frames) {
@@ -93,76 +151,269 @@ async function waitForIdle(page, options = {}) {
93
151
  },
94
152
  [idleFrames, timeout]
95
153
  );
154
+ if (typeof settled === "string") {
155
+ throw new Error(`waitForIdle failed: ${settled}`);
156
+ }
96
157
  if (!settled) {
97
158
  throw new Error(`waitForIdle timed out after ${timeout}ms`);
98
159
  }
99
160
  }
100
- async function click(page, idOrUuid) {
101
- 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(() => {
102
169
  const api = window.__R3F_DOM__;
103
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
104
- 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);
105
323
  }, idOrUuid);
106
324
  }
107
- async function doubleClick(page, idOrUuid) {
325
+ async function doubleClick(page, idOrUuid, timeout) {
326
+ await autoWaitForObject(page, idOrUuid, timeout);
108
327
  await page.evaluate((id) => {
109
- const api = window.__R3F_DOM__;
110
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
111
- api.doubleClick(id);
328
+ window.__R3F_DOM__.doubleClick(id);
112
329
  }, idOrUuid);
113
330
  }
114
- async function contextMenu(page, idOrUuid) {
331
+ async function contextMenu(page, idOrUuid, timeout) {
332
+ await autoWaitForObject(page, idOrUuid, timeout);
115
333
  await page.evaluate((id) => {
116
- const api = window.__R3F_DOM__;
117
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
118
- api.contextMenu(id);
334
+ window.__R3F_DOM__.contextMenu(id);
119
335
  }, idOrUuid);
120
336
  }
121
- async function hover(page, idOrUuid) {
337
+ async function hover(page, idOrUuid, timeout) {
338
+ await autoWaitForObject(page, idOrUuid, timeout);
122
339
  await page.evaluate((id) => {
123
- const api = window.__R3F_DOM__;
124
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
125
- api.hover(id);
340
+ window.__R3F_DOM__.hover(id);
126
341
  }, idOrUuid);
127
342
  }
128
- async function drag(page, idOrUuid, delta) {
343
+ async function drag(page, idOrUuid, delta, timeout) {
344
+ await autoWaitForObject(page, idOrUuid, timeout);
129
345
  await page.evaluate(
130
- ([id, d]) => {
131
- const api = window.__R3F_DOM__;
132
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
133
- api.drag(id, d);
346
+ async ([id, d]) => {
347
+ await window.__R3F_DOM__.drag(id, d);
134
348
  },
135
349
  [idOrUuid, delta]
136
350
  );
137
351
  }
138
- async function wheel(page, idOrUuid, options) {
352
+ async function wheel(page, idOrUuid, options, timeout) {
353
+ await autoWaitForObject(page, idOrUuid, timeout);
139
354
  await page.evaluate(
140
355
  ([id, opts]) => {
141
- const api = window.__R3F_DOM__;
142
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
143
- api.wheel(id, opts);
356
+ window.__R3F_DOM__.wheel(id, opts);
144
357
  },
145
358
  [idOrUuid, options]
146
359
  );
147
360
  }
148
- async function pointerMiss(page) {
361
+ async function pointerMiss(page, timeout) {
362
+ await autoWaitForBridge(page, timeout);
149
363
  await page.evaluate(() => {
150
- const api = window.__R3F_DOM__;
151
- if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
152
- api.pointerMiss();
364
+ window.__R3F_DOM__.pointerMiss();
153
365
  });
154
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
+ }
155
376
 
156
377
  // src/fixtures.ts
157
378
  var R3FFixture = class {
158
- constructor(_page) {
379
+ constructor(_page, opts) {
159
380
  this._page = _page;
381
+ this._debugListenerAttached = false;
382
+ if (opts?.debug) {
383
+ this._attachDebugListener();
384
+ }
160
385
  }
161
386
  /** The underlying Playwright Page. */
162
387
  get page() {
163
388
  return this._page;
164
389
  }
165
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
+ // -----------------------------------------------------------------------
166
417
  // Queries
167
418
  // -----------------------------------------------------------------------
168
419
  /** Get object metadata by testId or uuid. Returns null if not found. */
@@ -195,36 +446,153 @@ var R3FFixture = class {
195
446
  return api ? api.getCount() : 0;
196
447
  });
197
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
+ }
198
531
  // -----------------------------------------------------------------------
199
532
  // Interactions
200
533
  // -----------------------------------------------------------------------
201
- /** Click a 3D object by testId or uuid. */
202
- async click(idOrUuid) {
203
- 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);
204
541
  }
205
- /** Double-click a 3D object by testId or uuid. */
206
- async doubleClick(idOrUuid) {
207
- 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);
208
548
  }
209
- /** Right-click / context-menu a 3D object by testId or uuid. */
210
- async contextMenu(idOrUuid) {
211
- return contextMenu(this._page, idOrUuid);
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);
212
555
  }
213
- /** Hover over a 3D object by testId or uuid. */
214
- async hover(idOrUuid) {
215
- return hover(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);
216
562
  }
217
- /** Drag a 3D object with a world-space delta vector. */
218
- async drag(idOrUuid, delta) {
219
- return drag(this._page, idOrUuid, delta);
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);
220
569
  }
221
- /** Dispatch a wheel/scroll event on a 3D object. */
222
- async wheel(idOrUuid, options) {
223
- return wheel(this._page, idOrUuid, options);
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);
224
576
  }
225
- /** Click empty space to trigger onPointerMissed handlers. */
226
- async pointerMiss() {
227
- return pointerMiss(this._page);
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);
583
+ }
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);
228
596
  }
229
597
  // -----------------------------------------------------------------------
230
598
  // Waiters
@@ -251,6 +619,17 @@ var R3FFixture = class {
251
619
  async waitForIdle(options) {
252
620
  return waitForIdle(this._page, options);
253
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
+ }
254
633
  // -----------------------------------------------------------------------
255
634
  // Selection (for inspector integration)
256
635
  // -----------------------------------------------------------------------
@@ -270,155 +649,896 @@ var R3FFixture = class {
270
649
  });
271
650
  }
272
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
+ }
273
667
  var test = test$1.test.extend({
274
668
  r3f: async ({ page }, use) => {
275
669
  const fixture = new R3FFixture(page);
276
670
  await use(fixture);
277
671
  }
278
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
+ }
279
742
  var expect = test$1.expect.extend({
280
- // -----------------------------------------------------------------------
281
- // toExist — verify an object with the given testId/uuid exists in the scene
282
- // -----------------------------------------------------------------------
283
- async toExist(r3f, idOrUuid) {
284
- 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
+ }
285
757
  const pass = meta !== null;
286
758
  return {
287
759
  pass,
288
- 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)`,
289
761
  name: "toExist",
290
- expected: idOrUuid,
762
+ expected: id,
291
763
  actual: meta
292
764
  };
293
765
  },
294
- // -----------------------------------------------------------------------
295
- // toBeVisible — verify an object is visible (object.visible === true)
296
- // -----------------------------------------------------------------------
297
- async toBeVisible(r3f, idOrUuid) {
298
- const meta = await r3f.getObject(idOrUuid);
299
- if (!meta) {
300
- return {
301
- pass: false,
302
- message: () => `Expected object "${idOrUuid}" to be visible, but it was not found in the scene`,
303
- name: "toBeVisible"
304
- };
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 {
305
778
  }
306
- const pass = meta.visible;
779
+ if (!meta) return notFound("toBeVisible", id, "to be visible", timeout);
780
+ const m = meta;
307
781
  return {
308
- pass,
309
- 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)`,
310
784
  name: "toBeVisible",
311
785
  expected: true,
312
- actual: meta.visible
786
+ actual: m.visible
313
787
  };
314
788
  },
315
- // -----------------------------------------------------------------------
316
- // toBeInFrustum verify an object's bounding box intersects the camera
317
- // frustum (i.e. it is potentially on-screen). Uses inspect() for bounds.
318
- // -----------------------------------------------------------------------
319
- async toBeInFrustum(r3f, idOrUuid) {
320
- const inspection = await r3f.inspect(idOrUuid);
321
- if (!inspection) {
322
- return {
323
- pass: false,
324
- message: () => `Expected object "${idOrUuid}" to be in frustum, but it was not found in the scene`,
325
- name: "toBeInFrustum"
326
- };
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 {
327
805
  }
328
- const { bounds } = inspection;
329
- const isFinite = (v) => v.every(Number.isFinite);
330
- const pass = isFinite(bounds.min) && isFinite(bounds.max);
806
+ if (!meta) return notFound("toHavePosition", id, `to have position [${expected}]`, timeout);
807
+ const m = meta;
331
808
  return {
332
809
  pass,
333
- 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`,
334
- name: "toBeInFrustum",
335
- expected: "finite bounds",
336
- 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
337
814
  };
338
815
  },
339
- // -----------------------------------------------------------------------
340
- // toHavePosition verify object position within tolerance
341
- // -----------------------------------------------------------------------
342
- async toHavePosition(r3f, idOrUuid, expected, tolerance = 0.01) {
343
- const meta = await r3f.getObject(idOrUuid);
344
- if (!meta) {
345
- return {
346
- pass: false,
347
- message: () => `Expected object "${idOrUuid}" to have position [${expected}], but it was not found`,
348
- name: "toHavePosition"
349
- };
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 {
350
832
  }
351
- const [ex, ey, ez] = expected;
352
- const [ax, ay, az] = meta.position;
353
- const dx = Math.abs(ax - ex);
354
- const dy = Math.abs(ay - ey);
355
- const dz = Math.abs(az - ez);
356
- const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;
833
+ if (!meta) return notFound("toHaveRotation", id, `to have rotation [${expected}]`, timeout);
834
+ const m = meta;
357
835
  return {
358
836
  pass,
359
- 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)}])`,
360
- 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",
361
839
  expected,
362
- actual: meta.position
840
+ actual: m.rotation
363
841
  };
364
842
  },
365
- // -----------------------------------------------------------------------
366
- // toHaveBounds verify object bounding box (world-space)
367
- // -----------------------------------------------------------------------
368
- async toHaveBounds(r3f, idOrUuid, expected, tolerance = 0.1) {
369
- const inspection = await r3f.inspect(idOrUuid);
370
- if (!inspection) {
371
- return {
372
- pass: false,
373
- message: () => `Expected object "${idOrUuid}" to have specific bounds, but it was not found`,
374
- name: "toHaveBounds"
375
- };
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 {
376
859
  }
377
- const { bounds } = inspection;
378
- const withinTolerance = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
379
- 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;
380
862
  return {
381
863
  pass,
382
- 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)}`,
383
- 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",
384
866
  expected,
385
- actual: bounds
867
+ actual: m.scale
386
868
  };
387
869
  },
388
- // -----------------------------------------------------------------------
389
- // toHaveInstanceCount verify InstancedMesh instance count
390
- // -----------------------------------------------------------------------
391
- async toHaveInstanceCount(r3f, idOrUuid, expectedCount) {
392
- const meta = await r3f.getObject(idOrUuid);
393
- if (!meta) {
394
- return {
395
- pass: false,
396
- message: () => `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it was not found`,
397
- name: "toHaveInstanceCount"
398
- };
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 {
399
963
  }
400
- const actual = meta.instanceCount ?? 0;
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 {
989
+ }
990
+ if (!meta) return notFound("toHaveChildCount", id, `to have ${expectedCount} children`, timeout);
401
991
  const pass = actual === expectedCount;
402
992
  return {
403
993
  pass,
404
- 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)`,
405
1051
  name: "toHaveInstanceCount",
406
1052
  expected: expectedCount,
407
1053
  actual
408
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
+ };
409
1458
  }
410
1459
  });
411
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
+
412
1525
  exports.R3FFixture = R3FFixture;
1526
+ exports.circlePath = circlePath;
413
1527
  exports.click = click;
414
1528
  exports.contextMenu = contextMenu;
1529
+ exports.createR3FTest = createR3FTest;
1530
+ exports.curvePath = curvePath;
415
1531
  exports.doubleClick = doubleClick;
416
1532
  exports.drag = drag;
1533
+ exports.drawPathOnCanvas = drawPathOnCanvas;
417
1534
  exports.expect = expect;
418
1535
  exports.hover = hover;
1536
+ exports.linePath = linePath;
419
1537
  exports.pointerMiss = pointerMiss;
1538
+ exports.rectPath = rectPath;
420
1539
  exports.test = test;
421
1540
  exports.waitForIdle = waitForIdle;
1541
+ exports.waitForNewObject = waitForNewObject;
422
1542
  exports.waitForObject = waitForObject;
423
1543
  exports.waitForSceneReady = waitForSceneReady;
424
1544
  exports.wheel = wheel;