@putdotio/rokit 2.0.2 → 2.2.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 +15 -5
- package/dist/{roku-BxnS6Axs.mjs → debug-BbJdwNkW.mjs} +333 -12
- package/dist/index.d.mts +39 -1
- package/dist/index.mjs +2 -2
- package/dist/rokit.mjs +133 -92
- package/docs/DEBUGGING.md +75 -0
- package/docs/READINESS.md +7 -7
- package/docs/skills/rokit-harness/SKILL.md +6 -3
- 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 |
|
|
@@ -104,6 +106,7 @@ App-specific scenario scripts can import the generic helpers:
|
|
|
104
106
|
import {
|
|
105
107
|
assertMediaPlayerContainer,
|
|
106
108
|
assertSceneGraphNode,
|
|
109
|
+
captureScreenshot,
|
|
107
110
|
pressKey,
|
|
108
111
|
querySceneGraph,
|
|
109
112
|
waitForSceneGraphAssertion,
|
|
@@ -122,9 +125,14 @@ const context: RokuContext = {
|
|
|
122
125
|
};
|
|
123
126
|
|
|
124
127
|
await pressKey(context, "Info");
|
|
125
|
-
await querySceneGraph(context, { attempts: 3, requireComplete: true });
|
|
128
|
+
await querySceneGraph(context, { attempts: 3, requireAppNode: true, requireComplete: true });
|
|
126
129
|
await assertSceneGraphNode(context, "videoPlayerScreen", { state: "visible" });
|
|
127
130
|
await assertMediaPlayerContainer(context, "mp4");
|
|
131
|
+
await captureScreenshot(
|
|
132
|
+
{ ...context, password: process.env.ROKIT_PASSWORD ?? "" },
|
|
133
|
+
"artifacts/player.jpg",
|
|
134
|
+
{ attempts: 3 },
|
|
135
|
+
);
|
|
128
136
|
await waitForSceneGraphAssertion(context, "player ready", (xml) => {
|
|
129
137
|
if (!xml.includes("videoPlayerScreen")) {
|
|
130
138
|
throw new Error("expected player screen");
|
|
@@ -138,6 +146,7 @@ await waitForSceneGraphAssertion(context, "player ready", (xml) => {
|
|
|
138
146
|
|
|
139
147
|
- package, install, launch, deeplink params, and remote keypresses
|
|
140
148
|
- raw ECP queries and parsed media-player state
|
|
149
|
+
- BrightScript console capture and allowlisted debug-server commands
|
|
141
150
|
- SceneGraph state queries and named-node assertions
|
|
142
151
|
- timestamped screenshots, snapshots, and proof artifacts
|
|
143
152
|
|
|
@@ -148,6 +157,7 @@ artifacts.
|
|
|
148
157
|
## Docs
|
|
149
158
|
|
|
150
159
|
- [Contributing](./CONTRIBUTING.md)
|
|
160
|
+
- [Roku debugging](./docs/DEBUGGING.md)
|
|
151
161
|
- [Distribution](./docs/DISTRIBUTION.md)
|
|
152
162
|
- [Agent readiness](./docs/READINESS.md)
|
|
153
163
|
- [Security](./SECURITY.md)
|
|
@@ -1,6 +1,39 @@
|
|
|
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
|
-
import {
|
|
7
|
+
import { access, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
//#region src/errors.ts
|
|
10
|
+
var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
|
|
11
|
+
var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
|
|
12
|
+
get message() {
|
|
13
|
+
return "ROKIT_TARGET is not set";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
|
|
17
|
+
get message() {
|
|
18
|
+
return "ROKIT_PASSWORD is not set";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var DebugPortUnavailable = class extends Schema.TaggedErrorClass()("DebugPortUnavailable", {
|
|
22
|
+
detail: Schema.String,
|
|
23
|
+
port: Schema.Number
|
|
24
|
+
}) {
|
|
25
|
+
get message() {
|
|
26
|
+
return `Roku debug port ${this.port} unavailable: ${this.detail}`;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
|
|
30
|
+
const renderError = (error) => error.message;
|
|
31
|
+
const normalizeError = (error) => {
|
|
32
|
+
if (isRokitError(error)) return error;
|
|
33
|
+
return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
|
|
34
|
+
};
|
|
35
|
+
const isRokitError = (error) => error instanceof DebugPortUnavailable || error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
|
|
36
|
+
//#endregion
|
|
4
37
|
//#region src/xml.ts
|
|
5
38
|
const readXmlTag = (xml, tag) => {
|
|
6
39
|
return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
|
|
@@ -215,7 +248,7 @@ const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
|
|
|
215
248
|
lastApp = await queryActiveApp(context);
|
|
216
249
|
lastError = void 0;
|
|
217
250
|
} catch (error) {
|
|
218
|
-
lastError = formatErrorMessage(error);
|
|
251
|
+
lastError = formatErrorMessage$1(error);
|
|
219
252
|
await sleep(500);
|
|
220
253
|
continue;
|
|
221
254
|
}
|
|
@@ -295,7 +328,7 @@ const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4)
|
|
|
295
328
|
lastError = void 0;
|
|
296
329
|
if (mediaPlayer.state === expectedState) return mediaPlayer;
|
|
297
330
|
} catch (error) {
|
|
298
|
-
lastError = formatErrorMessage(error);
|
|
331
|
+
lastError = formatErrorMessage$1(error);
|
|
299
332
|
}
|
|
300
333
|
await sleep(500);
|
|
301
334
|
}
|
|
@@ -304,23 +337,32 @@ const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4)
|
|
|
304
337
|
};
|
|
305
338
|
const querySceneGraph = async (context, options = {}) => {
|
|
306
339
|
const attempts = options.attempts ?? 1;
|
|
340
|
+
const requireAppNode = options.requireAppNode ?? false;
|
|
307
341
|
const requireComplete = options.requireComplete ?? false;
|
|
308
342
|
const retryDelayMs = options.retryDelayMs ?? 500;
|
|
309
343
|
let lastXml = "";
|
|
310
344
|
let lastError;
|
|
345
|
+
let lastMissingAppNode = false;
|
|
311
346
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
312
347
|
try {
|
|
313
348
|
const xml = await queryEcp(context, "/query/sgnodes/all");
|
|
314
|
-
|
|
349
|
+
const complete = !requireComplete || isCompleteSceneGraph(xml);
|
|
350
|
+
const hasAppNode = !requireAppNode || !xml.includes("<All_Nodes>") || xml.includes("<App ");
|
|
351
|
+
if (complete && hasAppNode) return xml;
|
|
315
352
|
lastXml = xml;
|
|
353
|
+
lastMissingAppNode = complete && !hasAppNode;
|
|
316
354
|
} catch (error) {
|
|
317
355
|
lastError = error;
|
|
318
356
|
lastXml = "";
|
|
357
|
+
lastMissingAppNode = false;
|
|
319
358
|
}
|
|
320
359
|
if (attempt < attempts - 1) await sleep(retryDelayMs);
|
|
321
360
|
}
|
|
322
|
-
if (lastXml !== "")
|
|
323
|
-
|
|
361
|
+
if (lastXml !== "") {
|
|
362
|
+
if (lastMissingAppNode) throw new Error("SceneGraph query returned complete XML without an App node");
|
|
363
|
+
throw new Error("SceneGraph query returned incomplete XML");
|
|
364
|
+
}
|
|
365
|
+
throw new Error(`SceneGraph query failed: ${formatErrorMessage$1(lastError)}`);
|
|
324
366
|
};
|
|
325
367
|
const assertSceneGraphNode = async (context, nodeName, expectation) => {
|
|
326
368
|
assertNamedNode(await querySceneGraph(context), nodeName, expectation);
|
|
@@ -351,7 +393,7 @@ const waitForSceneGraphAssertion = async (context, description, assertXml, optio
|
|
|
351
393
|
assertXml(xml);
|
|
352
394
|
return xml;
|
|
353
395
|
} catch (error) {
|
|
354
|
-
lastError = formatErrorMessage(error);
|
|
396
|
+
lastError = formatErrorMessage$1(error);
|
|
355
397
|
}
|
|
356
398
|
await sleep(pollIntervalMs);
|
|
357
399
|
}
|
|
@@ -380,6 +422,41 @@ const takeScreenshot = async (context, outputPath) => {
|
|
|
380
422
|
password: context.password
|
|
381
423
|
});
|
|
382
424
|
};
|
|
425
|
+
const captureScreenshot = async (context, outputPath, options = {}) => {
|
|
426
|
+
const attempts = options.attempts ?? 3;
|
|
427
|
+
const retryDelayMs = options.retryDelayMs ?? 1500;
|
|
428
|
+
const resolvedOutput = resolve(outputPath);
|
|
429
|
+
await mkdir(dirname(resolvedOutput), { recursive: true });
|
|
430
|
+
let lastError = "unknown";
|
|
431
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
432
|
+
const captureDir = await mkdtemp(join(tmpdir(), `${safeTempPrefix(options.tempDirPrefix ?? "rokit-screenshot")}-`));
|
|
433
|
+
const capturePath = join(captureDir, basename(resolvedOutput));
|
|
434
|
+
try {
|
|
435
|
+
const tempResult = await firstExistingPath([await takeScreenshot(context, capturePath), capturePath]);
|
|
436
|
+
if (tempResult !== void 0) {
|
|
437
|
+
await copyFile(tempResult, resolvedOutput);
|
|
438
|
+
return resolvedOutput;
|
|
439
|
+
}
|
|
440
|
+
const directResult = await firstExistingPath([resolvedOutput, await takeScreenshot(context, resolvedOutput)]);
|
|
441
|
+
if (directResult === resolvedOutput) return resolvedOutput;
|
|
442
|
+
if (directResult !== void 0) {
|
|
443
|
+
await copyFile(directResult, resolvedOutput);
|
|
444
|
+
return resolvedOutput;
|
|
445
|
+
}
|
|
446
|
+
throw new Error("screenshot capture succeeded without writing an image file");
|
|
447
|
+
} catch (error) {
|
|
448
|
+
lastError = formatErrorMessage$1(error);
|
|
449
|
+
if (attempt === attempts) break;
|
|
450
|
+
await sleep(retryDelayMs);
|
|
451
|
+
} finally {
|
|
452
|
+
await rm(captureDir, {
|
|
453
|
+
force: true,
|
|
454
|
+
recursive: true
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
throw new Error(`failed to capture ${basename(resolvedOutput)}: ${lastError}`);
|
|
459
|
+
};
|
|
383
460
|
const packageChannel = async (outputPath, rootDir = process.cwd()) => {
|
|
384
461
|
const options = packageOptions(outputPath, rootDir);
|
|
385
462
|
await rokuDeploy.createPackage(options);
|
|
@@ -399,7 +476,7 @@ const packageOptions = (outputPath, rootDir = process.cwd()) => {
|
|
|
399
476
|
};
|
|
400
477
|
};
|
|
401
478
|
const validateEcpPath = (path) => {
|
|
402
|
-
rejectUnsafeInput(path, "ECP path");
|
|
479
|
+
rejectUnsafeInput$1(path, "ECP path");
|
|
403
480
|
if (path.includes("\\")) throw new Error("ECP path must not include backslashes");
|
|
404
481
|
if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) throw new Error("ECP path must be device-relative");
|
|
405
482
|
if (path.includes("?") || path.includes("#")) throw new Error("ECP path must not include query strings or fragments");
|
|
@@ -407,12 +484,24 @@ const validateEcpPath = (path) => {
|
|
|
407
484
|
if (/%(?:2e|2f|5c)/i.test(path)) throw new Error("ECP path must not include percent-encoded path segments");
|
|
408
485
|
return path;
|
|
409
486
|
};
|
|
410
|
-
const rejectUnsafeInput = (value, label) => {
|
|
487
|
+
const rejectUnsafeInput$1 = (value, label) => {
|
|
411
488
|
if ([...value].some((character) => {
|
|
412
489
|
const code = character.charCodeAt(0);
|
|
413
490
|
return code < 32 || code === 127;
|
|
414
491
|
})) throw new Error(`${label} contains control characters`);
|
|
415
492
|
};
|
|
493
|
+
const safeTempPrefix = (value) => value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
494
|
+
const firstExistingPath = async (paths) => {
|
|
495
|
+
for (const path of paths) if (await fileExists(path)) return path;
|
|
496
|
+
};
|
|
497
|
+
const fileExists = async (path) => {
|
|
498
|
+
try {
|
|
499
|
+
await access(path);
|
|
500
|
+
return true;
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
416
505
|
const validateRemoteKey = (key) => {
|
|
417
506
|
if (key.startsWith("Lit_")) return;
|
|
418
507
|
if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
|
|
@@ -440,7 +529,7 @@ const postLaunchMaybeAccepted = async (context, url) => {
|
|
|
440
529
|
});
|
|
441
530
|
if (!response.ok && response.status !== 503) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
|
|
442
531
|
} catch (error) {
|
|
443
|
-
const message = formatErrorMessage(error).toLowerCase();
|
|
532
|
+
const message = formatErrorMessage$1(error).toLowerCase();
|
|
444
533
|
if (!message.includes("abort") && !message.includes("timeout") && !message.includes("fetch failed")) throw error;
|
|
445
534
|
}
|
|
446
535
|
};
|
|
@@ -466,7 +555,7 @@ const readXmlNumberAttribute = (attributes, name) => {
|
|
|
466
555
|
const sleep = (ms) => new Promise((resolve) => {
|
|
467
556
|
setTimeout(resolve, ms);
|
|
468
557
|
});
|
|
469
|
-
const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
558
|
+
const formatErrorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
470
559
|
const readSsdpHeaders = (text) => {
|
|
471
560
|
const headers = /* @__PURE__ */ new Map();
|
|
472
561
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -484,4 +573,236 @@ const readLocationTarget = (location) => {
|
|
|
484
573
|
}
|
|
485
574
|
};
|
|
486
575
|
//#endregion
|
|
487
|
-
|
|
576
|
+
//#region src/runtime.ts
|
|
577
|
+
const appDir = process.cwd();
|
|
578
|
+
const envPath = join(join(appDir, ".rokit"), ".env");
|
|
579
|
+
const loadLocalEnv = () => {
|
|
580
|
+
if (existsSync(envPath)) process.loadEnvFile(envPath);
|
|
581
|
+
};
|
|
582
|
+
const loadEnv = () => ({
|
|
583
|
+
password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
|
|
584
|
+
target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
|
|
585
|
+
timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
|
|
586
|
+
username: process.env.ROKIT_USERNAME ?? "rokudev"
|
|
587
|
+
});
|
|
588
|
+
const requireTarget = (env) => {
|
|
589
|
+
const target = env.target?.trim();
|
|
590
|
+
if (!target) throw MissingTarget.make({});
|
|
591
|
+
return normalizeTarget(target);
|
|
592
|
+
};
|
|
593
|
+
const requirePassword = (env) => {
|
|
594
|
+
const password = env.password;
|
|
595
|
+
if (!password) throw MissingPassword.make({});
|
|
596
|
+
return password;
|
|
597
|
+
};
|
|
598
|
+
const resolveOutputPath = (path, label) => {
|
|
599
|
+
rejectUnsafeInput(path, label);
|
|
600
|
+
const resolved = resolve(appDir, path);
|
|
601
|
+
const relativePath = relative(appDir, resolved);
|
|
602
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
|
|
603
|
+
return resolved;
|
|
604
|
+
};
|
|
605
|
+
const resolveFileOutputPath = (path, label) => {
|
|
606
|
+
const resolved = resolveOutputPath(path, label);
|
|
607
|
+
if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
|
|
608
|
+
return resolved;
|
|
609
|
+
};
|
|
610
|
+
const rejectUnsafeInput = (value, label) => {
|
|
611
|
+
if ([...value].some((character) => {
|
|
612
|
+
const code = character.charCodeAt(0);
|
|
613
|
+
return code < 32 || code === 127;
|
|
614
|
+
})) fail(`${label} contains control characters`);
|
|
615
|
+
};
|
|
616
|
+
const rejectUnsafeEcpPath = (value) => {
|
|
617
|
+
try {
|
|
618
|
+
validateEcpPath(value);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
fail(formatErrorMessage(error));
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const fail = (message) => {
|
|
624
|
+
throw InvalidInput.make({ message });
|
|
625
|
+
};
|
|
626
|
+
const formatErrorMessage = (error) => {
|
|
627
|
+
if (error instanceof Error) return error.message;
|
|
628
|
+
return String(error);
|
|
629
|
+
};
|
|
630
|
+
const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
|
|
631
|
+
const parseTimeout = (value) => {
|
|
632
|
+
if (value === void 0) return 1e4;
|
|
633
|
+
const timeout = Number(value);
|
|
634
|
+
if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
|
|
635
|
+
return timeout;
|
|
636
|
+
};
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/debug.ts
|
|
639
|
+
const debugServerPort = 8080;
|
|
640
|
+
const brightScriptConsolePort = 8085;
|
|
641
|
+
const buildDebugCommand = (command, args) => {
|
|
642
|
+
validateDebugToken(command, "debug command");
|
|
643
|
+
for (const arg of args) rejectUnsafeInput(arg, "debug command argument");
|
|
644
|
+
if (command === "chanperf") {
|
|
645
|
+
validateChanperfArgs(args);
|
|
646
|
+
return debugServerCommand(command, args);
|
|
647
|
+
}
|
|
648
|
+
if (command === "brightscript_warnings") {
|
|
649
|
+
validateOptionalNonNegativeInteger(args, "brightscript_warnings");
|
|
650
|
+
return debugServerCommand(command, args);
|
|
651
|
+
}
|
|
652
|
+
if (command === "free" || command === "loaded_textures" || command === "r2d2_bitmaps") {
|
|
653
|
+
validateNoArgs(command, args);
|
|
654
|
+
return debugServerCommand(command, args);
|
|
655
|
+
}
|
|
656
|
+
if (command === "sgnodes") return debugServerCommand(command, normalizeSceneGraphNodeArgs(args));
|
|
657
|
+
if (command === "bsc" || command === "bscs" || command === "bt" || command === "classes" || command === "help" || command === "last" || command === "list") {
|
|
658
|
+
validateNoArgs(command, args);
|
|
659
|
+
return brightScriptConsoleCommand(command, args);
|
|
660
|
+
}
|
|
661
|
+
if (command === "threads" || command === "ths") {
|
|
662
|
+
validateOptionalNonNegativeInteger(args, command);
|
|
663
|
+
return brightScriptConsoleCommand(command, args);
|
|
664
|
+
}
|
|
665
|
+
return fail(`Unsupported Roku debug command: ${command}`);
|
|
666
|
+
};
|
|
667
|
+
const runDebugCommand = async (context, command, durationMs, idleTimeoutMs) => {
|
|
668
|
+
const safeCommand = buildDebugCommand(command.command, command.args);
|
|
669
|
+
const startedAt = Date.now();
|
|
670
|
+
const port = resolveDebugPort(context, safeCommand.port);
|
|
671
|
+
const body = await readDebugSocket(context, port, {
|
|
672
|
+
durationMs,
|
|
673
|
+
idleTimeoutMs,
|
|
674
|
+
request: safeCommand.request
|
|
675
|
+
});
|
|
676
|
+
const elapsedMs = Date.now() - startedAt;
|
|
677
|
+
return {
|
|
678
|
+
args: safeCommand.args,
|
|
679
|
+
body,
|
|
680
|
+
bytes: Buffer.byteLength(body),
|
|
681
|
+
command: safeCommand.command,
|
|
682
|
+
elapsedMs,
|
|
683
|
+
port
|
|
684
|
+
};
|
|
685
|
+
};
|
|
686
|
+
const captureDebugConsole = async (context, durationMs) => {
|
|
687
|
+
const startedAt = Date.now();
|
|
688
|
+
const port = resolveDebugPort(context, brightScriptConsolePort);
|
|
689
|
+
const body = await readDebugSocket(context, port, { durationMs });
|
|
690
|
+
const elapsedMs = Date.now() - startedAt;
|
|
691
|
+
return {
|
|
692
|
+
body,
|
|
693
|
+
bytes: Buffer.byteLength(body),
|
|
694
|
+
durationMs,
|
|
695
|
+
elapsedMs,
|
|
696
|
+
port
|
|
697
|
+
};
|
|
698
|
+
};
|
|
699
|
+
const debugServerCommand = (command, args) => ({
|
|
700
|
+
args,
|
|
701
|
+
command,
|
|
702
|
+
port: debugServerPort,
|
|
703
|
+
request: formatDebugRequest(command, args)
|
|
704
|
+
});
|
|
705
|
+
const brightScriptConsoleCommand = (command, args) => ({
|
|
706
|
+
args,
|
|
707
|
+
command,
|
|
708
|
+
port: brightScriptConsolePort,
|
|
709
|
+
request: formatDebugRequest(command, args)
|
|
710
|
+
});
|
|
711
|
+
const readDebugSocket = async (context, port, options) => await new Promise((resolve, reject) => {
|
|
712
|
+
let body = "";
|
|
713
|
+
let durationTimer;
|
|
714
|
+
let idleTimer;
|
|
715
|
+
let settled = false;
|
|
716
|
+
const socket = createConnection({
|
|
717
|
+
host: context.target,
|
|
718
|
+
port
|
|
719
|
+
});
|
|
720
|
+
const clearTimers = () => {
|
|
721
|
+
if (durationTimer !== void 0) clearTimeout(durationTimer);
|
|
722
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
723
|
+
};
|
|
724
|
+
const finish = () => {
|
|
725
|
+
if (settled) return;
|
|
726
|
+
settled = true;
|
|
727
|
+
clearTimers();
|
|
728
|
+
socket.destroy();
|
|
729
|
+
resolve(body);
|
|
730
|
+
};
|
|
731
|
+
const failRead = (detail) => {
|
|
732
|
+
if (settled) return;
|
|
733
|
+
settled = true;
|
|
734
|
+
clearTimers();
|
|
735
|
+
socket.destroy();
|
|
736
|
+
reject(DebugPortUnavailable.make({
|
|
737
|
+
detail,
|
|
738
|
+
port
|
|
739
|
+
}));
|
|
740
|
+
};
|
|
741
|
+
const scheduleIdleTimer = () => {
|
|
742
|
+
if (options.idleTimeoutMs === void 0) return;
|
|
743
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
744
|
+
idleTimer = setTimeout(finish, options.idleTimeoutMs);
|
|
745
|
+
};
|
|
746
|
+
socket.setEncoding("utf8");
|
|
747
|
+
socket.setTimeout(context.timeoutMs);
|
|
748
|
+
socket.once("connect", () => {
|
|
749
|
+
socket.setTimeout(0);
|
|
750
|
+
durationTimer = setTimeout(finish, options.durationMs);
|
|
751
|
+
if (options.request !== void 0) socket.write(options.request);
|
|
752
|
+
});
|
|
753
|
+
socket.on("data", (chunk) => {
|
|
754
|
+
body = `${body}${chunk}`;
|
|
755
|
+
scheduleIdleTimer();
|
|
756
|
+
});
|
|
757
|
+
socket.once("timeout", () => {
|
|
758
|
+
failRead("connection timed out");
|
|
759
|
+
});
|
|
760
|
+
socket.once("error", (error) => {
|
|
761
|
+
if (body.length > 0) {
|
|
762
|
+
finish();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
failRead(error.message);
|
|
766
|
+
});
|
|
767
|
+
socket.once("close", finish);
|
|
768
|
+
});
|
|
769
|
+
const formatDebugRequest = (command, args) => `${[command, ...args].join(" ")}\r\n`;
|
|
770
|
+
const resolveDebugPort = (context, port) => port === debugServerPort ? context.debugServerPort ?? debugServerPort : context.debugConsolePort ?? brightScriptConsolePort;
|
|
771
|
+
const validateDebugToken = (value, label) => {
|
|
772
|
+
rejectUnsafeInput(value, label);
|
|
773
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) fail(`${label} contains unsupported characters`);
|
|
774
|
+
};
|
|
775
|
+
const validateChanperfArgs = (args) => {
|
|
776
|
+
if (args.length === 0) return;
|
|
777
|
+
if (args[0] === "-r") fail("chanperf -r writes to the BrightScript console; use rokit console for capture");
|
|
778
|
+
fail("usage: rokit debug-command chanperf");
|
|
779
|
+
};
|
|
780
|
+
const normalizeSceneGraphNodeArgs = (args) => {
|
|
781
|
+
if (args.length === 1 && (args[0] === "roots" || args[0] === "all")) return args;
|
|
782
|
+
if (args.length === 1) {
|
|
783
|
+
validateDebugToken(args[0] ?? "", "sgnodes id");
|
|
784
|
+
return args;
|
|
785
|
+
}
|
|
786
|
+
if (args.length === 2 && args[0] === "id") {
|
|
787
|
+
validateDebugToken(args[1] ?? "", "sgnodes id");
|
|
788
|
+
return [args[1] ?? ""];
|
|
789
|
+
}
|
|
790
|
+
return fail("usage: rokit debug-command sgnodes <roots|all|node-id|id node-id>");
|
|
791
|
+
};
|
|
792
|
+
const validateNoArgs = (command, args) => {
|
|
793
|
+
if (args.length > 0) fail(`usage: rokit debug-command ${command}`);
|
|
794
|
+
};
|
|
795
|
+
const validateOptionalNonNegativeInteger = (args, command) => {
|
|
796
|
+
if (args.length === 0) return;
|
|
797
|
+
if (args.length === 1) {
|
|
798
|
+
validateNonNegativeInteger(args[0] ?? "", `${command} argument`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
fail(`usage: rokit debug-command ${command} [id]`);
|
|
802
|
+
};
|
|
803
|
+
const validateNonNegativeInteger = (value, label) => {
|
|
804
|
+
const parsed = Number(value);
|
|
805
|
+
if (!Number.isInteger(parsed) || parsed < 0) fail(`Invalid ${label}: ${value}`);
|
|
806
|
+
};
|
|
807
|
+
//#endregion
|
|
808
|
+
export { readNamedNodeAttributes as $, readMediaPlayerContainer as A, waitForSceneGraphAssertion as B, queryActiveApp as C, queryMediaPlayerXml as D, queryMediaPlayerSafe as E, takeScreenshot as F, assertNamedNodeText as G, assertNamedNode as H, validateEcpPath as I, escapeXmlAttribute as J, assertNamedNodeTranslation as K, validateRemoteKey as L, readMediaPlayerPositionMs as M, readMediaPlayerState as N, queryMediaPlayerXmlSafe as O, resolvePackageOutputPath as P, readNamedNodeAttribute as Q, waitForActiveApp as R, pressKey as S, queryMediaPlayer as T, assertNamedNodeSize as U, waitForSceneGraphNode as V, assertNamedNodeState as W, isNamedNodeVisible as X, isCompleteSceneGraph as Y, parseSceneGraphNumberList as Z, getDeviceInfo as _, loadEnv as a, sceneGraphContainsText as at, launchApp as b, requirePassword as c, readXmlTag as ct, resolveOutputPath as d, readNamedNodeBounds as et, assertMediaPlayerContainer as f, discoverRokuDevices as g, checkDevice as h, fail as i, readSceneGraphStatus as it, readMediaPlayerInfo as j, querySceneGraph as k, requireTarget as l, normalizeError as lt, captureScreenshot as m, captureDebugConsole as n, readNamedNodeTranslation as nt, loadLocalEnv as o, readActiveApp as ot, assertSceneGraphNode as p, assertSceneGraphNumberNear as q, runDebugCommand as r, readSceneGraphFailure as rt, rejectUnsafeEcpPath as s, readXmlAttribute as st, buildDebugCommand as t, readNamedNodeNumber as tt, resolveFileOutputPath as u, renderError as ut, installPackage as v, queryEcp as w, packageChannel as x, isActiveMediaPlayerState as y, waitForMediaPlayerState 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;
|
|
@@ -86,6 +88,7 @@ type MediaPlayerInfo = {
|
|
|
86
88
|
};
|
|
87
89
|
type RetryOptions = {
|
|
88
90
|
readonly attempts?: number;
|
|
91
|
+
readonly requireAppNode?: boolean;
|
|
89
92
|
readonly requireComplete?: boolean;
|
|
90
93
|
readonly retryDelayMs?: number;
|
|
91
94
|
};
|
|
@@ -97,6 +100,11 @@ type WaitForSceneGraphAssertionOptions = {
|
|
|
97
100
|
type PackageResult = {
|
|
98
101
|
readonly path: string;
|
|
99
102
|
};
|
|
103
|
+
type ScreenshotCaptureOptions = {
|
|
104
|
+
readonly attempts?: number;
|
|
105
|
+
readonly retryDelayMs?: number;
|
|
106
|
+
readonly tempDirPrefix?: string;
|
|
107
|
+
};
|
|
100
108
|
declare const checkDevice: (context: RokuContext) => Promise<DeviceSummary>;
|
|
101
109
|
declare const getDeviceInfo: (context: RokuContext) => Promise<rokuDeploy.DeviceInfo>;
|
|
102
110
|
declare const queryActiveApp: (context: RokuContext) => Promise<ActiveApp>;
|
|
@@ -126,8 +134,38 @@ declare const installPackage: (context: RokuContext & {
|
|
|
126
134
|
declare const takeScreenshot: (context: RokuContext & {
|
|
127
135
|
readonly password: string;
|
|
128
136
|
}, outputPath: string) => Promise<string>;
|
|
137
|
+
declare const captureScreenshot: (context: RokuContext & {
|
|
138
|
+
readonly password: string;
|
|
139
|
+
}, outputPath: string, options?: ScreenshotCaptureOptions) => Promise<string>;
|
|
129
140
|
declare const packageChannel: (outputPath: string, rootDir?: string) => Promise<PackageResult>;
|
|
130
141
|
declare const validateEcpPath: (path: string) => string;
|
|
131
142
|
declare const validateRemoteKey: (key: string) => void;
|
|
132
143
|
//#endregion
|
|
133
|
-
|
|
144
|
+
//#region src/debug.d.ts
|
|
145
|
+
type RokuDebugPort = 8080 | 8085;
|
|
146
|
+
type RokuDebugCommand = {
|
|
147
|
+
readonly args: readonly string[];
|
|
148
|
+
readonly command: string;
|
|
149
|
+
readonly port: RokuDebugPort;
|
|
150
|
+
readonly request: string;
|
|
151
|
+
};
|
|
152
|
+
type DebugCommandResult = {
|
|
153
|
+
readonly args: readonly string[];
|
|
154
|
+
readonly body: string;
|
|
155
|
+
readonly bytes: number;
|
|
156
|
+
readonly command: string;
|
|
157
|
+
readonly elapsedMs: number;
|
|
158
|
+
readonly port: number;
|
|
159
|
+
};
|
|
160
|
+
type DebugConsoleCapture = {
|
|
161
|
+
readonly body: string;
|
|
162
|
+
readonly bytes: number;
|
|
163
|
+
readonly durationMs: number;
|
|
164
|
+
readonly elapsedMs: number;
|
|
165
|
+
readonly port: number;
|
|
166
|
+
};
|
|
167
|
+
declare const buildDebugCommand: (command: string, args: readonly string[]) => RokuDebugCommand;
|
|
168
|
+
declare const runDebugCommand: (context: RokuContext, command: RokuDebugCommand, durationMs: number, idleTimeoutMs: number) => Promise<DebugCommandResult>;
|
|
169
|
+
declare const captureDebugConsole: (context: RokuContext, durationMs: number) => Promise<DebugConsoleCapture>;
|
|
170
|
+
//#endregion
|
|
171
|
+
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 ScreenshotCaptureOptions, type WaitForSceneGraphAssertionOptions, assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, captureScreenshot, 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 readNamedNodeAttributes, A as readMediaPlayerContainer, B as waitForSceneGraphAssertion, C as queryActiveApp, D as queryMediaPlayerXml, E as queryMediaPlayerSafe, F as takeScreenshot, G as assertNamedNodeText, H as assertNamedNode, I as validateEcpPath, J as escapeXmlAttribute, K as assertNamedNodeTranslation, L as validateRemoteKey, M as readMediaPlayerPositionMs, N as readMediaPlayerState, O as queryMediaPlayerXmlSafe, Q as readNamedNodeAttribute, R as waitForActiveApp, S as pressKey, T as queryMediaPlayer, U as assertNamedNodeSize, V as waitForSceneGraphNode, W as assertNamedNodeState, X as isNamedNodeVisible, Y as isCompleteSceneGraph, Z as parseSceneGraphNumberList, _ as getDeviceInfo, at as sceneGraphContainsText, b as launchApp, ct as readXmlTag, et as readNamedNodeBounds, f as assertMediaPlayerContainer, g as discoverRokuDevices, h as checkDevice, it as readSceneGraphStatus, j as readMediaPlayerInfo, k as querySceneGraph, m as captureScreenshot, n as captureDebugConsole, nt as readNamedNodeTranslation, ot as readActiveApp, p as assertSceneGraphNode, q as assertSceneGraphNumberNear, r as runDebugCommand, rt as readSceneGraphFailure, st as readXmlAttribute, t as buildDebugCommand, tt as readNamedNodeNumber, v as installPackage, w as queryEcp, x as packageChannel, y as isActiveMediaPlayerState, z as waitForMediaPlayerState } from "./debug-BbJdwNkW.mjs";
|
|
2
|
+
export { assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, captureScreenshot, 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 { C as queryActiveApp, L as validateRemoteKey, P as resolvePackageOutputPath, R as waitForActiveApp, S as pressKey, T as queryMediaPlayer, V as waitForSceneGraphNode, _ as getDeviceInfo, a as loadEnv, b as launchApp, c as requirePassword, d as resolveOutputPath, g as discoverRokuDevices, h as checkDevice, i as fail, it as readSceneGraphStatus, k as querySceneGraph, l as requireTarget, lt as normalizeError, m as captureScreenshot, n as captureDebugConsole, o as loadLocalEnv, p as assertSceneGraphNode, r as runDebugCommand, rt as readSceneGraphFailure, s as rejectUnsafeEcpPath, t as buildDebugCommand, u as resolveFileOutputPath, ut as renderError, v as installPackage, w as queryEcp, x as packageChannel, z as waitForMediaPlayerState } from "./debug-BbJdwNkW.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),
|
|
@@ -310,8 +255,7 @@ const runCommand = async (context, command, dryRun) => {
|
|
|
310
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
|
-
|
|
314
|
-
const screenshotPath = await takeScreenshot({
|
|
258
|
+
const screenshotPath = await captureScreenshot({
|
|
315
259
|
...deviceContext,
|
|
316
260
|
password
|
|
317
261
|
}, path);
|
|
@@ -460,7 +404,7 @@ const writeProof = async (context, outputDir, includeScreenshot) => {
|
|
|
460
404
|
writeJson("media-player", await observe(async () => await queryMediaPlayer(context)));
|
|
461
405
|
if (includeScreenshot) {
|
|
462
406
|
const password = requirePassword(context);
|
|
463
|
-
const path = await
|
|
407
|
+
const path = await captureScreenshot({
|
|
464
408
|
...context,
|
|
465
409
|
password
|
|
466
410
|
}, timestampOutputPath(`${outputDir}/screenshot.png`));
|
|
@@ -518,7 +462,7 @@ const commandNeedsTarget = (command, dryRun) => {
|
|
|
518
462
|
if (command.name === "describe" || command.name === "discover" || command.name === "package") return false;
|
|
519
463
|
return !(dryRun && commandSupportsDryRun(command));
|
|
520
464
|
};
|
|
521
|
-
const commandSupportsDryRun = (command) => command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
|
|
465
|
+
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";
|
|
522
466
|
const dryRunData = (command) => {
|
|
523
467
|
if (command.name === "launch") return {
|
|
524
468
|
appId: command.args.appId,
|
|
@@ -533,6 +477,19 @@ const dryRunData = (command) => {
|
|
|
533
477
|
until: command.args.until ? formatNodeData(command.args.until) : void 0
|
|
534
478
|
};
|
|
535
479
|
}
|
|
480
|
+
if (command.name === "console") return {
|
|
481
|
+
durationMs: command.args.durationMs,
|
|
482
|
+
path: timestampOutputPath(resolveFileOutputPath(command.args.outputPath, "console output path")),
|
|
483
|
+
port: 8085
|
|
484
|
+
};
|
|
485
|
+
if (command.name === "debug-command") return {
|
|
486
|
+
args: command.args.command.args,
|
|
487
|
+
command: command.args.command.command,
|
|
488
|
+
durationMs: command.args.durationMs,
|
|
489
|
+
idleTimeoutMs: command.args.idleTimeoutMs,
|
|
490
|
+
port: command.args.command.port,
|
|
491
|
+
request: command.args.command.request.trim()
|
|
492
|
+
};
|
|
536
493
|
if (command.name === "screenshot") return { path: timestampOutputPath(resolveFileOutputPath(command.outputPath, "screenshot output path")) };
|
|
537
494
|
if (command.name === "proof") return {
|
|
538
495
|
outputDir: resolveOutputPath(command.outputDir, "proof output directory"),
|
|
@@ -628,6 +585,14 @@ const parseCommand = (options) => {
|
|
|
628
585
|
const [name, ...args] = options.args;
|
|
629
586
|
if (name === "describe") return { name };
|
|
630
587
|
if (name === "check") return { name };
|
|
588
|
+
if (name === "console") return {
|
|
589
|
+
name,
|
|
590
|
+
args: parseConsoleArgs(args)
|
|
591
|
+
};
|
|
592
|
+
if (name === "debug-command") return {
|
|
593
|
+
name,
|
|
594
|
+
args: parseDebugCommandArgs(args)
|
|
595
|
+
};
|
|
631
596
|
if (name === "discover") return {
|
|
632
597
|
name,
|
|
633
598
|
timeoutMs: parseOptionalTimeout(args, "rokit discover")
|
|
@@ -784,6 +749,52 @@ const parseOutputPath = (args, usage) => {
|
|
|
784
749
|
if (args.length === 2 && args[0] === "--out" && args[1] !== void 0) return args[1];
|
|
785
750
|
return fail(`usage: ${usage}`);
|
|
786
751
|
};
|
|
752
|
+
const parseConsoleArgs = (args) => {
|
|
753
|
+
let outputPath;
|
|
754
|
+
let durationMs = defaultConsoleDurationMs;
|
|
755
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
756
|
+
const arg = args[index];
|
|
757
|
+
if (arg === "--duration-ms") {
|
|
758
|
+
durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
|
|
759
|
+
index += 1;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (arg?.startsWith("--")) fail(`Unknown console option: ${arg}`);
|
|
763
|
+
if (outputPath !== void 0 || arg === void 0) fail("usage: rokit console <output-path> [--duration-ms <ms>]");
|
|
764
|
+
outputPath = arg;
|
|
765
|
+
}
|
|
766
|
+
if (outputPath !== void 0) return {
|
|
767
|
+
durationMs,
|
|
768
|
+
outputPath
|
|
769
|
+
};
|
|
770
|
+
return fail("usage: rokit console <output-path> [--duration-ms <ms>]");
|
|
771
|
+
};
|
|
772
|
+
const parseDebugCommandArgs = (args) => {
|
|
773
|
+
let durationMs = defaultDebugCommandDurationMs;
|
|
774
|
+
let idleTimeoutMs = defaultDebugCommandIdleTimeoutMs;
|
|
775
|
+
const commandArgs = [];
|
|
776
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
777
|
+
const arg = args[index];
|
|
778
|
+
if (arg === "--duration-ms") {
|
|
779
|
+
durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
|
|
780
|
+
index += 1;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (arg === "--idle-timeout-ms") {
|
|
784
|
+
idleTimeoutMs = parsePositiveInteger(args[index + 1] ?? "", "idle timeout");
|
|
785
|
+
index += 1;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (arg !== void 0) commandArgs.push(arg);
|
|
789
|
+
}
|
|
790
|
+
const [command, ...debugArgs] = commandArgs;
|
|
791
|
+
if (command === void 0) fail("usage: rokit debug-command <command> [args...] [--duration-ms <ms>]");
|
|
792
|
+
return {
|
|
793
|
+
command: buildDebugCommand(command, debugArgs),
|
|
794
|
+
durationMs,
|
|
795
|
+
idleTimeoutMs
|
|
796
|
+
};
|
|
797
|
+
};
|
|
787
798
|
const parseProofArgs = (args) => {
|
|
788
799
|
let outputDir;
|
|
789
800
|
let screenshot = false;
|
|
@@ -855,6 +866,21 @@ const parseInputJson = (value) => {
|
|
|
855
866
|
timeoutMs: readOptionalNumber(parsed, "timeoutMs")
|
|
856
867
|
};
|
|
857
868
|
if (command === "check" || command === "device-info" || command === "active-app") return { name: command };
|
|
869
|
+
if (command === "console") return {
|
|
870
|
+
args: {
|
|
871
|
+
durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultConsoleDurationMs,
|
|
872
|
+
outputPath: readString(parsed, "outputPath")
|
|
873
|
+
},
|
|
874
|
+
name: "console"
|
|
875
|
+
};
|
|
876
|
+
if (command === "debug-command") return {
|
|
877
|
+
args: {
|
|
878
|
+
command: buildDebugCommand(readString(parsed, "debugCommand"), readOptionalStringArray(parsed, "args") ?? []),
|
|
879
|
+
durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultDebugCommandDurationMs,
|
|
880
|
+
idleTimeoutMs: readOptionalNumber(parsed, "idleTimeoutMs") ?? defaultDebugCommandIdleTimeoutMs
|
|
881
|
+
},
|
|
882
|
+
name: "debug-command"
|
|
883
|
+
};
|
|
858
884
|
if (command === "media-player" || command === "sgnodes" || command === "snapshot") return { name: command };
|
|
859
885
|
if (command === "wait-active") return {
|
|
860
886
|
appId: readString(parsed, "appId"),
|
|
@@ -962,6 +988,12 @@ const readStringArray = (record, key) => {
|
|
|
962
988
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
|
|
963
989
|
return value.map((item) => String(item));
|
|
964
990
|
};
|
|
991
|
+
const readOptionalStringArray = (record, key) => {
|
|
992
|
+
const value = record[key];
|
|
993
|
+
if (value === void 0) return;
|
|
994
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
|
|
995
|
+
return value.map((item) => String(item));
|
|
996
|
+
};
|
|
965
997
|
const readStringMap = (record, key) => {
|
|
966
998
|
const value = record[key];
|
|
967
999
|
if (value === void 0) return /* @__PURE__ */ new Map();
|
|
@@ -1091,6 +1123,13 @@ const describeCli = () => ({
|
|
|
1091
1123
|
commands: [
|
|
1092
1124
|
commandSchema("describe", "Print machine-readable command schemas.", false, false, []),
|
|
1093
1125
|
commandSchema("check", "Check ECP and developer installer reachability.", true, false, []),
|
|
1126
|
+
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.")]),
|
|
1127
|
+
commandSchema("debug-command", "Run an allowlisted Roku debug command.", true, true, [
|
|
1128
|
+
argumentField("debugCommand", "string", "Allowlisted Roku debug command."),
|
|
1129
|
+
argumentField("args", "string[]", "Debug command arguments.", false, true),
|
|
1130
|
+
optionField("durationMs", "positive-integer", "Maximum read duration in milliseconds."),
|
|
1131
|
+
optionField("idleTimeoutMs", "positive-integer", "Stop reading after this many idle milliseconds.")
|
|
1132
|
+
]),
|
|
1094
1133
|
commandSchema("discover", "Discover Roku ECP devices with SSDP.", false, false, [optionField("timeoutMs", "positive-integer", "Discovery timeout in milliseconds.")]),
|
|
1095
1134
|
commandSchema("device-info", "Read enhanced Roku device metadata.", true, false, []),
|
|
1096
1135
|
commandSchema("active-app", "Read the foreground app.", true, false, []),
|
|
@@ -1187,6 +1226,8 @@ const printHelp = () => {
|
|
|
1187
1226
|
usage:
|
|
1188
1227
|
rokit describe
|
|
1189
1228
|
rokit check
|
|
1229
|
+
rokit console <output-path> [--duration-ms <ms>]
|
|
1230
|
+
rokit debug-command <command> [args...] [--duration-ms <ms>] [--idle-timeout-ms <ms>]
|
|
1190
1231
|
rokit discover [--timeout-ms <ms>]
|
|
1191
1232
|
rokit device-info
|
|
1192
1233
|
rokit active-app
|
|
@@ -1220,8 +1261,8 @@ environment:
|
|
|
1220
1261
|
ROKIT_USERNAME=rokudev
|
|
1221
1262
|
ROKIT_TIMEOUT_MS=10000
|
|
1222
1263
|
|
|
1223
|
-
|
|
1224
|
-
ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted
|
|
1264
|
+
aliases:
|
|
1265
|
+
ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted when ROKIT_* names are unset.`);
|
|
1225
1266
|
};
|
|
1226
1267
|
//#endregion
|
|
1227
1268
|
//#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
|
|
@@ -25,9 +25,12 @@ repo.
|
|
|
25
25
|
6. For live proof, use `rokit snapshot` for a quick state read,
|
|
26
26
|
`rokit proof <output-dir>` for review artifacts, or `pnpm live:probe` in the
|
|
27
27
|
rokit repo for the full generic package/install/launch/input/proof probe.
|
|
28
|
-
7.
|
|
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
|
|
29
32
|
SceneGraph readiness.
|
|
30
|
-
|
|
33
|
+
9. Use `rokit press --until-node ...` for bounded navigation loops instead of
|
|
31
34
|
arbitrary sleeps.
|
|
32
35
|
|
|
33
36
|
## Boundaries
|