@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.
- package/CHANGELOG.md +8 -0
- package/dist/src/bridge/bridge-session.d.ts.map +1 -1
- package/dist/src/bridge/bridge-session.js +1 -0
- package/dist/src/bridge/bridge-session.js.map +1 -1
- package/dist/src/bridge/bridge-session.test.js +36 -0
- package/dist/src/bridge/bridge-session.test.js.map +1 -1
- package/dist/src/bridge/types.d.ts +8 -2
- package/dist/src/bridge/types.d.ts.map +1 -1
- package/dist/src/bridge/types.js.map +1 -1
- package/dist/src/commands/framework/action-loader.d.ts +5 -5
- package/dist/src/commands/framework/action-loader.js +7 -7
- package/dist/src/commands/framework/action-loader.js.map +1 -1
- package/dist/src/commands/framework/action-loader.test.d.ts +1 -1
- package/dist/src/commands/framework/action-loader.test.js +15 -15
- package/dist/src/commands/framework/action-loader.test.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/server/plugin-detection.test.js +1 -1
- package/dist/src/server/plugin-detection.test.js.map +1 -1
- package/dist/src/server/studio-bridge-server.d.ts +10 -2
- package/dist/src/server/studio-bridge-server.d.ts.map +1 -1
- package/dist/src/server/studio-bridge-server.js +3 -2
- package/dist/src/server/studio-bridge-server.js.map +1 -1
- package/dist/src/server/studio-bridge-server.test.js +39 -1
- package/dist/src/server/studio-bridge-server.test.js.map +1 -1
- package/dist/src/server/web-socket-protocol.d.ts +22 -0
- package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
- package/dist/src/server/web-socket-protocol.js +6 -0
- package/dist/src/server/web-socket-protocol.js.map +1 -1
- package/dist/src/server/web-socket-protocol.test.js +52 -0
- package/dist/src/server/web-socket-protocol.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/bridge/bridge-session.test.ts +45 -0
- package/src/bridge/bridge-session.ts +1 -0
- package/src/bridge/types.ts +14 -1
- package/src/commands/console/exec/{execute.luau → execute.lua} +118 -3
- package/src/commands/framework/action-loader.test.ts +15 -15
- package/src/commands/framework/action-loader.ts +7 -7
- package/src/index.ts +1 -0
- package/src/server/plugin-detection.test.ts +1 -1
- package/src/server/studio-bridge-server.test.ts +50 -1
- package/src/server/studio-bridge-server.ts +12 -2
- package/src/server/web-socket-protocol.test.ts +64 -0
- package/src/server/web-socket-protocol.ts +29 -0
- package/templates/studio-bridge-plugin-test/test/{execute-handler.test.luau → execute-handler.test.lua} +111 -0
- package/templates/studio-bridge-plugin-test/test/{test-runner.luau → test-runner.lua} +10 -10
- /package/build-scripts/{transform-add-session-attribute.luau → transform-add-session-attribute.lua} +0 -0
- /package/src/commands/console/logs/{query-logs.luau → query-logs.lua} +0 -0
- /package/src/commands/explorer/query/{query-data-model.luau → query-data-model.lua} +0 -0
- /package/src/commands/process/info/{query-state.luau → query-state.lua} +0 -0
- /package/src/commands/viewport/screenshot/{capture-screenshot.luau → capture-screenshot.lua} +0 -0
- /package/templates/studio-bridge-plugin/src/Shared/{ActionRouter.luau → ActionRouter.lua} +0 -0
- /package/templates/studio-bridge-plugin/src/Shared/{DiscoveryStateMachine.luau → DiscoveryStateMachine.lua} +0 -0
- /package/templates/studio-bridge-plugin/src/Shared/{MessageBuffer.luau → MessageBuffer.lua} +0 -0
- /package/templates/studio-bridge-plugin/src/Shared/{Protocol.luau → Protocol.lua} +0 -0
- /package/templates/studio-bridge-plugin/src/Vendor/{png.luau → png.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{actions.test.luau → actions.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{discovery.test.luau → discovery.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{hash-lifecycle.test.luau → hash-lifecycle.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{integration.test.luau → integration.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{new-actions.test.luau → new-actions.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{plugin-entry.test.luau → plugin-entry.test.lua} +0 -0
- /package/templates/studio-bridge-plugin-test/test/{protocol.test.luau → protocol.test.lua} +0 -0
- /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.
|
|
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.
|
|
19
|
-
"@quenty/nevermore-cli-helpers": "1.
|
|
20
|
-
"@quenty/nevermore-template-helpers": "1.
|
|
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": "
|
|
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', () => {
|
package/src/bridge/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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({
|
|
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({
|
|
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 .
|
|
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 .
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
-
await fs.writeFile(path.join(tmpDir, 'b.
|
|
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.
|
|
66
|
-
await fs.writeFile(path.join(subDir, 'b.
|
|
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.
|
|
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.
|
|
86
|
+
path.join('nested', 'deep', 'deep.lua')
|
|
87
87
|
);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it('ignores non-.
|
|
91
|
-
await fs.writeFile(path.join(tmpDir, 'script.
|
|
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.
|
|
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.
|
|
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 `.
|
|
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 `.
|
|
6
|
-
* as its `.ts` file (e.g. `exec.
|
|
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 `.
|
|
29
|
-
* as `ActionSource` entries. Only files that end in `.
|
|
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('.
|
|
57
|
+
} else if (entry.name.endsWith('.lua')) {
|
|
58
58
|
const source = await fs.readFile(fullPath, 'utf-8');
|
|
59
|
-
const name = path.basename(entry.name, '.
|
|
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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|