@react-three-dom/cypress 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,316 @@
1
+ // src/diffSnapshots.ts
2
+ var FIELDS_TO_COMPARE = [
3
+ "name",
4
+ "type",
5
+ "testId",
6
+ "visible",
7
+ "position",
8
+ "rotation",
9
+ "scale"
10
+ ];
11
+ function flattenTree(node) {
12
+ const map = /* @__PURE__ */ new Map();
13
+ function walk(n) {
14
+ map.set(n.uuid, n);
15
+ n.children.forEach(walk);
16
+ }
17
+ walk(node);
18
+ return map;
19
+ }
20
+ function valueEqual(a, b) {
21
+ if (a === b) return true;
22
+ if (Array.isArray(a) && Array.isArray(b)) {
23
+ if (a.length !== b.length) return false;
24
+ return a.every((v, i) => valueEqual(v, b[i]));
25
+ }
26
+ return false;
27
+ }
28
+ function diffSnapshots(before, after) {
29
+ const beforeMap = flattenTree(before.tree);
30
+ const afterMap = flattenTree(after.tree);
31
+ const added = [];
32
+ const removed = [];
33
+ const changed = [];
34
+ for (const [, node] of afterMap) {
35
+ if (!beforeMap.has(node.uuid)) added.push(node);
36
+ }
37
+ for (const [, node] of beforeMap) {
38
+ if (!afterMap.has(node.uuid)) removed.push(node);
39
+ }
40
+ for (const [uuid, afterNode] of afterMap) {
41
+ const beforeNode = beforeMap.get(uuid);
42
+ if (!beforeNode) continue;
43
+ for (const field of FIELDS_TO_COMPARE) {
44
+ const from = beforeNode[field];
45
+ const to = afterNode[field];
46
+ if (!valueEqual(from, to)) {
47
+ changed.push({ uuid, field, from, to });
48
+ }
49
+ }
50
+ }
51
+ return { added, removed, changed };
52
+ }
53
+
54
+ // src/reporter.ts
55
+ var RESET = "\x1B[0m";
56
+ var BOLD = "\x1B[1m";
57
+ var DIM = "\x1B[2m";
58
+ var GREEN = "\x1B[32m";
59
+ var RED = "\x1B[31m";
60
+ var YELLOW = "\x1B[33m";
61
+ var CYAN = "\x1B[36m";
62
+ var MAGENTA = "\x1B[35m";
63
+ var TAG = `${CYAN}[r3f-dom]${RESET}`;
64
+ function ok(msg) {
65
+ return `${TAG} ${GREEN}\u2713${RESET} ${msg}`;
66
+ }
67
+ function fail(msg) {
68
+ return `${TAG} ${RED}\u2717${RESET} ${msg}`;
69
+ }
70
+ function warn(msg) {
71
+ return `${TAG} ${YELLOW}\u26A0${RESET} ${msg}`;
72
+ }
73
+ function info(msg) {
74
+ return `${TAG} ${DIM}${msg}${RESET}`;
75
+ }
76
+ function heading(msg) {
77
+ return `
78
+ ${TAG} ${BOLD}${MAGENTA}${msg}${RESET}`;
79
+ }
80
+ function registerR3FTasks(on) {
81
+ on("task", {
82
+ r3fLog(message) {
83
+ process.stdout.write(message + "\n");
84
+ return null;
85
+ }
86
+ });
87
+ }
88
+ var _enabled = true;
89
+ function termLog(message) {
90
+ if (!_enabled) return;
91
+ cy.task("r3fLog", message, { log: false });
92
+ }
93
+ function cmdLog(name, message, props) {
94
+ Cypress.log({
95
+ name,
96
+ message,
97
+ consoleProps: () => props ?? {}
98
+ });
99
+ }
100
+ function getAPI(win) {
101
+ return win.__R3F_DOM__ ?? null;
102
+ }
103
+ var R3FReporter = class {
104
+ constructor(enabled = true) {
105
+ _enabled = enabled;
106
+ }
107
+ enable() {
108
+ _enabled = true;
109
+ }
110
+ disable() {
111
+ _enabled = false;
112
+ }
113
+ // ---- Lifecycle ----
114
+ logBridgeWaiting() {
115
+ termLog(info("Waiting for bridge (window.__R3F_DOM__)..."));
116
+ cmdLog("r3f", "Waiting for bridge...");
117
+ }
118
+ logBridgeConnected(diag) {
119
+ if (diag) {
120
+ termLog(ok(`Bridge connected \u2014 v${diag.version}, ${diag.objectCount} objects, ${diag.meshCount} meshes`));
121
+ termLog(info(` Canvas: ${diag.canvasWidth}\xD7${diag.canvasHeight} GPU: ${diag.webglRenderer}`));
122
+ termLog(info(` DOM nodes: ${diag.materializedDomNodes}/${diag.maxDomNodes} Dirty queue: ${diag.dirtyQueueSize}`));
123
+ cmdLog("r3f", `Bridge connected v${diag.version} \u2014 ${diag.objectCount} objects`, { diagnostics: diag });
124
+ } else {
125
+ termLog(ok("Bridge connected"));
126
+ cmdLog("r3f", "Bridge connected");
127
+ }
128
+ }
129
+ logBridgeError(error) {
130
+ termLog(fail(`Bridge error: ${error}`));
131
+ cmdLog("r3f", `Bridge error: ${error}`);
132
+ }
133
+ logSceneReady(objectCount) {
134
+ termLog(ok(`Scene ready \u2014 ${objectCount} objects stabilized`));
135
+ cmdLog("r3f", `Scene ready \u2014 ${objectCount} objects`);
136
+ }
137
+ logObjectFound(idOrUuid, type, name) {
138
+ const label = name ? `"${name}" (${type})` : type;
139
+ termLog(ok(`Object found: "${idOrUuid}" \u2192 ${label}`));
140
+ cmdLog("r3f", `Object found: "${idOrUuid}" \u2192 ${label}`);
141
+ }
142
+ logObjectNotFound(idOrUuid, suggestions) {
143
+ termLog(fail(`Object not found: "${idOrUuid}"`));
144
+ if (suggestions && suggestions.length > 0) {
145
+ termLog(warn("Did you mean:"));
146
+ for (const s of suggestions.slice(0, 5)) {
147
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
148
+ termLog(info(` \u2192 ${s.name || "(unnamed)"} [${id}]`));
149
+ }
150
+ }
151
+ cmdLog("r3f", `Object not found: "${idOrUuid}"`, { suggestions });
152
+ }
153
+ // ---- Interactions ----
154
+ logInteraction(action, idOrUuid, extra) {
155
+ const suffix = extra ? ` ${extra}` : "";
156
+ termLog(info(`${action}("${idOrUuid}")${suffix}`));
157
+ }
158
+ logInteractionDone(action, idOrUuid, durationMs) {
159
+ termLog(ok(`${action}("${idOrUuid}") \u2014 ${durationMs}ms`));
160
+ }
161
+ // ---- Assertions ----
162
+ logAssertionFailure(matcherName, id, detail, diag) {
163
+ termLog(heading(`Assertion failed: ${matcherName}("${id}")`));
164
+ termLog(fail(detail));
165
+ if (diag) {
166
+ this._printDiagnosticsSummary(diag);
167
+ }
168
+ }
169
+ // ---- Diagnostics ----
170
+ logDiagnostics() {
171
+ return cy.window({ log: false }).then((win) => {
172
+ const api = getAPI(win);
173
+ if (!api || typeof api.getDiagnostics !== "function") {
174
+ termLog(fail("Cannot fetch diagnostics \u2014 bridge not available"));
175
+ return;
176
+ }
177
+ const d = api.getDiagnostics();
178
+ termLog(heading("Bridge Diagnostics"));
179
+ this._printDiagnosticsFull(d);
180
+ cmdLog("r3fDiagnostics", `v${d.version} ${d.objectCount} objects`, { diagnostics: d });
181
+ });
182
+ }
183
+ fetchDiagnostics() {
184
+ return cy.window({ log: false }).then((win) => {
185
+ const api = getAPI(win);
186
+ if (!api || typeof api.getDiagnostics !== "function") return null;
187
+ return api.getDiagnostics();
188
+ });
189
+ }
190
+ fetchFuzzyMatches(query, limit = 5) {
191
+ return cy.window({ log: false }).then((win) => {
192
+ const api = getAPI(win);
193
+ if (!api || typeof api.fuzzyFind !== "function") return [];
194
+ return api.fuzzyFind(query, limit).map((m) => ({
195
+ testId: m.testId,
196
+ name: m.name,
197
+ uuid: m.uuid
198
+ }));
199
+ });
200
+ }
201
+ // ---- Private formatting ----
202
+ _printDiagnosticsSummary(d) {
203
+ termLog(info(` Bridge: v${d.version} ready=${d.ready}${d.error ? ` error="${d.error}"` : ""}`));
204
+ termLog(info(` Scene: ${d.objectCount} objects (${d.meshCount} meshes, ${d.groupCount} groups, ${d.lightCount} lights)`));
205
+ termLog(info(` Canvas: ${d.canvasWidth}\xD7${d.canvasHeight} GPU: ${d.webglRenderer}`));
206
+ }
207
+ _printDiagnosticsFull(d) {
208
+ const status = d.ready ? `${GREEN}READY${RESET}` : `${RED}NOT READY${RESET}`;
209
+ termLog(` ${BOLD}Status:${RESET} ${status} v${d.version}`);
210
+ if (d.error) termLog(` ${BOLD}Error:${RESET} ${RED}${d.error}${RESET}`);
211
+ termLog(` ${BOLD}Objects:${RESET} ${d.objectCount} total`);
212
+ termLog(` ${d.meshCount} meshes, ${d.groupCount} groups, ${d.lightCount} lights, ${d.cameraCount} cameras`);
213
+ termLog(` ${BOLD}DOM:${RESET} ${d.materializedDomNodes}/${d.maxDomNodes} materialized`);
214
+ termLog(` ${BOLD}Canvas:${RESET} ${d.canvasWidth}\xD7${d.canvasHeight}`);
215
+ termLog(` ${BOLD}GPU:${RESET} ${d.webglRenderer}`);
216
+ termLog(` ${BOLD}Dirty:${RESET} ${d.dirtyQueueSize} queued updates`);
217
+ }
218
+ };
219
+
220
+ // src/reporterState.ts
221
+ var _reporter = null;
222
+ function _setReporter(r) {
223
+ _reporter = r;
224
+ }
225
+ function _getReporter() {
226
+ return _reporter;
227
+ }
228
+
1
229
  // src/commands.ts
230
+ var _activeCanvasId = null;
231
+ function _getActiveCanvasId() {
232
+ return _activeCanvasId;
233
+ }
2
234
  function getR3F(win) {
3
- const api = win.__R3F_DOM__;
235
+ const w = win;
236
+ const api = _activeCanvasId ? w.__R3F_DOM_INSTANCES__?.[_activeCanvasId] : w.__R3F_DOM__;
4
237
  if (!api) {
5
238
  throw new Error(
6
- "react-three-dom bridge not found. Is <ThreeDom> mounted in your app?"
239
+ `react-three-dom bridge not found${_activeCanvasId ? ` (canvas: "${_activeCanvasId}")` : ""}. Is <ThreeDom${_activeCanvasId ? ` canvasId="${_activeCanvasId}"` : ""}> mounted in your app?`
7
240
  );
8
241
  }
9
242
  return api;
10
243
  }
244
+ var AUTO_WAIT_TIMEOUT = 5e3;
245
+ var AUTO_WAIT_POLL_MS = 100;
246
+ function resolveApi(win) {
247
+ const w = win;
248
+ return _activeCanvasId ? w.__R3F_DOM_INSTANCES__?.[_activeCanvasId] : w.__R3F_DOM__;
249
+ }
250
+ function autoWaitForBridge(timeout = AUTO_WAIT_TIMEOUT) {
251
+ const deadline = Date.now() + timeout;
252
+ function poll() {
253
+ return cy.window({ log: false }).then((win) => {
254
+ const api = resolveApi(win);
255
+ if (api && api._ready) return api;
256
+ if (api && !api._ready && api._error) {
257
+ throw new Error(
258
+ `[react-three-dom] Bridge initialization failed: ${api._error}`
259
+ );
260
+ }
261
+ if (Date.now() > deadline) {
262
+ throw new Error(
263
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not ready${_activeCanvasId ? ` (canvas: "${_activeCanvasId}")` : ""}.
264
+ Ensure <ThreeDom${_activeCanvasId ? ` canvasId="${_activeCanvasId}"` : ""}> is mounted inside your <Canvas> component.`
265
+ );
266
+ }
267
+ return cy.wait(AUTO_WAIT_POLL_MS, { log: false }).then(() => poll());
268
+ });
269
+ }
270
+ return poll();
271
+ }
272
+ function autoWaitForObject(idOrUuid, timeout = AUTO_WAIT_TIMEOUT) {
273
+ const deadline = Date.now() + timeout;
274
+ function poll() {
275
+ return cy.window({ log: false }).then((win) => {
276
+ const api = resolveApi(win);
277
+ if (!api) {
278
+ if (Date.now() > deadline) {
279
+ throw new Error(
280
+ `[react-three-dom] Auto-wait timed out after ${timeout}ms: bridge not found${_activeCanvasId ? ` (canvas: "${_activeCanvasId}")` : ""}.`
281
+ );
282
+ }
283
+ return cy.wait(AUTO_WAIT_POLL_MS, { log: false }).then(() => poll());
284
+ }
285
+ if (!api._ready && api._error) {
286
+ throw new Error(
287
+ `[react-three-dom] Bridge initialization failed: ${api._error}`
288
+ );
289
+ }
290
+ if (api._ready) {
291
+ const found = (api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid)) !== null;
292
+ if (found) return api;
293
+ }
294
+ if (Date.now() > deadline) {
295
+ let msg = `[react-three-dom] Auto-wait timed out after ${timeout}ms: object "${idOrUuid}" not found${_activeCanvasId ? ` (canvas: "${_activeCanvasId}")` : ""}.
296
+ Bridge: ready=${api._ready}, objectCount=${api.getCount()}.
297
+ Ensure the object has userData.testId="${idOrUuid}" or uuid="${idOrUuid}".`;
298
+ if (typeof api.fuzzyFind === "function") {
299
+ const suggestions = api.fuzzyFind(idOrUuid, 5);
300
+ if (suggestions.length > 0) {
301
+ msg += "\nDid you mean:\n" + suggestions.map((s) => {
302
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
303
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
304
+ }).join("\n");
305
+ }
306
+ }
307
+ throw new Error(msg);
308
+ }
309
+ return cy.wait(AUTO_WAIT_POLL_MS, { log: false }).then(() => poll());
310
+ });
311
+ }
312
+ return poll();
313
+ }
11
314
  function formatCypressSceneTree(node, prefix = "", isLast = true) {
12
315
  const connector = prefix === "" ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
13
316
  const childPrefix = prefix === "" ? "" : prefix + (isLast ? " " : "\u2502 ");
@@ -24,6 +327,26 @@ function formatCypressSceneTree(node, prefix = "", isLast = true) {
24
327
  return result;
25
328
  }
26
329
  function registerCommands() {
330
+ Cypress.Commands.add("r3fUseCanvas", (canvasId) => {
331
+ _activeCanvasId = canvasId;
332
+ Cypress.log({
333
+ name: "r3fUseCanvas",
334
+ message: canvasId ? `Switched to canvas "${canvasId}"` : "Switched to default canvas"
335
+ });
336
+ });
337
+ Cypress.Commands.add("r3fGetCanvasIds", () => {
338
+ return cy.window({ log: false }).then((win) => {
339
+ const instances = win.__R3F_DOM_INSTANCES__;
340
+ return instances ? Object.keys(instances) : [];
341
+ });
342
+ });
343
+ Cypress.Commands.add("r3fEnableReporter", (enabled = true) => {
344
+ if (enabled) {
345
+ _setReporter(new R3FReporter(true));
346
+ } else {
347
+ _setReporter(null);
348
+ }
349
+ });
27
350
  Cypress.Commands.add("r3fEnableDebug", () => {
28
351
  cy.window({ log: false }).then((win) => {
29
352
  win.__R3F_DOM_DEBUG__ = true;
@@ -44,56 +367,89 @@ function registerCommands() {
44
367
  });
45
368
  });
46
369
  Cypress.Commands.add("r3fClick", (idOrUuid) => {
47
- cy.window({ log: false }).then((win) => {
48
- getR3F(win).click(idOrUuid);
370
+ const reporter = _getReporter();
371
+ const t0 = Date.now();
372
+ reporter?.logInteraction("click", idOrUuid);
373
+ return autoWaitForObject(idOrUuid).then((api) => {
374
+ api.click(idOrUuid);
375
+ reporter?.logInteractionDone("click", idOrUuid, Date.now() - t0);
49
376
  });
50
377
  });
51
378
  Cypress.Commands.add("r3fDoubleClick", (idOrUuid) => {
52
- cy.window({ log: false }).then((win) => {
53
- getR3F(win).doubleClick(idOrUuid);
379
+ const reporter = _getReporter();
380
+ const t0 = Date.now();
381
+ reporter?.logInteraction("doubleClick", idOrUuid);
382
+ return autoWaitForObject(idOrUuid).then((api) => {
383
+ api.doubleClick(idOrUuid);
384
+ reporter?.logInteractionDone("doubleClick", idOrUuid, Date.now() - t0);
54
385
  });
55
386
  });
56
387
  Cypress.Commands.add("r3fContextMenu", (idOrUuid) => {
57
- cy.window({ log: false }).then((win) => {
58
- getR3F(win).contextMenu(idOrUuid);
388
+ const reporter = _getReporter();
389
+ const t0 = Date.now();
390
+ reporter?.logInteraction("contextMenu", idOrUuid);
391
+ return autoWaitForObject(idOrUuid).then((api) => {
392
+ api.contextMenu(idOrUuid);
393
+ reporter?.logInteractionDone("contextMenu", idOrUuid, Date.now() - t0);
59
394
  });
60
395
  });
61
396
  Cypress.Commands.add("r3fHover", (idOrUuid) => {
62
- cy.window({ log: false }).then((win) => {
63
- getR3F(win).hover(idOrUuid);
397
+ const reporter = _getReporter();
398
+ const t0 = Date.now();
399
+ reporter?.logInteraction("hover", idOrUuid);
400
+ return autoWaitForObject(idOrUuid).then((api) => {
401
+ api.hover(idOrUuid);
402
+ reporter?.logInteractionDone("hover", idOrUuid, Date.now() - t0);
403
+ });
404
+ });
405
+ Cypress.Commands.add("r3fUnhover", () => {
406
+ const reporter = _getReporter();
407
+ const t0 = Date.now();
408
+ reporter?.logInteraction("unhover", "(canvas)");
409
+ return autoWaitForBridge().then((api) => {
410
+ api.unhover();
411
+ reporter?.logInteractionDone("unhover", "(canvas)", Date.now() - t0);
64
412
  });
65
413
  });
66
414
  Cypress.Commands.add(
67
415
  "r3fDrag",
68
416
  (idOrUuid, delta) => {
69
- cy.window({ log: false }).then((win) => {
70
- return getR3F(win).drag(idOrUuid, delta);
417
+ return autoWaitForObject(idOrUuid).then((api) => {
418
+ return Cypress.Promise.resolve(api.drag(idOrUuid, delta));
71
419
  });
72
420
  }
73
421
  );
74
422
  Cypress.Commands.add(
75
423
  "r3fWheel",
76
424
  (idOrUuid, options) => {
77
- cy.window({ log: false }).then((win) => {
78
- getR3F(win).wheel(idOrUuid, options);
425
+ return autoWaitForObject(idOrUuid).then((api) => {
426
+ api.wheel(idOrUuid, options);
79
427
  });
80
428
  }
81
429
  );
82
430
  Cypress.Commands.add("r3fPointerMiss", () => {
83
- cy.window({ log: false }).then((win) => {
84
- getR3F(win).pointerMiss();
431
+ return autoWaitForBridge().then((api) => {
432
+ api.pointerMiss();
85
433
  });
86
434
  });
87
435
  Cypress.Commands.add("r3fSelect", (idOrUuid) => {
88
- cy.window({ log: false }).then((win) => {
89
- getR3F(win).select(idOrUuid);
436
+ return autoWaitForObject(idOrUuid).then((api) => {
437
+ api.select(idOrUuid);
90
438
  });
91
439
  });
92
440
  Cypress.Commands.add("r3fClearSelection", () => {
93
- cy.window({ log: false }).then((win) => {
94
- getR3F(win).clearSelection();
441
+ return autoWaitForBridge().then((api) => {
442
+ api.clearSelection();
95
443
  });
96
444
  });
445
+ Cypress.Commands.add(
446
+ "r3fDrawPath",
447
+ (points, options) => {
448
+ return autoWaitForBridge().then((api) => {
449
+ return Cypress.Promise.resolve(api.drawPath(points, options));
450
+ });
451
+ }
452
+ );
97
453
  Cypress.Commands.add("r3fLogScene", () => {
98
454
  return cy.window({ log: false }).then((win) => {
99
455
  const api = getR3F(win);
@@ -109,12 +465,98 @@ ${lines}`;
109
465
  console.log(`[r3f-dom] ${output}`);
110
466
  });
111
467
  });
468
+ Cypress.Commands.add("r3fGetDiagnostics", () => {
469
+ return cy.window({ log: false }).then((win) => {
470
+ const api = getR3F(win);
471
+ if (typeof api.getDiagnostics !== "function") return null;
472
+ return api.getDiagnostics();
473
+ });
474
+ });
475
+ Cypress.Commands.add("r3fLogDiagnostics", () => {
476
+ return cy.window({ log: false }).then((win) => {
477
+ const api = getR3F(win);
478
+ if (typeof api.getDiagnostics !== "function") {
479
+ Cypress.log({ name: "r3fLogDiagnostics", message: "getDiagnostics not available" });
480
+ return;
481
+ }
482
+ const d = api.getDiagnostics();
483
+ const lines = [
484
+ `Bridge Diagnostics`,
485
+ ` Status: ${d.ready ? "READY" : "NOT READY"} v${d.version}`,
486
+ d.error ? ` Error: ${d.error}` : null,
487
+ ` Objects: ${d.objectCount} total`,
488
+ ` ${d.meshCount} meshes, ${d.groupCount} groups, ${d.lightCount} lights, ${d.cameraCount} cameras`,
489
+ ` DOM: ${d.materializedDomNodes}/${d.maxDomNodes} materialized`,
490
+ ` Canvas: ${d.canvasWidth}\xD7${d.canvasHeight}`,
491
+ ` GPU: ${d.webglRenderer}`,
492
+ ` Dirty: ${d.dirtyQueueSize} queued updates`
493
+ ].filter(Boolean).join("\n");
494
+ Cypress.log({
495
+ name: "r3fLogDiagnostics",
496
+ message: `v${d.version} ${d.objectCount} objects`,
497
+ consoleProps: () => ({ diagnostics: d, formatted: lines })
498
+ });
499
+ console.log(`[r3f-dom] ${lines}`);
500
+ });
501
+ });
112
502
  Cypress.Commands.add("r3fGetObject", (idOrUuid) => {
113
503
  return cy.window({ log: false }).then((win) => {
114
504
  const api = getR3F(win);
115
505
  return api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid) ?? null;
116
506
  });
117
507
  });
508
+ Cypress.Commands.add("r3fGetByTestId", (testId) => {
509
+ return cy.window({ log: false }).then((win) => {
510
+ return getR3F(win).getByTestId(testId);
511
+ });
512
+ });
513
+ Cypress.Commands.add("r3fGetByName", (name) => {
514
+ return cy.window({ log: false }).then((win) => {
515
+ return getR3F(win).getByName(name);
516
+ });
517
+ });
518
+ Cypress.Commands.add("r3fGetByUuid", (uuid) => {
519
+ return cy.window({ log: false }).then((win) => {
520
+ return getR3F(win).getByUuid(uuid);
521
+ });
522
+ });
523
+ Cypress.Commands.add("r3fGetChildren", (idOrUuid) => {
524
+ return cy.window({ log: false }).then((win) => {
525
+ return getR3F(win).getChildren(idOrUuid);
526
+ });
527
+ });
528
+ Cypress.Commands.add("r3fGetParent", (idOrUuid) => {
529
+ return cy.window({ log: false }).then((win) => {
530
+ return getR3F(win).getParent(idOrUuid);
531
+ });
532
+ });
533
+ Cypress.Commands.add("r3fGetCanvas", () => {
534
+ return cy.get("[data-r3f-canvas]");
535
+ });
536
+ Cypress.Commands.add("r3fGetWorldPosition", (idOrUuid) => {
537
+ return cy.window({ log: false }).then((win) => {
538
+ const api = getR3F(win);
539
+ const insp = api.inspect(idOrUuid);
540
+ if (!insp?.worldMatrix || insp.worldMatrix.length < 15) return null;
541
+ const m = insp.worldMatrix;
542
+ return [m[12], m[13], m[14]];
543
+ });
544
+ });
545
+ Cypress.Commands.add("r3fDiffSnapshots", (before, after) => {
546
+ return cy.wrap(diffSnapshots(before, after), { log: false });
547
+ });
548
+ Cypress.Commands.add("r3fTrackObjectCount", (action) => {
549
+ return cy.window({ log: false }).then((win) => {
550
+ const before = getR3F(win).snapshot();
551
+ return action().then(
552
+ () => cy.window({ log: false }).then((w) => {
553
+ const after = getR3F(w).snapshot();
554
+ const d = diffSnapshots(before, after);
555
+ return { added: d.added.length, removed: d.removed.length };
556
+ })
557
+ );
558
+ });
559
+ });
118
560
  Cypress.Commands.add("r3fInspect", (idOrUuid) => {
119
561
  return cy.window({ log: false }).then((win) => {
120
562
  return getR3F(win).inspect(idOrUuid);
@@ -130,15 +572,6 @@ ${lines}`;
130
572
  return getR3F(win).getCount();
131
573
  });
132
574
  });
133
- Cypress.Commands.add(
134
- "r3fDrawPath",
135
- (points, options) => {
136
- return cy.window({ log: false }).then(async (win) => {
137
- const api = getR3F(win);
138
- return api.drawPath(points, options);
139
- });
140
- }
141
- );
142
575
  Cypress.Commands.add("r3fGetByType", (type) => {
143
576
  return cy.window({ log: false }).then((win) => {
144
577
  return getR3F(win).getByType(type);
@@ -159,20 +592,81 @@ ${lines}`;
159
592
  return getR3F(win).getObjects(ids);
160
593
  });
161
594
  });
595
+ Cypress.Commands.add("r3fGetByGeometryType", (type) => {
596
+ return cy.window({ log: false }).then((win) => {
597
+ return getR3F(win).getByGeometryType(type);
598
+ });
599
+ });
600
+ Cypress.Commands.add("r3fGetByMaterialType", (type) => {
601
+ return cy.window({ log: false }).then((win) => {
602
+ return getR3F(win).getByMaterialType(type);
603
+ });
604
+ });
605
+ Cypress.Commands.add("r3fFuzzyFind", (query, limit) => {
606
+ return cy.window({ log: false }).then((win) => {
607
+ const api = getR3F(win);
608
+ if (typeof api.fuzzyFind !== "function") return [];
609
+ return api.fuzzyFind(query, limit);
610
+ });
611
+ });
612
+ Cypress.Commands.add("r3fGetCameraState", () => {
613
+ return cy.window({ log: false }).then((win) => {
614
+ const api = getR3F(win);
615
+ return api.getCameraState();
616
+ });
617
+ });
162
618
  }
163
619
 
164
620
  // src/assertions.ts
165
621
  function getR3FFromWindow() {
166
622
  const win = cy.state("window");
167
- const api = win?.__R3F_DOM__;
623
+ const cid = _getActiveCanvasId();
624
+ const api = cid ? win?.__R3F_DOM_INSTANCES__?.[cid] : win?.__R3F_DOM__;
168
625
  if (!api) {
169
- throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
626
+ throw new Error(
627
+ `react-three-dom bridge not found${cid ? ` (canvas: "${cid}")` : ""}. Is <ThreeDom${cid ? ` canvasId="${cid}"` : ""}> mounted?`
628
+ );
170
629
  }
171
630
  return api;
172
631
  }
173
632
  function resolveObject(api, idOrUuid) {
174
633
  return api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid) ?? null;
175
634
  }
635
+ function requireObject(api, idOrUuid, matcherName) {
636
+ const meta = resolveObject(api, idOrUuid);
637
+ if (!meta) {
638
+ let msg = `[${matcherName}] 3D object "${idOrUuid}" not found in the scene`;
639
+ if (typeof api.fuzzyFind === "function") {
640
+ const suggestions = api.fuzzyFind(idOrUuid, 5);
641
+ if (suggestions.length > 0) {
642
+ msg += "\nDid you mean:\n" + suggestions.map((s) => {
643
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
644
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
645
+ }).join("\n");
646
+ }
647
+ }
648
+ throw new Error(msg);
649
+ }
650
+ return meta;
651
+ }
652
+ function requireInspection(api, idOrUuid, matcherName) {
653
+ const insp = api.inspect(idOrUuid);
654
+ if (!insp) {
655
+ throw new Error(`[${matcherName}] 3D object "${idOrUuid}" not found for inspection`);
656
+ }
657
+ return insp;
658
+ }
659
+ function collectTriangles(api) {
660
+ const snap = api.snapshot();
661
+ let total = 0;
662
+ function walk(node) {
663
+ const meta = api.getByUuid(node.uuid);
664
+ if (meta?.triangleCount) total += meta.triangleCount;
665
+ for (const child of node.children) walk(child);
666
+ }
667
+ walk(snap.tree);
668
+ return total;
669
+ }
176
670
  function registerAssertions() {
177
671
  chai.use((_chai) => {
178
672
  const { Assertion } = _chai;
@@ -189,99 +683,462 @@ function registerAssertions() {
189
683
  });
190
684
  Assertion.addMethod("r3fVisible", function(idOrUuid) {
191
685
  const api = getR3FFromWindow();
192
- const meta = resolveObject(api, idOrUuid);
193
- if (!meta) {
194
- throw new Error(`3D object "${idOrUuid}" not found in the scene`);
195
- }
686
+ const meta = requireObject(api, idOrUuid, "r3fVisible");
196
687
  this.assert(
197
688
  meta.visible,
198
- `expected 3D object "${idOrUuid}" to be visible`,
199
- `expected 3D object "${idOrUuid}" to NOT be visible`,
689
+ `expected "${idOrUuid}" to be visible`,
690
+ `expected "${idOrUuid}" to NOT be visible`,
200
691
  true,
201
692
  meta.visible
202
693
  );
203
694
  });
204
- Assertion.addMethod("r3fInFrustum", function(idOrUuid) {
695
+ Assertion.addMethod("r3fPosition", function(idOrUuid, expected, tolerance = 0.01) {
696
+ const api = getR3FFromWindow();
697
+ const meta = requireObject(api, idOrUuid, "r3fPosition");
698
+ const pass = expected.every((v, i) => Math.abs(meta.position[i] - v) <= tolerance);
699
+ this.assert(
700
+ pass,
701
+ `expected "${idOrUuid}" at [${expected}] (\xB1${tolerance}), got [${meta.position}]`,
702
+ `expected "${idOrUuid}" to NOT be at [${expected}] (\xB1${tolerance})`,
703
+ expected,
704
+ meta.position
705
+ );
706
+ });
707
+ Assertion.addMethod("r3fWorldPosition", function(idOrUuid, expected, tolerance = 0.01) {
708
+ const api = getR3FFromWindow();
709
+ const insp = requireInspection(api, idOrUuid, "r3fWorldPosition");
710
+ const m = insp.worldMatrix;
711
+ const wp = [m[12], m[13], m[14]];
712
+ const pass = expected.every((v, i) => Math.abs(wp[i] - v) <= tolerance);
713
+ this.assert(
714
+ pass,
715
+ `expected "${idOrUuid}" world position [${expected}] (\xB1${tolerance}), got [${wp.map((v) => v.toFixed(4))}]`,
716
+ `expected "${idOrUuid}" to NOT have world position [${expected}] (\xB1${tolerance})`,
717
+ expected,
718
+ wp
719
+ );
720
+ });
721
+ Assertion.addMethod("r3fRotation", function(idOrUuid, expected, tolerance = 0.01) {
722
+ const api = getR3FFromWindow();
723
+ const meta = requireObject(api, idOrUuid, "r3fRotation");
724
+ const pass = expected.every((v, i) => Math.abs(meta.rotation[i] - v) <= tolerance);
725
+ this.assert(
726
+ pass,
727
+ `expected "${idOrUuid}" rotation [${expected}] (\xB1${tolerance}), got [${meta.rotation}]`,
728
+ `expected "${idOrUuid}" to NOT have rotation [${expected}] (\xB1${tolerance})`,
729
+ expected,
730
+ meta.rotation
731
+ );
732
+ });
733
+ Assertion.addMethod("r3fScale", function(idOrUuid, expected, tolerance = 0.01) {
734
+ const api = getR3FFromWindow();
735
+ const meta = requireObject(api, idOrUuid, "r3fScale");
736
+ const pass = expected.every((v, i) => Math.abs(meta.scale[i] - v) <= tolerance);
737
+ this.assert(
738
+ pass,
739
+ `expected "${idOrUuid}" scale [${expected}] (\xB1${tolerance}), got [${meta.scale}]`,
740
+ `expected "${idOrUuid}" to NOT have scale [${expected}] (\xB1${tolerance})`,
741
+ expected,
742
+ meta.scale
743
+ );
744
+ });
745
+ Assertion.addMethod("r3fType", function(idOrUuid, expectedType) {
746
+ const api = getR3FFromWindow();
747
+ const meta = requireObject(api, idOrUuid, "r3fType");
748
+ this.assert(
749
+ meta.type === expectedType,
750
+ `expected "${idOrUuid}" type "${expectedType}", got "${meta.type}"`,
751
+ `expected "${idOrUuid}" to NOT have type "${expectedType}"`,
752
+ expectedType,
753
+ meta.type
754
+ );
755
+ });
756
+ Assertion.addMethod("r3fName", function(idOrUuid, expectedName) {
757
+ const api = getR3FFromWindow();
758
+ const meta = requireObject(api, idOrUuid, "r3fName");
759
+ this.assert(
760
+ meta.name === expectedName,
761
+ `expected "${idOrUuid}" name "${expectedName}", got "${meta.name}"`,
762
+ `expected "${idOrUuid}" to NOT have name "${expectedName}"`,
763
+ expectedName,
764
+ meta.name
765
+ );
766
+ });
767
+ Assertion.addMethod("r3fGeometryType", function(idOrUuid, expectedGeo) {
768
+ const api = getR3FFromWindow();
769
+ const meta = requireObject(api, idOrUuid, "r3fGeometryType");
770
+ this.assert(
771
+ meta.geometryType === expectedGeo,
772
+ `expected "${idOrUuid}" geometry "${expectedGeo}", got "${meta.geometryType ?? "none"}"`,
773
+ `expected "${idOrUuid}" to NOT have geometry "${expectedGeo}"`,
774
+ expectedGeo,
775
+ meta.geometryType
776
+ );
777
+ });
778
+ Assertion.addMethod("r3fMaterialType", function(idOrUuid, expectedMat) {
779
+ const api = getR3FFromWindow();
780
+ const meta = requireObject(api, idOrUuid, "r3fMaterialType");
781
+ this.assert(
782
+ meta.materialType === expectedMat,
783
+ `expected "${idOrUuid}" material "${expectedMat}", got "${meta.materialType ?? "none"}"`,
784
+ `expected "${idOrUuid}" to NOT have material "${expectedMat}"`,
785
+ expectedMat,
786
+ meta.materialType
787
+ );
788
+ });
789
+ Assertion.addMethod("r3fChildCount", function(idOrUuid, expectedCount) {
205
790
  const api = getR3FFromWindow();
206
- const inspection = api.inspect(idOrUuid);
207
- if (!inspection) {
208
- throw new Error(`3D object "${idOrUuid}" not found in the scene`);
791
+ const meta = requireObject(api, idOrUuid, "r3fChildCount");
792
+ const actual = meta.childrenUuids.length;
793
+ this.assert(
794
+ actual === expectedCount,
795
+ `expected "${idOrUuid}" ${expectedCount} children, got ${actual}`,
796
+ `expected "${idOrUuid}" to NOT have ${expectedCount} children`,
797
+ expectedCount,
798
+ actual
799
+ );
800
+ });
801
+ Assertion.addMethod("r3fParent", function(idOrUuid, expectedParent) {
802
+ const api = getR3FFromWindow();
803
+ const meta = requireObject(api, idOrUuid, "r3fParent");
804
+ if (!meta.parentUuid) {
805
+ throw new Error(`[r3fParent] "${idOrUuid}" has no parent (is scene root)`);
209
806
  }
210
- const { bounds } = inspection;
211
- const isFinite = (v) => v.every(Number.isFinite);
212
- const inFrustum = isFinite(bounds.min) && isFinite(bounds.max);
807
+ const parentMeta = api.getByUuid(meta.parentUuid);
808
+ const pass = parentMeta !== null && (parentMeta.uuid === expectedParent || parentMeta.testId === expectedParent || parentMeta.name === expectedParent);
809
+ const parentLabel = parentMeta?.testId ?? parentMeta?.name ?? meta.parentUuid;
810
+ this.assert(
811
+ pass,
812
+ `expected "${idOrUuid}" parent "${expectedParent}", got "${parentLabel}"`,
813
+ `expected "${idOrUuid}" to NOT have parent "${expectedParent}"`,
814
+ expectedParent,
815
+ parentLabel
816
+ );
817
+ });
818
+ Assertion.addMethod("r3fInstanceCount", function(idOrUuid, expectedCount) {
819
+ const api = getR3FFromWindow();
820
+ const meta = requireObject(api, idOrUuid, "r3fInstanceCount");
821
+ const actual = meta.instanceCount ?? 0;
822
+ this.assert(
823
+ actual === expectedCount,
824
+ `expected "${idOrUuid}" instance count ${expectedCount}, got ${actual}`,
825
+ `expected "${idOrUuid}" to NOT have instance count ${expectedCount}`,
826
+ expectedCount,
827
+ actual
828
+ );
829
+ });
830
+ Assertion.addMethod("r3fInFrustum", function(idOrUuid) {
831
+ const api = getR3FFromWindow();
832
+ const insp = requireInspection(api, idOrUuid, "r3fInFrustum");
833
+ const fin = (v) => v.every(Number.isFinite);
834
+ const pass = fin(insp.bounds.min) && fin(insp.bounds.max);
213
835
  this.assert(
214
- inFrustum,
215
- `expected 3D object "${idOrUuid}" to be in the camera frustum`,
216
- `expected 3D object "${idOrUuid}" to NOT be in the camera frustum`,
836
+ pass,
837
+ `expected "${idOrUuid}" to be in the camera frustum`,
838
+ `expected "${idOrUuid}" to NOT be in the camera frustum`,
217
839
  "in frustum",
218
- inFrustum ? "in frustum" : "out of frustum / invalid bounds"
840
+ pass ? "in frustum" : "invalid bounds"
219
841
  );
220
842
  });
221
- Assertion.addMethod(
222
- "r3fPosition",
223
- function(idOrUuid, expected, tolerance = 0.01) {
224
- const api = getR3FFromWindow();
225
- const meta = resolveObject(api, idOrUuid);
226
- if (!meta) {
227
- throw new Error(`3D object "${idOrUuid}" not found in the scene`);
228
- }
229
- const [ex, ey, ez] = expected;
230
- const [ax, ay, az] = meta.position;
231
- const pass = Math.abs(ax - ex) <= tolerance && Math.abs(ay - ey) <= tolerance && Math.abs(az - ez) <= tolerance;
843
+ Assertion.addMethod("r3fBounds", function(idOrUuid, expected, tolerance = 0.1) {
844
+ const api = getR3FFromWindow();
845
+ const insp = requireInspection(api, idOrUuid, "r3fBounds");
846
+ const w = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
847
+ const pass = w(insp.bounds.min, expected.min) && w(insp.bounds.max, expected.max);
848
+ this.assert(
849
+ pass,
850
+ `expected "${idOrUuid}" bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)} (\xB1${tolerance}), got min:${JSON.stringify(insp.bounds.min)} max:${JSON.stringify(insp.bounds.max)}`,
851
+ `expected "${idOrUuid}" bounds to NOT match`,
852
+ expected,
853
+ insp.bounds
854
+ );
855
+ });
856
+ Assertion.addMethod("r3fColor", function(idOrUuid, expectedColor) {
857
+ const api = getR3FFromWindow();
858
+ const insp = requireInspection(api, idOrUuid, "r3fColor");
859
+ const norm = expectedColor.startsWith("#") ? expectedColor.toLowerCase() : `#${expectedColor.toLowerCase()}`;
860
+ const actual = insp.material?.color?.toLowerCase();
861
+ this.assert(
862
+ actual === norm,
863
+ `expected "${idOrUuid}" color "${norm}", got "${actual ?? "no color"}"`,
864
+ `expected "${idOrUuid}" to NOT have color "${norm}"`,
865
+ norm,
866
+ actual
867
+ );
868
+ });
869
+ Assertion.addMethod("r3fOpacity", function(idOrUuid, expectedOpacity, tolerance = 0.01) {
870
+ const api = getR3FFromWindow();
871
+ const insp = requireInspection(api, idOrUuid, "r3fOpacity");
872
+ const actual = insp.material?.opacity;
873
+ const pass = actual !== void 0 && Math.abs(actual - expectedOpacity) <= tolerance;
874
+ this.assert(
875
+ pass,
876
+ `expected "${idOrUuid}" opacity ${expectedOpacity} (\xB1${tolerance}), got ${actual ?? "no material"}`,
877
+ `expected "${idOrUuid}" to NOT have opacity ${expectedOpacity} (\xB1${tolerance})`,
878
+ expectedOpacity,
879
+ actual
880
+ );
881
+ });
882
+ Assertion.addMethod("r3fTransparent", function(idOrUuid) {
883
+ const api = getR3FFromWindow();
884
+ const insp = requireInspection(api, idOrUuid, "r3fTransparent");
885
+ const pass = insp.material?.transparent === true;
886
+ this.assert(
887
+ pass,
888
+ `expected "${idOrUuid}" transparent=true, got ${insp.material?.transparent ?? "no material"}`,
889
+ `expected "${idOrUuid}" to NOT be transparent`,
890
+ true,
891
+ insp.material?.transparent
892
+ );
893
+ });
894
+ Assertion.addMethod("r3fVertexCount", function(idOrUuid, expectedCount) {
895
+ const api = getR3FFromWindow();
896
+ const meta = requireObject(api, idOrUuid, "r3fVertexCount");
897
+ const actual = meta.vertexCount ?? 0;
898
+ this.assert(
899
+ actual === expectedCount,
900
+ `expected "${idOrUuid}" ${expectedCount} vertices, got ${actual}`,
901
+ `expected "${idOrUuid}" to NOT have ${expectedCount} vertices`,
902
+ expectedCount,
903
+ actual
904
+ );
905
+ });
906
+ Assertion.addMethod("r3fTriangleCount", function(idOrUuid, expectedCount) {
907
+ const api = getR3FFromWindow();
908
+ const meta = requireObject(api, idOrUuid, "r3fTriangleCount");
909
+ const actual = meta.triangleCount ?? 0;
910
+ this.assert(
911
+ actual === expectedCount,
912
+ `expected "${idOrUuid}" ${expectedCount} triangles, got ${actual}`,
913
+ `expected "${idOrUuid}" to NOT have ${expectedCount} triangles`,
914
+ expectedCount,
915
+ actual
916
+ );
917
+ });
918
+ Assertion.addMethod("r3fUserData", function(idOrUuid, key, expectedValue) {
919
+ const api = getR3FFromWindow();
920
+ const insp = requireInspection(api, idOrUuid, "r3fUserData");
921
+ const hasKey = key in insp.userData;
922
+ if (expectedValue === void 0) {
923
+ this.assert(
924
+ hasKey,
925
+ `expected "${idOrUuid}" to have userData key "${key}"`,
926
+ `expected "${idOrUuid}" to NOT have userData key "${key}"`,
927
+ `key "${key}"`,
928
+ hasKey ? "present" : "missing"
929
+ );
930
+ } else {
931
+ const actual = insp.userData[key];
932
+ const pass = hasKey && JSON.stringify(actual) === JSON.stringify(expectedValue);
232
933
  this.assert(
233
934
  pass,
234
- `expected 3D object "${idOrUuid}" to be at [${expected}] (\xB1${tolerance}), but was at [${meta.position}]`,
235
- `expected 3D object "${idOrUuid}" to NOT be at [${expected}] (\xB1${tolerance})`,
236
- expected,
237
- meta.position
935
+ `expected "${idOrUuid}" userData.${key} = ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actual)}`,
936
+ `expected "${idOrUuid}" to NOT have userData.${key} = ${JSON.stringify(expectedValue)}`,
937
+ expectedValue,
938
+ actual
238
939
  );
239
940
  }
240
- );
241
- Assertion.addMethod(
242
- "r3fInstanceCount",
243
- function(idOrUuid, expectedCount) {
244
- const api = getR3FFromWindow();
245
- const meta = resolveObject(api, idOrUuid);
246
- if (!meta) {
247
- throw new Error(`3D object "${idOrUuid}" not found in the scene`);
248
- }
249
- const actual = meta.instanceCount ?? 0;
941
+ });
942
+ Assertion.addMethod("r3fMapTexture", function(idOrUuid, expectedName) {
943
+ const api = getR3FFromWindow();
944
+ const insp = requireInspection(api, idOrUuid, "r3fMapTexture");
945
+ const actual = insp.material?.map;
946
+ if (!expectedName) {
250
947
  this.assert(
251
- actual === expectedCount,
252
- `expected 3D object "${idOrUuid}" to have instance count ${expectedCount}, but had ${actual}`,
253
- `expected 3D object "${idOrUuid}" to NOT have instance count ${expectedCount}`,
254
- expectedCount,
948
+ actual !== void 0 && actual !== null,
949
+ `expected "${idOrUuid}" to have a map texture`,
950
+ `expected "${idOrUuid}" to NOT have a map texture`,
951
+ "any map",
255
952
  actual
256
953
  );
257
- }
258
- );
259
- Assertion.addMethod(
260
- "r3fBounds",
261
- function(idOrUuid, expected, tolerance = 0.1) {
262
- const api = getR3FFromWindow();
263
- const inspection = api.inspect(idOrUuid);
264
- if (!inspection) {
265
- throw new Error(`3D object "${idOrUuid}" not found in the scene`);
266
- }
267
- const { bounds } = inspection;
268
- const withinTol = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
269
- const pass = withinTol(bounds.min, expected.min) && withinTol(bounds.max, expected.max);
954
+ } else {
270
955
  this.assert(
271
- pass,
272
- `expected 3D object "${idOrUuid}" bounds to be min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)} (\xB1${tolerance}), but got min:${JSON.stringify(bounds.min)} max:${JSON.stringify(bounds.max)}`,
273
- `expected 3D object "${idOrUuid}" bounds to NOT match`,
274
- expected,
275
- bounds
956
+ actual === expectedName,
957
+ `expected "${idOrUuid}" map "${expectedName}", got "${actual ?? "none"}"`,
958
+ `expected "${idOrUuid}" to NOT have map "${expectedName}"`,
959
+ expectedName,
960
+ actual
276
961
  );
277
962
  }
278
- );
963
+ });
964
+ Assertion.addMethod("r3fObjectCount", function(expected) {
965
+ const api = getR3FFromWindow();
966
+ const actual = api.getCount();
967
+ this.assert(
968
+ actual === expected,
969
+ `expected scene to have ${expected} objects, got ${actual}`,
970
+ `expected scene to NOT have ${expected} objects`,
971
+ expected,
972
+ actual
973
+ );
974
+ });
975
+ Assertion.addMethod("r3fObjectCountGreaterThan", function(min) {
976
+ const api = getR3FFromWindow();
977
+ const actual = api.getCount();
978
+ this.assert(
979
+ actual > min,
980
+ `expected scene to have more than ${min} objects, got ${actual}`,
981
+ `expected scene to have at most ${min} objects, but has ${actual}`,
982
+ `> ${min}`,
983
+ actual
984
+ );
985
+ });
986
+ Assertion.addMethod("r3fCountByType", function(type, expected) {
987
+ const api = getR3FFromWindow();
988
+ const actual = api.getCountByType(type);
989
+ this.assert(
990
+ actual === expected,
991
+ `expected ${expected} "${type}" objects, got ${actual}`,
992
+ `expected scene to NOT have ${expected} "${type}" objects`,
993
+ expected,
994
+ actual
995
+ );
996
+ });
997
+ Assertion.addMethod("r3fTotalTriangleCount", function(expected) {
998
+ const api = getR3FFromWindow();
999
+ const actual = collectTriangles(api);
1000
+ this.assert(
1001
+ actual === expected,
1002
+ `expected ${expected} total triangles, got ${actual}`,
1003
+ `expected scene to NOT have ${expected} total triangles`,
1004
+ expected,
1005
+ actual
1006
+ );
1007
+ });
1008
+ Assertion.addMethod("r3fTotalTriangleCountLessThan", function(max) {
1009
+ const api = getR3FFromWindow();
1010
+ const actual = collectTriangles(api);
1011
+ this.assert(
1012
+ actual < max,
1013
+ `expected fewer than ${max} total triangles, got ${actual}`,
1014
+ `expected at least ${max} total triangles, but has ${actual}`,
1015
+ `< ${max}`,
1016
+ actual
1017
+ );
1018
+ });
1019
+ Assertion.addMethod("r3fCameraPosition", function(expected, tolerance = 0.1) {
1020
+ const api = getR3FFromWindow();
1021
+ const cam = api.getCameraState();
1022
+ const pass = expected.every((v, i) => Math.abs(cam.position[i] - v) <= tolerance);
1023
+ this.assert(
1024
+ pass,
1025
+ `expected camera at [${expected}], got [${cam.position}] (tol=${tolerance})`,
1026
+ `expected camera NOT at [${expected}]`,
1027
+ expected,
1028
+ cam.position
1029
+ );
1030
+ });
1031
+ Assertion.addMethod("r3fCameraFov", function(expected, tolerance = 0.1) {
1032
+ const api = getR3FFromWindow();
1033
+ const cam = api.getCameraState();
1034
+ const actual = cam.fov;
1035
+ const pass = actual !== void 0 && Math.abs(actual - expected) <= tolerance;
1036
+ this.assert(
1037
+ pass,
1038
+ `expected camera fov ${expected}, got ${actual ?? "N/A"} (tol=${tolerance})`,
1039
+ `expected camera fov NOT ${expected}`,
1040
+ expected,
1041
+ actual
1042
+ );
1043
+ });
1044
+ Assertion.addMethod("r3fCameraNear", function(expected, tolerance = 0.01) {
1045
+ const api = getR3FFromWindow();
1046
+ const cam = api.getCameraState();
1047
+ const pass = Math.abs(cam.near - expected) <= tolerance;
1048
+ this.assert(
1049
+ pass,
1050
+ `expected camera near ${expected}, got ${cam.near} (tol=${tolerance})`,
1051
+ `expected camera near NOT ${expected}`,
1052
+ expected,
1053
+ cam.near
1054
+ );
1055
+ });
1056
+ Assertion.addMethod("r3fCameraFar", function(expected, tolerance = 1) {
1057
+ const api = getR3FFromWindow();
1058
+ const cam = api.getCameraState();
1059
+ const pass = Math.abs(cam.far - expected) <= tolerance;
1060
+ this.assert(
1061
+ pass,
1062
+ `expected camera far ${expected}, got ${cam.far} (tol=${tolerance})`,
1063
+ `expected camera far NOT ${expected}`,
1064
+ expected,
1065
+ cam.far
1066
+ );
1067
+ });
1068
+ Assertion.addMethod("r3fCameraZoom", function(expected, tolerance = 0.01) {
1069
+ const api = getR3FFromWindow();
1070
+ const cam = api.getCameraState();
1071
+ const pass = Math.abs(cam.zoom - expected) <= tolerance;
1072
+ this.assert(
1073
+ pass,
1074
+ `expected camera zoom ${expected}, got ${cam.zoom} (tol=${tolerance})`,
1075
+ `expected camera zoom NOT ${expected}`,
1076
+ expected,
1077
+ cam.zoom
1078
+ );
1079
+ });
1080
+ Assertion.addMethod("r3fAllExist", function(idsOrPattern) {
1081
+ const api = getR3FFromWindow();
1082
+ const ids = typeof idsOrPattern === "string" ? resolvePatternSync(api, idsOrPattern) : idsOrPattern;
1083
+ const missing = ids.filter((id) => resolveObject(api, id) === null);
1084
+ this.assert(
1085
+ missing.length === 0 && ids.length > 0,
1086
+ `expected all objects to exist, missing: [${missing.join(", ")}]`,
1087
+ `expected some objects to NOT exist, but all do`,
1088
+ ids,
1089
+ { missing }
1090
+ );
1091
+ });
1092
+ Assertion.addMethod("r3fAllVisible", function(idsOrPattern) {
1093
+ const api = getR3FFromWindow();
1094
+ const ids = typeof idsOrPattern === "string" ? resolvePatternSync(api, idsOrPattern) : idsOrPattern;
1095
+ const hidden = ids.filter((id) => {
1096
+ const m = resolveObject(api, id);
1097
+ return !m || !m.visible;
1098
+ });
1099
+ this.assert(
1100
+ hidden.length === 0 && ids.length > 0,
1101
+ `expected all objects to be visible, hidden/missing: [${hidden.join(", ")}]`,
1102
+ `expected some objects to NOT be visible, but all are`,
1103
+ ids,
1104
+ { hidden }
1105
+ );
1106
+ });
1107
+ Assertion.addMethod("r3fNoneExist", function(idsOrPattern) {
1108
+ const api = getR3FFromWindow();
1109
+ const ids = typeof idsOrPattern === "string" ? resolvePatternSync(api, idsOrPattern) : idsOrPattern;
1110
+ const found = ids.filter((id) => resolveObject(api, id) !== null);
1111
+ this.assert(
1112
+ found.length === 0,
1113
+ `expected no objects to exist, but found: [${found.join(", ")}]`,
1114
+ `expected some objects to exist, but none do`,
1115
+ ids,
1116
+ { found }
1117
+ );
1118
+ });
279
1119
  });
280
1120
  }
1121
+ function resolvePatternSync(api, pattern) {
1122
+ const snap = api.snapshot();
1123
+ const ids = [];
1124
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
1125
+ function walk(node) {
1126
+ const testId = node.testId ?? node.name;
1127
+ if (regex.test(testId) || regex.test(node.uuid)) ids.push(testId || node.uuid);
1128
+ for (const child of node.children) walk(child);
1129
+ }
1130
+ walk(snap.tree);
1131
+ return ids;
1132
+ }
281
1133
 
282
1134
  // src/waiters.ts
1135
+ function resolveApiFromWindow(win) {
1136
+ const w = win;
1137
+ const cid = _getActiveCanvasId();
1138
+ return cid ? w.__R3F_DOM_INSTANCES__?.[cid] : w.__R3F_DOM__;
1139
+ }
283
1140
  function getBridgeState(win) {
284
- const api = win.__R3F_DOM__;
1141
+ const api = resolveApiFromWindow(win);
285
1142
  if (!api) return { exists: false, ready: false, error: null, count: 0 };
286
1143
  return {
287
1144
  exists: true,
@@ -292,6 +1149,8 @@ function getBridgeState(win) {
292
1149
  }
293
1150
  function assertBridgeNotErrored(state) {
294
1151
  if (state.exists && !state.ready && state.error) {
1152
+ const reporter = _getReporter();
1153
+ reporter?.logBridgeError(state.error);
295
1154
  throw new Error(
296
1155
  `[react-three-dom] Bridge initialization failed: ${state.error}
297
1156
  The <ThreeDom> component mounted but threw during setup. Check the browser console for the full stack trace.`
@@ -307,11 +1166,17 @@ function registerWaiters() {
307
1166
  pollIntervalMs = 100,
308
1167
  timeout = 1e4
309
1168
  } = options;
1169
+ const reporter = _getReporter();
1170
+ reporter?.logBridgeWaiting();
310
1171
  const startTime = Date.now();
311
1172
  let lastCount = -1;
312
1173
  let stableRuns = 0;
1174
+ let bridgeLogged = false;
313
1175
  function poll() {
314
1176
  if (Date.now() - startTime > timeout) {
1177
+ reporter?.logBridgeError(
1178
+ `Timed out after ${timeout}ms. Last count: ${lastCount}, stable: ${stableRuns}/${stableChecks}`
1179
+ );
315
1180
  throw new Error(
316
1181
  `r3fWaitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`
317
1182
  );
@@ -322,10 +1187,20 @@ function registerWaiters() {
322
1187
  if (!state.exists || !state.ready) {
323
1188
  return cy.wait(pollIntervalMs, { log: false }).then(() => poll());
324
1189
  }
1190
+ if (!bridgeLogged) {
1191
+ bridgeLogged = true;
1192
+ const api = resolveApiFromWindow(win);
1193
+ let diag;
1194
+ if (typeof api.getDiagnostics === "function") {
1195
+ diag = api.getDiagnostics();
1196
+ }
1197
+ reporter?.logBridgeConnected(diag);
1198
+ }
325
1199
  const count = state.count;
326
1200
  if (count === lastCount && count > 0) {
327
1201
  stableRuns++;
328
1202
  if (stableRuns >= stableChecks) {
1203
+ reporter?.logSceneReady(count);
329
1204
  return;
330
1205
  }
331
1206
  } else {
@@ -347,8 +1222,13 @@ function registerWaiters() {
347
1222
  timeout = 1e4
348
1223
  } = options;
349
1224
  const startTime = Date.now();
350
- let lastJson = "";
1225
+ let lastHash = "";
351
1226
  let stableCount = 0;
1227
+ function hashTree(node) {
1228
+ let h = `${node.uuid}:${node.visible ? 1 : 0}:${node.position[0].toFixed(3)},${node.position[1].toFixed(3)},${node.position[2].toFixed(3)}:${node.children.length}`;
1229
+ for (const c of node.children) h += "|" + hashTree(c);
1230
+ return h;
1231
+ }
352
1232
  function poll() {
353
1233
  if (Date.now() - startTime > timeout) {
354
1234
  throw new Error(`r3fWaitForIdle timed out after ${timeout}ms`);
@@ -359,10 +1239,10 @@ function registerWaiters() {
359
1239
  if (!state.exists || !state.ready) {
360
1240
  return cy.wait(pollIntervalMs, { log: false }).then(() => poll());
361
1241
  }
362
- const api = win.__R3F_DOM__;
1242
+ const api = resolveApiFromWindow(win);
363
1243
  const snap = api.snapshot();
364
- const json = JSON.stringify(snap.tree);
365
- if (json === lastJson && json !== "") {
1244
+ const hash = hashTree(snap.tree);
1245
+ if (hash === lastHash && hash !== "") {
366
1246
  stableCount++;
367
1247
  if (stableCount >= idleChecks) {
368
1248
  return;
@@ -370,7 +1250,7 @@ function registerWaiters() {
370
1250
  } else {
371
1251
  stableCount = 0;
372
1252
  }
373
- lastJson = json;
1253
+ lastHash = hash;
374
1254
  return cy.wait(pollIntervalMs, { log: false }).then(() => poll());
375
1255
  });
376
1256
  }
@@ -385,9 +1265,13 @@ function registerWaiters() {
385
1265
  objectTimeout = 4e4,
386
1266
  pollIntervalMs = 200
387
1267
  } = options;
1268
+ const reporter = _getReporter();
388
1269
  const bridgeStart = Date.now();
389
1270
  function waitForBridge() {
390
1271
  if (Date.now() - bridgeStart > bridgeTimeout) {
1272
+ reporter?.logBridgeError(
1273
+ `Timed out after ${bridgeTimeout}ms waiting for bridge`
1274
+ );
391
1275
  throw new Error(
392
1276
  `r3fWaitForObject("${idOrUuid}") timed out after ${bridgeTimeout}ms waiting for the bridge. Ensure <ThreeDom> is mounted inside your <Canvas>.`
393
1277
  );
@@ -405,19 +1289,33 @@ function registerWaiters() {
405
1289
  function pollForObject() {
406
1290
  if (Date.now() - objectStart > objectTimeout) {
407
1291
  return cy.window({ log: false }).then((win) => {
1292
+ const api = resolveApiFromWindow(win);
408
1293
  const state = getBridgeState(win);
409
- throw new Error(
410
- `r3fWaitForObject("${idOrUuid}") timed out after ${objectTimeout}ms. Bridge: ${state.exists ? "exists" : "missing"}, ready: ${state.ready}, objectCount: ${state.count}. Is the object rendered with userData.testId="${idOrUuid}" or uuid="${idOrUuid}"?`
411
- );
1294
+ let msg = `r3fWaitForObject("${idOrUuid}") timed out after ${objectTimeout}ms.
1295
+ Bridge: ${state.exists ? "exists" : "missing"}, ready: ${state.ready}, objectCount: ${state.count}.
1296
+ Ensure the object has userData.testId="${idOrUuid}" or uuid="${idOrUuid}".`;
1297
+ let suggestions = [];
1298
+ if (api && typeof api.fuzzyFind === "function") {
1299
+ suggestions = api.fuzzyFind(idOrUuid, 5);
1300
+ if (suggestions.length > 0) {
1301
+ msg += "\nDid you mean:\n" + suggestions.map((s) => {
1302
+ const id = s.testId ? `testId="${s.testId}"` : `uuid="${s.uuid}"`;
1303
+ return ` \u2192 ${s.name || "(unnamed)"} [${id}]`;
1304
+ }).join("\n");
1305
+ }
1306
+ }
1307
+ reporter?.logObjectNotFound(idOrUuid, suggestions);
1308
+ throw new Error(msg);
412
1309
  });
413
1310
  }
414
1311
  return cy.window({ log: false }).then((win) => {
415
- const api = win.__R3F_DOM__;
1312
+ const api = resolveApiFromWindow(win);
416
1313
  if (!api || !api._ready) {
417
1314
  return cy.wait(pollIntervalMs, { log: false }).then(() => pollForObject());
418
1315
  }
419
- const found = (api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid)) !== null;
420
- if (found) {
1316
+ const meta = api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid);
1317
+ if (meta) {
1318
+ reporter?.logObjectFound(idOrUuid, meta.type, meta.name || void 0);
421
1319
  Cypress.log({
422
1320
  name: "r3fWaitForObject",
423
1321
  message: `"${idOrUuid}" found`
@@ -450,7 +1348,7 @@ function registerWaiters() {
450
1348
  const state = getBridgeState(win);
451
1349
  assertBridgeNotErrored(state);
452
1350
  if (state.exists && state.ready) {
453
- const api = win.__R3F_DOM__;
1351
+ const api = resolveApiFromWindow(win);
454
1352
  const snap = api.snapshot();
455
1353
  collectUuids(snap.tree);
456
1354
  }
@@ -471,7 +1369,7 @@ function registerWaiters() {
471
1369
  const bridgeState = getBridgeState(w);
472
1370
  assertBridgeNotErrored(bridgeState);
473
1371
  if (!bridgeState.exists || !bridgeState.ready) return poll();
474
- const bridge = w.__R3F_DOM__;
1372
+ const bridge = resolveApiFromWindow(w);
475
1373
  const snap = bridge.snapshot();
476
1374
  const newObjects = [];
477
1375
  const newUuids = [];
@@ -508,11 +1406,63 @@ function registerWaiters() {
508
1406
  });
509
1407
  }
510
1408
  );
1409
+ Cypress.Commands.add(
1410
+ "r3fWaitForObjectRemoved",
1411
+ (idOrUuid, options = {}) => {
1412
+ const {
1413
+ bridgeTimeout = 3e4,
1414
+ pollIntervalMs = 100,
1415
+ timeout = 1e4
1416
+ } = options;
1417
+ const bridgeStart = Date.now();
1418
+ function waitForBridge() {
1419
+ if (Date.now() - bridgeStart > bridgeTimeout) {
1420
+ throw new Error(
1421
+ `r3fWaitForObjectRemoved("${idOrUuid}") timed out after ${bridgeTimeout}ms waiting for the bridge.`
1422
+ );
1423
+ }
1424
+ return cy.window({ log: false }).then((win) => {
1425
+ const state = getBridgeState(win);
1426
+ assertBridgeNotErrored(state);
1427
+ if (state.exists && state.ready) {
1428
+ return pollForRemoved();
1429
+ }
1430
+ return cy.wait(pollIntervalMs, { log: false }).then(() => waitForBridge());
1431
+ });
1432
+ }
1433
+ const removeDeadline = Date.now() + timeout;
1434
+ function pollForRemoved() {
1435
+ if (Date.now() > removeDeadline) {
1436
+ throw new Error(
1437
+ `r3fWaitForObjectRemoved("${idOrUuid}") timed out after ${timeout}ms. Object is still in the scene.`
1438
+ );
1439
+ }
1440
+ return cy.window({ log: false }).then((win) => {
1441
+ const api = resolveApiFromWindow(win);
1442
+ if (!api || !api._ready) {
1443
+ return cy.wait(pollIntervalMs, { log: false }).then(() => pollForRemoved());
1444
+ }
1445
+ const stillPresent = (api.getByTestId(idOrUuid) ?? api.getByUuid(idOrUuid)) !== null;
1446
+ if (!stillPresent) {
1447
+ Cypress.log({
1448
+ name: "r3fWaitForObjectRemoved",
1449
+ message: `"${idOrUuid}" removed`
1450
+ });
1451
+ return;
1452
+ }
1453
+ return cy.wait(pollIntervalMs, { log: false }).then(() => pollForRemoved());
1454
+ });
1455
+ }
1456
+ return waitForBridge();
1457
+ }
1458
+ );
511
1459
  }
512
1460
 
513
1461
  // src/index.ts
514
1462
  registerCommands();
515
1463
  registerAssertions();
516
1464
  registerWaiters();
1465
+
1466
+ export { R3FReporter, diffSnapshots, registerR3FTasks };
517
1467
  //# sourceMappingURL=index.js.map
518
1468
  //# sourceMappingURL=index.js.map