@quenty/studio-bridge 0.18.1 → 0.20.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 (66) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  3. package/dist/src/bridge/bridge-session.js +1 -0
  4. package/dist/src/bridge/bridge-session.js.map +1 -1
  5. package/dist/src/bridge/bridge-session.test.js +36 -0
  6. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  7. package/dist/src/bridge/types.d.ts +8 -2
  8. package/dist/src/bridge/types.d.ts.map +1 -1
  9. package/dist/src/bridge/types.js.map +1 -1
  10. package/dist/src/commands/framework/action-loader.d.ts +5 -5
  11. package/dist/src/commands/framework/action-loader.js +7 -7
  12. package/dist/src/commands/framework/action-loader.js.map +1 -1
  13. package/dist/src/commands/framework/action-loader.test.d.ts +1 -1
  14. package/dist/src/commands/framework/action-loader.test.js +15 -15
  15. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  16. package/dist/src/index.d.ts +1 -1
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/server/plugin-detection.test.js +1 -1
  20. package/dist/src/server/plugin-detection.test.js.map +1 -1
  21. package/dist/src/server/studio-bridge-server.d.ts +10 -2
  22. package/dist/src/server/studio-bridge-server.d.ts.map +1 -1
  23. package/dist/src/server/studio-bridge-server.js +3 -2
  24. package/dist/src/server/studio-bridge-server.js.map +1 -1
  25. package/dist/src/server/studio-bridge-server.test.js +39 -1
  26. package/dist/src/server/studio-bridge-server.test.js.map +1 -1
  27. package/dist/src/server/web-socket-protocol.d.ts +22 -0
  28. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  29. package/dist/src/server/web-socket-protocol.js +6 -0
  30. package/dist/src/server/web-socket-protocol.js.map +1 -1
  31. package/dist/src/server/web-socket-protocol.test.js +52 -0
  32. package/dist/src/server/web-socket-protocol.test.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +5 -5
  35. package/src/bridge/bridge-session.test.ts +45 -0
  36. package/src/bridge/bridge-session.ts +1 -0
  37. package/src/bridge/types.ts +14 -1
  38. package/src/commands/console/exec/{execute.luau → execute.lua} +118 -3
  39. package/src/commands/framework/action-loader.test.ts +15 -15
  40. package/src/commands/framework/action-loader.ts +7 -7
  41. package/src/index.ts +1 -0
  42. package/src/server/plugin-detection.test.ts +1 -1
  43. package/src/server/studio-bridge-server.test.ts +50 -1
  44. package/src/server/studio-bridge-server.ts +12 -2
  45. package/src/server/web-socket-protocol.test.ts +64 -0
  46. package/src/server/web-socket-protocol.ts +29 -0
  47. package/templates/studio-bridge-plugin-test/test/{execute-handler.test.luau → execute-handler.test.lua} +111 -0
  48. package/templates/studio-bridge-plugin-test/test/{test-runner.luau → test-runner.lua} +10 -10
  49. /package/build-scripts/{transform-add-session-attribute.luau → transform-add-session-attribute.lua} +0 -0
  50. /package/src/commands/console/logs/{query-logs.luau → query-logs.lua} +0 -0
  51. /package/src/commands/explorer/query/{query-data-model.luau → query-data-model.lua} +0 -0
  52. /package/src/commands/process/info/{query-state.luau → query-state.lua} +0 -0
  53. /package/src/commands/viewport/screenshot/{capture-screenshot.luau → capture-screenshot.lua} +0 -0
  54. /package/templates/studio-bridge-plugin/src/Shared/{ActionRouter.luau → ActionRouter.lua} +0 -0
  55. /package/templates/studio-bridge-plugin/src/Shared/{DiscoveryStateMachine.luau → DiscoveryStateMachine.lua} +0 -0
  56. /package/templates/studio-bridge-plugin/src/Shared/{MessageBuffer.luau → MessageBuffer.lua} +0 -0
  57. /package/templates/studio-bridge-plugin/src/Shared/{Protocol.luau → Protocol.lua} +0 -0
  58. /package/templates/studio-bridge-plugin/src/Vendor/{png.luau → png.lua} +0 -0
  59. /package/templates/studio-bridge-plugin-test/test/{actions.test.luau → actions.test.lua} +0 -0
  60. /package/templates/studio-bridge-plugin-test/test/{discovery.test.luau → discovery.test.lua} +0 -0
  61. /package/templates/studio-bridge-plugin-test/test/{hash-lifecycle.test.luau → hash-lifecycle.test.lua} +0 -0
  62. /package/templates/studio-bridge-plugin-test/test/{integration.test.luau → integration.test.lua} +0 -0
  63. /package/templates/studio-bridge-plugin-test/test/{new-actions.test.luau → new-actions.test.lua} +0 -0
  64. /package/templates/studio-bridge-plugin-test/test/{plugin-entry.test.luau → plugin-entry.test.lua} +0 -0
  65. /package/templates/studio-bridge-plugin-test/test/{protocol.test.luau → protocol.test.lua} +0 -0
  66. /package/templates/studio-bridge-plugin-test/test/{roblox-mocks.luau → roblox-mocks.lua} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/studio-bridge",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "WebSocket-based bridge for running Luau scripts in Roblox Studio",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -15,9 +15,9 @@
15
15
  "studio-bridge": "./dist/src/cli/cli.js"
16
16
  },
17
17
  "dependencies": {
18
- "@quenty/cli-output-helpers": "1.17.2",
19
- "@quenty/nevermore-cli-helpers": "1.17.1",
20
- "@quenty/nevermore-template-helpers": "1.18.2",
18
+ "@quenty/cli-output-helpers": "1.18.0",
19
+ "@quenty/nevermore-cli-helpers": "1.18.0",
20
+ "@quenty/nevermore-template-helpers": "1.20.0",
21
21
  "execa": "^9.6.1",
22
22
  "ws": "^8.18.0",
23
23
  "yargs": "^18.0.0",
@@ -39,5 +39,5 @@
39
39
  "test:plugin": "lune run templates/studio-bridge-plugin-test/test/test-runner",
40
40
  "test:integration": "node dist/test/integration/smoke-test.js"
41
41
  },
42
- "gitHead": "2808f7b0f98a18163c6819aeb5fd9064496bc63a"
42
+ "gitHead": "31423d2314075bdd1f14ebbbc3eb637c5e0cb6ce"
43
43
  }
@@ -174,6 +174,51 @@ describe('BridgeSession', () => {
174
174
  expect(result.success).toBe(false);
175
175
  expect(result.error).toBe('boom');
176
176
  });
177
+
178
+ it('surfaces the values the script returned', async () => {
179
+ const handle = new MockTransportHandle();
180
+ handle.sendActionAsync.mockResolvedValueOnce({
181
+ type: 'scriptComplete',
182
+ sessionId: 'session-1',
183
+ payload: {
184
+ success: true,
185
+ returnValues: [{ counts: { passed: 3 } }, 42],
186
+ },
187
+ });
188
+
189
+ const session = new BridgeSession(createSessionInfo(), handle);
190
+ const result = await session.execAsync('return results, 42');
191
+
192
+ expect(result.returnValues).toEqual([{ counts: { passed: 3 } }, 42]);
193
+ });
194
+
195
+ it('leaves returnValues undefined when the plugin reported none', async () => {
196
+ const handle = new MockTransportHandle();
197
+ handle.sendActionAsync.mockResolvedValueOnce({
198
+ type: 'scriptComplete',
199
+ sessionId: 'session-1',
200
+ payload: { success: true },
201
+ });
202
+
203
+ const session = new BridgeSession(createSessionInfo(), handle);
204
+ const result = await session.execAsync('print("hello")');
205
+
206
+ expect(result.returnValues).toBeUndefined();
207
+ });
208
+
209
+ it('leaves returnValues undefined on an error response', async () => {
210
+ const handle = new MockTransportHandle();
211
+ handle.sendActionAsync.mockResolvedValueOnce({
212
+ type: 'error',
213
+ sessionId: 'session-1',
214
+ payload: { code: 'SCRIPT_RUNTIME_ERROR', message: 'boom' },
215
+ });
216
+
217
+ const session = new BridgeSession(createSessionInfo(), handle);
218
+ const result = await session.execAsync('error("boom")');
219
+
220
+ expect(result.returnValues).toBeUndefined();
221
+ });
177
222
  });
178
223
 
179
224
  describe('queryStateAsync', () => {
@@ -118,6 +118,7 @@ export class BridgeSession extends EventEmitter {
118
118
  success: result.payload.success,
119
119
  output,
120
120
  error: result.payload.error,
121
+ returnValues: result.payload.returnValues,
121
122
  };
122
123
  }
123
124
 
@@ -11,10 +11,17 @@ import type {
11
11
  Capability,
12
12
  DataModelInstance,
13
13
  OutputLevel,
14
+ SerializedReturnValue,
14
15
  } from '../server/web-socket-protocol.js';
15
16
 
16
17
  // Re-export protocol types used in the public API
17
- export type { StudioState, Capability, DataModelInstance, OutputLevel };
18
+ export type {
19
+ StudioState,
20
+ Capability,
21
+ DataModelInstance,
22
+ OutputLevel,
23
+ SerializedReturnValue,
24
+ };
18
25
 
19
26
  export type SessionContext = 'edit' | 'client' | 'server';
20
27
  export type SessionOrigin = 'user' | 'managed';
@@ -47,6 +54,12 @@ export interface ExecResult {
47
54
  success: boolean;
48
55
  output: Array<{ level: OutputLevel; body: string }>;
49
56
  error?: string;
57
+ /**
58
+ * Everything the script returned, in order. Absent when the plugin reported
59
+ * no return channel at all (older plugin build, or a run that never reached
60
+ * a return) — distinct from `[]`, which means it returned nothing.
61
+ */
62
+ returnValues?: SerializedReturnValue[];
50
63
  }
51
64
 
52
65
  export interface StateResult {
@@ -10,6 +10,9 @@
10
10
  - Distinct error codes: SCRIPT_LOAD_ERROR, SCRIPT_RUNTIME_ERROR.
11
11
  - Sequential queueing: concurrent execute requests are processed
12
12
  one at a time in FIFO order.
13
+ - Return values: everything the script returns is marshalled onto
14
+ `payload.returnValues`, so a caller can read a result as a value
15
+ instead of scraping it back out of printed output.
13
16
 
14
17
  This module has no Roblox dependencies and is testable under Lune.
15
18
  ]]
@@ -24,6 +27,108 @@ local _queue: { { payload: { [string]: any }, requestId: string?, sessionId: str
24
27
  {}
25
28
  local _processing = false
26
29
 
30
+ -- ---------------------------------------------------------------------------
31
+ -- Return value serialization
32
+ -- ---------------------------------------------------------------------------
33
+
34
+ -- Produces the SerializedReturnValue shapes declared in
35
+ -- web-socket-protocol.ts. Action modules are loadstring'd standalone inside
36
+ -- the plugin and cannot require anything, so this cannot be shared with the
37
+ -- equivalent marshaller in query-data-model.lua.
38
+
39
+ local MAX_DEPTH = 64
40
+
41
+ local function unsupported(typeName: string, text: string): { [string]: any }
42
+ return { type = "Unsupported", typeName = typeName, toString = text }
43
+ end
44
+
45
+ local serializeValue: (value: any, depth: number, seen: { [any]: boolean }) -> any
46
+
47
+ -- An array-like table becomes a JSON array, anything else a JSON object with
48
+ -- string keys. Mixed tables take the object branch on purpose: JSONEncode
49
+ -- rejects them, and a rejected encode drops the whole scriptComplete message.
50
+ local function serializeTable(value: { [any]: any }, depth: number, seen: { [any]: boolean }): any
51
+ local count = 0
52
+ for _ in value do
53
+ count += 1
54
+ end
55
+
56
+ if count == #value then
57
+ local array = {}
58
+ for index = 1, count do
59
+ array[index] = serializeValue(value[index], depth + 1, seen)
60
+ end
61
+ return array
62
+ end
63
+
64
+ local map: { [string]: any } = {}
65
+ for key, item in value do
66
+ map[if type(key) == "string" then key else tostring(key)] = serializeValue(item, depth + 1, seen)
67
+ end
68
+ return map
69
+ end
70
+
71
+ function serializeValue(value: any, depth: number, seen: { [any]: boolean }): any
72
+ local valueType = typeof(value)
73
+
74
+ if valueType == "nil" or valueType == "string" or valueType == "boolean" then
75
+ return value
76
+ elseif valueType == "number" then
77
+ -- inf and nan have no JSON spelling, and encoding one produces a
78
+ -- document the server cannot decode -- losing the entire message.
79
+ if value ~= value or value == math.huge or value == -math.huge then
80
+ return unsupported("number", tostring(value))
81
+ end
82
+ return value
83
+ elseif valueType == "table" then
84
+ if depth > MAX_DEPTH then
85
+ return unsupported("table", "<max depth exceeded>")
86
+ end
87
+ if seen[value] then
88
+ return unsupported("table", "<cycle>")
89
+ end
90
+ seen[value] = true
91
+ local serialized = serializeTable(value, depth, seen)
92
+ seen[value] = nil
93
+ return serialized
94
+ elseif valueType == "Vector3" then
95
+ return { type = "Vector3", value = { value.X, value.Y, value.Z } }
96
+ elseif valueType == "Vector2" then
97
+ return { type = "Vector2", value = { value.X, value.Y } }
98
+ elseif valueType == "CFrame" then
99
+ return { type = "CFrame", value = { value:GetComponents() } }
100
+ elseif valueType == "Color3" then
101
+ return { type = "Color3", value = { value.R, value.G, value.B } }
102
+ elseif valueType == "UDim2" then
103
+ return { type = "UDim2", value = { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset } }
104
+ elseif valueType == "UDim" then
105
+ return { type = "UDim", value = { value.Scale, value.Offset } }
106
+ elseif valueType == "BrickColor" then
107
+ return { type = "BrickColor", name = value.Name, value = value.Number }
108
+ elseif valueType == "EnumItem" then
109
+ return { type = "EnumItem", enum = tostring(value.EnumType), name = value.Name, value = value.Value }
110
+ elseif valueType == "Instance" then
111
+ return { type = "Instance", className = value.ClassName, path = value:GetFullName() }
112
+ else
113
+ return unsupported(valueType, tostring(value))
114
+ end
115
+ end
116
+
117
+ -- Marshal the values a script returned, in order. A returned nil becomes an
118
+ -- explicit marker: a JSON array cannot hold a hole, and dropping the entry
119
+ -- would shift every later value into the wrong position.
120
+ local function serializeReturnValues(packed: { n: number, [number]: any }): { any }
121
+ local returnValues = {}
122
+ for index = 2, packed.n do
123
+ local serialized = serializeValue(packed[index], 1, {})
124
+ if serialized == nil then
125
+ serialized = { type = "Nil" }
126
+ end
127
+ returnValues[index - 1] = serialized
128
+ end
129
+ return returnValues
130
+ end
131
+
27
132
  -- ---------------------------------------------------------------------------
28
133
  -- Core execution logic
29
134
  -- ---------------------------------------------------------------------------
@@ -108,17 +213,27 @@ function ExecuteAction._handleExecute(
108
213
  originalWarn(...)
109
214
  end
110
215
 
111
- local success, runtimeError = pcall(fn)
216
+ local returned = table.pack(pcall(fn))
217
+ local success = returned[1]
112
218
 
113
219
  -- Restore originals
114
220
  env.print = originalPrint
115
221
  env.warn = originalWarn
116
222
 
117
223
  if not success then
118
- return sendResult({ success = false, error = tostring(runtimeError), code = "SCRIPT_RUNTIME_ERROR", output = captured })
224
+ return sendResult({
225
+ success = false,
226
+ error = tostring(returned[2]),
227
+ code = "SCRIPT_RUNTIME_ERROR",
228
+ output = captured,
229
+ })
119
230
  end
120
231
 
121
- return sendResult({ success = true, output = captured })
232
+ return sendResult({
233
+ success = true,
234
+ output = captured,
235
+ returnValues = serializeReturnValues(returned),
236
+ })
122
237
  end
123
238
 
124
239
  -- ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for action-loader: scanning .luau files and computing content hashes.
2
+ * Tests for action-loader: scanning .lua files and computing content hashes.
3
3
  */
4
4
 
5
5
  import { createHash } from 'crypto';
@@ -24,21 +24,21 @@ function sha256(content: string): string {
24
24
  }
25
25
 
26
26
  describe('loadActionSourcesAsync', () => {
27
- it('loads .luau files from a flat directory', async () => {
27
+ it('loads .lua files from a flat directory', async () => {
28
28
  const source = 'local M = {} return M';
29
- await fs.writeFile(path.join(tmpDir, 'hello.luau'), source);
29
+ await fs.writeFile(path.join(tmpDir, 'hello.lua'), source);
30
30
 
31
31
  const results = await loadActionSourcesAsync(tmpDir);
32
32
 
33
33
  expect(results).toHaveLength(1);
34
34
  expect(results[0].name).toBe('hello');
35
35
  expect(results[0].source).toBe(source);
36
- expect(results[0].relativePath).toBe('hello.luau');
36
+ expect(results[0].relativePath).toBe('hello.lua');
37
37
  });
38
38
 
39
39
  it('computes SHA-256 hash of source content', async () => {
40
40
  const source = 'return function() end';
41
- await fs.writeFile(path.join(tmpDir, 'test.luau'), source);
41
+ await fs.writeFile(path.join(tmpDir, 'test.lua'), source);
42
42
 
43
43
  const results = await loadActionSourcesAsync(tmpDir);
44
44
 
@@ -47,8 +47,8 @@ describe('loadActionSourcesAsync', () => {
47
47
  });
48
48
 
49
49
  it('different content produces different hashes', async () => {
50
- await fs.writeFile(path.join(tmpDir, 'a.luau'), 'version 1');
51
- await fs.writeFile(path.join(tmpDir, 'b.luau'), 'version 2');
50
+ await fs.writeFile(path.join(tmpDir, 'a.lua'), 'version 1');
51
+ await fs.writeFile(path.join(tmpDir, 'b.lua'), 'version 2');
52
52
 
53
53
  const results = await loadActionSourcesAsync(tmpDir);
54
54
 
@@ -62,8 +62,8 @@ describe('loadActionSourcesAsync', () => {
62
62
  const source = 'local x = 42';
63
63
  const subDir = path.join(tmpDir, 'sub');
64
64
  await fs.mkdir(subDir);
65
- await fs.writeFile(path.join(tmpDir, 'a.luau'), source);
66
- await fs.writeFile(path.join(subDir, 'b.luau'), source);
65
+ await fs.writeFile(path.join(tmpDir, 'a.lua'), source);
66
+ await fs.writeFile(path.join(subDir, 'b.lua'), source);
67
67
 
68
68
  const results = await loadActionSourcesAsync(tmpDir);
69
69
 
@@ -76,21 +76,21 @@ describe('loadActionSourcesAsync', () => {
76
76
  it('recursively scans subdirectories', async () => {
77
77
  const subDir = path.join(tmpDir, 'nested', 'deep');
78
78
  await fs.mkdir(subDir, { recursive: true });
79
- await fs.writeFile(path.join(subDir, 'deep.luau'), 'return nil');
79
+ await fs.writeFile(path.join(subDir, 'deep.lua'), 'return nil');
80
80
 
81
81
  const results = await loadActionSourcesAsync(tmpDir);
82
82
 
83
83
  expect(results).toHaveLength(1);
84
84
  expect(results[0].name).toBe('deep');
85
85
  expect(results[0].relativePath).toBe(
86
- path.join('nested', 'deep', 'deep.luau')
86
+ path.join('nested', 'deep', 'deep.lua')
87
87
  );
88
88
  });
89
89
 
90
- it('ignores non-.luau files', async () => {
91
- await fs.writeFile(path.join(tmpDir, 'script.lua'), 'not included');
90
+ it('ignores non-.lua files', async () => {
91
+ await fs.writeFile(path.join(tmpDir, 'script.luau'), 'not included');
92
92
  await fs.writeFile(path.join(tmpDir, 'module.ts'), 'not included');
93
- await fs.writeFile(path.join(tmpDir, 'action.luau'), 'included');
93
+ await fs.writeFile(path.join(tmpDir, 'action.lua'), 'included');
94
94
 
95
95
  const results = await loadActionSourcesAsync(tmpDir);
96
96
 
@@ -109,7 +109,7 @@ describe('loadActionSourcesAsync', () => {
109
109
  });
110
110
 
111
111
  it('hash is a 64-character hex string (SHA-256)', async () => {
112
- await fs.writeFile(path.join(tmpDir, 'check.luau'), 'content');
112
+ await fs.writeFile(path.join(tmpDir, 'check.lua'), 'content');
113
113
 
114
114
  const results = await loadActionSourcesAsync(tmpDir);
115
115
 
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Scans co-located `.luau` action files from the command directory tree
2
+ * Scans co-located `.lua` action files from the command directory tree
3
3
  * and returns their source contents for pushing to plugin sessions.
4
4
  *
5
- * Each command directory may contain a `.luau` file with the same stem
6
- * as its `.ts` file (e.g. `exec.luau` next to `exec.ts`). These files
5
+ * Each command directory may contain a `.lua` file with the same stem
6
+ * as its `.ts` file (e.g. `exec.lua` next to `exec.ts`). These files
7
7
  * are Luau modules that register action handlers in the plugin's
8
8
  * ActionRouter at runtime.
9
9
  */
@@ -25,8 +25,8 @@ export interface ActionSource {
25
25
  }
26
26
 
27
27
  /**
28
- * Recursively scan `baseDir` for `.luau` files and return their contents
29
- * as `ActionSource` entries. Only files that end in `.luau` are included.
28
+ * Recursively scan `baseDir` for `.lua` files and return their contents
29
+ * as `ActionSource` entries. Only files that end in `.lua` are included.
30
30
  */
31
31
  export async function loadActionSourcesAsync(
32
32
  baseDir?: string
@@ -54,9 +54,9 @@ async function scanDirAsync(
54
54
  const fullPath = path.join(currentDir, entry.name);
55
55
  if (entry.isDirectory()) {
56
56
  await scanDirAsync(baseDir, fullPath, results);
57
- } else if (entry.name.endsWith('.luau')) {
57
+ } else if (entry.name.endsWith('.lua')) {
58
58
  const source = await fs.readFile(fullPath, 'utf-8');
59
- const name = path.basename(entry.name, '.luau');
59
+ const name = path.basename(entry.name, '.lua');
60
60
  const relativePath = path.relative(baseDir, fullPath);
61
61
  const hash = createHash('sha256').update(source).digest('hex');
62
62
  results.push({ name, source, relativePath, hash });
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export type {
55
55
  DataModelInstance,
56
56
  ErrorCode,
57
57
  SerializedValue,
58
+ SerializedReturnValue,
58
59
  } from './server/web-socket-protocol.js';
59
60
 
60
61
  // Lower-level exports for advanced usage / testing
@@ -17,7 +17,7 @@ vi.mock('@quenty/nevermore-template-helpers', () => ({
17
17
  cleanupAsync: vi.fn(async () => {}),
18
18
  })),
19
19
  },
20
- resolvePackagePath: vi.fn((..._args: any[]) => '/fake/transform-script.luau'),
20
+ resolvePackagePath: vi.fn((..._args: any[]) => '/fake/transform-script.lua'),
21
21
  resolveTemplatePath: vi.fn((..._args: any[]) => '/fake/default.project.json'),
22
22
  }));
23
23
 
@@ -22,7 +22,7 @@ vi.mock('@quenty/nevermore-template-helpers', () => ({
22
22
  cleanupAsync: vi.fn(async () => {}),
23
23
  })),
24
24
  },
25
- resolvePackagePath: vi.fn((..._args: any[]) => '/fake/transform-script.luau'),
25
+ resolvePackagePath: vi.fn((..._args: any[]) => '/fake/transform-script.lua'),
26
26
  resolveTemplatePath: vi.fn((..._args: any[]) => '/fake/default.project.json'),
27
27
  }));
28
28
 
@@ -285,6 +285,55 @@ describe('StudioBridgeServer', () => {
285
285
  expect(result.logs).toContain('Script threw: boom');
286
286
  });
287
287
 
288
+ it('carries the script return values off the scriptComplete', async () => {
289
+ const ready = await createReadyServer();
290
+ server = ready.server;
291
+ client = ready.client;
292
+
293
+ const resultPromise = server.executeAsync({
294
+ scriptContent: 'return { counts = { passed = 7 } }',
295
+ });
296
+
297
+ await new Promise<void>((resolve) => {
298
+ client!.on('message', (raw) => {
299
+ const data = JSON.parse(
300
+ typeof raw === 'string' ? raw : raw.toString('utf-8')
301
+ );
302
+ if (data.type === 'execute') resolve();
303
+ });
304
+ });
305
+
306
+ client!.send(
307
+ JSON.stringify({
308
+ type: 'scriptComplete',
309
+ sessionId: ready.sessionId,
310
+ payload: {
311
+ success: true,
312
+ returnValues: [{ counts: { passed: 7 } }],
313
+ },
314
+ })
315
+ );
316
+
317
+ const result = await resultPromise;
318
+ expect(result.returnValues).toEqual([{ counts: { passed: 7 } }]);
319
+ });
320
+
321
+ it('leaves returnValues undefined when the run times out', async () => {
322
+ // Nothing reported a return channel, so the value is unknown rather than
323
+ // empty — a caller can still fall back to whatever output arrived.
324
+ const ready = await createReadyServer();
325
+ server = ready.server;
326
+ client = ready.client;
327
+
328
+ const result = await server.executeAsync({
329
+ scriptContent: 'while true do end',
330
+ timeoutMs: 200,
331
+ });
332
+
333
+ expect(result.success).toBe(false);
334
+ expect(result.returnValues).toBeUndefined();
335
+ });
336
+
288
337
  it('returns failure when client disconnects during execution', async () => {
289
338
  const ready = await createReadyServer();
290
339
  server = ready.server;
@@ -26,6 +26,7 @@ import {
26
26
  type Capability,
27
27
  type PluginMessage,
28
28
  type ServerMessage,
29
+ type SerializedReturnValue,
29
30
  encodeMessage,
30
31
  decodePluginMessage,
31
32
  } from './web-socket-protocol.js';
@@ -52,7 +53,7 @@ const defaultProjectPath = resolveTemplatePath(
52
53
  const sessionAttributeTransformScript = resolvePackagePath(
53
54
  import.meta.url,
54
55
  'build-scripts',
55
- 'transform-add-session-attribute.luau'
56
+ 'transform-add-session-attribute.lua'
56
57
  );
57
58
 
58
59
  function readServerVersion(): string {
@@ -106,6 +107,14 @@ export interface ExecuteOptions {
106
107
  export interface StudioBridgeResult {
107
108
  success: boolean;
108
109
  logs: string;
110
+ /**
111
+ * Everything the script returned, in order, marshalled by the plugin.
112
+ * Absent whenever no `scriptComplete` carrying a return channel arrived —
113
+ * a timeout, a disconnect, a plugin error, or an older plugin build — which
114
+ * is what separates "we never learned what it returned" from `[]`, "it
115
+ * returned nothing".
116
+ */
117
+ returnValues?: SerializedReturnValue[];
109
118
  }
110
119
 
111
120
  type BridgeState =
@@ -439,7 +448,7 @@ export class StudioBridgeServer {
439
448
  }
440
449
 
441
450
  /**
442
- * Ensure action modules (like `execute.luau`) are synced to the plugin
451
+ * Ensure action modules (like `execute.lua`) are synced to the plugin
443
452
  * before first use. Uses `syncActions` to check which actions the plugin
444
453
  * is missing, then registers them via `registerAction`.
445
454
  *
@@ -797,6 +806,7 @@ export class StudioBridgeServer {
797
806
  finish({
798
807
  success: msg.payload.success,
799
808
  logs: logLines.join('\n'),
809
+ returnValues: msg.payload.returnValues,
800
810
  });
801
811
  break;
802
812
  }
@@ -47,6 +47,70 @@ describe('decodePluginMessage', () => {
47
47
  });
48
48
  expect(msg).not.toHaveProperty('requestId');
49
49
  });
50
+
51
+ it('carries returnValues through unchanged, nesting included', () => {
52
+ const msg = roundTripPlugin({
53
+ type: 'scriptComplete',
54
+ sessionId: 'sess-1',
55
+ payload: {
56
+ success: true,
57
+ returnValues: [
58
+ { slug: 'maid', counts: { passed: 1014, failed: 0 } },
59
+ 'str',
60
+ 42,
61
+ true,
62
+ { type: 'Vector3', value: [1, 2, 3] },
63
+ [1, 2],
64
+ ],
65
+ },
66
+ });
67
+ expect(msg?.type).toBe('scriptComplete');
68
+ expect(
69
+ (msg as { payload: { returnValues?: unknown[] } }).payload.returnValues
70
+ ).toEqual([
71
+ { slug: 'maid', counts: { passed: 1014, failed: 0 } },
72
+ 'str',
73
+ 42,
74
+ true,
75
+ { type: 'Vector3', value: [1, 2, 3] },
76
+ [1, 2],
77
+ ]);
78
+ });
79
+
80
+ it('leaves returnValues undefined when the plugin sent none', () => {
81
+ // Distinguishable from an empty array: nothing reported a return channel,
82
+ // so a caller can still fall back to the run's printed output.
83
+ const msg = roundTripPlugin({
84
+ type: 'scriptComplete',
85
+ sessionId: 'sess-1',
86
+ payload: { success: true },
87
+ });
88
+ expect(
89
+ (msg as { payload: { returnValues?: unknown[] } }).payload.returnValues
90
+ ).toBeUndefined();
91
+ });
92
+
93
+ it('preserves an empty returnValues array', () => {
94
+ const msg = roundTripPlugin({
95
+ type: 'scriptComplete',
96
+ sessionId: 'sess-1',
97
+ payload: { success: true, returnValues: [] },
98
+ });
99
+ expect(
100
+ (msg as { payload: { returnValues?: unknown[] } }).payload.returnValues
101
+ ).toEqual([]);
102
+ });
103
+
104
+ it('ignores a returnValues field that is not an array', () => {
105
+ const msg = roundTripPlugin({
106
+ type: 'scriptComplete',
107
+ sessionId: 'sess-1',
108
+ payload: { success: true, returnValues: 'nope' },
109
+ });
110
+ expect(
111
+ (msg as { payload: { returnValues?: unknown[] } }).payload.returnValues
112
+ ).toBeUndefined();
113
+ });
50
114
  });
51
115
 
52
116
  describe('register', () => {
@@ -69,6 +69,22 @@ export type SerializedValue =
69
69
  | { type: 'Instance'; className: string; path: string }
70
70
  | { type: 'Unsupported'; typeName: string; toString: string };
71
71
 
72
+ /**
73
+ * A value returned by an executed script, marshalled for the wire: everything
74
+ * `SerializedValue` covers, plus tables, which the plugin walks recursively
75
+ * (array-like tables stay arrays, anything else becomes an object with string
76
+ * keys).
77
+ *
78
+ * `{ type: 'Nil' }` stands in for a `nil` return value: a JSON array cannot
79
+ * hold a hole, and dropping the entry would silently shift every value after
80
+ * it, misreporting which value was returned where.
81
+ */
82
+ export type SerializedReturnValue =
83
+ | SerializedValue
84
+ | { type: 'Nil' }
85
+ | SerializedReturnValue[]
86
+ | { [key: string]: SerializedReturnValue };
87
+
72
88
  export interface DataModelInstance {
73
89
  name: string;
74
90
  className: string;
@@ -99,6 +115,13 @@ export interface ScriptCompleteMessage extends BaseMessage {
99
115
  success: boolean;
100
116
  error?: string;
101
117
  output?: Array<{ level: string; body: string; timestamp: number }>;
118
+ /**
119
+ * Everything the script returned, in order, marshalled by the plugin.
120
+ * Absent when the plugin never reported a return channel (an older plugin
121
+ * build, or a script that failed before returning) — distinct from `[]`,
122
+ * which means the script ran and returned nothing.
123
+ */
124
+ returnValues?: SerializedReturnValue[];
102
125
  };
103
126
  }
104
127
 
@@ -354,6 +377,11 @@ export function decodePluginMessage(raw: string): PluginMessage | null {
354
377
  timestamp: typeof e.timestamp === 'number' ? e.timestamp : 0,
355
378
  }))
356
379
  : undefined;
380
+ // The values themselves are whatever the script returned, so they are
381
+ // carried through as-is; only the array wrapper is checked.
382
+ const returnValues = Array.isArray(payload.returnValues)
383
+ ? (payload.returnValues as SerializedReturnValue[])
384
+ : undefined;
357
385
  return {
358
386
  type: 'scriptComplete',
359
387
  sessionId,
@@ -363,6 +391,7 @@ export function decodePluginMessage(raw: string): PluginMessage | null {
363
391
  error:
364
392
  typeof payload.error === 'string' ? payload.error : undefined,
365
393
  output,
394
+ returnValues,
366
395
  },
367
396
  };
368
397
  }