@elizaos/plugin-xr 2.0.3-beta.5 → 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.
- package/dist/actions/xr-query-vision.d.ts +3 -0
- package/dist/actions/xr-query-vision.d.ts.map +1 -0
- package/dist/actions/xr-query-vision.js +39 -0
- package/dist/actions/xr-query-vision.js.map +1 -0
- package/dist/actions/xr-view-actions.d.ts +18 -0
- package/dist/actions/xr-view-actions.d.ts.map +1 -0
- package/dist/actions/xr-view-actions.js +304 -0
- package/dist/actions/xr-view-actions.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +124 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +18 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/xr-context.d.ts +3 -0
- package/dist/providers/xr-context.d.ts.map +1 -0
- package/dist/providers/xr-context.js +34 -0
- package/dist/providers/xr-context.js.map +1 -0
- package/dist/routes/xr-connect.d.ts +3 -0
- package/dist/routes/xr-connect.d.ts.map +1 -0
- package/{src/routes/xr-connect.ts → dist/routes/xr-connect.js} +12 -15
- package/dist/routes/xr-connect.js.map +1 -0
- package/dist/routes/xr-simulator-route.d.ts +8 -0
- package/dist/routes/xr-simulator-route.d.ts.map +1 -0
- package/{src/routes/xr-simulator-route.ts → dist/routes/xr-simulator-route.js} +10 -16
- package/dist/routes/xr-simulator-route.js.map +1 -0
- package/dist/routes/xr-status.d.ts +3 -0
- package/dist/routes/xr-status.d.ts.map +1 -0
- package/{src/routes/xr-status.ts → dist/routes/xr-status.js} +13 -15
- package/dist/routes/xr-status.js.map +1 -0
- package/dist/routes/xr-view-host.d.ts +24 -0
- package/dist/routes/xr-view-host.d.ts.map +1 -0
- package/{src/routes/xr-view-host.ts → dist/routes/xr-view-host.js} +22 -59
- package/dist/routes/xr-view-host.js.map +1 -0
- package/dist/routes/xr-views.d.ts +8 -0
- package/dist/routes/xr-views.d.ts.map +1 -0
- package/dist/routes/xr-views.js +31 -0
- package/dist/routes/xr-views.js.map +1 -0
- package/dist/services/audio-pipeline.d.ts +20 -0
- package/dist/services/audio-pipeline.d.ts.map +1 -0
- package/{src/services/audio-pipeline.ts → dist/services/audio-pipeline.js} +25 -58
- package/dist/services/audio-pipeline.js.map +1 -0
- package/dist/services/vision-pipeline.d.ts +16 -0
- package/dist/services/vision-pipeline.d.ts.map +1 -0
- package/dist/services/vision-pipeline.js +39 -0
- package/dist/services/vision-pipeline.js.map +1 -0
- package/dist/services/xr-session-service.d.ts +50 -0
- package/dist/services/xr-session-service.d.ts.map +1 -0
- package/{src/services/xr-session-service.ts → dist/services/xr-session-service.js} +85 -194
- package/dist/services/xr-session-service.js.map +1 -0
- package/package.json +9 -4
- package/AGENTS.md +0 -151
- package/CLAUDE.md +0 -151
- package/simulator/bun.lock +0 -159
- package/simulator/package.json +0 -28
- package/simulator/src/emulator.ts +0 -174
- package/simulator/src/mock-agent.ts +0 -233
- package/simulator/src/node.ts +0 -9
- package/simulator/src/playwright-fixture.ts +0 -169
- package/simulator/src/types.ts +0 -51
- package/simulator/tsconfig.json +0 -13
- package/simulator/vite.config.ts +0 -25
- package/src/__tests__/audio-pipeline.test.ts +0 -129
- package/src/__tests__/protocol.test.ts +0 -53
- package/src/__tests__/routes-e2e.test.ts +0 -276
- package/src/__tests__/vision-pipeline.test.ts +0 -73
- package/src/__tests__/xr-bundle-coverage.test.ts +0 -303
- package/src/__tests__/xr-feature-parity.test.ts +0 -524
- package/src/__tests__/xr-functional-parity.test.ts +0 -522
- package/src/__tests__/xr-view-host-http.test.ts +0 -239
- package/src/__tests__/xr-view-host.test.ts +0 -174
- package/src/actions/xr-query-vision.ts +0 -64
- package/src/actions/xr-view-actions.ts +0 -386
- package/src/index.ts +0 -55
- package/src/protocol.ts +0 -126
- package/src/providers/xr-context.ts +0 -49
- package/src/routes/xr-views.ts +0 -43
- package/src/services/vision-pipeline.ts +0 -57
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -30
- 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
|
-
}
|
package/simulator/src/node.ts
DELETED
|
@@ -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";
|
package/simulator/src/types.ts
DELETED
|
@@ -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
|
-
}
|
package/simulator/tsconfig.json
DELETED
|
@@ -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
|
-
}
|
package/simulator/vite.config.ts
DELETED
|
@@ -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
|
-
});
|