@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.
- package/CHANGELOG.md +21 -0
- package/README.md +36 -8
- package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
- package/dist/src/bridge/bridge-connection.js +1 -0
- package/dist/src/bridge/bridge-connection.js.map +1 -1
- package/dist/src/bridge/bridge-session.d.ts +11 -16
- package/dist/src/bridge/bridge-session.d.ts.map +1 -1
- package/dist/src/bridge/bridge-session.js +37 -29
- package/dist/src/bridge/bridge-session.js.map +1 -1
- package/dist/src/bridge/bridge-session.test.js +35 -0
- package/dist/src/bridge/bridge-session.test.js.map +1 -1
- package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
- package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
- package/dist/src/bridge/internal/bridge-host.js +1 -0
- package/dist/src/bridge/internal/bridge-host.js.map +1 -1
- package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
- package/dist/src/bridge/internal/host-protocol.js +23 -10
- package/dist/src/bridge/internal/host-protocol.js.map +1 -1
- package/dist/src/bridge/types.d.ts +5 -2
- package/dist/src/bridge/types.d.ts.map +1 -1
- package/dist/src/bridge/types.js.map +1 -1
- package/dist/src/commands/console/exec/exec.d.ts +13 -3
- package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
- package/dist/src/commands/console/exec/exec.js +82 -7
- package/dist/src/commands/console/exec/exec.js.map +1 -1
- package/dist/src/commands/console/exec/exec.test.js +63 -1
- package/dist/src/commands/console/exec/exec.test.js.map +1 -1
- package/dist/src/commands/framework/action-loader.test.js +1 -1
- package/dist/src/commands/framework/action-loader.test.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.js +15 -93
- package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
- package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
- package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.js +54 -74
- package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
- package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
- package/dist/src/commands/playtest/run/run.d.ts +9 -7
- package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
- package/dist/src/commands/playtest/run/run.js +33 -38
- package/dist/src/commands/playtest/run/run.js.map +1 -1
- package/dist/src/commands/process/close/close.d.ts +4 -0
- package/dist/src/commands/process/close/close.d.ts.map +1 -1
- package/dist/src/commands/process/close/close.js +54 -4
- package/dist/src/commands/process/close/close.js.map +1 -1
- package/dist/src/commands/process/close/close.test.js +36 -3
- package/dist/src/commands/process/close/close.test.js.map +1 -1
- package/dist/src/commands/process/info/info.d.ts +15 -2
- package/dist/src/commands/process/info/info.d.ts.map +1 -1
- package/dist/src/commands/process/info/info.js +44 -4
- package/dist/src/commands/process/info/info.js.map +1 -1
- package/dist/src/commands/process/info/info.test.js +48 -0
- package/dist/src/commands/process/info/info.test.js.map +1 -1
- package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
- package/dist/src/commands/process/launch/launch.js +22 -3
- package/dist/src/commands/process/launch/launch.js.map +1 -1
- package/dist/src/commands/process/launch/launch.test.js +27 -5
- package/dist/src/commands/process/launch/launch.test.js.map +1 -1
- package/dist/src/commands/process/list/list.d.ts +49 -6
- package/dist/src/commands/process/list/list.d.ts.map +1 -1
- package/dist/src/commands/process/list/list.js +185 -18
- package/dist/src/commands/process/list/list.js.map +1 -1
- package/dist/src/commands/process/list/list.test.js +92 -1
- package/dist/src/commands/process/list/list.test.js.map +1 -1
- package/dist/src/commands/process/run/run.d.ts +1 -1
- package/dist/src/commands/process/run/run.d.ts.map +1 -1
- package/dist/src/commands/process/run/run.js +3 -3
- package/dist/src/commands/process/run/run.js.map +1 -1
- package/dist/src/docker/docker-delegator.js +1 -1
- package/dist/src/docker/docker-delegator.js.map +1 -1
- package/dist/src/docker/docker-delegator.test.js +1 -1
- package/dist/src/docker/docker-delegator.test.js.map +1 -1
- package/dist/src/plugin/playtest-bridge.d.ts +36 -0
- package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.js +65 -0
- package/dist/src/plugin/playtest-bridge.js.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.js +35 -0
- package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
- package/dist/src/process/studio-launch-registry.d.ts +14 -0
- package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.js +37 -0
- package/dist/src/process/studio-launch-registry.js.map +1 -0
- package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
- package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.test.js +38 -0
- package/dist/src/process/studio-launch-registry.test.js.map +1 -0
- package/dist/src/process/studio-process-closer.d.ts +3 -1
- package/dist/src/process/studio-process-closer.d.ts.map +1 -1
- package/dist/src/process/studio-process-closer.js +29 -11
- package/dist/src/process/studio-process-closer.js.map +1 -1
- package/dist/src/process/studio-process-identity.d.ts +10 -0
- package/dist/src/process/studio-process-identity.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.js +63 -0
- package/dist/src/process/studio-process-identity.js.map +1 -0
- package/dist/src/process/studio-process-identity.test.d.ts +2 -0
- package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.test.js +30 -0
- package/dist/src/process/studio-process-identity.test.js.map +1 -0
- package/dist/src/roblox-mcp/index.d.ts +3 -0
- package/dist/src/roblox-mcp/index.d.ts.map +1 -0
- package/dist/src/roblox-mcp/index.js +3 -0
- package/dist/src/roblox-mcp/index.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
- package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
- package/dist/src/server/web-socket-protocol.d.ts +50 -15
- package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
- package/dist/src/server/web-socket-protocol.js +63 -13
- package/dist/src/server/web-socket-protocol.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/playtest-bridge.md +21 -0
- package/package.json +2 -2
- package/src/bridge/bridge-connection.ts +1 -0
- package/src/bridge/bridge-session.test.ts +51 -0
- package/src/bridge/bridge-session.ts +66 -47
- package/src/bridge/internal/bridge-host.ts +3 -0
- package/src/bridge/internal/host-protocol.ts +22 -9
- package/src/bridge/types.ts +5 -0
- package/src/commands/console/exec/exec.test.ts +94 -0
- package/src/commands/console/exec/exec.ts +130 -16
- package/src/commands/console/exec/execute.lua +147 -5
- package/src/commands/console/exec/execute.spec.lua +22 -2
- package/src/commands/framework/action-loader.test.ts +1 -1
- package/src/commands/playtest/player/playtest-players.lua +127 -0
- package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
- package/src/commands/playtest/player/playtest-players.test.ts +40 -21
- package/src/commands/playtest/player/playtest-players.ts +30 -110
- package/src/commands/playtest/playtest-sessions.test.ts +116 -14
- package/src/commands/playtest/playtest-sessions.ts +77 -88
- package/src/commands/playtest/run/run.ts +44 -58
- package/src/commands/playtest/start/start-playtest.lua +105 -6
- package/src/commands/process/close/close.test.ts +52 -3
- package/src/commands/process/close/close.ts +78 -4
- package/src/commands/process/info/info.test.ts +76 -0
- package/src/commands/process/info/info.ts +74 -6
- package/src/commands/process/launch/launch.test.ts +33 -5
- package/src/commands/process/launch/launch.ts +41 -3
- package/src/commands/process/list/list.test.ts +130 -1
- package/src/commands/process/list/list.ts +290 -22
- package/src/commands/process/run/run.ts +4 -4
- package/src/docker/docker-delegator.test.ts +1 -1
- package/src/docker/docker-delegator.ts +1 -1
- package/src/plugin/playtest-bridge.test.ts +53 -0
- package/src/plugin/playtest-bridge.ts +98 -0
- package/src/process/studio-launch-registry.test.ts +56 -0
- package/src/process/studio-launch-registry.ts +63 -0
- package/src/process/studio-process-closer.ts +36 -11
- package/src/process/studio-process-identity.test.ts +45 -0
- package/src/process/studio-process-identity.ts +90 -0
- package/src/roblox-mcp/index.ts +17 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
- package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
- package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
- package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
- package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
- package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
- package/src/roblox-mcp/studio/studio-session.ts +279 -0
- package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
- package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
- package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
- package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
- package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
- package/src/server/web-socket-protocol.ts +119 -29
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
- package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
- package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
- package/src/commands/playtest/run/playtest-runner.luau +0 -264
- package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
- package/src/commands/playtest/run/run-playtest-script.lua +0 -139
- package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Compiles source by looking it up among ModuleScripts compiled ahead of time,
|
|
4
|
+
for the bridge planted into a playtest: an ordinary Script there has neither
|
|
5
|
+
`loadstring` nor the permission to set a ModuleScript's source.
|
|
6
|
+
|
|
7
|
+
The edit session plants one ModuleScript per source the playtest may run (the
|
|
8
|
+
actions and any script `playtest run` sends), each returning
|
|
9
|
+
`{ fn = function(...) <source> end, source = <source> }`. A source that was
|
|
10
|
+
not planted cannot run there.
|
|
11
|
+
|
|
12
|
+
@class PrecompiledSources
|
|
13
|
+
]=]
|
|
14
|
+
|
|
15
|
+
local PrecompiledSources = {}
|
|
16
|
+
|
|
17
|
+
export type Entry = {
|
|
18
|
+
fn: (...any) -> ...any,
|
|
19
|
+
source: string,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
--[=[
|
|
23
|
+
Returns a compiler for the sources planted under `folder`, for
|
|
24
|
+
`ActionRouter:SetCompiler`.
|
|
25
|
+
]=]
|
|
26
|
+
function PrecompiledSources.createCompiler(folder: Instance): (source: string, chunkName: string?) -> (any, string?)
|
|
27
|
+
local bySource: { [string]: (...any) -> ...any } = {}
|
|
28
|
+
for _, child in folder:GetChildren() do
|
|
29
|
+
if child:IsA("ModuleScript") then
|
|
30
|
+
local ok, entry = pcall(require, child)
|
|
31
|
+
if ok and type(entry) == "table" and type(entry.fn) == "function" and type(entry.source) == "string" then
|
|
32
|
+
bySource[entry.source] = entry.fn
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return function(source: string, _chunkName: string?): (any, string?)
|
|
38
|
+
local fn = bySource[source]
|
|
39
|
+
if fn then
|
|
40
|
+
return fn, nil
|
|
41
|
+
end
|
|
42
|
+
return nil,
|
|
43
|
+
'This playtest session runs without plugin permissions, so it can only run code planted when the playtest started. Turn on Studio\'s "Load user plugins on playtest" setting to run any code in it.'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return PrecompiledSources
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
@class PrecompiledSources.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PrecompiledSources = require("PrecompiledSources")
|
|
10
|
+
|
|
11
|
+
local describe = Jest.Globals.describe
|
|
12
|
+
local expect = Jest.Globals.expect
|
|
13
|
+
local it = Jest.Globals.it
|
|
14
|
+
|
|
15
|
+
local function plant(folder: Instance, name: string, moduleSource: string)
|
|
16
|
+
local moduleScript = Instance.new("ModuleScript")
|
|
17
|
+
moduleScript.Name = name
|
|
18
|
+
moduleScript.Source = moduleSource
|
|
19
|
+
moduleScript.Parent = folder
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe("PrecompiledSources.createCompiler()", function()
|
|
23
|
+
it("should compile a planted source to its function", function()
|
|
24
|
+
local folder = Instance.new("Folder")
|
|
25
|
+
plant(folder, "1", 'return { fn = function(...) return 1 + 2\nend, source = "return 1 + 2" }')
|
|
26
|
+
|
|
27
|
+
local fn = PrecompiledSources.createCompiler(folder)("return 1 + 2")
|
|
28
|
+
|
|
29
|
+
expect(fn()).toEqual(3)
|
|
30
|
+
end)
|
|
31
|
+
|
|
32
|
+
it("should pass arguments through", function()
|
|
33
|
+
local folder = Instance.new("Folder")
|
|
34
|
+
plant(folder, "1", 'return { fn = function(...) return ...\nend, source = "return ..." }')
|
|
35
|
+
|
|
36
|
+
local fn = PrecompiledSources.createCompiler(folder)("return ...")
|
|
37
|
+
|
|
38
|
+
expect(fn("a")).toEqual("a")
|
|
39
|
+
end)
|
|
40
|
+
|
|
41
|
+
it("should explain that a source that was not planted cannot run", function()
|
|
42
|
+
local fn, err = PrecompiledSources.createCompiler(Instance.new("Folder"))("print(1)")
|
|
43
|
+
|
|
44
|
+
expect(fn).toBeNil()
|
|
45
|
+
expect(err).toContain("Load user plugins on playtest")
|
|
46
|
+
end)
|
|
47
|
+
|
|
48
|
+
it("should skip a planted module that does not load", function()
|
|
49
|
+
local folder = Instance.new("Folder")
|
|
50
|
+
plant(folder, "broken", "return {")
|
|
51
|
+
plant(folder, "1", 'return { fn = function(...) return 1\nend, source = "return 1" }')
|
|
52
|
+
|
|
53
|
+
local fn = PrecompiledSources.createCompiler(folder)("return 1")
|
|
54
|
+
|
|
55
|
+
expect(fn()).toEqual(1)
|
|
56
|
+
end)
|
|
57
|
+
end)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
The studio-bridge client: an action router, the log buffer `queryLogs` reads,
|
|
4
|
+
and the WebSocket to the bridge. Two entry points start it:
|
|
5
|
+
|
|
6
|
+
- The plugin (StudioBridgePlugin.server.lua), in every Studio DataModel it
|
|
7
|
+
loads into.
|
|
8
|
+
- The bridge planted into a playtest's server (StudioBridgePlaytest.server.lua),
|
|
9
|
+
an ordinary Script that runs whether or not Studio loads user plugins into
|
|
10
|
+
playtests. It has no plugin permissions, so it cannot compile code; it runs
|
|
11
|
+
the sources compiled for it ahead of time through the `compile` option.
|
|
12
|
+
|
|
13
|
+
It connects one of three ways:
|
|
14
|
+
|
|
15
|
+
- Ephemeral: `ephemeral` names the port and session id the CLI built the
|
|
16
|
+
plugin for.
|
|
17
|
+
- Playtest: `playtestArgs` names the bridge port, handed over through the
|
|
18
|
+
StudioTestService test args, since a playtest cannot make the HTTP requests
|
|
19
|
+
port discovery needs.
|
|
20
|
+
- Persistent: scans for a running bridge with DiscoveryStateMachine and
|
|
21
|
+
reconnects whenever it drops.
|
|
22
|
+
|
|
23
|
+
@class StudioBridgeClient
|
|
24
|
+
]=]
|
|
25
|
+
|
|
26
|
+
local HttpService = game:GetService("HttpService")
|
|
27
|
+
local LogService = game:GetService("LogService")
|
|
28
|
+
local Players = game:GetService("Players")
|
|
29
|
+
|
|
30
|
+
local ActionRouter = require(script.Parent.ActionRouter)
|
|
31
|
+
local BuiltInActions = require(script.Parent.BuiltInActions)
|
|
32
|
+
local DiscoveryStateMachine = require(script.Parent.DiscoveryStateMachine)
|
|
33
|
+
local MessageBuffer = require(script.Parent.MessageBuffer)
|
|
34
|
+
local StudioBridgeConnection = require(script.Parent.StudioBridgeConnection)
|
|
35
|
+
local StudioBridgeLogUtils = require(script.Parent.StudioBridgeLogUtils)
|
|
36
|
+
local StudioBridgeSessionUtils = require(script.Parent.StudioBridgeSessionUtils)
|
|
37
|
+
|
|
38
|
+
local LOG_BUFFER_CAPACITY = 1000
|
|
39
|
+
local POLL_INTERVAL_SEC = 2
|
|
40
|
+
|
|
41
|
+
local LOG_LEVELS: { [Enum.MessageType]: string } = {
|
|
42
|
+
[Enum.MessageType.MessageOutput] = "Print",
|
|
43
|
+
[Enum.MessageType.MessageInfo] = "Info",
|
|
44
|
+
[Enum.MessageType.MessageWarning] = "Warning",
|
|
45
|
+
[Enum.MessageType.MessageError] = "Error",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
local StudioBridgeClient = {}
|
|
49
|
+
|
|
50
|
+
export type Options = {
|
|
51
|
+
ephemeral: { port: string, sessionId: string }?,
|
|
52
|
+
playtestArgs: StudioBridgeSessionUtils.PlaytestArgs?,
|
|
53
|
+
compile: ActionRouter.Compiler?,
|
|
54
|
+
vendorPng: any?,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
--[=[
|
|
58
|
+
Starts the client and returns a function that stops it. Socket callbacks and
|
|
59
|
+
the poll loop outlive the script that started them, so the owner must stop it
|
|
60
|
+
when it unloads.
|
|
61
|
+
]=]
|
|
62
|
+
function StudioBridgeClient.start(options: Options): () -> ()
|
|
63
|
+
local playtestArgs = options.playtestArgs
|
|
64
|
+
|
|
65
|
+
local function createNonce(): string
|
|
66
|
+
return string.lower(string.sub(HttpService:GenerateGUID(false), 1, 4))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
local context: StudioBridgeSessionUtils.PluginContext = StudioBridgeSessionUtils.getContext()
|
|
70
|
+
local nonceOk, nonce = pcall(StudioBridgeSessionUtils.getOrCreateNonce, game:GetService("CoreGui"), createNonce)
|
|
71
|
+
if not nonceOk then
|
|
72
|
+
nonce = createNonce()
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
-- Read on every connect: Studio can load plugins before it names the DataModel after the place
|
|
76
|
+
local function getInstanceId(): string
|
|
77
|
+
if playtestArgs then
|
|
78
|
+
return playtestArgs.instanceId
|
|
79
|
+
end
|
|
80
|
+
return StudioBridgeSessionUtils.getInstanceId(game.Name, nonce)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local function getPlayerName(): string?
|
|
84
|
+
local player = if context == "client" then Players.LocalPlayer else nil
|
|
85
|
+
return if player then player.Name else nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
-- The clients of a multiplayer test share an instance id, so their session ids name their player
|
|
89
|
+
local function getSessionId(): string
|
|
90
|
+
local isMultiplayerClient = playtestArgs and playtestArgs.multiplayer and context == "client"
|
|
91
|
+
return StudioBridgeSessionUtils.getSessionId(
|
|
92
|
+
getInstanceId(),
|
|
93
|
+
context,
|
|
94
|
+
if isMultiplayerClient then getPlayerName() else nil
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
local router = ActionRouter.new()
|
|
99
|
+
local logBuffer = MessageBuffer.new(LOG_BUFFER_CAPACITY)
|
|
100
|
+
local currentConnection: StudioBridgeConnection.StudioBridgeConnection? = nil
|
|
101
|
+
local currentWebSocket: any = nil
|
|
102
|
+
local discovery: DiscoveryStateMachine.DiscoveryStateMachine? = nil
|
|
103
|
+
local pollThread: thread? = nil
|
|
104
|
+
local nameConnection: RBXScriptConnection? = nil
|
|
105
|
+
local isStopped = false
|
|
106
|
+
|
|
107
|
+
if options.vendorPng then
|
|
108
|
+
router:SetVendorPng(options.vendorPng)
|
|
109
|
+
end
|
|
110
|
+
if options.compile then
|
|
111
|
+
router:SetCompiler(options.compile)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
BuiltInActions.register(router, function(message)
|
|
115
|
+
if currentConnection then
|
|
116
|
+
currentConnection:Send(message)
|
|
117
|
+
end
|
|
118
|
+
end, logBuffer)
|
|
119
|
+
|
|
120
|
+
-- Hybrid clock: the wall clock at load, then os.clock() deltas, which are cheaper per entry
|
|
121
|
+
local clockBase = os.clock()
|
|
122
|
+
local timeBase = DateTime.now().UnixTimestampMillis / 1000
|
|
123
|
+
|
|
124
|
+
local logConnection = LogService.MessageOut:Connect(function(message, messageType)
|
|
125
|
+
if string.sub(message, 1, #StudioBridgeLogUtils.PREFIX) == StudioBridgeLogUtils.PREFIX then
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
logBuffer:Push({
|
|
130
|
+
level = LOG_LEVELS[messageType] or "Print",
|
|
131
|
+
body = message,
|
|
132
|
+
timestamp = timeBase + (os.clock() - clockBase),
|
|
133
|
+
})
|
|
134
|
+
end)
|
|
135
|
+
|
|
136
|
+
local function openConnection(webSocket: any, sessionId: string, label: string)
|
|
137
|
+
if isStopped then
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if currentConnection then
|
|
142
|
+
currentConnection:Destroy()
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
currentConnection = StudioBridgeConnection.new({
|
|
146
|
+
webSocket = webSocket,
|
|
147
|
+
router = router,
|
|
148
|
+
sessionId = sessionId,
|
|
149
|
+
registerPayload = StudioBridgeSessionUtils.createRegisterPayload(
|
|
150
|
+
getInstanceId(),
|
|
151
|
+
context,
|
|
152
|
+
StudioBridgeSessionUtils.getLaunchId(workspace),
|
|
153
|
+
getPlayerName(),
|
|
154
|
+
if playtestArgs then playtestArgs.playtestId else nil
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
StudioBridgeLogUtils.logVerbose(`Connected to {label} as {sessionId}`)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
local function createWebSocket(url: string): (boolean, any)
|
|
162
|
+
local ok, webSocket = pcall(function()
|
|
163
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = url })
|
|
164
|
+
end)
|
|
165
|
+
if ok then
|
|
166
|
+
currentWebSocket = webSocket
|
|
167
|
+
end
|
|
168
|
+
return ok, webSocket
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
local function stop()
|
|
172
|
+
if isStopped then
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
isStopped = true
|
|
176
|
+
logConnection:Disconnect()
|
|
177
|
+
if nameConnection then
|
|
178
|
+
nameConnection:Disconnect()
|
|
179
|
+
nameConnection = nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if pollThread then
|
|
183
|
+
task.cancel(pollThread)
|
|
184
|
+
pollThread = nil
|
|
185
|
+
end
|
|
186
|
+
if discovery then
|
|
187
|
+
discovery:Stop()
|
|
188
|
+
end
|
|
189
|
+
if currentConnection then
|
|
190
|
+
currentConnection:Destroy()
|
|
191
|
+
currentConnection = nil
|
|
192
|
+
end
|
|
193
|
+
if currentWebSocket then
|
|
194
|
+
pcall(function()
|
|
195
|
+
currentWebSocket:Close()
|
|
196
|
+
end)
|
|
197
|
+
currentWebSocket = nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
router:Destroy()
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
local function warnOnError(webSocket: any)
|
|
204
|
+
webSocket.Error:Connect(function(status, err)
|
|
205
|
+
StudioBridgeLogUtils.logVerbose(`WebSocket error ({status}): {err}`)
|
|
206
|
+
end)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
local ephemeral = options.ephemeral
|
|
210
|
+
if ephemeral then
|
|
211
|
+
-- Every open Studio loads this plugin, but only the one launched for this run
|
|
212
|
+
-- has the session id stamped on its place, which may load after the plugin
|
|
213
|
+
local function connectEphemeral()
|
|
214
|
+
if StudioBridgeSessionUtils.getLaunchId(workspace) ~= ephemeral.sessionId or currentWebSocket then
|
|
215
|
+
return
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
local ok, webSocket = createWebSocket(`ws://localhost:{ephemeral.port}/{ephemeral.sessionId}`)
|
|
219
|
+
if not (ok and webSocket) then
|
|
220
|
+
StudioBridgeLogUtils.warn("Failed to create WebSocket client")
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
webSocket.Opened:Connect(function()
|
|
225
|
+
openConnection(webSocket, ephemeral.sessionId, `localhost:{ephemeral.port} (ephemeral)`)
|
|
226
|
+
end)
|
|
227
|
+
warnOnError(webSocket)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
nameConnection = workspace:GetAttributeChangedSignal("StudioBridgeSessionId"):Connect(connectEphemeral)
|
|
231
|
+
connectEphemeral()
|
|
232
|
+
return stop
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
-- A multiplayer client's session id names its player, so wait for one
|
|
236
|
+
if playtestArgs and playtestArgs.multiplayer and context == "client" then
|
|
237
|
+
while not Players.LocalPlayer do
|
|
238
|
+
Players:GetPropertyChangedSignal("LocalPlayer"):Wait()
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
local playtestPort = if playtestArgs then playtestArgs.port else nil
|
|
243
|
+
|
|
244
|
+
local stateMachine: DiscoveryStateMachine.DiscoveryStateMachine
|
|
245
|
+
stateMachine = DiscoveryStateMachine.new(nil, {
|
|
246
|
+
scanPortsAsync = if playtestPort
|
|
247
|
+
then function()
|
|
248
|
+
return playtestPort, nil
|
|
249
|
+
end
|
|
250
|
+
else function(ports: { number }, timeoutSec: number)
|
|
251
|
+
return StudioBridgeClient._scanPortsAsync(ports, timeoutSec)
|
|
252
|
+
end,
|
|
253
|
+
connectWebSocket = createWebSocket,
|
|
254
|
+
onStateChange = function(oldState, newState)
|
|
255
|
+
if oldState == "idle" and newState == "searching" then
|
|
256
|
+
StudioBridgeLogUtils.logVerbose("Searching for host on ports 38741-38744...")
|
|
257
|
+
end
|
|
258
|
+
end,
|
|
259
|
+
onConnected = function(webSocket, port)
|
|
260
|
+
webSocket.Opened:Connect(function()
|
|
261
|
+
openConnection(webSocket, getSessionId(), `localhost:{port}`)
|
|
262
|
+
end)
|
|
263
|
+
webSocket.Closed:Connect(function()
|
|
264
|
+
stateMachine:HandleDisconnect("closed")
|
|
265
|
+
end)
|
|
266
|
+
warnOnError(webSocket)
|
|
267
|
+
end,
|
|
268
|
+
onDisconnected = function(reason)
|
|
269
|
+
StudioBridgeLogUtils.logVerbose(`Disconnected ({reason}), searching...`)
|
|
270
|
+
end,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
discovery = stateMachine
|
|
274
|
+
|
|
275
|
+
stateMachine:Start()
|
|
276
|
+
|
|
277
|
+
-- Reconnecting re-registers under the session id for the new name. A
|
|
278
|
+
-- playtest's id comes from its test args, so a rename leaves it unchanged.
|
|
279
|
+
if not playtestArgs then
|
|
280
|
+
nameConnection = game:GetPropertyChangedSignal("Name"):Connect(function()
|
|
281
|
+
if currentWebSocket then
|
|
282
|
+
pcall(function()
|
|
283
|
+
currentWebSocket:Close()
|
|
284
|
+
end)
|
|
285
|
+
end
|
|
286
|
+
end)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
-- Each cycle gets a fixed time budget, which PollAsync threads through to the port scan
|
|
290
|
+
pollThread = task.spawn(function()
|
|
291
|
+
while true do
|
|
292
|
+
local startTime = os.clock()
|
|
293
|
+
stateMachine:PollAsync(POLL_INTERVAL_SEC)
|
|
294
|
+
|
|
295
|
+
local remaining = POLL_INTERVAL_SEC - (os.clock() - startTime)
|
|
296
|
+
if remaining > 0 then
|
|
297
|
+
task.wait(remaining)
|
|
298
|
+
else
|
|
299
|
+
task.wait()
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end)
|
|
303
|
+
|
|
304
|
+
return stop
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
--[[
|
|
308
|
+
Probes every port's /health endpoint in parallel and returns the first port
|
|
309
|
+
to answer, or nil once all fail or the timeout passes. The caller is resumed
|
|
310
|
+
with task.defer so it has reached coroutine.yield() first.
|
|
311
|
+
]]
|
|
312
|
+
function StudioBridgeClient._scanPortsAsync(ports: { number }, timeoutSec: number): (number?, string?)
|
|
313
|
+
local callerThread = coroutine.running()
|
|
314
|
+
local foundPort: number? = nil
|
|
315
|
+
local foundBody: string? = nil
|
|
316
|
+
local remaining = #ports
|
|
317
|
+
local settled = false
|
|
318
|
+
|
|
319
|
+
local function settle()
|
|
320
|
+
if not settled then
|
|
321
|
+
settled = true
|
|
322
|
+
task.defer(callerThread)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
local threads: { thread } = {}
|
|
327
|
+
for _, port in ports do
|
|
328
|
+
table.insert(
|
|
329
|
+
threads,
|
|
330
|
+
task.spawn(function()
|
|
331
|
+
local ok, body = pcall(HttpService.GetAsync, HttpService, `http://localhost:{port}/health`)
|
|
332
|
+
if ok and not settled then
|
|
333
|
+
foundPort = port
|
|
334
|
+
foundBody = body
|
|
335
|
+
settle()
|
|
336
|
+
return
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
remaining -= 1
|
|
340
|
+
if remaining <= 0 then
|
|
341
|
+
settle()
|
|
342
|
+
end
|
|
343
|
+
end)
|
|
344
|
+
)
|
|
345
|
+
end
|
|
346
|
+
table.insert(threads, task.delay(timeoutSec, settle))
|
|
347
|
+
|
|
348
|
+
coroutine.yield()
|
|
349
|
+
|
|
350
|
+
for _, thread in threads do
|
|
351
|
+
pcall(task.cancel, thread)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
return foundPort, foundBody
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
return StudioBridgeClient
|
|
@@ -12,6 +12,8 @@ local PLUGIN_VERSION = "{{PLUGIN_VERSION}}"
|
|
|
12
12
|
local MAX_SLUG_LENGTH = 24
|
|
13
13
|
local NONCE_VALUE_NAME = "StudioBridgeNonce"
|
|
14
14
|
local LAUNCH_ID_ATTRIBUTE = "StudioBridgeSessionId"
|
|
15
|
+
local PLANTED_BRIDGE_NAME = "StudioBridgePlaytest"
|
|
16
|
+
local PLAYTEST_ID_ATTRIBUTE = "PlaytestId"
|
|
15
17
|
|
|
16
18
|
local StudioBridgeSessionUtils = {}
|
|
17
19
|
|
|
@@ -21,6 +23,7 @@ export type PlaytestArgs = {
|
|
|
21
23
|
port: number,
|
|
22
24
|
instanceId: string,
|
|
23
25
|
multiplayer: boolean,
|
|
26
|
+
playtestId: string?,
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
--[=[
|
|
@@ -45,6 +48,7 @@ StudioBridgeSessionUtils.CAPABILITIES = table.freeze({
|
|
|
45
48
|
"grepScripts",
|
|
46
49
|
"startPlaytest",
|
|
47
50
|
"endPlaytest",
|
|
51
|
+
"playtestPlayers",
|
|
48
52
|
})
|
|
49
53
|
|
|
50
54
|
--[=[
|
|
@@ -115,7 +119,8 @@ function StudioBridgeSessionUtils.createRegisterPayload(
|
|
|
115
119
|
instanceId: string,
|
|
116
120
|
context: PluginContext,
|
|
117
121
|
launchId: string?,
|
|
118
|
-
playerName: string
|
|
122
|
+
playerName: string?,
|
|
123
|
+
playtestId: string?
|
|
119
124
|
): { [string]: any }
|
|
120
125
|
return {
|
|
121
126
|
pluginVersion = PLUGIN_VERSION,
|
|
@@ -123,6 +128,7 @@ function StudioBridgeSessionUtils.createRegisterPayload(
|
|
|
123
128
|
context = context,
|
|
124
129
|
launchId = launchId,
|
|
125
130
|
playerName = playerName,
|
|
131
|
+
playtestId = playtestId,
|
|
126
132
|
placeName = game.Name or "Unknown",
|
|
127
133
|
placeId = game.PlaceId,
|
|
128
134
|
gameId = game.GameId,
|
|
@@ -143,8 +149,8 @@ end
|
|
|
143
149
|
--[=[
|
|
144
150
|
Returns what studio-bridge handed a playtest through its StudioTestService
|
|
145
151
|
test args: the bridge port, the instance id of the edit session that
|
|
146
|
-
started it,
|
|
147
|
-
did not start the playtest.
|
|
152
|
+
started it, whether it is a multiplayer test, and the playtest id. Nil when
|
|
153
|
+
studio-bridge did not start the playtest.
|
|
148
154
|
]=]
|
|
149
155
|
function StudioBridgeSessionUtils.getPlaytestArgs(testArgs: any): PlaytestArgs?
|
|
150
156
|
if
|
|
@@ -156,9 +162,29 @@ function StudioBridgeSessionUtils.getPlaytestArgs(testArgs: any): PlaytestArgs?
|
|
|
156
162
|
port = testArgs.studioBridgePort,
|
|
157
163
|
instanceId = testArgs.studioBridgeInstanceId,
|
|
158
164
|
multiplayer = type(testArgs.studioBridgePlayers) == "number",
|
|
165
|
+
playtestId = if type(testArgs.studioBridgePlaytestId) == "string"
|
|
166
|
+
then testArgs.studioBridgePlaytestId
|
|
167
|
+
else nil,
|
|
159
168
|
}
|
|
160
169
|
end
|
|
161
170
|
return nil
|
|
162
171
|
end
|
|
163
172
|
|
|
173
|
+
--[=[
|
|
174
|
+
Returns the bridge planted under `container` for the playtest `playtestId`,
|
|
175
|
+
or nil. The edit session plants it when Studio does not load user plugins
|
|
176
|
+
into playtests, and a playtest copies it from the edit DataModel.
|
|
177
|
+
]=]
|
|
178
|
+
function StudioBridgeSessionUtils.findPlantedBridge(container: Instance, playtestId: string?): Instance?
|
|
179
|
+
if not playtestId then
|
|
180
|
+
return nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
local planted = container:FindFirstChild(PLANTED_BRIDGE_NAME)
|
|
184
|
+
if planted and planted:GetAttribute(PLAYTEST_ID_ATTRIBUTE) == playtestId then
|
|
185
|
+
return planted
|
|
186
|
+
end
|
|
187
|
+
return nil
|
|
188
|
+
end
|
|
189
|
+
|
|
164
190
|
return StudioBridgeSessionUtils
|
|
@@ -82,12 +82,12 @@ describe("StudioBridgeSessionUtils.getLaunchId()", function()
|
|
|
82
82
|
end)
|
|
83
83
|
|
|
84
84
|
describe("StudioBridgeSessionUtils.getPlaytestArgs()", function()
|
|
85
|
-
it("should read the port and
|
|
85
|
+
it("should read the port, instance id and playtest id studio-bridge passed in the test args", function()
|
|
86
86
|
expect(StudioBridgeSessionUtils.getPlaytestArgs({
|
|
87
87
|
studioBridgePlaytestId = "abc",
|
|
88
88
|
studioBridgePort = 38741,
|
|
89
89
|
studioBridgeInstanceId = "test-place-3f9a",
|
|
90
|
-
})).toEqual({ port = 38741, instanceId = "test-place-3f9a", multiplayer = false })
|
|
90
|
+
})).toEqual({ port = 38741, instanceId = "test-place-3f9a", multiplayer = false, playtestId = "abc" })
|
|
91
91
|
end)
|
|
92
92
|
|
|
93
93
|
it("should report a multiplayer test", function()
|
|
@@ -149,6 +149,12 @@ describe("StudioBridgeSessionUtils.createRegisterPayload()", function()
|
|
|
149
149
|
expect(payload.playerName).toEqual("Player2")
|
|
150
150
|
end)
|
|
151
151
|
|
|
152
|
+
it("should carry the playtest id of a playtest studio-bridge started", function()
|
|
153
|
+
local payload = StudioBridgeSessionUtils.createRegisterPayload("test-place-3f9a", "server", nil, nil, "abc")
|
|
154
|
+
|
|
155
|
+
expect(payload.playtestId).toEqual("abc")
|
|
156
|
+
end)
|
|
157
|
+
|
|
152
158
|
it("should hand out a capabilities list the caller can change", function()
|
|
153
159
|
local payload = StudioBridgeSessionUtils.createRegisterPayload("test-place-3f9a", "edit")
|
|
154
160
|
|
|
@@ -157,3 +163,30 @@ describe("StudioBridgeSessionUtils.createRegisterPayload()", function()
|
|
|
157
163
|
expect(table.find(StudioBridgeSessionUtils.CAPABILITIES, "extra")).toBeNil()
|
|
158
164
|
end)
|
|
159
165
|
end)
|
|
166
|
+
|
|
167
|
+
describe("StudioBridgeSessionUtils.findPlantedBridge()", function()
|
|
168
|
+
local function plant(playtestId: string): Folder
|
|
169
|
+
local container = Instance.new("Folder")
|
|
170
|
+
local planted = Instance.new("Folder")
|
|
171
|
+
planted.Name = "StudioBridgePlaytest"
|
|
172
|
+
planted:SetAttribute("PlaytestId", playtestId)
|
|
173
|
+
planted.Parent = container
|
|
174
|
+
return container
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it("should find the bridge planted for the playtest", function()
|
|
178
|
+
local container = plant("abc")
|
|
179
|
+
|
|
180
|
+
expect(StudioBridgeSessionUtils.findPlantedBridge(container, "abc")).toBe(
|
|
181
|
+
container:FindFirstChild("StudioBridgePlaytest")
|
|
182
|
+
)
|
|
183
|
+
end)
|
|
184
|
+
|
|
185
|
+
it("should ignore a bridge planted for another playtest", function()
|
|
186
|
+
expect(StudioBridgeSessionUtils.findPlantedBridge(plant("old"), "abc")).toBeNil()
|
|
187
|
+
end)
|
|
188
|
+
|
|
189
|
+
it("should find nothing without a playtest id", function()
|
|
190
|
+
expect(StudioBridgeSessionUtils.findPlantedBridge(plant("abc"), nil)).toBeNil()
|
|
191
|
+
end)
|
|
192
|
+
end)
|