@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
@@ -1,44 +1,26 @@
1
1
  --!strict
2
2
  --[[
3
- Entry point for the studio-bridge plugin. Wires the modules under Shared/ to
4
- Roblox services and boots in one of two modes:
3
+ Entry point for the studio-bridge plugin. Starts StudioBridgeClient in one of
4
+ two modes:
5
5
 
6
6
  - Ephemeral: the CLI substituted the build constants below, so it connects
7
7
  straight to that port and session.
8
8
  - Persistent: the constants are left empty, so it scans for a
9
- running bridge with DiscoveryStateMachine and reconnects whenever it drops.
9
+ running bridge and reconnects whenever it drops.
10
10
 
11
11
  The plugin cannot use the Nevermore loader: rojo builds this template on its
12
12
  own from the published CLI package, without any node_modules.
13
13
  ]]
14
14
 
15
- local HttpService = game:GetService("HttpService")
16
- local LogService = game:GetService("LogService")
17
- local Players = game:GetService("Players")
18
15
  local RunService = game:GetService("RunService")
19
16
 
20
- local ActionRouter = require(script.Parent.Shared.ActionRouter)
21
- local BuiltInActions = require(script.Parent.Shared.BuiltInActions)
22
- local DiscoveryStateMachine = require(script.Parent.Shared.DiscoveryStateMachine)
23
- local MessageBuffer = require(script.Parent.Shared.MessageBuffer)
24
- local StudioBridgeConnection = require(script.Parent.Shared.StudioBridgeConnection)
25
- local StudioBridgeLogUtils = require(script.Parent.Shared.StudioBridgeLogUtils)
17
+ local StudioBridgeClient = require(script.Parent.Shared.StudioBridgeClient)
26
18
  local StudioBridgeSessionUtils = require(script.Parent.Shared.StudioBridgeSessionUtils)
27
19
 
28
20
  local PORT = "{{PORT}}"
29
21
  local SESSION_ID = "{{SESSION_ID}}"
30
22
  local IS_EPHEMERAL = ("{{EPHEMERAL}}" :: string) == "true"
31
23
 
32
- local LOG_BUFFER_CAPACITY = 1000
33
- local POLL_INTERVAL_SEC = 2
34
-
35
- local LOG_LEVELS: { [Enum.MessageType]: string } = {
36
- [Enum.MessageType.MessageOutput] = "Print",
37
- [Enum.MessageType.MessageInfo] = "Info",
38
- [Enum.MessageType.MessageWarning] = "Warning",
39
- [Enum.MessageType.MessageError] = "Error",
40
- }
41
-
42
24
  -- Playtests load a plugin instance into their server and client, but only when
43
25
  -- Studio's "load user plugins on playtest" setting is on. Those instances cannot
44
26
  -- make the HTTP requests port discovery needs, so they only connect in a
@@ -55,283 +37,25 @@ if not RunService:IsStudio() or (RunService:IsRunning() and not playtestArgs) th
55
37
  return
56
38
  end
57
39
 
58
- local function createNonce(): string
59
- return string.lower(string.sub(HttpService:GenerateGUID(false), 1, 4))
60
- end
61
-
62
- local context: StudioBridgeSessionUtils.PluginContext = StudioBridgeSessionUtils.getContext()
63
- local nonceOk, nonce = pcall(StudioBridgeSessionUtils.getOrCreateNonce, game:GetService("CoreGui"), createNonce)
64
- if not nonceOk then
65
- nonce = createNonce()
66
- end
67
-
68
- -- Read on every connect: Studio can load plugins before it names the DataModel after the place
69
- local function getInstanceId(): string
70
- if playtestArgs then
71
- return playtestArgs.instanceId
40
+ -- A plugin that loads into the playtest's server replaces the bridge planted for it, which can only run code compiled before the playtest
41
+ if playtestArgs and RunService:IsServer() then
42
+ local planted =
43
+ StudioBridgeSessionUtils.findPlantedBridge(game:GetService("ServerScriptService"), playtestArgs.playtestId)
44
+ if planted then
45
+ planted:Destroy()
72
46
  end
73
- return StudioBridgeSessionUtils.getInstanceId(game.Name, nonce)
74
- end
75
- local playtestPort = if playtestArgs then playtestArgs.port else nil
76
-
77
- local function getPlayerName(): string?
78
- local player = if context == "client" then Players.LocalPlayer else nil
79
- return if player then player.Name else nil
80
- end
81
-
82
- -- The clients of a multiplayer test share an instance id, so their session ids name their player
83
- local function getSessionId(): string
84
- local isMultiplayerClient = playtestArgs and playtestArgs.multiplayer and context == "client"
85
- return StudioBridgeSessionUtils.getSessionId(
86
- getInstanceId(),
87
- context,
88
- if isMultiplayerClient then getPlayerName() else nil
89
- )
90
47
  end
91
48
 
92
- local router = ActionRouter.new()
93
- local logBuffer = MessageBuffer.new(LOG_BUFFER_CAPACITY)
94
- local currentConnection: StudioBridgeConnection.StudioBridgeConnection? = nil
95
- local currentWebSocket: any = nil
96
- local discovery: DiscoveryStateMachine.DiscoveryStateMachine? = nil
97
- local pollThread: thread? = nil
98
- local nameConnection: RBXScriptConnection? = nil
99
- local isUnloaded = false
100
-
101
49
  local pngOk, png = pcall(function()
102
50
  return (require :: any)(script.Parent.Vendor.png)
103
51
  end)
104
- if pngOk then
105
- router:SetVendorPng(png)
106
- end
107
-
108
- BuiltInActions.register(router, function(message)
109
- if currentConnection then
110
- currentConnection:Send(message)
111
- end
112
- end, logBuffer)
113
-
114
- -- Hybrid clock: the wall clock at load, then os.clock() deltas, which are cheaper per entry
115
- local clockBase = os.clock()
116
- local timeBase = DateTime.now().UnixTimestampMillis / 1000
117
-
118
- local logConnection = LogService.MessageOut:Connect(function(message, messageType)
119
- if string.sub(message, 1, #StudioBridgeLogUtils.PREFIX) == StudioBridgeLogUtils.PREFIX then
120
- return
121
- end
122
-
123
- logBuffer:Push({
124
- level = LOG_LEVELS[messageType] or "Print",
125
- body = message,
126
- timestamp = timeBase + (os.clock() - clockBase),
127
- })
128
- end)
129
52
 
130
- local function openConnection(webSocket: any, sessionId: string, label: string)
131
- if isUnloaded then
132
- return
133
- end
134
-
135
- if currentConnection then
136
- currentConnection:Destroy()
137
- end
138
-
139
- currentConnection = StudioBridgeConnection.new({
140
- webSocket = webSocket,
141
- router = router,
142
- sessionId = sessionId,
143
- registerPayload = StudioBridgeSessionUtils.createRegisterPayload(
144
- getInstanceId(),
145
- context,
146
- StudioBridgeSessionUtils.getLaunchId(workspace),
147
- getPlayerName()
148
- ),
149
- })
150
-
151
- StudioBridgeLogUtils.logVerbose(`Connected to {label} as {sessionId}`)
152
- end
153
-
154
- local function createWebSocket(url: string): (boolean, any)
155
- local ok, webSocket = pcall(function()
156
- return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = url })
157
- end)
158
- if ok then
159
- currentWebSocket = webSocket
160
- end
161
- return ok, webSocket
162
- end
53
+ local stop = StudioBridgeClient.start({
54
+ ephemeral = if IS_EPHEMERAL then { port = PORT, sessionId = SESSION_ID } else nil,
55
+ playtestArgs = playtestArgs,
56
+ vendorPng = if pngOk then png else nil,
57
+ })
163
58
 
164
59
  -- Reloading the plugin destroys this script, but the socket callbacks and the
165
60
  -- poll loop outlive it, leaving the old instance connected beside the new one
166
- plugin.Unloading:Connect(function()
167
- isUnloaded = true
168
- logConnection:Disconnect()
169
- if nameConnection then
170
- nameConnection:Disconnect()
171
- nameConnection = nil
172
- end
173
-
174
- if pollThread then
175
- task.cancel(pollThread)
176
- pollThread = nil
177
- end
178
- if discovery then
179
- discovery:Stop()
180
- end
181
- if currentConnection then
182
- currentConnection:Destroy()
183
- currentConnection = nil
184
- end
185
- if currentWebSocket then
186
- pcall(function()
187
- currentWebSocket:Close()
188
- end)
189
- currentWebSocket = nil
190
- end
191
-
192
- router:Destroy()
193
- end)
194
-
195
- local function warnOnError(webSocket: any)
196
- webSocket.Error:Connect(function(status, err)
197
- StudioBridgeLogUtils.logVerbose(`WebSocket error ({status}): {err}`)
198
- end)
199
- end
200
-
201
- --[[
202
- Probes every port's /health endpoint in parallel and returns the first port
203
- to answer, or nil once all fail or the timeout passes. The caller is resumed
204
- with task.defer so it has reached coroutine.yield() first.
205
- ]]
206
- local function scanPortsAsync(ports: { number }, timeoutSec: number): (number?, string?)
207
- local callerThread = coroutine.running()
208
- local foundPort: number? = nil
209
- local foundBody: string? = nil
210
- local remaining = #ports
211
- local settled = false
212
-
213
- local function settle()
214
- if not settled then
215
- settled = true
216
- task.defer(callerThread)
217
- end
218
- end
219
-
220
- local threads: { thread } = {}
221
- for _, port in ports do
222
- table.insert(
223
- threads,
224
- task.spawn(function()
225
- local ok, body = pcall(HttpService.GetAsync, HttpService, `http://localhost:{port}/health`)
226
- if ok and not settled then
227
- foundPort = port
228
- foundBody = body
229
- settle()
230
- return
231
- end
232
-
233
- remaining -= 1
234
- if remaining <= 0 then
235
- settle()
236
- end
237
- end)
238
- )
239
- end
240
- table.insert(threads, task.delay(timeoutSec, settle))
241
-
242
- coroutine.yield()
243
-
244
- for _, thread in threads do
245
- pcall(task.cancel, thread)
246
- end
247
-
248
- return foundPort, foundBody
249
- end
250
-
251
- if IS_EPHEMERAL then
252
- -- Every open Studio loads this plugin, but only the one launched for this run
253
- -- has the session id stamped on its place, which may load after the plugin
254
- local function connectEphemeral()
255
- if StudioBridgeSessionUtils.getLaunchId(workspace) ~= SESSION_ID or currentWebSocket then
256
- return
257
- end
258
-
259
- local ok, webSocket = createWebSocket(`ws://localhost:{PORT}/{SESSION_ID}`)
260
- if not (ok and webSocket) then
261
- StudioBridgeLogUtils.warn("Failed to create WebSocket client")
262
- return
263
- end
264
-
265
- webSocket.Opened:Connect(function()
266
- openConnection(webSocket, SESSION_ID, `localhost:{PORT} (ephemeral)`)
267
- end)
268
- warnOnError(webSocket)
269
- end
270
-
271
- workspace:GetAttributeChangedSignal("StudioBridgeSessionId"):Connect(connectEphemeral)
272
- connectEphemeral()
273
- return
274
- end
275
-
276
- -- A multiplayer client's session id names its player, so wait for one
277
- if playtestArgs and playtestArgs.multiplayer and context == "client" then
278
- while not Players.LocalPlayer do
279
- Players:GetPropertyChangedSignal("LocalPlayer"):Wait()
280
- end
281
- end
282
-
283
- local stateMachine: DiscoveryStateMachine.DiscoveryStateMachine
284
- stateMachine = DiscoveryStateMachine.new(nil, {
285
- scanPortsAsync = if playtestPort
286
- then function()
287
- return playtestPort, nil
288
- end
289
- else scanPortsAsync,
290
- connectWebSocket = createWebSocket,
291
- onStateChange = function(oldState, newState)
292
- if oldState == "idle" and newState == "searching" then
293
- StudioBridgeLogUtils.logVerbose("Searching for host on ports 38741-38744...")
294
- end
295
- end,
296
- onConnected = function(webSocket, port)
297
- webSocket.Opened:Connect(function()
298
- openConnection(webSocket, getSessionId(), `localhost:{port}`)
299
- end)
300
- webSocket.Closed:Connect(function()
301
- stateMachine:HandleDisconnect("closed")
302
- end)
303
- warnOnError(webSocket)
304
- end,
305
- onDisconnected = function(reason)
306
- StudioBridgeLogUtils.logVerbose(`Disconnected ({reason}), searching...`)
307
- end,
308
- })
309
-
310
- discovery = stateMachine
311
-
312
- stateMachine:Start()
313
-
314
- -- Reconnecting re-registers under the session id for the new name. A
315
- -- playtest's id comes from its test args, so a rename leaves it unchanged.
316
- nameConnection = game:GetPropertyChangedSignal("Name"):Connect(function()
317
- if currentWebSocket and not playtestArgs then
318
- pcall(function()
319
- currentWebSocket:Close()
320
- end)
321
- end
322
- end)
323
-
324
- -- Each cycle gets a fixed time budget, which PollAsync threads through to the port scan
325
- pollThread = task.spawn(function()
326
- while true do
327
- local startTime = os.clock()
328
- stateMachine:PollAsync(POLL_INTERVAL_SEC)
329
-
330
- local remaining = POLL_INTERVAL_SEC - (os.clock() - startTime)
331
- if remaining > 0 then
332
- task.wait(remaining)
333
- else
334
- task.wait()
335
- end
336
- end
337
- end)
61
+ plugin.Unloading:Connect(stop)
@@ -1,264 +0,0 @@
1
- --!strict
2
- --[=[
3
- Runs a `playtest run` script inside the playtest's server and ends the
4
- playtest with the result, so running a script does not depend on Studio
5
- loading user plugins into playtests.
6
-
7
- The `run-playtest-script` action plants this source as a Script in the edit
8
- DataModel, with the code as a ModuleScript named `Code` under it, and starts a
9
- playtest. The playtest copies the edit DataModel, so the runner starts in its
10
- server. It only acts in the playtest it was planted for: anywhere else, such
11
- as a place saved while it was planted, it does nothing.
12
-
13
- The result crosses back through `StudioTestService:EndTest` in the same shape
14
- as an `execute` reply. Instances cannot cross DataModels, so values are
15
- marshalled here. Output capture, tracebacks and the marshaller are copies of
16
- execute.lua's, since the runner cannot require it.
17
-
18
- @class PlaytestRunner
19
- ]=]
20
-
21
- local MAX_DEPTH = 64
22
- local PLUGIN_PREFIX = "[StudioBridge]"
23
- -- Tracebacks name this module's frames the way its first line does
24
- local OWN_FRAME_PREFIX = string.match(debug.traceback(), "^([^\n]-):%d+")
25
-
26
- local PlaytestRunner = {}
27
-
28
- type LogEntry = {
29
- level: string,
30
- body: string,
31
- timestamp: number,
32
- }
33
-
34
- local LOG_LEVELS: { [Enum.MessageType]: string } = {
35
- [Enum.MessageType.MessageOutput] = "Print",
36
- [Enum.MessageType.MessageInfo] = "Info",
37
- [Enum.MessageType.MessageWarning] = "Warning",
38
- [Enum.MessageType.MessageError] = "Error",
39
- }
40
-
41
- --[=[
42
- Runs the `Code` module under `runner` and ends the playtest with the result,
43
- when this playtest is the one `runner` was planted for.
44
- ]=]
45
- function PlaytestRunner.runAsync(runner: Instance, studioTestService: any, players: Players)
46
- local argsOk, testArgs = pcall(function()
47
- return studioTestService:GetTestArgs()
48
- end)
49
- if not argsOk or type(testArgs) ~= "table" or testArgs.studioBridgeRunnerId ~= runner:GetAttribute("RunnerId") then
50
- return
51
- end
52
-
53
- local hasEnded = false
54
- local captured: { LogEntry } = {}
55
- local function endTest(result: { [string]: any })
56
- if hasEnded then
57
- return
58
- end
59
- hasEnded = true
60
- studioTestService:EndTest(result)
61
- end
62
-
63
- local timeoutSec = testArgs.studioBridgeTimeoutSec
64
- if type(timeoutSec) == "number" then
65
- task.delay(timeoutSec, function()
66
- endTest({
67
- success = false,
68
- error = `Script timed out after {timeoutSec}s`,
69
- code = "SCRIPT_TIMEOUT",
70
- output = captured,
71
- })
72
- end)
73
- end
74
-
75
- -- The plugin path waits for the client to connect, so match it in play mode
76
- if testArgs.studioBridgeWaitForPlayer and #players:GetPlayers() == 0 then
77
- players.PlayerAdded:Wait()
78
- end
79
-
80
- local scriptArgs = if type(testArgs.studioBridgeScriptArgs) == "table" then testArgs.studioBridgeScriptArgs else {}
81
- endTest(PlaytestRunner.run(runner:FindFirstChild("Code"), captured, scriptArgs))
82
- end
83
-
84
- --[=[
85
- Runs a code module with `args`, collecting everything logged while it runs
86
- into `captured`, and returns the result in the shape of an `execute` reply.
87
- ]=]
88
- function PlaytestRunner.run(codeModule: Instance?, captured: { LogEntry }, args: { any }?): { [string]: any }
89
- if not (codeModule and codeModule:IsA("ModuleScript")) then
90
- return { success = false, error = "Runner has no Code module", code = "SCRIPT_LOAD_ERROR", output = captured }
91
- end
92
-
93
- local requireOk, fn = pcall(require, codeModule)
94
- if not requireOk or type(fn) ~= "function" then
95
- return {
96
- success = false,
97
- error = if requireOk then "Code module did not return a function" else tostring(fn),
98
- code = "SCRIPT_LOAD_ERROR",
99
- output = captured,
100
- }
101
- end
102
-
103
- local logConnection = PlaytestRunner._captureOutput(captured)
104
- local returned = table.pack(xpcall(fn, function(err)
105
- return {
106
- message = tostring(err),
107
- traceback = PlaytestRunner._dropOwnFrames(debug.traceback(nil, 2)),
108
- }
109
- end, table.unpack(args or {})))
110
-
111
- -- MessageOut can fire a frame late, after the code's last print
112
- task.wait()
113
- logConnection:Disconnect()
114
-
115
- if not returned[1] then
116
- return {
117
- success = false,
118
- error = returned[2].message,
119
- traceback = returned[2].traceback,
120
- code = "SCRIPT_RUNTIME_ERROR",
121
- output = captured,
122
- }
123
- end
124
-
125
- return {
126
- success = true,
127
- output = captured,
128
- returnValues = PlaytestRunner._serializeReturnValues(returned),
129
- }
130
- end
131
-
132
- --[[
133
- Drops the frames of this module's own wrapper from a traceback, leaving only
134
- the script's.
135
- ]]
136
- function PlaytestRunner._dropOwnFrames(traceback: string): string
137
- if not OWN_FRAME_PREFIX then
138
- return traceback
139
- end
140
-
141
- local lines = {}
142
- for _, line in string.split(traceback, "\n") do
143
- if string.sub(line, 1, #OWN_FRAME_PREFIX + 1) ~= `{OWN_FRAME_PREFIX}:` then
144
- table.insert(lines, line)
145
- end
146
- end
147
- return table.concat(lines, "\n")
148
- end
149
-
150
- --[[
151
- Collects every message logged until the connection is disconnected, other
152
- than the plugin's own.
153
- ]]
154
- function PlaytestRunner._captureOutput(captured: { LogEntry }): RBXScriptConnection
155
- return game:GetService("LogService").MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
156
- if string.sub(message, 1, #PLUGIN_PREFIX) == PLUGIN_PREFIX then
157
- return
158
- end
159
-
160
- table.insert(captured, {
161
- level = LOG_LEVELS[messageType] or "Print",
162
- body = message,
163
- timestamp = os.clock(),
164
- })
165
- end)
166
- end
167
-
168
- --[[
169
- Marshals what a script returned, in order, skipping the pcall status. A nil
170
- becomes an explicit marker, since an array cannot hold a hole.
171
- ]]
172
- function PlaytestRunner._serializeReturnValues(packed: { n: number, [number]: any }): { any }
173
- local returnValues = {}
174
- for index = 2, packed.n do
175
- local serialized = PlaytestRunner._serializeValue(packed[index], 1, {})
176
- if serialized == nil then
177
- serialized = { type = "Nil" }
178
- end
179
- returnValues[index - 1] = serialized
180
- end
181
- return returnValues
182
- end
183
-
184
- --[[
185
- Produces the SerializedReturnValue shapes declared in web-socket-protocol.ts.
186
- Copied from execute.lua; keep the two in step.
187
- ]]
188
- function PlaytestRunner._serializeValue(value: any, depth: number, seen: { [any]: boolean }): any
189
- local valueType = typeof(value)
190
-
191
- if valueType == "nil" or valueType == "string" or valueType == "boolean" then
192
- return value
193
- elseif valueType == "number" then
194
- if value ~= value or value == math.huge or value == -math.huge then
195
- return PlaytestRunner._unsupported("number", tostring(value))
196
- end
197
- return value
198
- elseif valueType == "table" then
199
- if depth > MAX_DEPTH then
200
- return PlaytestRunner._unsupported("table", "<max depth exceeded>")
201
- end
202
- if seen[value] then
203
- return PlaytestRunner._unsupported("table", "<cycle>")
204
- end
205
-
206
- seen[value] = true
207
- local serialized = PlaytestRunner._serializeTable(value, depth, seen)
208
- seen[value] = nil
209
- return serialized
210
- elseif valueType == "Vector3" then
211
- return { type = "Vector3", value = { value.X, value.Y, value.Z } }
212
- elseif valueType == "Vector2" then
213
- return { type = "Vector2", value = { value.X, value.Y } }
214
- elseif valueType == "CFrame" then
215
- return { type = "CFrame", value = { value:GetComponents() } }
216
- elseif valueType == "Color3" then
217
- return { type = "Color3", value = { value.R, value.G, value.B } }
218
- elseif valueType == "UDim2" then
219
- return { type = "UDim2", value = { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset } }
220
- elseif valueType == "UDim" then
221
- return { type = "UDim", value = { value.Scale, value.Offset } }
222
- elseif valueType == "BrickColor" then
223
- return { type = "BrickColor", name = value.Name, value = value.Number }
224
- elseif valueType == "EnumItem" then
225
- return { type = "EnumItem", enum = tostring(value.EnumType), name = value.Name, value = value.Value }
226
- elseif valueType == "Instance" then
227
- return { type = "Instance", className = value.ClassName, path = value:GetFullName() }
228
- else
229
- return PlaytestRunner._unsupported(valueType, tostring(value))
230
- end
231
- end
232
-
233
- function PlaytestRunner._serializeTable(value: { [any]: any }, depth: number, seen: { [any]: boolean }): any
234
- local count = 0
235
- for _ in value do
236
- count += 1
237
- end
238
-
239
- if count == #value then
240
- local array = {}
241
- for index = 1, count do
242
- array[index] = PlaytestRunner._serializeValue(value[index], depth + 1, seen)
243
- end
244
- return array
245
- end
246
-
247
- local map: { [string]: any } = {}
248
- for key, item in value do
249
- map[if type(key) == "string" then key else tostring(key)] =
250
- PlaytestRunner._serializeValue(item, depth + 1, seen)
251
- end
252
- return map
253
- end
254
-
255
- function PlaytestRunner._unsupported(typeName: string, text: string): { [string]: any }
256
- return { type = "Unsupported", typeName = typeName, toString = text }
257
- end
258
-
259
- -- Planted as a Script it runs; in the test place it is a ModuleScript specs require
260
- if script:IsA("Script") then
261
- task.spawn(PlaytestRunner.runAsync, script, game:GetService("StudioTestService"), game:GetService("Players"))
262
- end
263
-
264
- return PlaytestRunner
@@ -1,71 +0,0 @@
1
- --!strict
2
- --[[
3
- @class playtest-runner.spec.lua
4
- ]]
5
-
6
- local require = require(script.Parent.loader).load(script)
7
-
8
- local Jest = require("Jest")
9
- local PlaytestRunner = require("playtest-runner")
10
-
11
- local describe = Jest.Globals.describe
12
- local expect = Jest.Globals.expect
13
- local it = Jest.Globals.it
14
-
15
- local function setup(testArgs: any)
16
- local ended = {}
17
- local runner = Instance.new("Folder")
18
- runner:SetAttribute("RunnerId", "runner-1")
19
-
20
- local studioTestService = {
21
- GetTestArgs = function()
22
- return testArgs
23
- end,
24
- EndTest = function(_self, value)
25
- table.insert(ended, value)
26
- end,
27
- }
28
-
29
- return {
30
- runner = runner,
31
- studioTestService = studioTestService,
32
- ended = ended,
33
- Destroy = function()
34
- runner:Destroy()
35
- end,
36
- }
37
- end
38
-
39
- describe("PlaytestRunner.runAsync()", function()
40
- it("should do nothing in a playtest it was not planted for", function()
41
- local controller = setup({ studioBridgeRunnerId = "someone-else" })
42
-
43
- PlaytestRunner.runAsync(controller.runner, controller.studioTestService, game:GetService("Players"))
44
-
45
- expect(controller.ended).toEqual({})
46
- controller:Destroy()
47
- end)
48
-
49
- it("should end the playtest with a load error when the code module is missing", function()
50
- local controller = setup({ studioBridgeRunnerId = "runner-1" })
51
-
52
- PlaytestRunner.runAsync(controller.runner, controller.studioTestService, game:GetService("Players"))
53
-
54
- expect(#controller.ended).toEqual(1)
55
- expect(controller.ended[1].success).toEqual(false)
56
- expect(controller.ended[1].code).toEqual("SCRIPT_LOAD_ERROR")
57
- controller:Destroy()
58
- end)
59
- end)
60
-
61
- describe("PlaytestRunner._serializeReturnValues()", function()
62
- it("should keep a nil in its position", function()
63
- local packed = table.pack(true, 1, nil, Vector3.new(1, 2, 3))
64
-
65
- expect(PlaytestRunner._serializeReturnValues(packed)).toEqual({
66
- 1,
67
- { type = "Nil" },
68
- { type = "Vector3", value = { 1, 2, 3 } },
69
- })
70
- end)
71
- end)