@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 +38 -2
- package/dist/index.d.mts +63 -0
- package/dist/index.mjs +2 -0
- package/dist/rokit.d.mts +1 -0
- package/dist/rokit.mjs +124 -144
- package/dist/roku-D-FxlWPE.mjs +197 -0
- package/package.json +14 -5
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
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/dist/rokit.d.mts
ADDED
|
@@ -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 === "
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
38
|
-
"smoke": "vp pack src/
|
|
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"
|