@elizaos/plugin-xr 2.0.3-beta.6 → 2.0.3-beta.7

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.
Files changed (83) hide show
  1. package/dist/actions/xr-query-vision.d.ts +3 -0
  2. package/dist/actions/xr-query-vision.d.ts.map +1 -0
  3. package/dist/actions/xr-query-vision.js +39 -0
  4. package/dist/actions/xr-query-vision.js.map +1 -0
  5. package/dist/actions/xr-view-actions.d.ts +18 -0
  6. package/dist/actions/xr-view-actions.d.ts.map +1 -0
  7. package/dist/actions/xr-view-actions.js +304 -0
  8. package/dist/actions/xr-view-actions.js.map +1 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +57 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/protocol.d.ts +124 -0
  14. package/dist/protocol.d.ts.map +1 -0
  15. package/dist/protocol.js +18 -0
  16. package/dist/protocol.js.map +1 -0
  17. package/dist/providers/xr-context.d.ts +3 -0
  18. package/dist/providers/xr-context.d.ts.map +1 -0
  19. package/dist/providers/xr-context.js +34 -0
  20. package/dist/providers/xr-context.js.map +1 -0
  21. package/dist/routes/xr-connect.d.ts +3 -0
  22. package/dist/routes/xr-connect.d.ts.map +1 -0
  23. package/{src/routes/xr-connect.ts → dist/routes/xr-connect.js} +12 -15
  24. package/dist/routes/xr-connect.js.map +1 -0
  25. package/dist/routes/xr-simulator-route.d.ts +8 -0
  26. package/dist/routes/xr-simulator-route.d.ts.map +1 -0
  27. package/{src/routes/xr-simulator-route.ts → dist/routes/xr-simulator-route.js} +10 -16
  28. package/dist/routes/xr-simulator-route.js.map +1 -0
  29. package/dist/routes/xr-status.d.ts +3 -0
  30. package/dist/routes/xr-status.d.ts.map +1 -0
  31. package/{src/routes/xr-status.ts → dist/routes/xr-status.js} +13 -15
  32. package/dist/routes/xr-status.js.map +1 -0
  33. package/dist/routes/xr-view-host.d.ts +24 -0
  34. package/dist/routes/xr-view-host.d.ts.map +1 -0
  35. package/{src/routes/xr-view-host.ts → dist/routes/xr-view-host.js} +22 -59
  36. package/dist/routes/xr-view-host.js.map +1 -0
  37. package/dist/routes/xr-views.d.ts +8 -0
  38. package/dist/routes/xr-views.d.ts.map +1 -0
  39. package/dist/routes/xr-views.js +31 -0
  40. package/dist/routes/xr-views.js.map +1 -0
  41. package/dist/services/audio-pipeline.d.ts +20 -0
  42. package/dist/services/audio-pipeline.d.ts.map +1 -0
  43. package/{src/services/audio-pipeline.ts → dist/services/audio-pipeline.js} +25 -58
  44. package/dist/services/audio-pipeline.js.map +1 -0
  45. package/dist/services/vision-pipeline.d.ts +16 -0
  46. package/dist/services/vision-pipeline.d.ts.map +1 -0
  47. package/dist/services/vision-pipeline.js +39 -0
  48. package/dist/services/vision-pipeline.js.map +1 -0
  49. package/dist/services/xr-session-service.d.ts +50 -0
  50. package/dist/services/xr-session-service.d.ts.map +1 -0
  51. package/{src/services/xr-session-service.ts → dist/services/xr-session-service.js} +85 -194
  52. package/dist/services/xr-session-service.js.map +1 -0
  53. package/package.json +9 -4
  54. package/AGENTS.md +0 -151
  55. package/CLAUDE.md +0 -151
  56. package/simulator/bun.lock +0 -159
  57. package/simulator/package.json +0 -28
  58. package/simulator/src/emulator.ts +0 -174
  59. package/simulator/src/mock-agent.ts +0 -233
  60. package/simulator/src/node.ts +0 -9
  61. package/simulator/src/playwright-fixture.ts +0 -169
  62. package/simulator/src/types.ts +0 -51
  63. package/simulator/tsconfig.json +0 -13
  64. package/simulator/vite.config.ts +0 -25
  65. package/src/__tests__/audio-pipeline.test.ts +0 -129
  66. package/src/__tests__/protocol.test.ts +0 -53
  67. package/src/__tests__/routes-e2e.test.ts +0 -276
  68. package/src/__tests__/vision-pipeline.test.ts +0 -73
  69. package/src/__tests__/xr-bundle-coverage.test.ts +0 -303
  70. package/src/__tests__/xr-feature-parity.test.ts +0 -524
  71. package/src/__tests__/xr-functional-parity.test.ts +0 -522
  72. package/src/__tests__/xr-view-host-http.test.ts +0 -239
  73. package/src/__tests__/xr-view-host.test.ts +0 -174
  74. package/src/actions/xr-query-vision.ts +0 -64
  75. package/src/actions/xr-view-actions.ts +0 -386
  76. package/src/index.ts +0 -55
  77. package/src/protocol.ts +0 -126
  78. package/src/providers/xr-context.ts +0 -49
  79. package/src/routes/xr-views.ts +0 -43
  80. package/src/services/vision-pipeline.ts +0 -57
  81. package/tsconfig.build.json +0 -9
  82. package/tsconfig.json +0 -30
  83. package/vitest.config.ts +0 -21
@@ -1,174 +0,0 @@
1
- /**
2
- * XR Emulator — browser-side IIFE injected by Playwright via page.addInitScript().
3
- *
4
- * What it does:
5
- * 1. Installs IWER (immersive-web-emulation-runtime) to polyfill navigator.xr
6
- * with a controllable Quest 3 device.
7
- * 2. Overrides navigator.mediaDevices.getUserMedia to return:
8
- * - Video: a canvas-captureStream() that Playwright can paint frames onto.
9
- * - Audio: a synthetic silence stream (real audio comes via __xrTestHooks).
10
- * 3. Exposes window.__XREmulator with a programmatic control API.
11
- *
12
- * Fork baseline: meta-quest/immersive-web-emulator
13
- * Additions: camera frame injection, audio stream mock, __XREmulator control API.
14
- *
15
- * rawCameraAccess simulation:
16
- * The experimental WebXR rawCameraAccess path (XRWebGLBinding.getCameraImage) is
17
- * outside IWER's current emulation surface, so app-xr automatically falls back to the getUserMedia
18
- * video track (Path 3). Injecting frames via __XREmulator.injectCameraFrame() paints
19
- * onto the canvas that feeds getUserMedia, making injected frames reachable by both
20
- * the getUserMedia path and any code that reads the canvas directly.
21
- */
22
-
23
- import { metaQuest3, XRDevice } from "iwer";
24
- import type { EmulatorStats, XREmulatorAPI, XRPose } from "./types.ts";
25
-
26
- // ── Camera canvas ─────────────────────────────────────────────────────────
27
-
28
- const cameraCanvas = document.createElement("canvas");
29
- cameraCanvas.width = 640;
30
- cameraCanvas.height = 480;
31
- const cameraCtx = cameraCanvas.getContext("2d")!;
32
-
33
- // Fill with a recognisable test pattern (grey + crosshair)
34
- function drawTestPattern(ctx: CanvasRenderingContext2D, w: number, h: number) {
35
- ctx.fillStyle = "#333";
36
- ctx.fillRect(0, 0, w, h);
37
- ctx.strokeStyle = "#0f0";
38
- ctx.lineWidth = 2;
39
- ctx.beginPath();
40
- ctx.moveTo(w / 2, 0);
41
- ctx.lineTo(w / 2, h);
42
- ctx.moveTo(0, h / 2);
43
- ctx.lineTo(w, h / 2);
44
- ctx.stroke();
45
- ctx.fillStyle = "#0f0";
46
- ctx.font = "16px monospace";
47
- ctx.fillText("XR SIMULATOR", 12, 24);
48
- }
49
- drawTestPattern(cameraCtx, 640, 480);
50
-
51
- const cameraStream = cameraCanvas.captureStream(30); // 30 fps canvas stream
52
-
53
- // ── Audio stream (silence) ───────────────────────────────────────────────
54
-
55
- function createSilentAudioStream(): MediaStream {
56
- const ctx = new AudioContext();
57
- const dest = ctx.createMediaStreamDestination();
58
- // Connect a silent oscillator at 0 gain to keep the stream alive
59
- const gain = ctx.createGain();
60
- gain.gain.value = 0;
61
- const osc = ctx.createOscillator();
62
- osc.connect(gain);
63
- gain.connect(dest);
64
- osc.start();
65
- return dest.stream;
66
- }
67
-
68
- let silentAudioStream: MediaStream | null = null;
69
-
70
- // ── getUserMedia override ─────────────────────────────────────────────────
71
-
72
- const _originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(
73
- navigator.mediaDevices,
74
- );
75
-
76
- navigator.mediaDevices.getUserMedia = async (
77
- constraints?: MediaStreamConstraints,
78
- ): Promise<MediaStream> => {
79
- const hasVideo = constraints?.video;
80
- const hasAudio = constraints?.audio;
81
-
82
- if (hasVideo && !hasAudio) {
83
- // Camera-only: return our canvas stream
84
- return cameraStream;
85
- }
86
-
87
- if (hasAudio && !hasVideo) {
88
- // Mic-only: return synthetic silence
89
- if (!silentAudioStream) silentAudioStream = createSilentAudioStream();
90
- return silentAudioStream;
91
- }
92
-
93
- if (hasVideo && hasAudio) {
94
- // Combined: merge both tracks into one MediaStream
95
- if (!silentAudioStream) silentAudioStream = createSilentAudioStream();
96
- const combined = new MediaStream([
97
- ...cameraStream.getVideoTracks(),
98
- ...silentAudioStream.getAudioTracks(),
99
- ]);
100
- return combined;
101
- }
102
-
103
- // Fallback for other constraint shapes
104
- return _originalGetUserMedia(constraints);
105
- };
106
-
107
- // ── IWER XR device ────────────────────────────────────────────────────────
108
-
109
- const xrDevice = new XRDevice(metaQuest3);
110
- xrDevice.installRuntime();
111
-
112
- // ── State ─────────────────────────────────────────────────────────────────
113
-
114
- let framesInjected = 0;
115
-
116
- // ── Control API ───────────────────────────────────────────────────────────
117
-
118
- const api: XREmulatorAPI = {
119
- setPose(pose: Partial<XRPose>) {
120
- if (pose.position) {
121
- xrDevice.position.set(pose.position.x, pose.position.y, pose.position.z);
122
- }
123
- if (pose.orientation) {
124
- xrDevice.quaternion.set(
125
- pose.orientation.x,
126
- pose.orientation.y,
127
- pose.orientation.z,
128
- pose.orientation.w,
129
- );
130
- }
131
- },
132
-
133
- async injectCameraFrame(jpegDataUrl: string): Promise<void> {
134
- // createImageBitmap is more reliable than new Image() in headless contexts
135
- const resp = await fetch(jpegDataUrl);
136
- const blob = await resp.blob();
137
- const bmp = await createImageBitmap(blob);
138
- cameraCtx.drawImage(bmp, 0, 0, cameraCanvas.width, cameraCanvas.height);
139
- bmp.close();
140
- framesInjected++;
141
- },
142
-
143
- getStats(): EmulatorStats {
144
- const wsConnected =
145
- typeof window.__xrTestHooks !== "undefined" &&
146
- window.__xrTestHooks.getSocketState() === "OPEN";
147
- return {
148
- sessionActive: false, // updated below once session is active
149
- framesInjected,
150
- cameraStreamActive: cameraStream.active,
151
- wsConnected,
152
- };
153
- },
154
-
155
- simulateDisconnect() {
156
- // Force-close the WebSocket so the reconnect logic kicks in
157
- // The app exposes the socket via __xrTestHooks
158
- if (window.__xrTestHooks) {
159
- (
160
- window as { __xrForceDisconnect?: () => void }
161
- ).__xrForceDisconnect?.();
162
- }
163
- },
164
-
165
- simulateReconnect() {
166
- (
167
- window as { __xrForceReconnect?: () => void }
168
- ).__xrForceReconnect?.();
169
- },
170
- };
171
-
172
- window.__XREmulator = api;
173
-
174
- console.info("[XR Emulator] installed — navigator.xr:", !!navigator.xr);
@@ -1,233 +0,0 @@
1
- /**
2
- * Lightweight WebSocket server that simulates plugin-xr's XRSessionService.
3
- * Used in Playwright tests — starts a real ws server on a configurable port,
4
- * records all received binary frames, and lets tests script responses.
5
- */
6
-
7
- import { WebSocket, WebSocketServer } from "ws";
8
- import {
9
- decodeBinaryFrame,
10
- encodeBinaryFrame,
11
- type XRBinaryHeader,
12
- type XRClientControl,
13
- type XRTTSAudioHeader,
14
- } from "../../src/protocol.ts";
15
-
16
- export interface ReceivedFrame {
17
- header: XRBinaryHeader | XRTTSAudioHeader;
18
- payload: Buffer;
19
- receivedAt: number;
20
- }
21
-
22
- export interface ReceivedControl {
23
- message: XRClientControl;
24
- receivedAt: number;
25
- }
26
-
27
- export interface MockAgentServerOptions {
28
- port?: number;
29
- /** Automatically send a transcript response after receiving N audio frames */
30
- autoTranscriptAfterFrames?: number;
31
- autoTranscriptText?: string;
32
- }
33
-
34
- export class MockAgentServer {
35
- private wss: WebSocketServer | null = null;
36
- private client: WebSocket | null = null;
37
-
38
- readonly receivedFrames: ReceivedFrame[] = [];
39
- readonly receivedControls: ReceivedControl[] = [];
40
- private waiters = new Map<string, Array<() => void>>();
41
-
42
- constructor(private readonly options: MockAgentServerOptions = {}) {}
43
-
44
- get port(): number {
45
- return this.options.port ?? 31338;
46
- }
47
-
48
- async start(): Promise<void> {
49
- return new Promise((resolve, reject) => {
50
- this.wss = new WebSocketServer({ port: this.port });
51
- this.wss.on("listening", resolve);
52
- this.wss.on("error", reject);
53
- this.wss.on("connection", (ws) => this.onClient(ws));
54
- });
55
- }
56
-
57
- async stop(): Promise<void> {
58
- this.client?.close();
59
- this.client = null;
60
- return new Promise((resolve) => this.wss?.close(() => resolve()));
61
- }
62
-
63
- // ── Sending responses ──────────────────────────────────────────────────
64
-
65
- sendTranscript(text: string, final = true): void {
66
- this.sendText({ type: "transcript", text, final });
67
- }
68
-
69
- sendAgentText(text: string): void {
70
- this.sendText({ type: "agent_text", text });
71
- }
72
-
73
- sendTTSAudio(audio: Buffer, sampleRate = 24000): void {
74
- if (!this.client || this.client.readyState !== WebSocket.OPEN) return;
75
- const header: XRTTSAudioHeader = {
76
- type: "tts_audio",
77
- sampleRate,
78
- channels: 1,
79
- encoding: "mp3",
80
- };
81
- this.client.send(encodeBinaryFrame(header, audio), { binary: true });
82
- }
83
-
84
- // ── Waiting helpers ────────────────────────────────────────────────────
85
-
86
- /** Resolves when the device has connected and sent a 'hello' message.
87
- * Resolves immediately if 'hello' was already received (handles the race
88
- * where the connection fires before the waiter is registered). */
89
- waitForConnection(timeoutMs = 5000): Promise<void> {
90
- if (this.receivedControls.some((c) => c.message.type === "hello")) {
91
- return Promise.resolve();
92
- }
93
- return this.waitFor("connected", timeoutMs);
94
- }
95
-
96
- /** Resolves when at least one audio binary frame has been received. */
97
- waitForAudioFrame(timeoutMs = 10000): Promise<ReceivedFrame> {
98
- return this.waitForFrame("audio", timeoutMs);
99
- }
100
-
101
- /** Resolves when at least one camera binary frame has been received. */
102
- waitForCameraFrame(timeoutMs = 10000): Promise<ReceivedFrame> {
103
- return this.waitForFrame("frame", timeoutMs);
104
- }
105
-
106
- audioFrames(): ReceivedFrame[] {
107
- return this.receivedFrames.filter((f) => f.header.type === "audio");
108
- }
109
-
110
- cameraFrames(): ReceivedFrame[] {
111
- return this.receivedFrames.filter((f) => f.header.type === "frame");
112
- }
113
-
114
- reset(): void {
115
- this.receivedFrames.length = 0;
116
- this.receivedControls.length = 0;
117
- }
118
-
119
- // ── Private ────────────────────────────────────────────────────────────
120
-
121
- private onClient(ws: WebSocket): void {
122
- this.client = ws;
123
-
124
- ws.on("message", (data, isBinary) => {
125
- if (isBinary) {
126
- this.handleBinary(data as Buffer);
127
- } else {
128
- this.handleText(ws, data.toString("utf8"));
129
- }
130
- });
131
-
132
- ws.on("close", () => {
133
- if (this.client === ws) this.client = null;
134
- });
135
- }
136
-
137
- private handleText(ws: WebSocket, raw: string): void {
138
- try {
139
- const msg = JSON.parse(raw) as XRClientControl;
140
- this.receivedControls.push({ message: msg, receivedAt: Date.now() });
141
-
142
- if (msg.type === "hello") {
143
- // Send ready
144
- ws.send(JSON.stringify({ type: "ready", sessionId: "mock-session" }));
145
- this.notify("connected");
146
- }
147
- if (msg.type === "ping") {
148
- ws.send(JSON.stringify({ type: "pong" }));
149
- }
150
- } catch {}
151
- }
152
-
153
- private handleBinary(data: Buffer): void {
154
- try {
155
- const { header, payload } = decodeBinaryFrame(data);
156
- const frame: ReceivedFrame = { header, payload, receivedAt: Date.now() };
157
- this.receivedFrames.push(frame);
158
- this.notify(`frame:${header.type}`);
159
-
160
- // Auto-transcript after N audio frames
161
- const { autoTranscriptAfterFrames, autoTranscriptText } = this.options;
162
- if (
163
- header.type === "audio" &&
164
- autoTranscriptAfterFrames !== undefined &&
165
- this.audioFrames().length >= autoTranscriptAfterFrames
166
- ) {
167
- const text = autoTranscriptText ?? "test transcript";
168
- setTimeout(() => {
169
- this.sendTranscript(text);
170
- this.sendAgentText(`Agent response to: ${text}`);
171
- }, 50);
172
- }
173
- } catch {}
174
- }
175
-
176
- private sendText(msg: object): void {
177
- if (!this.client || this.client.readyState !== WebSocket.OPEN) return;
178
- this.client.send(JSON.stringify(msg));
179
- }
180
-
181
- private waitFor(event: string, timeoutMs: number): Promise<void> {
182
- return new Promise((resolve, reject) => {
183
- const timer = setTimeout(
184
- () =>
185
- reject(new Error(`MockAgentServer: timeout waiting for "${event}"`)),
186
- timeoutMs,
187
- );
188
- const list = this.waiters.get(event) ?? [];
189
- list.push(() => {
190
- clearTimeout(timer);
191
- resolve();
192
- });
193
- this.waiters.set(event, list);
194
- });
195
- }
196
-
197
- private waitForFrame(
198
- type: string,
199
- timeoutMs: number,
200
- ): Promise<ReceivedFrame> {
201
- // Check if already received
202
- const existing = this.receivedFrames.find((f) => f.header.type === type);
203
- if (existing) return Promise.resolve(existing);
204
-
205
- return new Promise((resolve, reject) => {
206
- const timer = setTimeout(
207
- () =>
208
- reject(
209
- new Error(
210
- `MockAgentServer: timeout waiting for frame type "${type}"`,
211
- ),
212
- ),
213
- timeoutMs,
214
- );
215
- const event = `frame:${type}`;
216
- const list = this.waiters.get(event) ?? [];
217
- list.push(() => {
218
- clearTimeout(timer);
219
- const frame = [...this.receivedFrames].reverse().find(
220
- (f: ReceivedFrame) => f.header.type === type,
221
- )!;
222
- resolve(frame);
223
- });
224
- this.waiters.set(event, list);
225
- });
226
- }
227
-
228
- private notify(event: string): void {
229
- const list = this.waiters.get(event) ?? [];
230
- this.waiters.delete(event);
231
- for (const cb of list) cb();
232
- }
233
- }
@@ -1,9 +0,0 @@
1
- // Node-side entry point — exports the Playwright fixture and mock server.
2
- // Do not import this from browser code.
3
- export {
4
- expect,
5
- MockAgentServer,
6
- test,
7
- XREmulatorPage,
8
- } from "./playwright-fixture.ts";
9
- export type { EmulatorStats, XRPose } from "./types.ts";
@@ -1,169 +0,0 @@
1
- /**
2
- * Playwright fixture and page wrapper for XR emulator testing.
3
- *
4
- * Usage:
5
- * import { test, expect } from './fixtures.ts';
6
- *
7
- * test('full roundtrip', async ({ xrPage, mockAgent }) => {
8
- * await xrPage.goto('/');
9
- * await mockAgent.waitForConnection();
10
- * await xrPage.injectCameraFrame('./fixtures/desk.jpg');
11
- * const frame = await mockAgent.waitForCameraFrame();
12
- * expect(frame.payload.length).toBeGreaterThan(100);
13
- * });
14
- */
15
-
16
- import { existsSync, readFileSync } from "node:fs";
17
- import { dirname, resolve } from "node:path";
18
- import { fileURLToPath } from "node:url";
19
- import { test as base, type Page } from "@playwright/test";
20
- import { MockAgentServer } from "./mock-agent.ts";
21
- import type { EmulatorStats, XRPose } from "./types.ts";
22
-
23
- const __dirname = dirname(fileURLToPath(import.meta.url));
24
- const EMULATOR_DIST = resolve(__dirname, "../dist/emulator.js");
25
-
26
- // ── XREmulatorPage ────────────────────────────────────────────────────────
27
-
28
- export class XREmulatorPage {
29
- constructor(readonly page: Page) {}
30
-
31
- /** Inject the emulator script before the page loads. Call before page.goto(). */
32
- async inject(): Promise<void> {
33
- if (!existsSync(EMULATOR_DIST)) {
34
- throw new Error(
35
- `Emulator bundle not found at ${EMULATOR_DIST}. Run: cd eliza/plugins/plugin-xr/simulator && bun run build`,
36
- );
37
- }
38
- await this.page.addInitScript({ path: EMULATOR_DIST });
39
- }
40
-
41
- /** Navigate and wait for the emulator to be ready. */
42
- async goto(url: string): Promise<void> {
43
- await this.page.goto(url);
44
- // Wait for emulator to install (logs a console message)
45
- await this.page.waitForFunction(
46
- () => typeof window.__XREmulator !== "undefined",
47
- {
48
- timeout: 5000,
49
- },
50
- );
51
- }
52
-
53
- /** Set the emulated headset pose. */
54
- async setPose(pose: Partial<XRPose>): Promise<void> {
55
- await this.page.evaluate((p) => window.__XREmulator.setPose(p), pose);
56
- }
57
-
58
- /** Inject a camera frame from a local image file (JPEG or PNG). */
59
- async injectCameraFrame(imagePath: string): Promise<void> {
60
- const abs = resolve(imagePath);
61
- const data = readFileSync(abs);
62
- const mime = imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
63
- const dataUrl = `data:${mime};base64,${data.toString("base64")}`;
64
- await this.page.evaluate(
65
- (url) => window.__XREmulator.injectCameraFrame(url),
66
- dataUrl,
67
- );
68
- }
69
-
70
- /** Inject a camera frame from an inline data URL. */
71
- async injectCameraDataUrl(dataUrl: string): Promise<void> {
72
- await this.page.evaluate(
73
- (url) => window.__XREmulator.injectCameraFrame(url),
74
- dataUrl,
75
- );
76
- }
77
-
78
- /** Send a synthetic audio chunk directly to the agent WebSocket. */
79
- async sendAudioChunk(
80
- base64: string,
81
- sampleRate = 48000,
82
- encoding = "webm-opus",
83
- ): Promise<void> {
84
- await this.page.evaluate(
85
- ({ b64, sr, enc }) => {
86
- if (!window.__xrTestHooks)
87
- throw new Error("__xrTestHooks not available — is VITE_TEST=true?");
88
- window.__xrTestHooks.sendAudioChunk(b64, sr, enc);
89
- },
90
- { b64: base64, sr: sampleRate, enc: encoding },
91
- );
92
- }
93
-
94
- /** Get emulator stats. */
95
- async getStats(): Promise<EmulatorStats> {
96
- return this.page.evaluate(() => window.__XREmulator.getStats());
97
- }
98
-
99
- /** Get WebSocket readyState from test hooks. */
100
- async getSocketState(): Promise<string> {
101
- return this.page.evaluate(() => {
102
- if (!window.__xrTestHooks) return "UNAVAILABLE";
103
- return window.__xrTestHooks.getSocketState();
104
- });
105
- }
106
-
107
- /** Wait for the page's status text to match a pattern. */
108
- async waitForStatus(pattern: string | RegExp, timeout = 8000): Promise<void> {
109
- await this.page
110
- .locator("#status-text")
111
- .filter({ hasText: pattern })
112
- .waitFor({
113
- state: "visible",
114
- timeout,
115
- });
116
- }
117
-
118
- /** Wait for agent response text to appear. */
119
- async waitForAgentText(timeout = 10000): Promise<string> {
120
- const el = this.page.locator("#agent-response");
121
- await el.waitFor({ state: "visible", timeout });
122
- await el.filter({ hasNotText: "" }).waitFor({ timeout });
123
- return el.innerText();
124
- }
125
-
126
- /** Wait for transcript text to appear. */
127
- async waitForTranscript(timeout = 10000): Promise<string> {
128
- const el = this.page.locator("#transcript");
129
- await el.waitFor({ state: "visible", timeout });
130
- await el.filter({ hasNotText: "" }).waitFor({ timeout });
131
- return el.innerText();
132
- }
133
-
134
- /** Force-disconnect the WebSocket (tests reconnect logic). */
135
- async simulateDisconnect(): Promise<void> {
136
- await this.page.evaluate(() => window.__XREmulator.simulateDisconnect());
137
- }
138
- }
139
-
140
- // ── Playwright fixture extensions ─────────────────────────────────────────
141
-
142
- interface XRFixtures {
143
- mockAgent: MockAgentServer;
144
- xrPage: XREmulatorPage;
145
- }
146
-
147
- export const test = base.extend<XRFixtures>({
148
- mockAgent: async (_fixtures, use, testInfo) => {
149
- // Use a unique port per worker to allow parallel test runs
150
- const port = 31338 + testInfo.workerIndex;
151
- const server = new MockAgentServer({ port });
152
- await server.start();
153
- await use(server);
154
- await server.stop();
155
- },
156
-
157
- xrPage: async ({ page }, use) => {
158
- const xrp = new XREmulatorPage(page);
159
- await xrp.inject();
160
- await use(xrp);
161
- },
162
- });
163
-
164
- export { expect } from "@playwright/test";
165
-
166
- // ── Node-side exports ─────────────────────────────────────────────────────
167
-
168
- export { MockAgentServer } from "./mock-agent.ts";
169
- export type { EmulatorStats, XRPose } from "./types.ts";
@@ -1,51 +0,0 @@
1
- export interface Vec3 {
2
- x: number;
3
- y: number;
4
- z: number;
5
- }
6
-
7
- export interface Quat {
8
- x: number;
9
- y: number;
10
- z: number;
11
- w: number;
12
- }
13
-
14
- export interface XRPose {
15
- position: Vec3;
16
- orientation: Quat;
17
- }
18
-
19
- export interface EmulatorStats {
20
- sessionActive: boolean;
21
- framesInjected: number;
22
- cameraStreamActive: boolean;
23
- wsConnected: boolean;
24
- }
25
-
26
- /** window.__XREmulator — set by emulator.ts, consumed by Playwright via page.evaluate() */
27
- export interface XREmulatorAPI {
28
- setPose(pose: Partial<XRPose>): void;
29
- injectCameraFrame(jpegDataUrl: string): Promise<void>;
30
- getStats(): EmulatorStats;
31
- /** Simulate device disconnection (closes WebSocket) */
32
- simulateDisconnect(): void;
33
- /** Simulate reconnect after a disconnect */
34
- simulateReconnect(): void;
35
- }
36
-
37
- declare global {
38
- interface Window {
39
- __XREmulator: XREmulatorAPI;
40
- /** Set by app-xr/src/main.ts in VITE_TEST mode */
41
- __xrTestHooks: {
42
- sendAudioChunk(
43
- base64: string,
44
- sampleRate: number,
45
- encoding: string,
46
- ): void;
47
- getSocketState(): "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED";
48
- sendPing?(): void;
49
- };
50
- }
51
- }
@@ -1,13 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "noEmit": true,
8
- "allowImportingTsExtensions": true,
9
- "skipLibCheck": true,
10
- "lib": ["ES2022", "DOM"]
11
- },
12
- "include": ["src/**/*.ts"]
13
- }
@@ -1,25 +0,0 @@
1
- import { resolve } from "node:path";
2
- import { defineConfig } from "vite";
3
-
4
- // Builds the browser-side emulator as a self-contained IIFE.
5
- // Output: dist/emulator.js — injected into pages by Playwright via addInitScript.
6
- export default defineConfig({
7
- build: {
8
- lib: {
9
- entry: resolve(__dirname, "src/emulator.ts"),
10
- name: "XREmulator",
11
- fileName: "emulator",
12
- formats: ["iife"],
13
- },
14
- outDir: "dist",
15
- rollupOptions: {
16
- output: {
17
- inlineDynamicImports: true,
18
- // Force the output filename to emulator.js (not emulator.iife.js)
19
- entryFileNames: "[name].js",
20
- },
21
- },
22
- minify: false, // keep readable for debugging
23
- sourcemap: true,
24
- },
25
- });