@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 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 { assertSceneGraphNode, pressKey, querySceneGraph, type RokuContext } from "@putdotio/rokit";
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
- await querySceneGraph(context);
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 { 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 };
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-D-FxlWPE.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";
@@ -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
- lastApp = await queryActiveApp(context);
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
- throw new Error(`expected active app ${appId}, got ${last}`);
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 postOk(context, url);
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) => await queryEcp(context, "/query/sgnodes/all");
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, 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.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A tiny CLI companion for Roku device harness work.",
5
5
  "keywords": [
6
6
  "cli",