@quenty/studio-bridge 0.22.1 → 0.24.0
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/CHANGELOG.md +25 -0
- package/README.md +36 -8
- package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
- package/dist/src/bridge/bridge-connection.js +1 -0
- package/dist/src/bridge/bridge-connection.js.map +1 -1
- package/dist/src/bridge/bridge-session.d.ts +11 -16
- package/dist/src/bridge/bridge-session.d.ts.map +1 -1
- package/dist/src/bridge/bridge-session.js +37 -29
- package/dist/src/bridge/bridge-session.js.map +1 -1
- package/dist/src/bridge/bridge-session.test.js +35 -0
- package/dist/src/bridge/bridge-session.test.js.map +1 -1
- package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
- package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
- package/dist/src/bridge/internal/bridge-host.js +1 -0
- package/dist/src/bridge/internal/bridge-host.js.map +1 -1
- package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
- package/dist/src/bridge/internal/host-protocol.js +23 -10
- package/dist/src/bridge/internal/host-protocol.js.map +1 -1
- package/dist/src/bridge/types.d.ts +5 -2
- package/dist/src/bridge/types.d.ts.map +1 -1
- package/dist/src/bridge/types.js.map +1 -1
- package/dist/src/commands/console/exec/exec.d.ts +13 -3
- package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
- package/dist/src/commands/console/exec/exec.js +82 -7
- package/dist/src/commands/console/exec/exec.js.map +1 -1
- package/dist/src/commands/console/exec/exec.test.js +63 -1
- package/dist/src/commands/console/exec/exec.test.js.map +1 -1
- package/dist/src/commands/framework/action-loader.test.js +1 -1
- package/dist/src/commands/framework/action-loader.test.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.js +15 -93
- package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
- package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
- package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.js +54 -74
- package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
- package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
- package/dist/src/commands/playtest/run/run.d.ts +9 -7
- package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
- package/dist/src/commands/playtest/run/run.js +33 -38
- package/dist/src/commands/playtest/run/run.js.map +1 -1
- package/dist/src/commands/process/close/close.d.ts +4 -0
- package/dist/src/commands/process/close/close.d.ts.map +1 -1
- package/dist/src/commands/process/close/close.js +54 -4
- package/dist/src/commands/process/close/close.js.map +1 -1
- package/dist/src/commands/process/close/close.test.js +36 -3
- package/dist/src/commands/process/close/close.test.js.map +1 -1
- package/dist/src/commands/process/info/info.d.ts +15 -2
- package/dist/src/commands/process/info/info.d.ts.map +1 -1
- package/dist/src/commands/process/info/info.js +44 -4
- package/dist/src/commands/process/info/info.js.map +1 -1
- package/dist/src/commands/process/info/info.test.js +48 -0
- package/dist/src/commands/process/info/info.test.js.map +1 -1
- package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
- package/dist/src/commands/process/launch/launch.js +22 -3
- package/dist/src/commands/process/launch/launch.js.map +1 -1
- package/dist/src/commands/process/launch/launch.test.js +27 -5
- package/dist/src/commands/process/launch/launch.test.js.map +1 -1
- package/dist/src/commands/process/list/list.d.ts +49 -6
- package/dist/src/commands/process/list/list.d.ts.map +1 -1
- package/dist/src/commands/process/list/list.js +185 -18
- package/dist/src/commands/process/list/list.js.map +1 -1
- package/dist/src/commands/process/list/list.test.js +92 -1
- package/dist/src/commands/process/list/list.test.js.map +1 -1
- package/dist/src/commands/process/run/run.d.ts +1 -1
- package/dist/src/commands/process/run/run.d.ts.map +1 -1
- package/dist/src/commands/process/run/run.js +3 -3
- package/dist/src/commands/process/run/run.js.map +1 -1
- package/dist/src/docker/docker-delegator.js +1 -1
- package/dist/src/docker/docker-delegator.js.map +1 -1
- package/dist/src/docker/docker-delegator.test.js +1 -1
- package/dist/src/docker/docker-delegator.test.js.map +1 -1
- package/dist/src/plugin/playtest-bridge.d.ts +36 -0
- package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.js +65 -0
- package/dist/src/plugin/playtest-bridge.js.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.js +35 -0
- package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
- package/dist/src/process/studio-launch-registry.d.ts +14 -0
- package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.js +37 -0
- package/dist/src/process/studio-launch-registry.js.map +1 -0
- package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
- package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.test.js +38 -0
- package/dist/src/process/studio-launch-registry.test.js.map +1 -0
- package/dist/src/process/studio-process-closer.d.ts +3 -1
- package/dist/src/process/studio-process-closer.d.ts.map +1 -1
- package/dist/src/process/studio-process-closer.js +29 -11
- package/dist/src/process/studio-process-closer.js.map +1 -1
- package/dist/src/process/studio-process-identity.d.ts +10 -0
- package/dist/src/process/studio-process-identity.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.js +63 -0
- package/dist/src/process/studio-process-identity.js.map +1 -0
- package/dist/src/process/studio-process-identity.test.d.ts +2 -0
- package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.test.js +30 -0
- package/dist/src/process/studio-process-identity.test.js.map +1 -0
- package/dist/src/roblox-mcp/index.d.ts +3 -0
- package/dist/src/roblox-mcp/index.d.ts.map +1 -0
- package/dist/src/roblox-mcp/index.js +3 -0
- package/dist/src/roblox-mcp/index.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
- package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
- package/dist/src/server/web-socket-protocol.d.ts +50 -15
- package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
- package/dist/src/server/web-socket-protocol.js +63 -13
- package/dist/src/server/web-socket-protocol.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/playtest-bridge.md +21 -0
- package/package.json +5 -5
- package/src/bridge/bridge-connection.ts +1 -0
- package/src/bridge/bridge-session.test.ts +51 -0
- package/src/bridge/bridge-session.ts +66 -47
- package/src/bridge/internal/bridge-host.ts +3 -0
- package/src/bridge/internal/host-protocol.ts +22 -9
- package/src/bridge/types.ts +5 -0
- package/src/commands/console/exec/exec.test.ts +94 -0
- package/src/commands/console/exec/exec.ts +130 -16
- package/src/commands/console/exec/execute.lua +147 -5
- package/src/commands/console/exec/execute.spec.lua +22 -2
- package/src/commands/framework/action-loader.test.ts +1 -1
- package/src/commands/playtest/player/playtest-players.lua +127 -0
- package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
- package/src/commands/playtest/player/playtest-players.test.ts +40 -21
- package/src/commands/playtest/player/playtest-players.ts +30 -110
- package/src/commands/playtest/playtest-sessions.test.ts +116 -14
- package/src/commands/playtest/playtest-sessions.ts +77 -88
- package/src/commands/playtest/run/run.ts +44 -58
- package/src/commands/playtest/start/start-playtest.lua +105 -6
- package/src/commands/process/close/close.test.ts +52 -3
- package/src/commands/process/close/close.ts +78 -4
- package/src/commands/process/info/info.test.ts +76 -0
- package/src/commands/process/info/info.ts +74 -6
- package/src/commands/process/launch/launch.test.ts +33 -5
- package/src/commands/process/launch/launch.ts +41 -3
- package/src/commands/process/list/list.test.ts +130 -1
- package/src/commands/process/list/list.ts +290 -22
- package/src/commands/process/run/run.ts +4 -4
- package/src/docker/docker-delegator.test.ts +1 -1
- package/src/docker/docker-delegator.ts +1 -1
- package/src/plugin/playtest-bridge.test.ts +53 -0
- package/src/plugin/playtest-bridge.ts +98 -0
- package/src/process/studio-launch-registry.test.ts +56 -0
- package/src/process/studio-launch-registry.ts +63 -0
- package/src/process/studio-process-closer.ts +36 -11
- package/src/process/studio-process-identity.test.ts +45 -0
- package/src/process/studio-process-identity.ts +90 -0
- package/src/roblox-mcp/index.ts +17 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
- package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
- package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
- package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
- package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
- package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
- package/src/roblox-mcp/studio/studio-session.ts +279 -0
- package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
- package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
- package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
- package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
- package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
- package/src/server/web-socket-protocol.ts +119 -29
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
- package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
- package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
- package/src/commands/playtest/run/playtest-runner.luau +0 -264
- package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
- package/src/commands/playtest/run/run-playtest-script.lua +0 -139
- package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the studio-bridge client the edit session plants into a playtest's
|
|
3
|
+
* server, for when Studio does not load user plugins into playtests.
|
|
4
|
+
*
|
|
5
|
+
* The planted client is an ordinary Script, so it can neither `loadstring`
|
|
6
|
+
* nor set a ModuleScript's source. Everything it may run is compiled ahead of
|
|
7
|
+
* time here: each source becomes a ModuleScript returning its function and
|
|
8
|
+
* the source itself, which the client's router looks up by exact text when
|
|
9
|
+
* the bridge pushes an action or sends a script (see PrecompiledSources.lua).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs/promises';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { resolveTemplatePath } from '@quenty/nevermore-template-helpers';
|
|
15
|
+
import { loadActionSourcesAsync } from '../commands/framework/action-loader.js';
|
|
16
|
+
import { getPluginBuildVersionAsync } from './plugin-build-version.js';
|
|
17
|
+
|
|
18
|
+
/** The `bridge` of a `startPlaytest` request. */
|
|
19
|
+
export interface PlaytestBridge {
|
|
20
|
+
/** Source of the planted entry Script. */
|
|
21
|
+
entrySource: string;
|
|
22
|
+
/** Source of the entry LocalScript planted for the playtest's clients, if any. */
|
|
23
|
+
clientEntrySource?: string;
|
|
24
|
+
/** The plugin's Shared modules, by name. */
|
|
25
|
+
modules: Record<string, string>;
|
|
26
|
+
/** ModuleScript sources compiled ahead of time, see `wrapPrecompiledSource`. */
|
|
27
|
+
precompiled: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const templateDir = resolveTemplatePath(
|
|
31
|
+
import.meta.url,
|
|
32
|
+
'studio-bridge-plugin'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the bridge to plant, able to run every action plus `scripts`, the
|
|
37
|
+
* code a caller will send the playtest's server session.
|
|
38
|
+
*/
|
|
39
|
+
export async function buildPlaytestBridgeAsync(
|
|
40
|
+
scripts: string[] = [],
|
|
41
|
+
options: { withClient?: boolean } = {}
|
|
42
|
+
): Promise<PlaytestBridge> {
|
|
43
|
+
const [entrySource, clientEntrySource, modules, actions] = await Promise.all([
|
|
44
|
+
fs.readFile(
|
|
45
|
+
path.join(templateDir, 'playtest', 'StudioBridgePlaytest.server.lua'),
|
|
46
|
+
'utf-8'
|
|
47
|
+
),
|
|
48
|
+
options.withClient
|
|
49
|
+
? fs.readFile(
|
|
50
|
+
path.join(templateDir, 'playtest', 'StudioBridgePlaytest.client.lua'),
|
|
51
|
+
'utf-8'
|
|
52
|
+
)
|
|
53
|
+
: Promise.resolve(undefined),
|
|
54
|
+
readSharedModulesAsync(),
|
|
55
|
+
loadActionSourcesAsync(),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const sources = [...new Set([...actions.map((a) => a.source), ...scripts])];
|
|
59
|
+
return {
|
|
60
|
+
entrySource,
|
|
61
|
+
...(clientEntrySource ? { clientEntrySource } : {}),
|
|
62
|
+
modules,
|
|
63
|
+
precompiled: sources.map(wrapPrecompiledSource),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readSharedModulesAsync(): Promise<Record<string, string>> {
|
|
68
|
+
const sharedDir = path.join(templateDir, 'src', 'Shared');
|
|
69
|
+
const version = await getPluginBuildVersionAsync();
|
|
70
|
+
|
|
71
|
+
const modules: Record<string, string> = {};
|
|
72
|
+
for (const fileName of await fs.readdir(sharedDir)) {
|
|
73
|
+
if (!fileName.endsWith('.lua') || fileName.endsWith('.spec.lua')) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const source = await fs.readFile(path.join(sharedDir, fileName), 'utf-8');
|
|
77
|
+
modules[path.basename(fileName, '.lua')] = source.replaceAll(
|
|
78
|
+
'{{PLUGIN_VERSION}}',
|
|
79
|
+
version
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return modules;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A ModuleScript source returning `{ fn, source }` for `source`. The function
|
|
87
|
+
* opens on the first line, so line numbers in errors match `source`. The text
|
|
88
|
+
* is a long string led by a character that is cut off again, since a long
|
|
89
|
+
* string drops a leading newline.
|
|
90
|
+
*/
|
|
91
|
+
export function wrapPrecompiledSource(source: string): string {
|
|
92
|
+
// The string must close at its closer, not inside or straddling `source`
|
|
93
|
+
let level = '';
|
|
94
|
+
while (`${source}]${level}]`.indexOf(`]${level}]`) !== source.length) {
|
|
95
|
+
level += '=';
|
|
96
|
+
}
|
|
97
|
+
return `return { fn = function(...) ${source}\nend, source = string.sub([${level}[x${source}]${level}], 2) }`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
findStudioLaunch,
|
|
7
|
+
readStudioLaunchesAsync,
|
|
8
|
+
recordStudioLaunchAsync,
|
|
9
|
+
} from './studio-launch-registry.js';
|
|
10
|
+
|
|
11
|
+
describe('studio launch registry', () => {
|
|
12
|
+
let dir: string;
|
|
13
|
+
let file: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'launch-registry-'));
|
|
17
|
+
file = path.join(dir, 'nested', 'launches.json');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('records launches and reads them back', async () => {
|
|
25
|
+
await recordStudioLaunchAsync(
|
|
26
|
+
{ pid: 10, cwd: '/game', opened: 'a new Baseplate', launchedAt: 1 },
|
|
27
|
+
file
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(await readStudioLaunchesAsync(file)).toEqual([
|
|
31
|
+
{ pid: 10, cwd: '/game', opened: 'a new Baseplate', launchedAt: 1 },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads a missing or broken registry as empty', async () => {
|
|
36
|
+
expect(await readStudioLaunchesAsync(file)).toEqual([]);
|
|
37
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
38
|
+
await fs.writeFile(file, 'not json');
|
|
39
|
+
expect(await readStudioLaunchesAsync(file)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('finds a launch by pid, else by when the process started', () => {
|
|
43
|
+
const records = [
|
|
44
|
+
{ pid: 10, cwd: '/a', opened: 'x', launchedAt: 1_000 },
|
|
45
|
+
{ cwd: '/b', opened: 'y', launchedAt: 60_000 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
expect(findStudioLaunch(records, { pid: 10 })?.cwd).toBe('/a');
|
|
49
|
+
expect(findStudioLaunch(records, { pid: 99, startedAt: 62_000 })?.cwd).toBe(
|
|
50
|
+
'/b'
|
|
51
|
+
);
|
|
52
|
+
expect(
|
|
53
|
+
findStudioLaunch(records, { pid: 99, startedAt: 500_000 })
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
const LAUNCH_MATCH_WINDOW_MS = 10_000;
|
|
6
|
+
const MAX_RECORDS = 50;
|
|
7
|
+
|
|
8
|
+
export interface StudioLaunchRecord {
|
|
9
|
+
pid?: number;
|
|
10
|
+
cwd: string;
|
|
11
|
+
opened: string;
|
|
12
|
+
launchedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defaultLaunchRegistryPath(): string {
|
|
16
|
+
return path.join(os.tmpdir(), 'studio-bridge', 'launches.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function recordStudioLaunchAsync(
|
|
20
|
+
record: StudioLaunchRecord,
|
|
21
|
+
registryPath = defaultLaunchRegistryPath()
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const records = await readStudioLaunchesAsync(registryPath);
|
|
24
|
+
records.push(record);
|
|
25
|
+
await fs.mkdir(path.dirname(registryPath), { recursive: true });
|
|
26
|
+
await fs.writeFile(
|
|
27
|
+
registryPath,
|
|
28
|
+
JSON.stringify(records.slice(-MAX_RECORDS), null, 2)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readStudioLaunchesAsync(
|
|
33
|
+
registryPath = defaultLaunchRegistryPath()
|
|
34
|
+
): Promise<StudioLaunchRecord[]> {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await fs.readFile(registryPath, 'utf-8'));
|
|
37
|
+
return Array.isArray(parsed)
|
|
38
|
+
? parsed.filter(
|
|
39
|
+
(r): r is StudioLaunchRecord =>
|
|
40
|
+
r && typeof r.cwd === 'string' && typeof r.launchedAt === 'number'
|
|
41
|
+
)
|
|
42
|
+
: [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findStudioLaunch(
|
|
49
|
+
records: StudioLaunchRecord[],
|
|
50
|
+
process: { pid: number; startedAt?: number }
|
|
51
|
+
): StudioLaunchRecord | undefined {
|
|
52
|
+
const byPid = records.filter((r) => r.pid === process.pid);
|
|
53
|
+
if (byPid.length > 0) {
|
|
54
|
+
return byPid[byPid.length - 1];
|
|
55
|
+
}
|
|
56
|
+
if (process.startedAt === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const near = records.filter(
|
|
60
|
+
(r) => Math.abs(r.launchedAt - process.startedAt!) <= LAUNCH_MATCH_WINDOW_MS
|
|
61
|
+
);
|
|
62
|
+
return near.length === 1 ? near[0] : undefined;
|
|
63
|
+
}
|
|
@@ -14,6 +14,7 @@ export interface RunningStudioProcess {
|
|
|
14
14
|
pid: number;
|
|
15
15
|
windowTitle: string;
|
|
16
16
|
commandLine?: string;
|
|
17
|
+
startedAt?: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface CloseStudioOptions {
|
|
@@ -31,7 +32,7 @@ export async function listStudioProcessesAsync(): Promise<
|
|
|
31
32
|
'-NoProfile',
|
|
32
33
|
'-NonInteractive',
|
|
33
34
|
'-Command',
|
|
34
|
-
|
|
35
|
+
"@(Get-CimInstance Win32_Process -Filter \"Name='RobloxStudioBeta.exe'\" | ForEach-Object { [pscustomobject]@{ Id = $_.ProcessId; MainWindowTitle = (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue).MainWindowTitle; CommandLine = $_.CommandLine; StartTime = $_.CreationDate.ToUniversalTime().ToString('o') } }) | ConvertTo-Json -Compress",
|
|
35
36
|
],
|
|
36
37
|
{ reject: false }
|
|
37
38
|
);
|
|
@@ -39,7 +40,7 @@ export async function listStudioProcessesAsync(): Promise<
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
if (process.platform === 'darwin') {
|
|
42
|
-
const { stdout } = await execa('ps', ['-axo', 'pid=,command='], {
|
|
43
|
+
const { stdout } = await execa('ps', ['-axo', 'pid=,etime=,command='], {
|
|
43
44
|
reject: false,
|
|
44
45
|
});
|
|
45
46
|
return parseMacProcessList(stdout);
|
|
@@ -58,35 +59,59 @@ export function parseWindowsProcessList(
|
|
|
58
59
|
Id: number;
|
|
59
60
|
MainWindowTitle: string | null;
|
|
60
61
|
CommandLine?: string | null;
|
|
62
|
+
StartTime?: string | null;
|
|
61
63
|
};
|
|
62
64
|
const parsed = JSON.parse(trimmed) as Entry | Entry[];
|
|
63
65
|
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
64
66
|
|
|
65
|
-
return entries.map((entry) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
return entries.map((entry) => {
|
|
68
|
+
const startedAt = entry.StartTime ? Date.parse(entry.StartTime) : NaN;
|
|
69
|
+
return {
|
|
70
|
+
pid: entry.Id,
|
|
71
|
+
windowTitle: entry.MainWindowTitle ?? '',
|
|
72
|
+
commandLine: entry.CommandLine ?? undefined,
|
|
73
|
+
...(Number.isFinite(startedAt) ? { startedAt } : {}),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
export function parseMacProcessList(
|
|
78
|
+
export function parseMacProcessList(
|
|
79
|
+
stdout: string,
|
|
80
|
+
now = Date.now()
|
|
81
|
+
): RunningStudioProcess[] {
|
|
73
82
|
const processes: RunningStudioProcess[] = [];
|
|
74
83
|
for (const line of stdout.split('\n')) {
|
|
75
84
|
const match =
|
|
76
|
-
/^\s*(\d+)\s+(.*RobloxStudio\.app\/Contents\/MacOS\/RobloxStudio.*)$/.exec(
|
|
85
|
+
/^\s*(\d+)\s+(?:([\d:-]+)\s+)?(.*RobloxStudio\.app\/Contents\/MacOS\/RobloxStudio.*)$/.exec(
|
|
77
86
|
line
|
|
78
87
|
);
|
|
79
88
|
if (match) {
|
|
89
|
+
const elapsedSec = match[2] ? parseElapsedSeconds(match[2]) : undefined;
|
|
80
90
|
processes.push({
|
|
81
91
|
pid: Number(match[1]),
|
|
82
|
-
windowTitle: match[
|
|
83
|
-
commandLine: match[
|
|
92
|
+
windowTitle: match[3],
|
|
93
|
+
commandLine: match[3],
|
|
94
|
+
...(elapsedSec !== undefined
|
|
95
|
+
? { startedAt: now - elapsedSec * 1000 }
|
|
96
|
+
: {}),
|
|
84
97
|
});
|
|
85
98
|
}
|
|
86
99
|
}
|
|
87
100
|
return processes;
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
export function parseElapsedSeconds(etime: string): number | undefined {
|
|
104
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
|
|
105
|
+
if (!match) return undefined;
|
|
106
|
+
const [, days, hours, minutes, seconds] = match;
|
|
107
|
+
return (
|
|
108
|
+
Number(days ?? 0) * 86400 +
|
|
109
|
+
Number(hours ?? 0) * 3600 +
|
|
110
|
+
Number(minutes) * 60 +
|
|
111
|
+
Number(seconds)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* Processes whose window shows the given place. Studio titles a local file
|
|
92
117
|
* by its full path and a cloud place by its name on the website, which the
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { matchProcessByUptime } from './studio-process-identity.js';
|
|
3
|
+
|
|
4
|
+
describe('matchProcessByUptime', () => {
|
|
5
|
+
const now = 1_000_000_000;
|
|
6
|
+
const processes = [
|
|
7
|
+
{ pid: 1, startedAt: now - 300_000 },
|
|
8
|
+
{ pid: 2, startedAt: now - 290_000 },
|
|
9
|
+
{ pid: 3 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it('picks the process started when the uptime says', () => {
|
|
13
|
+
expect(
|
|
14
|
+
matchProcessByUptime(processes, { uptimeSec: 290.4, observedAt: now })
|
|
15
|
+
?.pid
|
|
16
|
+
).toBe(2);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('allows for the clock starting after the process and drifting with age', () => {
|
|
20
|
+
expect(
|
|
21
|
+
matchProcessByUptime([{ pid: 9, startedAt: now - 3_600_000 - 10_000 }], {
|
|
22
|
+
uptimeSec: 3600,
|
|
23
|
+
observedAt: now,
|
|
24
|
+
})?.pid
|
|
25
|
+
).toBe(9);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('finds nothing outside the tolerance', () => {
|
|
29
|
+
expect(
|
|
30
|
+
matchProcessByUptime(processes, { uptimeSec: 250, observedAt: now })
|
|
31
|
+
).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('refuses to choose between two processes started together', () => {
|
|
35
|
+
expect(
|
|
36
|
+
matchProcessByUptime(
|
|
37
|
+
[
|
|
38
|
+
{ pid: 1, startedAt: now - 100_000 },
|
|
39
|
+
{ pid: 2, startedAt: now - 100_500 },
|
|
40
|
+
],
|
|
41
|
+
{ uptimeSec: 100.2, observedAt: now }
|
|
42
|
+
)
|
|
43
|
+
).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { BridgeConnection, SessionInfo } from '../bridge/index.js';
|
|
2
|
+
import type { RunningStudioProcess } from './studio-process-closer.js';
|
|
3
|
+
|
|
4
|
+
export const UPTIME_TOLERANCE_MS = 3000;
|
|
5
|
+
const UPTIME_DRIFT_RATIO = 0.0025;
|
|
6
|
+
const UPTIME_READ_TIMEOUT_MS = 3000;
|
|
7
|
+
|
|
8
|
+
export interface UptimeReading {
|
|
9
|
+
uptimeSec: number;
|
|
10
|
+
observedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function matchProcessByUptime<
|
|
14
|
+
T extends Pick<RunningStudioProcess, 'pid' | 'startedAt'>
|
|
15
|
+
>(processes: T[], reading: UptimeReading): T | undefined {
|
|
16
|
+
const startedAt = reading.observedAt - reading.uptimeSec * 1000;
|
|
17
|
+
const tolerance =
|
|
18
|
+
UPTIME_TOLERANCE_MS + reading.uptimeSec * 1000 * UPTIME_DRIFT_RATIO;
|
|
19
|
+
const candidates = processes
|
|
20
|
+
.filter((p) => p.startedAt !== undefined)
|
|
21
|
+
.map((p) => ({ process: p, delta: Math.abs(p.startedAt! - startedAt) }))
|
|
22
|
+
.sort((a, b) => a.delta - b.delta);
|
|
23
|
+
const [best, next] = candidates;
|
|
24
|
+
if (!best || best.delta > tolerance) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (next && next.delta <= tolerance) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return best.process;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveSessionPidsAsync(
|
|
34
|
+
connection: BridgeConnection,
|
|
35
|
+
processes: RunningStudioProcess[],
|
|
36
|
+
instanceIds?: string[]
|
|
37
|
+
): Promise<Map<string, number>> {
|
|
38
|
+
const pids = new Map<string, number>();
|
|
39
|
+
if (!processes.some((p) => p.startedAt !== undefined)) {
|
|
40
|
+
return pids;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const byInstance = new Map<string, SessionInfo[]>();
|
|
44
|
+
for (const session of connection.listSessions()) {
|
|
45
|
+
if (instanceIds && !instanceIds.includes(session.instanceId)) continue;
|
|
46
|
+
const list = byInstance.get(session.instanceId) ?? [];
|
|
47
|
+
list.push(session);
|
|
48
|
+
byInstance.set(session.instanceId, list);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await Promise.all(
|
|
52
|
+
[...byInstance].map(async ([instanceId, sessions]) => {
|
|
53
|
+
const ordered = [...sessions].sort(
|
|
54
|
+
(a, b) => Number(b.context === 'edit') - Number(a.context === 'edit')
|
|
55
|
+
);
|
|
56
|
+
for (const info of ordered) {
|
|
57
|
+
const reading = await _readUptimeAsync(connection, info.sessionId);
|
|
58
|
+
if (reading) {
|
|
59
|
+
const match = matchProcessByUptime(processes, reading);
|
|
60
|
+
if (match) {
|
|
61
|
+
pids.set(instanceId, match.pid);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
return pids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function _readUptimeAsync(
|
|
72
|
+
connection: BridgeConnection,
|
|
73
|
+
sessionId: string
|
|
74
|
+
): Promise<UptimeReading | undefined> {
|
|
75
|
+
try {
|
|
76
|
+
const session = await connection.resolveSessionAsync(sessionId);
|
|
77
|
+
const sentAt = Date.now();
|
|
78
|
+
const result = await session.execAsync(
|
|
79
|
+
'return os.clock()',
|
|
80
|
+
UPTIME_READ_TIMEOUT_MS
|
|
81
|
+
);
|
|
82
|
+
const uptimeSec = Number(result.returnValues?.[0]);
|
|
83
|
+
if (!result.success || !Number.isFinite(uptimeSec)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return { uptimeSec, observedAt: (sentAt + Date.now()) / 2 };
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
tryListStudiosAsync,
|
|
3
|
+
tryExecuteLuauAsync,
|
|
4
|
+
tryCallStudioToolAsync,
|
|
5
|
+
closeStudioMcpAsync,
|
|
6
|
+
type StudioMcpTarget,
|
|
7
|
+
type StudioMcpFailure,
|
|
8
|
+
type StudioMcpResult,
|
|
9
|
+
type StudioMcpOptions,
|
|
10
|
+
type StudioMcpLuauOptions,
|
|
11
|
+
} from './roblox-studio-mcp.js';
|
|
12
|
+
export {
|
|
13
|
+
getStudioMcpSnapshotAsync,
|
|
14
|
+
resetStudioMcpSnapshot,
|
|
15
|
+
type StudioMcpSnapshot,
|
|
16
|
+
type StudioMcpSnapshotOptions,
|
|
17
|
+
} from './studio-mcp-snapshot.js';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { McpStdioClient } from './mcp-stdio-client.js';
|
|
3
|
+
import {
|
|
4
|
+
createFakeMcpServer,
|
|
5
|
+
toolText,
|
|
6
|
+
type FakeHandler,
|
|
7
|
+
} from '../test/fake-mcp-server.js';
|
|
8
|
+
|
|
9
|
+
const initialize: FakeHandler = (method) =>
|
|
10
|
+
method === 'initialize'
|
|
11
|
+
? {
|
|
12
|
+
result: {
|
|
13
|
+
protocolVersion: '2025-06-18',
|
|
14
|
+
capabilities: { tools: {} },
|
|
15
|
+
serverInfo: { name: 'RobloxStudio', version: '1.0.0' },
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
: undefined;
|
|
19
|
+
|
|
20
|
+
async function startAsync(handler: FakeHandler, requestTimeoutMs?: number) {
|
|
21
|
+
const server = createFakeMcpServer(
|
|
22
|
+
(method, params) => initialize(method, params) ?? handler(method, params)
|
|
23
|
+
);
|
|
24
|
+
const client = await McpStdioClient.startAsync('StudioMCP.exe', {
|
|
25
|
+
spawnProcess: () => server.child,
|
|
26
|
+
requestTimeoutMs,
|
|
27
|
+
});
|
|
28
|
+
return { server, client };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('McpStdioClient', () => {
|
|
32
|
+
it('initializes, then says it is initialized', async () => {
|
|
33
|
+
const { server, client } = await startAsync(() => undefined);
|
|
34
|
+
|
|
35
|
+
expect(client.protocolVersion).toBe('2025-06-18');
|
|
36
|
+
expect(client.serverInfo?.name).toBe('RobloxStudio');
|
|
37
|
+
expect(server.requests.map((r) => r.method)).toEqual([
|
|
38
|
+
'initialize',
|
|
39
|
+
'notifications/initialized',
|
|
40
|
+
]);
|
|
41
|
+
await client.closeAsync();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('lists tools, repairing a list result without caching hints', async () => {
|
|
45
|
+
const { client } = await startAsync((method) =>
|
|
46
|
+
method === 'tools/list'
|
|
47
|
+
? {
|
|
48
|
+
result: {
|
|
49
|
+
resultType: 'complete',
|
|
50
|
+
tools: [{ name: 'execute_luau' }],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: undefined
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(await client.listToolsAsync()).toEqual([{ name: 'execute_luau' }]);
|
|
57
|
+
await expect(client.requestAsync('tools/list', {})).resolves.toMatchObject({
|
|
58
|
+
ttlMs: 0,
|
|
59
|
+
cacheScope: 'private',
|
|
60
|
+
});
|
|
61
|
+
await client.closeAsync();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('calls a tool and joins its text content', async () => {
|
|
65
|
+
const { server, client } = await startAsync((method) =>
|
|
66
|
+
method === 'tools/call' ? toolText('42') : undefined
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const result = await client.callToolAsync('execute_luau', { code: 'x' });
|
|
70
|
+
|
|
71
|
+
expect(result).toMatchObject({ isError: false, text: '42' });
|
|
72
|
+
expect(server.requests.at(-1)?.params).toEqual({
|
|
73
|
+
name: 'execute_luau',
|
|
74
|
+
arguments: { code: 'x' },
|
|
75
|
+
});
|
|
76
|
+
await client.closeAsync();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reports a tool error as isError rather than throwing', async () => {
|
|
80
|
+
const { client } = await startAsync(() => toolText('boom', true));
|
|
81
|
+
|
|
82
|
+
await expect(client.callToolAsync('execute_luau')).resolves.toMatchObject({
|
|
83
|
+
isError: true,
|
|
84
|
+
text: 'boom',
|
|
85
|
+
});
|
|
86
|
+
await client.closeAsync();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects a JSON-RPC error with its message', async () => {
|
|
90
|
+
const { client } = await startAsync(() => ({
|
|
91
|
+
error: { code: -32602, message: 'bad params' },
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
await expect(client.requestAsync('tools/call', {})).rejects.toThrow(
|
|
95
|
+
'tools/call failed: bad params'
|
|
96
|
+
);
|
|
97
|
+
await client.closeAsync();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects requests in flight when the server exits, with its stderr', async () => {
|
|
101
|
+
const { server, client } = await startAsync(() => undefined);
|
|
102
|
+
|
|
103
|
+
const pending = client.callToolAsync('execute_luau');
|
|
104
|
+
server.exit(3, 'Studio went away');
|
|
105
|
+
|
|
106
|
+
await expect(pending).rejects.toThrow(
|
|
107
|
+
"Studio's MCP server exited (code 3): Studio went away"
|
|
108
|
+
);
|
|
109
|
+
await expect(client.callToolAsync('execute_luau')).rejects.toThrow(
|
|
110
|
+
'exited'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('waits for the server to exit when closed', async () => {
|
|
115
|
+
const { server, client } = await startAsync(() => undefined);
|
|
116
|
+
|
|
117
|
+
await client.closeAsync();
|
|
118
|
+
|
|
119
|
+
expect(server.child.killed).toBe(true);
|
|
120
|
+
expect(client.isRunning).toBe(false);
|
|
121
|
+
await expect(client.callToolAsync('execute_luau')).rejects.toThrow(
|
|
122
|
+
'closed'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('closes a server that already exited without killing it again', async () => {
|
|
127
|
+
const { server, client } = await startAsync(() => undefined);
|
|
128
|
+
server.exit(0);
|
|
129
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
130
|
+
|
|
131
|
+
await client.closeAsync();
|
|
132
|
+
|
|
133
|
+
expect(server.child.killed).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('times out a request the server never answers', async () => {
|
|
137
|
+
const { client } = await startAsync(() => undefined);
|
|
138
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
139
|
+
|
|
140
|
+
const pending = expect(
|
|
141
|
+
client.callToolAsync('execute_luau')
|
|
142
|
+
).rejects.toThrow('tools/call timed out after 30000ms');
|
|
143
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
144
|
+
await pending;
|
|
145
|
+
vi.useRealTimers();
|
|
146
|
+
await client.closeAsync();
|
|
147
|
+
});
|
|
148
|
+
});
|