@putdotio/rokit 1.0.0 → 1.1.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
@@ -38,19 +38,47 @@ Then run:
38
38
  pnpm exec rokit check
39
39
  pnpm exec rokit launch dev
40
40
  pnpm exec rokit press Down Select
41
+ pnpm exec rokit press --delay-ms 250 Right Select
41
42
  pnpm exec rokit query /query/active-app
43
+ pnpm exec rokit wait-node videoPlayerScreen visible
42
44
  pnpm exec rokit screenshot artifacts/live/player.png
43
45
  ```
44
46
 
47
+ App-specific scenario scripts can also import the generic helpers:
48
+
49
+ ```ts
50
+ import { assertSceneGraphNode, pressKey, querySceneGraph, type RokuContext } from "@putdotio/rokit";
51
+
52
+ const target = process.env.ROKIT_TARGET;
53
+
54
+ if (!target) {
55
+ throw new Error("ROKIT_TARGET is not set");
56
+ }
57
+
58
+ const context: RokuContext = {
59
+ target,
60
+ timeoutMs: 10_000,
61
+ username: "rokudev",
62
+ };
63
+
64
+ await pressKey(context, "Info");
65
+ await assertSceneGraphNode(context, "videoPlayerScreen", { state: "visible" });
66
+ await querySceneGraph(context);
67
+ ```
68
+
45
69
  ## Commands
46
70
 
47
71
  ```bash
48
72
  rokit check
49
73
  rokit device-info
50
74
  rokit active-app
75
+ rokit wait-active <app-id> [--timeout-ms <ms>]
51
76
  rokit launch <app-id> [--param key=value]
52
- rokit press <key> [key...]
77
+ rokit press [--delay-ms <ms>] <key> [key...]
53
78
  rokit query <ecp-path>
79
+ rokit sgnodes
80
+ rokit assert-node <node-name> <visible|hidden|absent|text|attr> [value]
81
+ rokit wait-node <node-name> <visible|hidden|absent|text|attr> [value] [--timeout-ms <ms>]
54
82
  rokit screenshot <output-path>
55
83
  rokit install <zip-path>
56
84
  rokit --version
@@ -60,10 +88,15 @@ rokit --version
60
88
  is reachable.
61
89
  - `device-info` prints enhanced Roku device metadata as JSON.
62
90
  - `active-app` prints the foreground app.
91
+ - `wait-active` waits until the requested app is foregrounded.
63
92
  - `launch` opens an app and waits until it is active. Use repeated `--param`
64
93
  values for deeplink parameters.
65
- - `press` sends Roku remote keys through ECP.
94
+ - `press` sends Roku remote keys through ECP. Use `--delay-ms` for navigation
95
+ sequences that need a stable gap between keys.
66
96
  - `query` prints a raw ECP response such as `/query/sgnodes/all`.
97
+ - `sgnodes` prints the raw SceneGraph tree from `/query/sgnodes/all`.
98
+ - `assert-node` checks a named SceneGraph node once.
99
+ - `wait-node` polls SceneGraph until a named node condition matches.
67
100
  - `screenshot` saves a developer screenshot. It requires `ROKIT_PASSWORD`.
68
101
  - `install` publishes an existing ZIP through `roku-deploy`. It requires
69
102
  `ROKIT_PASSWORD`.
@@ -92,6 +125,7 @@ tokens, and app-specific media identifiers do not belong in git.
92
125
  - launch and deeplink parameters
93
126
  - remote keypresses
94
127
  - raw ECP queries
128
+ - SceneGraph state queries and named-node assertions
95
129
  - screenshots
96
130
 
97
131
  App repositories should keep their own scenario commands for product behavior,
@@ -101,6 +135,8 @@ HTML, or checking app-specific UI nodes.
101
135
  ## Docs
102
136
 
103
137
  - [Contributing](./CONTRIBUTING.md)
138
+ - [Distribution](./docs/DISTRIBUTION.md)
139
+ - [Agent readiness](./docs/READINESS.md)
104
140
  - [Security](./SECURITY.md)
105
141
 
106
142
  ## Repo Internals
@@ -0,0 +1,63 @@
1
+ import * as rokuDeploy from "roku-deploy";
2
+
3
+ //#region src/scenegraph.d.ts
4
+ type NodeState = "absent" | "hidden" | "visible";
5
+ type NodeExpectation = {
6
+ readonly state: NodeState;
7
+ readonly text?: string;
8
+ } | {
9
+ readonly attribute: string;
10
+ readonly value: string;
11
+ };
12
+ declare const readNamedNodeAttributes: (xml: string, nodeName: string) => string | undefined;
13
+ declare const readNamedNodeAttribute: (xml: string, nodeName: string, attributeName: string) => string | undefined;
14
+ declare const isNamedNodeVisible: (xml: string, nodeName: string) => boolean;
15
+ declare const assertNamedNode: (xml: string, nodeName: string, expectation: NodeExpectation) => void;
16
+ declare const assertNamedNodeState: (xml: string, nodeName: string, state: NodeState) => void;
17
+ declare const assertNamedNodeText: (xml: string, nodeName: string, expectedText: string) => void;
18
+ //#endregion
19
+ //#region src/xml.d.ts
20
+ type ActiveApp = {
21
+ readonly id: string;
22
+ readonly name: string;
23
+ readonly type: string;
24
+ readonly version: string;
25
+ };
26
+ declare const readXmlTag: (xml: string, tag: string) => string | undefined;
27
+ declare const readXmlAttribute: (attributes: string, name: string) => string | undefined;
28
+ declare const readActiveApp: (xml: string) => ActiveApp;
29
+ //#endregion
30
+ //#region src/roku.d.ts
31
+ declare const remoteKeys: readonly ["Home", "Rev", "Fwd", "Play", "Select", "Left", "Right", "Down", "Up", "Back", "InstantReplay", "Info", "Backspace", "Search", "Enter", "VolumeDown", "VolumeMute", "VolumeUp", "PowerOff", "ChannelUp", "ChannelDown", "InputTuner", "InputHDMI1", "InputHDMI2", "InputHDMI3", "InputHDMI4"];
32
+ type RemoteKey = (typeof remoteKeys)[number] | `Lit_${string}`;
33
+ type RokuContext = {
34
+ readonly password?: string;
35
+ readonly target: string;
36
+ readonly timeoutMs: number;
37
+ readonly username: string;
38
+ };
39
+ type DeviceSummary = {
40
+ readonly ecp: string;
41
+ readonly installerStatus: number;
42
+ readonly model: string;
43
+ readonly name: string;
44
+ };
45
+ declare const checkDevice: (context: RokuContext) => Promise<DeviceSummary>;
46
+ declare const getDeviceInfo: (context: RokuContext) => Promise<rokuDeploy.DeviceInfo>;
47
+ declare const queryActiveApp: (context: RokuContext) => Promise<ActiveApp>;
48
+ declare const waitForActiveApp: (context: RokuContext, appId: string, timeoutMs?: number) => Promise<ActiveApp>;
49
+ declare const launchApp: (context: RokuContext, appId: string, params?: ReadonlyMap<string, string>) => Promise<ActiveApp>;
50
+ declare const pressKey: (context: RokuContext, key: string) => Promise<void>;
51
+ declare const queryEcp: (context: RokuContext, path: string) => Promise<string>;
52
+ declare const querySceneGraph: (context: RokuContext) => Promise<string>;
53
+ declare const assertSceneGraphNode: (context: RokuContext, nodeName: string, expectation: NodeExpectation) => Promise<void>;
54
+ declare const waitForSceneGraphNode: (context: RokuContext, nodeName: string, expectation: NodeExpectation, timeoutMs?: number) => Promise<void>;
55
+ declare const installPackage: (context: RokuContext & {
56
+ readonly password: string;
57
+ }, zipPath: string) => Promise<string>;
58
+ declare const takeScreenshot: (context: RokuContext & {
59
+ readonly password: string;
60
+ }, outputPath: string) => Promise<string>;
61
+ declare const validateRemoteKey: (key: string) => void;
62
+ //#endregion
63
+ export { type ActiveApp, type DeviceSummary, type NodeExpectation, type NodeState, type RemoteKey, type RokuContext, assertNamedNode, assertNamedNodeState, assertNamedNodeText, assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, isNamedNodeVisible, launchApp, pressKey, queryActiveApp, queryEcp, querySceneGraph, readActiveApp, readNamedNodeAttribute, readNamedNodeAttributes, readXmlAttribute, readXmlTag, takeScreenshot, validateRemoteKey, waitForActiveApp, waitForSceneGraphNode };
package/dist/index.mjs ADDED
@@ -0,0 +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-D-FxlWPE.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 };
@@ -0,0 +1 @@
1
+ export { };
package/dist/rokit.mjs CHANGED
@@ -1,141 +1,8 @@
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-D-FxlWPE.mjs";
2
3
  import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
3
5
  import { existsSync, mkdirSync } from "node:fs";
4
- import { basename, dirname, extname, join, resolve } from "node:path";
5
- import * as rokuDeploy from "roku-deploy";
6
- //#region src/xml.ts
7
- const readXmlTag = (xml, tag) => {
8
- return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
9
- };
10
- const readXmlAttribute = (attributes, name) => {
11
- return new RegExp(`${name}="([^"]*)"`).exec(attributes)?.[1];
12
- };
13
- const readActiveApp = (xml) => {
14
- const match = /<app\s+([^>]*)>([^<]*)<\/app>/.exec(xml);
15
- if (!match) throw new Error("active app response did not include an app node");
16
- const attributes = match[1] ?? "";
17
- return {
18
- id: readXmlAttribute(attributes, "id") ?? "",
19
- name: match[2]?.trim() ?? "",
20
- type: readXmlAttribute(attributes, "type") ?? "",
21
- version: readXmlAttribute(attributes, "version") ?? ""
22
- };
23
- };
24
- //#endregion
25
- //#region src/roku.ts
26
- const ecpPort = 8060;
27
- const remoteKeySet = new Set([
28
- "Home",
29
- "Rev",
30
- "Fwd",
31
- "Play",
32
- "Select",
33
- "Left",
34
- "Right",
35
- "Down",
36
- "Up",
37
- "Back",
38
- "InstantReplay",
39
- "Info",
40
- "Backspace",
41
- "Search",
42
- "Enter",
43
- "VolumeDown",
44
- "VolumeMute",
45
- "VolumeUp",
46
- "PowerOff",
47
- "ChannelUp",
48
- "ChannelDown",
49
- "InputTuner",
50
- "InputHDMI1",
51
- "InputHDMI2",
52
- "InputHDMI3",
53
- "InputHDMI4"
54
- ]);
55
- const checkDevice = async (context) => {
56
- const deviceInfo = await fetchText(context, "/query/device-info");
57
- const installerStatus = await fetchInstallerStatus(context);
58
- return {
59
- ecp: `http://${context.target}:${ecpPort}`,
60
- installerStatus,
61
- model: readXmlTag(deviceInfo, "model-name") ?? "unknown model",
62
- name: readXmlTag(deviceInfo, "friendly-device-name") ?? readXmlTag(deviceInfo, "friendlyName") ?? "unknown"
63
- };
64
- };
65
- const getDeviceInfo = async (context) => await rokuDeploy.getDeviceInfo({
66
- enhance: true,
67
- host: context.target,
68
- remotePort: ecpPort,
69
- timeout: context.timeoutMs
70
- });
71
- const queryActiveApp = async (context) => readActiveApp(await fetchText(context, "/query/active-app"));
72
- const launchApp = async (context, appId, params = /* @__PURE__ */ new Map()) => {
73
- const url = ecpUrl(context, `/launch/${encodeURIComponent(appId)}`);
74
- for (const [key, value] of params) url.searchParams.set(key, value);
75
- await postOk(context, url);
76
- return await waitForActiveApp(context, appId);
77
- };
78
- const pressKey = async (context, key) => {
79
- validateRemoteKey(key);
80
- await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
81
- };
82
- const queryEcp = async (context, path) => await fetchText(context, path.startsWith("/") ? path : `/${path}`);
83
- const installPackage = async (context, zipPath) => {
84
- const resolvedZip = resolve(zipPath);
85
- const extension = extname(resolvedZip);
86
- return (await rokuDeploy.publish({
87
- host: context.target,
88
- outDir: dirname(resolvedZip),
89
- outFile: basename(resolvedZip, extension),
90
- password: context.password,
91
- rootDir: process.cwd(),
92
- username: context.username
93
- })).message;
94
- };
95
- const takeScreenshot = async (context, outputPath) => {
96
- const resolvedOutput = resolve(outputPath);
97
- const extension = extname(resolvedOutput);
98
- return await rokuDeploy.takeScreenshot({
99
- host: context.target,
100
- outDir: dirname(resolvedOutput),
101
- outFile: basename(resolvedOutput, extension),
102
- password: context.password
103
- });
104
- };
105
- const validateRemoteKey = (key) => {
106
- if (key.startsWith("Lit_")) return;
107
- if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
108
- };
109
- const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
110
- const start = Date.now();
111
- let lastApp;
112
- while (Date.now() - start < timeoutMs) {
113
- lastApp = await queryActiveApp(context);
114
- if (lastApp.id === appId) return lastApp;
115
- await sleep(500);
116
- }
117
- const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown";
118
- throw new Error(`expected active app ${appId}, got ${last}`);
119
- };
120
- const fetchInstallerStatus = async (context) => {
121
- return (await fetch(`http://${context.target}`, { signal: AbortSignal.timeout(context.timeoutMs) })).status;
122
- };
123
- const fetchText = async (context, path) => {
124
- const response = await fetch(ecpUrl(context, path), { signal: AbortSignal.timeout(context.timeoutMs) });
125
- if (!response.ok) throw new Error(`GET ${path} returned HTTP ${response.status}`);
126
- return await response.text();
127
- };
128
- const postOk = async (context, url) => {
129
- const response = await fetch(url, {
130
- method: "POST",
131
- signal: AbortSignal.timeout(context.timeoutMs)
132
- });
133
- if (!response.ok) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
134
- };
135
- const ecpUrl = (context, path) => new URL(path, `http://${context.target}:${ecpPort}`);
136
- const sleep = (ms) => new Promise((resolve) => {
137
- setTimeout(resolve, ms);
138
- });
139
6
  const envPath = join(join(process.cwd(), ".rokit"), ".env");
140
7
  const loadLocalEnv = () => {
141
8
  if (existsSync(envPath)) process.loadEnvFile(envPath);
@@ -217,13 +84,19 @@ const runCommand = async (context, command) => {
217
84
  console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim());
218
85
  return;
219
86
  }
87
+ if (command.name === "wait-active") {
88
+ const app = await waitForActiveApp(context, command.appId, command.timeoutMs);
89
+ console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim());
90
+ return;
91
+ }
220
92
  if (command.name === "launch") {
221
93
  const app = await launchApp(context, command.args.appId, command.args.params);
222
94
  console.log(`launched: ${app.id} ${app.name} ${app.version}`.trim());
223
95
  return;
224
96
  }
225
97
  if (command.name === "press") {
226
- for (const key of command.keys) {
98
+ for (const [index, key] of command.args.keys.entries()) {
99
+ if (index > 0 && command.args.delayMs > 0) await sleep(command.args.delayMs);
227
100
  await pressKey(context, key);
228
101
  console.log(`pressed: ${key}`);
229
102
  }
@@ -233,6 +106,20 @@ const runCommand = async (context, command) => {
233
106
  console.log(await queryEcp(context, command.path));
234
107
  return;
235
108
  }
109
+ if (command.name === "sgnodes") {
110
+ console.log(await querySceneGraph(context));
111
+ return;
112
+ }
113
+ if (command.name === "assert-node") {
114
+ await assertSceneGraphNode(context, command.args.nodeName, command.args.expectation);
115
+ console.log(`asserted node: ${formatNodeCondition(command.args)}`);
116
+ return;
117
+ }
118
+ if (command.name === "wait-node") {
119
+ await waitForSceneGraphNode(context, command.args.nodeName, command.args.expectation, command.args.timeoutMs);
120
+ console.log(`matched node: ${formatNodeCondition(command.args)}`);
121
+ return;
122
+ }
236
123
  if (command.name === "screenshot") {
237
124
  const password = requirePassword(context);
238
125
  mkdirSync(dirname(command.outputPath), { recursive: true });
@@ -255,17 +142,23 @@ const parseCommand = (argv) => {
255
142
  if (name === "check") return { name };
256
143
  if (name === "device-info") return { name };
257
144
  if (name === "active-app") return { name };
258
- if (name === "launch") return {
259
- name,
260
- args: parseLaunchArgs(args)
261
- };
262
- if (name === "press") {
263
- if (args.length === 0) fail("usage: rokit press <key> [key...]");
145
+ if (name === "wait-active") {
146
+ const appId = args[0];
147
+ if (!appId) fail("usage: rokit wait-active <app-id> [--timeout-ms <ms>]");
264
148
  return {
149
+ appId,
265
150
  name,
266
- keys: args
151
+ timeoutMs: parseTimeoutOption(args.slice(1), `rokit ${name} <app-id>`)
267
152
  };
268
153
  }
154
+ if (name === "launch") return {
155
+ name,
156
+ args: parseLaunchArgs(args)
157
+ };
158
+ if (name === "press") return {
159
+ name,
160
+ args: parsePressArgs(args)
161
+ };
269
162
  if (name === "query") {
270
163
  const path = args[0];
271
164
  if (!path) fail("usage: rokit query <ecp-path>");
@@ -274,6 +167,11 @@ const parseCommand = (argv) => {
274
167
  path
275
168
  };
276
169
  }
170
+ if (name === "sgnodes") return { name };
171
+ if (name === "assert-node" || name === "wait-node") return {
172
+ name,
173
+ args: parseNodeCondition(name, args)
174
+ };
277
175
  if (name === "screenshot") {
278
176
  const outputPath = args[0];
279
177
  if (!outputPath) fail("usage: rokit screenshot <output-path>");
@@ -292,6 +190,84 @@ const parseCommand = (argv) => {
292
190
  }
293
191
  return fail(`Unknown command: ${name ?? ""}`);
294
192
  };
193
+ const parseNodeCondition = (commandName, args) => {
194
+ const [nodeName, condition, ...rest] = args;
195
+ if (!nodeName || !condition) return fail(`usage: rokit ${commandName} <node-name> <visible|hidden|absent|text|attr> [value] [--timeout-ms <ms>]`);
196
+ if (condition === "visible" || condition === "hidden" || condition === "absent") {
197
+ const timeoutMs = parseTimeoutOption(rest, `rokit ${commandName} <node-name> ${condition}`);
198
+ return {
199
+ expectation: { state: condition },
200
+ nodeName,
201
+ timeoutMs
202
+ };
203
+ }
204
+ if (condition === "text") {
205
+ const [text, ...optionArgs] = rest;
206
+ if (text === void 0) fail(`usage: rokit ${commandName} <node-name> text <expected-text>`);
207
+ return {
208
+ expectation: {
209
+ state: "visible",
210
+ text
211
+ },
212
+ nodeName,
213
+ timeoutMs: parseTimeoutOption(optionArgs, `rokit ${commandName} <node-name> text`)
214
+ };
215
+ }
216
+ if (condition === "attr") {
217
+ const [pair, ...optionArgs] = rest;
218
+ if (pair === void 0) fail(`usage: rokit ${commandName} <node-name> attr <name=value>`);
219
+ const equalsIndex = pair.indexOf("=");
220
+ if (equalsIndex <= 0) fail(`Invalid attr condition: ${pair}`);
221
+ return {
222
+ expectation: {
223
+ attribute: pair.slice(0, equalsIndex),
224
+ value: pair.slice(equalsIndex + 1)
225
+ },
226
+ nodeName,
227
+ timeoutMs: parseTimeoutOption(optionArgs, `rokit ${commandName} <node-name> attr`)
228
+ };
229
+ }
230
+ return fail(`Unknown node condition: ${condition}`);
231
+ };
232
+ const parsePressArgs = (args) => {
233
+ let delayMs = 0;
234
+ const keys = [];
235
+ for (let index = 0; index < args.length; index += 1) {
236
+ const arg = args[index];
237
+ if (arg === "--delay-ms") {
238
+ const value = args[index + 1];
239
+ if (!value) fail("usage: rokit press [--delay-ms <ms>] <key> [key...]");
240
+ delayMs = parsePositiveInteger(value, "delay");
241
+ index += 1;
242
+ continue;
243
+ }
244
+ if (arg?.startsWith("--")) fail(`Unknown press option: ${arg}`);
245
+ if (arg !== void 0) keys.push(arg);
246
+ }
247
+ if (keys.length === 0) fail("usage: rokit press [--delay-ms <ms>] <key> [key...]");
248
+ return {
249
+ delayMs,
250
+ keys
251
+ };
252
+ };
253
+ const parseTimeoutOption = (args, usagePrefix) => {
254
+ if (args.length === 0) return;
255
+ if (args.length !== 2 || args[0] !== "--timeout-ms") fail(`usage: ${usagePrefix} [--timeout-ms <ms>]`);
256
+ return parsePositiveInteger(args[1] ?? "", "timeout");
257
+ };
258
+ const parsePositiveInteger = (value, label) => {
259
+ const parsed = Number(value);
260
+ if (!Number.isInteger(parsed) || parsed <= 0) fail(`Invalid ${label}: ${value}`);
261
+ return parsed;
262
+ };
263
+ const formatNodeCondition = ({ expectation, nodeName }) => {
264
+ if ("attribute" in expectation) return `${nodeName} attr ${expectation.attribute}=${expectation.value}`;
265
+ const suffix = expectation.text === void 0 ? "" : ` text=${expectation.text}`;
266
+ return `${nodeName} ${expectation.state}${suffix}`;
267
+ };
268
+ const sleep = (ms) => new Promise((resolve) => {
269
+ setTimeout(resolve, ms);
270
+ });
295
271
  const parseLaunchArgs = (args) => {
296
272
  const appId = args[0];
297
273
  if (!appId) fail("usage: rokit launch <app-id> [--param key=value]");
@@ -318,9 +294,13 @@ usage:
318
294
  rokit check
319
295
  rokit device-info
320
296
  rokit active-app
297
+ rokit wait-active <app-id> [--timeout-ms <ms>]
321
298
  rokit launch <app-id> [--param key=value]
322
- rokit press <key> [key...]
299
+ rokit press [--delay-ms <ms>] <key> [key...]
323
300
  rokit query <ecp-path>
301
+ rokit sgnodes
302
+ rokit assert-node <node-name> <visible|hidden|absent|text|attr> [value]
303
+ rokit wait-node <node-name> <visible|hidden|absent|text|attr> [value] [--timeout-ms <ms>]
324
304
  rokit screenshot <output-path>
325
305
  rokit install <zip-path>
326
306
  rokit --version
@@ -0,0 +1,197 @@
1
+ import * as rokuDeploy from "roku-deploy";
2
+ import { basename, dirname, extname, resolve } from "node:path";
3
+ //#region src/xml.ts
4
+ const readXmlTag = (xml, tag) => {
5
+ return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
6
+ };
7
+ const readXmlAttribute = (attributes, name) => {
8
+ return new RegExp(`${name}="([^"]*)"`).exec(attributes)?.[1];
9
+ };
10
+ const readActiveApp = (xml) => {
11
+ const match = /<app(?:\s+([^>]*))?>([^<]*)<\/app>/.exec(xml);
12
+ if (!match) throw new Error("active app response did not include an app node");
13
+ const attributes = match[1] ?? "";
14
+ return {
15
+ id: readXmlAttribute(attributes, "id") ?? "",
16
+ name: match[2]?.trim() ?? "",
17
+ type: readXmlAttribute(attributes, "type") ?? "",
18
+ version: readXmlAttribute(attributes, "version") ?? ""
19
+ };
20
+ };
21
+ //#endregion
22
+ //#region src/scenegraph.ts
23
+ const readNamedNodeAttributes = (xml, nodeName) => {
24
+ return new RegExp(`<[A-Za-z0-9]+\\b(?=[^>]*\\bname="${escapeRegExp(nodeName)}")([^>]*)>`).exec(xml)?.[1];
25
+ };
26
+ const readNamedNodeAttribute = (xml, nodeName, attributeName) => {
27
+ const attributes = readNamedNodeAttributes(xml, nodeName);
28
+ if (!attributes) return;
29
+ return readXmlAttribute(attributes, attributeName);
30
+ };
31
+ const isNamedNodeVisible = (xml, nodeName) => {
32
+ const attributes = readNamedNodeAttributes(xml, nodeName);
33
+ return attributes !== void 0 && !attributes.includes("visible=\"false\"");
34
+ };
35
+ const assertNamedNode = (xml, nodeName, expectation) => {
36
+ if ("attribute" in expectation) {
37
+ assertNamedNodeAttribute(xml, nodeName, expectation.attribute, expectation.value);
38
+ return;
39
+ }
40
+ assertNamedNodeState(xml, nodeName, expectation.state);
41
+ if (expectation.text !== void 0) assertNamedNodeText(xml, nodeName, expectation.text);
42
+ };
43
+ const assertNamedNodeState = (xml, nodeName, state) => {
44
+ const attributes = readNamedNodeAttributes(xml, nodeName);
45
+ if (state === "absent") {
46
+ if (attributes !== void 0) throw new Error(`expected SceneGraph node "${nodeName}" to be absent`);
47
+ return;
48
+ }
49
+ if (!attributes) throw new Error(`expected SceneGraph node "${nodeName}"`);
50
+ if (state === "visible" && attributes.includes("visible=\"false\"")) throw new Error(`expected SceneGraph node "${nodeName}" to be visible`);
51
+ if (state === "hidden" && !attributes.includes("visible=\"false\"")) throw new Error(`expected SceneGraph node "${nodeName}" to be hidden`);
52
+ };
53
+ const assertNamedNodeText = (xml, nodeName, expectedText) => {
54
+ const text = readNamedNodeAttribute(xml, nodeName, "text");
55
+ if (text !== expectedText) throw new Error(`expected "${nodeName}" text "${expectedText}", got "${text ?? "missing"}"`);
56
+ };
57
+ const assertNamedNodeAttribute = (xml, nodeName, attributeName, expectedValue) => {
58
+ const value = readNamedNodeAttribute(xml, nodeName, attributeName);
59
+ if (value !== expectedValue) throw new Error(`expected "${nodeName}" ${attributeName} "${expectedValue}", got "${value ?? "missing"}"`);
60
+ };
61
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ //#endregion
63
+ //#region src/roku.ts
64
+ const ecpPort = 8060;
65
+ const remoteKeySet = new Set([
66
+ "Home",
67
+ "Rev",
68
+ "Fwd",
69
+ "Play",
70
+ "Select",
71
+ "Left",
72
+ "Right",
73
+ "Down",
74
+ "Up",
75
+ "Back",
76
+ "InstantReplay",
77
+ "Info",
78
+ "Backspace",
79
+ "Search",
80
+ "Enter",
81
+ "VolumeDown",
82
+ "VolumeMute",
83
+ "VolumeUp",
84
+ "PowerOff",
85
+ "ChannelUp",
86
+ "ChannelDown",
87
+ "InputTuner",
88
+ "InputHDMI1",
89
+ "InputHDMI2",
90
+ "InputHDMI3",
91
+ "InputHDMI4"
92
+ ]);
93
+ const checkDevice = async (context) => {
94
+ const deviceInfo = await fetchText(context, "/query/device-info");
95
+ const installerStatus = await fetchInstallerStatus(context);
96
+ return {
97
+ ecp: `http://${context.target}:${ecpPort}`,
98
+ installerStatus,
99
+ model: readXmlTag(deviceInfo, "model-name") ?? "unknown model",
100
+ name: readXmlTag(deviceInfo, "friendly-device-name") ?? readXmlTag(deviceInfo, "friendlyName") ?? "unknown"
101
+ };
102
+ };
103
+ const getDeviceInfo = async (context) => await rokuDeploy.getDeviceInfo({
104
+ enhance: true,
105
+ host: context.target,
106
+ remotePort: ecpPort,
107
+ timeout: context.timeoutMs
108
+ });
109
+ const queryActiveApp = async (context) => readActiveApp(await fetchText(context, "/query/active-app"));
110
+ const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
111
+ const start = Date.now();
112
+ let lastApp;
113
+ while (Date.now() - start < timeoutMs) {
114
+ lastApp = await queryActiveApp(context);
115
+ if (lastApp.id === appId) return lastApp;
116
+ await sleep(500);
117
+ }
118
+ const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown";
119
+ throw new Error(`expected active app ${appId}, got ${last}`);
120
+ };
121
+ const launchApp = async (context, appId, params = /* @__PURE__ */ new Map()) => {
122
+ const url = ecpUrl(context, `/launch/${encodeURIComponent(appId)}`);
123
+ for (const [key, value] of params) url.searchParams.set(key, value);
124
+ await postOk(context, url);
125
+ return await waitForActiveApp(context, appId);
126
+ };
127
+ const pressKey = async (context, key) => {
128
+ validateRemoteKey(key);
129
+ await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
130
+ };
131
+ const queryEcp = async (context, path) => await fetchText(context, path.startsWith("/") ? path : `/${path}`);
132
+ const querySceneGraph = async (context) => await queryEcp(context, "/query/sgnodes/all");
133
+ const assertSceneGraphNode = async (context, nodeName, expectation) => {
134
+ assertNamedNode(await querySceneGraph(context), nodeName, expectation);
135
+ };
136
+ const waitForSceneGraphNode = async (context, nodeName, expectation, timeoutMs = 3e4) => {
137
+ const start = Date.now();
138
+ let lastError;
139
+ while (Date.now() - start < timeoutMs) {
140
+ try {
141
+ await assertSceneGraphNode(context, nodeName, expectation);
142
+ return;
143
+ } catch (error) {
144
+ lastError = error instanceof Error ? error.message : String(error);
145
+ }
146
+ await sleep(500);
147
+ }
148
+ const suffix = lastError ? `; last observation: ${lastError}` : "";
149
+ throw new Error(`expected SceneGraph node "${nodeName}" to match condition${suffix}`);
150
+ };
151
+ const installPackage = async (context, zipPath) => {
152
+ const resolvedZip = resolve(zipPath);
153
+ const extension = extname(resolvedZip);
154
+ return (await rokuDeploy.publish({
155
+ host: context.target,
156
+ outDir: dirname(resolvedZip),
157
+ outFile: basename(resolvedZip, extension),
158
+ password: context.password,
159
+ rootDir: process.cwd(),
160
+ username: context.username
161
+ })).message;
162
+ };
163
+ const takeScreenshot = async (context, outputPath) => {
164
+ const resolvedOutput = resolve(outputPath);
165
+ const extension = extname(resolvedOutput);
166
+ return await rokuDeploy.takeScreenshot({
167
+ host: context.target,
168
+ outDir: dirname(resolvedOutput),
169
+ outFile: basename(resolvedOutput, extension),
170
+ password: context.password
171
+ });
172
+ };
173
+ const validateRemoteKey = (key) => {
174
+ if (key.startsWith("Lit_")) return;
175
+ if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
176
+ };
177
+ const fetchInstallerStatus = async (context) => {
178
+ return (await fetch(`http://${context.target}`, { signal: AbortSignal.timeout(context.timeoutMs) })).status;
179
+ };
180
+ const fetchText = async (context, path) => {
181
+ const response = await fetch(ecpUrl(context, path), { signal: AbortSignal.timeout(context.timeoutMs) });
182
+ if (!response.ok) throw new Error(`GET ${path} returned HTTP ${response.status}`);
183
+ return await response.text();
184
+ };
185
+ const postOk = async (context, url) => {
186
+ const response = await fetch(url, {
187
+ method: "POST",
188
+ signal: AbortSignal.timeout(context.timeoutMs)
189
+ });
190
+ if (!response.ok) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
191
+ };
192
+ const ecpUrl = (context, path) => new URL(path, `http://${context.target}:${ecpPort}`);
193
+ const sleep = (ms) => new Promise((resolve) => {
194
+ setTimeout(resolve, ms);
195
+ });
196
+ //#endregion
197
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putdotio/rokit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A tiny CLI companion for Roku device harness work.",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,18 +27,27 @@
27
27
  ],
28
28
  "type": "module",
29
29
  "sideEffects": false,
30
+ "types": "dist/index.d.mts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.mts",
34
+ "import": "./dist/index.mjs"
35
+ }
36
+ },
30
37
  "publishConfig": {
31
38
  "access": "public"
32
39
  },
33
40
  "scripts": {
34
- "build": "vp pack src/rokit.ts",
41
+ "build": "vp pack src/index.ts src/rokit.ts --dts",
35
42
  "check": "vp check .",
36
43
  "clean": "rm -rf .turbo coverage dist",
37
- "prepack": "vp pack src/rokit.ts",
38
- "smoke": "vp pack src/rokit.ts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null",
44
+ "hooks:install": "git config core.hooksPath .git-hooks",
45
+ "live:smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs check",
46
+ "prepack": "vp pack src/index.ts src/rokit.ts --dts",
47
+ "smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null",
39
48
  "test": "vp test --passWithNoTests",
40
49
  "typecheck": "tsc --noEmit",
41
- "verify": "vp check . && tsc --noEmit && vp pack src/rokit.ts && vp test --passWithNoTests && npm pack --dry-run"
50
+ "verify": "vp check . && tsc --noEmit && vp pack src/index.ts src/rokit.ts --dts && vp test --passWithNoTests && npm pack --dry-run"
42
51
  },
43
52
  "dependencies": {
44
53
  "roku-deploy": "3.17.3"