@putdotio/rokit 1.7.0 → 2.0.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/AGENTS.md +90 -0
- package/CONTRIBUTING.md +52 -0
- package/README.md +80 -112
- package/SECURITY.md +38 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.mjs +2 -2
- package/dist/rokit.mjs +798 -66
- package/dist/{roku-B61mpmWt.mjs → roku-BxnS6Axs.mjs} +102 -5
- package/docs/DISTRIBUTION.md +60 -0
- package/docs/READINESS.md +80 -0
- package/docs/skills/rokit-harness/SKILL.md +40 -0
- package/examples/live-probe-channel/components/MainScene.xml +26 -0
- package/examples/live-probe-channel/manifest +5 -0
- package/examples/live-probe-channel/source/main.brs +14 -0
- package/package.json +15 -5
package/dist/rokit.mjs
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as waitForMediaPlayerState,
|
|
2
|
+
import { C as takeScreenshot, D as waitForMediaPlayerState, E as waitForActiveApp, G as readSceneGraphFailure, K as readSceneGraphStatus, S as resolvePackageOutputPath, T as validateRemoteKey, _ as querySceneGraph, a as getDeviceInfo, c as launchApp, d as queryActiveApp, f as queryEcp, i as discoverRokuDevices, k as waitForSceneGraphNode, l as packageChannel, n as assertSceneGraphNode, o as installPackage, p as queryMediaPlayer, r as checkDevice, u as pressKey, w as validateEcpPath } from "./roku-BxnS6Axs.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { NodeRuntime } from "@effect/platform-node";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { Effect, Schema } from "effect";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
|
|
10
|
+
var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
|
|
11
|
+
get message() {
|
|
12
|
+
return "ROKIT_TARGET is not set";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
|
|
16
|
+
get message() {
|
|
17
|
+
return "ROKIT_PASSWORD is not set";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
|
|
21
|
+
const renderError = (error) => error.message;
|
|
22
|
+
const normalizeError = (error) => {
|
|
23
|
+
if (isRokitError(error)) return error;
|
|
24
|
+
return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
|
|
25
|
+
};
|
|
26
|
+
const isRokitError = (error) => error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/runtime.ts
|
|
29
|
+
const appDir = process.cwd();
|
|
30
|
+
const envPath = join(join(appDir, ".rokit"), ".env");
|
|
7
31
|
const loadLocalEnv = () => {
|
|
8
32
|
if (existsSync(envPath)) process.loadEnvFile(envPath);
|
|
9
33
|
};
|
|
@@ -15,17 +39,41 @@ const loadEnv = () => ({
|
|
|
15
39
|
});
|
|
16
40
|
const requireTarget = (env) => {
|
|
17
41
|
const target = env.target?.trim();
|
|
18
|
-
if (!target)
|
|
42
|
+
if (!target) throw MissingTarget.make({});
|
|
19
43
|
return normalizeTarget(target);
|
|
20
44
|
};
|
|
21
45
|
const requirePassword = (env) => {
|
|
22
46
|
const password = env.password;
|
|
23
|
-
if (!password)
|
|
47
|
+
if (!password) throw MissingPassword.make({});
|
|
24
48
|
return password;
|
|
25
49
|
};
|
|
26
|
-
|
|
50
|
+
const resolveOutputPath = (path, label) => {
|
|
51
|
+
rejectUnsafeInput(path, label);
|
|
52
|
+
const resolved = resolve(appDir, path);
|
|
53
|
+
const relativePath = relative(appDir, resolved);
|
|
54
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
|
|
55
|
+
return resolved;
|
|
56
|
+
};
|
|
57
|
+
const resolveFileOutputPath = (path, label) => {
|
|
58
|
+
const resolved = resolveOutputPath(path, label);
|
|
59
|
+
if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
|
|
60
|
+
return resolved;
|
|
61
|
+
};
|
|
62
|
+
const rejectUnsafeInput = (value, label) => {
|
|
63
|
+
if ([...value].some((character) => {
|
|
64
|
+
const code = character.charCodeAt(0);
|
|
65
|
+
return code < 32 || code === 127;
|
|
66
|
+
})) fail(`${label} contains control characters`);
|
|
67
|
+
};
|
|
68
|
+
const rejectUnsafeEcpPath = (value) => {
|
|
69
|
+
try {
|
|
70
|
+
validateEcpPath(value);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
fail(formatErrorMessage(error));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
27
75
|
const fail = (message) => {
|
|
28
|
-
throw
|
|
76
|
+
throw InvalidInput.make({ message });
|
|
29
77
|
};
|
|
30
78
|
const formatErrorMessage = (error) => {
|
|
31
79
|
if (error instanceof Error) return error.message;
|
|
@@ -41,38 +89,83 @@ const parseTimeout = (value) => {
|
|
|
41
89
|
//#endregion
|
|
42
90
|
//#region src/cli.ts
|
|
43
91
|
const packageJson = createRequire(import.meta.url)("../package.json");
|
|
44
|
-
const
|
|
92
|
+
const mainEffect = Effect.fn("mainEffect")(function* (argv = process.argv.slice(2)) {
|
|
45
93
|
let outputMode = inferOutputMode(argv);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
94
|
+
let fields = [];
|
|
95
|
+
yield* Effect.tryPromise({
|
|
96
|
+
try: async () => {
|
|
97
|
+
await runMain(argv, (mode) => {
|
|
98
|
+
outputMode = mode;
|
|
99
|
+
}, (nextFields) => {
|
|
100
|
+
fields = nextFields;
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
catch: normalizeError
|
|
104
|
+
}).pipe(Effect.catch((error) => Effect.sync(() => {
|
|
105
|
+
printError(outputMode, renderError(error), fields);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
})));
|
|
108
|
+
});
|
|
109
|
+
const runMain = async (argv, setOutputMode, setFields) => {
|
|
110
|
+
const options = parseGlobalOptions(argv);
|
|
111
|
+
const fields = options.fields;
|
|
112
|
+
setOutputMode(options.outputMode);
|
|
113
|
+
setFields(fields);
|
|
114
|
+
const firstArg = options.inputJson === void 0 ? options.args[0] : "input-json";
|
|
115
|
+
if (options.inputJson === void 0 && (!firstArg || firstArg === "--help" || firstArg === "-h")) {
|
|
116
|
+
printHelp();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (options.inputJson === void 0 && (firstArg === "--version" || firstArg === "-v")) {
|
|
120
|
+
console.log(packageJson.version);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const command = parseCommand(options);
|
|
124
|
+
let context;
|
|
125
|
+
if (commandNeedsTarget(command, options.dryRun)) {
|
|
58
126
|
loadLocalEnv();
|
|
59
127
|
const env = loadEnv();
|
|
60
|
-
|
|
61
|
-
const result = await runCommand({
|
|
128
|
+
context = {
|
|
62
129
|
password: env.password,
|
|
63
|
-
target,
|
|
130
|
+
target: requireTarget(env),
|
|
64
131
|
timeoutMs: env.timeoutMs,
|
|
65
132
|
username: env.username
|
|
66
|
-
}
|
|
67
|
-
printResult(options.outputMode, result);
|
|
68
|
-
} catch (error) {
|
|
69
|
-
printError(outputMode, formatErrorMessage(error));
|
|
70
|
-
process.exitCode = 1;
|
|
133
|
+
};
|
|
71
134
|
}
|
|
135
|
+
const result = await runCommand(context, command, options.dryRun);
|
|
136
|
+
printResult(options.outputMode, result, fields);
|
|
72
137
|
};
|
|
73
|
-
const runCommand = async (context, command) => {
|
|
138
|
+
const runCommand = async (context, command, dryRun) => {
|
|
139
|
+
if (command.name === "describe") return {
|
|
140
|
+
command: command.name,
|
|
141
|
+
data: describeCli(),
|
|
142
|
+
status: "ok"
|
|
143
|
+
};
|
|
144
|
+
if (command.name === "discover") {
|
|
145
|
+
if (dryRun) return dryRunResult(command.name, { timeoutMs: command.timeoutMs ?? 3e3 });
|
|
146
|
+
const devices = await discoverRokuDevices(command.timeoutMs);
|
|
147
|
+
return {
|
|
148
|
+
command: command.name,
|
|
149
|
+
data: { devices },
|
|
150
|
+
message: devices.length === 0 ? "no Roku devices discovered" : devices.map((device) => `${device.target ?? "unknown"} ${device.location}`).join("\n"),
|
|
151
|
+
status: "ok"
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (command.name === "package") {
|
|
155
|
+
const outputPath = resolveFileOutputPath(command.outputPath, "package output path");
|
|
156
|
+
if (dryRun) return dryRunResult(command.name, { path: resolvePackageOutputPath(outputPath) });
|
|
157
|
+
const result = await packageChannel(outputPath);
|
|
158
|
+
return {
|
|
159
|
+
command: command.name,
|
|
160
|
+
data: result,
|
|
161
|
+
message: `package: ${result.path}`,
|
|
162
|
+
status: "ok"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (dryRun && commandSupportsDryRun(command)) return dryRunResult(command.name, dryRunData(command));
|
|
166
|
+
const deviceContext = requireContext(context);
|
|
74
167
|
if (command.name === "check") {
|
|
75
|
-
const summary = await checkDevice(
|
|
168
|
+
const summary = await checkDevice(deviceContext);
|
|
76
169
|
return {
|
|
77
170
|
command: command.name,
|
|
78
171
|
data: summary,
|
|
@@ -86,11 +179,11 @@ const runCommand = async (context, command) => {
|
|
|
86
179
|
}
|
|
87
180
|
if (command.name === "device-info") return {
|
|
88
181
|
command: command.name,
|
|
89
|
-
data: await getDeviceInfo(
|
|
182
|
+
data: await getDeviceInfo(deviceContext),
|
|
90
183
|
status: "ok"
|
|
91
184
|
};
|
|
92
185
|
if (command.name === "active-app") {
|
|
93
|
-
const app = await queryActiveApp(
|
|
186
|
+
const app = await queryActiveApp(deviceContext);
|
|
94
187
|
return {
|
|
95
188
|
command: command.name,
|
|
96
189
|
data: app,
|
|
@@ -99,7 +192,7 @@ const runCommand = async (context, command) => {
|
|
|
99
192
|
};
|
|
100
193
|
}
|
|
101
194
|
if (command.name === "media-player") {
|
|
102
|
-
const mediaPlayer = await queryMediaPlayer(
|
|
195
|
+
const mediaPlayer = await queryMediaPlayer(deviceContext);
|
|
103
196
|
return {
|
|
104
197
|
command: command.name,
|
|
105
198
|
data: mediaPlayer,
|
|
@@ -108,7 +201,7 @@ const runCommand = async (context, command) => {
|
|
|
108
201
|
};
|
|
109
202
|
}
|
|
110
203
|
if (command.name === "wait-media-player") {
|
|
111
|
-
const mediaPlayer = await waitForMediaPlayerState(
|
|
204
|
+
const mediaPlayer = await waitForMediaPlayerState(deviceContext, command.state, command.timeoutMs);
|
|
112
205
|
return {
|
|
113
206
|
command: command.name,
|
|
114
207
|
data: mediaPlayer,
|
|
@@ -117,7 +210,7 @@ const runCommand = async (context, command) => {
|
|
|
117
210
|
};
|
|
118
211
|
}
|
|
119
212
|
if (command.name === "wait-active") {
|
|
120
|
-
const app = await waitForActiveApp(
|
|
213
|
+
const app = await waitForActiveApp(deviceContext, command.appId, command.timeoutMs);
|
|
121
214
|
return {
|
|
122
215
|
command: command.name,
|
|
123
216
|
data: app,
|
|
@@ -126,7 +219,11 @@ const runCommand = async (context, command) => {
|
|
|
126
219
|
};
|
|
127
220
|
}
|
|
128
221
|
if (command.name === "launch") {
|
|
129
|
-
|
|
222
|
+
if (dryRun) return dryRunResult(command.name, {
|
|
223
|
+
appId: command.args.appId,
|
|
224
|
+
params: Object.fromEntries(command.args.params)
|
|
225
|
+
});
|
|
226
|
+
const app = await launchApp(deviceContext, command.args.appId, command.args.params);
|
|
130
227
|
return {
|
|
131
228
|
command: command.name,
|
|
132
229
|
data: app,
|
|
@@ -135,24 +232,43 @@ const runCommand = async (context, command) => {
|
|
|
135
232
|
};
|
|
136
233
|
}
|
|
137
234
|
if (command.name === "press") {
|
|
235
|
+
if (dryRun) return dryRunResult(command.name, {
|
|
236
|
+
delayMs: command.args.delayMs,
|
|
237
|
+
keys: command.args.keys,
|
|
238
|
+
maxAttempts: command.args.maxAttempts,
|
|
239
|
+
until: command.args.until ? formatNodeData(command.args.until) : void 0
|
|
240
|
+
});
|
|
138
241
|
const pressed = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
242
|
+
let attempts = 0;
|
|
243
|
+
while (attempts < command.args.maxAttempts) {
|
|
244
|
+
attempts += 1;
|
|
245
|
+
for (const [index, key] of command.args.keys.entries()) {
|
|
246
|
+
if ((index > 0 || attempts > 1) && command.args.delayMs > 0) await sleep(command.args.delayMs);
|
|
247
|
+
await pressKey(deviceContext, key);
|
|
248
|
+
pressed.push(key);
|
|
249
|
+
}
|
|
250
|
+
if (!command.args.until) break;
|
|
251
|
+
try {
|
|
252
|
+
await assertSceneGraphNode(deviceContext, command.args.until.nodeName, command.args.until.expectation);
|
|
253
|
+
break;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (attempts >= command.args.maxAttempts) throw error;
|
|
256
|
+
}
|
|
143
257
|
}
|
|
144
258
|
return {
|
|
145
259
|
command: command.name,
|
|
146
260
|
data: {
|
|
261
|
+
attempts,
|
|
147
262
|
delayMs: command.args.delayMs,
|
|
148
|
-
keys: pressed
|
|
263
|
+
keys: pressed,
|
|
264
|
+
until: command.args.until ? formatNodeData(command.args.until) : void 0
|
|
149
265
|
},
|
|
150
266
|
message: pressed.map((key) => `pressed: ${key}`).join("\n"),
|
|
151
267
|
status: "ok"
|
|
152
268
|
};
|
|
153
269
|
}
|
|
154
270
|
if (command.name === "query") {
|
|
155
|
-
const body = await queryEcp(
|
|
271
|
+
const body = await queryEcp(deviceContext, command.path);
|
|
156
272
|
return {
|
|
157
273
|
command: command.name,
|
|
158
274
|
data: {
|
|
@@ -164,7 +280,7 @@ const runCommand = async (context, command) => {
|
|
|
164
280
|
};
|
|
165
281
|
}
|
|
166
282
|
if (command.name === "sgnodes") {
|
|
167
|
-
const body = await querySceneGraph(
|
|
283
|
+
const body = await querySceneGraph(deviceContext);
|
|
168
284
|
return {
|
|
169
285
|
command: command.name,
|
|
170
286
|
data: { body },
|
|
@@ -173,7 +289,7 @@ const runCommand = async (context, command) => {
|
|
|
173
289
|
};
|
|
174
290
|
}
|
|
175
291
|
if (command.name === "assert-node") {
|
|
176
|
-
await assertSceneGraphNode(
|
|
292
|
+
await assertSceneGraphNode(deviceContext, command.args.nodeName, command.args.expectation);
|
|
177
293
|
return {
|
|
178
294
|
command: command.name,
|
|
179
295
|
data: formatNodeData(command.args),
|
|
@@ -182,7 +298,7 @@ const runCommand = async (context, command) => {
|
|
|
182
298
|
};
|
|
183
299
|
}
|
|
184
300
|
if (command.name === "wait-node") {
|
|
185
|
-
await waitForSceneGraphNode(
|
|
301
|
+
await waitForSceneGraphNode(deviceContext, command.args.nodeName, command.args.expectation, command.args.timeoutMs);
|
|
186
302
|
return {
|
|
187
303
|
command: command.name,
|
|
188
304
|
data: formatNodeData(command.args),
|
|
@@ -191,23 +307,59 @@ const runCommand = async (context, command) => {
|
|
|
191
307
|
};
|
|
192
308
|
}
|
|
193
309
|
if (command.name === "screenshot") {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
310
|
+
const path = resolveFileOutputPath(command.outputPath, "screenshot output path");
|
|
311
|
+
if (dryRun) return dryRunResult(command.name, { path });
|
|
312
|
+
const password = requirePassword(deviceContext);
|
|
313
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
314
|
+
const screenshotPath = await takeScreenshot({
|
|
315
|
+
...deviceContext,
|
|
198
316
|
password
|
|
199
|
-
},
|
|
317
|
+
}, path);
|
|
318
|
+
return {
|
|
319
|
+
command: command.name,
|
|
320
|
+
data: { path: screenshotPath },
|
|
321
|
+
message: `screenshot: ${screenshotPath}`,
|
|
322
|
+
status: "ok"
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (command.name === "snapshot") {
|
|
326
|
+
const data = await collectSnapshot(deviceContext);
|
|
327
|
+
return observationResult(command.name, data);
|
|
328
|
+
}
|
|
329
|
+
if (command.name === "proof") {
|
|
330
|
+
const outputDir = resolveOutputPath(command.outputDir, "proof output directory");
|
|
331
|
+
if (dryRun) return dryRunResult(command.name, {
|
|
332
|
+
outputDir,
|
|
333
|
+
screenshot: command.screenshot
|
|
334
|
+
});
|
|
335
|
+
const data = await writeProof(deviceContext, outputDir, command.screenshot);
|
|
336
|
+
return {
|
|
337
|
+
command: command.name,
|
|
338
|
+
data,
|
|
339
|
+
...partialObservationMetadata(data.snapshot),
|
|
340
|
+
message: data.artifacts.map((artifact) => `${artifact.kind}: ${artifact.path}`).join("\n"),
|
|
341
|
+
status: "ok"
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (command.name === "wait-ready") {
|
|
345
|
+
const data = await waitForReady(deviceContext, command);
|
|
346
|
+
const failedObservations = data.sceneGraph.status === "failed" ? ["sceneGraph"] : [];
|
|
200
347
|
return {
|
|
201
348
|
command: command.name,
|
|
202
|
-
data
|
|
203
|
-
|
|
349
|
+
data,
|
|
350
|
+
...failedObservations.length > 0 ? {
|
|
351
|
+
failedObservations,
|
|
352
|
+
partial: true
|
|
353
|
+
} : void 0,
|
|
354
|
+
message: `ready: active app ${data.activeApp.id}`,
|
|
204
355
|
status: "ok"
|
|
205
356
|
};
|
|
206
357
|
}
|
|
207
358
|
if (command.name === "install") {
|
|
208
|
-
|
|
359
|
+
if (dryRun) return dryRunResult(command.name, { zipPath: command.zipPath });
|
|
360
|
+
const password = requirePassword(deviceContext);
|
|
209
361
|
const message = await installPackage({
|
|
210
|
-
...
|
|
362
|
+
...deviceContext,
|
|
211
363
|
password
|
|
212
364
|
}, command.zipPath);
|
|
213
365
|
return {
|
|
@@ -217,13 +369,191 @@ const runCommand = async (context, command) => {
|
|
|
217
369
|
status: "ok"
|
|
218
370
|
};
|
|
219
371
|
}
|
|
220
|
-
throw new Error(
|
|
372
|
+
throw new Error("unsupported command");
|
|
373
|
+
};
|
|
374
|
+
const requireContext = (context) => {
|
|
375
|
+
if (!context) return fail("ROKIT_TARGET is not set");
|
|
376
|
+
return context;
|
|
377
|
+
};
|
|
378
|
+
const dryRunResult = (command, data) => ({
|
|
379
|
+
command,
|
|
380
|
+
data,
|
|
381
|
+
dryRun: true,
|
|
382
|
+
message: `dry-run: ${command}`,
|
|
383
|
+
status: "ok"
|
|
384
|
+
});
|
|
385
|
+
const observe = async (read) => {
|
|
386
|
+
try {
|
|
387
|
+
return {
|
|
388
|
+
data: await read(),
|
|
389
|
+
status: "ok"
|
|
390
|
+
};
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return {
|
|
393
|
+
error: { message: renderError(normalizeError(error)) },
|
|
394
|
+
status: "failed"
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const collectSnapshot = async (context) => {
|
|
399
|
+
const sceneGraph = await observe(async () => await querySceneGraph(context));
|
|
400
|
+
const sceneGraphBody = sceneGraph.status === "ok" ? sceneGraph.data : void 0;
|
|
401
|
+
return {
|
|
402
|
+
activeApp: await observe(async () => await queryActiveApp(context)),
|
|
403
|
+
device: await observe(async () => await checkDevice(context)),
|
|
404
|
+
mediaPlayer: await observe(async () => await queryMediaPlayer(context)),
|
|
405
|
+
sceneGraph: sceneGraphBody === void 0 ? sceneGraph : {
|
|
406
|
+
data: {
|
|
407
|
+
failure: readSceneGraphFailure(sceneGraphBody),
|
|
408
|
+
status: readSceneGraphStatus(sceneGraphBody)
|
|
409
|
+
},
|
|
410
|
+
status: "ok"
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
const observationResult = (command, data) => ({
|
|
415
|
+
command,
|
|
416
|
+
data,
|
|
417
|
+
...partialObservationMetadata(data),
|
|
418
|
+
status: "ok"
|
|
419
|
+
});
|
|
420
|
+
const partialObservationMetadata = (snapshot) => {
|
|
421
|
+
const failedObservations = failedSnapshotObservations(snapshot);
|
|
422
|
+
return failedObservations.length === 0 ? {} : {
|
|
423
|
+
failedObservations,
|
|
424
|
+
partial: true
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
const failedSnapshotObservations = (snapshot) => {
|
|
428
|
+
const failures = [];
|
|
429
|
+
if (snapshot.activeApp.status === "failed") failures.push("activeApp");
|
|
430
|
+
if (snapshot.device.status === "failed") failures.push("device");
|
|
431
|
+
if (snapshot.mediaPlayer.status === "failed") failures.push("mediaPlayer");
|
|
432
|
+
if (snapshot.sceneGraph.status === "failed") failures.push("sceneGraph");
|
|
433
|
+
return failures;
|
|
434
|
+
};
|
|
435
|
+
const writeProof = async (context, outputDir, includeScreenshot) => {
|
|
436
|
+
mkdirSync(outputDir, { recursive: true });
|
|
437
|
+
const artifacts = [];
|
|
438
|
+
const writeJson = (name, value) => {
|
|
439
|
+
const path = `${outputDir}/${name}.json`;
|
|
440
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
441
|
+
artifacts.push({
|
|
442
|
+
kind: "json",
|
|
443
|
+
path
|
|
444
|
+
});
|
|
445
|
+
return path;
|
|
446
|
+
};
|
|
447
|
+
const snapshot = await collectSnapshot(context);
|
|
448
|
+
writeJson("summary", snapshot);
|
|
449
|
+
const sceneGraph = await observe(async () => await querySceneGraph(context));
|
|
450
|
+
if (sceneGraph.status === "ok") {
|
|
451
|
+
const path = `${outputDir}/sgnodes.xml`;
|
|
452
|
+
writeFileSync(path, sceneGraph.data);
|
|
453
|
+
artifacts.push({
|
|
454
|
+
kind: "xml",
|
|
455
|
+
path
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
writeJson("device-info", await observe(async () => await getDeviceInfo(context)));
|
|
459
|
+
writeJson("active-app", await observe(async () => await queryActiveApp(context)));
|
|
460
|
+
writeJson("media-player", await observe(async () => await queryMediaPlayer(context)));
|
|
461
|
+
if (includeScreenshot) {
|
|
462
|
+
const password = requirePassword(context);
|
|
463
|
+
const path = await takeScreenshot({
|
|
464
|
+
...context,
|
|
465
|
+
password
|
|
466
|
+
}, `${outputDir}/screenshot.png`);
|
|
467
|
+
artifacts.push({
|
|
468
|
+
kind: "screenshot",
|
|
469
|
+
path
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
artifacts,
|
|
474
|
+
outputDir,
|
|
475
|
+
snapshot
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
const waitForReady = async (context, command) => {
|
|
479
|
+
const activeApp = await waitForActiveApp(context, command.appId, command.timeoutMs);
|
|
480
|
+
const sceneGraph = await observe(async () => querySceneGraph(context, {
|
|
481
|
+
attempts: 3,
|
|
482
|
+
requireComplete: true
|
|
483
|
+
}));
|
|
484
|
+
if (command.node) await waitForSceneGraphNode(context, command.node.nodeName, command.node.expectation, command.node.timeoutMs ?? command.timeoutMs);
|
|
485
|
+
return {
|
|
486
|
+
activeApp,
|
|
487
|
+
mediaPlayer: command.mediaState ? {
|
|
488
|
+
data: await waitForMediaPlayerState(context, command.mediaState, command.timeoutMs),
|
|
489
|
+
status: "ok"
|
|
490
|
+
} : await observe(async () => queryMediaPlayer(context)),
|
|
491
|
+
sceneGraph: sceneGraph.status === "ok" ? {
|
|
492
|
+
data: {
|
|
493
|
+
failure: readSceneGraphFailure(sceneGraph.data),
|
|
494
|
+
status: readSceneGraphStatus(sceneGraph.data)
|
|
495
|
+
},
|
|
496
|
+
status: "ok"
|
|
497
|
+
} : sceneGraph
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
const commandNeedsTarget = (command, dryRun) => {
|
|
501
|
+
if (command.name === "describe" || command.name === "discover" || command.name === "package") return false;
|
|
502
|
+
return !(dryRun && commandSupportsDryRun(command));
|
|
503
|
+
};
|
|
504
|
+
const commandSupportsDryRun = (command) => command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
|
|
505
|
+
const dryRunData = (command) => {
|
|
506
|
+
if (command.name === "launch") return {
|
|
507
|
+
appId: command.args.appId,
|
|
508
|
+
params: Object.fromEntries(command.args.params)
|
|
509
|
+
};
|
|
510
|
+
if (command.name === "press") {
|
|
511
|
+
for (const key of command.args.keys) validateRemoteKey(key);
|
|
512
|
+
return {
|
|
513
|
+
delayMs: command.args.delayMs,
|
|
514
|
+
keys: command.args.keys,
|
|
515
|
+
maxAttempts: command.args.maxAttempts,
|
|
516
|
+
until: command.args.until ? formatNodeData(command.args.until) : void 0
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (command.name === "screenshot") return { path: resolveFileOutputPath(command.outputPath, "screenshot output path") };
|
|
520
|
+
if (command.name === "proof") return {
|
|
521
|
+
outputDir: resolveOutputPath(command.outputDir, "proof output directory"),
|
|
522
|
+
screenshot: command.screenshot
|
|
523
|
+
};
|
|
524
|
+
if (command.name === "package") return { path: resolveOutputPath(command.outputPath, "package output path") };
|
|
525
|
+
if (command.name === "install") return { zipPath: command.zipPath };
|
|
526
|
+
return {};
|
|
221
527
|
};
|
|
222
528
|
const parseGlobalOptions = (argv) => {
|
|
223
529
|
const args = [];
|
|
224
|
-
let
|
|
530
|
+
let dryRun = false;
|
|
531
|
+
let fields = [];
|
|
532
|
+
let inputJson;
|
|
533
|
+
let outputMode = defaultOutputMode();
|
|
225
534
|
for (let index = 0; index < argv.length; index += 1) {
|
|
226
535
|
const arg = argv[index];
|
|
536
|
+
if (arg === "--dry-run") {
|
|
537
|
+
dryRun = true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (arg === "--fields") {
|
|
541
|
+
const value = argv[index + 1];
|
|
542
|
+
if (!value) fail("usage: rokit [--fields field[,field...]] <command>");
|
|
543
|
+
fields = value.split(",").map((field) => field.trim()).filter((field) => field.length > 0);
|
|
544
|
+
outputMode = "json";
|
|
545
|
+
index += 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (arg === "--input-json") {
|
|
549
|
+
const value = argv[index + 1];
|
|
550
|
+
if (!value) fail("usage: rokit --input-json '<payload>'");
|
|
551
|
+
if (inputJson !== void 0) fail("usage: rokit --input-json '<payload>'");
|
|
552
|
+
inputJson = value;
|
|
553
|
+
outputMode = "json";
|
|
554
|
+
index += 1;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
227
557
|
if (arg === "--json") {
|
|
228
558
|
outputMode = "json";
|
|
229
559
|
continue;
|
|
@@ -237,18 +567,27 @@ const parseGlobalOptions = (argv) => {
|
|
|
237
567
|
}
|
|
238
568
|
args.push(arg);
|
|
239
569
|
}
|
|
570
|
+
if (inputJson !== void 0 && args.length > 0) fail("usage: rokit --input-json '<payload>'");
|
|
240
571
|
return {
|
|
241
572
|
args,
|
|
573
|
+
dryRun,
|
|
574
|
+
fields,
|
|
575
|
+
inputJson,
|
|
242
576
|
outputMode
|
|
243
577
|
};
|
|
244
578
|
};
|
|
245
579
|
const inferOutputMode = (argv) => {
|
|
246
580
|
if (argv.includes("--json")) return "json";
|
|
247
|
-
|
|
581
|
+
const outputIndex = argv.indexOf("--output");
|
|
582
|
+
if (argv[outputIndex + 1] === "json") return "json";
|
|
583
|
+
if (argv[outputIndex + 1] === "text") return "text";
|
|
584
|
+
if (argv.includes("--input-json") || argv.includes("--fields")) return "json";
|
|
585
|
+
return defaultOutputMode();
|
|
248
586
|
};
|
|
249
|
-
const
|
|
587
|
+
const defaultOutputMode = () => process.stdout.isTTY ? "text" : "json";
|
|
588
|
+
const printResult = (outputMode, result, fields) => {
|
|
250
589
|
if (outputMode === "json") {
|
|
251
|
-
console.log(JSON.stringify(result, null, 2));
|
|
590
|
+
console.log(JSON.stringify(applyFields(result, fields), null, 2));
|
|
252
591
|
return;
|
|
253
592
|
}
|
|
254
593
|
if (result.message !== void 0) {
|
|
@@ -257,7 +596,7 @@ const printResult = (outputMode, result) => {
|
|
|
257
596
|
}
|
|
258
597
|
console.log(JSON.stringify(result.data, null, 2));
|
|
259
598
|
};
|
|
260
|
-
const printError = (outputMode, message) => {
|
|
599
|
+
const printError = (outputMode, message, _fields) => {
|
|
261
600
|
if (outputMode === "json") {
|
|
262
601
|
console.error(JSON.stringify({
|
|
263
602
|
error: { message },
|
|
@@ -267,9 +606,15 @@ const printError = (outputMode, message) => {
|
|
|
267
606
|
}
|
|
268
607
|
console.error(message);
|
|
269
608
|
};
|
|
270
|
-
const parseCommand = (
|
|
271
|
-
|
|
609
|
+
const parseCommand = (options) => {
|
|
610
|
+
if (options.inputJson !== void 0) return parseInputJson(options.inputJson);
|
|
611
|
+
const [name, ...args] = options.args;
|
|
612
|
+
if (name === "describe") return { name };
|
|
272
613
|
if (name === "check") return { name };
|
|
614
|
+
if (name === "discover") return {
|
|
615
|
+
name,
|
|
616
|
+
timeoutMs: parseOptionalTimeout(args, "rokit discover")
|
|
617
|
+
};
|
|
273
618
|
if (name === "device-info") return { name };
|
|
274
619
|
if (name === "active-app") return { name };
|
|
275
620
|
if (name === "media-player") return { name };
|
|
@@ -282,6 +627,7 @@ const parseCommand = (argv) => {
|
|
|
282
627
|
timeoutMs: parseTimeoutOption(args.slice(1), "rokit wait-media-player <state>")
|
|
283
628
|
};
|
|
284
629
|
}
|
|
630
|
+
if (name === "wait-ready") return parseWaitReadyArgs(args);
|
|
285
631
|
if (name === "wait-active") {
|
|
286
632
|
const appId = args[0];
|
|
287
633
|
if (!appId) fail("usage: rokit wait-active <app-id> [--timeout-ms <ms>]");
|
|
@@ -302,6 +648,7 @@ const parseCommand = (argv) => {
|
|
|
302
648
|
if (name === "query") {
|
|
303
649
|
const path = args[0];
|
|
304
650
|
if (!path) fail("usage: rokit query <ecp-path>");
|
|
651
|
+
rejectUnsafeEcpPath(path);
|
|
305
652
|
return {
|
|
306
653
|
name,
|
|
307
654
|
path
|
|
@@ -320,6 +667,12 @@ const parseCommand = (argv) => {
|
|
|
320
667
|
outputPath
|
|
321
668
|
};
|
|
322
669
|
}
|
|
670
|
+
if (name === "snapshot") return { name };
|
|
671
|
+
if (name === "proof") return parseProofArgs(args);
|
|
672
|
+
if (name === "package") return {
|
|
673
|
+
name,
|
|
674
|
+
outputPath: parseOutputPath(args, "rokit package --out <zip-path>")
|
|
675
|
+
};
|
|
323
676
|
if (name === "install") {
|
|
324
677
|
const zipPath = args[0];
|
|
325
678
|
if (!zipPath) fail("usage: rokit install <zip-path>");
|
|
@@ -371,7 +724,10 @@ const parseNodeCondition = (commandName, args) => {
|
|
|
371
724
|
};
|
|
372
725
|
const parsePressArgs = (args) => {
|
|
373
726
|
let delayMs = 0;
|
|
727
|
+
let maxAttempts = 1;
|
|
728
|
+
let maxAttemptsWasProvided = false;
|
|
374
729
|
const keys = [];
|
|
730
|
+
let until;
|
|
375
731
|
for (let index = 0; index < args.length; index += 1) {
|
|
376
732
|
const arg = args[index];
|
|
377
733
|
if (arg === "--delay-ms") {
|
|
@@ -381,15 +737,254 @@ const parsePressArgs = (args) => {
|
|
|
381
737
|
index += 1;
|
|
382
738
|
continue;
|
|
383
739
|
}
|
|
740
|
+
if (arg === "--max") {
|
|
741
|
+
const value = args[index + 1];
|
|
742
|
+
if (!value) fail("usage: rokit press [--delay-ms <ms>] [--until-node ... --max <count>] <key> [key...]");
|
|
743
|
+
maxAttempts = parsePositiveInteger(value, "max attempts");
|
|
744
|
+
maxAttemptsWasProvided = true;
|
|
745
|
+
index += 1;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (arg === "--until-node") {
|
|
749
|
+
until = parseNodeCondition("press --until-node", args.slice(index + 1));
|
|
750
|
+
if (!maxAttemptsWasProvided) maxAttempts = 8;
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
384
753
|
if (arg?.startsWith("--")) fail(`Unknown press option: ${arg}`);
|
|
385
754
|
if (arg !== void 0) keys.push(arg);
|
|
386
755
|
}
|
|
387
756
|
if (keys.length === 0) fail("usage: rokit press [--delay-ms <ms>] <key> [key...]");
|
|
388
757
|
return {
|
|
389
758
|
delayMs,
|
|
390
|
-
keys
|
|
759
|
+
keys,
|
|
760
|
+
maxAttempts,
|
|
761
|
+
until
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
const parseOptionalTimeout = (args, usagePrefix) => args.length === 0 ? void 0 : parseTimeoutOption(args, usagePrefix);
|
|
765
|
+
const parseOutputPath = (args, usage) => {
|
|
766
|
+
if (args.length === 1 && args[0] !== void 0 && !args[0].startsWith("--")) return args[0];
|
|
767
|
+
if (args.length === 2 && args[0] === "--out" && args[1] !== void 0) return args[1];
|
|
768
|
+
return fail(`usage: ${usage}`);
|
|
769
|
+
};
|
|
770
|
+
const parseProofArgs = (args) => {
|
|
771
|
+
let outputDir;
|
|
772
|
+
let screenshot = false;
|
|
773
|
+
const assignOutputDir = (nextOutputDir) => {
|
|
774
|
+
if (!nextOutputDir) fail("usage: rokit proof <output-dir> [--screenshot]");
|
|
775
|
+
if (outputDir !== void 0) fail("usage: rokit proof <output-dir> [--screenshot]");
|
|
776
|
+
outputDir = nextOutputDir;
|
|
777
|
+
};
|
|
778
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
779
|
+
const arg = args[index];
|
|
780
|
+
if (arg === "--out") {
|
|
781
|
+
assignOutputDir(args[index + 1]);
|
|
782
|
+
index += 1;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (arg === "--screenshot") {
|
|
786
|
+
screenshot = true;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg?.startsWith("--")) fail(`Unknown proof option: ${arg}`);
|
|
790
|
+
assignOutputDir(arg);
|
|
791
|
+
}
|
|
792
|
+
if (!outputDir) return fail("usage: rokit proof <output-dir> [--screenshot]");
|
|
793
|
+
return {
|
|
794
|
+
name: "proof",
|
|
795
|
+
outputDir,
|
|
796
|
+
screenshot
|
|
797
|
+
};
|
|
798
|
+
};
|
|
799
|
+
const parseWaitReadyArgs = (args) => {
|
|
800
|
+
const appId = args[0];
|
|
801
|
+
if (!appId) return fail("usage: rokit wait-ready <app-id> [--media-state <state>] [--node ...] [--timeout-ms <ms>]");
|
|
802
|
+
let mediaState;
|
|
803
|
+
let node;
|
|
804
|
+
let timeoutMs;
|
|
805
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
806
|
+
const arg = args[index];
|
|
807
|
+
if (arg === "--media-state") {
|
|
808
|
+
mediaState = args[index + 1];
|
|
809
|
+
if (!mediaState) fail("usage: rokit wait-ready <app-id> --media-state <state>");
|
|
810
|
+
index += 1;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (arg === "--timeout-ms") {
|
|
814
|
+
timeoutMs = parsePositiveInteger(args[index + 1] ?? "", "timeout");
|
|
815
|
+
index += 1;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (arg === "--node") {
|
|
819
|
+
node = parseNodeCondition("wait-ready --node", args.slice(index + 1));
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
fail(`Unknown wait-ready option: ${arg ?? ""}`);
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
appId,
|
|
826
|
+
mediaState,
|
|
827
|
+
name: "wait-ready",
|
|
828
|
+
node,
|
|
829
|
+
timeoutMs: timeoutMs ?? node?.timeoutMs
|
|
830
|
+
};
|
|
831
|
+
};
|
|
832
|
+
const parseInputJson = (value) => {
|
|
833
|
+
const parsed = requireRecord(JSON.parse(value), "input JSON");
|
|
834
|
+
const command = readString(parsed, "command");
|
|
835
|
+
if (command === "describe") return { name: "describe" };
|
|
836
|
+
if (command === "discover") return {
|
|
837
|
+
name: "discover",
|
|
838
|
+
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
839
|
+
};
|
|
840
|
+
if (command === "check" || command === "device-info" || command === "active-app") return { name: command };
|
|
841
|
+
if (command === "media-player" || command === "sgnodes" || command === "snapshot") return { name: command };
|
|
842
|
+
if (command === "wait-active") return {
|
|
843
|
+
appId: readString(parsed, "appId"),
|
|
844
|
+
name: "wait-active",
|
|
845
|
+
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
846
|
+
};
|
|
847
|
+
if (command === "wait-media-player") return {
|
|
848
|
+
name: "wait-media-player",
|
|
849
|
+
state: readString(parsed, "state"),
|
|
850
|
+
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
851
|
+
};
|
|
852
|
+
if (command === "wait-ready") return {
|
|
853
|
+
appId: readString(parsed, "appId"),
|
|
854
|
+
mediaState: readOptionalString(parsed, "mediaState"),
|
|
855
|
+
name: "wait-ready",
|
|
856
|
+
node: readOptionalNodeCondition(parsed, "node"),
|
|
857
|
+
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
858
|
+
};
|
|
859
|
+
if (command === "launch") return {
|
|
860
|
+
args: {
|
|
861
|
+
appId: readString(parsed, "appId"),
|
|
862
|
+
params: readStringMap(parsed, "params")
|
|
863
|
+
},
|
|
864
|
+
name: "launch"
|
|
865
|
+
};
|
|
866
|
+
if (command === "press") {
|
|
867
|
+
const keys = readStringArray(parsed, "keys");
|
|
868
|
+
const until = readOptionalNodeCondition(parsed, "until");
|
|
869
|
+
if (keys.length === 0) fail("input JSON field must include at least one key: keys");
|
|
870
|
+
return {
|
|
871
|
+
args: {
|
|
872
|
+
delayMs: readOptionalNumber(parsed, "delayMs") ?? 0,
|
|
873
|
+
keys,
|
|
874
|
+
maxAttempts: readOptionalNumber(parsed, "maxAttempts") ?? (until === void 0 ? 1 : 8),
|
|
875
|
+
until
|
|
876
|
+
},
|
|
877
|
+
name: "press"
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
if (command === "query") {
|
|
881
|
+
const path = readString(parsed, "path");
|
|
882
|
+
rejectUnsafeEcpPath(path);
|
|
883
|
+
return {
|
|
884
|
+
name: "query",
|
|
885
|
+
path
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (command === "assert-node" || command === "wait-node") return {
|
|
889
|
+
args: {
|
|
890
|
+
expectation: readNodeExpectation(parsed),
|
|
891
|
+
nodeName: readString(parsed, "nodeName"),
|
|
892
|
+
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
893
|
+
},
|
|
894
|
+
name: command
|
|
895
|
+
};
|
|
896
|
+
if (command === "screenshot") return {
|
|
897
|
+
name: "screenshot",
|
|
898
|
+
outputPath: readString(parsed, "outputPath")
|
|
899
|
+
};
|
|
900
|
+
if (command === "proof") return {
|
|
901
|
+
name: "proof",
|
|
902
|
+
outputDir: readString(parsed, "outputDir"),
|
|
903
|
+
screenshot: readOptionalBoolean(parsed, "screenshot") ?? false
|
|
904
|
+
};
|
|
905
|
+
if (command === "package") return {
|
|
906
|
+
name: "package",
|
|
907
|
+
outputPath: readString(parsed, "outputPath")
|
|
908
|
+
};
|
|
909
|
+
if (command === "install") return {
|
|
910
|
+
name: "install",
|
|
911
|
+
zipPath: readString(parsed, "zipPath")
|
|
912
|
+
};
|
|
913
|
+
return fail(`Unknown command: ${command}`);
|
|
914
|
+
};
|
|
915
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
916
|
+
const requireRecord = (value, label) => {
|
|
917
|
+
if (!isRecord(value)) return fail(`${label} must be an object`);
|
|
918
|
+
return value;
|
|
919
|
+
};
|
|
920
|
+
const readString = (record, key) => {
|
|
921
|
+
const value = record[key];
|
|
922
|
+
if (typeof value !== "string" || value.length === 0) return fail(`input JSON is missing string field: ${key}`);
|
|
923
|
+
return value;
|
|
924
|
+
};
|
|
925
|
+
const readOptionalString = (record, key) => {
|
|
926
|
+
const value = record[key];
|
|
927
|
+
if (value === void 0) return;
|
|
928
|
+
if (typeof value !== "string") return fail(`input JSON field must be a string: ${key}`);
|
|
929
|
+
return value;
|
|
930
|
+
};
|
|
931
|
+
const readOptionalBoolean = (record, key) => {
|
|
932
|
+
const value = record[key];
|
|
933
|
+
if (value === void 0) return;
|
|
934
|
+
if (typeof value !== "boolean") return fail(`input JSON field must be a boolean: ${key}`);
|
|
935
|
+
return value;
|
|
936
|
+
};
|
|
937
|
+
const readOptionalNumber = (record, key) => {
|
|
938
|
+
const value = record[key];
|
|
939
|
+
if (value === void 0) return;
|
|
940
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return fail(`input JSON field must be a positive integer: ${key}`);
|
|
941
|
+
return value;
|
|
942
|
+
};
|
|
943
|
+
const readStringArray = (record, key) => {
|
|
944
|
+
const value = record[key];
|
|
945
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
|
|
946
|
+
return value.map((item) => String(item));
|
|
947
|
+
};
|
|
948
|
+
const readStringMap = (record, key) => {
|
|
949
|
+
const value = record[key];
|
|
950
|
+
if (value === void 0) return /* @__PURE__ */ new Map();
|
|
951
|
+
if (!isRecord(value)) return fail(`input JSON field must be an object: ${key}`);
|
|
952
|
+
const params = /* @__PURE__ */ new Map();
|
|
953
|
+
for (const [paramKey, paramValue] of Object.entries(value)) {
|
|
954
|
+
if (typeof paramValue !== "string") return fail(`input JSON map values must be strings: ${key}`);
|
|
955
|
+
params.set(paramKey, paramValue);
|
|
956
|
+
}
|
|
957
|
+
return params;
|
|
958
|
+
};
|
|
959
|
+
const readOptionalNodeCondition = (record, key) => {
|
|
960
|
+
const value = record[key];
|
|
961
|
+
if (value === void 0) return;
|
|
962
|
+
if (!isRecord(value)) return fail(`input JSON field must be an object: ${key}`);
|
|
963
|
+
return {
|
|
964
|
+
expectation: readNodeExpectation(value),
|
|
965
|
+
nodeName: readString(value, "nodeName"),
|
|
966
|
+
timeoutMs: readOptionalNumber(value, "timeoutMs")
|
|
391
967
|
};
|
|
392
968
|
};
|
|
969
|
+
const readNodeExpectation = (record) => {
|
|
970
|
+
const expectation = record.expectation;
|
|
971
|
+
if (isRecord(expectation)) {
|
|
972
|
+
if (typeof expectation.attribute === "string" && typeof expectation.value === "string") return {
|
|
973
|
+
attribute: expectation.attribute,
|
|
974
|
+
value: expectation.value
|
|
975
|
+
};
|
|
976
|
+
const state = expectation.state;
|
|
977
|
+
if (state === "visible" || state === "hidden" || state === "absent") {
|
|
978
|
+
const text = expectation.text;
|
|
979
|
+
if (text !== void 0 && typeof text !== "string") return fail("input JSON node expectation text must be a string");
|
|
980
|
+
return text === void 0 ? { state } : {
|
|
981
|
+
state,
|
|
982
|
+
text
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return fail("input JSON is missing a valid node expectation");
|
|
987
|
+
};
|
|
393
988
|
const parseTimeoutOption = (args, usagePrefix) => {
|
|
394
989
|
if (args.length === 0) return;
|
|
395
990
|
if (args.length !== 2 || args[0] !== "--timeout-ms") fail(`usage: ${usagePrefix} [--timeout-ms <ms>]`);
|
|
@@ -419,6 +1014,33 @@ const formatMediaPlayerMessage = (mediaPlayer) => {
|
|
|
419
1014
|
].join(" ")}`;
|
|
420
1015
|
};
|
|
421
1016
|
const formatMaybeMs = (value) => value === void 0 ? "unknown" : `${value}ms`;
|
|
1017
|
+
const applyFields = (value, fields) => {
|
|
1018
|
+
if (fields.length === 0) return value;
|
|
1019
|
+
const output = {};
|
|
1020
|
+
for (const field of fields) copyField(value, output, field.split("."));
|
|
1021
|
+
preserveStatusMetadata(value, output);
|
|
1022
|
+
return output;
|
|
1023
|
+
};
|
|
1024
|
+
const preserveStatusMetadata = (source, target) => {
|
|
1025
|
+
if (!isRecord(source)) return;
|
|
1026
|
+
for (const key of [
|
|
1027
|
+
"status",
|
|
1028
|
+
"partial",
|
|
1029
|
+
"failedObservations"
|
|
1030
|
+
]) if (key in source) target[key] = source[key];
|
|
1031
|
+
};
|
|
1032
|
+
const copyField = (source, target, path) => {
|
|
1033
|
+
const [head, ...tail] = path;
|
|
1034
|
+
if (!head || !isRecord(source) || !(head in source)) return;
|
|
1035
|
+
if (tail.length === 0) {
|
|
1036
|
+
target[head] = source[head];
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const child = target[head];
|
|
1040
|
+
const childTarget = isRecord(child) ? child : {};
|
|
1041
|
+
target[head] = childTarget;
|
|
1042
|
+
copyField(source[head], childTarget, tail);
|
|
1043
|
+
};
|
|
422
1044
|
const sleep = (ms) => new Promise((resolve) => {
|
|
423
1045
|
setTimeout(resolve, ms);
|
|
424
1046
|
});
|
|
@@ -441,18 +1063,125 @@ const parseLaunchArgs = (args) => {
|
|
|
441
1063
|
params
|
|
442
1064
|
};
|
|
443
1065
|
};
|
|
1066
|
+
const describeCli = () => ({
|
|
1067
|
+
automation: {
|
|
1068
|
+
dryRun: true,
|
|
1069
|
+
inputJson: true,
|
|
1070
|
+
nonTtyJsonDefault: true,
|
|
1071
|
+
outputFields: true,
|
|
1072
|
+
schemaIntrospection: true
|
|
1073
|
+
},
|
|
1074
|
+
commands: [
|
|
1075
|
+
commandSchema("describe", "Print machine-readable command schemas.", false, false, []),
|
|
1076
|
+
commandSchema("check", "Check ECP and developer installer reachability.", true, false, []),
|
|
1077
|
+
commandSchema("discover", "Discover Roku ECP devices with SSDP.", false, false, [optionField("timeoutMs", "positive-integer", "Discovery timeout in milliseconds.")]),
|
|
1078
|
+
commandSchema("device-info", "Read enhanced Roku device metadata.", true, false, []),
|
|
1079
|
+
commandSchema("active-app", "Read the foreground app.", true, false, []),
|
|
1080
|
+
commandSchema("media-player", "Read parsed /query/media-player state.", true, false, []),
|
|
1081
|
+
commandSchema("snapshot", "Read a compact state snapshot.", true, false, []),
|
|
1082
|
+
commandSchema("proof", "Write reviewable state artifacts.", true, true, [argumentField("outputDir", "path", "Directory where proof artifacts are written."), optionField("screenshot", "boolean", "Include a developer screenshot.")]),
|
|
1083
|
+
commandSchema("package", "Create a sideload ZIP from the current app root.", false, true, [optionField("outputPath", "path", "ZIP output path inside the current app root.", true)]),
|
|
1084
|
+
commandSchema("install", "Publish an existing ZIP to a developer-enabled Roku.", true, true, [argumentField("zipPath", "path", "Existing sideload ZIP path.")]),
|
|
1085
|
+
commandSchema("launch", "Launch an app by id with optional params.", true, true, [argumentField("appId", "string", "Roku application id."), optionField("params", "record<string,string>", "Launch parameters.", false, true)]),
|
|
1086
|
+
commandSchema("press", "Send remote keys, optionally until a node condition matches.", true, true, [
|
|
1087
|
+
argumentField("keys", "string[]", "Remote keys to send.", true, true),
|
|
1088
|
+
optionField("delayMs", "positive-integer", "Delay between keys in milliseconds."),
|
|
1089
|
+
optionField("maxAttempts", "positive-integer", "Maximum repeated attempts."),
|
|
1090
|
+
optionField("until", "node-condition", "SceneGraph condition that stops the loop.")
|
|
1091
|
+
]),
|
|
1092
|
+
commandSchema("query", "Read a raw ECP path.", true, false, [argumentField("path", "ecp-path", "Raw ECP path without query string or fragment.")]),
|
|
1093
|
+
commandSchema("sgnodes", "Read raw SceneGraph XML.", true, false, []),
|
|
1094
|
+
commandSchema("assert-node", "Assert one SceneGraph node condition.", true, false, [...nodeConditionFields()], [...nodeConditionInputJsonFields()]),
|
|
1095
|
+
commandSchema("wait-node", "Wait for a SceneGraph node condition.", true, false, [...nodeConditionFields(), optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")], [...nodeConditionInputJsonFields(), optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")]),
|
|
1096
|
+
commandSchema("wait-active", "Wait for a foreground app id.", true, false, [argumentField("appId", "string", "Expected foreground Roku application id."), optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")]),
|
|
1097
|
+
commandSchema("wait-media-player", "Wait for a media-player state.", true, false, [argumentField("state", "string", "Expected media-player state."), optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")]),
|
|
1098
|
+
commandSchema("wait-ready", "Wait for active app plus optional node/media readiness.", true, false, [
|
|
1099
|
+
argumentField("appId", "string", "Expected foreground Roku application id."),
|
|
1100
|
+
optionField("mediaState", "string", "Optional media-player state to wait for."),
|
|
1101
|
+
optionField("node", "node-condition", "Optional SceneGraph node condition."),
|
|
1102
|
+
optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")
|
|
1103
|
+
]),
|
|
1104
|
+
commandSchema("screenshot", "Write a developer screenshot.", true, true, [argumentField("outputPath", "path", "Screenshot output path inside the current app root.")])
|
|
1105
|
+
],
|
|
1106
|
+
globalOptions: [
|
|
1107
|
+
globalField("json", "boolean", "Print structured JSON output."),
|
|
1108
|
+
globalField("output", "json|text", "Select structured or human output.", false, ["json", "text"]),
|
|
1109
|
+
globalField("dryRun", "boolean", "Validate mutating commands without side effects."),
|
|
1110
|
+
globalField("fields", "field-mask", "Comma-separated JSON field mask for output trimming."),
|
|
1111
|
+
globalField("inputJson", "json-object", "Command payload matching the described input schema.")
|
|
1112
|
+
],
|
|
1113
|
+
schemaVersion: 2
|
|
1114
|
+
});
|
|
1115
|
+
const commandSchema = (name, description, requiresTarget, mutates, parameters, inputJsonFields = parameters) => ({
|
|
1116
|
+
description,
|
|
1117
|
+
inputJson: {
|
|
1118
|
+
fields: [{
|
|
1119
|
+
description: "Command name.",
|
|
1120
|
+
name: "command",
|
|
1121
|
+
required: true,
|
|
1122
|
+
type: "string",
|
|
1123
|
+
values: [name]
|
|
1124
|
+
}, ...inputJsonFields],
|
|
1125
|
+
required: ["command", ...inputJsonFields.filter((field) => field.required).map((field) => field.name)]
|
|
1126
|
+
},
|
|
1127
|
+
mutates,
|
|
1128
|
+
name,
|
|
1129
|
+
parameters,
|
|
1130
|
+
requiresTarget
|
|
1131
|
+
});
|
|
1132
|
+
const argumentField = (name, type, description, required = true, repeatable = false, values) => ({
|
|
1133
|
+
description,
|
|
1134
|
+
name,
|
|
1135
|
+
repeatable,
|
|
1136
|
+
required,
|
|
1137
|
+
type,
|
|
1138
|
+
values
|
|
1139
|
+
});
|
|
1140
|
+
const optionField = (name, type, description, required = false, repeatable = false, values) => ({
|
|
1141
|
+
description,
|
|
1142
|
+
name,
|
|
1143
|
+
repeatable,
|
|
1144
|
+
required,
|
|
1145
|
+
type,
|
|
1146
|
+
values
|
|
1147
|
+
});
|
|
1148
|
+
const globalField = (name, type, description, required = false, values) => ({
|
|
1149
|
+
description,
|
|
1150
|
+
name,
|
|
1151
|
+
required,
|
|
1152
|
+
type,
|
|
1153
|
+
values
|
|
1154
|
+
});
|
|
1155
|
+
const nodeConditionFields = () => [
|
|
1156
|
+
argumentField("nodeName", "string", "SceneGraph node name."),
|
|
1157
|
+
argumentField("condition", "visible|hidden|absent|text|attr", "Expected node condition.", true, false, [
|
|
1158
|
+
"visible",
|
|
1159
|
+
"hidden",
|
|
1160
|
+
"absent",
|
|
1161
|
+
"text",
|
|
1162
|
+
"attr"
|
|
1163
|
+
]),
|
|
1164
|
+
optionField("value", "string", "Text or attr name=value pair for text/attr conditions.")
|
|
1165
|
+
];
|
|
1166
|
+
const nodeConditionInputJsonFields = () => [argumentField("nodeName", "string", "SceneGraph node name."), argumentField("expectation", "node-expectation-object", "Expected node state, text, or attribute object.")];
|
|
444
1167
|
const printHelp = () => {
|
|
445
1168
|
console.log(`rokit - Roku device harness helper
|
|
446
1169
|
|
|
447
1170
|
usage:
|
|
1171
|
+
rokit describe
|
|
448
1172
|
rokit check
|
|
1173
|
+
rokit discover [--timeout-ms <ms>]
|
|
449
1174
|
rokit device-info
|
|
450
1175
|
rokit active-app
|
|
451
1176
|
rokit media-player
|
|
1177
|
+
rokit snapshot
|
|
1178
|
+
rokit proof <output-dir> [--screenshot]
|
|
1179
|
+
rokit package --out <zip-path>
|
|
452
1180
|
rokit wait-active <app-id> [--timeout-ms <ms>]
|
|
453
1181
|
rokit wait-media-player <state> [--timeout-ms <ms>]
|
|
1182
|
+
rokit wait-ready <app-id> [--media-state <state>] [--node <node-name> <condition> [value]] [--timeout-ms <ms>]
|
|
454
1183
|
rokit launch <app-id> [--param key=value]
|
|
455
|
-
rokit press [--delay-ms <ms>] <key> [key...]
|
|
1184
|
+
rokit press [--delay-ms <ms>] [--max <count>] <key> [key...] [--until-node <node-name> <condition> [value]]
|
|
456
1185
|
rokit query <ecp-path>
|
|
457
1186
|
rokit sgnodes
|
|
458
1187
|
rokit assert-node <node-name> <visible|hidden|absent|text|attr> [value]
|
|
@@ -464,6 +1193,9 @@ usage:
|
|
|
464
1193
|
global options:
|
|
465
1194
|
--json
|
|
466
1195
|
--output json | text
|
|
1196
|
+
--dry-run
|
|
1197
|
+
--fields field[,field...]
|
|
1198
|
+
--input-json '<payload>'
|
|
467
1199
|
|
|
468
1200
|
environment:
|
|
469
1201
|
ROKIT_TARGET=<roku-ip>
|
|
@@ -476,6 +1208,6 @@ compatibility:
|
|
|
476
1208
|
};
|
|
477
1209
|
//#endregion
|
|
478
1210
|
//#region src/rokit.ts
|
|
479
|
-
|
|
1211
|
+
NodeRuntime.runMain(mainEffect());
|
|
480
1212
|
//#endregion
|
|
481
1213
|
export {};
|