@elizaos/plugin-xr 2.0.3-beta.5

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 (39) hide show
  1. package/AGENTS.md +151 -0
  2. package/CLAUDE.md +151 -0
  3. package/LICENSE +21 -0
  4. package/README.md +106 -0
  5. package/package.json +57 -0
  6. package/simulator/bun.lock +159 -0
  7. package/simulator/package.json +28 -0
  8. package/simulator/src/emulator.ts +174 -0
  9. package/simulator/src/mock-agent.ts +233 -0
  10. package/simulator/src/node.ts +9 -0
  11. package/simulator/src/playwright-fixture.ts +169 -0
  12. package/simulator/src/types.ts +51 -0
  13. package/simulator/tsconfig.json +13 -0
  14. package/simulator/vite.config.ts +25 -0
  15. package/src/__tests__/audio-pipeline.test.ts +129 -0
  16. package/src/__tests__/protocol.test.ts +53 -0
  17. package/src/__tests__/routes-e2e.test.ts +276 -0
  18. package/src/__tests__/vision-pipeline.test.ts +73 -0
  19. package/src/__tests__/xr-bundle-coverage.test.ts +303 -0
  20. package/src/__tests__/xr-feature-parity.test.ts +524 -0
  21. package/src/__tests__/xr-functional-parity.test.ts +522 -0
  22. package/src/__tests__/xr-view-host-http.test.ts +239 -0
  23. package/src/__tests__/xr-view-host.test.ts +174 -0
  24. package/src/actions/xr-query-vision.ts +64 -0
  25. package/src/actions/xr-view-actions.ts +386 -0
  26. package/src/index.ts +55 -0
  27. package/src/protocol.ts +126 -0
  28. package/src/providers/xr-context.ts +49 -0
  29. package/src/routes/xr-connect.ts +89 -0
  30. package/src/routes/xr-simulator-route.ts +37 -0
  31. package/src/routes/xr-status.ts +36 -0
  32. package/src/routes/xr-view-host.ts +359 -0
  33. package/src/routes/xr-views.ts +43 -0
  34. package/src/services/audio-pipeline.ts +120 -0
  35. package/src/services/vision-pipeline.ts +57 -0
  36. package/src/services/xr-session-service.ts +388 -0
  37. package/tsconfig.build.json +9 -0
  38. package/tsconfig.json +30 -0
  39. package/vitest.config.ts +21 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Route-level e2e for plugin-xr (issue #8802).
3
+ *
4
+ * Boots the plugin's declared `Route[]` through the REAL production dispatcher
5
+ * over a loopback `http.createServer`, exercising the real auth gate, query
6
+ * parsing, param extraction, and `routeHandler` return-shape marshaling — with a
7
+ * faked `XRSessionService` standing in for the only runtime dependency.
8
+ *
9
+ * The XR routes use the canonical return-shape `routeHandler(ctx) -> {status,
10
+ * headers, body}` contract. In production those routes are dispatched by
11
+ * `mountRoutesOnHono` -> `dispatchRoute` (packages/agent/src/api/hono-adapter.ts
12
+ * + dispatch-route.ts), NOT by the legacy `tryHandleRuntimePluginRoute` (which
13
+ * only invokes the Express-shaped `route.handler`). This test drives the actual
14
+ * production path: it builds the real Hono app for the runtime and serves its
15
+ * `fetch` handler over a real TCP socket, asserting on live HTTP responses.
16
+ *
17
+ * No mocked `json`/`error` helpers, no shape-only checks: every assertion is on
18
+ * a real HTTP response delivered over the loopback socket.
19
+ */
20
+
21
+ import { Buffer } from "node:buffer";
22
+ import http from "node:http";
23
+ import type { AddressInfo } from "node:net";
24
+ import type { IAgentRuntime } from "@elizaos/core";
25
+ import { afterEach, describe, expect, it } from "vitest";
26
+
27
+ import { buildHonoAppForRuntime } from "../../../../packages/agent/src/api/hono-adapter.ts";
28
+ import { xrConnectRoute } from "../routes/xr-connect.ts";
29
+ import { xrSimulatorRoute } from "../routes/xr-simulator-route.ts";
30
+ import { xrStatusRoute } from "../routes/xr-status.ts";
31
+ import { xrViewHostRoute } from "../routes/xr-view-host.ts";
32
+ import { xrViewsRoute } from "../routes/xr-views.ts";
33
+ import { XR_SERVICE_TYPE } from "../services/xr-session-service.ts";
34
+
35
+ const XR_ROUTES = [
36
+ xrStatusRoute,
37
+ xrConnectRoute,
38
+ xrViewsRoute,
39
+ xrViewHostRoute,
40
+ xrSimulatorRoute,
41
+ ];
42
+
43
+ const servers: http.Server[] = [];
44
+
45
+ afterEach(async () => {
46
+ await Promise.all(
47
+ servers.map(
48
+ (server) =>
49
+ new Promise<void>((resolve) => {
50
+ server.closeAllConnections?.();
51
+ server.close(() => resolve());
52
+ }),
53
+ ),
54
+ );
55
+ servers.length = 0;
56
+ });
57
+
58
+ interface FakeConnection {
59
+ id: string;
60
+ deviceType: string;
61
+ connectedAt: Date;
62
+ }
63
+
64
+ function makeRuntime(
65
+ options: {
66
+ withService?: boolean;
67
+ connections?: FakeConnection[];
68
+ recentFrameIds?: string[];
69
+ } = {},
70
+ ): IAgentRuntime {
71
+ const { withService = true, connections = [], recentFrameIds = [] } = options;
72
+
73
+ const recent = new Set(recentFrameIds);
74
+ const service = {
75
+ getConnections: () => connections,
76
+ getVisionPipeline: () => ({
77
+ hasRecentFrame: (id: string) => recent.has(id),
78
+ }),
79
+ };
80
+
81
+ return {
82
+ routes: XR_ROUTES,
83
+ getService: (key: string) =>
84
+ withService && key === XR_SERVICE_TYPE ? service : null,
85
+ // xrViewHostRoute reads `runtime.port` to build the bundle origin.
86
+ port: 31337,
87
+ } as unknown as IAgentRuntime;
88
+ }
89
+
90
+ /**
91
+ * Serve the real production Hono app (built from `runtime.routes`) over a real
92
+ * loopback TCP socket. Bridges the Node request/response to Hono's standard
93
+ * `fetch` handler so assertions run against genuine HTTP — not an in-memory
94
+ * handler invocation.
95
+ */
96
+ async function startServer(
97
+ runtime: IAgentRuntime,
98
+ isAuthorized: () => boolean = () => true,
99
+ ): Promise<string> {
100
+ const app = buildHonoAppForRuntime(runtime, { isAuthorized });
101
+
102
+ const server = http.createServer((req, res) => {
103
+ const chunks: Buffer[] = [];
104
+ req.on("data", (chunk) => chunks.push(chunk as Buffer));
105
+ req.on("end", () => {
106
+ void (async () => {
107
+ const url = `http://127.0.0.1${req.url ?? "/"}`;
108
+ const method = req.method ?? "GET";
109
+ const headers = new Headers();
110
+ for (const [key, value] of Object.entries(req.headers)) {
111
+ if (value === undefined) continue;
112
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
113
+ }
114
+ const hasBody = method !== "GET" && method !== "HEAD";
115
+ const request = new Request(url, {
116
+ method,
117
+ headers,
118
+ body: hasBody && chunks.length ? Buffer.concat(chunks) : undefined,
119
+ });
120
+
121
+ const response = await app.fetch(request);
122
+
123
+ res.statusCode = response.status;
124
+ response.headers.forEach((value, key) => {
125
+ res.setHeader(key, value);
126
+ });
127
+ const buf = Buffer.from(await response.arrayBuffer());
128
+ res.end(buf);
129
+ })();
130
+ });
131
+ });
132
+
133
+ servers.push(server);
134
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
135
+ const { port } = server.address() as AddressInfo;
136
+ return `http://127.0.0.1:${port}`;
137
+ }
138
+
139
+ describe("plugin-xr routes (real dispatch)", () => {
140
+ it("serves /xr/status with the connected-device list when the service is present", async () => {
141
+ const connectedAt = new Date("2024-01-01T00:00:00.000Z");
142
+ const base = await startServer(
143
+ makeRuntime({
144
+ connections: [
145
+ { id: "conn-a", deviceType: "quest3", connectedAt },
146
+ { id: "conn-b", deviceType: "xreal", connectedAt },
147
+ ],
148
+ recentFrameIds: ["conn-a"],
149
+ }),
150
+ );
151
+
152
+ const res = await fetch(`${base}/xr/status`);
153
+ expect(res.status).toBe(200);
154
+ expect(res.headers.get("content-type")).toContain("application/json");
155
+
156
+ const body = (await res.json()) as {
157
+ connected: boolean;
158
+ connections: Array<{
159
+ id: string;
160
+ deviceType: string;
161
+ connectedAt: string;
162
+ hasRecentFrame: boolean;
163
+ }>;
164
+ };
165
+ expect(body.connected).toBe(true);
166
+ expect(body.connections).toHaveLength(2);
167
+ expect(body.connections[0]).toEqual({
168
+ id: "conn-a",
169
+ deviceType: "quest3",
170
+ connectedAt: "2024-01-01T00:00:00.000Z",
171
+ hasRecentFrame: true,
172
+ });
173
+ expect(body.connections[1].hasRecentFrame).toBe(false);
174
+ });
175
+
176
+ it("reports connected:false when the service has no active connections", async () => {
177
+ const base = await startServer(makeRuntime({ connections: [] }));
178
+ const res = await fetch(`${base}/xr/status`);
179
+ expect(res.status).toBe(200);
180
+ const body = (await res.json()) as {
181
+ connected: boolean;
182
+ connections: unknown[];
183
+ };
184
+ expect(body.connected).toBe(false);
185
+ expect(body.connections).toEqual([]);
186
+ });
187
+
188
+ it("returns 503 from /xr/status when the XR service is unavailable", async () => {
189
+ const base = await startServer(makeRuntime({ withService: false }));
190
+ const res = await fetch(`${base}/xr/status`);
191
+ expect(res.status).toBe(503);
192
+ const body = (await res.json()) as { error: string };
193
+ expect(body.error).toContain("XR service not running");
194
+ });
195
+
196
+ it("enforces the auth gate on the non-public /xr/status route", async () => {
197
+ const base = await startServer(makeRuntime(), () => false);
198
+ const res = await fetch(`${base}/xr/status`);
199
+ expect(res.status).toBe(401);
200
+ expect(((await res.json()) as { error: string }).error).toBe(
201
+ "Unauthorized",
202
+ );
203
+ });
204
+
205
+ it("serves /xr/connect as an HTML pairing page when authorized", async () => {
206
+ const base = await startServer(makeRuntime());
207
+ const res = await fetch(`${base}/xr/connect`);
208
+ expect(res.status).toBe(200);
209
+ expect(res.headers.get("content-type")).toContain("text/html");
210
+ const html = await res.text();
211
+ expect(html).toContain("<!DOCTYPE html>");
212
+ expect(html).toContain("Connect XR Headset");
213
+ });
214
+
215
+ it("auth-gates /xr/connect when the request is unauthorized", async () => {
216
+ const base = await startServer(makeRuntime(), () => false);
217
+ const res = await fetch(`${base}/xr/connect`);
218
+ expect(res.status).toBe(401);
219
+ });
220
+
221
+ it("serves /xr/views with views, count, and live connections", async () => {
222
+ const base = await startServer(
223
+ makeRuntime({
224
+ connections: [
225
+ {
226
+ id: "conn-a",
227
+ deviceType: "quest3",
228
+ connectedAt: new Date("2024-01-01T00:00:00.000Z"),
229
+ },
230
+ ],
231
+ }),
232
+ );
233
+ const res = await fetch(`${base}/xr/views`);
234
+ expect(res.status).toBe(200);
235
+ const body = (await res.json()) as {
236
+ views: unknown[];
237
+ count: number;
238
+ connections: Array<{ id: string; deviceType: string }>;
239
+ };
240
+ expect(Array.isArray(body.views)).toBe(true);
241
+ expect(body.count).toBe(body.views.length);
242
+ expect(body.connections).toEqual([{ id: "conn-a", deviceType: "quest3" }]);
243
+ });
244
+
245
+ it("serves /xr/view-host/:id HTML with the extracted view id", async () => {
246
+ const base = await startServer(makeRuntime());
247
+ const res = await fetch(`${base}/xr/view-host/wallet`);
248
+ expect(res.status).toBe(200);
249
+ expect(res.headers.get("content-type")).toContain("text/html");
250
+ const html = await res.text();
251
+ expect(html).toContain('data-view-id="wallet"');
252
+ expect(html).toContain("/api/views/wallet/bundle.js");
253
+ expect(res.headers.get("content-security-policy")).toContain("esm.sh");
254
+ });
255
+
256
+ it("serves /xr/simulator.js according to whether the emulator bundle is built", async () => {
257
+ const base = await startServer(makeRuntime());
258
+ const res = await fetch(`${base}/xr/simulator.js`);
259
+
260
+ // The route's two real branches: 200 + JS when the simulator bundle has
261
+ // been built, 404 + a build hint otherwise. Both are exercised here over
262
+ // real HTTP; which one fires depends on whether `simulator:build` has run.
263
+ if (res.status === 200) {
264
+ expect(res.headers.get("content-type")).toContain(
265
+ "application/javascript",
266
+ );
267
+ const js = await res.text();
268
+ expect(js.length).toBeGreaterThan(0);
269
+ } else {
270
+ expect(res.status).toBe(404);
271
+ const body = (await res.json()) as { error: string; hint: string };
272
+ expect(body.error).toContain("Emulator bundle not built");
273
+ expect(body.hint).toContain("simulator");
274
+ }
275
+ });
276
+ });
@@ -0,0 +1,73 @@
1
+ import type { IAgentRuntime } from "@elizaos/core";
2
+ import { ModelType } from "@elizaos/core";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { VisionPipeline } from "../services/vision-pipeline.ts";
5
+
6
+ function makeRuntime(result = "a desk with a laptop"): IAgentRuntime {
7
+ return {
8
+ useModel: vi.fn().mockResolvedValue(result),
9
+ } as unknown as IAgentRuntime;
10
+ }
11
+
12
+ const FRAME_HEADER = {
13
+ type: "frame" as const,
14
+ ts: Date.now(),
15
+ width: 1280,
16
+ height: 720,
17
+ format: "jpeg" as const,
18
+ };
19
+
20
+ describe("VisionPipeline", () => {
21
+ let pipeline: VisionPipeline;
22
+
23
+ beforeEach(() => {
24
+ pipeline = new VisionPipeline();
25
+ });
26
+
27
+ it("stores and retrieves the latest frame", () => {
28
+ const data = Buffer.alloc(512, 0xff);
29
+ pipeline.storeFrame("conn1", FRAME_HEADER, data);
30
+ const frame = pipeline.getLatestFrame("conn1");
31
+ expect(frame).toBeDefined();
32
+ expect(frame?.data).toBe(data);
33
+ });
34
+
35
+ it("returns undefined for an unknown connection", () => {
36
+ expect(pipeline.getLatestFrame("unknown")).toBeUndefined();
37
+ });
38
+
39
+ it("clears frames on clear()", () => {
40
+ pipeline.storeFrame("conn1", FRAME_HEADER, Buffer.alloc(128));
41
+ pipeline.clear("conn1");
42
+ expect(pipeline.getLatestFrame("conn1")).toBeUndefined();
43
+ });
44
+
45
+ it("describeFrame calls IMAGE_DESCRIPTION model with data URL", async () => {
46
+ const runtime = makeRuntime("a red chair");
47
+ const data = Buffer.from([0xff, 0xd8, 0xff]); // Minimal JPEG header bytes.
48
+ pipeline.storeFrame("conn1", FRAME_HEADER, data);
49
+
50
+ const result = await pipeline.describeFrame(runtime, "conn1");
51
+
52
+ expect(runtime.useModel).toHaveBeenCalledWith(
53
+ ModelType.IMAGE_DESCRIPTION,
54
+ expect.objectContaining({
55
+ imageUrl: expect.stringContaining("data:image/jpeg;base64,"),
56
+ }),
57
+ );
58
+ expect(result).toBe("a red chair");
59
+ });
60
+
61
+ it("describeFrame returns null when no frame exists", async () => {
62
+ const runtime = makeRuntime();
63
+ const result = await pipeline.describeFrame(runtime, "conn1");
64
+ expect(result).toBeNull();
65
+ expect(runtime.useModel).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("hasRecentFrame returns true when frame exists", () => {
69
+ pipeline.storeFrame("conn1", FRAME_HEADER, Buffer.alloc(64));
70
+ expect(pipeline.hasRecentFrame("conn1")).toBe(true);
71
+ expect(pipeline.hasRecentFrame("conn2")).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * XR view bundle coverage — validates that every registered XR view plugin
3
+ * has a built dist/views/bundle.js that is non-empty, valid JavaScript,
4
+ * and exports the component named in the plugin manifest.
5
+ *
6
+ * This is the "real elizaOS plugin infrastructure" layer the simulator tests
7
+ * cannot reach: it proves that the actual view content (the React component
8
+ * that loads inside the XR shell) is built, present, and structurally sound.
9
+ *
10
+ * What is tested:
11
+ * - bundle.js exists for all 18 source-buildable plugins
12
+ * - bundle.js is non-empty and contains built view content
13
+ * - bundle.js contains the componentExport name from the manifest
14
+ * - bundle.js is valid JavaScript (no JSON or HTML accidentally written there)
15
+ * - The plugin manifest and bundle agree on componentExport
16
+ */
17
+
18
+ import { existsSync, readFileSync, statSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { describe, expect, it } from "vitest";
22
+
23
+ const repoRoot = resolve(
24
+ dirname(fileURLToPath(import.meta.url)),
25
+ "../../../..",
26
+ );
27
+
28
+ function readFile(relPath: string): string {
29
+ return readFileSync(resolve(repoRoot, relPath), "utf8");
30
+ }
31
+
32
+ function fileExists(relPath: string): boolean {
33
+ return existsSync(resolve(repoRoot, relPath));
34
+ }
35
+
36
+ function fileSize(relPath: string): number {
37
+ const p = resolve(repoRoot, relPath);
38
+ if (!existsSync(p)) return 0;
39
+ return statSync(p).size;
40
+ }
41
+
42
+ function bundlePathFor(pluginDir: string): string {
43
+ return `${pluginDir}/dist/views/bundle.js`;
44
+ }
45
+
46
+ function missingBundlePaths(): string[] {
47
+ return PLUGIN_BUNDLES.map(({ pluginDir }) => bundlePathFor(pluginDir)).filter(
48
+ (bundlePath) => !fileExists(bundlePath),
49
+ );
50
+ }
51
+
52
+ // Parses viewType/id/componentExport/bundlePath from plugin source
53
+ function extractXrViews(
54
+ source: string,
55
+ ): Array<{ id: string; componentExport: string; bundlePath: string }> {
56
+ const results: Array<{
57
+ id: string;
58
+ componentExport: string;
59
+ bundlePath: string;
60
+ }> = [];
61
+ // Match view objects with viewType: "xr"
62
+ const viewsStart = source.indexOf("views:");
63
+ if (viewsStart === -1) return results;
64
+ const arrayStart = source.indexOf("[", viewsStart);
65
+ if (arrayStart === -1) return results;
66
+ let depth = 0;
67
+ let arrayEnd = -1;
68
+ for (let i = arrayStart; i < source.length; i++) {
69
+ if (source[i] === "[") depth++;
70
+ if (source[i] === "]") depth--;
71
+ if (depth === 0) {
72
+ arrayEnd = i;
73
+ break;
74
+ }
75
+ }
76
+ if (arrayEnd === -1) return results;
77
+ const body = source.slice(arrayStart + 1, arrayEnd);
78
+ const objects: string[] = [];
79
+ let start = -1;
80
+ depth = 0;
81
+ for (let i = 0; i < body.length; i++) {
82
+ if (body[i] === "{") {
83
+ if (depth === 0) start = i;
84
+ depth++;
85
+ }
86
+ if (body[i] === "}") {
87
+ depth--;
88
+ if (depth === 0 && start !== -1) {
89
+ objects.push(body.slice(start, i + 1));
90
+ start = -1;
91
+ }
92
+ }
93
+ }
94
+ for (const obj of objects) {
95
+ // Source-level mirror of core's `getViewModalities`: a view renders on the
96
+ // explicit `modalities: [...]` list when present, otherwise the single
97
+ // `viewType` (default "gui"). The view is an XR view when "xr" is among them.
98
+ const modalitiesMatch = obj.match(/modalities:\s*\[([^\]]*)\]/);
99
+ const modalities = modalitiesMatch
100
+ ? [...modalitiesMatch[1].matchAll(/"([^"]+)"/g)].map((m) => m[1])
101
+ : [obj.match(/viewType:\s*"([^"]+)"/)?.[1] ?? "gui"];
102
+ if (!modalities.includes("xr")) continue;
103
+ const id = obj.match(/\bid:\s*"([^"]+)"/)?.[1];
104
+ const componentExport = obj.match(/componentExport:\s*"([^"]+)"/)?.[1];
105
+ const bundlePath = obj.match(/bundlePath:\s*"([^"]+)"/)?.[1];
106
+ if (id && componentExport && bundlePath) {
107
+ results.push({ id, componentExport, bundlePath });
108
+ }
109
+ }
110
+ return results;
111
+ }
112
+
113
+ // The 18 plugin manifests → (plugin directory, manifest path)
114
+ const PLUGIN_BUNDLES: Array<{ pluginDir: string; manifestPath: string }> = [
115
+ {
116
+ pluginDir: "plugins/plugin-companion",
117
+ manifestPath: "plugins/plugin-companion/src/plugin.ts",
118
+ },
119
+ {
120
+ pluginDir: "plugins/plugin-contacts",
121
+ manifestPath: "plugins/plugin-contacts/src/plugin.ts",
122
+ },
123
+ {
124
+ pluginDir: "plugins/plugin-hyperliquid-app",
125
+ manifestPath: "plugins/plugin-hyperliquid-app/src/plugin.ts",
126
+ },
127
+ {
128
+ pluginDir: "plugins/plugin-messages",
129
+ manifestPath: "plugins/plugin-messages/src/plugin.ts",
130
+ },
131
+ {
132
+ pluginDir: "plugins/app-model-tester",
133
+ manifestPath: "plugins/app-model-tester/src/plugin.ts",
134
+ },
135
+ {
136
+ pluginDir: "plugins/plugin-phone",
137
+ manifestPath: "plugins/plugin-phone/src/plugin.ts",
138
+ },
139
+ {
140
+ pluginDir: "plugins/plugin-polymarket-app",
141
+ manifestPath: "plugins/plugin-polymarket-app/src/plugin.ts",
142
+ },
143
+ {
144
+ pluginDir: "plugins/plugin-shopify-ui",
145
+ manifestPath: "plugins/plugin-shopify-ui/src/plugin.ts",
146
+ },
147
+ {
148
+ pluginDir: "plugins/plugin-steward-app",
149
+ manifestPath: "plugins/plugin-steward-app/src/plugin.ts",
150
+ },
151
+ {
152
+ pluginDir: "plugins/plugin-vincent",
153
+ manifestPath: "plugins/plugin-vincent/src/plugin.ts",
154
+ },
155
+ {
156
+ pluginDir: "plugins/plugin-wallet-ui",
157
+ manifestPath: "plugins/plugin-wallet-ui/src/plugin.ts",
158
+ },
159
+ {
160
+ pluginDir: "plugins/plugin-feed",
161
+ manifestPath: "plugins/plugin-feed/src/index.ts",
162
+ },
163
+ {
164
+ pluginDir: "plugins/plugin-app-control",
165
+ manifestPath: "plugins/plugin-app-control/src/index.ts",
166
+ },
167
+ {
168
+ pluginDir: "plugins/plugin-screenshare",
169
+ manifestPath: "plugins/plugin-screenshare/src/index.ts",
170
+ },
171
+ {
172
+ pluginDir: "plugins/plugin-task-coordinator",
173
+ manifestPath: "plugins/plugin-task-coordinator/src/index.ts",
174
+ },
175
+ {
176
+ pluginDir: "plugins/plugin-trajectory-logger",
177
+ manifestPath: "plugins/plugin-trajectory-logger/src/plugin.ts",
178
+ },
179
+ {
180
+ pluginDir: "plugins/plugin-training",
181
+ manifestPath: "plugins/plugin-training/src/setup-routes.ts",
182
+ },
183
+ {
184
+ pluginDir: "plugins/plugin-facewear",
185
+ manifestPath: "plugins/plugin-facewear/src/index.ts",
186
+ },
187
+ ];
188
+
189
+ describe("XR view bundle coverage — all 18 plugin bundles built and valid", () => {
190
+ it("declares dist/views/bundle.js for every plugin with an XR view", () => {
191
+ const missingDeclarations: string[] = [];
192
+ for (const { pluginDir, manifestPath } of PLUGIN_BUNDLES) {
193
+ const xrViews = extractXrViews(readFile(manifestPath));
194
+ if (xrViews.length === 0) {
195
+ missingDeclarations.push(pluginDir);
196
+ }
197
+ }
198
+ expect(
199
+ missingDeclarations,
200
+ "plugins without view build declarations",
201
+ ).toEqual([]);
202
+ });
203
+
204
+ it("dist/views/bundle.js exists for every plugin with an XR view", () => {
205
+ expect(
206
+ missingBundlePaths(),
207
+ "plugins with missing XR view bundles (run each plugin's build:views)",
208
+ ).toEqual([]);
209
+ });
210
+
211
+ it("built bundle.js files are non-empty (at least 1 KB of content)", () => {
212
+ const tooSmall: string[] = [];
213
+ for (const { pluginDir } of PLUGIN_BUNDLES) {
214
+ const bundlePath = bundlePathFor(pluginDir);
215
+ const size = fileSize(bundlePath);
216
+ if (size < 1024) {
217
+ tooSmall.push(`${bundlePath}: ${size} bytes`);
218
+ }
219
+ }
220
+ expect(tooSmall, "bundles too small to contain real content").toEqual([]);
221
+ });
222
+
223
+ it("every bundle.js starts with valid JavaScript (not HTML or JSON)", () => {
224
+ const invalid: string[] = [];
225
+ for (const { pluginDir } of PLUGIN_BUNDLES) {
226
+ const bundlePath = bundlePathFor(pluginDir);
227
+ if (!fileExists(bundlePath)) {
228
+ invalid.push(`${bundlePath}: missing`);
229
+ continue;
230
+ }
231
+ const first = readFile(bundlePath).trimStart().slice(0, 20);
232
+ if (first.startsWith("<") || first.startsWith("{")) {
233
+ invalid.push(`${bundlePath}: starts with "${first}"`);
234
+ }
235
+ }
236
+ expect(invalid, "bundles with invalid content type").toEqual([]);
237
+ });
238
+
239
+ it("every bundle.js contains the componentExport declared in the plugin manifest", () => {
240
+ const mismatches: string[] = [];
241
+ for (const { pluginDir, manifestPath } of PLUGIN_BUNDLES) {
242
+ const bundlePath = bundlePathFor(pluginDir);
243
+ if (!fileExists(bundlePath)) {
244
+ mismatches.push(`${bundlePath}: missing`);
245
+ continue;
246
+ }
247
+
248
+ const manifestSource = readFile(manifestPath);
249
+ const xrViews = extractXrViews(manifestSource);
250
+ const bundle = readFile(bundlePath);
251
+
252
+ for (const view of xrViews) {
253
+ // componentExport may be a full path like "@pkg/name#ExportName" — extract just the export name
254
+ const exportName = view.componentExport.includes("#")
255
+ ? (view.componentExport.split("#").pop() ?? view.componentExport)
256
+ : view.componentExport;
257
+ if (!bundle.includes(exportName)) {
258
+ mismatches.push(
259
+ `${pluginDir}: bundle does not contain export "${exportName}" (from manifest componentExport "${view.componentExport}" for view "${view.id}")`,
260
+ );
261
+ }
262
+ }
263
+ }
264
+ expect(
265
+ mismatches,
266
+ "bundles missing their declared componentExport",
267
+ ).toEqual([]);
268
+ });
269
+
270
+ it("bundle.js size is consistent with real plugin content", () => {
271
+ // A real built view bundle should be at least 5 KB. Empty or skeletal files are typically < 1 KB.
272
+ const tooSmall: string[] = [];
273
+ for (const { pluginDir } of PLUGIN_BUNDLES) {
274
+ const bundlePath = `${pluginDir}/dist/views/bundle.js`;
275
+ const size = fileSize(bundlePath);
276
+ if (size > 0 && size < 5000) {
277
+ tooSmall.push(
278
+ `${bundlePath}: ${size} bytes (expected ≥ 5 KB for real content)`,
279
+ );
280
+ }
281
+ }
282
+ expect(tooSmall, "suspiciously small bundles").toEqual([]);
283
+ });
284
+
285
+ it("plugin manifest bundlePath uses the standard view bundle location", () => {
286
+ const mismatches: string[] = [];
287
+ for (const { pluginDir, manifestPath } of PLUGIN_BUNDLES) {
288
+ const manifestSource = readFile(manifestPath);
289
+ const xrViews = extractXrViews(manifestSource);
290
+ for (const view of xrViews) {
291
+ if (view.bundlePath !== "dist/views/bundle.js") {
292
+ mismatches.push(
293
+ `${pluginDir}: manifest says bundlePath="${view.bundlePath}"`,
294
+ );
295
+ }
296
+ }
297
+ }
298
+ expect(
299
+ mismatches,
300
+ "manifest bundlePath using non-standard locations",
301
+ ).toEqual([]);
302
+ });
303
+ });