@putdotio/rokit 2.0.1 → 2.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/AGENTS.md +4 -4
- package/README.md +12 -6
- package/dist/{roku-BxnS6Axs.mjs → debug-af6vvS9T.mjs} +273 -10
- package/dist/index.d.mts +30 -1
- package/dist/index.mjs +2 -2
- package/dist/rokit.mjs +152 -93
- package/docs/DEBUGGING.md +75 -0
- package/docs/READINESS.md +7 -7
- package/docs/skills/rokit-harness/SKILL.md +10 -4
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -48,12 +48,12 @@ Keep it platform-focused, typed, and useful for both humans and agents.
|
|
|
48
48
|
- Missing config/env and child-command failures should not print stack traces.
|
|
49
49
|
- `ROKIT_PASSWORD` is required only for developer-installer operations such as
|
|
50
50
|
install and screenshot.
|
|
51
|
-
- `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are
|
|
52
|
-
primary public contract.
|
|
51
|
+
- `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are optional fallback aliases, not
|
|
52
|
+
the primary public contract.
|
|
53
53
|
- Avoid sleeps in generic commands. App repos can add meaningful wait/assert
|
|
54
54
|
loops around `rokit` primitives.
|
|
55
|
-
- Release details live in `docs/DISTRIBUTION.md`;
|
|
56
|
-
`docs/READINESS.md`.
|
|
55
|
+
- Release details live in `docs/DISTRIBUTION.md`; debugging details live in
|
|
56
|
+
`docs/DEBUGGING.md`; readiness details live in `docs/READINESS.md`.
|
|
57
57
|
|
|
58
58
|
## When Contracts Change
|
|
59
59
|
|
package/README.md
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
+
Requires Node `>=24.14.0`; install it in a consumer repo with:
|
|
21
|
+
|
|
20
22
|
```bash
|
|
21
23
|
pnpm add -D @putdotio/rokit
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
Node `>=24.14`
|
|
25
|
-
|
|
26
26
|
## Quick Start
|
|
27
27
|
|
|
28
28
|
Set the target Roku in the app repo that consumes `rokit`:
|
|
@@ -40,13 +40,13 @@ pnpm exec rokit package --out artifacts/live/channel.zip
|
|
|
40
40
|
pnpm exec rokit install artifacts/live/channel.zip
|
|
41
41
|
pnpm exec rokit launch dev
|
|
42
42
|
pnpm exec rokit press Down Select
|
|
43
|
+
pnpm exec rokit console artifacts/live/console.log --duration-ms 30000
|
|
43
44
|
pnpm exec rokit proof artifacts/live/proof --screenshot
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
`ROKIT_PASSWORD` is required for developer-installer operations such as
|
|
47
48
|
`install` and `screenshot`. `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are
|
|
48
|
-
accepted as
|
|
49
|
-
naming.
|
|
49
|
+
accepted as optional aliases when the `ROKIT_*` names are unset.
|
|
50
50
|
|
|
51
51
|
## Automation
|
|
52
52
|
|
|
@@ -76,6 +76,8 @@ Common commands:
|
|
|
76
76
|
| `discover` | Find Roku ECP devices with SSDP |
|
|
77
77
|
| `device-info` | Read Roku device metadata |
|
|
78
78
|
| `active-app` | Read the foreground app |
|
|
79
|
+
| `console <output-path>` | Capture BrightScript console output from `8085` |
|
|
80
|
+
| `debug-command <command> [args...]` | Run an allowlisted Roku debug command |
|
|
79
81
|
| `media-player` | Read parsed `/query/media-player` state |
|
|
80
82
|
| `snapshot` | Read a compact state snapshot |
|
|
81
83
|
| `proof <output-dir>` | Write reviewable local proof artifacts |
|
|
@@ -87,12 +89,14 @@ Common commands:
|
|
|
87
89
|
| `sgnodes` | Print the raw SceneGraph tree |
|
|
88
90
|
| `assert-node` / `wait-node` | Check generic SceneGraph node state |
|
|
89
91
|
| `wait-active` / `wait-media-player` / `wait-ready` | Poll generic runtime readiness |
|
|
90
|
-
| `screenshot <output-path>` | Save a developer screenshot
|
|
92
|
+
| `screenshot <output-path>` | Save a timestamped developer screenshot |
|
|
91
93
|
|
|
92
94
|
Mutating commands support `--dry-run` where the platform can validate without
|
|
93
95
|
changing device or filesystem state. ECP paths reject query strings, fragments,
|
|
94
96
|
traversal, backslashes, control characters, and percent-encoded path segments.
|
|
95
97
|
Generated output paths must stay within the current working directory.
|
|
98
|
+
Screenshots append a timestamp to the requested filename and report the actual
|
|
99
|
+
path written, so repeated captures do not reuse cache-prone filenames.
|
|
96
100
|
|
|
97
101
|
## Library Use
|
|
98
102
|
|
|
@@ -136,8 +140,9 @@ await waitForSceneGraphAssertion(context, "player ready", (xml) => {
|
|
|
136
140
|
|
|
137
141
|
- package, install, launch, deeplink params, and remote keypresses
|
|
138
142
|
- raw ECP queries and parsed media-player state
|
|
143
|
+
- BrightScript console capture and allowlisted debug-server commands
|
|
139
144
|
- SceneGraph state queries and named-node assertions
|
|
140
|
-
- screenshots, snapshots, and proof artifacts
|
|
145
|
+
- timestamped screenshots, snapshots, and proof artifacts
|
|
141
146
|
|
|
142
147
|
Consumer app repos own product behavior: opening specific routes, asserting
|
|
143
148
|
playback for real content, checking app-specific UI nodes, and generating review
|
|
@@ -146,6 +151,7 @@ artifacts.
|
|
|
146
151
|
## Docs
|
|
147
152
|
|
|
148
153
|
- [Contributing](./CONTRIBUTING.md)
|
|
154
|
+
- [Roku debugging](./docs/DEBUGGING.md)
|
|
149
155
|
- [Distribution](./docs/DISTRIBUTION.md)
|
|
150
156
|
- [Agent readiness](./docs/READINESS.md)
|
|
151
157
|
- [Security](./SECURITY.md)
|
|
@@ -1,6 +1,37 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
1
5
|
import * as rokuDeploy from "roku-deploy";
|
|
2
6
|
import { createSocket } from "node:dgram";
|
|
3
|
-
|
|
7
|
+
//#region src/errors.ts
|
|
8
|
+
var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
|
|
9
|
+
var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
|
|
10
|
+
get message() {
|
|
11
|
+
return "ROKIT_TARGET is not set";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
|
|
15
|
+
get message() {
|
|
16
|
+
return "ROKIT_PASSWORD is not set";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var DebugPortUnavailable = class extends Schema.TaggedErrorClass()("DebugPortUnavailable", {
|
|
20
|
+
detail: Schema.String,
|
|
21
|
+
port: Schema.Number
|
|
22
|
+
}) {
|
|
23
|
+
get message() {
|
|
24
|
+
return `Roku debug port ${this.port} unavailable: ${this.detail}`;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
|
|
28
|
+
const renderError = (error) => error.message;
|
|
29
|
+
const normalizeError = (error) => {
|
|
30
|
+
if (isRokitError(error)) return error;
|
|
31
|
+
return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
|
|
32
|
+
};
|
|
33
|
+
const isRokitError = (error) => error instanceof DebugPortUnavailable || error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
|
|
34
|
+
//#endregion
|
|
4
35
|
//#region src/xml.ts
|
|
5
36
|
const readXmlTag = (xml, tag) => {
|
|
6
37
|
return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
|
|
@@ -215,7 +246,7 @@ const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
|
|
|
215
246
|
lastApp = await queryActiveApp(context);
|
|
216
247
|
lastError = void 0;
|
|
217
248
|
} catch (error) {
|
|
218
|
-
lastError = formatErrorMessage(error);
|
|
249
|
+
lastError = formatErrorMessage$1(error);
|
|
219
250
|
await sleep(500);
|
|
220
251
|
continue;
|
|
221
252
|
}
|
|
@@ -295,7 +326,7 @@ const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4)
|
|
|
295
326
|
lastError = void 0;
|
|
296
327
|
if (mediaPlayer.state === expectedState) return mediaPlayer;
|
|
297
328
|
} catch (error) {
|
|
298
|
-
lastError = formatErrorMessage(error);
|
|
329
|
+
lastError = formatErrorMessage$1(error);
|
|
299
330
|
}
|
|
300
331
|
await sleep(500);
|
|
301
332
|
}
|
|
@@ -320,7 +351,7 @@ const querySceneGraph = async (context, options = {}) => {
|
|
|
320
351
|
if (attempt < attempts - 1) await sleep(retryDelayMs);
|
|
321
352
|
}
|
|
322
353
|
if (lastXml !== "") throw new Error("SceneGraph query returned incomplete XML");
|
|
323
|
-
throw new Error(`SceneGraph query failed: ${formatErrorMessage(lastError)}`);
|
|
354
|
+
throw new Error(`SceneGraph query failed: ${formatErrorMessage$1(lastError)}`);
|
|
324
355
|
};
|
|
325
356
|
const assertSceneGraphNode = async (context, nodeName, expectation) => {
|
|
326
357
|
assertNamedNode(await querySceneGraph(context), nodeName, expectation);
|
|
@@ -351,7 +382,7 @@ const waitForSceneGraphAssertion = async (context, description, assertXml, optio
|
|
|
351
382
|
assertXml(xml);
|
|
352
383
|
return xml;
|
|
353
384
|
} catch (error) {
|
|
354
|
-
lastError = formatErrorMessage(error);
|
|
385
|
+
lastError = formatErrorMessage$1(error);
|
|
355
386
|
}
|
|
356
387
|
await sleep(pollIntervalMs);
|
|
357
388
|
}
|
|
@@ -399,7 +430,7 @@ const packageOptions = (outputPath, rootDir = process.cwd()) => {
|
|
|
399
430
|
};
|
|
400
431
|
};
|
|
401
432
|
const validateEcpPath = (path) => {
|
|
402
|
-
rejectUnsafeInput(path, "ECP path");
|
|
433
|
+
rejectUnsafeInput$1(path, "ECP path");
|
|
403
434
|
if (path.includes("\\")) throw new Error("ECP path must not include backslashes");
|
|
404
435
|
if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) throw new Error("ECP path must be device-relative");
|
|
405
436
|
if (path.includes("?") || path.includes("#")) throw new Error("ECP path must not include query strings or fragments");
|
|
@@ -407,7 +438,7 @@ const validateEcpPath = (path) => {
|
|
|
407
438
|
if (/%(?:2e|2f|5c)/i.test(path)) throw new Error("ECP path must not include percent-encoded path segments");
|
|
408
439
|
return path;
|
|
409
440
|
};
|
|
410
|
-
const rejectUnsafeInput = (value, label) => {
|
|
441
|
+
const rejectUnsafeInput$1 = (value, label) => {
|
|
411
442
|
if ([...value].some((character) => {
|
|
412
443
|
const code = character.charCodeAt(0);
|
|
413
444
|
return code < 32 || code === 127;
|
|
@@ -440,7 +471,7 @@ const postLaunchMaybeAccepted = async (context, url) => {
|
|
|
440
471
|
});
|
|
441
472
|
if (!response.ok && response.status !== 503) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
|
|
442
473
|
} catch (error) {
|
|
443
|
-
const message = formatErrorMessage(error).toLowerCase();
|
|
474
|
+
const message = formatErrorMessage$1(error).toLowerCase();
|
|
444
475
|
if (!message.includes("abort") && !message.includes("timeout") && !message.includes("fetch failed")) throw error;
|
|
445
476
|
}
|
|
446
477
|
};
|
|
@@ -466,7 +497,7 @@ const readXmlNumberAttribute = (attributes, name) => {
|
|
|
466
497
|
const sleep = (ms) => new Promise((resolve) => {
|
|
467
498
|
setTimeout(resolve, ms);
|
|
468
499
|
});
|
|
469
|
-
const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
500
|
+
const formatErrorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
470
501
|
const readSsdpHeaders = (text) => {
|
|
471
502
|
const headers = /* @__PURE__ */ new Map();
|
|
472
503
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -484,4 +515,236 @@ const readLocationTarget = (location) => {
|
|
|
484
515
|
}
|
|
485
516
|
};
|
|
486
517
|
//#endregion
|
|
487
|
-
|
|
518
|
+
//#region src/runtime.ts
|
|
519
|
+
const appDir = process.cwd();
|
|
520
|
+
const envPath = join(join(appDir, ".rokit"), ".env");
|
|
521
|
+
const loadLocalEnv = () => {
|
|
522
|
+
if (existsSync(envPath)) process.loadEnvFile(envPath);
|
|
523
|
+
};
|
|
524
|
+
const loadEnv = () => ({
|
|
525
|
+
password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
|
|
526
|
+
target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
|
|
527
|
+
timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
|
|
528
|
+
username: process.env.ROKIT_USERNAME ?? "rokudev"
|
|
529
|
+
});
|
|
530
|
+
const requireTarget = (env) => {
|
|
531
|
+
const target = env.target?.trim();
|
|
532
|
+
if (!target) throw MissingTarget.make({});
|
|
533
|
+
return normalizeTarget(target);
|
|
534
|
+
};
|
|
535
|
+
const requirePassword = (env) => {
|
|
536
|
+
const password = env.password;
|
|
537
|
+
if (!password) throw MissingPassword.make({});
|
|
538
|
+
return password;
|
|
539
|
+
};
|
|
540
|
+
const resolveOutputPath = (path, label) => {
|
|
541
|
+
rejectUnsafeInput(path, label);
|
|
542
|
+
const resolved = resolve(appDir, path);
|
|
543
|
+
const relativePath = relative(appDir, resolved);
|
|
544
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
|
|
545
|
+
return resolved;
|
|
546
|
+
};
|
|
547
|
+
const resolveFileOutputPath = (path, label) => {
|
|
548
|
+
const resolved = resolveOutputPath(path, label);
|
|
549
|
+
if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
|
|
550
|
+
return resolved;
|
|
551
|
+
};
|
|
552
|
+
const rejectUnsafeInput = (value, label) => {
|
|
553
|
+
if ([...value].some((character) => {
|
|
554
|
+
const code = character.charCodeAt(0);
|
|
555
|
+
return code < 32 || code === 127;
|
|
556
|
+
})) fail(`${label} contains control characters`);
|
|
557
|
+
};
|
|
558
|
+
const rejectUnsafeEcpPath = (value) => {
|
|
559
|
+
try {
|
|
560
|
+
validateEcpPath(value);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
fail(formatErrorMessage(error));
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const fail = (message) => {
|
|
566
|
+
throw InvalidInput.make({ message });
|
|
567
|
+
};
|
|
568
|
+
const formatErrorMessage = (error) => {
|
|
569
|
+
if (error instanceof Error) return error.message;
|
|
570
|
+
return String(error);
|
|
571
|
+
};
|
|
572
|
+
const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
|
|
573
|
+
const parseTimeout = (value) => {
|
|
574
|
+
if (value === void 0) return 1e4;
|
|
575
|
+
const timeout = Number(value);
|
|
576
|
+
if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
|
|
577
|
+
return timeout;
|
|
578
|
+
};
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/debug.ts
|
|
581
|
+
const debugServerPort = 8080;
|
|
582
|
+
const brightScriptConsolePort = 8085;
|
|
583
|
+
const buildDebugCommand = (command, args) => {
|
|
584
|
+
validateDebugToken(command, "debug command");
|
|
585
|
+
for (const arg of args) rejectUnsafeInput(arg, "debug command argument");
|
|
586
|
+
if (command === "chanperf") {
|
|
587
|
+
validateChanperfArgs(args);
|
|
588
|
+
return debugServerCommand(command, args);
|
|
589
|
+
}
|
|
590
|
+
if (command === "brightscript_warnings") {
|
|
591
|
+
validateOptionalNonNegativeInteger(args, "brightscript_warnings");
|
|
592
|
+
return debugServerCommand(command, args);
|
|
593
|
+
}
|
|
594
|
+
if (command === "free" || command === "loaded_textures" || command === "r2d2_bitmaps") {
|
|
595
|
+
validateNoArgs(command, args);
|
|
596
|
+
return debugServerCommand(command, args);
|
|
597
|
+
}
|
|
598
|
+
if (command === "sgnodes") return debugServerCommand(command, normalizeSceneGraphNodeArgs(args));
|
|
599
|
+
if (command === "bsc" || command === "bscs" || command === "bt" || command === "classes" || command === "help" || command === "last" || command === "list") {
|
|
600
|
+
validateNoArgs(command, args);
|
|
601
|
+
return brightScriptConsoleCommand(command, args);
|
|
602
|
+
}
|
|
603
|
+
if (command === "threads" || command === "ths") {
|
|
604
|
+
validateOptionalNonNegativeInteger(args, command);
|
|
605
|
+
return brightScriptConsoleCommand(command, args);
|
|
606
|
+
}
|
|
607
|
+
return fail(`Unsupported Roku debug command: ${command}`);
|
|
608
|
+
};
|
|
609
|
+
const runDebugCommand = async (context, command, durationMs, idleTimeoutMs) => {
|
|
610
|
+
const safeCommand = buildDebugCommand(command.command, command.args);
|
|
611
|
+
const startedAt = Date.now();
|
|
612
|
+
const port = resolveDebugPort(context, safeCommand.port);
|
|
613
|
+
const body = await readDebugSocket(context, port, {
|
|
614
|
+
durationMs,
|
|
615
|
+
idleTimeoutMs,
|
|
616
|
+
request: safeCommand.request
|
|
617
|
+
});
|
|
618
|
+
const elapsedMs = Date.now() - startedAt;
|
|
619
|
+
return {
|
|
620
|
+
args: safeCommand.args,
|
|
621
|
+
body,
|
|
622
|
+
bytes: Buffer.byteLength(body),
|
|
623
|
+
command: safeCommand.command,
|
|
624
|
+
elapsedMs,
|
|
625
|
+
port
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
const captureDebugConsole = async (context, durationMs) => {
|
|
629
|
+
const startedAt = Date.now();
|
|
630
|
+
const port = resolveDebugPort(context, brightScriptConsolePort);
|
|
631
|
+
const body = await readDebugSocket(context, port, { durationMs });
|
|
632
|
+
const elapsedMs = Date.now() - startedAt;
|
|
633
|
+
return {
|
|
634
|
+
body,
|
|
635
|
+
bytes: Buffer.byteLength(body),
|
|
636
|
+
durationMs,
|
|
637
|
+
elapsedMs,
|
|
638
|
+
port
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
const debugServerCommand = (command, args) => ({
|
|
642
|
+
args,
|
|
643
|
+
command,
|
|
644
|
+
port: debugServerPort,
|
|
645
|
+
request: formatDebugRequest(command, args)
|
|
646
|
+
});
|
|
647
|
+
const brightScriptConsoleCommand = (command, args) => ({
|
|
648
|
+
args,
|
|
649
|
+
command,
|
|
650
|
+
port: brightScriptConsolePort,
|
|
651
|
+
request: formatDebugRequest(command, args)
|
|
652
|
+
});
|
|
653
|
+
const readDebugSocket = async (context, port, options) => await new Promise((resolve, reject) => {
|
|
654
|
+
let body = "";
|
|
655
|
+
let durationTimer;
|
|
656
|
+
let idleTimer;
|
|
657
|
+
let settled = false;
|
|
658
|
+
const socket = createConnection({
|
|
659
|
+
host: context.target,
|
|
660
|
+
port
|
|
661
|
+
});
|
|
662
|
+
const clearTimers = () => {
|
|
663
|
+
if (durationTimer !== void 0) clearTimeout(durationTimer);
|
|
664
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
665
|
+
};
|
|
666
|
+
const finish = () => {
|
|
667
|
+
if (settled) return;
|
|
668
|
+
settled = true;
|
|
669
|
+
clearTimers();
|
|
670
|
+
socket.destroy();
|
|
671
|
+
resolve(body);
|
|
672
|
+
};
|
|
673
|
+
const failRead = (detail) => {
|
|
674
|
+
if (settled) return;
|
|
675
|
+
settled = true;
|
|
676
|
+
clearTimers();
|
|
677
|
+
socket.destroy();
|
|
678
|
+
reject(DebugPortUnavailable.make({
|
|
679
|
+
detail,
|
|
680
|
+
port
|
|
681
|
+
}));
|
|
682
|
+
};
|
|
683
|
+
const scheduleIdleTimer = () => {
|
|
684
|
+
if (options.idleTimeoutMs === void 0) return;
|
|
685
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
686
|
+
idleTimer = setTimeout(finish, options.idleTimeoutMs);
|
|
687
|
+
};
|
|
688
|
+
socket.setEncoding("utf8");
|
|
689
|
+
socket.setTimeout(context.timeoutMs);
|
|
690
|
+
socket.once("connect", () => {
|
|
691
|
+
socket.setTimeout(0);
|
|
692
|
+
durationTimer = setTimeout(finish, options.durationMs);
|
|
693
|
+
if (options.request !== void 0) socket.write(options.request);
|
|
694
|
+
});
|
|
695
|
+
socket.on("data", (chunk) => {
|
|
696
|
+
body = `${body}${chunk}`;
|
|
697
|
+
scheduleIdleTimer();
|
|
698
|
+
});
|
|
699
|
+
socket.once("timeout", () => {
|
|
700
|
+
failRead("connection timed out");
|
|
701
|
+
});
|
|
702
|
+
socket.once("error", (error) => {
|
|
703
|
+
if (body.length > 0) {
|
|
704
|
+
finish();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
failRead(error.message);
|
|
708
|
+
});
|
|
709
|
+
socket.once("close", finish);
|
|
710
|
+
});
|
|
711
|
+
const formatDebugRequest = (command, args) => `${[command, ...args].join(" ")}\r\n`;
|
|
712
|
+
const resolveDebugPort = (context, port) => port === debugServerPort ? context.debugServerPort ?? debugServerPort : context.debugConsolePort ?? brightScriptConsolePort;
|
|
713
|
+
const validateDebugToken = (value, label) => {
|
|
714
|
+
rejectUnsafeInput(value, label);
|
|
715
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) fail(`${label} contains unsupported characters`);
|
|
716
|
+
};
|
|
717
|
+
const validateChanperfArgs = (args) => {
|
|
718
|
+
if (args.length === 0) return;
|
|
719
|
+
if (args[0] === "-r") fail("chanperf -r writes to the BrightScript console; use rokit console for capture");
|
|
720
|
+
fail("usage: rokit debug-command chanperf");
|
|
721
|
+
};
|
|
722
|
+
const normalizeSceneGraphNodeArgs = (args) => {
|
|
723
|
+
if (args.length === 1 && (args[0] === "roots" || args[0] === "all")) return args;
|
|
724
|
+
if (args.length === 1) {
|
|
725
|
+
validateDebugToken(args[0] ?? "", "sgnodes id");
|
|
726
|
+
return args;
|
|
727
|
+
}
|
|
728
|
+
if (args.length === 2 && args[0] === "id") {
|
|
729
|
+
validateDebugToken(args[1] ?? "", "sgnodes id");
|
|
730
|
+
return [args[1] ?? ""];
|
|
731
|
+
}
|
|
732
|
+
return fail("usage: rokit debug-command sgnodes <roots|all|node-id|id node-id>");
|
|
733
|
+
};
|
|
734
|
+
const validateNoArgs = (command, args) => {
|
|
735
|
+
if (args.length > 0) fail(`usage: rokit debug-command ${command}`);
|
|
736
|
+
};
|
|
737
|
+
const validateOptionalNonNegativeInteger = (args, command) => {
|
|
738
|
+
if (args.length === 0) return;
|
|
739
|
+
if (args.length === 1) {
|
|
740
|
+
validateNonNegativeInteger(args[0] ?? "", `${command} argument`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
fail(`usage: rokit debug-command ${command} [id]`);
|
|
744
|
+
};
|
|
745
|
+
const validateNonNegativeInteger = (value, label) => {
|
|
746
|
+
const parsed = Number(value);
|
|
747
|
+
if (!Number.isInteger(parsed) || parsed < 0) fail(`Invalid ${label}: ${value}`);
|
|
748
|
+
};
|
|
749
|
+
//#endregion
|
|
750
|
+
export { readNamedNodeBounds as $, readMediaPlayerInfo as A, waitForSceneGraphNode as B, queryEcp as C, queryMediaPlayerXmlSafe as D, queryMediaPlayerXml as E, validateEcpPath as F, assertNamedNodeTranslation as G, assertNamedNodeSize as H, validateRemoteKey as I, isCompleteSceneGraph as J, assertSceneGraphNumberNear as K, waitForActiveApp as L, readMediaPlayerState as M, resolvePackageOutputPath as N, querySceneGraph as O, takeScreenshot as P, readNamedNodeAttributes as Q, waitForMediaPlayerState as R, queryActiveApp as S, queryMediaPlayerSafe as T, assertNamedNodeState as U, assertNamedNode as V, assertNamedNodeText as W, parseSceneGraphNumberList as X, isNamedNodeVisible as Y, readNamedNodeAttribute as Z, installPackage as _, loadEnv as a, readActiveApp as at, packageChannel as b, requirePassword as c, normalizeError as ct, resolveOutputPath as d, readNamedNodeNumber as et, assertMediaPlayerContainer as f, getDeviceInfo as g, discoverRokuDevices as h, fail as i, sceneGraphContainsText as it, readMediaPlayerPositionMs as j, readMediaPlayerContainer as k, requireTarget as l, renderError as lt, checkDevice as m, captureDebugConsole as n, readSceneGraphFailure as nt, loadLocalEnv as o, readXmlAttribute as ot, assertSceneGraphNode as p, escapeXmlAttribute as q, runDebugCommand as r, readSceneGraphStatus as rt, rejectUnsafeEcpPath as s, readXmlTag as st, buildDebugCommand as t, readNamedNodeTranslation as tt, resolveFileOutputPath as u, isActiveMediaPlayerState as v, queryMediaPlayer as w, pressKey as x, launchApp as y, waitForSceneGraphAssertion as z };
|
package/dist/index.d.mts
CHANGED
|
@@ -49,6 +49,8 @@ declare const readActiveApp: (xml: string) => ActiveApp;
|
|
|
49
49
|
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"];
|
|
50
50
|
type RemoteKey = (typeof remoteKeys)[number] | `Lit_${string}`;
|
|
51
51
|
type RokuContext = {
|
|
52
|
+
readonly debugConsolePort?: number;
|
|
53
|
+
readonly debugServerPort?: number;
|
|
52
54
|
readonly password?: string;
|
|
53
55
|
readonly target: string;
|
|
54
56
|
readonly timeoutMs: number;
|
|
@@ -130,4 +132,31 @@ declare const packageChannel: (outputPath: string, rootDir?: string) => Promise<
|
|
|
130
132
|
declare const validateEcpPath: (path: string) => string;
|
|
131
133
|
declare const validateRemoteKey: (key: string) => void;
|
|
132
134
|
//#endregion
|
|
133
|
-
|
|
135
|
+
//#region src/debug.d.ts
|
|
136
|
+
type RokuDebugPort = 8080 | 8085;
|
|
137
|
+
type RokuDebugCommand = {
|
|
138
|
+
readonly args: readonly string[];
|
|
139
|
+
readonly command: string;
|
|
140
|
+
readonly port: RokuDebugPort;
|
|
141
|
+
readonly request: string;
|
|
142
|
+
};
|
|
143
|
+
type DebugCommandResult = {
|
|
144
|
+
readonly args: readonly string[];
|
|
145
|
+
readonly body: string;
|
|
146
|
+
readonly bytes: number;
|
|
147
|
+
readonly command: string;
|
|
148
|
+
readonly elapsedMs: number;
|
|
149
|
+
readonly port: number;
|
|
150
|
+
};
|
|
151
|
+
type DebugConsoleCapture = {
|
|
152
|
+
readonly body: string;
|
|
153
|
+
readonly bytes: number;
|
|
154
|
+
readonly durationMs: number;
|
|
155
|
+
readonly elapsedMs: number;
|
|
156
|
+
readonly port: number;
|
|
157
|
+
};
|
|
158
|
+
declare const buildDebugCommand: (command: string, args: readonly string[]) => RokuDebugCommand;
|
|
159
|
+
declare const runDebugCommand: (context: RokuContext, command: RokuDebugCommand, durationMs: number, idleTimeoutMs: number) => Promise<DebugCommandResult>;
|
|
160
|
+
declare const captureDebugConsole: (context: RokuContext, durationMs: number) => Promise<DebugConsoleCapture>;
|
|
161
|
+
//#endregion
|
|
162
|
+
export { type ActiveApp, type DebugCommandResult, type DebugConsoleCapture, type DeviceSummary, type DiscoveredRokuDevice, type MediaPlayerInfo, type MediaPlayerState, type NodeExpectation, type NodeState, type PackageResult, type RemoteKey, type RetryOptions, type RokuContext, type RokuDebugCommand, type RokuDebugPort, type SceneGraphAssertion, type SceneGraphBounds, type SceneGraphPoint, type SceneGraphStatus, type WaitForSceneGraphAssertionOptions, assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, runDebugCommand, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
export { assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
|
|
1
|
+
import { $ as readNamedNodeBounds, A as readMediaPlayerInfo, B as waitForSceneGraphNode, C as queryEcp, D as queryMediaPlayerXmlSafe, E as queryMediaPlayerXml, F as validateEcpPath, G as assertNamedNodeTranslation, H as assertNamedNodeSize, I as validateRemoteKey, J as isCompleteSceneGraph, K as assertSceneGraphNumberNear, L as waitForActiveApp, M as readMediaPlayerState, O as querySceneGraph, P as takeScreenshot, Q as readNamedNodeAttributes, R as waitForMediaPlayerState, S as queryActiveApp, T as queryMediaPlayerSafe, U as assertNamedNodeState, V as assertNamedNode, W as assertNamedNodeText, X as parseSceneGraphNumberList, Y as isNamedNodeVisible, Z as readNamedNodeAttribute, _ as installPackage, at as readActiveApp, b as packageChannel, et as readNamedNodeNumber, f as assertMediaPlayerContainer, g as getDeviceInfo, h as discoverRokuDevices, it as sceneGraphContainsText, j as readMediaPlayerPositionMs, k as readMediaPlayerContainer, m as checkDevice, n as captureDebugConsole, nt as readSceneGraphFailure, ot as readXmlAttribute, p as assertSceneGraphNode, q as escapeXmlAttribute, r as runDebugCommand, rt as readSceneGraphStatus, st as readXmlTag, t as buildDebugCommand, tt as readNamedNodeTranslation, v as isActiveMediaPlayerState, w as queryMediaPlayer, x as pressKey, y as launchApp, z as waitForSceneGraphAssertion } from "./debug-af6vvS9T.mjs";
|
|
2
|
+
export { assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, runDebugCommand, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
|
package/dist/rokit.mjs
CHANGED
|
@@ -1,94 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as
|
|
2
|
+
import { B as waitForSceneGraphNode, C as queryEcp, I as validateRemoteKey, L as waitForActiveApp, N as resolvePackageOutputPath, O as querySceneGraph, P as takeScreenshot, R as waitForMediaPlayerState, S as queryActiveApp, _ as installPackage, a as loadEnv, b as packageChannel, c as requirePassword, ct as normalizeError, d as resolveOutputPath, g as getDeviceInfo, h as discoverRokuDevices, i as fail, l as requireTarget, lt as renderError, m as checkDevice, n as captureDebugConsole, nt as readSceneGraphFailure, o as loadLocalEnv, p as assertSceneGraphNode, r as runDebugCommand, rt as readSceneGraphStatus, s as rejectUnsafeEcpPath, t as buildDebugCommand, u as resolveFileOutputPath, w as queryMediaPlayer, x as pressKey, y as launchApp } from "./debug-af6vvS9T.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import {
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
5
7
|
import { NodeRuntime } from "@effect/platform-node";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { Effect, Schema } from "effect";
|
|
8
|
-
//#region src/errors.ts
|
|
9
|
-
var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
|
|
10
|
-
var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
|
|
11
|
-
get message() {
|
|
12
|
-
return "ROKIT_TARGET is not set";
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
|
|
16
|
-
get message() {
|
|
17
|
-
return "ROKIT_PASSWORD is not set";
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
|
|
21
|
-
const renderError = (error) => error.message;
|
|
22
|
-
const normalizeError = (error) => {
|
|
23
|
-
if (isRokitError(error)) return error;
|
|
24
|
-
return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
|
|
25
|
-
};
|
|
26
|
-
const isRokitError = (error) => error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region src/runtime.ts
|
|
29
|
-
const appDir = process.cwd();
|
|
30
|
-
const envPath = join(join(appDir, ".rokit"), ".env");
|
|
31
|
-
const loadLocalEnv = () => {
|
|
32
|
-
if (existsSync(envPath)) process.loadEnvFile(envPath);
|
|
33
|
-
};
|
|
34
|
-
const loadEnv = () => ({
|
|
35
|
-
password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
|
|
36
|
-
target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
|
|
37
|
-
timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
|
|
38
|
-
username: process.env.ROKIT_USERNAME ?? "rokudev"
|
|
39
|
-
});
|
|
40
|
-
const requireTarget = (env) => {
|
|
41
|
-
const target = env.target?.trim();
|
|
42
|
-
if (!target) throw MissingTarget.make({});
|
|
43
|
-
return normalizeTarget(target);
|
|
44
|
-
};
|
|
45
|
-
const requirePassword = (env) => {
|
|
46
|
-
const password = env.password;
|
|
47
|
-
if (!password) throw MissingPassword.make({});
|
|
48
|
-
return password;
|
|
49
|
-
};
|
|
50
|
-
const resolveOutputPath = (path, label) => {
|
|
51
|
-
rejectUnsafeInput(path, label);
|
|
52
|
-
const resolved = resolve(appDir, path);
|
|
53
|
-
const relativePath = relative(appDir, resolved);
|
|
54
|
-
if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
|
|
55
|
-
return resolved;
|
|
56
|
-
};
|
|
57
|
-
const resolveFileOutputPath = (path, label) => {
|
|
58
|
-
const resolved = resolveOutputPath(path, label);
|
|
59
|
-
if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
|
|
60
|
-
return resolved;
|
|
61
|
-
};
|
|
62
|
-
const rejectUnsafeInput = (value, label) => {
|
|
63
|
-
if ([...value].some((character) => {
|
|
64
|
-
const code = character.charCodeAt(0);
|
|
65
|
-
return code < 32 || code === 127;
|
|
66
|
-
})) fail(`${label} contains control characters`);
|
|
67
|
-
};
|
|
68
|
-
const rejectUnsafeEcpPath = (value) => {
|
|
69
|
-
try {
|
|
70
|
-
validateEcpPath(value);
|
|
71
|
-
} catch (error) {
|
|
72
|
-
fail(formatErrorMessage(error));
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
const fail = (message) => {
|
|
76
|
-
throw InvalidInput.make({ message });
|
|
77
|
-
};
|
|
78
|
-
const formatErrorMessage = (error) => {
|
|
79
|
-
if (error instanceof Error) return error.message;
|
|
80
|
-
return String(error);
|
|
81
|
-
};
|
|
82
|
-
const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
|
|
83
|
-
const parseTimeout = (value) => {
|
|
84
|
-
if (value === void 0) return 1e4;
|
|
85
|
-
const timeout = Number(value);
|
|
86
|
-
if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
|
|
87
|
-
return timeout;
|
|
88
|
-
};
|
|
89
|
-
//#endregion
|
|
90
8
|
//#region src/cli.ts
|
|
91
9
|
const packageJson = createRequire(import.meta.url)("../package.json");
|
|
10
|
+
const defaultConsoleDurationMs = 3e4;
|
|
11
|
+
const defaultDebugCommandDurationMs = 3e3;
|
|
12
|
+
const defaultDebugCommandIdleTimeoutMs = 500;
|
|
92
13
|
const mainEffect = Effect.fn("mainEffect")(function* (argv = process.argv.slice(2)) {
|
|
93
14
|
let outputMode = inferOutputMode(argv);
|
|
94
15
|
let fields = [];
|
|
@@ -177,6 +98,30 @@ const runCommand = async (context, command, dryRun) => {
|
|
|
177
98
|
status: "ok"
|
|
178
99
|
};
|
|
179
100
|
}
|
|
101
|
+
if (command.name === "console") {
|
|
102
|
+
const path = timestampOutputPath(resolveFileOutputPath(command.args.outputPath, "console output path"));
|
|
103
|
+
const capture = await captureDebugConsole(deviceContext, command.args.durationMs);
|
|
104
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
105
|
+
writeFileSync(path, capture.body);
|
|
106
|
+
return {
|
|
107
|
+
command: command.name,
|
|
108
|
+
data: {
|
|
109
|
+
...capture,
|
|
110
|
+
path
|
|
111
|
+
},
|
|
112
|
+
message: `console: ${path}`,
|
|
113
|
+
status: "ok"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (command.name === "debug-command") {
|
|
117
|
+
const result = await runDebugCommand(deviceContext, command.args.command, command.args.durationMs, command.args.idleTimeoutMs);
|
|
118
|
+
return {
|
|
119
|
+
command: command.name,
|
|
120
|
+
data: result,
|
|
121
|
+
message: result.body,
|
|
122
|
+
status: "ok"
|
|
123
|
+
};
|
|
124
|
+
}
|
|
180
125
|
if (command.name === "device-info") return {
|
|
181
126
|
command: command.name,
|
|
182
127
|
data: await getDeviceInfo(deviceContext),
|
|
@@ -307,7 +252,7 @@ const runCommand = async (context, command, dryRun) => {
|
|
|
307
252
|
};
|
|
308
253
|
}
|
|
309
254
|
if (command.name === "screenshot") {
|
|
310
|
-
const path = resolveFileOutputPath(command.outputPath, "screenshot output path");
|
|
255
|
+
const path = timestampOutputPath(resolveFileOutputPath(command.outputPath, "screenshot output path"));
|
|
311
256
|
if (dryRun) return dryRunResult(command.name, { path });
|
|
312
257
|
const password = requirePassword(deviceContext);
|
|
313
258
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -463,7 +408,7 @@ const writeProof = async (context, outputDir, includeScreenshot) => {
|
|
|
463
408
|
const path = await takeScreenshot({
|
|
464
409
|
...context,
|
|
465
410
|
password
|
|
466
|
-
}, `${outputDir}/screenshot.png`);
|
|
411
|
+
}, timestampOutputPath(`${outputDir}/screenshot.png`));
|
|
467
412
|
artifacts.push({
|
|
468
413
|
kind: "screenshot",
|
|
469
414
|
path
|
|
@@ -475,6 +420,23 @@ const writeProof = async (context, outputDir, includeScreenshot) => {
|
|
|
475
420
|
snapshot
|
|
476
421
|
};
|
|
477
422
|
};
|
|
423
|
+
const timestampOutputPath = (path, date = /* @__PURE__ */ new Date()) => {
|
|
424
|
+
const extension = extname(path);
|
|
425
|
+
const name = basename(path, extension);
|
|
426
|
+
return join(dirname(path), `${name}-${formatTimestamp(date)}${extension}`);
|
|
427
|
+
};
|
|
428
|
+
const formatTimestamp = (date) => [
|
|
429
|
+
date.getFullYear().toString(),
|
|
430
|
+
padDatePart(date.getMonth() + 1),
|
|
431
|
+
padDatePart(date.getDate()),
|
|
432
|
+
"-",
|
|
433
|
+
padDatePart(date.getHours()),
|
|
434
|
+
padDatePart(date.getMinutes()),
|
|
435
|
+
padDatePart(date.getSeconds()),
|
|
436
|
+
"-",
|
|
437
|
+
padDatePart(date.getMilliseconds(), 3)
|
|
438
|
+
].join("");
|
|
439
|
+
const padDatePart = (value, length = 2) => value.toString().padStart(length, "0");
|
|
478
440
|
const waitForReady = async (context, command) => {
|
|
479
441
|
const activeApp = await waitForActiveApp(context, command.appId, command.timeoutMs);
|
|
480
442
|
const sceneGraph = await observe(async () => querySceneGraph(context, {
|
|
@@ -501,7 +463,7 @@ const commandNeedsTarget = (command, dryRun) => {
|
|
|
501
463
|
if (command.name === "describe" || command.name === "discover" || command.name === "package") return false;
|
|
502
464
|
return !(dryRun && commandSupportsDryRun(command));
|
|
503
465
|
};
|
|
504
|
-
const commandSupportsDryRun = (command) => command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
|
|
466
|
+
const commandSupportsDryRun = (command) => command.name === "console" || command.name === "debug-command" || command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
|
|
505
467
|
const dryRunData = (command) => {
|
|
506
468
|
if (command.name === "launch") return {
|
|
507
469
|
appId: command.args.appId,
|
|
@@ -516,7 +478,20 @@ const dryRunData = (command) => {
|
|
|
516
478
|
until: command.args.until ? formatNodeData(command.args.until) : void 0
|
|
517
479
|
};
|
|
518
480
|
}
|
|
519
|
-
if (command.name === "
|
|
481
|
+
if (command.name === "console") return {
|
|
482
|
+
durationMs: command.args.durationMs,
|
|
483
|
+
path: timestampOutputPath(resolveFileOutputPath(command.args.outputPath, "console output path")),
|
|
484
|
+
port: 8085
|
|
485
|
+
};
|
|
486
|
+
if (command.name === "debug-command") return {
|
|
487
|
+
args: command.args.command.args,
|
|
488
|
+
command: command.args.command.command,
|
|
489
|
+
durationMs: command.args.durationMs,
|
|
490
|
+
idleTimeoutMs: command.args.idleTimeoutMs,
|
|
491
|
+
port: command.args.command.port,
|
|
492
|
+
request: command.args.command.request.trim()
|
|
493
|
+
};
|
|
494
|
+
if (command.name === "screenshot") return { path: timestampOutputPath(resolveFileOutputPath(command.outputPath, "screenshot output path")) };
|
|
520
495
|
if (command.name === "proof") return {
|
|
521
496
|
outputDir: resolveOutputPath(command.outputDir, "proof output directory"),
|
|
522
497
|
screenshot: command.screenshot
|
|
@@ -611,6 +586,14 @@ const parseCommand = (options) => {
|
|
|
611
586
|
const [name, ...args] = options.args;
|
|
612
587
|
if (name === "describe") return { name };
|
|
613
588
|
if (name === "check") return { name };
|
|
589
|
+
if (name === "console") return {
|
|
590
|
+
name,
|
|
591
|
+
args: parseConsoleArgs(args)
|
|
592
|
+
};
|
|
593
|
+
if (name === "debug-command") return {
|
|
594
|
+
name,
|
|
595
|
+
args: parseDebugCommandArgs(args)
|
|
596
|
+
};
|
|
614
597
|
if (name === "discover") return {
|
|
615
598
|
name,
|
|
616
599
|
timeoutMs: parseOptionalTimeout(args, "rokit discover")
|
|
@@ -767,6 +750,52 @@ const parseOutputPath = (args, usage) => {
|
|
|
767
750
|
if (args.length === 2 && args[0] === "--out" && args[1] !== void 0) return args[1];
|
|
768
751
|
return fail(`usage: ${usage}`);
|
|
769
752
|
};
|
|
753
|
+
const parseConsoleArgs = (args) => {
|
|
754
|
+
let outputPath;
|
|
755
|
+
let durationMs = defaultConsoleDurationMs;
|
|
756
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
757
|
+
const arg = args[index];
|
|
758
|
+
if (arg === "--duration-ms") {
|
|
759
|
+
durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
|
|
760
|
+
index += 1;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (arg?.startsWith("--")) fail(`Unknown console option: ${arg}`);
|
|
764
|
+
if (outputPath !== void 0 || arg === void 0) fail("usage: rokit console <output-path> [--duration-ms <ms>]");
|
|
765
|
+
outputPath = arg;
|
|
766
|
+
}
|
|
767
|
+
if (outputPath !== void 0) return {
|
|
768
|
+
durationMs,
|
|
769
|
+
outputPath
|
|
770
|
+
};
|
|
771
|
+
return fail("usage: rokit console <output-path> [--duration-ms <ms>]");
|
|
772
|
+
};
|
|
773
|
+
const parseDebugCommandArgs = (args) => {
|
|
774
|
+
let durationMs = defaultDebugCommandDurationMs;
|
|
775
|
+
let idleTimeoutMs = defaultDebugCommandIdleTimeoutMs;
|
|
776
|
+
const commandArgs = [];
|
|
777
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
778
|
+
const arg = args[index];
|
|
779
|
+
if (arg === "--duration-ms") {
|
|
780
|
+
durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
|
|
781
|
+
index += 1;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (arg === "--idle-timeout-ms") {
|
|
785
|
+
idleTimeoutMs = parsePositiveInteger(args[index + 1] ?? "", "idle timeout");
|
|
786
|
+
index += 1;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg !== void 0) commandArgs.push(arg);
|
|
790
|
+
}
|
|
791
|
+
const [command, ...debugArgs] = commandArgs;
|
|
792
|
+
if (command === void 0) fail("usage: rokit debug-command <command> [args...] [--duration-ms <ms>]");
|
|
793
|
+
return {
|
|
794
|
+
command: buildDebugCommand(command, debugArgs),
|
|
795
|
+
durationMs,
|
|
796
|
+
idleTimeoutMs
|
|
797
|
+
};
|
|
798
|
+
};
|
|
770
799
|
const parseProofArgs = (args) => {
|
|
771
800
|
let outputDir;
|
|
772
801
|
let screenshot = false;
|
|
@@ -838,6 +867,21 @@ const parseInputJson = (value) => {
|
|
|
838
867
|
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
839
868
|
};
|
|
840
869
|
if (command === "check" || command === "device-info" || command === "active-app") return { name: command };
|
|
870
|
+
if (command === "console") return {
|
|
871
|
+
args: {
|
|
872
|
+
durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultConsoleDurationMs,
|
|
873
|
+
outputPath: readString(parsed, "outputPath")
|
|
874
|
+
},
|
|
875
|
+
name: "console"
|
|
876
|
+
};
|
|
877
|
+
if (command === "debug-command") return {
|
|
878
|
+
args: {
|
|
879
|
+
command: buildDebugCommand(readString(parsed, "debugCommand"), readOptionalStringArray(parsed, "args") ?? []),
|
|
880
|
+
durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultDebugCommandDurationMs,
|
|
881
|
+
idleTimeoutMs: readOptionalNumber(parsed, "idleTimeoutMs") ?? defaultDebugCommandIdleTimeoutMs
|
|
882
|
+
},
|
|
883
|
+
name: "debug-command"
|
|
884
|
+
};
|
|
841
885
|
if (command === "media-player" || command === "sgnodes" || command === "snapshot") return { name: command };
|
|
842
886
|
if (command === "wait-active") return {
|
|
843
887
|
appId: readString(parsed, "appId"),
|
|
@@ -945,6 +989,12 @@ const readStringArray = (record, key) => {
|
|
|
945
989
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
|
|
946
990
|
return value.map((item) => String(item));
|
|
947
991
|
};
|
|
992
|
+
const readOptionalStringArray = (record, key) => {
|
|
993
|
+
const value = record[key];
|
|
994
|
+
if (value === void 0) return;
|
|
995
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
|
|
996
|
+
return value.map((item) => String(item));
|
|
997
|
+
};
|
|
948
998
|
const readStringMap = (record, key) => {
|
|
949
999
|
const value = record[key];
|
|
950
1000
|
if (value === void 0) return /* @__PURE__ */ new Map();
|
|
@@ -1074,6 +1124,13 @@ const describeCli = () => ({
|
|
|
1074
1124
|
commands: [
|
|
1075
1125
|
commandSchema("describe", "Print machine-readable command schemas.", false, false, []),
|
|
1076
1126
|
commandSchema("check", "Check ECP and developer installer reachability.", true, false, []),
|
|
1127
|
+
commandSchema("console", "Capture BrightScript console output from port 8085.", true, true, [argumentField("outputPath", "path", "Console log output path inside the current app root."), optionField("durationMs", "positive-integer", "Capture duration in milliseconds.")]),
|
|
1128
|
+
commandSchema("debug-command", "Run an allowlisted Roku debug command.", true, true, [
|
|
1129
|
+
argumentField("debugCommand", "string", "Allowlisted Roku debug command."),
|
|
1130
|
+
argumentField("args", "string[]", "Debug command arguments.", false, true),
|
|
1131
|
+
optionField("durationMs", "positive-integer", "Maximum read duration in milliseconds."),
|
|
1132
|
+
optionField("idleTimeoutMs", "positive-integer", "Stop reading after this many idle milliseconds.")
|
|
1133
|
+
]),
|
|
1077
1134
|
commandSchema("discover", "Discover Roku ECP devices with SSDP.", false, false, [optionField("timeoutMs", "positive-integer", "Discovery timeout in milliseconds.")]),
|
|
1078
1135
|
commandSchema("device-info", "Read enhanced Roku device metadata.", true, false, []),
|
|
1079
1136
|
commandSchema("active-app", "Read the foreground app.", true, false, []),
|
|
@@ -1101,7 +1158,7 @@ const describeCli = () => ({
|
|
|
1101
1158
|
optionField("node", "node-condition", "Optional SceneGraph node condition."),
|
|
1102
1159
|
optionField("timeoutMs", "positive-integer", "Wait timeout in milliseconds.")
|
|
1103
1160
|
]),
|
|
1104
|
-
commandSchema("screenshot", "Write a developer screenshot.", true, true, [argumentField("outputPath", "path", "Screenshot output path inside the current app root.")])
|
|
1161
|
+
commandSchema("screenshot", "Write a timestamped developer screenshot.", true, true, [argumentField("outputPath", "path", "Screenshot base output path inside the current app root.")])
|
|
1105
1162
|
],
|
|
1106
1163
|
globalOptions: [
|
|
1107
1164
|
globalField("json", "boolean", "Print structured JSON output."),
|
|
@@ -1170,6 +1227,8 @@ const printHelp = () => {
|
|
|
1170
1227
|
usage:
|
|
1171
1228
|
rokit describe
|
|
1172
1229
|
rokit check
|
|
1230
|
+
rokit console <output-path> [--duration-ms <ms>]
|
|
1231
|
+
rokit debug-command <command> [args...] [--duration-ms <ms>] [--idle-timeout-ms <ms>]
|
|
1173
1232
|
rokit discover [--timeout-ms <ms>]
|
|
1174
1233
|
rokit device-info
|
|
1175
1234
|
rokit active-app
|
|
@@ -1203,8 +1262,8 @@ environment:
|
|
|
1203
1262
|
ROKIT_USERNAME=rokudev
|
|
1204
1263
|
ROKIT_TIMEOUT_MS=10000
|
|
1205
1264
|
|
|
1206
|
-
|
|
1207
|
-
ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted
|
|
1265
|
+
aliases:
|
|
1266
|
+
ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted when ROKIT_* names are unset.`);
|
|
1208
1267
|
};
|
|
1209
1268
|
//#endregion
|
|
1210
1269
|
//#region src/rokit.ts
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Roku Debugging
|
|
2
|
+
|
|
3
|
+
`rokit` can wrap Roku debugging surfaces when they expose generic device or
|
|
4
|
+
runtime state. Keep product journeys, selectors, content IDs, account state, and
|
|
5
|
+
assertions in consumer app repos.
|
|
6
|
+
|
|
7
|
+
## Official Surfaces
|
|
8
|
+
|
|
9
|
+
Roku documents the developer debug console as a telnet connection to port
|
|
10
|
+
`8085`. It emits app runtime output, compilation errors, crash line numbers,
|
|
11
|
+
stack traces, and variable state when an app fails. Open it before sideloading or
|
|
12
|
+
reproducing a startup crash so the first failure is captured.
|
|
13
|
+
|
|
14
|
+
Roku also documents debug-server utilities on port `8080`. Useful generic
|
|
15
|
+
commands include:
|
|
16
|
+
|
|
17
|
+
| Command | Port | Use |
|
|
18
|
+
| ------------------- | ------ | ------------------------------------------- |
|
|
19
|
+
| `chanperf` | `8080` | Current app memory and CPU snapshot |
|
|
20
|
+
| `free` | `8080` | Device memory snapshot |
|
|
21
|
+
| `sgnodes roots` | `8080` | Top-level SceneGraph nodes |
|
|
22
|
+
| `sgnodes all` | `8080` | Full SceneGraph node listing |
|
|
23
|
+
| `sgnodes <node_ID>` | `8080` | SceneGraph nodes with a matching `id` field |
|
|
24
|
+
| `loaded_textures` | `8080` | Loaded texture diagnostics |
|
|
25
|
+
| `r2d2_bitmaps` | `8080` | Bitmap diagnostics |
|
|
26
|
+
|
|
27
|
+
The BrightScript debug protocol is a separate binary protocol for interactive
|
|
28
|
+
debugger clients. It can inspect variables, stack traces, breakpoints, and
|
|
29
|
+
stepping state after a launch request enables remote debugging. It is not the
|
|
30
|
+
first `rokit` target because crash capture needs append-only text artifacts more
|
|
31
|
+
than an IDE-style session.
|
|
32
|
+
|
|
33
|
+
Sources:
|
|
34
|
+
|
|
35
|
+
- [Roku debugging](https://developer.roku.com/docs/developer-program/debugging/debugging-channels.md)
|
|
36
|
+
- [BrightScript debug protocol](https://developer.roku.com/docs/developer-program/debugging/socket-based-debugger.md)
|
|
37
|
+
- [Roku developer tools](https://developer.roku.com/docs/developer-program/dev-tools/tools-overview.md)
|
|
38
|
+
|
|
39
|
+
## `rokit` Shape
|
|
40
|
+
|
|
41
|
+
The implemented first slice covers text telnet capture and allowlisted
|
|
42
|
+
debug-server commands:
|
|
43
|
+
|
|
44
|
+
- `rokit console <output-path> [--duration-ms <ms>]` captures port `8085`
|
|
45
|
+
output to a timestamped local log.
|
|
46
|
+
- `rokit debug-command <command> [args...]` sends one allowlisted command to
|
|
47
|
+
port `8080` or `8085` and prints the response.
|
|
48
|
+
|
|
49
|
+
`debug-command` is described as mutating for agent safety because some
|
|
50
|
+
allowlisted Roku commands can affect profiling/debugger state.
|
|
51
|
+
|
|
52
|
+
Roku's `chanperf -r <seconds>` command writes repeated samples to the
|
|
53
|
+
BrightScript console on `8085`, not the `8080` socket that receives the command.
|
|
54
|
+
`rokit debug-command` intentionally rejects that form until a coordinated
|
|
55
|
+
capture command can open the console, start sampling, stop sampling, and write
|
|
56
|
+
one proof bundle.
|
|
57
|
+
|
|
58
|
+
The natural next command is `rokit crash-watch <app-id> <output-dir>`, which
|
|
59
|
+
would connect to the console, launch an app, capture logs, and write normal
|
|
60
|
+
proof artifacts after the capture window.
|
|
61
|
+
|
|
62
|
+
Use Node's `node:net` module directly instead of shelling out to a local telnet
|
|
63
|
+
binary. This keeps the CLI cross-platform, typed, and easier to test.
|
|
64
|
+
|
|
65
|
+
## Limits
|
|
66
|
+
|
|
67
|
+
- Live verification requires a developer-enabled Roku with reachable debug
|
|
68
|
+
ports. Unit tests can cover parsing, command validation, output paths, and
|
|
69
|
+
socket behavior with fake TCP servers.
|
|
70
|
+
- The console should be connected before the repro. It cannot recover log lines
|
|
71
|
+
emitted before the socket was open.
|
|
72
|
+
- Another tool, such as an IDE or Roku plugin, may already own the console
|
|
73
|
+
connection.
|
|
74
|
+
- If the device hard-reboots, `rokit` can only preserve bytes received before
|
|
75
|
+
the socket disconnects.
|
package/docs/READINESS.md
CHANGED
|
@@ -8,12 +8,12 @@ Status: current as of 2026-05-17
|
|
|
8
8
|
|
|
9
9
|
Overall: B+
|
|
10
10
|
|
|
11
|
-
| Dimension | Status | Evidence
|
|
12
|
-
| ---------- | ------ |
|
|
13
|
-
| bootable | pass | `pnpm smoke` builds the CLI and checks `--version` plus `--help`
|
|
14
|
-
| testable | pass | `pnpm verify` runs TypeScript, bundle, unit tests, and npm pack dry run
|
|
15
|
-
| observable | pass | CLI commands print active app, device info, raw ECP state, SceneGraph XML, snapshots, proof bundles, screenshots, compact assertion failures, and JSON output by default in non-TTY runs | no CI hardware lane |
|
|
16
|
-
| verifiable | pass | CI runs `pnpm verify`; `pnpm live:smoke` proves a configured Roku responds; `pnpm live:probe` packages, installs, launches, drives, asserts, screenshots, and proofs a generic channel
|
|
11
|
+
| Dimension | Status | Evidence | Gap |
|
|
12
|
+
| ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
13
|
+
| bootable | pass | `pnpm smoke` builds the CLI and checks `--version` plus `--help` | no server boot surface, by design |
|
|
14
|
+
| testable | pass | `pnpm verify` runs TypeScript, bundle, unit tests, and npm pack dry run | live Roku checks require local hardware |
|
|
15
|
+
| observable | pass | CLI commands print active app, device info, raw ECP state, SceneGraph XML, BrightScript console logs, debug-server output, snapshots, proof bundles, screenshots, compact assertion failures, and JSON output by default in non-TTY runs | no CI hardware lane |
|
|
16
|
+
| verifiable | pass | CI runs `pnpm verify`; `pnpm live:smoke` proves a configured Roku responds; `pnpm live:probe` packages, installs, launches, drives, asserts, screenshots, and proofs a generic channel | no CI hardware lane |
|
|
17
17
|
|
|
18
18
|
## Layers
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ Overall: B+
|
|
|
22
22
|
- Interact: `rokit press`, `rokit launch`, `rokit query`, `rokit sgnodes`
|
|
23
23
|
- E2e: `pnpm live:probe` for a configured developer-enabled Roku
|
|
24
24
|
- Enforce: GitHub Actions `verify`, optional `.git-hooks/pre-push`
|
|
25
|
-
- Observe: ECP responses, SceneGraph XML, `rokit snapshot`, `rokit proof`, screenshots, concise command output
|
|
25
|
+
- Observe: ECP responses, SceneGraph XML, `rokit console`, `rokit debug-command`, `rokit snapshot`, `rokit proof`, screenshots, concise command output
|
|
26
26
|
- Isolate: `.rokit/` and `.env` stay local per worktree
|
|
27
27
|
|
|
28
28
|
## Agent-Facing CLI Contract
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rokit-harness
|
|
3
|
-
description: Use rokit as a generic Roku harness adapter for packaging, installing, launching, key input, ECP/SceneGraph/media-player observation, readiness waits, screenshots, and proof bundles.
|
|
3
|
+
description: Use rokit as a generic Roku harness adapter for packaging, installing, launching, key input, ECP/SceneGraph/media-player observation, readiness waits, debug console capture, screenshots, and proof bundles.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# rokit Harness
|
|
@@ -19,12 +19,18 @@ repo.
|
|
|
19
19
|
3. Prefer structured output. In automation, rely on the non-TTY JSON default or
|
|
20
20
|
pass `--json` explicitly.
|
|
21
21
|
4. Use `--fields` to keep observations small when only a few values are needed.
|
|
22
|
-
5.
|
|
22
|
+
5. Screenshot commands append a timestamp to the requested filename. Use the
|
|
23
|
+
returned JSON `data.path` as the artifact path instead of assuming the input
|
|
24
|
+
path was written.
|
|
25
|
+
6. For live proof, use `rokit snapshot` for a quick state read,
|
|
23
26
|
`rokit proof <output-dir>` for review artifacts, or `pnpm live:probe` in the
|
|
24
27
|
rokit repo for the full generic package/install/launch/input/proof probe.
|
|
25
|
-
|
|
28
|
+
7. For crash/debug proof, start `rokit console <output-path>` before
|
|
29
|
+
reproducing the problem so startup errors and crash traces are captured.
|
|
30
|
+
Use `rokit debug-command <command>` only for generic Roku debug commands.
|
|
31
|
+
8. Use `rokit wait-ready <app-id>` after launch when the app can race ECP or
|
|
26
32
|
SceneGraph readiness.
|
|
27
|
-
|
|
33
|
+
9. Use `rokit press --until-node ...` for bounded navigation loops instead of
|
|
28
34
|
arbitrary sleeps.
|
|
29
35
|
|
|
30
36
|
## Boundaries
|