@putdotio/rokit 1.1.0 → 1.3.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 +17 -5
- package/dist/index.d.mts +12 -2
- package/dist/index.mjs +2 -2
- package/dist/rokit.mjs +1 -1
- package/dist/{roku-D-FxlWPE.mjs → roku-7hiM97p0.mjs} +69 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,13 @@ pnpm exec rokit screenshot artifacts/live/player.png
|
|
|
47
47
|
App-specific scenario scripts can also import the generic helpers:
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
assertSceneGraphNode,
|
|
52
|
+
pressKey,
|
|
53
|
+
querySceneGraph,
|
|
54
|
+
readNamedNodeTranslation,
|
|
55
|
+
type RokuContext,
|
|
56
|
+
} from "@putdotio/rokit";
|
|
51
57
|
|
|
52
58
|
const target = process.env.ROKIT_TARGET;
|
|
53
59
|
|
|
@@ -62,8 +68,9 @@ const context: RokuContext = {
|
|
|
62
68
|
};
|
|
63
69
|
|
|
64
70
|
await pressKey(context, "Info");
|
|
71
|
+
const xml = await querySceneGraph(context, { attempts: 3 });
|
|
65
72
|
await assertSceneGraphNode(context, "videoPlayerScreen", { state: "visible" });
|
|
66
|
-
|
|
73
|
+
console.log(readNamedNodeTranslation(xml, "videoPlayerScreen"));
|
|
67
74
|
```
|
|
68
75
|
|
|
69
76
|
## Commands
|
|
@@ -88,13 +95,17 @@ rokit --version
|
|
|
88
95
|
is reachable.
|
|
89
96
|
- `device-info` prints enhanced Roku device metadata as JSON.
|
|
90
97
|
- `active-app` prints the foreground app.
|
|
91
|
-
- `wait-active` waits until the requested app is foregrounded
|
|
98
|
+
- `wait-active` waits until the requested app is foregrounded and tolerates
|
|
99
|
+
transient ECP read failures while polling.
|
|
92
100
|
- `launch` opens an app and waits until it is active. Use repeated `--param`
|
|
93
|
-
values for deeplink parameters.
|
|
101
|
+
values for deeplink parameters. Roku launch responses can race app startup, so
|
|
102
|
+
launch accepts transient timeout/fetch failures and then verifies foreground
|
|
103
|
+
state.
|
|
94
104
|
- `press` sends Roku remote keys through ECP. Use `--delay-ms` for navigation
|
|
95
105
|
sequences that need a stable gap between keys.
|
|
96
106
|
- `query` prints a raw ECP response such as `/query/sgnodes/all`.
|
|
97
|
-
- `sgnodes` prints the raw SceneGraph tree from `/query/sgnodes/all`.
|
|
107
|
+
- `sgnodes` prints the raw SceneGraph tree from `/query/sgnodes/all`. Library
|
|
108
|
+
callers can pass retry options to `querySceneGraph`.
|
|
98
109
|
- `assert-node` checks a named SceneGraph node once.
|
|
99
110
|
- `wait-node` polls SceneGraph until a named node condition matches.
|
|
100
111
|
- `screenshot` saves a developer screenshot. It requires `ROKIT_PASSWORD`.
|
|
@@ -126,6 +137,7 @@ tokens, and app-specific media identifiers do not belong in git.
|
|
|
126
137
|
- remote keypresses
|
|
127
138
|
- raw ECP queries
|
|
128
139
|
- SceneGraph state queries and named-node assertions
|
|
140
|
+
- SceneGraph attribute, numeric geometry, bounds, and translation readers
|
|
129
141
|
- screenshots
|
|
130
142
|
|
|
131
143
|
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 = {
|
|
@@ -42,6 +48,10 @@ type DeviceSummary = {
|
|
|
42
48
|
readonly model: string;
|
|
43
49
|
readonly name: string;
|
|
44
50
|
};
|
|
51
|
+
type RetryOptions = {
|
|
52
|
+
readonly attempts?: number;
|
|
53
|
+
readonly retryDelayMs?: number;
|
|
54
|
+
};
|
|
45
55
|
declare const checkDevice: (context: RokuContext) => Promise<DeviceSummary>;
|
|
46
56
|
declare const getDeviceInfo: (context: RokuContext) => Promise<rokuDeploy.DeviceInfo>;
|
|
47
57
|
declare const queryActiveApp: (context: RokuContext) => Promise<ActiveApp>;
|
|
@@ -49,7 +59,7 @@ declare const waitForActiveApp: (context: RokuContext, appId: string, timeoutMs?
|
|
|
49
59
|
declare const launchApp: (context: RokuContext, appId: string, params?: ReadonlyMap<string, string>) => Promise<ActiveApp>;
|
|
50
60
|
declare const pressKey: (context: RokuContext, key: string) => Promise<void>;
|
|
51
61
|
declare const queryEcp: (context: RokuContext, path: string) => Promise<string>;
|
|
52
|
-
declare const querySceneGraph: (context: RokuContext) => Promise<string>;
|
|
62
|
+
declare const querySceneGraph: (context: RokuContext, options?: RetryOptions) => Promise<string>;
|
|
53
63
|
declare const assertSceneGraphNode: (context: RokuContext, nodeName: string, expectation: NodeExpectation) => Promise<void>;
|
|
54
64
|
declare const waitForSceneGraphNode: (context: RokuContext, nodeName: string, expectation: NodeExpectation, timeoutMs?: number) => Promise<void>;
|
|
55
65
|
declare const installPackage: (context: RokuContext & {
|
|
@@ -60,4 +70,4 @@ declare const takeScreenshot: (context: RokuContext & {
|
|
|
60
70
|
}, outputPath: string) => Promise<string>;
|
|
61
71
|
declare const validateRemoteKey: (key: string) => void;
|
|
62
72
|
//#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 };
|
|
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 {
|
|
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-
|
|
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";
|
|
@@ -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;
|
|
@@ -110,18 +141,27 @@ const queryActiveApp = async (context) => readActiveApp(await fetchText(context,
|
|
|
110
141
|
const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
|
|
111
142
|
const start = Date.now();
|
|
112
143
|
let lastApp;
|
|
144
|
+
let lastError;
|
|
113
145
|
while (Date.now() - start < timeoutMs) {
|
|
114
|
-
|
|
146
|
+
try {
|
|
147
|
+
lastApp = await queryActiveApp(context);
|
|
148
|
+
lastError = void 0;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
lastError = formatErrorMessage(error);
|
|
151
|
+
await sleep(500);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
115
154
|
if (lastApp.id === appId) return lastApp;
|
|
116
155
|
await sleep(500);
|
|
117
156
|
}
|
|
118
157
|
const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown";
|
|
119
|
-
|
|
158
|
+
const errorSuffix = lastError ? `; last ECP error: ${lastError}` : "";
|
|
159
|
+
throw new Error(`expected active app ${appId}, got ${last}${errorSuffix}`);
|
|
120
160
|
};
|
|
121
161
|
const launchApp = async (context, appId, params = /* @__PURE__ */ new Map()) => {
|
|
122
162
|
const url = ecpUrl(context, `/launch/${encodeURIComponent(appId)}`);
|
|
123
163
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
124
|
-
await
|
|
164
|
+
await postLaunchMaybeAccepted(context, url);
|
|
125
165
|
return await waitForActiveApp(context, appId);
|
|
126
166
|
};
|
|
127
167
|
const pressKey = async (context, key) => {
|
|
@@ -129,7 +169,18 @@ const pressKey = async (context, key) => {
|
|
|
129
169
|
await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
|
|
130
170
|
};
|
|
131
171
|
const queryEcp = async (context, path) => await fetchText(context, path.startsWith("/") ? path : `/${path}`);
|
|
132
|
-
const querySceneGraph = async (context) =>
|
|
172
|
+
const querySceneGraph = async (context, options = {}) => {
|
|
173
|
+
const attempts = options.attempts ?? 1;
|
|
174
|
+
const retryDelayMs = options.retryDelayMs ?? 500;
|
|
175
|
+
let lastError;
|
|
176
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) try {
|
|
177
|
+
return await queryEcp(context, "/query/sgnodes/all");
|
|
178
|
+
} catch (error) {
|
|
179
|
+
lastError = error;
|
|
180
|
+
if (attempt < attempts - 1) await sleep(retryDelayMs);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`SceneGraph query failed: ${formatErrorMessage(lastError)}`);
|
|
183
|
+
};
|
|
133
184
|
const assertSceneGraphNode = async (context, nodeName, expectation) => {
|
|
134
185
|
assertNamedNode(await querySceneGraph(context), nodeName, expectation);
|
|
135
186
|
};
|
|
@@ -189,9 +240,22 @@ const postOk = async (context, url) => {
|
|
|
189
240
|
});
|
|
190
241
|
if (!response.ok) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
|
|
191
242
|
};
|
|
243
|
+
const postLaunchMaybeAccepted = async (context, url) => {
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch(url, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
signal: AbortSignal.timeout(context.timeoutMs)
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok && response.status !== 503) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const message = formatErrorMessage(error).toLowerCase();
|
|
252
|
+
if (!message.includes("abort") && !message.includes("timeout") && !message.includes("fetch failed")) throw error;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
192
255
|
const ecpUrl = (context, path) => new URL(path, `http://${context.target}:${ecpPort}`);
|
|
193
256
|
const sleep = (ms) => new Promise((resolve) => {
|
|
194
257
|
setTimeout(resolve, ms);
|
|
195
258
|
});
|
|
259
|
+
const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
196
260
|
//#endregion
|
|
197
|
-
export { readXmlTag as S, isNamedNodeVisible as _, launchApp as a,
|
|
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 };
|