@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.
- package/LICENSE +202 -0
- package/README.md +269 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-runtime-inspection.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/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 +31 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.mjs +93 -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 +21 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.mjs +173 -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 +473 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +78 -0
- package/src/__tests__/asset-runtime-inspection.unit.test.ts +16 -0
- package/src/__tests__/console.unit.test.ts +12 -0
- package/src/__tests__/errors.unit.test.ts +102 -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 +369 -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 +856 -0
- package/src/__tests__/vm-async-eval-verify.mjs +149 -0
- package/src/error-messages.ts +9 -0
- package/src/errors.ts +143 -0
- package/src/execute.ts +152 -0
- package/src/index.ts +19 -0
- package/src/introspect.ts +223 -0
- package/src/server.ts +331 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { startServer } from '../server';
|
|
3
|
+
|
|
4
|
+
describe('remote console server', () => {
|
|
5
|
+
it('starts and disposes the eval server without a second command surface', async () => {
|
|
6
|
+
const result = await startServer({ port: 0, host: '127.0.0.1', world: {} });
|
|
7
|
+
expect(result.ok).toBe(true);
|
|
8
|
+
if (!result.ok) return;
|
|
9
|
+
expect(result.value.port).toBeGreaterThan(0);
|
|
10
|
+
await result.value.close();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// w11: RemoteErrorCode completeness unit test (5-member closed union).
|
|
2
|
+
// Verifies that the error-code family has exactly 5 members after
|
|
3
|
+
// deleting inspector-write-denied + script-timeout in w7.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { REMOTE_ERROR_CODE_TO_JSONRPC, RemoteError, type RemoteErrorCode } from '../errors';
|
|
7
|
+
|
|
8
|
+
const EXPECTED_MEMBERS: readonly RemoteErrorCode[] = [
|
|
9
|
+
'script-syntax-error',
|
|
10
|
+
'script-runtime-error',
|
|
11
|
+
'server-startup-failed',
|
|
12
|
+
'server-not-running',
|
|
13
|
+
'eval-result-not-serializable',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe('RemoteErrorCode closed union — 5-member completeness', () => {
|
|
17
|
+
it('REMOTE_ERROR_CODE_TO_JSONRPC has exactly 5 keys matching the expected set', () => {
|
|
18
|
+
const keys = Object.keys(REMOTE_ERROR_CODE_TO_JSONRPC) as RemoteErrorCode[];
|
|
19
|
+
expect(keys).toHaveLength(5);
|
|
20
|
+
// Sort-stable comparison — order is incidental
|
|
21
|
+
expect([...keys].sort()).toStrictEqual([...EXPECTED_MEMBERS].sort());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('no inspector-write-denied residue', () => {
|
|
25
|
+
expect(
|
|
26
|
+
REMOTE_ERROR_CODE_TO_JSONRPC['inspector-write-denied' as RemoteErrorCode],
|
|
27
|
+
).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('no script-timeout residue', () => {
|
|
31
|
+
expect(REMOTE_ERROR_CODE_TO_JSONRPC['script-timeout' as RemoteErrorCode]).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
for (const code of EXPECTED_MEMBERS) {
|
|
35
|
+
it(`REMOTE_ERROR_CODE_TO_JSONRPC[${JSON.stringify(code)}] is a number`, () => {
|
|
36
|
+
expect(typeof REMOTE_ERROR_CODE_TO_JSONRPC[code]).toBe('number');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('RemoteError construction + toJSON', () => {
|
|
42
|
+
it('constructs with the core structured fields', () => {
|
|
43
|
+
const err = new RemoteError({
|
|
44
|
+
code: 'script-syntax-error',
|
|
45
|
+
expected: 'valid JS',
|
|
46
|
+
hint: 'fix it',
|
|
47
|
+
});
|
|
48
|
+
expect(err.code).toBe('script-syntax-error');
|
|
49
|
+
expect(err.expected).toBe('valid JS');
|
|
50
|
+
expect(err.hint).toBe('fix it');
|
|
51
|
+
expect(err.message).toContain('[RemoteError script-syntax-error]');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('toJSON() returns the core plain object when detail is absent', () => {
|
|
55
|
+
const err = new RemoteError({
|
|
56
|
+
code: 'server-not-running',
|
|
57
|
+
expected: 'server reachable',
|
|
58
|
+
hint: 'start demo',
|
|
59
|
+
});
|
|
60
|
+
const json = err.toJSON();
|
|
61
|
+
expect(json).toStrictEqual({
|
|
62
|
+
code: 'server-not-running',
|
|
63
|
+
expected: 'server reachable',
|
|
64
|
+
hint: 'start demo',
|
|
65
|
+
message: err.message,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('server-startup-failed carries descriptive hint', () => {
|
|
70
|
+
const err = new RemoteError({
|
|
71
|
+
code: 'server-startup-failed',
|
|
72
|
+
expected: 'server starts',
|
|
73
|
+
hint: 'port occupied',
|
|
74
|
+
});
|
|
75
|
+
expect(err.code).toBe('server-startup-failed');
|
|
76
|
+
expect(err.hint).toBe('port occupied');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('script-runtime-error carries error context', () => {
|
|
80
|
+
const err = new RemoteError({
|
|
81
|
+
code: 'script-runtime-error',
|
|
82
|
+
expected: 'script executes',
|
|
83
|
+
hint: 'check symbols',
|
|
84
|
+
});
|
|
85
|
+
expect(err.code).toBe('script-runtime-error');
|
|
86
|
+
expect(err.expected).toBe('script executes');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('toJSON retains only the bounded result-shape detail', () => {
|
|
90
|
+
const err = new RemoteError({
|
|
91
|
+
code: 'eval-result-not-serializable',
|
|
92
|
+
expected: 'eval result is JSON-serializable',
|
|
93
|
+
hint: 'return a JSON-safe value',
|
|
94
|
+
detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
|
|
95
|
+
});
|
|
96
|
+
expect(err.toJSON()).toMatchObject({
|
|
97
|
+
code: 'eval-result-not-serializable',
|
|
98
|
+
detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
|
|
99
|
+
});
|
|
100
|
+
expect(JSON.stringify(err)).not.toContain('privateMarker');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @forgeax/engine-remote/src/__tests__/execute-browser-safe — the ./execute
|
|
2
|
+
// subpath is the eval core reused by the browser remote-live bridge (createApp
|
|
3
|
+
// dials a loopback relay; the page realm runs executeScript directly because a
|
|
4
|
+
// browser cannot bind a Node WS server). It MUST stay ws-free / node-built-in-
|
|
5
|
+
// free at the SOURCE level, else importing it in a browser bundle throws on the
|
|
6
|
+
// `ws` browser shim (WebSocketServer unsupported) — the exact failure that
|
|
7
|
+
// makes app.remote silently undefined in a real dev browser today.
|
|
8
|
+
//
|
|
9
|
+
// Guarding the source (not the dist bundle) keeps this test runnable in the
|
|
10
|
+
// unit project without a prior build step, and pins the invariant at the layer
|
|
11
|
+
// a human would break it (adding an import to execute.ts).
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
|
|
17
|
+
const EXECUTE_SRC = fileURLToPath(new URL('../execute.ts', import.meta.url));
|
|
18
|
+
|
|
19
|
+
describe('execute.ts browser safety (remote-live bridge)', () => {
|
|
20
|
+
const source = readFileSync(EXECUTE_SRC, 'utf8');
|
|
21
|
+
// Match only real import statements, not prose in comments.
|
|
22
|
+
const importLines = source
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((line) => /^\s*import\b/.test(line) || /^\s*}\s*from\s*['"]/.test(line));
|
|
25
|
+
|
|
26
|
+
it('imports no ws package', () => {
|
|
27
|
+
expect(importLines.some((l) => /['"]ws['"]/.test(l))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('imports no node: built-in', () => {
|
|
31
|
+
expect(importLines.some((l) => /['"]node:/.test(l))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('imports only its sibling error module', () => {
|
|
35
|
+
// The single permitted runtime import is `./errors` (types-only chain).
|
|
36
|
+
const fromClauses = importLines
|
|
37
|
+
.map((l) => l.match(/from\s*['"]([^'"]+)['"]/)?.[1])
|
|
38
|
+
.filter((s): s is string => typeof s === 'string');
|
|
39
|
+
for (const spec of fromClauses) {
|
|
40
|
+
expect(spec).toBe('./errors');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createProfiler } from '@forgeax/engine-profiler';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { executeScript } from '../execute';
|
|
5
|
+
|
|
6
|
+
describe('browser-safe execute profiler root', () => {
|
|
7
|
+
it('runs a bounded capture start through the same eval core', async () => {
|
|
8
|
+
const profiler = createProfiler();
|
|
9
|
+
const result = await executeScript('profiler.startCapture({ frameLimit: 1, eventLimit: 8 })', {
|
|
10
|
+
world: {},
|
|
11
|
+
renderer: {},
|
|
12
|
+
assets: {},
|
|
13
|
+
profiler,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(result).toMatchObject({
|
|
17
|
+
ok: true,
|
|
18
|
+
value: { ok: true, value: { captureId: 'capture-0001' } },
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('allows the page to return a structured not-enabled result without a transport error', async () => {
|
|
23
|
+
const result = await executeScript(
|
|
24
|
+
"typeof profiler === 'undefined' ? { ok: false, error: { code: 'profiler-not-enabled', expected: 'an opted-in profiler root', hint: 'Pass profiler to createApp.', detail: { enabled: false } } } : null",
|
|
25
|
+
{ world: {}, renderer: {}, assets: {} },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(result).toMatchObject({
|
|
29
|
+
ok: true,
|
|
30
|
+
value: {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: {
|
|
33
|
+
code: 'profiler-not-enabled',
|
|
34
|
+
expected: expect.any(String),
|
|
35
|
+
hint: expect.any(String),
|
|
36
|
+
detail: { enabled: false },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// @forgeax/engine-remote/src/__tests__/execute.async.test.ts
|
|
2
|
+
// TDD green-phase tests for async executeScript (w5):
|
|
3
|
+
// w1 — async read of world/renderer/assets state
|
|
4
|
+
// w2 — async write without sandbox interception (route B, no inspector-write-denied)
|
|
5
|
+
// w3 — _import('@forgeax/engine-ecs') + World.query discovery
|
|
6
|
+
//
|
|
7
|
+
// Route B (D-1): host realm eval via new Function with injected _import.
|
|
8
|
+
// Scripts that need dynamic import use the _import parameter:
|
|
9
|
+
// const ecs = await _import('@forgeax/engine-ecs');
|
|
10
|
+
// Simple expressions return value directly.
|
|
11
|
+
// No wrapReadOnly, no timeout. inspector-write-denied and script-timeout
|
|
12
|
+
// error codes deleted per D-5, not in RemoteErrorCode union.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { executeScript } from '../execute';
|
|
16
|
+
|
|
17
|
+
// ── Mock world ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeMockWorld() {
|
|
20
|
+
const state: unknown[] = [];
|
|
21
|
+
return {
|
|
22
|
+
inspect(): { entityCount: number } {
|
|
23
|
+
return { entityCount: state.length + 5 };
|
|
24
|
+
},
|
|
25
|
+
push(item: unknown): unknown[] {
|
|
26
|
+
state.push(item);
|
|
27
|
+
return state;
|
|
28
|
+
},
|
|
29
|
+
getList(): unknown[] {
|
|
30
|
+
return [...state];
|
|
31
|
+
},
|
|
32
|
+
getState() {
|
|
33
|
+
return { entityCount: state.length + 5, pushedCount: state.length };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mockRenderer = {
|
|
39
|
+
isReady: true,
|
|
40
|
+
dispose() {
|
|
41
|
+
/* no-op */
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const mockAssets = {
|
|
45
|
+
load(guid: string, expectedKind: string) {
|
|
46
|
+
return Promise.resolve({ ok: true, value: { guid, kind: expectedKind } });
|
|
47
|
+
},
|
|
48
|
+
snapshot() {
|
|
49
|
+
return { scopeId: 'test-scope', generation: 1, entries: [] };
|
|
50
|
+
},
|
|
51
|
+
subscribe() {
|
|
52
|
+
return () => {};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function makeCtx() {
|
|
57
|
+
return {
|
|
58
|
+
world: makeMockWorld(),
|
|
59
|
+
renderer: mockRenderer,
|
|
60
|
+
assets: mockAssets,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wrap script in async IIFE with _import injection. */
|
|
65
|
+
function aw(script: string): string {
|
|
66
|
+
return `(async () => {\n${script}\n})()`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// w1: async read
|
|
71
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('executeScript async - read (w1)', () => {
|
|
74
|
+
it('reads world.inspect() entity count', async () => {
|
|
75
|
+
const ctx = makeCtx();
|
|
76
|
+
const result = await executeScript('world.inspect().entityCount', ctx);
|
|
77
|
+
expect(result.ok).toBe(true);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
expect(result.value).toBe(5);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reads renderer.isReady from context', async () => {
|
|
84
|
+
const ctx = makeCtx();
|
|
85
|
+
const result = await executeScript('renderer.isReady', ctx);
|
|
86
|
+
expect(result.ok).toBe(true);
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
expect(result.value).toBe(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reads the AssetRegistry snapshot', async () => {
|
|
93
|
+
const ctx = makeCtx();
|
|
94
|
+
const result = await executeScript('assets.snapshot().generation', ctx);
|
|
95
|
+
expect(result.ok).toBe(true);
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
expect(result.value).toBe(1);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns script-syntax-error on malformed expression', async () => {
|
|
102
|
+
const ctx = makeCtx();
|
|
103
|
+
const result = await executeScript('world.inspect((', ctx);
|
|
104
|
+
expect(result.ok).toBe(false);
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
expect(result.error.code).toBe('script-syntax-error');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns script-runtime-error on throw', async () => {
|
|
111
|
+
const ctx = makeCtx();
|
|
112
|
+
const result = await executeScript('throw new Error("boom")', ctx);
|
|
113
|
+
expect(result.ok).toBe(false);
|
|
114
|
+
if (!result.ok) {
|
|
115
|
+
expect(result.error.code).toBe('script-runtime-error');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('NO script-timeout: error code deleted from RemoteErrorCode (route B, no timeout)', async () => {
|
|
120
|
+
const ctx = makeCtx();
|
|
121
|
+
const result = await executeScript('throw new Error("x")', ctx);
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
expect(result.error.code).not.toBe('script-timeout');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('await + setTimeout works via async IIFE wrapping', async () => {
|
|
128
|
+
const ctx = makeCtx();
|
|
129
|
+
const script = aw('await new Promise(r => setTimeout(r, 20)); return 42;');
|
|
130
|
+
const result = await executeScript(script, ctx);
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
if (result.ok) {
|
|
133
|
+
expect(result.value).toBe(42);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// w2: async write — no inspector-write-denied (sandbox dismantled)
|
|
140
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe('executeScript async - write (w2)', () => {
|
|
143
|
+
it('world.push succeeds without inspector-write-denied', async () => {
|
|
144
|
+
const ctx = makeCtx();
|
|
145
|
+
const result = await executeScript('world.push(10)', ctx);
|
|
146
|
+
expect(result.ok).toBe(true);
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
expect(result.value).toEqual([10]);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('write-then-read: push modifies state, persists across evals', async () => {
|
|
153
|
+
const ctx = makeCtx();
|
|
154
|
+
|
|
155
|
+
// Contract (round 20260713-124956): the script is an async function body,
|
|
156
|
+
// so a MULTI-statement script uses an explicit `return` for its value (a
|
|
157
|
+
// lone trailing expression is auto-returned; a statement list is not). This
|
|
158
|
+
// replaces the prior indirect-eval completion-value behavior — the trade
|
|
159
|
+
// that buys legal top-level `return` + `await`, which the docs already show.
|
|
160
|
+
const r1 = await executeScript('world.push(42); world.push(43); return world.getList()', ctx);
|
|
161
|
+
expect(r1.ok).toBe(true);
|
|
162
|
+
if (r1.ok) {
|
|
163
|
+
expect(r1.value).toEqual([42, 43]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const r2 = await executeScript('world.getList()', ctx);
|
|
167
|
+
expect(r2.ok).toBe(true);
|
|
168
|
+
if (r2.ok) {
|
|
169
|
+
expect(r2.value).toEqual([42, 43]);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('error code is NOT inspector-write-denied on write', async () => {
|
|
174
|
+
const ctx = makeCtx();
|
|
175
|
+
const result = await executeScript('world.push({})', ctx);
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
// w3: _import in eval (route B — host realm, via async IIFE)
|
|
182
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('executeScript async - _import (w3)', () => {
|
|
185
|
+
it('uses the context import resolver when a host supplies one', async () => {
|
|
186
|
+
const imported: string[] = [];
|
|
187
|
+
const result = await executeScript(
|
|
188
|
+
'const ecs = await _import("@forgeax/engine-ecs"); return ecs.name;',
|
|
189
|
+
{
|
|
190
|
+
...makeCtx(),
|
|
191
|
+
importModule: async (specifier: string) => {
|
|
192
|
+
imported.push(specifier);
|
|
193
|
+
return { name: 'browser-resolved-ecs' };
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(result).toEqual({ ok: true, value: 'browser-resolved-ecs' });
|
|
199
|
+
expect(imported).toEqual(['@forgeax/engine-ecs']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('_import(@forgeax/engine-ecs) resolves in eval', async () => {
|
|
203
|
+
const ctx = makeCtx();
|
|
204
|
+
const script = aw(
|
|
205
|
+
[
|
|
206
|
+
'const { World } = await _import("@forgeax/engine-ecs");',
|
|
207
|
+
'return typeof World.prototype.query === "function";',
|
|
208
|
+
].join('\n'),
|
|
209
|
+
);
|
|
210
|
+
const result = await executeScript(script, ctx);
|
|
211
|
+
expect(result.ok).toBe(true);
|
|
212
|
+
if (result.ok) {
|
|
213
|
+
expect(result.value).toBe(true);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('World.query and Entity are discoverable in eval', async () => {
|
|
218
|
+
const ctx = makeCtx();
|
|
219
|
+
// Verify _import works and the final query surface is discoverable.
|
|
220
|
+
const script = aw(
|
|
221
|
+
[
|
|
222
|
+
'const { World, Entity } = await _import("@forgeax/engine-ecs");',
|
|
223
|
+
'return {',
|
|
224
|
+
' hasWorldQuery: typeof World.prototype.query === "function",',
|
|
225
|
+
' hasEntity: typeof Entity === "object",',
|
|
226
|
+
'};',
|
|
227
|
+
].join('\n'),
|
|
228
|
+
);
|
|
229
|
+
const result = await executeScript(script, ctx);
|
|
230
|
+
expect(result.ok).toBe(true);
|
|
231
|
+
if (result.ok) {
|
|
232
|
+
const v = result.value as {
|
|
233
|
+
hasWorldQuery: boolean;
|
|
234
|
+
hasEntity: boolean;
|
|
235
|
+
};
|
|
236
|
+
expect(v.hasWorldQuery).toBe(true);
|
|
237
|
+
expect(v.hasEntity).toBe(true);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('World.query accepts one descriptor', async () => {
|
|
242
|
+
const script = aw(
|
|
243
|
+
[
|
|
244
|
+
'const { World } = await _import("@forgeax/engine-ecs");',
|
|
245
|
+
'return {',
|
|
246
|
+
' isFunction: typeof World.prototype.query === "function",',
|
|
247
|
+
' arity: World.prototype.query.length,',
|
|
248
|
+
'};',
|
|
249
|
+
].join('\n'),
|
|
250
|
+
);
|
|
251
|
+
const result = await executeScript(script, makeCtx());
|
|
252
|
+
expect(result.ok).toBe(true);
|
|
253
|
+
if (result.ok) {
|
|
254
|
+
const v = result.value as { isFunction: boolean; arity: number };
|
|
255
|
+
expect(v.isFunction).toBe(true);
|
|
256
|
+
expect(v.arity).toBe(1);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('Entity component keeps its self field', async () => {
|
|
261
|
+
// Verify Entity component is defined and has a 'self' field.
|
|
262
|
+
// This confirms the component token exists in the imported module,
|
|
263
|
+
// which remains useful to eval scripts for component access.
|
|
264
|
+
const script = aw(
|
|
265
|
+
[
|
|
266
|
+
'const { Entity } = await _import("@forgeax/engine-ecs");',
|
|
267
|
+
'return {',
|
|
268
|
+
' isObject: typeof Entity === "object",',
|
|
269
|
+
' hasDef: typeof Entity.defineComponent === "undefined" || true,',
|
|
270
|
+
' name: Entity.name,',
|
|
271
|
+
'};',
|
|
272
|
+
].join('\n'),
|
|
273
|
+
);
|
|
274
|
+
const result = await executeScript(script, makeCtx());
|
|
275
|
+
expect(result.ok).toBe(true);
|
|
276
|
+
if (result.ok) {
|
|
277
|
+
const v = result.value as { isObject: boolean; name: string };
|
|
278
|
+
expect(v.isObject).toBe(true);
|
|
279
|
+
expect(v.name).toBe('Entity');
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('the final World query entry point is discoverable', async () => {
|
|
284
|
+
const ctx = makeCtx();
|
|
285
|
+
const script = aw(
|
|
286
|
+
[
|
|
287
|
+
'const ecs = await _import("@forgeax/engine-ecs");',
|
|
288
|
+
'return typeof ecs.World.prototype.query === "function";',
|
|
289
|
+
].join('\n'),
|
|
290
|
+
);
|
|
291
|
+
const result = await executeScript(script, ctx);
|
|
292
|
+
expect(result.ok).toBe(true);
|
|
293
|
+
if (result.ok) {
|
|
294
|
+
expect(result.value).toBe(true);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
// Un-wrapped top-level return / await (solo round 20260713-124956):
|
|
301
|
+
// the README + AGENTS.md recipes show top-level `return` and `await` WITHOUT
|
|
302
|
+
// an async IIFE, but the old indirect-eval core (`return eval(script)`) threw
|
|
303
|
+
// script-syntax-error on both. These pin the AsyncFunction-body contract so a
|
|
304
|
+
// docs-following AI user's first, un-wrapped script works.
|
|
305
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('executeScript - un-wrapped top-level return / await', () => {
|
|
308
|
+
it('top-level `return <literal>` returns the value (no async IIFE)', async () => {
|
|
309
|
+
const ctx = makeCtx();
|
|
310
|
+
const result = await executeScript('return 42', ctx);
|
|
311
|
+
expect(result.ok).toBe(true);
|
|
312
|
+
if (result.ok) expect(result.value).toBe(42);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('top-level `await <promise>` resolves (no async IIFE)', async () => {
|
|
316
|
+
const ctx = makeCtx();
|
|
317
|
+
const result = await executeScript('await Promise.resolve(7)', ctx);
|
|
318
|
+
expect(result.ok).toBe(true);
|
|
319
|
+
if (result.ok) expect(result.value).toBe(7);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('bare expression still auto-returns its value', async () => {
|
|
323
|
+
const ctx = makeCtx();
|
|
324
|
+
const result = await executeScript('renderer.isReady', ctx);
|
|
325
|
+
expect(result.ok).toBe(true);
|
|
326
|
+
if (result.ok) expect(result.value).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('bare expression with a trailing semicolon still returns its value', async () => {
|
|
330
|
+
const ctx = makeCtx();
|
|
331
|
+
const result = await executeScript('renderer.isReady;', ctx);
|
|
332
|
+
expect(result.ok).toBe(true);
|
|
333
|
+
if (result.ok) expect(result.value).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('multi-statement: top-level await _import then return (the documented recipe, un-wrapped)', async () => {
|
|
337
|
+
const ctx = makeCtx();
|
|
338
|
+
const script = [
|
|
339
|
+
'const ecs = await _import("@forgeax/engine-ecs");',
|
|
340
|
+
'return typeof ecs.World.prototype.query;',
|
|
341
|
+
].join('\n');
|
|
342
|
+
const result = await executeScript(script, ctx);
|
|
343
|
+
expect(result.ok).toBe(true);
|
|
344
|
+
if (result.ok) expect(result.value).toBe('function');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('the historical async-IIFE form still works (backward compatible)', async () => {
|
|
348
|
+
const ctx = makeCtx();
|
|
349
|
+
const result = await executeScript(aw('return 99;'), ctx);
|
|
350
|
+
expect(result.ok).toBe(true);
|
|
351
|
+
if (result.ok) expect(result.value).toBe(99);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('genuine syntax error still surfaces script-syntax-error (both compile modes fail)', async () => {
|
|
355
|
+
const ctx = makeCtx();
|
|
356
|
+
const result = await executeScript('const = ;', ctx);
|
|
357
|
+
expect(result.ok).toBe(false);
|
|
358
|
+
if (!result.ok) expect(result.error.code).toBe('script-syntax-error');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('multi-statement WITHOUT return yields undefined (async-body contract, not eval completion value)', async () => {
|
|
362
|
+
const ctx = makeCtx();
|
|
363
|
+
// A statement list is not auto-returned; only a lone trailing expression is.
|
|
364
|
+
// This is the intentional trade for legal top-level return/await.
|
|
365
|
+
const result = await executeScript('const a = 1; a + 1', ctx);
|
|
366
|
+
expect(result.ok).toBe(true);
|
|
367
|
+
if (result.ok) expect(result.value).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { executeScript } from '../execute';
|
|
3
|
+
import { buildIntrospectDoc } from '../introspect';
|
|
4
|
+
|
|
5
|
+
describe('execution report structural root', () => {
|
|
6
|
+
const execution = {
|
|
7
|
+
report: () => ({ actualTier: 'shared', world: { health: 'healthy' } }),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
it('is available to eval without importing the App owner', async () => {
|
|
11
|
+
const result = await executeScript('execution.report().actualTier', {
|
|
12
|
+
world: {},
|
|
13
|
+
renderer: {},
|
|
14
|
+
assets: {},
|
|
15
|
+
execution,
|
|
16
|
+
});
|
|
17
|
+
expect(result).toEqual({ ok: true, value: 'shared' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('projects the provider without adding an RPC method', () => {
|
|
21
|
+
const document = buildIntrospectDoc('127.0.0.1', 5732, {
|
|
22
|
+
world: {},
|
|
23
|
+
renderer: {},
|
|
24
|
+
assets: {},
|
|
25
|
+
execution,
|
|
26
|
+
}) as {
|
|
27
|
+
methods: Array<{ name: string }>;
|
|
28
|
+
roots: Record<string, { type: string; capability?: string }>;
|
|
29
|
+
};
|
|
30
|
+
expect(document.methods.map((method) => method.name)).toEqual(['eval', 'introspect']);
|
|
31
|
+
expect(document.roots.execution).toEqual(
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
type: 'ExecutionReportProvider',
|
|
34
|
+
capability: 'execution-report-v1',
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createProfiler } from '@forgeax/engine-profiler';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { buildIntrospectDoc } from '../introspect';
|
|
5
|
+
|
|
6
|
+
describe('remote profiler introspection projection', () => {
|
|
7
|
+
it('derives root names from the supplied live roots', () => {
|
|
8
|
+
const profiler = createProfiler();
|
|
9
|
+
const doc = buildIntrospectDoc('127.0.0.1', 5732, {
|
|
10
|
+
world: {},
|
|
11
|
+
renderer: {},
|
|
12
|
+
assets: {},
|
|
13
|
+
profiler,
|
|
14
|
+
}) as {
|
|
15
|
+
roots: Record<string, { available: boolean }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
expect(Object.keys(doc.roots)).toEqual(['world', 'renderer', 'assets', 'profiler']);
|
|
19
|
+
expect(Object.values(doc.roots).every((root) => root.available)).toBe(true);
|
|
20
|
+
expect(doc.roots.profiler).toMatchObject({
|
|
21
|
+
operations: {
|
|
22
|
+
startCapture: 'profiler.startCapture({ frameLimit, eventLimit })',
|
|
23
|
+
latestCapture: expect.stringContaining('after the host reaches the frame boundary'),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not invent optional roots that are absent from the live context', () => {
|
|
29
|
+
const doc = buildIntrospectDoc('127.0.0.1', 5732, {
|
|
30
|
+
world: {},
|
|
31
|
+
renderer: {},
|
|
32
|
+
assets: {},
|
|
33
|
+
}) as { roots: Record<string, unknown> };
|
|
34
|
+
|
|
35
|
+
expect(doc.roots).toEqual({
|
|
36
|
+
world: expect.any(Object),
|
|
37
|
+
renderer: expect.any(Object),
|
|
38
|
+
assets: expect.any(Object),
|
|
39
|
+
});
|
|
40
|
+
expect('profiler' in doc.roots).toBe(false);
|
|
41
|
+
expect('rhiCapture' in doc.roots).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not project a profiler-shaped root for a recorder stand-in', () => {
|
|
45
|
+
const doc = buildIntrospectDoc('127.0.0.1', 5732, {
|
|
46
|
+
world: {},
|
|
47
|
+
renderer: {},
|
|
48
|
+
assets: {},
|
|
49
|
+
profiler: { startCapture() {}, latestCapture() {}, phaseCatalog: {} },
|
|
50
|
+
}) as { roots: Record<string, unknown>; capabilities: { profiler: { enabled: boolean } } };
|
|
51
|
+
|
|
52
|
+
expect(doc.roots.profiler).toBeUndefined();
|
|
53
|
+
expect(doc.capabilities.profiler.enabled).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|