rbxstudio-mcp 2.2.2 → 2.2.4

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 (43) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/dist/__tests__/bridge-service.test.d.ts +0 -0
  4. package/dist/__tests__/bridge-service.test.d.ts.map +0 -0
  5. package/dist/__tests__/bridge-service.test.js +0 -0
  6. package/dist/__tests__/bridge-service.test.js.map +0 -0
  7. package/dist/__tests__/http-server.test.d.ts +0 -0
  8. package/dist/__tests__/http-server.test.d.ts.map +0 -0
  9. package/dist/__tests__/http-server.test.js +0 -0
  10. package/dist/__tests__/http-server.test.js.map +0 -0
  11. package/dist/__tests__/integration.test.d.ts +0 -0
  12. package/dist/__tests__/integration.test.d.ts.map +0 -0
  13. package/dist/__tests__/integration.test.js +0 -0
  14. package/dist/__tests__/integration.test.js.map +0 -0
  15. package/dist/__tests__/smoke.test.d.ts +0 -0
  16. package/dist/__tests__/smoke.test.d.ts.map +0 -0
  17. package/dist/__tests__/smoke.test.js +0 -0
  18. package/dist/__tests__/smoke.test.js.map +0 -0
  19. package/dist/bridge-service.d.ts +94 -0
  20. package/dist/bridge-service.d.ts.map +1 -1
  21. package/dist/bridge-service.js +188 -1
  22. package/dist/bridge-service.js.map +1 -1
  23. package/dist/http-server.d.ts +0 -0
  24. package/dist/http-server.d.ts.map +1 -1
  25. package/dist/http-server.js +52 -0
  26. package/dist/http-server.js.map +1 -1
  27. package/dist/index.d.ts +0 -0
  28. package/dist/index.d.ts.map +0 -0
  29. package/dist/index.js +27 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/tools/index.d.ts +15 -0
  32. package/dist/tools/index.d.ts.map +1 -1
  33. package/dist/tools/index.js +121 -10
  34. package/dist/tools/index.js.map +1 -1
  35. package/dist/tools/studio-client.d.ts +0 -0
  36. package/dist/tools/studio-client.d.ts.map +0 -0
  37. package/dist/tools/studio-client.js +0 -0
  38. package/dist/tools/studio-client.js.map +0 -0
  39. package/package.json +1 -1
  40. package/studio-plugin/INSTALLATION.md +0 -0
  41. package/studio-plugin/MCPPlugin.rbxmx +0 -0
  42. package/studio-plugin/plugin.json +0 -0
  43. package/studio-plugin/plugin.luau +348 -90
@@ -7,12 +7,240 @@ local ScriptEditorService = game:GetService("ScriptEditorService")
7
7
  local CollectionService = game:GetService("CollectionService")
8
8
  local LogService = game:GetService("LogService")
9
9
 
10
- -- StudioTestService for play/stop control (may not be available in all contexts)
10
+ -- StudioTestService for play/stop control (may not be available in all contexts).
11
+ -- NOTE: The previous version of this file mistakenly fetched "TestService" here
12
+ -- — that's a different (legacy) service. The actual API for ExecutePlayModeAsync
13
+ -- and EndTest lives on "StudioTestService". The playSolo handler also re-fetches
14
+ -- this defensively so an older Studio doesn't blow up at plugin load time.
11
15
  local StudioTestService = nil
12
16
  pcall(function()
13
- StudioTestService = game:GetService("TestService")
17
+ StudioTestService = game:GetService("StudioTestService")
14
18
  end)
15
19
 
20
+ -- ============================================================================
21
+ -- TEST SESSION COMPANION
22
+ --
23
+ -- StudioTestService:EndTest() can only be called from the Server DataModel of
24
+ -- a running test. The plugin lives in the Edit DataModel, so it cannot call
25
+ -- EndTest itself. To work around this we inject a small Script into
26
+ -- ServerScriptService BEFORE calling ExecutePlayModeAsync. When the test
27
+ -- starts, that Script runs in the Server DataModel and:
28
+ -- 1. Streams LogService.MessageOut to the bridge (/test-session/log)
29
+ -- 2. Polls the bridge (/test-session/poll) for an "end" command
30
+ -- 3. On "end", calls StudioTestService:EndTest() — which terminates the
31
+ -- yielding ExecutePlayModeAsync call back in the plugin.
32
+ --
33
+ -- Cleanup strategy:
34
+ -- - Tagged with TEST_COMPANION_TAG so we can find orphans
35
+ -- - Swept on plugin activate, on every playSolo start, and after the test
36
+ -- ends (whether via stop_play, natural end, or user clicking Stop)
37
+ -- ============================================================================
38
+ local TEST_COMPANION_NAME = "_MCPTestCompanion"
39
+ local TEST_COMPANION_TAG = "_MCPTestCompanion"
40
+
41
+ -- The companion script source. Two placeholders are substituted at injection
42
+ -- time: __MCP_SERVER_URL__ and __MCP_SESSION_ID__. We use distinctive
43
+ -- placeholders (not %%...%% or {{...}}) to avoid collisions with Lua's own
44
+ -- pattern syntax during gsub.
45
+ local COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion (auto-generated, safe to delete)
46
+ -- Runs in the Server DataModel of a Studio test session started by the MCP
47
+ -- plugin. Pipes output back to the local MCP bridge and listens for an "end"
48
+ -- command to terminate the test cleanly via StudioTestService:EndTest().
49
+
50
+ local HttpService = game:GetService("HttpService")
51
+ local LogService = game:GetService("LogService")
52
+ local StudioTestService
53
+ do
54
+ local ok, svc = pcall(function() return game:GetService("StudioTestService") end)
55
+ if ok then StudioTestService = svc end
56
+ end
57
+
58
+ local SERVER_URL = "__MCP_SERVER_URL__"
59
+ local SESSION_ID = "__MCP_SESSION_ID__"
60
+ local POLL_INTERVAL = 0.4
61
+ local LOG_FLUSH_INTERVAL = 0.25
62
+ local MAX_BUFFER = 5000
63
+ local MAX_CONSECUTIVE_FAILURES = 30 -- ~12s of failed polls before we give up
64
+
65
+ local outputBuffer = {}
66
+ local stopRequested = false
67
+ local consecutiveFailures = 0
68
+
69
+ local function postJSON(path, body)
70
+ local ok, response = pcall(function()
71
+ return HttpService:RequestAsync({
72
+ Url = SERVER_URL .. path,
73
+ Method = "POST",
74
+ Headers = { ["Content-Type"] = "application/json" },
75
+ Body = HttpService:JSONEncode(body),
76
+ })
77
+ end)
78
+ if ok and response and response.Success then
79
+ consecutiveFailures = 0
80
+ return true, response
81
+ else
82
+ consecutiveFailures += 1
83
+ return false, nil
84
+ end
85
+ end
86
+
87
+ LogService.MessageOut:Connect(function(message, messageType)
88
+ if #outputBuffer >= MAX_BUFFER then
89
+ -- Drop oldest to keep buffer bounded; we'd rather lose stale output
90
+ -- than balloon memory in a long-running test.
91
+ table.remove(outputBuffer, 1)
92
+ end
93
+ table.insert(outputBuffer, {
94
+ message = tostring(message),
95
+ messageType = messageType.Name,
96
+ timestamp = os.time(),
97
+ })
98
+ end)
99
+
100
+ -- Log flusher
101
+ task.spawn(function()
102
+ while not stopRequested do
103
+ if #outputBuffer > 0 then
104
+ local toSend = outputBuffer
105
+ outputBuffer = {}
106
+ local ok = postJSON("/test-session/log", {
107
+ sessionId = SESSION_ID,
108
+ messages = toSend,
109
+ })
110
+ if not ok then
111
+ -- Re-queue (capped) so we don't lose log lines on a transient
112
+ -- bridge hiccup. Newer entries take priority since they're
113
+ -- usually what the AI wants to see.
114
+ for i = 1, math.min(#toSend, MAX_BUFFER - #outputBuffer) do
115
+ table.insert(outputBuffer, toSend[i])
116
+ end
117
+ end
118
+ end
119
+ task.wait(LOG_FLUSH_INTERVAL)
120
+ end
121
+ end)
122
+
123
+ -- Command poller
124
+ task.spawn(function()
125
+ while not stopRequested and consecutiveFailures < MAX_CONSECUTIVE_FAILURES do
126
+ local ok, response = postJSON("/test-session/poll", { sessionId = SESSION_ID })
127
+ if ok and response and response.Body then
128
+ local decodeOk, body = pcall(function()
129
+ return HttpService:JSONDecode(response.Body)
130
+ end)
131
+ if decodeOk and body then
132
+ -- Bridge tells us the session is over (e.g. user clicked Stop
133
+ -- in Studio, or the plugin was reloaded). Stop polling.
134
+ if body.ended then
135
+ stopRequested = true
136
+ break
137
+ end
138
+ if body.command and body.command.cmd == "end" then
139
+ stopRequested = true
140
+ -- Final flush of any pending output before we tear down.
141
+ if #outputBuffer > 0 then
142
+ local final = outputBuffer
143
+ outputBuffer = {}
144
+ postJSON("/test-session/log", {
145
+ sessionId = SESSION_ID,
146
+ messages = final,
147
+ })
148
+ end
149
+ -- Tell the bridge we're acknowledging the stop. This wakes
150
+ -- any stop_play caller waiting on a confirmation.
151
+ postJSON("/test-session/ended", {
152
+ sessionId = SESSION_ID,
153
+ reason = "mcp_stop",
154
+ })
155
+ -- Finally, end the test. This terminates the yielding
156
+ -- ExecutePlayModeAsync call back in the Edit-side plugin.
157
+ if StudioTestService then
158
+ pcall(function()
159
+ StudioTestService:EndTest(body.command.args or "MCP_Stop")
160
+ end)
161
+ end
162
+ return
163
+ end
164
+ end
165
+ end
166
+ task.wait(POLL_INTERVAL)
167
+ end
168
+ end)
169
+ ]]
170
+
171
+ -- gsub-safe replacement (treat replacement as plain string, not a pattern).
172
+ local function plainReplace(haystack, needle, replacement)
173
+ -- Use string.find with plain=true to avoid pattern interpretation in needle.
174
+ local result = {}
175
+ local i = 1
176
+ while true do
177
+ local s, e = string.find(haystack, needle, i, true)
178
+ if not s then
179
+ table.insert(result, string.sub(haystack, i))
180
+ break
181
+ end
182
+ table.insert(result, string.sub(haystack, i, s - 1))
183
+ table.insert(result, replacement)
184
+ i = e + 1
185
+ end
186
+ return table.concat(result)
187
+ end
188
+
189
+ local function cleanupTestCompanions()
190
+ local count = 0
191
+ pcall(function()
192
+ for _, inst in ipairs(CollectionService:GetTagged(TEST_COMPANION_TAG)) do
193
+ pcall(function() inst:Destroy() end)
194
+ count += 1
195
+ end
196
+ end)
197
+ -- Belt & suspenders: also remove any name-matching script in
198
+ -- ServerScriptService in case the tag was somehow stripped.
199
+ pcall(function()
200
+ local sss = game:GetService("ServerScriptService")
201
+ for _, child in ipairs(sss:GetChildren()) do
202
+ if child.Name == TEST_COMPANION_NAME then
203
+ pcall(function() child:Destroy() end)
204
+ count += 1
205
+ end
206
+ end
207
+ end)
208
+ return count
209
+ end
210
+
211
+ local function injectTestCompanion(serverUrl, sessionId)
212
+ -- Always sweep before creating to guarantee at most one companion exists.
213
+ cleanupTestCompanions()
214
+
215
+ local source = COMPANION_SCRIPT_TEMPLATE
216
+ source = plainReplace(source, "__MCP_SERVER_URL__", serverUrl)
217
+ source = plainReplace(source, "__MCP_SESSION_ID__", sessionId)
218
+
219
+ local script = Instance.new("Script")
220
+ script.Name = TEST_COMPANION_NAME
221
+ script.Source = source
222
+ -- IMPORTANT: Archivable MUST stay true (the default).
223
+ -- ExecutePlayModeAsync builds the test DataModel by Clone()-ing the Edit
224
+ -- DataModel, and Instance:Clone() silently returns nil for any instance
225
+ -- with Archivable=false. We previously set this to false as a "safety"
226
+ -- against the companion getting saved into the user's place file — but
227
+ -- the side effect was that the companion never made it into the test
228
+ -- session at all. No companion → no log streaming and no way to receive
229
+ -- the "end" command, so stop_play would just time out.
230
+ --
231
+ -- We rely on the cleanup sweeps (on plugin activate, before each test,
232
+ -- after each test) to keep companions out of the saved file. The script
233
+ -- is also tagged with TEST_COMPANION_TAG so any orphan is easy to find.
234
+ script.Archivable = true
235
+ script.Parent = game:GetService("ServerScriptService")
236
+
237
+ pcall(function()
238
+ CollectionService:AddTag(script, TEST_COMPANION_TAG)
239
+ end)
240
+
241
+ return script
242
+ end
243
+
16
244
  -- Track output log for get_output
17
245
  local outputBuffer = {}
18
246
  local MAX_OUTPUT_BUFFER = 1000
@@ -4312,123 +4540,143 @@ end
4312
4540
 
4313
4541
  -- ============================================
4314
4542
  -- PLAYTEST CONTROL HANDLERS
4543
+ --
4544
+ -- play_solo: receives a sessionId from the bridge, injects a companion
4545
+ -- Script tagged with TEST_COMPANION_TAG into ServerScriptService, then
4546
+ -- spawns ExecutePlayModeAsync in a separate thread so the HTTP response
4547
+ -- can return immediately. When the test ends (for any reason), we sweep
4548
+ -- the companion and notify the bridge.
4549
+ --
4550
+ -- stop_play: this handler is kept only for backward compatibility. The
4551
+ -- real stop path is the bridge enqueueing an "end" command that the
4552
+ -- companion picks up and acts on. Calling this endpoint directly will
4553
+ -- always be a no-op telling the caller to use the new path.
4315
4554
  -- ============================================
4316
4555
 
4317
- -- Track active play test task
4318
- local activePlayTestTask = nil
4319
-
4320
4556
  handlers.playSolo = function(requestData)
4321
- local success, result = pcall(function()
4322
- local wasRunning = RunService:IsRunning()
4323
-
4324
- -- If already running, stop first
4325
- if wasRunning then
4326
- RunService:Stop()
4327
- task.wait(0.2)
4328
- end
4329
-
4330
- -- Try the new StudioTestService first (available since Dec 2025)
4331
- local studioTestSuccess, studioTestService = pcall(function()
4332
- return game:GetService("StudioTestService")
4333
- end)
4334
-
4335
- if studioTestSuccess and studioTestService then
4336
- -- Use the new StudioTestService API for proper Play Solo mode
4337
- -- Run in background task so we don't block the HTTP response
4338
- activePlayTestTask = task.spawn(function()
4339
- -- ExecutePlayModeAsync(args: Variant?) - per official docs, pass a string identifier
4340
- local testResult = studioTestService:ExecutePlayModeAsync("MCP_PlayTest")
4341
- activePlayTestTask = nil
4342
- end)
4557
+ local sessionId = requestData and requestData.sessionId
4558
+ if type(sessionId) ~= "string" or sessionId == "" then
4559
+ return {
4560
+ success = false,
4561
+ error = "sessionId required (the bridge generates one and passes it; older clients won't have it).",
4562
+ }
4563
+ end
4343
4564
 
4344
- return {
4345
- success = true,
4346
- wasRunning = wasRunning,
4347
- method = "StudioTestService:ExecutePlayModeAsync",
4348
- message = wasRunning and "Restarted Play Solo (was already running)" or "Started Play Solo mode",
4349
- note = "Use get_output to read script output. Use stop_play when done."
4350
- }
4351
- else
4352
- -- Fallback to RunService for older Studio versions
4353
- RunService:Run()
4565
+ -- Validate StudioTestService is available BEFORE we touch anything.
4566
+ local stsOk, sts = pcall(function() return game:GetService("StudioTestService") end)
4567
+ if not stsOk or not sts then
4568
+ return {
4569
+ success = false,
4570
+ error = "StudioTestService unavailable in this Studio version. Update Roblox Studio to use playtest features.",
4571
+ }
4572
+ end
4354
4573
 
4355
- return {
4356
- success = true,
4357
- wasRunning = wasRunning,
4358
- method = "RunService:Run (fallback)",
4359
- message = wasRunning and "Restarted play test (was already running)" or "Started Run mode (fallback - StudioTestService not available)",
4360
- note = "Use get_output to read script output. Use stop_play when done."
4361
- }
4362
- end
4574
+ -- HttpService.HttpEnabled preflight.
4575
+ --
4576
+ -- Plugins (this script) bypass HttpEnabled, but in-game scripts — and
4577
+ -- that includes our injected companion running inside the test
4578
+ -- DataModel do not. Without HttpEnabled the companion can't reach
4579
+ -- the bridge to stream logs or receive the "end" command, so stop_play
4580
+ -- silently times out from the AI's perspective.
4581
+ --
4582
+ -- We can't fix this for the user: HttpEnabled is gated by
4583
+ -- LocalUserSecurity, which means a plugin literally cannot flip it
4584
+ -- from code. Best we can do is fail fast with an actionable error so
4585
+ -- the AI/user knows exactly what to toggle.
4586
+ local httpEnabledOk, httpEnabled = pcall(function()
4587
+ return HttpService.HttpEnabled
4363
4588
  end)
4364
-
4365
- if success then
4366
- return result
4367
- else
4589
+ if httpEnabledOk and httpEnabled == false then
4368
4590
  return {
4369
4591
  success = false,
4370
- error = "Failed to start play test: " .. tostring(result)
4592
+ error = "HttpService.HttpEnabled is false. The injected test-session companion runs as an in-game script and needs HTTP access to stream logs and receive the stop command. Enable it in Studio: Game Settings → Security → Allow HTTP Requests (or Home → Game Settings → Security). A plugin cannot toggle this setting itself (LocalUserSecurity).",
4593
+ fixSteps = {
4594
+ "Open Game Settings (Home tab → Game Settings, or File → Game Settings)",
4595
+ "Go to the Security section",
4596
+ "Toggle 'Allow HTTP Requests' ON",
4597
+ "Click Save, then call play_solo again",
4598
+ },
4371
4599
  }
4372
4600
  end
4373
- end
4374
4601
 
4375
- handlers.stopPlay = function(requestData)
4376
- local success, result = pcall(function()
4377
- -- NOTE: RunService:IsRunning() returns false from plugin context even when play test is running
4378
- -- So we always try to stop via StudioTestService first
4379
-
4380
- -- Try StudioTestService:EndTest first (matches ExecutePlayModeAsync)
4381
- local studioTestSuccess, studioTestService = pcall(function()
4382
- return game:GetService("StudioTestService")
4383
- end)
4602
+ -- If a previous test was somehow left running (e.g. plugin reloaded
4603
+ -- mid-test, user clicked Stop without us hearing), best-effort it.
4604
+ -- We can't detect Play mode from a plugin reliably, so we just do an
4605
+ -- orphan sweep if there's no actual test running, the sweep is a
4606
+ -- no-op; if there is one, the user will need to stop it manually
4607
+ -- since EndTest can only be called from the test's Server DataModel.
4608
+ cleanupTestCompanions()
4384
4609
 
4385
- if studioTestSuccess and studioTestService then
4386
- local endSuccess, endError = pcall(function()
4387
- studioTestService:EndTest()
4610
+ local success, result = pcall(function()
4611
+ local companion = injectTestCompanion(pluginState.serverUrl, sessionId)
4612
+
4613
+ -- ExecutePlayModeAsync yields until the test ends. Run it on its
4614
+ -- own thread so the HTTP response for play_solo returns now,
4615
+ -- rather than after the test concludes. The plugin's poll loop
4616
+ -- continues to service other requests during the test.
4617
+ task.spawn(function()
4618
+ local execOk, execResult = pcall(function()
4619
+ return sts:ExecutePlayModeAsync(sessionId)
4388
4620
  end)
4389
4621
 
4390
- if endSuccess then
4391
- activePlayTestTask = nil
4392
- return {
4393
- success = true,
4394
- method = "StudioTestService:EndTest",
4395
- message = "Stopped play test"
4396
- }
4397
- else
4398
- -- EndTest failed, try RunService:Stop as fallback
4399
- RunService:Stop()
4400
- activePlayTestTask = nil
4401
- return {
4402
- success = true,
4403
- method = "RunService:Stop (fallback)",
4404
- message = "Stopped play test",
4405
- warning = "EndTest failed: " .. tostring(endError) .. ". Used RunService:Stop() which does NOT restore pre-play state."
4406
- }
4407
- end
4408
- end
4622
+ -- Test has ended. Always clean up the companion in the Edit
4623
+ -- DataModel (the one in the test DM died with the test).
4624
+ cleanupTestCompanions()
4409
4625
 
4410
- -- No StudioTestService, use RunService:Stop
4411
- RunService:Stop()
4412
- activePlayTestTask = nil
4626
+ -- Tell the bridge the session is over. The companion may have
4627
+ -- already done this if stop was MCP-driven; the bridge's
4628
+ -- endTestSession is idempotent so a duplicate post is fine.
4629
+ pcall(function()
4630
+ HttpService:RequestAsync({
4631
+ Url = pluginState.serverUrl .. "/test-session/ended",
4632
+ Method = "POST",
4633
+ Headers = { ["Content-Type"] = "application/json" },
4634
+ Body = HttpService:JSONEncode({
4635
+ sessionId = sessionId,
4636
+ reason = execOk and "natural" or "exec_error",
4637
+ value = execOk and execResult or nil,
4638
+ error = (not execOk) and tostring(execResult) or nil,
4639
+ }),
4640
+ })
4641
+ end)
4642
+ end)
4413
4643
 
4414
4644
  return {
4415
4645
  success = true,
4416
- method = "RunService:Stop",
4417
- message = "Stopped play test",
4418
- warning = "Note: RunService:Stop() does NOT restore pre-play state. Objects created/modified during play remain changed."
4646
+ sessionId = sessionId,
4647
+ method = "StudioTestService:ExecutePlayModeAsync",
4648
+ companionPath = getInstancePath(companion),
4649
+ message = "Started Play Solo with MCP companion. Use stop_play to end, get_playtest_output to read logs.",
4419
4650
  }
4420
4651
  end)
4421
4652
 
4422
4653
  if success then
4423
4654
  return result
4424
4655
  else
4656
+ -- If injection or task spawn fails, make sure we don't leave a
4657
+ -- companion script behind.
4658
+ pcall(cleanupTestCompanions)
4425
4659
  return {
4426
4660
  success = false,
4427
- error = "Failed to stop play test: " .. tostring(result)
4661
+ sessionId = sessionId,
4662
+ error = "Failed to start play test: " .. tostring(result),
4428
4663
  }
4429
4664
  end
4430
4665
  end
4431
4666
 
4667
+ handlers.stopPlay = function(requestData)
4668
+ -- Deprecated path. The MCP `stop_play` tool now talks to the bridge
4669
+ -- directly (which queues a command for the in-test companion to pick
4670
+ -- up). This handler exists only so older bridge versions or direct
4671
+ -- HTTP callers don't crash — it never actually stops a test, since
4672
+ -- EndTest cannot be called from the Edit DataModel.
4673
+ return {
4674
+ success = false,
4675
+ deprecated = true,
4676
+ error = "/api/stop-play is deprecated. The MCP stop_play tool now signals the in-test companion via the bridge — update your MCP server to a version that includes the test-session protocol.",
4677
+ }
4678
+ end
4679
+
4432
4680
  -- ============================================
4433
4681
  -- SCREENSHOT HANDLER
4434
4682
  -- ============================================
@@ -5200,6 +5448,10 @@ local function activatePlugin()
5200
5448
  screenGui.Enabled = true
5201
5449
  updateUIState()
5202
5450
 
5451
+ -- Sweep any orphan test companions left over from a previous Studio
5452
+ -- session that crashed/was force-closed mid-test. Safe no-op otherwise.
5453
+ pcall(cleanupTestCompanions)
5454
+
5203
5455
  pcall(function()
5204
5456
  HttpService:RequestAsync({
5205
5457
  Url = pluginState.serverUrl .. "/ready",
@@ -5248,7 +5500,13 @@ local function deactivatePlugin()
5248
5500
  pluginState.connection:Disconnect()
5249
5501
  pluginState.connection = nil
5250
5502
  end
5251
-
5503
+
5504
+ -- Don't leave a companion script in the place when the user disables
5505
+ -- the plugin. (If a test is currently running it'll be in the test's
5506
+ -- Server DataModel — that copy dies with the test. We're cleaning the
5507
+ -- Edit-side template so it's not there next time the user saves.)
5508
+ pcall(cleanupTestCompanions)
5509
+
5252
5510
  pluginState.consecutiveFailures = 0
5253
5511
  pluginState.currentRetryDelay = 0.5
5254
5512
  end