@putdotio/rokit 1.2.0 → 1.4.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/README.md CHANGED
@@ -42,12 +42,19 @@ pnpm exec rokit press --delay-ms 250 Right Select
42
42
  pnpm exec rokit query /query/active-app
43
43
  pnpm exec rokit wait-node videoPlayerScreen visible
44
44
  pnpm exec rokit screenshot artifacts/live/player.png
45
+ pnpm exec rokit --json active-app
45
46
  ```
46
47
 
47
48
  App-specific scenario scripts can also import the generic helpers:
48
49
 
49
50
  ```ts
50
- import { assertSceneGraphNode, pressKey, querySceneGraph, type RokuContext } from "@putdotio/rokit";
51
+ import {
52
+ assertSceneGraphNode,
53
+ pressKey,
54
+ querySceneGraph,
55
+ readNamedNodeTranslation,
56
+ type RokuContext,
57
+ } from "@putdotio/rokit";
51
58
 
52
59
  const target = process.env.ROKIT_TARGET;
53
60
 
@@ -62,8 +69,9 @@ const context: RokuContext = {
62
69
  };
63
70
 
64
71
  await pressKey(context, "Info");
72
+ const xml = await querySceneGraph(context, { attempts: 3 });
65
73
  await assertSceneGraphNode(context, "videoPlayerScreen", { state: "visible" });
66
- await querySceneGraph(context, { attempts: 3 });
74
+ console.log(readNamedNodeTranslation(xml, "videoPlayerScreen"));
67
75
  ```
68
76
 
69
77
  ## Commands
@@ -84,6 +92,16 @@ rokit install <zip-path>
84
92
  rokit --version
85
93
  ```
86
94
 
95
+ Global options:
96
+
97
+ ```bash
98
+ rokit --json <command>
99
+ rokit --output json <command>
100
+ ```
101
+
102
+ JSON mode wraps command output as `{ "status": "ok", "command": "...", ... }`
103
+ and reports failures as `{ "status": "failed", "error": { "message": "..." } }`.
104
+
87
105
  - `check` confirms the Roku ECP endpoint responds and the developer installer
88
106
  is reachable.
89
107
  - `device-info` prints enhanced Roku device metadata as JSON.
@@ -130,6 +148,7 @@ tokens, and app-specific media identifiers do not belong in git.
130
148
  - remote keypresses
131
149
  - raw ECP queries
132
150
  - SceneGraph state queries and named-node assertions
151
+ - SceneGraph attribute, numeric geometry, bounds, and translation readers
133
152
  - screenshots
134
153
 
135
154
  App repositories should keep their own scenario commands for product behavior,
package/dist/index.d.mts CHANGED
@@ -2,6 +2,8 @@ import * as rokuDeploy from "roku-deploy";
2
2
 
3
3
  //#region src/scenegraph.d.ts
4
4
  type NodeState = "absent" | "hidden" | "visible";
5
+ type SceneGraphBounds = readonly [x: number, y: number, width: number, height: number];
6
+ type SceneGraphPoint = readonly [x: number, y: number];
5
7
  type NodeExpectation = {
6
8
  readonly state: NodeState;
7
9
  readonly text?: string;
@@ -11,10 +13,14 @@ type NodeExpectation = {
11
13
  };
12
14
  declare const readNamedNodeAttributes: (xml: string, nodeName: string) => string | undefined;
13
15
  declare const readNamedNodeAttribute: (xml: string, nodeName: string, attributeName: string) => string | undefined;
16
+ declare const readNamedNodeNumber: (xml: string, nodeName: string, attributeName: string) => number | undefined;
17
+ declare const readNamedNodeBounds: (xml: string, nodeName: string) => SceneGraphBounds | undefined;
18
+ declare const readNamedNodeTranslation: (xml: string, nodeName: string) => SceneGraphPoint | undefined;
14
19
  declare const isNamedNodeVisible: (xml: string, nodeName: string) => boolean;
15
20
  declare const assertNamedNode: (xml: string, nodeName: string, expectation: NodeExpectation) => void;
16
21
  declare const assertNamedNodeState: (xml: string, nodeName: string, state: NodeState) => void;
17
22
  declare const assertNamedNodeText: (xml: string, nodeName: string, expectedText: string) => void;
23
+ declare const parseSceneGraphNumberList: (value: string) => number[];
18
24
  //#endregion
19
25
  //#region src/xml.d.ts
20
26
  type ActiveApp = {
@@ -64,4 +70,4 @@ declare const takeScreenshot: (context: RokuContext & {
64
70
  }, outputPath: string) => Promise<string>;
65
71
  declare const validateRemoteKey: (key: string) => void;
66
72
  //#endregion
67
- export { type ActiveApp, type DeviceSummary, type NodeExpectation, type NodeState, type RemoteKey, type RetryOptions, type RokuContext, assertNamedNode, assertNamedNodeState, assertNamedNodeText, assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, isNamedNodeVisible, launchApp, pressKey, queryActiveApp, queryEcp, querySceneGraph, readActiveApp, readNamedNodeAttribute, readNamedNodeAttributes, readXmlAttribute, readXmlTag, takeScreenshot, validateRemoteKey, waitForActiveApp, waitForSceneGraphNode };
73
+ export { type ActiveApp, type DeviceSummary, type NodeExpectation, type NodeState, type RemoteKey, type RetryOptions, type RokuContext, type SceneGraphBounds, type SceneGraphPoint, assertNamedNode, assertNamedNodeState, assertNamedNodeText, assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, isNamedNodeVisible, launchApp, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, querySceneGraph, readActiveApp, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readXmlAttribute, readXmlTag, takeScreenshot, validateRemoteKey, waitForActiveApp, waitForSceneGraphNode };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { S as readXmlTag, _ as isNamedNodeVisible, a as launchApp, b as readActiveApp, c as queryEcp, d as validateRemoteKey, f as waitForActiveApp, g as assertNamedNodeText, h as assertNamedNodeState, i as installPackage, l as querySceneGraph, m as assertNamedNode, n as checkDevice, o as pressKey, p as waitForSceneGraphNode, r as getDeviceInfo, s as queryActiveApp, t as assertSceneGraphNode, u as takeScreenshot, v as readNamedNodeAttribute, x as readXmlAttribute, y as readNamedNodeAttributes } from "./roku-DxlYPbY1.mjs";
2
- export { assertNamedNode, assertNamedNodeState, assertNamedNodeText, assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, isNamedNodeVisible, launchApp, pressKey, queryActiveApp, queryEcp, querySceneGraph, readActiveApp, readNamedNodeAttribute, readNamedNodeAttributes, readXmlAttribute, readXmlTag, takeScreenshot, validateRemoteKey, waitForActiveApp, waitForSceneGraphNode };
1
+ import { C as readNamedNodeTranslation, E as readXmlTag, S as readNamedNodeNumber, T as readXmlAttribute, _ as isNamedNodeVisible, a as launchApp, b as readNamedNodeAttributes, c as queryEcp, d as validateRemoteKey, f as waitForActiveApp, g as assertNamedNodeText, h as assertNamedNodeState, i as installPackage, l as querySceneGraph, m as assertNamedNode, n as checkDevice, o as pressKey, p as waitForSceneGraphNode, r as getDeviceInfo, s as queryActiveApp, t as assertSceneGraphNode, u as takeScreenshot, v as parseSceneGraphNumberList, w as readActiveApp, x as readNamedNodeBounds, y as readNamedNodeAttribute } from "./roku-7hiM97p0.mjs";
2
+ export { assertNamedNode, assertNamedNodeState, assertNamedNodeText, assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, isNamedNodeVisible, launchApp, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, querySceneGraph, readActiveApp, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readXmlAttribute, readXmlTag, takeScreenshot, validateRemoteKey, waitForActiveApp, waitForSceneGraphNode };
package/dist/rokit.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as launchApp, c as queryEcp, f as waitForActiveApp, i as installPackage, l as querySceneGraph, n as checkDevice, o as pressKey, p as waitForSceneGraphNode, r as getDeviceInfo, s as queryActiveApp, t as assertSceneGraphNode, u as takeScreenshot } from "./roku-DxlYPbY1.mjs";
2
+ import { a as launchApp, c as queryEcp, f as waitForActiveApp, i as installPackage, l as querySceneGraph, n as checkDevice, o as pressKey, p as waitForSceneGraphNode, r as getDeviceInfo, s as queryActiveApp, t as assertSceneGraphNode, u as takeScreenshot } from "./roku-7hiM97p0.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join } from "node:path";
5
5
  import { existsSync, mkdirSync } from "node:fs";
@@ -23,9 +23,9 @@ const requirePassword = (env) => {
23
23
  if (!password) return fail("ROKIT_PASSWORD is not set");
24
24
  return password;
25
25
  };
26
+ var RokitCliError = class extends Error {};
26
27
  const fail = (message) => {
27
- console.error(message);
28
- process.exit(1);
28
+ throw new RokitCliError(message);
29
29
  };
30
30
  const formatErrorMessage = (error) => {
31
31
  if (error instanceof Error) return error.message;
@@ -42,100 +42,212 @@ const parseTimeout = (value) => {
42
42
  //#region src/cli.ts
43
43
  const packageJson = createRequire(import.meta.url)("../package.json");
44
44
  const main = async (argv = process.argv.slice(2)) => {
45
- const firstArg = argv[0];
46
- if (!firstArg || firstArg === "--help" || firstArg === "-h") {
47
- printHelp();
48
- return;
49
- }
50
- if (firstArg === "--version" || firstArg === "-v") {
51
- console.log(packageJson.version);
52
- return;
53
- }
54
- loadLocalEnv();
55
- const env = loadEnv();
56
- const target = requireTarget(env);
57
- const context = {
58
- password: env.password,
59
- target,
60
- timeoutMs: env.timeoutMs,
61
- username: env.username
62
- };
63
- const command = parseCommand(argv);
45
+ let outputMode = inferOutputMode(argv);
64
46
  try {
65
- await runCommand(context, command);
47
+ const options = parseGlobalOptions(argv);
48
+ outputMode = options.outputMode;
49
+ const firstArg = options.args[0];
50
+ if (!firstArg || firstArg === "--help" || firstArg === "-h") {
51
+ printHelp();
52
+ return;
53
+ }
54
+ if (firstArg === "--version" || firstArg === "-v") {
55
+ console.log(packageJson.version);
56
+ return;
57
+ }
58
+ loadLocalEnv();
59
+ const env = loadEnv();
60
+ const target = requireTarget(env);
61
+ const result = await runCommand({
62
+ password: env.password,
63
+ target,
64
+ timeoutMs: env.timeoutMs,
65
+ username: env.username
66
+ }, parseCommand(options.args));
67
+ printResult(options.outputMode, result);
66
68
  } catch (error) {
67
- fail(formatErrorMessage(error));
69
+ printError(outputMode, formatErrorMessage(error));
70
+ process.exitCode = 1;
68
71
  }
69
72
  };
70
73
  const runCommand = async (context, command) => {
71
74
  if (command.name === "check") {
72
75
  const summary = await checkDevice(context);
73
- console.log(`device: ${summary.name} (${summary.model})`);
74
- console.log(`ecp: ${summary.ecp}`);
75
- console.log(`developer installer HTTP status: ${summary.installerStatus}`);
76
- return;
77
- }
78
- if (command.name === "device-info") {
79
- console.log(JSON.stringify(await getDeviceInfo(context), null, 2));
80
- return;
76
+ return {
77
+ command: command.name,
78
+ data: summary,
79
+ message: [
80
+ `device: ${summary.name} (${summary.model})`,
81
+ `ecp: ${summary.ecp}`,
82
+ `developer installer HTTP status: ${summary.installerStatus}`
83
+ ].join("\n"),
84
+ status: "ok"
85
+ };
81
86
  }
87
+ if (command.name === "device-info") return {
88
+ command: command.name,
89
+ data: await getDeviceInfo(context),
90
+ status: "ok"
91
+ };
82
92
  if (command.name === "active-app") {
83
93
  const app = await queryActiveApp(context);
84
- console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim());
85
- return;
94
+ return {
95
+ command: command.name,
96
+ data: app,
97
+ message: `active app: ${app.id} ${app.name} ${app.version}`.trim(),
98
+ status: "ok"
99
+ };
86
100
  }
87
101
  if (command.name === "wait-active") {
88
102
  const app = await waitForActiveApp(context, command.appId, command.timeoutMs);
89
- console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim());
90
- return;
103
+ return {
104
+ command: command.name,
105
+ data: app,
106
+ message: `active app: ${app.id} ${app.name} ${app.version}`.trim(),
107
+ status: "ok"
108
+ };
91
109
  }
92
110
  if (command.name === "launch") {
93
111
  const app = await launchApp(context, command.args.appId, command.args.params);
94
- console.log(`launched: ${app.id} ${app.name} ${app.version}`.trim());
95
- return;
112
+ return {
113
+ command: command.name,
114
+ data: app,
115
+ message: `launched: ${app.id} ${app.name} ${app.version}`.trim(),
116
+ status: "ok"
117
+ };
96
118
  }
97
119
  if (command.name === "press") {
120
+ const pressed = [];
98
121
  for (const [index, key] of command.args.keys.entries()) {
99
122
  if (index > 0 && command.args.delayMs > 0) await sleep(command.args.delayMs);
100
123
  await pressKey(context, key);
101
- console.log(`pressed: ${key}`);
124
+ pressed.push(key);
102
125
  }
103
- return;
126
+ return {
127
+ command: command.name,
128
+ data: {
129
+ delayMs: command.args.delayMs,
130
+ keys: pressed
131
+ },
132
+ message: pressed.map((key) => `pressed: ${key}`).join("\n"),
133
+ status: "ok"
134
+ };
104
135
  }
105
136
  if (command.name === "query") {
106
- console.log(await queryEcp(context, command.path));
107
- return;
137
+ const body = await queryEcp(context, command.path);
138
+ return {
139
+ command: command.name,
140
+ data: {
141
+ body,
142
+ path: command.path
143
+ },
144
+ message: body,
145
+ status: "ok"
146
+ };
108
147
  }
109
148
  if (command.name === "sgnodes") {
110
- console.log(await querySceneGraph(context));
111
- return;
149
+ const body = await querySceneGraph(context);
150
+ return {
151
+ command: command.name,
152
+ data: { body },
153
+ message: body,
154
+ status: "ok"
155
+ };
112
156
  }
113
157
  if (command.name === "assert-node") {
114
158
  await assertSceneGraphNode(context, command.args.nodeName, command.args.expectation);
115
- console.log(`asserted node: ${formatNodeCondition(command.args)}`);
116
- return;
159
+ return {
160
+ command: command.name,
161
+ data: formatNodeData(command.args),
162
+ message: `asserted node: ${formatNodeCondition(command.args)}`,
163
+ status: "ok"
164
+ };
117
165
  }
118
166
  if (command.name === "wait-node") {
119
167
  await waitForSceneGraphNode(context, command.args.nodeName, command.args.expectation, command.args.timeoutMs);
120
- console.log(`matched node: ${formatNodeCondition(command.args)}`);
121
- return;
168
+ return {
169
+ command: command.name,
170
+ data: formatNodeData(command.args),
171
+ message: `matched node: ${formatNodeCondition(command.args)}`,
172
+ status: "ok"
173
+ };
122
174
  }
123
175
  if (command.name === "screenshot") {
124
176
  const password = requirePassword(context);
125
177
  mkdirSync(dirname(command.outputPath), { recursive: true });
126
- console.log(`screenshot: ${await takeScreenshot({
178
+ const path = await takeScreenshot({
127
179
  ...context,
128
180
  password
129
- }, command.outputPath)}`);
130
- return;
181
+ }, command.outputPath);
182
+ return {
183
+ command: command.name,
184
+ data: { path },
185
+ message: `screenshot: ${path}`,
186
+ status: "ok"
187
+ };
131
188
  }
132
189
  if (command.name === "install") {
133
190
  const password = requirePassword(context);
134
- console.log(await installPackage({
191
+ const message = await installPackage({
135
192
  ...context,
136
193
  password
137
- }, command.zipPath));
194
+ }, command.zipPath);
195
+ return {
196
+ command: command.name,
197
+ data: { message },
198
+ message,
199
+ status: "ok"
200
+ };
138
201
  }
202
+ throw new Error(`unsupported command: ${command.name}`);
203
+ };
204
+ const parseGlobalOptions = (argv) => {
205
+ const args = [];
206
+ let outputMode = "text";
207
+ for (let index = 0; index < argv.length; index += 1) {
208
+ const arg = argv[index];
209
+ if (arg === "--json") {
210
+ outputMode = "json";
211
+ continue;
212
+ }
213
+ if (arg === "--output") {
214
+ const value = argv[index + 1];
215
+ if (value !== "json" && value !== "text") fail("usage: rokit [--json|--output json|--output text] <command>");
216
+ outputMode = value === "json" ? "json" : "text";
217
+ index += 1;
218
+ continue;
219
+ }
220
+ args.push(arg);
221
+ }
222
+ return {
223
+ args,
224
+ outputMode
225
+ };
226
+ };
227
+ const inferOutputMode = (argv) => {
228
+ if (argv.includes("--json")) return "json";
229
+ return argv[argv.indexOf("--output") + 1] === "json" ? "json" : "text";
230
+ };
231
+ const printResult = (outputMode, result) => {
232
+ if (outputMode === "json") {
233
+ console.log(JSON.stringify(result, null, 2));
234
+ return;
235
+ }
236
+ if (result.message !== void 0) {
237
+ console.log(result.message);
238
+ return;
239
+ }
240
+ console.log(JSON.stringify(result.data, null, 2));
241
+ };
242
+ const printError = (outputMode, message) => {
243
+ if (outputMode === "json") {
244
+ console.error(JSON.stringify({
245
+ error: { message },
246
+ status: "failed"
247
+ }, null, 2));
248
+ return;
249
+ }
250
+ console.error(message);
139
251
  };
140
252
  const parseCommand = (argv) => {
141
253
  const [name, ...args] = argv;
@@ -265,6 +377,11 @@ const formatNodeCondition = ({ expectation, nodeName }) => {
265
377
  const suffix = expectation.text === void 0 ? "" : ` text=${expectation.text}`;
266
378
  return `${nodeName} ${expectation.state}${suffix}`;
267
379
  };
380
+ const formatNodeData = ({ expectation, nodeName, timeoutMs }) => ({
381
+ expectation,
382
+ nodeName,
383
+ timeoutMs
384
+ });
268
385
  const sleep = (ms) => new Promise((resolve) => {
269
386
  setTimeout(resolve, ms);
270
387
  });
@@ -305,6 +422,10 @@ usage:
305
422
  rokit install <zip-path>
306
423
  rokit --version
307
424
 
425
+ global options:
426
+ --json
427
+ --output json | text
428
+
308
429
  environment:
309
430
  ROKIT_TARGET=<roku-ip>
310
431
  ROKIT_PASSWORD=<developer-mode-password>
@@ -28,6 +28,36 @@ const readNamedNodeAttribute = (xml, nodeName, attributeName) => {
28
28
  if (!attributes) return;
29
29
  return readXmlAttribute(attributes, attributeName);
30
30
  };
31
+ const readNamedNodeNumber = (xml, nodeName, attributeName) => {
32
+ const value = readNamedNodeAttribute(xml, nodeName, attributeName);
33
+ if (value !== void 0) {
34
+ const parsed = Number(value);
35
+ return Number.isFinite(parsed) ? parsed : void 0;
36
+ }
37
+ if (attributeName !== "width" && attributeName !== "height") return;
38
+ const bounds = readNamedNodeBounds(xml, nodeName);
39
+ if (!bounds) return;
40
+ return attributeName === "width" ? bounds[2] : bounds[3];
41
+ };
42
+ const readNamedNodeBounds = (xml, nodeName) => {
43
+ const bounds = readNamedNodeAttribute(xml, nodeName, "bounds");
44
+ if (!bounds) return;
45
+ const parts = parseSceneGraphNumberList(bounds);
46
+ if (parts.length < 4) return;
47
+ return [
48
+ parts[0],
49
+ parts[1],
50
+ parts[2],
51
+ parts[3]
52
+ ];
53
+ };
54
+ const readNamedNodeTranslation = (xml, nodeName) => {
55
+ const translation = readNamedNodeAttribute(xml, nodeName, "translation");
56
+ if (!translation) return;
57
+ const parts = parseSceneGraphNumberList(translation);
58
+ if (parts.length < 2) return;
59
+ return [parts[0], parts[1]];
60
+ };
31
61
  const isNamedNodeVisible = (xml, nodeName) => {
32
62
  const attributes = readNamedNodeAttributes(xml, nodeName);
33
63
  return attributes !== void 0 && !attributes.includes("visible=\"false\"");
@@ -59,6 +89,7 @@ const assertNamedNodeAttribute = (xml, nodeName, attributeName, expectedValue) =
59
89
  if (value !== expectedValue) throw new Error(`expected "${nodeName}" ${attributeName} "${expectedValue}", got "${value ?? "missing"}"`);
60
90
  };
61
91
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ const parseSceneGraphNumberList = (value) => value.replace(/[[\]{}]/g, "").split(",").map((part) => Number(part.trim())).filter((part) => Number.isFinite(part));
62
93
  //#endregion
63
94
  //#region src/roku.ts
64
95
  const ecpPort = 8060;
@@ -227,4 +258,4 @@ const sleep = (ms) => new Promise((resolve) => {
227
258
  });
228
259
  const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
229
260
  //#endregion
230
- export { readXmlTag as S, isNamedNodeVisible as _, launchApp as a, readActiveApp as b, queryEcp as c, validateRemoteKey as d, waitForActiveApp as f, assertNamedNodeText as g, assertNamedNodeState as h, installPackage as i, querySceneGraph as l, assertNamedNode as m, checkDevice as n, pressKey as o, waitForSceneGraphNode as p, getDeviceInfo as r, queryActiveApp as s, assertSceneGraphNode as t, takeScreenshot as u, readNamedNodeAttribute as v, readXmlAttribute as x, readNamedNodeAttributes as y };
261
+ export { readNamedNodeTranslation as C, readXmlTag as E, readNamedNodeNumber as S, readXmlAttribute as T, isNamedNodeVisible as _, launchApp as a, readNamedNodeAttributes as b, queryEcp as c, validateRemoteKey as d, waitForActiveApp as f, assertNamedNodeText as g, assertNamedNodeState as h, installPackage as i, querySceneGraph as l, assertNamedNode as m, checkDevice as n, pressKey as o, waitForSceneGraphNode as p, getDeviceInfo as r, queryActiveApp as s, assertSceneGraphNode as t, takeScreenshot as u, parseSceneGraphNumberList as v, readActiveApp as w, readNamedNodeBounds as x, readNamedNodeAttribute as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putdotio/rokit",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "A tiny CLI companion for Roku device harness work.",
5
5
  "keywords": [
6
6
  "cli",