@quenty/studio-bridge 0.23.0 → 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.
Files changed (231) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +2 -2
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. 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
- timeout?: number;
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
- const result = await session.execAsync(
61
- options.scriptContent,
62
- options.timeout,
63
- options.args
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
- timeout: arg.option({
163
- description: 'Execution timeout in milliseconds',
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(session, {
178
- scriptContent,
179
- timeout: args.timeout,
180
- args: args.scriptArgs,
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.args`, a list of strings, to the script as `...`.
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 = ExecuteAction._runAsync(fn, timeoutSeconds, args)
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
- -- MessageOut can fire a frame late, after the script's last print
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)