@react-three-dom/playwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,378 @@
1
+ import { test as test$1, expect as expect$1 } from '@playwright/test';
2
+
3
+ // src/fixtures.ts
4
+
5
+ // src/waiters.ts
6
+ async function waitForSceneReady(page, options = {}) {
7
+ const {
8
+ stableChecks = 3,
9
+ pollIntervalMs = 100,
10
+ timeout = 1e4
11
+ } = options;
12
+ const deadline = Date.now() + timeout;
13
+ await page.waitForFunction(() => typeof window.__R3F_DOM__ !== "undefined", void 0, {
14
+ timeout
15
+ });
16
+ let lastCount = -1;
17
+ let stableRuns = 0;
18
+ while (Date.now() < deadline) {
19
+ const count = await page.evaluate(() => window.__R3F_DOM__.getCount());
20
+ if (count === lastCount && count > 0) {
21
+ stableRuns++;
22
+ if (stableRuns >= stableChecks) return;
23
+ } else {
24
+ stableRuns = 0;
25
+ }
26
+ lastCount = count;
27
+ await page.waitForTimeout(pollIntervalMs);
28
+ }
29
+ throw new Error(
30
+ `waitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`
31
+ );
32
+ }
33
+ async function waitForIdle(page, options = {}) {
34
+ const {
35
+ idleFrames = 10,
36
+ timeout = 1e4
37
+ } = options;
38
+ const settled = await page.evaluate(
39
+ ([frames, timeoutMs]) => {
40
+ return new Promise((resolve) => {
41
+ const deadline = Date.now() + timeoutMs;
42
+ let lastJson = "";
43
+ let stableCount = 0;
44
+ function check() {
45
+ if (Date.now() > deadline) {
46
+ resolve(false);
47
+ return;
48
+ }
49
+ const snap = window.__R3F_DOM__?.snapshot();
50
+ const json = snap ? JSON.stringify(snap.tree) : "";
51
+ if (json === lastJson && json !== "") {
52
+ stableCount++;
53
+ if (stableCount >= frames) {
54
+ resolve(true);
55
+ return;
56
+ }
57
+ } else {
58
+ stableCount = 0;
59
+ }
60
+ lastJson = json;
61
+ requestAnimationFrame(check);
62
+ }
63
+ requestAnimationFrame(check);
64
+ });
65
+ },
66
+ [idleFrames, timeout]
67
+ );
68
+ if (!settled) {
69
+ throw new Error(`waitForIdle timed out after ${timeout}ms`);
70
+ }
71
+ }
72
+ async function click(page, idOrUuid) {
73
+ await page.evaluate((id) => {
74
+ const api = window.__R3F_DOM__;
75
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
76
+ api.click(id);
77
+ }, idOrUuid);
78
+ }
79
+ async function doubleClick(page, idOrUuid) {
80
+ await page.evaluate((id) => {
81
+ const api = window.__R3F_DOM__;
82
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
83
+ api.doubleClick(id);
84
+ }, idOrUuid);
85
+ }
86
+ async function contextMenu(page, idOrUuid) {
87
+ await page.evaluate((id) => {
88
+ const api = window.__R3F_DOM__;
89
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
90
+ api.contextMenu(id);
91
+ }, idOrUuid);
92
+ }
93
+ async function hover(page, idOrUuid) {
94
+ await page.evaluate((id) => {
95
+ const api = window.__R3F_DOM__;
96
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
97
+ api.hover(id);
98
+ }, idOrUuid);
99
+ }
100
+ async function drag(page, idOrUuid, delta) {
101
+ await page.evaluate(
102
+ ([id, d]) => {
103
+ const api = window.__R3F_DOM__;
104
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
105
+ api.drag(id, d);
106
+ },
107
+ [idOrUuid, delta]
108
+ );
109
+ }
110
+ async function wheel(page, idOrUuid, options) {
111
+ await page.evaluate(
112
+ ([id, opts]) => {
113
+ const api = window.__R3F_DOM__;
114
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
115
+ api.wheel(id, opts);
116
+ },
117
+ [idOrUuid, options]
118
+ );
119
+ }
120
+ async function pointerMiss(page) {
121
+ await page.evaluate(() => {
122
+ const api = window.__R3F_DOM__;
123
+ if (!api) throw new Error("react-three-dom bridge not found. Is <ThreeDom> mounted?");
124
+ api.pointerMiss();
125
+ });
126
+ }
127
+
128
+ // src/fixtures.ts
129
+ var R3FFixture = class {
130
+ constructor(_page) {
131
+ this._page = _page;
132
+ }
133
+ /** The underlying Playwright Page. */
134
+ get page() {
135
+ return this._page;
136
+ }
137
+ // -----------------------------------------------------------------------
138
+ // Queries
139
+ // -----------------------------------------------------------------------
140
+ /** Get object metadata by testId or uuid. Returns null if not found. */
141
+ async getObject(idOrUuid) {
142
+ return this._page.evaluate((id) => {
143
+ const api = window.__R3F_DOM__;
144
+ if (!api) return null;
145
+ return api.getByTestId(id) ?? api.getByUuid(id) ?? null;
146
+ }, idOrUuid);
147
+ }
148
+ /** Get heavy inspection data (Tier 2) by testId or uuid. */
149
+ async inspect(idOrUuid) {
150
+ return this._page.evaluate((id) => {
151
+ const api = window.__R3F_DOM__;
152
+ if (!api) return null;
153
+ return api.inspect(id);
154
+ }, idOrUuid);
155
+ }
156
+ /** Take a full scene snapshot. */
157
+ async snapshot() {
158
+ return this._page.evaluate(() => {
159
+ const api = window.__R3F_DOM__;
160
+ return api ? api.snapshot() : null;
161
+ });
162
+ }
163
+ /** Get the total number of tracked objects. */
164
+ async getCount() {
165
+ return this._page.evaluate(() => {
166
+ const api = window.__R3F_DOM__;
167
+ return api ? api.getCount() : 0;
168
+ });
169
+ }
170
+ // -----------------------------------------------------------------------
171
+ // Interactions
172
+ // -----------------------------------------------------------------------
173
+ /** Click a 3D object by testId or uuid. */
174
+ async click(idOrUuid) {
175
+ return click(this._page, idOrUuid);
176
+ }
177
+ /** Double-click a 3D object by testId or uuid. */
178
+ async doubleClick(idOrUuid) {
179
+ return doubleClick(this._page, idOrUuid);
180
+ }
181
+ /** Right-click / context-menu a 3D object by testId or uuid. */
182
+ async contextMenu(idOrUuid) {
183
+ return contextMenu(this._page, idOrUuid);
184
+ }
185
+ /** Hover over a 3D object by testId or uuid. */
186
+ async hover(idOrUuid) {
187
+ return hover(this._page, idOrUuid);
188
+ }
189
+ /** Drag a 3D object with a world-space delta vector. */
190
+ async drag(idOrUuid, delta) {
191
+ return drag(this._page, idOrUuid, delta);
192
+ }
193
+ /** Dispatch a wheel/scroll event on a 3D object. */
194
+ async wheel(idOrUuid, options) {
195
+ return wheel(this._page, idOrUuid, options);
196
+ }
197
+ /** Click empty space to trigger onPointerMissed handlers. */
198
+ async pointerMiss() {
199
+ return pointerMiss(this._page);
200
+ }
201
+ // -----------------------------------------------------------------------
202
+ // Waiters
203
+ // -----------------------------------------------------------------------
204
+ /**
205
+ * Wait until the scene is ready — `window.__R3F_DOM__` is available and
206
+ * the object count has stabilised across several consecutive checks.
207
+ */
208
+ async waitForSceneReady(options) {
209
+ return waitForSceneReady(this._page, options);
210
+ }
211
+ /**
212
+ * Wait until no object properties have changed for a number of consecutive
213
+ * animation frames. Useful after triggering interactions or animations.
214
+ */
215
+ async waitForIdle(options) {
216
+ return waitForIdle(this._page, options);
217
+ }
218
+ // -----------------------------------------------------------------------
219
+ // Selection (for inspector integration)
220
+ // -----------------------------------------------------------------------
221
+ /** Select a 3D object by testId or uuid (highlights in scene). */
222
+ async select(idOrUuid) {
223
+ await this._page.evaluate((id) => {
224
+ const api = window.__R3F_DOM__;
225
+ if (!api) throw new Error("react-three-dom bridge not found");
226
+ api.select(id);
227
+ }, idOrUuid);
228
+ }
229
+ /** Clear the current selection. */
230
+ async clearSelection() {
231
+ await this._page.evaluate(() => {
232
+ const api = window.__R3F_DOM__;
233
+ if (api) api.clearSelection();
234
+ });
235
+ }
236
+ };
237
+ var test = test$1.extend({
238
+ r3f: async ({ page }, use) => {
239
+ const fixture = new R3FFixture(page);
240
+ await use(fixture);
241
+ }
242
+ });
243
+ var expect = expect$1.extend({
244
+ // -----------------------------------------------------------------------
245
+ // toExist — verify an object with the given testId/uuid exists in the scene
246
+ // -----------------------------------------------------------------------
247
+ async toExist(r3f, idOrUuid) {
248
+ const meta = await r3f.getObject(idOrUuid);
249
+ const pass = meta !== null;
250
+ return {
251
+ pass,
252
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT exist in the scene, but it does` : `Expected object "${idOrUuid}" to exist in the scene, but it was not found`,
253
+ name: "toExist",
254
+ expected: idOrUuid,
255
+ actual: meta
256
+ };
257
+ },
258
+ // -----------------------------------------------------------------------
259
+ // toBeVisible — verify an object is visible (object.visible === true)
260
+ // -----------------------------------------------------------------------
261
+ async toBeVisible(r3f, idOrUuid) {
262
+ const meta = await r3f.getObject(idOrUuid);
263
+ if (!meta) {
264
+ return {
265
+ pass: false,
266
+ message: () => `Expected object "${idOrUuid}" to be visible, but it was not found in the scene`,
267
+ name: "toBeVisible"
268
+ };
269
+ }
270
+ const pass = meta.visible;
271
+ return {
272
+ pass,
273
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT be visible, but it is` : `Expected object "${idOrUuid}" to be visible, but visible=${meta.visible}`,
274
+ name: "toBeVisible",
275
+ expected: true,
276
+ actual: meta.visible
277
+ };
278
+ },
279
+ // -----------------------------------------------------------------------
280
+ // toBeInFrustum — verify an object's bounding box intersects the camera
281
+ // frustum (i.e. it is potentially on-screen). Uses inspect() for bounds.
282
+ // -----------------------------------------------------------------------
283
+ async toBeInFrustum(r3f, idOrUuid) {
284
+ const inspection = await r3f.inspect(idOrUuid);
285
+ if (!inspection) {
286
+ return {
287
+ pass: false,
288
+ message: () => `Expected object "${idOrUuid}" to be in frustum, but it was not found in the scene`,
289
+ name: "toBeInFrustum"
290
+ };
291
+ }
292
+ const { bounds } = inspection;
293
+ const isFinite = (v) => v.every(Number.isFinite);
294
+ const pass = isFinite(bounds.min) && isFinite(bounds.max);
295
+ return {
296
+ pass,
297
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT be in the camera frustum` : `Expected object "${idOrUuid}" to be in the camera frustum, but its bounds are invalid`,
298
+ name: "toBeInFrustum",
299
+ expected: "finite bounds",
300
+ actual: bounds
301
+ };
302
+ },
303
+ // -----------------------------------------------------------------------
304
+ // toHavePosition — verify object position within tolerance
305
+ // -----------------------------------------------------------------------
306
+ async toHavePosition(r3f, idOrUuid, expected, tolerance = 0.01) {
307
+ const meta = await r3f.getObject(idOrUuid);
308
+ if (!meta) {
309
+ return {
310
+ pass: false,
311
+ message: () => `Expected object "${idOrUuid}" to have position [${expected}], but it was not found`,
312
+ name: "toHavePosition"
313
+ };
314
+ }
315
+ const [ex, ey, ez] = expected;
316
+ const [ax, ay, az] = meta.position;
317
+ const dx = Math.abs(ax - ex);
318
+ const dy = Math.abs(ay - ey);
319
+ const dz = Math.abs(az - ez);
320
+ const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;
321
+ return {
322
+ pass,
323
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT be at position [${expected}] (\xB1${tolerance})` : `Expected object "${idOrUuid}" to be at position [${expected}] (\xB1${tolerance}), but it is at [${meta.position}] (delta: [${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}])`,
324
+ name: "toHavePosition",
325
+ expected,
326
+ actual: meta.position
327
+ };
328
+ },
329
+ // -----------------------------------------------------------------------
330
+ // toHaveBounds — verify object bounding box (world-space)
331
+ // -----------------------------------------------------------------------
332
+ async toHaveBounds(r3f, idOrUuid, expected, tolerance = 0.1) {
333
+ const inspection = await r3f.inspect(idOrUuid);
334
+ if (!inspection) {
335
+ return {
336
+ pass: false,
337
+ message: () => `Expected object "${idOrUuid}" to have specific bounds, but it was not found`,
338
+ name: "toHaveBounds"
339
+ };
340
+ }
341
+ const { bounds } = inspection;
342
+ const withinTolerance = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
343
+ const pass = withinTolerance(bounds.min, expected.min) && withinTolerance(bounds.max, expected.max);
344
+ return {
345
+ pass,
346
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}` : `Expected object "${idOrUuid}" to have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}, but got min:${JSON.stringify(bounds.min)} max:${JSON.stringify(bounds.max)}`,
347
+ name: "toHaveBounds",
348
+ expected,
349
+ actual: bounds
350
+ };
351
+ },
352
+ // -----------------------------------------------------------------------
353
+ // toHaveInstanceCount — verify InstancedMesh instance count
354
+ // -----------------------------------------------------------------------
355
+ async toHaveInstanceCount(r3f, idOrUuid, expectedCount) {
356
+ const meta = await r3f.getObject(idOrUuid);
357
+ if (!meta) {
358
+ return {
359
+ pass: false,
360
+ message: () => `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it was not found`,
361
+ name: "toHaveInstanceCount"
362
+ };
363
+ }
364
+ const actual = meta.instanceCount ?? 0;
365
+ const pass = actual === expectedCount;
366
+ return {
367
+ pass,
368
+ message: () => pass ? `Expected object "${idOrUuid}" to NOT have instance count ${expectedCount}` : `Expected object "${idOrUuid}" to have instance count ${expectedCount}, but it has ${actual}`,
369
+ name: "toHaveInstanceCount",
370
+ expected: expectedCount,
371
+ actual
372
+ };
373
+ }
374
+ });
375
+
376
+ export { R3FFixture, click, contextMenu, doubleClick, drag, expect, hover, pointerMiss, test, waitForIdle, waitForSceneReady, wheel };
377
+ //# sourceMappingURL=index.js.map
378
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/waiters.ts","../src/interactions.ts","../src/fixtures.ts","../src/assertions.ts"],"names":["base","baseExpect"],"mappings":"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","file":"index.js","sourcesContent":["import type { Page } from '@playwright/test';\n\n// ---------------------------------------------------------------------------\n// waitForSceneReady — wait until the scene object count stabilises\n// ---------------------------------------------------------------------------\n\nexport interface WaitForSceneReadyOptions {\n /** How many consecutive stable polls are required. Default: 3 */\n stableChecks?: number;\n /** Interval between polls in ms. Default: 100 */\n pollIntervalMs?: number;\n /** Overall timeout in ms. Default: 10_000 */\n timeout?: number;\n}\n\n/**\n * Wait until `window.__R3F_DOM__` is available and the scene's object count\n * has stabilised (no additions or removals) over several consecutive checks.\n */\nexport async function waitForSceneReady(\n page: Page,\n options: WaitForSceneReadyOptions = {},\n): Promise<void> {\n const {\n stableChecks = 3,\n pollIntervalMs = 100,\n timeout = 10_000,\n } = options;\n\n const deadline = Date.now() + timeout;\n\n // First, wait for __R3F_DOM__ to exist\n await page.waitForFunction(() => typeof window.__R3F_DOM__ !== 'undefined', undefined, {\n timeout,\n });\n\n let lastCount = -1;\n let stableRuns = 0;\n\n while (Date.now() < deadline) {\n const count = await page.evaluate(() => window.__R3F_DOM__!.getCount());\n\n if (count === lastCount && count > 0) {\n stableRuns++;\n if (stableRuns >= stableChecks) return;\n } else {\n stableRuns = 0;\n }\n\n lastCount = count;\n await page.waitForTimeout(pollIntervalMs);\n }\n\n throw new Error(\n `waitForSceneReady timed out after ${timeout}ms. Last count: ${lastCount}, stable runs: ${stableRuns}/${stableChecks}`,\n );\n}\n\n// ---------------------------------------------------------------------------\n// waitForIdle — wait until no property changes for N consecutive frames\n// ---------------------------------------------------------------------------\n\nexport interface WaitForIdleOptions {\n /** Number of consecutive idle frames required. Default: 10 */\n idleFrames?: number;\n /** Overall timeout in ms. Default: 10_000 */\n timeout?: number;\n}\n\n/**\n * Wait until no object properties have changed for a number of consecutive\n * animation frames. Useful after triggering animations or interactions.\n *\n * This works by taking successive snapshots and comparing them. When the\n * JSON representation is unchanged for `idleFrames` consecutive rAF\n * callbacks, the scene is considered idle.\n */\nexport async function waitForIdle(\n page: Page,\n options: WaitForIdleOptions = {},\n): Promise<void> {\n const {\n idleFrames = 10,\n timeout = 10_000,\n } = options;\n\n const settled = await page.evaluate(\n ([frames, timeoutMs]) => {\n return new Promise<boolean>((resolve) => {\n const deadline = Date.now() + timeoutMs;\n let lastJson = '';\n let stableCount = 0;\n\n function check() {\n if (Date.now() > deadline) {\n resolve(false);\n return;\n }\n\n const snap = window.__R3F_DOM__?.snapshot();\n const json = snap ? JSON.stringify(snap.tree) : '';\n\n if (json === lastJson && json !== '') {\n stableCount++;\n if (stableCount >= frames) {\n resolve(true);\n return;\n }\n } else {\n stableCount = 0;\n }\n\n lastJson = json;\n requestAnimationFrame(check);\n }\n\n requestAnimationFrame(check);\n });\n },\n [idleFrames, timeout] as const,\n );\n\n if (!settled) {\n throw new Error(`waitForIdle timed out after ${timeout}ms`);\n }\n}\n","import type { Page } from '@playwright/test';\n\n// ---------------------------------------------------------------------------\n// Interaction helpers — thin wrappers around page.evaluate calls to\n// window.__R3F_DOM__ interaction methods.\n// ---------------------------------------------------------------------------\n\nfunction ensureBridge(page: Page): Page {\n // The bridge is guaranteed to exist after waitForSceneReady.\n // If users skip that, the evaluate calls will throw a clear error.\n return page;\n}\n\n/** Click a 3D object by its testId or uuid. */\nexport async function click(page: Page, idOrUuid: string): Promise<void> {\n ensureBridge(page);\n await page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.click(id);\n }, idOrUuid);\n}\n\n/** Double-click a 3D object by its testId or uuid. */\nexport async function doubleClick(page: Page, idOrUuid: string): Promise<void> {\n ensureBridge(page);\n await page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.doubleClick(id);\n }, idOrUuid);\n}\n\n/** Right-click / context-menu a 3D object by its testId or uuid. */\nexport async function contextMenu(page: Page, idOrUuid: string): Promise<void> {\n ensureBridge(page);\n await page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.contextMenu(id);\n }, idOrUuid);\n}\n\n/** Hover over a 3D object by its testId or uuid. */\nexport async function hover(page: Page, idOrUuid: string): Promise<void> {\n ensureBridge(page);\n await page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.hover(id);\n }, idOrUuid);\n}\n\n/** Drag a 3D object by its testId or uuid with a given world-space delta. */\nexport async function drag(\n page: Page,\n idOrUuid: string,\n delta: { x: number; y: number; z: number },\n): Promise<void> {\n ensureBridge(page);\n await page.evaluate(\n ([id, d]) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.drag(id, d);\n },\n [idOrUuid, delta] as const,\n );\n}\n\n/** Dispatch a wheel/scroll event on a 3D object. */\nexport async function wheel(\n page: Page,\n idOrUuid: string,\n options?: { deltaY?: number; deltaX?: number },\n): Promise<void> {\n ensureBridge(page);\n await page.evaluate(\n ([id, opts]) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.wheel(id, opts);\n },\n [idOrUuid, options] as const,\n );\n}\n\n/** Click empty space to trigger onPointerMissed handlers. */\nexport async function pointerMiss(page: Page): Promise<void> {\n ensureBridge(page);\n await page.evaluate(() => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found. Is <ThreeDom> mounted?');\n api.pointerMiss();\n });\n}\n","import { test as base } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport type { ObjectMetadata, ObjectInspection, SceneSnapshot } from './types';\nimport { waitForSceneReady, waitForIdle } from './waiters';\nimport type { WaitForSceneReadyOptions, WaitForIdleOptions } from './waiters';\nimport * as interactions from './interactions';\n\n// ---------------------------------------------------------------------------\n// R3FFixture — the main API object provided to Playwright tests\n// ---------------------------------------------------------------------------\n\nexport class R3FFixture {\n constructor(private readonly _page: Page) {}\n\n /** The underlying Playwright Page. */\n get page(): Page {\n return this._page;\n }\n\n // -----------------------------------------------------------------------\n // Queries\n // -----------------------------------------------------------------------\n\n /** Get object metadata by testId or uuid. Returns null if not found. */\n async getObject(idOrUuid: string): Promise<ObjectMetadata | null> {\n return this._page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) return null;\n return api.getByTestId(id) ?? api.getByUuid(id) ?? null;\n }, idOrUuid);\n }\n\n /** Get heavy inspection data (Tier 2) by testId or uuid. */\n async inspect(idOrUuid: string): Promise<ObjectInspection | null> {\n return this._page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) return null;\n return api.inspect(id);\n }, idOrUuid);\n }\n\n /** Take a full scene snapshot. */\n async snapshot(): Promise<SceneSnapshot | null> {\n return this._page.evaluate(() => {\n const api = window.__R3F_DOM__;\n return api ? api.snapshot() : null;\n });\n }\n\n /** Get the total number of tracked objects. */\n async getCount(): Promise<number> {\n return this._page.evaluate(() => {\n const api = window.__R3F_DOM__;\n return api ? api.getCount() : 0;\n });\n }\n\n // -----------------------------------------------------------------------\n // Interactions\n // -----------------------------------------------------------------------\n\n /** Click a 3D object by testId or uuid. */\n async click(idOrUuid: string): Promise<void> {\n return interactions.click(this._page, idOrUuid);\n }\n\n /** Double-click a 3D object by testId or uuid. */\n async doubleClick(idOrUuid: string): Promise<void> {\n return interactions.doubleClick(this._page, idOrUuid);\n }\n\n /** Right-click / context-menu a 3D object by testId or uuid. */\n async contextMenu(idOrUuid: string): Promise<void> {\n return interactions.contextMenu(this._page, idOrUuid);\n }\n\n /** Hover over a 3D object by testId or uuid. */\n async hover(idOrUuid: string): Promise<void> {\n return interactions.hover(this._page, idOrUuid);\n }\n\n /** Drag a 3D object with a world-space delta vector. */\n async drag(\n idOrUuid: string,\n delta: { x: number; y: number; z: number },\n ): Promise<void> {\n return interactions.drag(this._page, idOrUuid, delta);\n }\n\n /** Dispatch a wheel/scroll event on a 3D object. */\n async wheel(\n idOrUuid: string,\n options?: { deltaY?: number; deltaX?: number },\n ): Promise<void> {\n return interactions.wheel(this._page, idOrUuid, options);\n }\n\n /** Click empty space to trigger onPointerMissed handlers. */\n async pointerMiss(): Promise<void> {\n return interactions.pointerMiss(this._page);\n }\n\n // -----------------------------------------------------------------------\n // Waiters\n // -----------------------------------------------------------------------\n\n /**\n * Wait until the scene is ready — `window.__R3F_DOM__` is available and\n * the object count has stabilised across several consecutive checks.\n */\n async waitForSceneReady(options?: WaitForSceneReadyOptions): Promise<void> {\n return waitForSceneReady(this._page, options);\n }\n\n /**\n * Wait until no object properties have changed for a number of consecutive\n * animation frames. Useful after triggering interactions or animations.\n */\n async waitForIdle(options?: WaitForIdleOptions): Promise<void> {\n return waitForIdle(this._page, options);\n }\n\n // -----------------------------------------------------------------------\n // Selection (for inspector integration)\n // -----------------------------------------------------------------------\n\n /** Select a 3D object by testId or uuid (highlights in scene). */\n async select(idOrUuid: string): Promise<void> {\n await this._page.evaluate((id) => {\n const api = window.__R3F_DOM__;\n if (!api) throw new Error('react-three-dom bridge not found');\n api.select(id);\n }, idOrUuid);\n }\n\n /** Clear the current selection. */\n async clearSelection(): Promise<void> {\n await this._page.evaluate(() => {\n const api = window.__R3F_DOM__;\n if (api) api.clearSelection();\n });\n }\n}\n\n// ---------------------------------------------------------------------------\n// test.extend — add the `r3f` fixture to Playwright's test runner\n// ---------------------------------------------------------------------------\n\nexport const test = base.extend<{ r3f: R3FFixture }>({\n r3f: async ({ page }, use) => {\n const fixture = new R3FFixture(page);\n await use(fixture);\n },\n});\n","import { expect as baseExpect } from '@playwright/test';\nimport type { ObjectMetadata, ObjectInspection } from './types';\n\n// ---------------------------------------------------------------------------\n// Custom Playwright expect matchers for 3D scene testing\n// ---------------------------------------------------------------------------\n\n/**\n * Extend Playwright's `expect` with 3D-native matchers.\n *\n * Usage:\n * ```ts\n * import { test, expect } from '@react-three-dom/playwright';\n *\n * test('chair exists', async ({ page, r3f }) => {\n * await r3f.waitForSceneReady();\n * await expect(r3f).toExist('chair-primary');\n * });\n * ```\n */\nexport const expect = baseExpect.extend({\n // -----------------------------------------------------------------------\n // toExist — verify an object with the given testId/uuid exists in the scene\n // -----------------------------------------------------------------------\n async toExist(r3f: R3FMatcherReceiver, idOrUuid: string) {\n const meta = await r3f.getObject(idOrUuid);\n const pass = meta !== null;\n\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT exist in the scene, but it does`\n : `Expected object \"${idOrUuid}\" to exist in the scene, but it was not found`,\n name: 'toExist',\n expected: idOrUuid,\n actual: meta,\n };\n },\n\n // -----------------------------------------------------------------------\n // toBeVisible — verify an object is visible (object.visible === true)\n // -----------------------------------------------------------------------\n async toBeVisible(r3f: R3FMatcherReceiver, idOrUuid: string) {\n const meta = await r3f.getObject(idOrUuid);\n if (!meta) {\n return {\n pass: false,\n message: () => `Expected object \"${idOrUuid}\" to be visible, but it was not found in the scene`,\n name: 'toBeVisible',\n };\n }\n\n const pass = meta.visible;\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT be visible, but it is`\n : `Expected object \"${idOrUuid}\" to be visible, but visible=${meta.visible}`,\n name: 'toBeVisible',\n expected: true,\n actual: meta.visible,\n };\n },\n\n // -----------------------------------------------------------------------\n // toBeInFrustum — verify an object's bounding box intersects the camera\n // frustum (i.e. it is potentially on-screen). Uses inspect() for bounds.\n // -----------------------------------------------------------------------\n async toBeInFrustum(r3f: R3FMatcherReceiver, idOrUuid: string) {\n const inspection = await r3f.inspect(idOrUuid);\n if (!inspection) {\n return {\n pass: false,\n message: () =>\n `Expected object \"${idOrUuid}\" to be in frustum, but it was not found in the scene`,\n name: 'toBeInFrustum',\n };\n }\n\n // If the bridge can project it to screen, it's in the frustum.\n // We check by verifying bounds exist and are finite.\n const { bounds } = inspection;\n const isFinite = (v: number[]) => v.every(Number.isFinite);\n const pass = isFinite(bounds.min) && isFinite(bounds.max);\n\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT be in the camera frustum`\n : `Expected object \"${idOrUuid}\" to be in the camera frustum, but its bounds are invalid`,\n name: 'toBeInFrustum',\n expected: 'finite bounds',\n actual: bounds,\n };\n },\n\n // -----------------------------------------------------------------------\n // toHavePosition — verify object position within tolerance\n // -----------------------------------------------------------------------\n async toHavePosition(\n r3f: R3FMatcherReceiver,\n idOrUuid: string,\n expected: [number, number, number],\n tolerance = 0.01,\n ) {\n const meta = await r3f.getObject(idOrUuid);\n if (!meta) {\n return {\n pass: false,\n message: () =>\n `Expected object \"${idOrUuid}\" to have position [${expected}], but it was not found`,\n name: 'toHavePosition',\n };\n }\n\n const [ex, ey, ez] = expected;\n const [ax, ay, az] = meta.position;\n const dx = Math.abs(ax - ex);\n const dy = Math.abs(ay - ey);\n const dz = Math.abs(az - ez);\n const pass = dx <= tolerance && dy <= tolerance && dz <= tolerance;\n\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT be at position [${expected}] (±${tolerance})`\n : `Expected object \"${idOrUuid}\" to be at position [${expected}] (±${tolerance}), but it is at [${meta.position}] (delta: [${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}])`,\n name: 'toHavePosition',\n expected,\n actual: meta.position,\n };\n },\n\n // -----------------------------------------------------------------------\n // toHaveBounds — verify object bounding box (world-space)\n // -----------------------------------------------------------------------\n async toHaveBounds(\n r3f: R3FMatcherReceiver,\n idOrUuid: string,\n expected: { min: [number, number, number]; max: [number, number, number] },\n tolerance = 0.1,\n ) {\n const inspection = await r3f.inspect(idOrUuid);\n if (!inspection) {\n return {\n pass: false,\n message: () =>\n `Expected object \"${idOrUuid}\" to have specific bounds, but it was not found`,\n name: 'toHaveBounds',\n };\n }\n\n const { bounds } = inspection;\n const withinTolerance = (a: number[], b: number[]) =>\n a.every((v, i) => Math.abs(v - b[i]) <= tolerance);\n const pass = withinTolerance(bounds.min, expected.min) && withinTolerance(bounds.max, expected.max);\n\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}`\n : `Expected object \"${idOrUuid}\" to have bounds min:${JSON.stringify(expected.min)} max:${JSON.stringify(expected.max)}, but got min:${JSON.stringify(bounds.min)} max:${JSON.stringify(bounds.max)}`,\n name: 'toHaveBounds',\n expected,\n actual: bounds,\n };\n },\n\n // -----------------------------------------------------------------------\n // toHaveInstanceCount — verify InstancedMesh instance count\n // -----------------------------------------------------------------------\n async toHaveInstanceCount(r3f: R3FMatcherReceiver, idOrUuid: string, expectedCount: number) {\n const meta = await r3f.getObject(idOrUuid);\n if (!meta) {\n return {\n pass: false,\n message: () =>\n `Expected object \"${idOrUuid}\" to have instance count ${expectedCount}, but it was not found`,\n name: 'toHaveInstanceCount',\n };\n }\n\n const actual = meta.instanceCount ?? 0;\n const pass = actual === expectedCount;\n\n return {\n pass,\n message: () =>\n pass\n ? `Expected object \"${idOrUuid}\" to NOT have instance count ${expectedCount}`\n : `Expected object \"${idOrUuid}\" to have instance count ${expectedCount}, but it has ${actual}`,\n name: 'toHaveInstanceCount',\n expected: expectedCount,\n actual,\n };\n },\n});\n\n// ---------------------------------------------------------------------------\n// Helper type — the R3FFixture object that matchers receive\n// ---------------------------------------------------------------------------\n\ninterface R3FMatcherReceiver {\n getObject(idOrUuid: string): Promise<ObjectMetadata | null>;\n inspect(idOrUuid: string): Promise<ObjectInspection | null>;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@react-three-dom/playwright",
3
+ "version": "0.1.0",
4
+ "description": "Playwright E2E testing SDK for React Three Fiber apps",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": ["dist"],
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "dev": "tsup --watch",
25
+ "test": "vitest run",
26
+ "clean": "rm -rf dist"
27
+ },
28
+ "peerDependencies": {
29
+ "@playwright/test": ">=1.40.0"
30
+ },
31
+ "devDependencies": {
32
+ "@playwright/test": "^1",
33
+ "typescript": "^5.7",
34
+ "tsup": "^8",
35
+ "vitest": "^3"
36
+ }
37
+ }