@forgeax/engine-remote 0.1.2

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 (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +269 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +2 -0
  5. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/console.unit.test.d.ts +2 -0
  7. package/dist/__tests__/console.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  9. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/execute-browser-safe.unit.test.d.ts +2 -0
  11. package/dist/__tests__/execute-browser-safe.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/execute-profiler-root.browser.test.d.ts +2 -0
  13. package/dist/__tests__/execute-profiler-root.browser.test.d.ts.map +1 -0
  14. package/dist/__tests__/execute.async.test.d.ts +2 -0
  15. package/dist/__tests__/execute.async.test.d.ts.map +1 -0
  16. package/dist/__tests__/execution-report-root.unit.test.d.ts +2 -0
  17. package/dist/__tests__/execution-report-root.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/introspect-profiler.unit.test.d.ts +2 -0
  19. package/dist/__tests__/introspect-profiler.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/method-roster-profiler.test.d.ts +2 -0
  21. package/dist/__tests__/method-roster-profiler.test.d.ts.map +1 -0
  22. package/dist/__tests__/rhi-capture-remote.integration.test.d.ts +2 -0
  23. package/dist/__tests__/rhi-capture-remote.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/server.unit.test.d.ts +2 -0
  25. package/dist/__tests__/server.unit.test.d.ts.map +1 -0
  26. package/dist/error-messages.d.ts +3 -0
  27. package/dist/error-messages.d.ts.map +1 -0
  28. package/dist/errors.d.ts +82 -0
  29. package/dist/errors.d.ts.map +1 -0
  30. package/dist/errors.mjs +37 -0
  31. package/dist/errors.mjs.map +1 -0
  32. package/dist/execute.d.ts +31 -0
  33. package/dist/execute.d.ts.map +1 -0
  34. package/dist/execute.mjs +93 -0
  35. package/dist/execute.mjs.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.mjs +30 -0
  39. package/dist/index.mjs.map +1 -0
  40. package/dist/introspect.d.ts +21 -0
  41. package/dist/introspect.d.ts.map +1 -0
  42. package/dist/introspect.mjs +173 -0
  43. package/dist/introspect.mjs.map +1 -0
  44. package/dist/server.d.ts +35 -0
  45. package/dist/server.d.ts.map +1 -0
  46. package/dist/server.mjs +473 -0
  47. package/dist/server.mjs.map +1 -0
  48. package/package.json +78 -0
  49. package/src/__tests__/asset-runtime-inspection.unit.test.ts +16 -0
  50. package/src/__tests__/console.unit.test.ts +12 -0
  51. package/src/__tests__/errors.unit.test.ts +102 -0
  52. package/src/__tests__/execute-browser-safe.unit.test.ts +43 -0
  53. package/src/__tests__/execute-profiler-root.browser.test.ts +41 -0
  54. package/src/__tests__/execute.async.test.ts +369 -0
  55. package/src/__tests__/execution-report-root.unit.test.ts +38 -0
  56. package/src/__tests__/introspect-profiler.unit.test.ts +55 -0
  57. package/src/__tests__/method-roster-profiler.test.ts +80 -0
  58. package/src/__tests__/rhi-capture-remote.integration.test.ts +58 -0
  59. package/src/__tests__/server.unit.test.ts +856 -0
  60. package/src/__tests__/vm-async-eval-verify.mjs +149 -0
  61. package/src/error-messages.ts +9 -0
  62. package/src/errors.ts +143 -0
  63. package/src/execute.ts +152 -0
  64. package/src/index.ts +19 -0
  65. package/src/introspect.ts +223 -0
  66. package/src/server.ts +331 -0
@@ -0,0 +1,856 @@
1
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
2
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
3
+ //
4
+ // Source files (N=1):
5
+ // - packages/console/src/__tests__/server.test.ts
6
+ //
7
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
8
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
9
+
10
+ import { createProfiler } from '@forgeax/engine-profiler';
11
+ import { defaultConnect } from '@forgeax/engine-types/inspector-client';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { WebSocket } from 'ws';
14
+ import { RemoteError } from '../errors';
15
+ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer } from '../server';
16
+
17
+ {
18
+ // --- from server.test.ts ---
19
+ // Route B (2026-06-29): eval is full-access, no sandbox, no registry.
20
+ // startServer takes { world, renderer?, assets? } — no engine/registry.
21
+
22
+ type JsonRpcRequest = {
23
+ jsonrpc: '2.0';
24
+ method: string;
25
+ params?: unknown;
26
+ id?: number | string | null;
27
+ };
28
+
29
+ type JsonRpcResponse = {
30
+ jsonrpc: '2.0';
31
+ result?: unknown;
32
+ error?: {
33
+ code: number;
34
+ message: string;
35
+ data?: {
36
+ code: string;
37
+ expected: string;
38
+ hint: string;
39
+ message?: string;
40
+ detail?: { code: string; shape: string };
41
+ };
42
+ };
43
+ id: number | string | null;
44
+ };
45
+
46
+ async function connect(port: number): Promise<WebSocket> {
47
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/inspector`);
48
+ await new Promise<void>((resolve, reject) => {
49
+ ws.once('open', () => resolve());
50
+ ws.once('error', (e) => reject(e));
51
+ });
52
+ return ws;
53
+ }
54
+
55
+ async function send(ws: WebSocket, msg: JsonRpcRequest): Promise<JsonRpcResponse> {
56
+ return new Promise<JsonRpcResponse>((resolve, reject) => {
57
+ const handler = (raw: WebSocket.RawData): void => {
58
+ ws.off('message', handler);
59
+ try {
60
+ const parsed = JSON.parse(raw.toString()) as JsonRpcResponse;
61
+ resolve(parsed);
62
+ } catch (e) {
63
+ reject(e);
64
+ }
65
+ };
66
+ ws.on('message', handler);
67
+ ws.send(JSON.stringify(msg));
68
+ });
69
+ }
70
+
71
+ async function sendRaw(ws: WebSocket, raw: string): Promise<JsonRpcResponse> {
72
+ return new Promise<JsonRpcResponse>((resolve, reject) => {
73
+ const handler = (message: WebSocket.RawData): void => {
74
+ ws.off('message', handler);
75
+ try {
76
+ const parsed = JSON.parse(message.toString()) as JsonRpcResponse;
77
+ resolve(parsed);
78
+ } catch (e) {
79
+ reject(e);
80
+ }
81
+ };
82
+ ws.on('message', handler);
83
+ ws.send(raw);
84
+ });
85
+ }
86
+
87
+ async function withServer(
88
+ fn: (handle: ConsoleHandle) => Promise<void>,
89
+ opts: {
90
+ port?: number;
91
+ world?: unknown;
92
+ renderer?: unknown;
93
+ assets?: unknown;
94
+ profiler?: unknown;
95
+ importModule?: (specifier: string) => Promise<unknown>;
96
+ } = {},
97
+ ): Promise<void> {
98
+ const startResult = await startServer({
99
+ port: opts.port ?? 0,
100
+ host: '127.0.0.1',
101
+ world: opts.world ?? {},
102
+ renderer: opts.renderer,
103
+ assets: opts.assets,
104
+ profiler: opts.profiler,
105
+ ...(opts.importModule === undefined ? {} : { importModule: opts.importModule }),
106
+ });
107
+ if (!startResult.ok) {
108
+ throw startResult.error;
109
+ }
110
+ const handle = startResult.value;
111
+ try {
112
+ await fn(handle);
113
+ } finally {
114
+ await handle.close();
115
+ }
116
+ }
117
+
118
+ describe('startServer happy path', () => {
119
+ it('returns Result.ok with a ConsoleHandle exposing .port + .close', async () => {
120
+ await withServer(async (handle) => {
121
+ expect(typeof handle.port).toBe('number');
122
+ expect(handle.port).toBeGreaterThan(0);
123
+ expect(typeof handle.close).toBe('function');
124
+ });
125
+ });
126
+
127
+ it('close is idempotent', async () => {
128
+ await withServer(async (handle) => {
129
+ await handle.close();
130
+ await handle.close();
131
+ });
132
+ });
133
+ });
134
+
135
+ describe('JSON-RPC envelope shape', () => {
136
+ it('uses the host import resolver without coupling remote to engine packages', async () => {
137
+ await withServer(
138
+ async (handle) => {
139
+ const ws = await connect(handle.port);
140
+ const resp = await send(ws, {
141
+ jsonrpc: '2.0',
142
+ method: 'eval',
143
+ params: {
144
+ script: "const module = await _import('virtual:host-module'); return module.value",
145
+ },
146
+ id: 11,
147
+ });
148
+ expect(resp.result).toBe('host-resolved');
149
+ expect(resp.error).toBeUndefined();
150
+ ws.close();
151
+ },
152
+ {
153
+ importModule: async (specifier) =>
154
+ specifier === 'virtual:host-module' ? { value: 'host-resolved' } : {},
155
+ },
156
+ );
157
+ });
158
+
159
+ it('response carries jsonrpc + id and either result or error (mutually exclusive)', async () => {
160
+ await withServer(async (handle) => {
161
+ const ws = await connect(handle.port);
162
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 1 });
163
+ expect(resp.jsonrpc).toBe('2.0');
164
+ expect(resp.id).toBe(1);
165
+ expect(resp.result).toBeDefined();
166
+ expect(resp.error).toBeUndefined();
167
+ ws.close();
168
+ });
169
+ });
170
+
171
+ it('unknown method returns -32601 method-not-found on the response error envelope', async () => {
172
+ await withServer(async (handle) => {
173
+ const ws = await connect(handle.port);
174
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'no-such-method', id: 2 });
175
+ expect(resp.error).toBeDefined();
176
+ expect(resp.error?.code).toBe(-32601);
177
+ expect(resp.error?.message.toLowerCase()).toContain('method');
178
+ ws.close();
179
+ });
180
+ });
181
+
182
+ it('malformed JSON returns -32700 parse-error (server stays alive)', async () => {
183
+ await withServer(async (handle) => {
184
+ const ws = await connect(handle.port);
185
+ const responsePromise = new Promise<JsonRpcResponse>((resolve) => {
186
+ ws.once('message', (raw) => {
187
+ resolve(JSON.parse(raw.toString()) as JsonRpcResponse);
188
+ });
189
+ });
190
+ ws.send('this is not json');
191
+ const resp = await responsePromise;
192
+ expect(resp.error?.code).toBe(-32700);
193
+ ws.close();
194
+ });
195
+ });
196
+
197
+ it('missing method field returns -32600 invalid-request', async () => {
198
+ await withServer(async (handle) => {
199
+ const ws = await connect(handle.port);
200
+ const responsePromise = new Promise<JsonRpcResponse>((resolve) => {
201
+ ws.once('message', (raw) => {
202
+ resolve(JSON.parse(raw.toString()) as JsonRpcResponse);
203
+ });
204
+ });
205
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id: 3 }));
206
+ const resp = await responsePromise;
207
+ expect(resp.error?.code).toBe(-32600);
208
+ ws.close();
209
+ });
210
+ });
211
+
212
+ it('null envelope returns -32600 without root execution and preserves the connection', async () => {
213
+ let worldReads = 0;
214
+ const world = new Proxy(
215
+ {},
216
+ {
217
+ get(_target, property) {
218
+ worldReads += 1;
219
+ throw new Error(`unexpected world read: ${String(property)}`);
220
+ },
221
+ },
222
+ );
223
+
224
+ await withServer(
225
+ async (handle) => {
226
+ const ws = await connect(handle.port);
227
+ let closeCount = 0;
228
+ ws.on('close', () => {
229
+ closeCount += 1;
230
+ });
231
+
232
+ const invalid = await sendRaw(ws, 'null');
233
+ expect(invalid).toEqual({
234
+ jsonrpc: '2.0',
235
+ id: null,
236
+ error: { code: -32600, message: 'Invalid Request' },
237
+ });
238
+ expect(worldReads).toBe(0);
239
+
240
+ const introspect = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 1 });
241
+ expect(introspect.jsonrpc).toBe('2.0');
242
+ expect(introspect.id).toBe(1);
243
+ expect(introspect.result).toBeDefined();
244
+
245
+ const evaluated = await send(ws, {
246
+ jsonrpc: '2.0',
247
+ method: 'eval',
248
+ params: { script: 'return 7' },
249
+ id: 2,
250
+ });
251
+ expect(evaluated).toEqual({ jsonrpc: '2.0', id: 2, result: 7 });
252
+
253
+ const repeated = await sendRaw(ws, 'null');
254
+ expect(repeated).toEqual(invalid);
255
+ expect(worldReads).toBe(0);
256
+ expect(ws.readyState).toBe(ws.OPEN);
257
+ expect(closeCount).toBe(0);
258
+ ws.close();
259
+ },
260
+ { world },
261
+ );
262
+ });
263
+ });
264
+
265
+ describe('introspect() OpenRPC L2 subset', () => {
266
+ it('returns the 4 top-level fields + components.{schemas,errors}', async () => {
267
+ await withServer(async (handle) => {
268
+ const ws = await connect(handle.port);
269
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 10 });
270
+ const doc = resp.result as Record<string, unknown>;
271
+ expect(typeof doc.openrpc).toBe('string');
272
+ expect(doc.info).toBeDefined();
273
+ expect(Array.isArray(doc.servers)).toBe(true);
274
+ expect(Array.isArray(doc.methods)).toBe(true);
275
+ const components = doc.components as Record<string, unknown>;
276
+ expect(components).toBeDefined();
277
+ expect(components.schemas).toBeDefined();
278
+ expect(components.errors).toBeDefined();
279
+ ws.close();
280
+ });
281
+ });
282
+
283
+ it('methods[] lists `eval` + `introspect` (route B rename)', async () => {
284
+ await withServer(async (handle) => {
285
+ const ws = await connect(handle.port);
286
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 11 });
287
+ const doc = resp.result as { methods: Array<{ name: string }> };
288
+ const names = new Set(doc.methods.map((m) => m.name));
289
+ expect(names.has('eval')).toBe(true);
290
+ expect(names.has('introspect')).toBe(true);
291
+ ws.close();
292
+ });
293
+ });
294
+
295
+ it('components.errors carries all 5 RemoteErrorCode members', async () => {
296
+ await withServer(async (handle) => {
297
+ const ws = await connect(handle.port);
298
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 12 });
299
+ const doc = resp.result as { components: { errors: Record<string, { code: number }> } };
300
+ const errCodes = new Set<number>();
301
+ for (const key of Object.keys(doc.components.errors)) {
302
+ const entry = doc.components.errors[key];
303
+ if (entry !== undefined) {
304
+ errCodes.add(entry.code);
305
+ }
306
+ }
307
+ expect(errCodes.has(-32001)).toBe(true);
308
+ expect(errCodes.has(-32002)).toBe(true);
309
+ expect(errCodes.has(-32003)).toBe(true);
310
+ expect(errCodes.has(-32004)).toBe(true);
311
+ expect(errCodes.has(-32005)).toBe(true);
312
+ ws.close();
313
+ });
314
+ });
315
+
316
+ it('components.errors preserves exact message projection and key order', async () => {
317
+ await withServer(async (handle) => {
318
+ const ws = await connect(handle.port);
319
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 12 });
320
+ const doc = resp.result as {
321
+ components: { errors: Record<string, { code: number; message: string }> };
322
+ };
323
+ expect(Object.entries(doc.components.errors)).toEqual([
324
+ ['script-syntax-error', { code: -32001, message: 'Script syntax error' }],
325
+ ['script-runtime-error', { code: -32002, message: 'Script runtime error' }],
326
+ ['server-startup-failed', { code: -32003, message: 'Server startup failed' }],
327
+ ['server-not-running', { code: -32004, message: 'Server not reachable' }],
328
+ [
329
+ 'eval-result-not-serializable',
330
+ { code: -32005, message: 'Eval result not serializable' },
331
+ ],
332
+ ]);
333
+ ws.close();
334
+ });
335
+ });
336
+
337
+ it('merges host component descriptors without changing methods or errors', async () => {
338
+ const descriptors = [
339
+ {
340
+ name: 'Visibility',
341
+ schema: { state: 'enum' },
342
+ fields: {
343
+ state: {
344
+ type: 'enum',
345
+ labels: { inherited: 0, hidden: 1, visible: 2 },
346
+ },
347
+ },
348
+ meta: {
349
+ quickStart: 'set Visibility.state through World.set',
350
+ recovery: { code: 'component-field-invalid-value', hint: 'use an allowed label' },
351
+ },
352
+ },
353
+ ] satisfies readonly ComponentIntrospectionDescriptor[];
354
+
355
+ const startResult = await startServer({
356
+ port: 0,
357
+ host: '127.0.0.1',
358
+ world: {},
359
+ introspection: descriptors,
360
+ });
361
+ expect(startResult.ok).toBe(true);
362
+ if (!startResult.ok) throw startResult.error;
363
+
364
+ try {
365
+ const ws = await connect(startResult.value.port);
366
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 13 });
367
+ const doc = resp.result as {
368
+ methods: Array<{ name: string }>;
369
+ components: {
370
+ schemas: Record<string, unknown>;
371
+ errors: Record<string, unknown>;
372
+ };
373
+ };
374
+ expect(doc.methods.map((method) => method.name)).toEqual(['eval', 'introspect']);
375
+ expect(doc.components.schemas.Visibility).toEqual(descriptors[0]);
376
+ expect(Object.keys(doc.components.errors)).toHaveLength(5);
377
+ expect(doc.components.errors).toHaveProperty('script-runtime-error');
378
+ expect(doc.components.errors).toHaveProperty('server-not-running');
379
+ ws.close();
380
+ } finally {
381
+ await startResult.value.close();
382
+ }
383
+ });
384
+
385
+ it('keeps injected schemas passive for the existing eval method', async () => {
386
+ await withServer(async (handle) => {
387
+ const ws = await connect(handle.port);
388
+ const resp = await send(ws, {
389
+ jsonrpc: '2.0',
390
+ method: 'eval',
391
+ params: { script: 'JSON.stringify({ methods: 2, schemas: 1 })' },
392
+ id: 14,
393
+ });
394
+ expect(resp.result).toBe('{"methods":2,"schemas":1}');
395
+ ws.close();
396
+ });
397
+ });
398
+ it('projects the opted-in profiler root and bounded capture capability', async () => {
399
+ const profiler = createProfiler();
400
+ await withServer(
401
+ async (handle) => {
402
+ const ws = await connect(handle.port);
403
+ const introspection = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 13 });
404
+ const doc = introspection.result as {
405
+ roots: Record<string, { available: boolean; capability?: string }>;
406
+ capabilities: { profiler: { enabled: boolean; limits: Record<string, unknown> } };
407
+ };
408
+ expect(doc.roots.profiler).toMatchObject({
409
+ available: true,
410
+ capability: 'cpu-profile-v1',
411
+ });
412
+ expect(doc.capabilities.profiler).toMatchObject({
413
+ enabled: true,
414
+ limits: { frameLimit: 'positive-safe-integer', eventLimit: 'positive-safe-integer' },
415
+ });
416
+
417
+ const started = await send(ws, {
418
+ jsonrpc: '2.0',
419
+ method: 'eval',
420
+ params: { script: 'profiler.startCapture({ frameLimit: 1, eventLimit: 8 })' },
421
+ id: 14,
422
+ });
423
+ expect(started.result).toMatchObject({ ok: true, value: { captureId: 'capture-0001' } });
424
+ ws.close();
425
+ },
426
+ { profiler },
427
+ );
428
+ });
429
+
430
+ it('omits an unconfigured profiler root and publishes an enablement hint', async () => {
431
+ await withServer(async (handle) => {
432
+ const ws = await connect(handle.port);
433
+ const response = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 15 });
434
+ const doc = response.result as {
435
+ roots: Record<string, unknown>;
436
+ capabilities: { profiler: { enabled: boolean; code: string; hint: string } };
437
+ };
438
+ expect(doc.roots.profiler).toBeUndefined();
439
+ expect(doc.capabilities.profiler).toMatchObject({
440
+ enabled: false,
441
+ code: 'profiler-not-enabled',
442
+ });
443
+ expect(doc.capabilities.profiler.hint).toContain('profiler');
444
+ ws.close();
445
+ });
446
+ });
447
+
448
+ it('returns the profiler-owned structured not-enabled result through eval', async () => {
449
+ const profiler = createProfiler({ enabled: false });
450
+ await withServer(
451
+ async (handle) => {
452
+ const ws = await connect(handle.port);
453
+ const response = await send(ws, {
454
+ jsonrpc: '2.0',
455
+ method: 'eval',
456
+ params: { script: 'profiler.startCapture({ frameLimit: 1, eventLimit: 8 })' },
457
+ id: 16,
458
+ });
459
+ expect(response.result).toMatchObject({
460
+ ok: false,
461
+ error: {
462
+ code: 'profiler-not-enabled',
463
+ expected: expect.any(String),
464
+ hint: expect.any(String),
465
+ detail: { enabled: false },
466
+ },
467
+ });
468
+ ws.close();
469
+ },
470
+ { profiler },
471
+ );
472
+ });
473
+ });
474
+
475
+ describe('eval dispatch -- route B (full-access, no sandbox)', () => {
476
+ it('world.spawn() succeeds via eval (no server-startup-failed in route B)', async () => {
477
+ const writableWorld = {
478
+ spawn(): { entity: number } {
479
+ return { entity: 1 };
480
+ },
481
+ };
482
+ await withServer(
483
+ async (handle) => {
484
+ const ws = await connect(handle.port);
485
+ const resp = await send(ws, {
486
+ jsonrpc: '2.0',
487
+ method: 'eval',
488
+ params: { script: 'world.spawn()' },
489
+ id: 20,
490
+ });
491
+ expect(resp.result).toBeDefined();
492
+ expect(resp.error).toBeUndefined();
493
+ ws.close();
494
+ },
495
+ { world: writableWorld },
496
+ );
497
+ });
498
+
499
+ it('renderer read returns real field value', async () => {
500
+ const stubRenderer = { backend: 'webgpu', isReady: true };
501
+ await withServer(
502
+ async (handle) => {
503
+ const ws = await connect(handle.port);
504
+ const resp = await send(ws, {
505
+ jsonrpc: '2.0',
506
+ method: 'eval',
507
+ params: { script: 'renderer.backend' },
508
+ id: 21,
509
+ });
510
+ expect(resp.result).toBe('webgpu');
511
+ ws.close();
512
+ },
513
+ { world: {}, renderer: stubRenderer },
514
+ );
515
+ });
516
+
517
+ it('script-syntax-error maps to JSON-RPC -32001', async () => {
518
+ await withServer(async (handle) => {
519
+ const ws = await connect(handle.port);
520
+ const resp = await send(ws, {
521
+ jsonrpc: '2.0',
522
+ method: 'eval',
523
+ params: { script: 'world.inspect((' },
524
+ id: 22,
525
+ });
526
+ expect(resp.error).toBeDefined();
527
+ expect(resp.error?.code).toBe(-32001);
528
+ expect(resp.error?.data).toBeDefined();
529
+ ws.close();
530
+ });
531
+ });
532
+
533
+ it('script-runtime-error maps to JSON-RPC -32002', async () => {
534
+ await withServer(async (handle) => {
535
+ const ws = await connect(handle.port);
536
+ const resp = await send(ws, {
537
+ jsonrpc: '2.0',
538
+ method: 'eval',
539
+ params: { script: 'throw new Error("boom")' },
540
+ id: 23,
541
+ });
542
+ expect(resp.error).toBeDefined();
543
+ expect(resp.error?.code).toBe(-32002);
544
+ ws.close();
545
+ });
546
+ });
547
+
548
+ it('successful non-JSON results return a structured error and preserve the socket', async () => {
549
+ const world = {
550
+ count: 0,
551
+ bump() {
552
+ this.count += 1;
553
+ },
554
+ inspect() {
555
+ return { count: this.count, stable: true };
556
+ },
557
+ };
558
+ const renderer = {
559
+ inspect() {
560
+ return { renderer: 'stable' };
561
+ },
562
+ };
563
+ await withServer(
564
+ async (handle) => {
565
+ const ws = await connect(handle.port);
566
+ const cyclic = await send(ws, {
567
+ jsonrpc: '2.0',
568
+ method: 'eval',
569
+ params: {
570
+ script:
571
+ "world.bump(); const value = { privateMarker: 'must-not-cross-wire' }; value.self = value; return value;",
572
+ },
573
+ id: 'cyclic-result',
574
+ });
575
+ expect(cyclic).toMatchObject({
576
+ jsonrpc: '2.0',
577
+ id: 'cyclic-result',
578
+ error: {
579
+ code: -32005,
580
+ message: 'Eval result not serializable',
581
+ data: {
582
+ code: 'eval-result-not-serializable',
583
+ expected: 'eval result is JSON-serializable',
584
+ hint: 'return a JSON-safe value; BigInt and cyclic objects are unsupported over JSON-RPC',
585
+ detail: {
586
+ code: 'eval-result-not-serializable',
587
+ shape: 'cyclic-object',
588
+ },
589
+ },
590
+ },
591
+ });
592
+ expect(JSON.stringify(cyclic)).not.toContain('privateMarker');
593
+
594
+ const afterCyclic = await send(ws, {
595
+ jsonrpc: '2.0',
596
+ method: 'eval',
597
+ params: { script: 'world.inspect()' },
598
+ id: 'after-cyclic',
599
+ });
600
+ expect(afterCyclic).toEqual({
601
+ jsonrpc: '2.0',
602
+ id: 'after-cyclic',
603
+ result: { count: 1, stable: true },
604
+ });
605
+
606
+ let notificationReceived = false;
607
+ const notificationHandler = () => {
608
+ notificationReceived = true;
609
+ };
610
+ ws.on('message', notificationHandler);
611
+ ws.send(
612
+ JSON.stringify({
613
+ jsonrpc: '2.0',
614
+ method: 'eval',
615
+ params: { script: 'world.bump(); return 7' },
616
+ }),
617
+ );
618
+ await new Promise((resolve) => setTimeout(resolve, 50));
619
+ ws.off('message', notificationHandler);
620
+ expect(notificationReceived).toBe(false);
621
+
622
+ const afterNotification = await send(ws, {
623
+ jsonrpc: '2.0',
624
+ method: 'eval',
625
+ params: { script: 'renderer.inspect()' },
626
+ id: 'after-notification',
627
+ });
628
+ expect(afterNotification.result).toEqual({ renderer: 'stable' });
629
+
630
+ const bigint = await send(ws, {
631
+ jsonrpc: '2.0',
632
+ method: 'eval',
633
+ params: { script: 'world.bump(); return 1n' },
634
+ id: 'bigint-result',
635
+ });
636
+ expect(bigint).toMatchObject({
637
+ jsonrpc: '2.0',
638
+ id: 'bigint-result',
639
+ error: {
640
+ code: -32005,
641
+ message: 'Eval result not serializable',
642
+ data: {
643
+ code: 'eval-result-not-serializable',
644
+ detail: { code: 'eval-result-not-serializable', shape: 'bigint' },
645
+ },
646
+ },
647
+ });
648
+
649
+ const afterBigInt = await send(ws, {
650
+ jsonrpc: '2.0',
651
+ method: 'eval',
652
+ params: { script: 'world.inspect()' },
653
+ id: 'after-bigint',
654
+ });
655
+ expect(afterBigInt).toEqual({
656
+ jsonrpc: '2.0',
657
+ id: 'after-bigint',
658
+ result: { count: 3, stable: true },
659
+ });
660
+ ws.close();
661
+ },
662
+ { world, renderer },
663
+ );
664
+ });
665
+
666
+ it('shared inspector client preserves structured serialization errors and recovers', async () => {
667
+ const world = {
668
+ count: 0,
669
+ bump() {
670
+ this.count += 1;
671
+ },
672
+ inspect() {
673
+ return { count: this.count, stable: true };
674
+ },
675
+ };
676
+ await withServer(
677
+ async (handle) => {
678
+ const connected = await defaultConnect(`ws://127.0.0.1:${handle.port}/inspector`);
679
+ expect(connected.ok).toBe(true);
680
+ if (!connected.ok) return;
681
+ const client = connected.value;
682
+ await expect(
683
+ client.eval(
684
+ "world.bump(); const value = { secret: 'hidden' }; value.self = value; return value",
685
+ ),
686
+ ).rejects.toMatchObject({
687
+ code: 'eval-result-not-serializable',
688
+ expected: 'eval result is JSON-serializable',
689
+ detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
690
+ });
691
+ await expect(client.eval('world.inspect()')).resolves.toEqual({ count: 1, stable: true });
692
+ await client.dispose();
693
+ await client.dispose();
694
+ },
695
+ { world },
696
+ );
697
+ });
698
+
699
+ it('JSON-RPC error envelopes preserve exact human messages', async () => {
700
+ await withServer(async (handle) => {
701
+ const ws = await connect(handle.port);
702
+ const syntax = await send(ws, {
703
+ jsonrpc: '2.0',
704
+ method: 'eval',
705
+ params: { script: 'world.inspect((' },
706
+ id: 24,
707
+ });
708
+ expect(syntax.error).toMatchObject({
709
+ code: -32001,
710
+ message: 'Script syntax error',
711
+ data: { code: 'script-syntax-error' },
712
+ });
713
+
714
+ const runtime = await send(ws, {
715
+ jsonrpc: '2.0',
716
+ method: 'eval',
717
+ params: { script: 'throw new Error("boom")' },
718
+ id: 25,
719
+ });
720
+ expect(runtime.error).toMatchObject({
721
+ code: -32002,
722
+ message: 'Script runtime error',
723
+ data: { code: 'script-runtime-error' },
724
+ });
725
+ ws.close();
726
+ });
727
+ });
728
+ });
729
+
730
+ describe('EADDRINUSE -> server-startup-failed (no silent fallback)', () => {
731
+ it('second server on same port returns Result.err with code server-startup-failed', async () => {
732
+ await withServer(async (handle) => {
733
+ const portInUse = handle.port;
734
+ const second = await startServer({
735
+ port: portInUse,
736
+ host: '127.0.0.1',
737
+ world: {},
738
+ });
739
+ expect(second.ok).toBe(false);
740
+ if (!second.ok) {
741
+ expect(second.error).toBeInstanceOf(RemoteError);
742
+ expect(second.error.code).toBe('server-startup-failed');
743
+ expect(second.error.hint.length).toBeGreaterThan(0);
744
+ }
745
+ });
746
+ });
747
+ });
748
+
749
+ describe('renderer + assets context surfaces through eval (route B)', () => {
750
+ it('renderer.spawn() succeeds (full-access, no sandbox)', async () => {
751
+ const stubRenderer = {
752
+ spawn(): { ok: boolean } {
753
+ return { ok: true };
754
+ },
755
+ backend: 'webgpu',
756
+ };
757
+ await withServer(
758
+ async (handle) => {
759
+ const ws = await connect(handle.port);
760
+ const resp = await send(ws, {
761
+ jsonrpc: '2.0',
762
+ method: 'eval',
763
+ params: { script: 'renderer.spawn()' },
764
+ id: 50,
765
+ });
766
+ expect(resp.result).toBeDefined();
767
+ expect(resp.result).toEqual({ ok: true });
768
+ ws.close();
769
+ },
770
+ { world: {}, renderer: stubRenderer },
771
+ );
772
+ });
773
+
774
+ it('assets.load() succeeds (full-access, no sandbox)', async () => {
775
+ const stubAssets = {
776
+ load(guid: string, kind: string): Promise<unknown> {
777
+ return Promise.resolve({ ok: true, value: { guid, kind } });
778
+ },
779
+ };
780
+ await withServer(
781
+ async (handle) => {
782
+ const ws = await connect(handle.port);
783
+ const resp = await send(ws, {
784
+ jsonrpc: '2.0',
785
+ method: 'eval',
786
+ params: { script: 'await assets.load("asset-guid", "mesh")' },
787
+ id: 51,
788
+ });
789
+ expect(resp.result).toEqual({ ok: true, value: { guid: 'asset-guid', kind: 'mesh' } });
790
+ ws.close();
791
+ },
792
+ { world: {}, assets: stubAssets },
793
+ );
794
+ });
795
+
796
+ it('renderer read returns real field value', async () => {
797
+ await withServer(
798
+ async (handle) => {
799
+ const ws = await connect(handle.port);
800
+ const resp = await send(ws, {
801
+ jsonrpc: '2.0',
802
+ method: 'eval',
803
+ params: { script: 'renderer.backend' },
804
+ id: 52,
805
+ });
806
+ expect(resp.result).toBe('webgpu');
807
+ ws.close();
808
+ },
809
+ { world: {}, renderer: { backend: 'webgpu' } },
810
+ );
811
+ });
812
+ });
813
+
814
+ describe('introspect() servers[].url reflects opts.port + opts.host (Round 2 F-4)', () => {
815
+ // Round 2 F-4 nit: previously hardcoded ws://127.0.0.1:5732/inspector;
816
+ // the OpenRPC self-describing schema must reflect the live binding so
817
+ // AI users that reach introspect() can connect back to the same URL.
818
+ it('servers[0].url uses the bound port returned in ConsoleHandle', async () => {
819
+ await withServer(async (handle) => {
820
+ const ws = await connect(handle.port);
821
+ const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 60 });
822
+ const doc = resp.result as { servers: ReadonlyArray<{ url: string }> };
823
+ const url = doc.servers[0]?.url ?? '';
824
+ expect(url).toContain(String(handle.port));
825
+ expect(url).toMatch(/^ws:\/\//);
826
+ ws.close();
827
+ });
828
+ });
829
+ });
830
+
831
+ describe('close() releases port + terminates clients', () => {
832
+ it('after close() the same port is rebindable + connected clients are dropped', async () => {
833
+ const start1 = await startServer({ port: 0, host: '127.0.0.1', world: {} });
834
+ if (!start1.ok) {
835
+ throw start1.error;
836
+ }
837
+ const handle1 = start1.value;
838
+ const port = handle1.port;
839
+ const ws = await connect(port);
840
+ const closedPromise = new Promise<void>((resolve) => {
841
+ ws.once('close', () => resolve());
842
+ });
843
+ await handle1.close();
844
+ // Existing client should observe close (force-terminated by the server).
845
+ await closedPromise;
846
+ // Rebind the same port immediately — this proves the server.close
847
+ // completed before the Promise resolved (g7 evidence: server.close +
848
+ // clients.forEach(terminate) is the correct ordering).
849
+ const start2 = await startServer({ port, host: '127.0.0.1', world: {} });
850
+ expect(start2.ok).toBe(true);
851
+ if (start2.ok) {
852
+ await start2.value.close();
853
+ }
854
+ });
855
+ });
856
+ }