@forgeax/engine-remote 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +269 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/cli-defaults.unit.test.d.ts +2 -0
- package/dist/__tests__/cli-defaults.unit.test.d.ts.map +1 -0
- package/dist/__tests__/console.unit.test.d.ts +2 -0
- package/dist/__tests__/console.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts +2 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts +2 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts.map +1 -0
- package/dist/__tests__/execute.async.test.d.ts +2 -0
- package/dist/__tests__/execute.async.test.d.ts.map +1 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts.map +1 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts +2 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts.map +1 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts +2 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts +2 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts.map +1 -0
- package/dist/__tests__/server.unit.test.d.ts +2 -0
- package/dist/__tests__/server.unit.test.d.ts.map +1 -0
- package/dist/__tests__/simulation-inspect.integration.test.d.ts +2 -0
- package/dist/__tests__/simulation-inspect.integration.test.d.ts.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.mjs +336 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/defineSubcommand.d.ts +40 -0
- package/dist/defineSubcommand.d.ts.map +1 -0
- package/dist/error-messages.d.ts +3 -0
- package/dist/error-messages.d.ts.map +1 -0
- package/dist/errors.d.ts +82 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +37 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/execute.d.ts +32 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.mjs +95 -0
- package/dist/execute.mjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +30 -0
- package/dist/index.mjs.map +1 -0
- package/dist/introspect.d.ts +23 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.mjs +170 -0
- package/dist/introspect.mjs.map +1 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +435 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +81 -0
- package/src/__tests__/cli-defaults.unit.test.ts +69 -0
- package/src/__tests__/console.unit.test.ts +679 -0
- package/src/__tests__/errors.unit.test.ts +88 -0
- package/src/__tests__/execute-browser-safe.unit.test.ts +43 -0
- package/src/__tests__/execute-profiler-root.browser.test.ts +41 -0
- package/src/__tests__/execute.async.test.ts +373 -0
- package/src/__tests__/execution-report-root.unit.test.ts +38 -0
- package/src/__tests__/introspect-profiler.unit.test.ts +55 -0
- package/src/__tests__/method-roster-profiler.test.ts +80 -0
- package/src/__tests__/rhi-capture-remote.integration.test.ts +58 -0
- package/src/__tests__/server.unit.test.ts +613 -0
- package/src/__tests__/simulation-inspect.integration.test.ts +67 -0
- package/src/__tests__/vm-async-eval-verify.mjs +149 -0
- package/src/cli.ts +333 -0
- package/src/defineSubcommand.ts +169 -0
- package/src/error-messages.ts +9 -0
- package/src/errors.ts +143 -0
- package/src/execute.ts +155 -0
- package/src/index.ts +20 -0
- package/src/introspect.ts +218 -0
- package/src/server.ts +285 -0
|
@@ -0,0 +1,613 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
12
|
+
import { WebSocket } from 'ws';
|
|
13
|
+
import { RemoteError } from '../errors';
|
|
14
|
+
import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer } from '../server';
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
// --- from server.test.ts ---
|
|
18
|
+
// Route B (2026-06-29): eval is full-access, no sandbox, no registry.
|
|
19
|
+
// startServer takes { world, renderer?, assets? } — no engine/registry.
|
|
20
|
+
|
|
21
|
+
type JsonRpcRequest = {
|
|
22
|
+
jsonrpc: '2.0';
|
|
23
|
+
method: string;
|
|
24
|
+
params?: unknown;
|
|
25
|
+
id?: number | string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type JsonRpcResponse = {
|
|
29
|
+
jsonrpc: '2.0';
|
|
30
|
+
result?: unknown;
|
|
31
|
+
error?: {
|
|
32
|
+
code: number;
|
|
33
|
+
message: string;
|
|
34
|
+
data?: {
|
|
35
|
+
code: string;
|
|
36
|
+
expected: string;
|
|
37
|
+
hint: string;
|
|
38
|
+
message?: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
id: number | string | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function connect(port: number): Promise<WebSocket> {
|
|
45
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/inspector`);
|
|
46
|
+
await new Promise<void>((resolve, reject) => {
|
|
47
|
+
ws.once('open', () => resolve());
|
|
48
|
+
ws.once('error', (e) => reject(e));
|
|
49
|
+
});
|
|
50
|
+
return ws;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function send(ws: WebSocket, msg: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
54
|
+
return new Promise<JsonRpcResponse>((resolve, reject) => {
|
|
55
|
+
const handler = (raw: WebSocket.RawData): void => {
|
|
56
|
+
ws.off('message', handler);
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw.toString()) as JsonRpcResponse;
|
|
59
|
+
resolve(parsed);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
reject(e);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
ws.on('message', handler);
|
|
65
|
+
ws.send(JSON.stringify(msg));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function withServer(
|
|
70
|
+
fn: (handle: ConsoleHandle) => Promise<void>,
|
|
71
|
+
opts: {
|
|
72
|
+
port?: number;
|
|
73
|
+
world?: unknown;
|
|
74
|
+
renderer?: unknown;
|
|
75
|
+
assets?: unknown;
|
|
76
|
+
profiler?: unknown;
|
|
77
|
+
} = {},
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const startResult = await startServer({
|
|
80
|
+
port: opts.port ?? 0,
|
|
81
|
+
host: '127.0.0.1',
|
|
82
|
+
world: opts.world ?? {},
|
|
83
|
+
renderer: opts.renderer,
|
|
84
|
+
assets: opts.assets,
|
|
85
|
+
profiler: opts.profiler,
|
|
86
|
+
});
|
|
87
|
+
if (!startResult.ok) {
|
|
88
|
+
throw startResult.error;
|
|
89
|
+
}
|
|
90
|
+
const handle = startResult.value;
|
|
91
|
+
try {
|
|
92
|
+
await fn(handle);
|
|
93
|
+
} finally {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('startServer happy path', () => {
|
|
99
|
+
it('returns Result.ok with a ConsoleHandle exposing .port + .close', async () => {
|
|
100
|
+
await withServer(async (handle) => {
|
|
101
|
+
expect(typeof handle.port).toBe('number');
|
|
102
|
+
expect(handle.port).toBeGreaterThan(0);
|
|
103
|
+
expect(typeof handle.close).toBe('function');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('JSON-RPC envelope shape', () => {
|
|
109
|
+
it('response carries jsonrpc + id and either result or error (mutually exclusive)', async () => {
|
|
110
|
+
await withServer(async (handle) => {
|
|
111
|
+
const ws = await connect(handle.port);
|
|
112
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 1 });
|
|
113
|
+
expect(resp.jsonrpc).toBe('2.0');
|
|
114
|
+
expect(resp.id).toBe(1);
|
|
115
|
+
expect(resp.result).toBeDefined();
|
|
116
|
+
expect(resp.error).toBeUndefined();
|
|
117
|
+
ws.close();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('unknown method returns -32601 method-not-found on the response error envelope', async () => {
|
|
122
|
+
await withServer(async (handle) => {
|
|
123
|
+
const ws = await connect(handle.port);
|
|
124
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'no-such-method', id: 2 });
|
|
125
|
+
expect(resp.error).toBeDefined();
|
|
126
|
+
expect(resp.error?.code).toBe(-32601);
|
|
127
|
+
expect(resp.error?.message.toLowerCase()).toContain('method');
|
|
128
|
+
ws.close();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('malformed JSON returns -32700 parse-error (server stays alive)', async () => {
|
|
133
|
+
await withServer(async (handle) => {
|
|
134
|
+
const ws = await connect(handle.port);
|
|
135
|
+
const responsePromise = new Promise<JsonRpcResponse>((resolve) => {
|
|
136
|
+
ws.once('message', (raw) => {
|
|
137
|
+
resolve(JSON.parse(raw.toString()) as JsonRpcResponse);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
ws.send('this is not json');
|
|
141
|
+
const resp = await responsePromise;
|
|
142
|
+
expect(resp.error?.code).toBe(-32700);
|
|
143
|
+
ws.close();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('missing method field returns -32600 invalid-request', async () => {
|
|
148
|
+
await withServer(async (handle) => {
|
|
149
|
+
const ws = await connect(handle.port);
|
|
150
|
+
const responsePromise = new Promise<JsonRpcResponse>((resolve) => {
|
|
151
|
+
ws.once('message', (raw) => {
|
|
152
|
+
resolve(JSON.parse(raw.toString()) as JsonRpcResponse);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
ws.send(JSON.stringify({ jsonrpc: '2.0', id: 3 }));
|
|
156
|
+
const resp = await responsePromise;
|
|
157
|
+
expect(resp.error?.code).toBe(-32600);
|
|
158
|
+
ws.close();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('introspect() OpenRPC L2 subset', () => {
|
|
164
|
+
it('returns the 4 top-level fields + components.{schemas,errors}', async () => {
|
|
165
|
+
await withServer(async (handle) => {
|
|
166
|
+
const ws = await connect(handle.port);
|
|
167
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 10 });
|
|
168
|
+
const doc = resp.result as Record<string, unknown>;
|
|
169
|
+
expect(typeof doc.openrpc).toBe('string');
|
|
170
|
+
expect(doc.info).toBeDefined();
|
|
171
|
+
expect(Array.isArray(doc.servers)).toBe(true);
|
|
172
|
+
expect(Array.isArray(doc.methods)).toBe(true);
|
|
173
|
+
const components = doc.components as Record<string, unknown>;
|
|
174
|
+
expect(components).toBeDefined();
|
|
175
|
+
expect(components.schemas).toBeDefined();
|
|
176
|
+
expect(components.errors).toBeDefined();
|
|
177
|
+
ws.close();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('methods[] lists `eval` + `introspect` (route B rename)', async () => {
|
|
182
|
+
await withServer(async (handle) => {
|
|
183
|
+
const ws = await connect(handle.port);
|
|
184
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 11 });
|
|
185
|
+
const doc = resp.result as { methods: Array<{ name: string }> };
|
|
186
|
+
const names = new Set(doc.methods.map((m) => m.name));
|
|
187
|
+
expect(names.has('eval')).toBe(true);
|
|
188
|
+
expect(names.has('introspect')).toBe(true);
|
|
189
|
+
ws.close();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('components.errors carries all 5 RemoteErrorCode members', async () => {
|
|
194
|
+
await withServer(async (handle) => {
|
|
195
|
+
const ws = await connect(handle.port);
|
|
196
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 12 });
|
|
197
|
+
const doc = resp.result as { components: { errors: Record<string, { code: number }> } };
|
|
198
|
+
const errCodes = new Set<number>();
|
|
199
|
+
for (const key of Object.keys(doc.components.errors)) {
|
|
200
|
+
const entry = doc.components.errors[key];
|
|
201
|
+
if (entry !== undefined) {
|
|
202
|
+
errCodes.add(entry.code);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
expect(errCodes.has(-32001)).toBe(true);
|
|
206
|
+
expect(errCodes.has(-32002)).toBe(true);
|
|
207
|
+
expect(errCodes.has(-32003)).toBe(true);
|
|
208
|
+
expect(errCodes.has(-32004)).toBe(true);
|
|
209
|
+
expect(errCodes.has(-32005)).toBe(true);
|
|
210
|
+
ws.close();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('components.errors preserves exact message projection and key order', async () => {
|
|
215
|
+
await withServer(async (handle) => {
|
|
216
|
+
const ws = await connect(handle.port);
|
|
217
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 12 });
|
|
218
|
+
const doc = resp.result as {
|
|
219
|
+
components: { errors: Record<string, { code: number; message: string }> };
|
|
220
|
+
};
|
|
221
|
+
expect(Object.entries(doc.components.errors)).toEqual([
|
|
222
|
+
['script-syntax-error', { code: -32001, message: 'Script syntax error' }],
|
|
223
|
+
['script-runtime-error', { code: -32002, message: 'Script runtime error' }],
|
|
224
|
+
['server-startup-failed', { code: -32003, message: 'Server startup failed' }],
|
|
225
|
+
['server-not-running', { code: -32004, message: 'Server not reachable' }],
|
|
226
|
+
[
|
|
227
|
+
'eval-result-not-serializable',
|
|
228
|
+
{ code: -32005, message: 'Eval result not serializable' },
|
|
229
|
+
],
|
|
230
|
+
]);
|
|
231
|
+
ws.close();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('merges host component descriptors without changing methods or errors', async () => {
|
|
236
|
+
const descriptors = [
|
|
237
|
+
{
|
|
238
|
+
name: 'Visibility',
|
|
239
|
+
schema: { state: 'enum' },
|
|
240
|
+
fields: {
|
|
241
|
+
state: {
|
|
242
|
+
type: 'enum',
|
|
243
|
+
labels: { inherited: 0, hidden: 1, visible: 2 },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
meta: {
|
|
247
|
+
quickStart: 'set Visibility.state through World.set',
|
|
248
|
+
recovery: { code: 'component-field-invalid-value', hint: 'use an allowed label' },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
] satisfies readonly ComponentIntrospectionDescriptor[];
|
|
252
|
+
|
|
253
|
+
const startResult = await startServer({
|
|
254
|
+
port: 0,
|
|
255
|
+
host: '127.0.0.1',
|
|
256
|
+
world: {},
|
|
257
|
+
introspection: descriptors,
|
|
258
|
+
});
|
|
259
|
+
expect(startResult.ok).toBe(true);
|
|
260
|
+
if (!startResult.ok) throw startResult.error;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const ws = await connect(startResult.value.port);
|
|
264
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 13 });
|
|
265
|
+
const doc = resp.result as {
|
|
266
|
+
methods: Array<{ name: string }>;
|
|
267
|
+
components: {
|
|
268
|
+
schemas: Record<string, unknown>;
|
|
269
|
+
errors: Record<string, unknown>;
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
expect(doc.methods.map((method) => method.name)).toEqual(['eval', 'introspect']);
|
|
273
|
+
expect(doc.components.schemas.Visibility).toEqual(descriptors[0]);
|
|
274
|
+
expect(Object.keys(doc.components.errors)).toHaveLength(5);
|
|
275
|
+
expect(doc.components.errors).toHaveProperty('script-runtime-error');
|
|
276
|
+
expect(doc.components.errors).toHaveProperty('server-not-running');
|
|
277
|
+
ws.close();
|
|
278
|
+
} finally {
|
|
279
|
+
await startResult.value.close();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('keeps injected schemas passive for the existing eval method', async () => {
|
|
284
|
+
await withServer(async (handle) => {
|
|
285
|
+
const ws = await connect(handle.port);
|
|
286
|
+
const resp = await send(ws, {
|
|
287
|
+
jsonrpc: '2.0',
|
|
288
|
+
method: 'eval',
|
|
289
|
+
params: { script: 'JSON.stringify({ methods: 2, schemas: 1 })' },
|
|
290
|
+
id: 14,
|
|
291
|
+
});
|
|
292
|
+
expect(resp.result).toBe('{"methods":2,"schemas":1}');
|
|
293
|
+
ws.close();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
it('projects the opted-in profiler root and bounded capture capability', async () => {
|
|
297
|
+
const profiler = createProfiler();
|
|
298
|
+
await withServer(
|
|
299
|
+
async (handle) => {
|
|
300
|
+
const ws = await connect(handle.port);
|
|
301
|
+
const introspection = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 13 });
|
|
302
|
+
const doc = introspection.result as {
|
|
303
|
+
roots: Record<string, { available: boolean; capability?: string }>;
|
|
304
|
+
capabilities: { profiler: { enabled: boolean; limits: Record<string, unknown> } };
|
|
305
|
+
};
|
|
306
|
+
expect(doc.roots.profiler).toMatchObject({
|
|
307
|
+
available: true,
|
|
308
|
+
capability: 'cpu-profile-v1',
|
|
309
|
+
});
|
|
310
|
+
expect(doc.capabilities.profiler).toMatchObject({
|
|
311
|
+
enabled: true,
|
|
312
|
+
limits: { frameLimit: 'positive-safe-integer', eventLimit: 'positive-safe-integer' },
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const started = await send(ws, {
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
method: 'eval',
|
|
318
|
+
params: { script: 'profiler.startCapture({ frameLimit: 1, eventLimit: 8 })' },
|
|
319
|
+
id: 14,
|
|
320
|
+
});
|
|
321
|
+
expect(started.result).toMatchObject({ ok: true, value: { captureId: 'capture-0001' } });
|
|
322
|
+
ws.close();
|
|
323
|
+
},
|
|
324
|
+
{ profiler },
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('omits an unconfigured profiler root and publishes an enablement hint', async () => {
|
|
329
|
+
await withServer(async (handle) => {
|
|
330
|
+
const ws = await connect(handle.port);
|
|
331
|
+
const response = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 15 });
|
|
332
|
+
const doc = response.result as {
|
|
333
|
+
roots: Record<string, unknown>;
|
|
334
|
+
capabilities: { profiler: { enabled: boolean; code: string; hint: string } };
|
|
335
|
+
};
|
|
336
|
+
expect(doc.roots.profiler).toBeUndefined();
|
|
337
|
+
expect(doc.capabilities.profiler).toMatchObject({
|
|
338
|
+
enabled: false,
|
|
339
|
+
code: 'profiler-not-enabled',
|
|
340
|
+
});
|
|
341
|
+
expect(doc.capabilities.profiler.hint).toContain('profiler');
|
|
342
|
+
ws.close();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('returns the profiler-owned structured not-enabled result through eval', async () => {
|
|
347
|
+
const profiler = createProfiler({ enabled: false });
|
|
348
|
+
await withServer(
|
|
349
|
+
async (handle) => {
|
|
350
|
+
const ws = await connect(handle.port);
|
|
351
|
+
const response = await send(ws, {
|
|
352
|
+
jsonrpc: '2.0',
|
|
353
|
+
method: 'eval',
|
|
354
|
+
params: { script: 'profiler.startCapture({ frameLimit: 1, eventLimit: 8 })' },
|
|
355
|
+
id: 16,
|
|
356
|
+
});
|
|
357
|
+
expect(response.result).toMatchObject({
|
|
358
|
+
ok: false,
|
|
359
|
+
error: {
|
|
360
|
+
code: 'profiler-not-enabled',
|
|
361
|
+
expected: expect.any(String),
|
|
362
|
+
hint: expect.any(String),
|
|
363
|
+
detail: { enabled: false },
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
ws.close();
|
|
367
|
+
},
|
|
368
|
+
{ profiler },
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('eval dispatch -- route B (full-access, no sandbox)', () => {
|
|
374
|
+
it('world.spawn() succeeds via eval (no server-startup-failed in route B)', async () => {
|
|
375
|
+
const writableWorld = {
|
|
376
|
+
spawn(): { entity: number } {
|
|
377
|
+
return { entity: 1 };
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
await withServer(
|
|
381
|
+
async (handle) => {
|
|
382
|
+
const ws = await connect(handle.port);
|
|
383
|
+
const resp = await send(ws, {
|
|
384
|
+
jsonrpc: '2.0',
|
|
385
|
+
method: 'eval',
|
|
386
|
+
params: { script: 'world.spawn()' },
|
|
387
|
+
id: 20,
|
|
388
|
+
});
|
|
389
|
+
expect(resp.result).toBeDefined();
|
|
390
|
+
expect(resp.error).toBeUndefined();
|
|
391
|
+
ws.close();
|
|
392
|
+
},
|
|
393
|
+
{ world: writableWorld },
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('renderer read returns real field value', async () => {
|
|
398
|
+
const stubRenderer = {
|
|
399
|
+
backend: 'webgpu',
|
|
400
|
+
isReady: true,
|
|
401
|
+
inspect: () => ({ capabilities: { backendKind: 'webgpu' } }),
|
|
402
|
+
};
|
|
403
|
+
await withServer(
|
|
404
|
+
async (handle) => {
|
|
405
|
+
const ws = await connect(handle.port);
|
|
406
|
+
const resp = await send(ws, {
|
|
407
|
+
jsonrpc: '2.0',
|
|
408
|
+
method: 'eval',
|
|
409
|
+
params: { script: 'renderer.inspect().capabilities.backendKind' },
|
|
410
|
+
id: 21,
|
|
411
|
+
});
|
|
412
|
+
expect(resp.result).toBe('webgpu');
|
|
413
|
+
ws.close();
|
|
414
|
+
},
|
|
415
|
+
{ world: {}, renderer: stubRenderer },
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('script-syntax-error maps to JSON-RPC -32001', async () => {
|
|
420
|
+
await withServer(async (handle) => {
|
|
421
|
+
const ws = await connect(handle.port);
|
|
422
|
+
const resp = await send(ws, {
|
|
423
|
+
jsonrpc: '2.0',
|
|
424
|
+
method: 'eval',
|
|
425
|
+
params: { script: 'world.inspect((' },
|
|
426
|
+
id: 22,
|
|
427
|
+
});
|
|
428
|
+
expect(resp.error).toBeDefined();
|
|
429
|
+
expect(resp.error?.code).toBe(-32001);
|
|
430
|
+
expect(resp.error?.data).toBeDefined();
|
|
431
|
+
ws.close();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('script-runtime-error maps to JSON-RPC -32002', async () => {
|
|
436
|
+
await withServer(async (handle) => {
|
|
437
|
+
const ws = await connect(handle.port);
|
|
438
|
+
const resp = await send(ws, {
|
|
439
|
+
jsonrpc: '2.0',
|
|
440
|
+
method: 'eval',
|
|
441
|
+
params: { script: 'throw new Error("boom")' },
|
|
442
|
+
id: 23,
|
|
443
|
+
});
|
|
444
|
+
expect(resp.error).toBeDefined();
|
|
445
|
+
expect(resp.error?.code).toBe(-32002);
|
|
446
|
+
ws.close();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('JSON-RPC error envelopes preserve exact human messages', async () => {
|
|
451
|
+
await withServer(async (handle) => {
|
|
452
|
+
const ws = await connect(handle.port);
|
|
453
|
+
const syntax = await send(ws, {
|
|
454
|
+
jsonrpc: '2.0',
|
|
455
|
+
method: 'eval',
|
|
456
|
+
params: { script: 'world.inspect((' },
|
|
457
|
+
id: 24,
|
|
458
|
+
});
|
|
459
|
+
expect(syntax.error).toMatchObject({
|
|
460
|
+
code: -32001,
|
|
461
|
+
message: 'Script syntax error',
|
|
462
|
+
data: { code: 'script-syntax-error' },
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const runtime = await send(ws, {
|
|
466
|
+
jsonrpc: '2.0',
|
|
467
|
+
method: 'eval',
|
|
468
|
+
params: { script: 'throw new Error("boom")' },
|
|
469
|
+
id: 25,
|
|
470
|
+
});
|
|
471
|
+
expect(runtime.error).toMatchObject({
|
|
472
|
+
code: -32002,
|
|
473
|
+
message: 'Script runtime error',
|
|
474
|
+
data: { code: 'script-runtime-error' },
|
|
475
|
+
});
|
|
476
|
+
ws.close();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('EADDRINUSE -> server-startup-failed (no silent fallback)', () => {
|
|
482
|
+
it('second server on same port returns Result.err with code server-startup-failed', async () => {
|
|
483
|
+
await withServer(async (handle) => {
|
|
484
|
+
const portInUse = handle.port;
|
|
485
|
+
const second = await startServer({
|
|
486
|
+
port: portInUse,
|
|
487
|
+
host: '127.0.0.1',
|
|
488
|
+
world: {},
|
|
489
|
+
});
|
|
490
|
+
expect(second.ok).toBe(false);
|
|
491
|
+
if (!second.ok) {
|
|
492
|
+
expect(second.error).toBeInstanceOf(RemoteError);
|
|
493
|
+
expect(second.error.code).toBe('server-startup-failed');
|
|
494
|
+
expect(second.error.hint.length).toBeGreaterThan(0);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('renderer + assets context surfaces through eval (route B)', () => {
|
|
501
|
+
it('renderer.spawn() succeeds (full-access, no sandbox)', async () => {
|
|
502
|
+
const stubRenderer = {
|
|
503
|
+
spawn(): { ok: boolean } {
|
|
504
|
+
return { ok: true };
|
|
505
|
+
},
|
|
506
|
+
backend: 'webgpu',
|
|
507
|
+
};
|
|
508
|
+
await withServer(
|
|
509
|
+
async (handle) => {
|
|
510
|
+
const ws = await connect(handle.port);
|
|
511
|
+
const resp = await send(ws, {
|
|
512
|
+
jsonrpc: '2.0',
|
|
513
|
+
method: 'eval',
|
|
514
|
+
params: { script: 'renderer.spawn()' },
|
|
515
|
+
id: 50,
|
|
516
|
+
});
|
|
517
|
+
expect(resp.result).toBeDefined();
|
|
518
|
+
expect(resp.result).toEqual({ ok: true });
|
|
519
|
+
ws.close();
|
|
520
|
+
},
|
|
521
|
+
{ world: {}, renderer: stubRenderer },
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('assets.register() succeeds (full-access, no sandbox)', async () => {
|
|
526
|
+
const stubAssets = {
|
|
527
|
+
register(_a: unknown): number {
|
|
528
|
+
return 42;
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
await withServer(
|
|
532
|
+
async (handle) => {
|
|
533
|
+
const ws = await connect(handle.port);
|
|
534
|
+
const resp = await send(ws, {
|
|
535
|
+
jsonrpc: '2.0',
|
|
536
|
+
method: 'eval',
|
|
537
|
+
params: { script: 'assets.register({ kind: "mesh" })' },
|
|
538
|
+
id: 51,
|
|
539
|
+
});
|
|
540
|
+
expect(resp.result).toBe(42);
|
|
541
|
+
ws.close();
|
|
542
|
+
},
|
|
543
|
+
{ world: {}, assets: stubAssets },
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('renderer read returns real field value', async () => {
|
|
548
|
+
await withServer(
|
|
549
|
+
async (handle) => {
|
|
550
|
+
const ws = await connect(handle.port);
|
|
551
|
+
const resp = await send(ws, {
|
|
552
|
+
jsonrpc: '2.0',
|
|
553
|
+
method: 'eval',
|
|
554
|
+
params: { script: 'renderer.inspect().capabilities.backendKind' },
|
|
555
|
+
id: 52,
|
|
556
|
+
});
|
|
557
|
+
expect(resp.result).toBe('webgpu');
|
|
558
|
+
ws.close();
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
world: {},
|
|
562
|
+
renderer: {
|
|
563
|
+
backend: 'webgpu',
|
|
564
|
+
inspect: () => ({ capabilities: { backendKind: 'webgpu' } }),
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe('introspect() servers[].url reflects opts.port + opts.host (Round 2 F-4)', () => {
|
|
572
|
+
// Round 2 F-4 nit: previously hardcoded ws://127.0.0.1:5732/inspector;
|
|
573
|
+
// the OpenRPC self-describing schema must reflect the live binding so
|
|
574
|
+
// AI users that reach introspect() can connect back to the same URL.
|
|
575
|
+
it('servers[0].url uses the bound port returned in ConsoleHandle', async () => {
|
|
576
|
+
await withServer(async (handle) => {
|
|
577
|
+
const ws = await connect(handle.port);
|
|
578
|
+
const resp = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 60 });
|
|
579
|
+
const doc = resp.result as { servers: ReadonlyArray<{ url: string }> };
|
|
580
|
+
const url = doc.servers[0]?.url ?? '';
|
|
581
|
+
expect(url).toContain(String(handle.port));
|
|
582
|
+
expect(url).toMatch(/^ws:\/\//);
|
|
583
|
+
ws.close();
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('close() releases port + terminates clients', () => {
|
|
589
|
+
it('after close() the same port is rebindable + connected clients are dropped', async () => {
|
|
590
|
+
const start1 = await startServer({ port: 0, host: '127.0.0.1', world: {} });
|
|
591
|
+
if (!start1.ok) {
|
|
592
|
+
throw start1.error;
|
|
593
|
+
}
|
|
594
|
+
const handle1 = start1.value;
|
|
595
|
+
const port = handle1.port;
|
|
596
|
+
const ws = await connect(port);
|
|
597
|
+
const closedPromise = new Promise<void>((resolve) => {
|
|
598
|
+
ws.once('close', () => resolve());
|
|
599
|
+
});
|
|
600
|
+
await handle1.close();
|
|
601
|
+
// Existing client should observe close (force-terminated by the server).
|
|
602
|
+
await closedPromise;
|
|
603
|
+
// Rebind the same port immediately — this proves the server.close
|
|
604
|
+
// completed before the Promise resolved (g7 evidence: server.close +
|
|
605
|
+
// clients.forEach(terminate) is the correct ordering).
|
|
606
|
+
const start2 = await startServer({ port, host: '127.0.0.1', world: {} });
|
|
607
|
+
expect(start2.ok).toBe(true);
|
|
608
|
+
if (start2.ok) {
|
|
609
|
+
await start2.value.close();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildIntrospectDoc } from '../introspect';
|
|
4
|
+
import { startServer } from '../server';
|
|
5
|
+
|
|
6
|
+
describe('remote simulation inspection projection', () => {
|
|
7
|
+
it('projects the existing simulation read root without adding remote actions', () => {
|
|
8
|
+
const doc = buildIntrospectDoc('127.0.0.1', 5732, {
|
|
9
|
+
world: {},
|
|
10
|
+
renderer: {},
|
|
11
|
+
assets: {},
|
|
12
|
+
simulation: {
|
|
13
|
+
inspect: () => ({
|
|
14
|
+
formatVersion: 1,
|
|
15
|
+
recordOwner: '@forgeax/engine-ecs',
|
|
16
|
+
schemaOwner: '@forgeax/engine-ecs',
|
|
17
|
+
participants: [],
|
|
18
|
+
trace: { recordTick: 0, sampleCount: 0 },
|
|
19
|
+
report: { verdict: 'match', entries: [] },
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
}) as {
|
|
23
|
+
roots: Record<string, { type: string; description: string }>;
|
|
24
|
+
methods: readonly { name: string }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
expect(doc.roots.simulation).toMatchObject({
|
|
28
|
+
available: true,
|
|
29
|
+
type: 'SimulationInspection',
|
|
30
|
+
});
|
|
31
|
+
expect(doc.methods.map((method) => method.name)).toEqual(['eval', 'introspect']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps simulation inspection on the existing eval transport', async () => {
|
|
35
|
+
const started = await startServer({
|
|
36
|
+
port: 0,
|
|
37
|
+
host: '127.0.0.1',
|
|
38
|
+
world: {},
|
|
39
|
+
simulation: {
|
|
40
|
+
inspect: () => ({
|
|
41
|
+
formatVersion: 1,
|
|
42
|
+
recordOwner: '@forgeax/engine-ecs',
|
|
43
|
+
schemaOwner: '@forgeax/engine-ecs',
|
|
44
|
+
participants: [],
|
|
45
|
+
trace: { recordTick: 0, sampleCount: 0 },
|
|
46
|
+
report: { verdict: 'match', entries: [] },
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(started.ok).toBe(true);
|
|
51
|
+
if (!started.ok) return;
|
|
52
|
+
try {
|
|
53
|
+
const { defaultConnect } = await import('@forgeax/engine-types/inspector-client');
|
|
54
|
+
const connected = await defaultConnect(`ws://127.0.0.1:${started.value.port}/inspector`);
|
|
55
|
+
expect(connected.ok).toBe(true);
|
|
56
|
+
if (!connected.ok) return;
|
|
57
|
+
const value = await connected.value.eval('simulation.inspect()');
|
|
58
|
+
expect(value).toMatchObject({
|
|
59
|
+
formatVersion: 1,
|
|
60
|
+
recordOwner: '@forgeax/engine-ecs',
|
|
61
|
+
});
|
|
62
|
+
await connected.value.dispose();
|
|
63
|
+
} finally {
|
|
64
|
+
await started.value.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|