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