@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
@@ -5,8 +5,14 @@
5
5
  * each connects to the bridge as its own session. Those sessions can't be
6
6
  * matched to the Studio that started them by instance id, since unpublished
7
7
  * places give every DataModel its own. Instead, `startPlaytestAsync` passes a
8
- * playtest id as the StudioTestService test args, and each new session is
9
- * asked for it back through `GetTestArgs`.
8
+ * playtest id as the StudioTestService test args, and each session registers
9
+ * with it.
10
+ *
11
+ * The playtest's server session is the plugin when Studio loads user plugins
12
+ * into playtests. Otherwise the edit session plants the same client into the
13
+ * playtest as an ordinary Script (see plugin/playtest-bridge.ts). That one can
14
+ * only run code compiled when the playtest started: the actions, and the
15
+ * scripts `startPlaytestAsync` is given.
10
16
  */
11
17
 
12
18
  import { randomUUID } from 'crypto';
@@ -20,6 +26,7 @@ import type {
20
26
  } from '../../bridge/index.js';
21
27
  import { ContextNotFoundError } from '../../bridge/index.js';
22
28
  import { getPluginBuildVersionAsync } from '../../plugin/plugin-build-version.js';
29
+ import { buildPlaytestBridgeAsync } from '../../plugin/playtest-bridge.js';
23
30
  import { tryReadLoadUserPluginsOnPlaytestAsync } from '../../process/studio-settings.js';
24
31
 
25
32
  /** How long a started playtest may run before its pending start request is dropped. */
@@ -29,16 +36,19 @@ const SESSION_WAIT_TIMEOUT_MS = 30_000;
29
36
  /** Each player of a multiplayer test boots its own Studio process. */
30
37
  const MULTIPLAYER_SESSION_WAIT_TIMEOUT_MS = 90_000;
31
38
  const SESSION_CLOSE_TIMEOUT_MS = 10_000;
32
- const READ_RETRY_DELAY_MS = 1_000;
33
-
34
- const PLAYTEST_PLUGINS_OFF_MESSAGE = `Studio's "Load user plugins on playtest" setting is off, so the studio-bridge plugin doesn't load into playtests and their sessions can't connect. Turn it on in Studio Settings, or run a script in a playtest's server with 'studio-bridge playtest run', which works without it.`;
35
39
 
36
- const READ_PLAYTEST_ID_SCRIPT = [
37
- 'local ok, args = pcall(function()',
38
- '\treturn game:GetService("StudioTestService"):GetTestArgs()',
39
- 'end)',
40
- 'return if ok and type(args) == "table" then args.studioBridgePlaytestId else nil',
41
- ].join('\n');
40
+ const PLAYTEST_PLUGINS_OFF_MESSAGE = `Studio's "Load user plugins on playtest" setting is off, so the studio-bridge plugin doesn't load into a playtest's client. A playtest started with 'studio-bridge playtest start' gets a planted client session instead, which runs only code compiled when it started; turn the setting on to run any code.`;
41
+
42
+ export interface StartPlaytestOptions {
43
+ /**
44
+ * Code the caller will run in the playtest's server session. A server
45
+ * session planted without plugin permissions can only run code compiled
46
+ * when the playtest starts.
47
+ */
48
+ scripts?: string[];
49
+ /** Wait for the server session only, so play mode needs no client plugin. */
50
+ serverOnly?: boolean;
51
+ }
42
52
 
43
53
  export interface StartedPlaytest {
44
54
  playtestId: string;
@@ -68,19 +78,33 @@ export async function startPlaytestAsync(
68
78
  connection: BridgeConnection,
69
79
  editSession: BridgeSession,
70
80
  mode: PlaytestMode,
71
- players?: number
81
+ players?: number,
82
+ options: StartPlaytestOptions = {}
72
83
  ): Promise<StartedPlaytest> {
73
84
  if (editSession.context !== 'edit') {
74
85
  throw new Error(
75
86
  `Playtests start from an edit session, but '${editSession.info.sessionId}' is a ${editSession.context} session`
76
87
  );
77
88
  }
78
- if (await isPlaytestPluginsOffAsync(connection)) {
79
- throw new Error(PLAYTEST_PLUGINS_OFF_MESSAGE);
80
- }
89
+ const pluginsOnPlaytest = await tryReadPlaytestPluginsAsync(connection);
81
90
  if (players !== undefined) {
82
- await assertCanStartMultiplayerAsync(editSession, mode, players);
91
+ assertValidMultiplayer(mode, players);
83
92
  }
93
+ // Plugin sessions in the playtest register with its id, which older plugins leave out
94
+ if (pluginsOnPlaytest !== false) {
95
+ await assertCurrentPluginAsync(editSession);
96
+ }
97
+
98
+ // A server plugin, when Studio does load one, stands down for a planted bridge
99
+ const bridge =
100
+ pluginsOnPlaytest === true
101
+ ? undefined
102
+ : await buildPlaytestBridgeAsync(options.scripts, {
103
+ withClient:
104
+ pluginsOnPlaytest === false &&
105
+ mode === 'play' &&
106
+ !options.serverOnly,
107
+ });
84
108
 
85
109
  const playtestId = randomUUID();
86
110
  const ended = editSession
@@ -89,7 +113,8 @@ export async function startPlaytestAsync(
89
113
  playtestId,
90
114
  connection.port,
91
115
  PLAYTEST_MAX_DURATION_MS,
92
- players
116
+ players,
117
+ bridge
93
118
  )
94
119
  .catch(
95
120
  (err): PlaytestEndResult => ({
@@ -99,7 +124,9 @@ export async function startPlaytestAsync(
99
124
  })
100
125
  );
101
126
 
102
- const clientCount = players ?? (mode === 'play' ? 1 : 0);
127
+ const clientCount = options.serverOnly
128
+ ? 0
129
+ : players ?? (mode === 'play' ? 1 : 0);
103
130
  const waitTimeoutMs =
104
131
  players !== undefined
105
132
  ? MULTIPLAYER_SESSION_WAIT_TIMEOUT_MS
@@ -153,20 +180,14 @@ export async function startPlaytestAsync(
153
180
 
154
181
  // Without its server session the playtest can't be ended from here
155
182
  if (!server?.isConnected) {
156
- throw new Error(
157
- `Playtest started, but ${waited}, so stop it in Studio. Play sessions only connect with Studio's "Load user plugins on playtest" setting on.`
158
- );
183
+ throw new Error(`Playtest started, but ${waited}, so stop it in Studio.`);
159
184
  }
160
185
 
161
186
  await endPlaytestAsync(connection, server);
162
187
  throw new Error(`Playtest started, but ${waited}, so it was stopped.`);
163
188
  }
164
189
 
165
- async function assertCanStartMultiplayerAsync(
166
- editSession: BridgeSession,
167
- mode: PlaytestMode,
168
- players: number
169
- ): Promise<void> {
190
+ function assertValidMultiplayer(mode: PlaytestMode, players: number): void {
170
191
  if (!Number.isInteger(players) || players < 1) {
171
192
  throw new Error(
172
193
  `--players must be a whole number of at least 1, not ${players}`
@@ -175,12 +196,15 @@ async function assertCanStartMultiplayerAsync(
175
196
  if (mode !== 'play') {
176
197
  throw new Error('A multiplayer test runs in play mode');
177
198
  }
199
+ }
178
200
 
179
- // Older plugins give every client the same session id, so all but one drop off
201
+ async function assertCurrentPluginAsync(
202
+ editSession: BridgeSession
203
+ ): Promise<void> {
180
204
  const expected = await getPluginBuildVersionAsync();
181
205
  if (editSession.info.pluginVersion !== expected) {
182
206
  throw new Error(
183
- `Multiplayer tests need the current studio-bridge plugin, but '${editSession.info.placeName}' has an older one. Run 'studio-bridge plugin install' first.`
207
+ `Playtests with plugin sessions need the current studio-bridge plugin, but '${editSession.info.placeName}' has an older one. Run 'studio-bridge plugin install' first.`
184
208
  );
185
209
  }
186
210
  }
@@ -255,16 +279,15 @@ export async function explainMissingPlaytestContextAsync(
255
279
  }
256
280
 
257
281
  /**
258
- * Whether this machine's Studio has "Load user plugins on playtest" off. A
259
- * remote Studio's settings aren't on this machine, so they count as unknown.
282
+ * Whether this machine's Studio has "Load user plugins on playtest" on. A
283
+ * remote Studio's settings aren't on this machine, so they are unknown.
260
284
  */
261
- async function isPlaytestPluginsOffAsync(
285
+ async function tryReadPlaytestPluginsAsync(
262
286
  connection: BridgeConnection
263
- ): Promise<boolean> {
264
- return (
265
- !connection.isRemote &&
266
- (await tryReadLoadUserPluginsOnPlaytestAsync()) === false
267
- );
287
+ ): Promise<boolean | undefined> {
288
+ return connection.isRemote
289
+ ? undefined
290
+ : await tryReadLoadUserPluginsOnPlaytestAsync();
268
291
  }
269
292
 
270
293
  async function explainNoPlaytestAsync(
@@ -273,7 +296,10 @@ async function explainNoPlaytestAsync(
273
296
  context?: SessionContext
274
297
  ): Promise<string> {
275
298
  const of = studio ? ` of ${studio}` : '';
276
- if (await isPlaytestPluginsOffAsync(connection)) {
299
+ if (
300
+ context === 'client' &&
301
+ (await tryReadPlaytestPluginsAsync(connection)) === false
302
+ ) {
277
303
  return `No playtest${of} is connected. ${PLAYTEST_PLUGINS_OFF_MESSAGE}`;
278
304
  }
279
305
  const missing = context ? `, so there is no ${context} session` : '';
@@ -307,7 +333,6 @@ async function waitForPlaytestSessionsAsync(
307
333
  ): Promise<PlaytestSessions> {
308
334
  return new Promise((resolve) => {
309
335
  const found: PlaytestSessions = { clients: [] };
310
- const checked = new Set<string>();
311
336
 
312
337
  const cleanup = () => {
313
338
  clearTimeout(timer);
@@ -316,30 +341,22 @@ async function waitForPlaytestSessionsAsync(
316
341
  };
317
342
 
318
343
  const onSession = (session: BridgeSession) => {
319
- const { sessionId } = session.info;
320
- if (session.context === 'edit' || checked.has(sessionId)) {
344
+ if (session.info.playtestId !== playtestId) {
321
345
  return;
322
346
  }
323
- checked.add(sessionId);
324
-
325
- void readPlaytestIdAsync(connection, sessionId, signal).then((read) => {
326
- if (signal.aborted || !read) return;
327
- if (read.id !== playtestId) {
328
- // The last playtest's session can linger under the same id; read its replacement too
329
- checked.delete(sessionId);
330
- return;
331
- }
332
-
333
- if (read.session.context === 'server') {
334
- found.server = read.session;
335
- } else {
336
- found.clients.push(read.session);
337
- }
338
- if (found.server && found.clients.length >= clientCount) {
339
- cleanup();
340
- resolve(found);
341
- }
342
- });
347
+
348
+ if (session.context === 'server') {
349
+ found.server = session;
350
+ } else if (
351
+ session.context === 'client' &&
352
+ !found.clients.some((c) => c.info.sessionId === session.info.sessionId)
353
+ ) {
354
+ found.clients.push(session);
355
+ }
356
+ if (found.server && found.clients.length >= clientCount) {
357
+ cleanup();
358
+ resolve(found);
359
+ }
343
360
  };
344
361
 
345
362
  // Resolves with whichever sessions connected; the caller reports the rest
@@ -358,34 +375,6 @@ async function waitForPlaytestSessionsAsync(
358
375
  });
359
376
  }
360
377
 
361
- /**
362
- * Reads the playtest id a session was started under, retrying while the read
363
- * fails: a playtest's DataModels are slow to answer while their Studio boots,
364
- * and a session that reconnects is read again under the same id.
365
- */
366
- async function readPlaytestIdAsync(
367
- connection: BridgeConnection,
368
- sessionId: string,
369
- signal: AbortSignal
370
- ): Promise<{ session: BridgeSession; id: string | undefined } | undefined> {
371
- while (!signal.aborted) {
372
- const session = connection.getSession(sessionId);
373
- if (session) {
374
- try {
375
- const result = await session.execAsync(READ_PLAYTEST_ID_SCRIPT, 5_000);
376
- if (result.success) {
377
- const value = result.returnValues?.[0];
378
- return { session, id: typeof value === 'string' ? value : undefined };
379
- }
380
- } catch {
381
- // Retried below
382
- }
383
- }
384
- await new Promise((resolve) => setTimeout(resolve, READ_RETRY_DELAY_MS));
385
- }
386
- return undefined;
387
- }
388
-
389
378
  async function waitForSessionCloseAsync(
390
379
  connection: BridgeConnection,
391
380
  sessionId: string
@@ -5,26 +5,24 @@
5
5
  * `--context client`. Its output and return values come back the same way as
6
6
  * `console exec`.
7
7
  *
8
- * In the server, the edit session plants a runner script in the playtest
9
- * (playtest-runner.luau), so this works even when Studio does not load user
10
- * plugins into playtests. The client has no such path: it needs the
11
- * playtest's client session, so it needs that Studio setting on.
8
+ * Both run through the playtest's sessions. The server session works even
9
+ * when Studio does not load user plugins into playtests, since the edit
10
+ * session then plants it, with this code compiled ahead of time (see
11
+ * playtest-sessions.ts). The client session needs that Studio setting on.
12
12
  */
13
13
 
14
- import * as fs from 'fs/promises';
15
- import { resolvePackagePath } from '@quenty/nevermore-template-helpers';
16
14
  import { defineCommand } from '../../framework/define-command.js';
17
15
  import { arg } from '../../framework/arg-builder.js';
18
16
  import { resolveScriptContentAsync } from '../../../cli/resolve-script-content.js';
19
17
  import {
20
18
  execHandlerAsync,
21
19
  formatExecText,
22
- type ExecResult as CommandExecResult,
20
+ INPUT_ARG_DESCRIPTION,
21
+ readInputFilesAsync,
23
22
  } from '../../console/exec/exec.js';
24
23
  import type {
25
24
  BridgeConnection,
26
25
  BridgeSession,
27
- ExecResult,
28
26
  PlaytestMode,
29
27
  SessionContext,
30
28
  } from '../../../bridge/index.js';
@@ -45,15 +43,15 @@ export interface PlaytestRunOptions {
45
43
  timeout?: number;
46
44
  /** Passed to the script as `...`. */
47
45
  args?: string[];
46
+ /** Passed to the script as buffers in `...`, before `args`. */
47
+ inputs?: Buffer[];
48
48
  }
49
49
 
50
- /** Matches `console exec`'s default execution timeout. */
51
- const DEFAULT_TIMEOUT_MS = 120_000;
50
+ const PLAYER_JOIN_TIMEOUT_MS = 30_000;
52
51
 
53
52
  export interface PlaytestRunResult {
54
53
  success: boolean;
55
- /** Absent when the script ran through the planted runner. */
56
- playtestId?: string;
54
+ playtestId: string;
57
55
  output: string[];
58
56
  error?: string;
59
57
  returnValues?: SerializedReturnValue[];
@@ -66,8 +64,9 @@ interface PlaytestRunArgs {
66
64
  code?: string;
67
65
  file?: string;
68
66
  mode?: PlaytestMode;
69
- timeout?: number;
67
+ scriptTimeout?: number;
70
68
  scriptArgs?: string[];
69
+ input?: string[];
71
70
  }
72
71
 
73
72
  export async function playtestRunHandlerAsync(
@@ -85,14 +84,15 @@ export async function playtestRunHandlerAsync(
85
84
  'edit'
86
85
  );
87
86
 
88
- if (options.context === 'server') {
89
- return runWithPlantedRunnerAsync(editSession, options);
90
- }
91
-
92
87
  const playtest = await startPlaytestAsync(
93
88
  connection,
94
89
  editSession,
95
- options.mode
90
+ options.mode,
91
+ undefined,
92
+ {
93
+ scripts: [options.scriptContent],
94
+ serverOnly: options.context === 'server',
95
+ }
96
96
  );
97
97
 
98
98
  let result: PlaytestRunResult;
@@ -101,12 +101,17 @@ export async function playtestRunHandlerAsync(
101
101
  if (!session) {
102
102
  throw new Error(`Playtest has no ${options.context} session`);
103
103
  }
104
+ // In play mode the server's code expects the player, who joins a moment later
105
+ if (options.mode === 'play' && options.context === 'server') {
106
+ await waitForPlayerAsync(session);
107
+ }
104
108
 
105
109
  result = {
106
110
  ...(await execHandlerAsync(session, {
107
111
  scriptContent: options.scriptContent,
108
112
  timeout: options.timeout,
109
113
  args: options.args,
114
+ inputs: options.inputs,
110
115
  })),
111
116
  playtestId: playtest.playtestId,
112
117
  };
@@ -123,44 +128,19 @@ export async function playtestRunHandlerAsync(
123
128
  return stopError ? { ...result, stopError } : result;
124
129
  }
125
130
 
126
- async function runWithPlantedRunnerAsync(
127
- editSession: BridgeSession,
128
- options: PlaytestRunOptions
129
- ): Promise<PlaytestRunResult> {
130
- const runnerSource = await fs.readFile(
131
- resolvePackagePath(
132
- import.meta.url,
133
- 'src',
134
- 'commands',
135
- 'playtest',
136
- 'run',
137
- 'playtest-runner.luau'
138
- ),
139
- 'utf-8'
131
+ async function waitForPlayerAsync(serverSession: BridgeSession): Promise<void> {
132
+ const deadline = Date.now() + PLAYER_JOIN_TIMEOUT_MS;
133
+ while (Date.now() < deadline) {
134
+ const { players } = await serverSession.playtestPlayersAsync(
135
+ { op: 'list' },
136
+ PLAYER_JOIN_TIMEOUT_MS
137
+ );
138
+ if (players.length > 0) return;
139
+ await new Promise((resolve) => setTimeout(resolve, 250));
140
+ }
141
+ throw new Error(
142
+ `No player joined the playtest within ${PLAYER_JOIN_TIMEOUT_MS / 1000}s`
140
143
  );
141
-
142
- const result = await editSession.runPlaytestScriptAsync({
143
- mode: options.mode,
144
- script: options.scriptContent,
145
- runnerSource,
146
- timeoutSec: (options.timeout ?? DEFAULT_TIMEOUT_MS) / 1000,
147
- args: options.args,
148
- });
149
-
150
- return toCommandResult(result);
151
- }
152
-
153
- /** Shapes a bridge ExecResult the way `console exec` reports it. */
154
- function toCommandResult(result: ExecResult): CommandExecResult {
155
- return {
156
- success: result.success,
157
- output: result.output.map((entry) => entry.body),
158
- error: result.error,
159
- returnValues: result.returnValues,
160
- summary: result.success
161
- ? 'Script executed successfully'
162
- : `Script failed: ${result.error}`,
163
- };
164
144
  }
165
145
 
166
146
  /** Ends the playtest, returning why it could not instead of throwing. */
@@ -207,10 +187,14 @@ export const playtestRunCommand = defineCommand<
207
187
  choices: ['play', 'run'],
208
188
  default: 'play',
209
189
  }),
210
- timeout: arg.option({
211
- description: 'Execution timeout in milliseconds',
190
+ scriptTimeout: arg.option({
191
+ description: 'Cancel the script after this many milliseconds',
212
192
  type: 'number',
213
193
  }),
194
+ input: arg.option({
195
+ description: INPUT_ARG_DESCRIPTION,
196
+ array: true,
197
+ }),
214
198
  scriptArgs: arg.passthrough({
215
199
  description:
216
200
  'Arguments after --, passed to the script as ... (all strings)',
@@ -226,14 +210,16 @@ export const playtestRunCommand = defineCommand<
226
210
  },
227
211
  handler: async (connection, args, targeting) => {
228
212
  const { scriptContent } = await resolveScriptContentAsync(args);
213
+ const inputs = await readInputFilesAsync(args.input);
229
214
 
230
215
  return playtestRunHandlerAsync(connection, {
231
216
  scriptContent,
232
217
  mode: args.mode ?? 'play',
233
218
  context: targeting.context ?? 'server',
234
219
  target: targeting.target,
235
- timeout: args.timeout,
220
+ timeout: args.scriptTimeout,
236
221
  args: args.scriptArgs,
222
+ inputs,
237
223
  });
238
224
  },
239
225
  });
@@ -12,11 +12,18 @@
12
12
  client per player, each in its own Studio process.
13
13
  - The playtest id, bridge port and edit instance id ride along as the test
14
14
  args. The playtest's DataModels cannot make the HTTP requests port
15
- discovery needs, so the plugin in each connects straight to that port,
16
- registers under the edit session's instance, and answers with the
17
- playtest id when asked which playtest it belongs to.
15
+ discovery needs, so the plugin in each connects straight to that port and
16
+ registers under the edit session's instance and the playtest id.
17
+ - With `bridge`, it first plants the studio-bridge client as a Script in
18
+ ServerScriptService, for when Studio does not load user plugins into
19
+ playtests. The playtest copies the edit DataModel, so the client starts in
20
+ its server and connects as its server session. With
21
+ `bridge.clientEntrySource` it also plants a LocalScript in
22
+ StarterPlayerScripts, which every player's client runs and connects as its
23
+ client session. The plants are removed once the playtest ends.
18
24
 
19
- Request: `{ type: "startPlaytest", payload: { mode: "play" | "run", playtestId, bridgePort, instanceId, players? } }`
25
+ Request: `{ type: "startPlaytest", payload: { mode: "play" | "run", playtestId, bridgePort, instanceId, players?, bridge? } }`
26
+ `bridge`: `{ entrySource, clientEntrySource?, modules: { [name]: source }, precompiled: { source } }`
20
27
  Response: `{ type: "playtestEnded", payload: { playtestId, success, error? } }`
21
28
 
22
29
  @class StartPlaytestAction
@@ -62,7 +69,10 @@ function StartPlaytestAction.register(router: any, sendMessage: SendMessage?)
62
69
  studioBridgeInstanceId = instanceId,
63
70
  studioBridgePlayers = players,
64
71
  }
65
- task.spawn(StartPlaytestAction._runAsync, sendMessage, mode, testArgs, requestId, sessionId)
72
+ local planted = if payload.bridge ~= nil
73
+ then StartPlaytestAction._plant(router, payload.bridge, playtestId)
74
+ else {}
75
+ task.spawn(StartPlaytestAction._runAsync, sendMessage, mode, testArgs, requestId, sessionId, planted)
66
76
 
67
77
  -- Replied through sendMessage, so the router must not reply as well
68
78
  return nil
@@ -80,7 +90,8 @@ function StartPlaytestAction._runAsync(
80
90
  studioBridgePlayers: number?,
81
91
  },
82
92
  requestId: string,
83
- sessionId: string
93
+ sessionId: string,
94
+ planted: { Instance }
84
95
  )
85
96
  local ok, err = pcall(function()
86
97
  local studioTestService = game:GetService("StudioTestService") :: any
@@ -93,6 +104,10 @@ function StartPlaytestAction._runAsync(
93
104
  end
94
105
  end)
95
106
 
107
+ for _, instance in planted do
108
+ instance:Destroy()
109
+ end
110
+
96
111
  sendMessage({
97
112
  type = "playtestEnded",
98
113
  sessionId = sessionId,
@@ -105,4 +120,88 @@ function StartPlaytestAction._runAsync(
105
120
  })
106
121
  end
107
122
 
123
+ --[[
124
+ Plants the studio-bridge client for the playtest `playtestId` and returns it.
125
+ Errors on a malformed `bridge`, or a precompiled source that does not
126
+ compile, before anything is planted: the planted client skips a source that
127
+ fails to load, which would hide the compile error.
128
+ ]]
129
+ function StartPlaytestAction._plant(router: any, bridge: any, playtestId: string): { Instance }
130
+ if
131
+ type(bridge) ~= "table"
132
+ or type(bridge.entrySource) ~= "string"
133
+ or type(bridge.modules) ~= "table"
134
+ or type(bridge.precompiled) ~= "table"
135
+ then
136
+ error("Invalid bridge in startPlaytest request")
137
+ end
138
+
139
+ for _, source in bridge.precompiled do
140
+ local fn, compileErr
141
+ if type(router.Compile) == "function" then
142
+ fn, compileErr = router:Compile(source, "=exec")
143
+ else
144
+ fn, compileErr = (loadstring :: any)(source, "=exec")
145
+ end
146
+ if not fn then
147
+ error(`Script does not compile: {compileErr or "unknown error"}`, 0)
148
+ end
149
+ end
150
+
151
+ local entry = Instance.new("Script")
152
+ entry.Name = "StudioBridgePlaytest"
153
+ entry.Source = bridge.entrySource
154
+ entry:SetAttribute("PlaytestId", playtestId)
155
+
156
+ local shared = Instance.new("Folder")
157
+ shared.Name = "Shared"
158
+ shared.Parent = entry
159
+ for name, source in bridge.modules do
160
+ local moduleScript = Instance.new("ModuleScript")
161
+ moduleScript.Name = name
162
+ moduleScript.Source = source
163
+ moduleScript.Parent = shared
164
+ end
165
+
166
+ local precompiled = Instance.new("Folder")
167
+ precompiled.Name = "Precompiled"
168
+ precompiled.Parent = entry
169
+ for index, source in bridge.precompiled do
170
+ local moduleScript = Instance.new("ModuleScript")
171
+ moduleScript.Name = tostring(index)
172
+ moduleScript.Source = source
173
+ moduleScript.Parent = precompiled
174
+ end
175
+
176
+ local planted = { entry }
177
+ local clientEntry: LocalScript? = nil
178
+ if type(bridge.clientEntrySource) == "string" then
179
+ clientEntry = Instance.new("LocalScript")
180
+ local client = clientEntry :: LocalScript
181
+ client.Name = "StudioBridgePlaytestClient"
182
+ client.Source = bridge.clientEntrySource
183
+ client:SetAttribute("PlaytestId", playtestId)
184
+ shared:Clone().Parent = client
185
+ precompiled:Clone().Parent = client
186
+ table.insert(planted, client)
187
+ end
188
+
189
+ -- A bridge left from an earlier playtest would stand in for this one's
190
+ local serverScriptService = game:GetService("ServerScriptService")
191
+ local starterPlayerScripts = game:GetService("StarterPlayer"):WaitForChild("StarterPlayerScripts")
192
+ for _, container in { serverScriptService, starterPlayerScripts } do
193
+ for _, child in container:GetChildren() do
194
+ if child.Name == entry.Name or child.Name == "StudioBridgePlaytestClient" then
195
+ child:Destroy()
196
+ end
197
+ end
198
+ end
199
+
200
+ entry.Parent = serverScriptService
201
+ if clientEntry then
202
+ clientEntry.Parent = starterPlayerScripts
203
+ end
204
+ return planted
205
+ end
206
+
108
207
  return StartPlaytestAction