@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
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from 'fs/promises';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
10
11
|
import { defineCommand } from '../../framework/define-command.js';
|
|
11
12
|
import { arg } from '../../framework/arg-builder.js';
|
|
12
13
|
import { resolveScriptContentAsync } from '../../../cli/resolve-script-content.js';
|
|
13
|
-
import type { BridgeSession } from '../../../bridge/index.js';
|
|
14
|
+
import type { BridgeConnection, BridgeSession } from '../../../bridge/index.js';
|
|
15
|
+
import type { RunAs } from '../../../bridge/types.js';
|
|
16
|
+
import { tryExecuteLuauAsync } from '../../../roblox-mcp/index.js';
|
|
17
|
+
import { listStudioProcessesAsync } from '../../../process/studio-process-closer.js';
|
|
18
|
+
import { resolveSessionPidsAsync } from '../../../process/studio-process-identity.js';
|
|
14
19
|
import type { SerializedReturnValue } from '../../../server/web-socket-protocol.js';
|
|
15
20
|
|
|
16
21
|
export interface ExecOptions {
|
|
@@ -18,6 +23,9 @@ export interface ExecOptions {
|
|
|
18
23
|
timeout?: number;
|
|
19
24
|
/** Passed to the script as `...`. */
|
|
20
25
|
args?: string[];
|
|
26
|
+
/** Passed to the script as buffers in `...`, before `args`. */
|
|
27
|
+
inputs?: Buffer[];
|
|
28
|
+
runAs?: RunAs;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export interface ExecResult {
|
|
@@ -26,6 +34,7 @@ export interface ExecResult {
|
|
|
26
34
|
error?: string;
|
|
27
35
|
traceback?: string;
|
|
28
36
|
returnValues?: SerializedReturnValue[];
|
|
37
|
+
ranAs?: RunAs;
|
|
29
38
|
summary: string;
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -46,8 +55,10 @@ export interface RunResult {
|
|
|
46
55
|
interface ConsoleExecArgs {
|
|
47
56
|
code?: string;
|
|
48
57
|
file?: string;
|
|
49
|
-
|
|
58
|
+
scriptTimeout?: number;
|
|
50
59
|
scriptArgs?: string[];
|
|
60
|
+
input?: string[];
|
|
61
|
+
runAs?: RunAs;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/**
|
|
@@ -55,13 +66,32 @@ interface ConsoleExecArgs {
|
|
|
55
66
|
*/
|
|
56
67
|
export async function execHandlerAsync(
|
|
57
68
|
session: BridgeSession,
|
|
58
|
-
options: ExecOptions
|
|
69
|
+
options: ExecOptions,
|
|
70
|
+
connection?: BridgeConnection
|
|
59
71
|
): Promise<ExecResult> {
|
|
60
|
-
|
|
61
|
-
options
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
if (options.runAs === 'mcp') {
|
|
73
|
+
return execThroughMcpAsync(session, options, connection);
|
|
74
|
+
}
|
|
75
|
+
const result = options.runAs
|
|
76
|
+
? await session.execAsync(
|
|
77
|
+
options.scriptContent,
|
|
78
|
+
options.timeout,
|
|
79
|
+
options.args,
|
|
80
|
+
options.inputs,
|
|
81
|
+
options.runAs
|
|
82
|
+
)
|
|
83
|
+
: options.inputs?.length
|
|
84
|
+
? await session.execAsync(
|
|
85
|
+
options.scriptContent,
|
|
86
|
+
options.timeout,
|
|
87
|
+
options.args,
|
|
88
|
+
options.inputs
|
|
89
|
+
)
|
|
90
|
+
: await session.execAsync(
|
|
91
|
+
options.scriptContent,
|
|
92
|
+
options.timeout,
|
|
93
|
+
options.args
|
|
94
|
+
);
|
|
65
95
|
|
|
66
96
|
const output = (result.output ?? []).map((entry) =>
|
|
67
97
|
typeof entry === 'string' ? entry : entry.body
|
|
@@ -73,12 +103,76 @@ export async function execHandlerAsync(
|
|
|
73
103
|
error: result.error,
|
|
74
104
|
traceback: result.traceback,
|
|
75
105
|
returnValues: result.returnValues,
|
|
106
|
+
...(result.ranAs ? { ranAs: result.ranAs } : {}),
|
|
76
107
|
summary: result.success
|
|
77
108
|
? 'Script executed successfully'
|
|
78
109
|
: `Script failed: ${result.error}`,
|
|
79
110
|
};
|
|
80
111
|
}
|
|
81
112
|
|
|
113
|
+
export const INPUT_ARG_DESCRIPTION =
|
|
114
|
+
'File to send with the script, which receives it as a buffer in ... before any args. Repeat for more files';
|
|
115
|
+
|
|
116
|
+
export async function readInputFilesAsync(
|
|
117
|
+
files: string[] | undefined
|
|
118
|
+
): Promise<Buffer[]> {
|
|
119
|
+
return Promise.all(
|
|
120
|
+
(files ?? []).map(async (file) => {
|
|
121
|
+
try {
|
|
122
|
+
return await fs.readFile(path.resolve(file));
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error(`Input file not found: ${file}`);
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function execThroughMcpAsync(
|
|
131
|
+
session: BridgeSession,
|
|
132
|
+
options: ExecOptions,
|
|
133
|
+
connection?: BridgeConnection
|
|
134
|
+
): Promise<ExecResult> {
|
|
135
|
+
if (options.args?.length || options.inputs?.length) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"--run-as mcp can't pass args or --input: Studio's MCP server only takes the code."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const processes = connection
|
|
141
|
+
? await listStudioProcessesAsync().catch(() => [])
|
|
142
|
+
: [];
|
|
143
|
+
const sessionPids =
|
|
144
|
+
connection && processes.length > 0
|
|
145
|
+
? await resolveSessionPidsAsync(connection, processes, [
|
|
146
|
+
session.info.instanceId,
|
|
147
|
+
])
|
|
148
|
+
: undefined;
|
|
149
|
+
const result = await tryExecuteLuauAsync(options.scriptContent, {
|
|
150
|
+
target: session.info.instanceId,
|
|
151
|
+
context: session.info.context,
|
|
152
|
+
sessions: connection?.listSessions() ?? [session.info],
|
|
153
|
+
...(sessionPids ? { sessionPids } : {}),
|
|
154
|
+
...(processes.length > 0
|
|
155
|
+
? { listWindowsAsync: async () => processes }
|
|
156
|
+
: {}),
|
|
157
|
+
...(options.timeout !== undefined ? { timeoutMs: options.timeout } : {}),
|
|
158
|
+
});
|
|
159
|
+
return result.success
|
|
160
|
+
? {
|
|
161
|
+
success: true,
|
|
162
|
+
output: [],
|
|
163
|
+
returnValues: [result.value],
|
|
164
|
+
ranAs: 'mcp',
|
|
165
|
+
summary: 'Script executed successfully',
|
|
166
|
+
}
|
|
167
|
+
: {
|
|
168
|
+
success: false,
|
|
169
|
+
output: [],
|
|
170
|
+
error: `Studio's MCP server: ${result.message}`,
|
|
171
|
+
ranAs: 'mcp',
|
|
172
|
+
summary: `Script failed: ${result.message}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
82
176
|
/**
|
|
83
177
|
* Read a Luau script file and execute it in a connected Studio session.
|
|
84
178
|
*/
|
|
@@ -130,6 +224,7 @@ export function formatExecText(result: {
|
|
|
130
224
|
returnValues?: SerializedReturnValue[];
|
|
131
225
|
error?: string;
|
|
132
226
|
traceback?: string;
|
|
227
|
+
ranAs?: RunAs;
|
|
133
228
|
summary: string;
|
|
134
229
|
}): string {
|
|
135
230
|
const lines = [...result.output];
|
|
@@ -139,6 +234,9 @@ export function formatExecText(result: {
|
|
|
139
234
|
if (result.error) lines.push(result.error);
|
|
140
235
|
if (result.traceback)
|
|
141
236
|
lines.push(OutputHelper.formatDim(result.traceback.trimEnd()));
|
|
237
|
+
if (result.ranAs && result.ranAs !== 'plugin') {
|
|
238
|
+
lines.push(OutputHelper.formatDim(`(ran as ${result.ranAs})`));
|
|
239
|
+
}
|
|
142
240
|
return lines.length > 0 ? lines.join('\n') : result.summary;
|
|
143
241
|
}
|
|
144
242
|
|
|
@@ -159,10 +257,19 @@ export const execCommand = defineCommand<ConsoleExecArgs, ExecResult>({
|
|
|
159
257
|
description: 'Path to a Luau script file to execute',
|
|
160
258
|
alias: 'f',
|
|
161
259
|
}),
|
|
162
|
-
|
|
163
|
-
description: '
|
|
260
|
+
scriptTimeout: arg.option({
|
|
261
|
+
description: 'Cancel the script after this many milliseconds',
|
|
164
262
|
type: 'number',
|
|
165
263
|
}),
|
|
264
|
+
input: arg.option({
|
|
265
|
+
description: INPUT_ARG_DESCRIPTION,
|
|
266
|
+
array: true,
|
|
267
|
+
}),
|
|
268
|
+
runAs: arg.option({
|
|
269
|
+
description:
|
|
270
|
+
"Where the code runs: plugin (our plugin, its own copy of modules), game (an ordinary script in a running playtest, so require returns the game's modules) or mcp (Studio's MCP sandbox). Default: plugin where there is one, else game",
|
|
271
|
+
choices: ['plugin', 'game', 'mcp'],
|
|
272
|
+
}),
|
|
166
273
|
scriptArgs: arg.passthrough({
|
|
167
274
|
description:
|
|
168
275
|
'Arguments after --, passed to the script as ... (all strings)',
|
|
@@ -171,13 +278,20 @@ export const execCommand = defineCommand<ConsoleExecArgs, ExecResult>({
|
|
|
171
278
|
cli: {
|
|
172
279
|
format: formatExecText,
|
|
173
280
|
},
|
|
174
|
-
handler: async (session, args) => {
|
|
281
|
+
handler: async (session, args, connection) => {
|
|
175
282
|
const { scriptContent } = await resolveScriptContentAsync(args);
|
|
283
|
+
const inputs = await readInputFilesAsync(args.input);
|
|
176
284
|
|
|
177
|
-
return execHandlerAsync(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
285
|
+
return execHandlerAsync(
|
|
286
|
+
session,
|
|
287
|
+
{
|
|
288
|
+
scriptContent,
|
|
289
|
+
timeout: args.scriptTimeout,
|
|
290
|
+
args: args.scriptArgs,
|
|
291
|
+
inputs,
|
|
292
|
+
runAs: args.runAs,
|
|
293
|
+
},
|
|
294
|
+
connection
|
|
295
|
+
);
|
|
182
296
|
},
|
|
183
297
|
});
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
`SCRIPT_RUNTIME_ERROR` with a `traceback` when it throws, and
|
|
9
9
|
`SCRIPT_TIMEOUT` when it outlives `payload.timeoutMs`. A timed out script
|
|
10
10
|
is cancelled, so the requests queued behind it still run.
|
|
11
|
-
- Passes `payload.
|
|
11
|
+
- Passes `payload.inputs`, a list of base64 files, to the script as buffers
|
|
12
|
+
in `...`, followed by `payload.args`, a list of strings.
|
|
12
13
|
- Runs concurrent requests one at a time, in arrival order.
|
|
13
14
|
- Returns everything printed while the script runs, read from LogService, so
|
|
14
15
|
output from required modules and from threads the script starts comes back.
|
|
@@ -26,6 +27,18 @@
|
|
|
26
27
|
|
|
27
28
|
local MAX_DEPTH = 64
|
|
28
29
|
local CHUNK_NAME = "=exec"
|
|
30
|
+
local GAME_RUNNER_NAME = "StudioBridgeRunAsGame"
|
|
31
|
+
local GAME_RUNNER_HEADER =
|
|
32
|
+
'local __bridgeArgs = table.pack(script:WaitForChild("Args"):Invoke()) local __bridgeResult = table.pack(xpcall(function(...) '
|
|
33
|
+
local GAME_RUNNER_FOOTER =
|
|
34
|
+
'\nend, function(err) return { message = tostring(err), traceback = debug.traceback(nil, 2) } end, table.unpack(__bridgeArgs, 1, __bridgeArgs.n))) local __bridgeOut = { n = __bridgeResult.n, ok = __bridgeResult[1] } for i = 2, __bridgeResult.n do __bridgeOut["v" .. (i - 1)] = __bridgeResult[i] end script:WaitForChild("Done"):Fire(__bridgeOut)'
|
|
35
|
+
local NO_PLUGIN_MESSAGE =
|
|
36
|
+
"This session runs as game code: it is the bridge planted into a playtest because Studio's \"Load user plugins on playtest\" setting is off, so there is no plugin to run in. Turn the setting on, or use --run-as mcp, which runs any code here but cannot require the game's modules."
|
|
37
|
+
local NOT_RUNNING_MESSAGE =
|
|
38
|
+
"--run-as game needs a running playtest. Start one with 'studio-bridge playtest start', then target --context server or client."
|
|
39
|
+
|
|
40
|
+
local MIN_OUTPUT_WAIT_FRAMES = 3
|
|
41
|
+
local MAX_OUTPUT_WAIT_FRAMES = 30
|
|
29
42
|
local PLUGIN_PREFIX = "[StudioBridge]"
|
|
30
43
|
-- Tracebacks name this module's frames the way its first line does
|
|
31
44
|
local OWN_FRAME_PREFIX = string.match(debug.traceback(), "^([^\n]-):%d+")
|
|
@@ -177,15 +190,26 @@ function ExecuteAction._handleExecute(
|
|
|
177
190
|
return reply({ success = false, error = "Missing code in execute request", code = "SCRIPT_LOAD_ERROR" })
|
|
178
191
|
end
|
|
179
192
|
|
|
193
|
+
local canPlant = ExecuteAction._canPlantScripts()
|
|
194
|
+
if payload.runAs == "plugin" and not canPlant then
|
|
195
|
+
return reply({ success = false, error = NO_PLUGIN_MESSAGE, code = "SCRIPT_LOAD_ERROR", ranAs = "game" })
|
|
196
|
+
end
|
|
197
|
+
local asGame = payload.runAs == "game" and canPlant
|
|
198
|
+
if asGame and not game:GetService("RunService"):IsRunning() then
|
|
199
|
+
return reply({ success = false, error = NOT_RUNNING_MESSAGE, code = "SCRIPT_LOAD_ERROR" })
|
|
200
|
+
end
|
|
201
|
+
local ranAs = if asGame or not canPlant then "game" else "plugin"
|
|
202
|
+
|
|
180
203
|
local compileOk, fn, compileErr = pcall(compile, code, CHUNK_NAME)
|
|
181
204
|
if not compileOk then
|
|
182
|
-
return reply({ success = false, error = tostring(fn), code = "SCRIPT_LOAD_ERROR" })
|
|
205
|
+
return reply({ success = false, error = tostring(fn), code = "SCRIPT_LOAD_ERROR", ranAs = ranAs })
|
|
183
206
|
end
|
|
184
207
|
if not fn then
|
|
185
208
|
return reply({
|
|
186
209
|
success = false,
|
|
187
210
|
error = if compileErr then tostring(compileErr) else "Failed to compile script",
|
|
188
211
|
code = "SCRIPT_LOAD_ERROR",
|
|
212
|
+
ranAs = ranAs,
|
|
189
213
|
})
|
|
190
214
|
end
|
|
191
215
|
|
|
@@ -194,6 +218,29 @@ function ExecuteAction._handleExecute(
|
|
|
194
218
|
return reply({ success = false, error = "Script args must be a list", code = "SCRIPT_LOAD_ERROR" })
|
|
195
219
|
end
|
|
196
220
|
|
|
221
|
+
local inputs = payload.inputs or {}
|
|
222
|
+
if type(inputs) ~= "table" then
|
|
223
|
+
return reply({ success = false, error = "Script inputs must be a list", code = "SCRIPT_LOAD_ERROR" })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
local scriptArgs = {}
|
|
227
|
+
for index, encoded in inputs do
|
|
228
|
+
local decodeOk, decoded = pcall(function()
|
|
229
|
+
return game:GetService("EncodingService"):Base64Decode(buffer.fromstring(encoded))
|
|
230
|
+
end)
|
|
231
|
+
if not decodeOk or type(encoded) ~= "string" then
|
|
232
|
+
return reply({
|
|
233
|
+
success = false,
|
|
234
|
+
error = `Script input {index} is not valid base64`,
|
|
235
|
+
code = "SCRIPT_LOAD_ERROR",
|
|
236
|
+
})
|
|
237
|
+
end
|
|
238
|
+
table.insert(scriptArgs, decoded)
|
|
239
|
+
end
|
|
240
|
+
for _, arg in args do
|
|
241
|
+
table.insert(scriptArgs, arg)
|
|
242
|
+
end
|
|
243
|
+
|
|
197
244
|
local timeoutMs = payload.timeoutMs
|
|
198
245
|
local timeoutSeconds = if type(timeoutMs) == "number" then timeoutMs / 1000 else nil
|
|
199
246
|
-- The caller's timeout started when it sent the request, not when its turn came
|
|
@@ -210,10 +257,14 @@ function ExecuteAction._handleExecute(
|
|
|
210
257
|
|
|
211
258
|
local captured: { LogEntry } = {}
|
|
212
259
|
local logConnection = ExecuteAction._captureOutput(captured)
|
|
213
|
-
local status, result
|
|
260
|
+
local status, result
|
|
261
|
+
if asGame then
|
|
262
|
+
status, result = ExecuteAction._runAsGameAsync(code, timeoutSeconds, scriptArgs)
|
|
263
|
+
else
|
|
264
|
+
status, result = ExecuteAction._runAsync(fn, timeoutSeconds, scriptArgs)
|
|
265
|
+
end
|
|
214
266
|
|
|
215
|
-
|
|
216
|
-
task.wait()
|
|
267
|
+
ExecuteAction._waitForOutputAsync(captured)
|
|
217
268
|
logConnection:Disconnect()
|
|
218
269
|
|
|
219
270
|
if status == "timeout" then
|
|
@@ -222,6 +273,7 @@ function ExecuteAction._handleExecute(
|
|
|
222
273
|
error = `Script timed out after {timeoutMs}ms and was cancelled`,
|
|
223
274
|
code = "SCRIPT_TIMEOUT",
|
|
224
275
|
output = captured,
|
|
276
|
+
ranAs = ranAs,
|
|
225
277
|
})
|
|
226
278
|
elseif status == "error" then
|
|
227
279
|
return reply({
|
|
@@ -230,6 +282,7 @@ function ExecuteAction._handleExecute(
|
|
|
230
282
|
traceback = result.traceback,
|
|
231
283
|
code = "SCRIPT_RUNTIME_ERROR",
|
|
232
284
|
output = captured,
|
|
285
|
+
ranAs = ranAs,
|
|
233
286
|
})
|
|
234
287
|
end
|
|
235
288
|
|
|
@@ -237,6 +290,7 @@ function ExecuteAction._handleExecute(
|
|
|
237
290
|
success = true,
|
|
238
291
|
output = captured,
|
|
239
292
|
returnValues = ExecuteAction._serializeReturnValues(result),
|
|
293
|
+
ranAs = ranAs,
|
|
240
294
|
})
|
|
241
295
|
end
|
|
242
296
|
|
|
@@ -258,6 +312,23 @@ function ExecuteAction._dropOwnFrames(traceback: string): string
|
|
|
258
312
|
return table.concat(lines, "\n")
|
|
259
313
|
end
|
|
260
314
|
|
|
315
|
+
--[[
|
|
316
|
+
Waits for the script's last output to arrive. MessageOut fires a frame or
|
|
317
|
+
two after a print, more in a playtest's server, so this waits a few frames
|
|
318
|
+
and then for one without new output.
|
|
319
|
+
]]
|
|
320
|
+
function ExecuteAction._waitForOutputAsync(captured: { LogEntry })
|
|
321
|
+
local seen = #captured
|
|
322
|
+
local frames = 0
|
|
323
|
+
local grew
|
|
324
|
+
repeat
|
|
325
|
+
task.wait()
|
|
326
|
+
frames += 1
|
|
327
|
+
grew = #captured ~= seen
|
|
328
|
+
seen = #captured
|
|
329
|
+
until frames >= MAX_OUTPUT_WAIT_FRAMES or (frames >= MIN_OUTPUT_WAIT_FRAMES and not grew)
|
|
330
|
+
end
|
|
331
|
+
|
|
261
332
|
--[[
|
|
262
333
|
Collects every message logged until the connection is disconnected, other
|
|
263
334
|
than the plugin's own.
|
|
@@ -331,6 +402,77 @@ function ExecuteAction._runAsync(fn: (...any) -> ...any, timeoutSec: number?, ar
|
|
|
331
402
|
return final.status, final.result
|
|
332
403
|
end
|
|
333
404
|
|
|
405
|
+
--[[
|
|
406
|
+
Whether this thread may set a script's source: a plugin can, the bridge
|
|
407
|
+
planted into a playtest cannot.
|
|
408
|
+
]]
|
|
409
|
+
function ExecuteAction._canPlantScripts(): boolean
|
|
410
|
+
return (pcall(function()
|
|
411
|
+
local probe = Instance.new("Script")
|
|
412
|
+
probe.Source = ""
|
|
413
|
+
probe:Destroy()
|
|
414
|
+
end))
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
--[[
|
|
418
|
+
Runs `code` as an ordinary game Script (server) or LocalScript (client), so
|
|
419
|
+
`require` returns the modules the running game uses rather than the plugin's
|
|
420
|
+
own copies. Returns what `_runAsync` returns.
|
|
421
|
+
]]
|
|
422
|
+
function ExecuteAction._runAsGameAsync(code: string, timeoutSec: number?, args: { any }?): (string, any)
|
|
423
|
+
local RunService = game:GetService("RunService")
|
|
424
|
+
local isServer = RunService:IsServer()
|
|
425
|
+
local runner = Instance.new(if isServer then "Script" else "LocalScript")
|
|
426
|
+
runner.Name = GAME_RUNNER_NAME
|
|
427
|
+
|
|
428
|
+
local packedArgs = args or {}
|
|
429
|
+
local argsFunction = Instance.new("BindableFunction")
|
|
430
|
+
argsFunction.Name = "Args"
|
|
431
|
+
argsFunction.OnInvoke = function()
|
|
432
|
+
return table.unpack(packedArgs)
|
|
433
|
+
end
|
|
434
|
+
argsFunction.Parent = runner
|
|
435
|
+
|
|
436
|
+
local done = Instance.new("BindableEvent")
|
|
437
|
+
done.Name = "Done"
|
|
438
|
+
done.Parent = runner
|
|
439
|
+
|
|
440
|
+
local outcome: { [string]: any }? = nil
|
|
441
|
+
local connection = done.Event:Connect(function(result)
|
|
442
|
+
outcome = result
|
|
443
|
+
end)
|
|
444
|
+
|
|
445
|
+
runner.Source = GAME_RUNNER_HEADER .. code .. GAME_RUNNER_FOOTER
|
|
446
|
+
runner.Parent = if isServer
|
|
447
|
+
then game:GetService("ServerScriptService")
|
|
448
|
+
else (game:GetService("Players").LocalPlayer :: any):WaitForChild("PlayerScripts")
|
|
449
|
+
|
|
450
|
+
local deadline = if timeoutSec then os.clock() + timeoutSec else math.huge
|
|
451
|
+
while not outcome and os.clock() < deadline do
|
|
452
|
+
task.wait()
|
|
453
|
+
end
|
|
454
|
+
connection:Disconnect()
|
|
455
|
+
runner:Destroy()
|
|
456
|
+
|
|
457
|
+
if not outcome then
|
|
458
|
+
return "timeout", nil
|
|
459
|
+
end
|
|
460
|
+
local final = outcome :: { [string]: any }
|
|
461
|
+
if final.ok then
|
|
462
|
+
local packed = { n = final.n, true }
|
|
463
|
+
for index = 2, final.n do
|
|
464
|
+
packed[index] = final["v" .. (index - 1)]
|
|
465
|
+
end
|
|
466
|
+
return "ok", packed
|
|
467
|
+
end
|
|
468
|
+
local err = final.v1
|
|
469
|
+
return "error",
|
|
470
|
+
{
|
|
471
|
+
message = if type(err) == "table" then tostring(err.message) else tostring(err),
|
|
472
|
+
traceback = if type(err) == "table" then err.traceback else nil,
|
|
473
|
+
}
|
|
474
|
+
end
|
|
475
|
+
|
|
334
476
|
--[[
|
|
335
477
|
Marshals what a script returned, in order, skipping the pcall status. A nil
|
|
336
478
|
becomes an explicit marker: a JSON array cannot hold a hole, and dropping it
|
|
@@ -53,7 +53,7 @@ describe("ExecuteAction.handleExecute()", function()
|
|
|
53
53
|
it("should report success with no return values", function()
|
|
54
54
|
local result = runScript("local _ = 1")
|
|
55
55
|
|
|
56
|
-
expect(result).toEqual({ success = true, output = {}, returnValues = {} })
|
|
56
|
+
expect(result).toEqual({ success = true, output = {}, returnValues = {}, ranAs = "plugin" })
|
|
57
57
|
end)
|
|
58
58
|
|
|
59
59
|
it("should capture print and warn output in order", function()
|
|
@@ -141,6 +141,26 @@ describe("ExecuteAction.handleExecute()", function()
|
|
|
141
141
|
expect(result.returnValues).toEqual({ "Player1", 3, 2 } :: { any })
|
|
142
142
|
end)
|
|
143
143
|
|
|
144
|
+
it("should pass inputs to the script as buffers before its args", function()
|
|
145
|
+
local result = ExecuteAction.handleExecute({
|
|
146
|
+
script = "local data, name = ... return typeof(data), buffer.tostring(data), name",
|
|
147
|
+
inputs = { "aGVsbG8=" },
|
|
148
|
+
args = { "Player1" },
|
|
149
|
+
}, "req-1", "session-1")
|
|
150
|
+
|
|
151
|
+
expect(result.returnValues).toEqual({ "buffer", "hello", "Player1" } :: { any })
|
|
152
|
+
end)
|
|
153
|
+
|
|
154
|
+
it("should reject an input that is not a string", function()
|
|
155
|
+
local result = ExecuteAction.handleExecute({
|
|
156
|
+
script = "return 1",
|
|
157
|
+
inputs = { 5 },
|
|
158
|
+
}, "req-1", "session-1")
|
|
159
|
+
|
|
160
|
+
expect(result.success).toEqual(false)
|
|
161
|
+
expect(result.error).toContain("Script input 1")
|
|
162
|
+
end)
|
|
163
|
+
|
|
144
164
|
it("should cancel a script that outlives its timeout", function()
|
|
145
165
|
local result = ExecuteAction.handleExecute(
|
|
146
166
|
{ script = "task.wait(5) print('too late')", timeoutMs = 50 },
|
|
@@ -252,7 +272,7 @@ describe("ExecuteAction.register()", function()
|
|
|
252
272
|
type = "scriptComplete",
|
|
253
273
|
sessionId = "session-1",
|
|
254
274
|
requestId = "req-1",
|
|
255
|
-
payload = { success = true, output = {}, returnValues = { 1 } },
|
|
275
|
+
payload = { success = true, output = {}, returnValues = { 1 }, ranAs = "plugin" },
|
|
256
276
|
},
|
|
257
277
|
})
|
|
258
278
|
|
|
@@ -118,10 +118,10 @@ describe('loadActionSourcesAsync', () => {
|
|
|
118
118
|
'find-instances',
|
|
119
119
|
'grep-scripts',
|
|
120
120
|
'import-instances',
|
|
121
|
+
'playtest-players',
|
|
121
122
|
'query-data-model',
|
|
122
123
|
'query-logs',
|
|
123
124
|
'query-state',
|
|
124
|
-
'run-playtest-script',
|
|
125
125
|
'start-playtest',
|
|
126
126
|
]);
|
|
127
127
|
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Action that reads and changes the players of a running playtest.
|
|
4
|
+
|
|
5
|
+
StudioTestService only adds players from the server DataModel of a
|
|
6
|
+
multiplayer test, so `list`, `add` and `remove` target the server session.
|
|
7
|
+
`leave` targets a player's client session: it closes that player's Studio
|
|
8
|
+
process, which kicking from the server leaves open.
|
|
9
|
+
|
|
10
|
+
Request: `{ type: "playtestPlayers", payload: { op: "list" } }`
|
|
11
|
+
Request: `{ type: "playtestPlayers", payload: { op: "add", count, timeoutSec } }`
|
|
12
|
+
Request: `{ type: "playtestPlayers", payload: { op: "remove", kick: { name }, names: { name }, timeoutSec } }`
|
|
13
|
+
Request: `{ type: "playtestPlayers", payload: { op: "leave" } }`
|
|
14
|
+
Response: `{ type: "playtestPlayersResult", payload: { players?, remaining? } }`
|
|
15
|
+
|
|
16
|
+
- `add` answers once the players have joined, or with those that joined by
|
|
17
|
+
the timeout.
|
|
18
|
+
- `remove` kicks the players in `kick`, then answers once every player in
|
|
19
|
+
`names` has left, with `remaining` listing any still there at the timeout.
|
|
20
|
+
|
|
21
|
+
@class PlaytestPlayersAction
|
|
22
|
+
]=]
|
|
23
|
+
|
|
24
|
+
local Players = game:GetService("Players")
|
|
25
|
+
|
|
26
|
+
local POLL_INTERVAL_SEC = 0.25
|
|
27
|
+
|
|
28
|
+
local PlaytestPlayersAction = {}
|
|
29
|
+
|
|
30
|
+
export type PlayerInfo = {
|
|
31
|
+
name: string,
|
|
32
|
+
userId: number,
|
|
33
|
+
displayName: string,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
--[=[
|
|
37
|
+
Registers the `playtestPlayers` handler.
|
|
38
|
+
]=]
|
|
39
|
+
function PlaytestPlayersAction.register(router: any)
|
|
40
|
+
router:setResponseType("playtestPlayers", "playtestPlayersResult")
|
|
41
|
+
router:register("playtestPlayers", function(payload: { [string]: any }, _requestId: string, _sessionId: string)
|
|
42
|
+
local op = payload.op
|
|
43
|
+
if op == "list" then
|
|
44
|
+
return { players = PlaytestPlayersAction._list() }
|
|
45
|
+
elseif op == "add" then
|
|
46
|
+
return { players = PlaytestPlayersAction._addAsync(payload.count, payload.timeoutSec) }
|
|
47
|
+
elseif op == "remove" then
|
|
48
|
+
return { remaining = PlaytestPlayersAction._removeAsync(payload.kick, payload.names, payload.timeoutSec) }
|
|
49
|
+
elseif op == "leave" then
|
|
50
|
+
task.defer(function()
|
|
51
|
+
(game:GetService("StudioTestService") :: any):LeaveTest()
|
|
52
|
+
end)
|
|
53
|
+
return {}
|
|
54
|
+
end
|
|
55
|
+
error(`Unknown playtestPlayers op: {tostring(op)}`)
|
|
56
|
+
end)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
function PlaytestPlayersAction._info(player: Player): PlayerInfo
|
|
60
|
+
return { name = player.Name, userId = player.UserId, displayName = player.DisplayName }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
function PlaytestPlayersAction._list(): { PlayerInfo }
|
|
64
|
+
local players = {}
|
|
65
|
+
for _, player in Players:GetPlayers() do
|
|
66
|
+
table.insert(players, PlaytestPlayersAction._info(player))
|
|
67
|
+
end
|
|
68
|
+
return players
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
function PlaytestPlayersAction._addAsync(count: any, timeoutSec: any): { PlayerInfo }
|
|
72
|
+
if type(count) ~= "number" or count < 1 or type(timeoutSec) ~= "number" then
|
|
73
|
+
error("add needs a count of at least 1 and a timeoutSec")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
local before: { [Player]: boolean } = {}
|
|
77
|
+
for _, player in Players:GetPlayers() do
|
|
78
|
+
before[player] = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
(game:GetService("StudioTestService") :: any):AddPlayers(count)
|
|
82
|
+
|
|
83
|
+
local deadline = os.clock() + timeoutSec
|
|
84
|
+
local added: { PlayerInfo }
|
|
85
|
+
repeat
|
|
86
|
+
task.wait(POLL_INTERVAL_SEC)
|
|
87
|
+
added = {}
|
|
88
|
+
for _, player in Players:GetPlayers() do
|
|
89
|
+
if not before[player] then
|
|
90
|
+
table.insert(added, PlaytestPlayersAction._info(player))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
until #added >= count or os.clock() > deadline
|
|
94
|
+
|
|
95
|
+
return added
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
function PlaytestPlayersAction._removeAsync(kick: any, names: any, timeoutSec: any): { string }
|
|
99
|
+
if type(kick) ~= "table" or type(names) ~= "table" or type(timeoutSec) ~= "number" then
|
|
100
|
+
error("remove needs kick, names and timeoutSec")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
for _, name in kick do
|
|
104
|
+
local player = Players:FindFirstChild(name)
|
|
105
|
+
if player and player:IsA("Player") then
|
|
106
|
+
player:Kick("Removed by studio-bridge")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local deadline = os.clock() + timeoutSec
|
|
111
|
+
local remaining: { string }
|
|
112
|
+
repeat
|
|
113
|
+
remaining = {}
|
|
114
|
+
for _, name in names do
|
|
115
|
+
if Players:FindFirstChild(name) then
|
|
116
|
+
table.insert(remaining, name)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
if #remaining > 0 then
|
|
120
|
+
task.wait(POLL_INTERVAL_SEC)
|
|
121
|
+
end
|
|
122
|
+
until #remaining == 0 or os.clock() > deadline
|
|
123
|
+
|
|
124
|
+
return remaining
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return PlaytestPlayersAction
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
@class playtest-players.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local ActionRouter = require("ActionRouter")
|
|
9
|
+
local Jest = require("Jest")
|
|
10
|
+
local PlaytestPlayersAction = require("playtest-players")
|
|
11
|
+
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
local function dispatch(payload: { [string]: any })
|
|
17
|
+
local router = ActionRouter.new()
|
|
18
|
+
PlaytestPlayersAction.register(router)
|
|
19
|
+
return router:Dispatch({ type = "playtestPlayers", sessionId = "session-1", requestId = "r1", payload = payload })
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe("playtestPlayers", function()
|
|
23
|
+
it("should list the players", function()
|
|
24
|
+
local response = dispatch({ op = "list" })
|
|
25
|
+
|
|
26
|
+
expect(response.type).toEqual("playtestPlayersResult")
|
|
27
|
+
expect(#response.payload.players).toEqual(#game:GetService("Players"):GetPlayers())
|
|
28
|
+
end)
|
|
29
|
+
|
|
30
|
+
it("should answer remove at once with no player left to wait for", function()
|
|
31
|
+
local response = dispatch({ op = "remove", kick = {}, names = { "NoSuchPlayer" }, timeoutSec = 5 })
|
|
32
|
+
|
|
33
|
+
expect(response.payload.remaining).toEqual({})
|
|
34
|
+
end)
|
|
35
|
+
|
|
36
|
+
it("should reject an unknown op", function()
|
|
37
|
+
local response = dispatch({ op = "nope" })
|
|
38
|
+
|
|
39
|
+
expect(response.type).toEqual("error")
|
|
40
|
+
expect(response.payload.message).toContain("Unknown playtestPlayers op")
|
|
41
|
+
end)
|
|
42
|
+
|
|
43
|
+
it("should reject add without a count", function()
|
|
44
|
+
local response = dispatch({ op = "add", timeoutSec = 5 })
|
|
45
|
+
|
|
46
|
+
expect(response.type).toEqual("error")
|
|
47
|
+
end)
|
|
48
|
+
end)
|