From 46714e237cac08b8440e2eb4bf370a7d6da2379e Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 15 May 2026 19:07:47 +0300 Subject: [PATCH 1/4] feat: add SceneGraph assertion commands --- AGENTS.md | 3 + README.md | 10 +++ src/cli.ts | 156 +++++++++++++++++++++++++++++++++++++++- src/roku.ts | 80 +++++++++++++++------ src/scenegraph.ts | 107 +++++++++++++++++++++++++++ src/xml.ts | 2 +- test/cli.test.ts | 1 + test/scenegraph.test.ts | 48 +++++++++++++ test/xml.test.ts | 26 +++++++ 9 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 src/scenegraph.ts create mode 100644 test/scenegraph.test.ts create mode 100644 test/xml.test.ts diff --git a/AGENTS.md b/AGENTS.md index 887b1fb..ee3973c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,9 @@ Keep it platform-focused, typed, and useful for both humans and agents. it already owns the platform mechanics. - Use Roku ECP for launch, keypresses, active-app queries, and raw runtime state. +- Keep SceneGraph helpers generic: node state, text, attributes, focus/state + waits, and raw tree output are okay; product-specific screen contracts stay in + app repos. - Keep app journeys, content IDs, account data, and product assertions out of the generic harness. diff --git a/README.md b/README.md index 72ffe4b..ced2d0f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ pnpm exec rokit check pnpm exec rokit launch dev pnpm exec rokit press Down Select pnpm exec rokit query /query/active-app +pnpm exec rokit wait-node videoPlayerScreen visible pnpm exec rokit screenshot artifacts/live/player.png ``` @@ -48,9 +49,13 @@ pnpm exec rokit screenshot artifacts/live/player.png rokit check rokit device-info rokit active-app +rokit wait-active [--timeout-ms ] rokit launch [--param key=value] rokit press [key...] rokit query +rokit sgnodes +rokit assert-node [value] +rokit wait-node [value] [--timeout-ms ] rokit screenshot rokit install rokit --version @@ -60,10 +65,14 @@ rokit --version is reachable. - `device-info` prints enhanced Roku device metadata as JSON. - `active-app` prints the foreground app. +- `wait-active` waits until the requested app is foregrounded. - `launch` opens an app and waits until it is active. Use repeated `--param` values for deeplink parameters. - `press` sends Roku remote keys through ECP. - `query` prints a raw ECP response such as `/query/sgnodes/all`. +- `sgnodes` prints the raw SceneGraph tree from `/query/sgnodes/all`. +- `assert-node` checks a named SceneGraph node once. +- `wait-node` polls SceneGraph until a named node condition matches. - `screenshot` saves a developer screenshot. It requires `ROKIT_PASSWORD`. - `install` publishes an existing ZIP through `roku-deploy`. It requires `ROKIT_PASSWORD`. @@ -92,6 +101,7 @@ tokens, and app-specific media identifiers do not belong in git. - launch and deeplink parameters - remote keypresses - raw ECP queries +- SceneGraph state queries and named-node assertions - screenshots App repositories should keep their own scenario commands for product behavior, diff --git a/src/cli.ts b/src/cli.ts index 7f68b39..c5a8be3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { createRequire } from "node:module"; import { + assertSceneGraphNode, checkDevice, getDeviceInfo, installPackage, @@ -9,9 +10,13 @@ import { pressKey, queryActiveApp, queryEcp, + querySceneGraph, takeScreenshot, + waitForActiveApp, + waitForSceneGraphNode, type RokuContext, } from "./roku.js"; +import type { NodeExpectation } from "./scenegraph.js"; import { fail, formatErrorMessage, @@ -26,15 +31,25 @@ type LaunchArgs = { readonly params: ReadonlyMap; }; +type NodeCondition = { + readonly expectation: NodeExpectation; + readonly nodeName: string; + readonly timeoutMs?: number; +}; + type Command = | { readonly name: "active-app" } + | { readonly args: NodeCondition; readonly name: "assert-node" } | { readonly name: "check" } | { readonly name: "device-info" } | { readonly name: "install"; readonly zipPath: string } | { readonly name: "launch"; readonly args: LaunchArgs } | { readonly name: "press"; readonly keys: readonly string[] } | { readonly name: "query"; readonly path: string } - | { readonly name: "screenshot"; readonly outputPath: string }; + | { readonly name: "screenshot"; readonly outputPath: string } + | { readonly name: "sgnodes" } + | { readonly appId: string; readonly name: "wait-active"; readonly timeoutMs?: number } + | { readonly args: NodeCondition; readonly name: "wait-node" }; const require = createRequire(import.meta.url); const packageJson = require("../package.json") as { version: string }; @@ -91,6 +106,12 @@ const runCommand = async (context: RokuContext, command: Command): Promise return; } + if (command.name === "wait-active") { + const app = await waitForActiveApp(context, command.appId, command.timeoutMs); + console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim()); + return; + } + if (command.name === "launch") { const app = await launchApp(context, command.args.appId, command.args.params); console.log(`launched: ${app.id} ${app.name} ${app.version}`.trim()); @@ -110,6 +131,28 @@ const runCommand = async (context: RokuContext, command: Command): Promise return; } + if (command.name === "sgnodes") { + console.log(await querySceneGraph(context)); + return; + } + + if (command.name === "assert-node") { + await assertSceneGraphNode(context, command.args.nodeName, command.args.expectation); + console.log(`asserted node: ${formatNodeCondition(command.args)}`); + return; + } + + if (command.name === "wait-node") { + await waitForSceneGraphNode( + context, + command.args.nodeName, + command.args.expectation, + command.args.timeoutMs, + ); + console.log(`matched node: ${formatNodeCondition(command.args)}`); + return; + } + if (command.name === "screenshot") { const password = requirePassword(context); mkdirSync(dirname(command.outputPath), { recursive: true }); @@ -140,6 +183,20 @@ const parseCommand = (argv: readonly string[]): Command => { return { name }; } + if (name === "wait-active") { + const appId = args[0]; + + if (!appId) { + fail("usage: rokit wait-active [--timeout-ms ]"); + } + + return { + appId, + name, + timeoutMs: parseTimeoutOption(args.slice(1), `rokit ${name} `), + }; + } + if (name === "launch") { return { name, args: parseLaunchArgs(args) }; } @@ -162,6 +219,14 @@ const parseCommand = (argv: readonly string[]): Command => { return { name, path }; } + if (name === "sgnodes") { + return { name }; + } + + if (name === "assert-node" || name === "wait-node") { + return { name, args: parseNodeCondition(name, args) }; + } + if (name === "screenshot") { const outputPath = args[0]; @@ -185,6 +250,91 @@ const parseCommand = (argv: readonly string[]): Command => { return fail(`Unknown command: ${name ?? ""}`); }; +const parseNodeCondition = (commandName: string, args: readonly string[]): NodeCondition => { + const [nodeName, condition, ...rest] = args; + + if (!nodeName || !condition) { + return fail( + `usage: rokit ${commandName} [value] [--timeout-ms ]`, + ); + } + + if (condition === "visible" || condition === "hidden" || condition === "absent") { + const timeoutMs = parseTimeoutOption(rest, `rokit ${commandName} ${condition}`); + return { + expectation: { state: condition }, + nodeName, + timeoutMs, + }; + } + + if (condition === "text") { + const [text, ...optionArgs] = rest; + + if (text === undefined) { + fail(`usage: rokit ${commandName} text `); + } + + return { + expectation: { state: "visible", text }, + nodeName, + timeoutMs: parseTimeoutOption(optionArgs, `rokit ${commandName} text`), + }; + } + + if (condition === "attr") { + const [pair, ...optionArgs] = rest; + + if (pair === undefined) { + fail(`usage: rokit ${commandName} attr `); + } + + const equalsIndex = pair.indexOf("="); + + if (equalsIndex <= 0) { + fail(`Invalid attr condition: ${pair}`); + } + + return { + expectation: { + attribute: pair.slice(0, equalsIndex), + value: pair.slice(equalsIndex + 1), + }, + nodeName, + timeoutMs: parseTimeoutOption(optionArgs, `rokit ${commandName} attr`), + }; + } + + return fail(`Unknown node condition: ${condition}`); +}; + +const parseTimeoutOption = (args: readonly string[], usagePrefix: string): number | undefined => { + if (args.length === 0) { + return undefined; + } + + if (args.length !== 2 || args[0] !== "--timeout-ms") { + fail(`usage: ${usagePrefix} [--timeout-ms ]`); + } + + const timeoutMs = Number(args[1]); + + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + fail(`Invalid timeout: ${args[1] ?? ""}`); + } + + return timeoutMs; +}; + +const formatNodeCondition = ({ expectation, nodeName }: NodeCondition): string => { + if ("attribute" in expectation) { + return `${nodeName} attr ${expectation.attribute}=${expectation.value}`; + } + + const suffix = expectation.text === undefined ? "" : ` text=${expectation.text}`; + return `${nodeName} ${expectation.state}${suffix}`; +}; + const parseLaunchArgs = (args: readonly string[]): LaunchArgs => { const appId = args[0]; @@ -227,9 +377,13 @@ usage: rokit check rokit device-info rokit active-app + rokit wait-active [--timeout-ms ] rokit launch [--param key=value] rokit press [key...] rokit query + rokit sgnodes + rokit assert-node [value] + rokit wait-node [value] [--timeout-ms ] rokit screenshot rokit install rokit --version diff --git a/src/roku.ts b/src/roku.ts index 91c2a5f..b9d53f9 100644 --- a/src/roku.ts +++ b/src/roku.ts @@ -1,5 +1,6 @@ import * as rokuDeploy from "roku-deploy"; import { basename, dirname, extname, resolve } from "node:path"; +import { assertNamedNode, type NodeExpectation } from "./scenegraph.js"; import { readActiveApp, readXmlTag, type ActiveApp } from "./xml.js"; const ecpPort = 8060; @@ -77,6 +78,28 @@ export const getDeviceInfo = async (context: RokuContext) => export const queryActiveApp = async (context: RokuContext): Promise => readActiveApp(await fetchText(context, "/query/active-app")); +export const waitForActiveApp = async ( + context: RokuContext, + appId: string, + timeoutMs = 10_000, +): Promise => { + const start = Date.now(); + let lastApp: ActiveApp | undefined; + + while (Date.now() - start < timeoutMs) { + lastApp = await queryActiveApp(context); + + if (lastApp.id === appId) { + return lastApp; + } + + await sleep(500); + } + + const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown"; + throw new Error(`expected active app ${appId}, got ${last}`); +}; + export const launchApp = async ( context: RokuContext, appId: string, @@ -100,6 +123,41 @@ export const pressKey = async (context: RokuContext, key: string): Promise export const queryEcp = async (context: RokuContext, path: string): Promise => await fetchText(context, path.startsWith("/") ? path : `/${path}`); +export const querySceneGraph = async (context: RokuContext): Promise => + await queryEcp(context, "/query/sgnodes/all"); + +export const assertSceneGraphNode = async ( + context: RokuContext, + nodeName: string, + expectation: NodeExpectation, +): Promise => { + assertNamedNode(await querySceneGraph(context), nodeName, expectation); +}; + +export const waitForSceneGraphNode = async ( + context: RokuContext, + nodeName: string, + expectation: NodeExpectation, + timeoutMs = 30_000, +): Promise => { + const start = Date.now(); + let lastError: string | undefined; + + while (Date.now() - start < timeoutMs) { + try { + await assertSceneGraphNode(context, nodeName, expectation); + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + + await sleep(500); + } + + const suffix = lastError ? `; last observation: ${lastError}` : ""; + throw new Error(`expected SceneGraph node "${nodeName}" to match condition${suffix}`); +}; + export const installPackage = async ( context: RokuContext & { readonly password: string }, zipPath: string, @@ -143,28 +201,6 @@ export const validateRemoteKey = (key: string): void => { } }; -const waitForActiveApp = async ( - context: RokuContext, - appId: string, - timeoutMs = 10_000, -): Promise => { - const start = Date.now(); - let lastApp: ActiveApp | undefined; - - while (Date.now() - start < timeoutMs) { - lastApp = await queryActiveApp(context); - - if (lastApp.id === appId) { - return lastApp; - } - - await sleep(500); - } - - const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown"; - throw new Error(`expected active app ${appId}, got ${last}`); -}; - const fetchInstallerStatus = async (context: RokuContext): Promise => { const response = await fetch(`http://${context.target}`, { signal: AbortSignal.timeout(context.timeoutMs), diff --git a/src/scenegraph.ts b/src/scenegraph.ts new file mode 100644 index 0000000..c572e36 --- /dev/null +++ b/src/scenegraph.ts @@ -0,0 +1,107 @@ +import { readXmlAttribute } from "./xml.js"; + +export type NodeState = "absent" | "hidden" | "visible"; + +export type NodeExpectation = + | { + readonly state: NodeState; + readonly text?: string; + } + | { + readonly attribute: string; + readonly value: string; + }; + +export const readNamedNodeAttributes = (xml: string, nodeName: string): string | undefined => { + const pattern = new RegExp( + `<[A-Za-z0-9]+\\b(?=[^>]*\\bname="${escapeRegExp(nodeName)}")([^>]*)>`, + ); + + return pattern.exec(xml)?.[1]; +}; + +export const readNamedNodeAttribute = ( + xml: string, + nodeName: string, + attributeName: string, +): string | undefined => { + const attributes = readNamedNodeAttributes(xml, nodeName); + + if (!attributes) { + return undefined; + } + + return readXmlAttribute(attributes, attributeName); +}; + +export const isNamedNodeVisible = (xml: string, nodeName: string): boolean => { + const attributes = readNamedNodeAttributes(xml, nodeName); + + return attributes !== undefined && !attributes.includes('visible="false"'); +}; + +export const assertNamedNode = ( + xml: string, + nodeName: string, + expectation: NodeExpectation, +): void => { + if ("attribute" in expectation) { + assertNamedNodeAttribute(xml, nodeName, expectation.attribute, expectation.value); + return; + } + + assertNamedNodeState(xml, nodeName, expectation.state); + + if (expectation.text !== undefined) { + assertNamedNodeText(xml, nodeName, expectation.text); + } +}; + +export const assertNamedNodeState = (xml: string, nodeName: string, state: NodeState): void => { + const attributes = readNamedNodeAttributes(xml, nodeName); + + if (state === "absent") { + if (attributes !== undefined) { + throw new Error(`expected SceneGraph node "${nodeName}" to be absent`); + } + + return; + } + + if (!attributes) { + throw new Error(`expected SceneGraph node "${nodeName}"`); + } + + if (state === "visible" && attributes.includes('visible="false"')) { + throw new Error(`expected SceneGraph node "${nodeName}" to be visible`); + } + + if (state === "hidden" && !attributes.includes('visible="false"')) { + throw new Error(`expected SceneGraph node "${nodeName}" to be hidden`); + } +}; + +export const assertNamedNodeText = (xml: string, nodeName: string, expectedText: string): void => { + const text = readNamedNodeAttribute(xml, nodeName, "text"); + + if (text !== expectedText) { + throw new Error(`expected "${nodeName}" text "${expectedText}", got "${text ?? "missing"}"`); + } +}; + +const assertNamedNodeAttribute = ( + xml: string, + nodeName: string, + attributeName: string, + expectedValue: string, +): void => { + const value = readNamedNodeAttribute(xml, nodeName, attributeName); + + if (value !== expectedValue) { + throw new Error( + `expected "${nodeName}" ${attributeName} "${expectedValue}", got "${value ?? "missing"}"`, + ); + } +}; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/src/xml.ts b/src/xml.ts index f682080..c9a0730 100644 --- a/src/xml.ts +++ b/src/xml.ts @@ -16,7 +16,7 @@ export const readXmlAttribute = (attributes: string, name: string): string | und }; export const readActiveApp = (xml: string): ActiveApp => { - const match = /]*)>([^<]*)<\/app>/.exec(xml); + const match = /]*))?>([^<]*)<\/app>/.exec(xml); if (!match) { throw new Error("active app response did not include an app node"); diff --git a/test/cli.test.ts b/test/cli.test.ts index 36f26ad..4b673d1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -16,6 +16,7 @@ describe("rokit cli", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("rokit - Roku device harness helper"); expect(result.stdout).toContain("rokit check"); + expect(result.stdout).toContain("rokit wait-node"); expect(result.stderr).toBe(""); }); diff --git a/test/scenegraph.test.ts b/test/scenegraph.test.ts new file mode 100644 index 0000000..12e6372 --- /dev/null +++ b/test/scenegraph.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { assertNamedNode, isNamedNodeVisible, readNamedNodeAttribute } from "../src/scenegraph.js"; + +const xml = ` + + + `; + +describe("SceneGraph helpers", () => { + it("reads named node attributes", () => { + expect(readNamedNodeAttribute(xml, "title", "text")).toBe("Big Buck Bunny"); + expect(readNamedNodeAttribute(xml, "progressTrack", "height")).toBe("10"); + expect(readNamedNodeAttribute(xml, "missing", "text")).toBeUndefined(); + }); + + it("detects visible nodes", () => { + expect(isNamedNodeVisible(xml, "videoPlayerScreen")).toBe(true); + expect(isNamedNodeVisible(xml, "osd")).toBe(false); + expect(isNamedNodeVisible(xml, "missing")).toBe(false); + }); + + it("asserts node state, text, and attributes", () => { + expect(() => assertNamedNode(xml, "videoPlayerScreen", { state: "visible" })).not.toThrow(); + expect(() => assertNamedNode(xml, "osd", { state: "hidden" })).not.toThrow(); + expect(() => assertNamedNode(xml, "missing", { state: "absent" })).not.toThrow(); + expect(() => + assertNamedNode(xml, "title", { state: "visible", text: "Big Buck Bunny" }), + ).not.toThrow(); + expect(() => + assertNamedNode(xml, "progressTrack", { attribute: "height", value: "10" }), + ).not.toThrow(); + }); + + it("throws compact expectation failures", () => { + expect(() => assertNamedNode(xml, "osd", { state: "visible" })).toThrow( + 'expected SceneGraph node "osd" to be visible', + ); + expect(() => assertNamedNode(xml, "title", { state: "absent" })).toThrow( + 'expected SceneGraph node "title" to be absent', + ); + expect(() => assertNamedNode(xml, "title", { state: "visible", text: "Other" })).toThrow( + 'expected "title" text "Other", got "Big Buck Bunny"', + ); + }); +}); diff --git a/test/xml.test.ts b/test/xml.test.ts new file mode 100644 index 0000000..0e01d2b --- /dev/null +++ b/test/xml.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { readActiveApp } from "../src/xml.js"; + +describe("XML helpers", () => { + it("reads active-app responses with attributes", () => { + expect( + readActiveApp( + 'put.io', + ), + ).toEqual({ + id: "dev", + name: "put.io", + type: "appl", + version: "1.0", + }); + }); + + it("reads active-app responses without attributes", () => { + expect(readActiveApp("Roku")).toEqual({ + id: "", + name: "Roku", + type: "", + version: "", + }); + }); +}); From 4617ed472d1d14212527200257265eba9c4b6f42 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 15 May 2026 19:10:30 +0300 Subject: [PATCH 2/4] feat: expose rokit library API --- AGENTS.md | 2 ++ README.md | 22 ++++++++++++++++++++++ package.json | 15 +++++++++++---- src/index.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/index.ts diff --git a/AGENTS.md b/AGENTS.md index ee3973c..6ceb5ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ Keep it platform-focused, typed, and useful for both humans and agents. ## Patterns - Keep CLI wiring thin: parse/dispatch commands, then call named Roku helpers. +- Keep `src/index.ts` as the public library surface for app-specific scenario + scripts. Export generic Roku/SceneGraph primitives only. - Treat `process.cwd()` as the consumer app root. - Keep `.rokit/` consumer-local; it can hold env, generated artifacts, and transient device state. diff --git a/README.md b/README.md index ced2d0f..cd5212d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,28 @@ pnpm exec rokit wait-node videoPlayerScreen visible pnpm exec rokit screenshot artifacts/live/player.png ``` +App-specific scenario scripts can also import the generic helpers: + +```ts +import { assertSceneGraphNode, pressKey, querySceneGraph, type RokuContext } from "@putdotio/rokit"; + +const target = process.env.ROKIT_TARGET; + +if (!target) { + throw new Error("ROKIT_TARGET is not set"); +} + +const context: RokuContext = { + target, + timeoutMs: 10_000, + username: "rokudev", +}; + +await pressKey(context, "Info"); +await assertSceneGraphNode(context, "videoPlayerScreen", { state: "visible" }); +await querySceneGraph(context); +``` + ## Commands ```bash diff --git a/package.json b/package.json index b3a8706..07a24e7 100644 --- a/package.json +++ b/package.json @@ -27,18 +27,25 @@ ], "type": "module", "sideEffects": false, + "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, "publishConfig": { "access": "public" }, "scripts": { - "build": "vp pack src/rokit.ts", + "build": "vp pack src/index.ts src/rokit.ts --dts", "check": "vp check .", "clean": "rm -rf .turbo coverage dist", - "prepack": "vp pack src/rokit.ts", - "smoke": "vp pack src/rokit.ts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null", + "prepack": "vp pack src/index.ts src/rokit.ts --dts", + "smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null", "test": "vp test --passWithNoTests", "typecheck": "tsc --noEmit", - "verify": "vp check . && tsc --noEmit && vp pack src/rokit.ts && vp test --passWithNoTests && npm pack --dry-run" + "verify": "vp check . && tsc --noEmit && vp pack src/index.ts src/rokit.ts --dts && vp test --passWithNoTests && npm pack --dry-run" }, "dependencies": { "roku-deploy": "3.17.3" diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6749cae --- /dev/null +++ b/src/index.ts @@ -0,0 +1,27 @@ +export { + assertSceneGraphNode, + checkDevice, + getDeviceInfo, + installPackage, + launchApp, + pressKey, + queryActiveApp, + queryEcp, + querySceneGraph, + takeScreenshot, + validateRemoteKey, + waitForActiveApp, + waitForSceneGraphNode, +} from "./roku.js"; +export type { DeviceSummary, RemoteKey, RokuContext } from "./roku.js"; +export { + assertNamedNode, + assertNamedNodeState, + assertNamedNodeText, + isNamedNodeVisible, + readNamedNodeAttribute, + readNamedNodeAttributes, +} from "./scenegraph.js"; +export type { NodeExpectation, NodeState } from "./scenegraph.js"; +export { readActiveApp, readXmlAttribute, readXmlTag } from "./xml.js"; +export type { ActiveApp } from "./xml.js"; From de2c71e84329d54e9bf1a73fed6884f355a64d62 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 15 May 2026 19:12:35 +0300 Subject: [PATCH 3/4] feat: support delayed key sequences --- README.md | 6 +++-- src/cli.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cd5212d..134974f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Then run: pnpm exec rokit check pnpm exec rokit launch dev pnpm exec rokit press Down Select +pnpm exec rokit press --delay-ms 250 Right Select pnpm exec rokit query /query/active-app pnpm exec rokit wait-node videoPlayerScreen visible pnpm exec rokit screenshot artifacts/live/player.png @@ -73,7 +74,7 @@ rokit device-info rokit active-app rokit wait-active [--timeout-ms ] rokit launch [--param key=value] -rokit press [key...] +rokit press [--delay-ms ] [key...] rokit query rokit sgnodes rokit assert-node [value] @@ -90,7 +91,8 @@ rokit --version - `wait-active` waits until the requested app is foregrounded. - `launch` opens an app and waits until it is active. Use repeated `--param` values for deeplink parameters. -- `press` sends Roku remote keys through ECP. +- `press` sends Roku remote keys through ECP. Use `--delay-ms` for navigation + sequences that need a stable gap between keys. - `query` prints a raw ECP response such as `/query/sgnodes/all`. - `sgnodes` prints the raw SceneGraph tree from `/query/sgnodes/all`. - `assert-node` checks a named SceneGraph node once. diff --git a/src/cli.ts b/src/cli.ts index c5a8be3..a28bd9a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,11 @@ type NodeCondition = { readonly timeoutMs?: number; }; +type PressArgs = { + readonly delayMs: number; + readonly keys: readonly string[]; +}; + type Command = | { readonly name: "active-app" } | { readonly args: NodeCondition; readonly name: "assert-node" } @@ -44,7 +49,7 @@ type Command = | { readonly name: "device-info" } | { readonly name: "install"; readonly zipPath: string } | { readonly name: "launch"; readonly args: LaunchArgs } - | { readonly name: "press"; readonly keys: readonly string[] } + | { readonly args: PressArgs; readonly name: "press" } | { readonly name: "query"; readonly path: string } | { readonly name: "screenshot"; readonly outputPath: string } | { readonly name: "sgnodes" } @@ -119,7 +124,11 @@ const runCommand = async (context: RokuContext, command: Command): Promise } if (command.name === "press") { - for (const key of command.keys) { + for (const [index, key] of command.args.keys.entries()) { + if (index > 0 && command.args.delayMs > 0) { + await sleep(command.args.delayMs); + } + await pressKey(context, key); console.log(`pressed: ${key}`); } @@ -202,11 +211,7 @@ const parseCommand = (argv: readonly string[]): Command => { } if (name === "press") { - if (args.length === 0) { - fail("usage: rokit press [key...]"); - } - - return { name, keys: args }; + return { name, args: parsePressArgs(args) }; } if (name === "query") { @@ -308,6 +313,41 @@ const parseNodeCondition = (commandName: string, args: readonly string[]): NodeC return fail(`Unknown node condition: ${condition}`); }; +const parsePressArgs = (args: readonly string[]): PressArgs => { + let delayMs = 0; + const keys: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === "--delay-ms") { + const value = args[index + 1]; + + if (!value) { + fail("usage: rokit press [--delay-ms ] [key...]"); + } + + delayMs = parsePositiveInteger(value, "delay"); + index += 1; + continue; + } + + if (arg?.startsWith("--")) { + fail(`Unknown press option: ${arg}`); + } + + if (arg !== undefined) { + keys.push(arg); + } + } + + if (keys.length === 0) { + fail("usage: rokit press [--delay-ms ] [key...]"); + } + + return { delayMs, keys }; +}; + const parseTimeoutOption = (args: readonly string[], usagePrefix: string): number | undefined => { if (args.length === 0) { return undefined; @@ -317,13 +357,17 @@ const parseTimeoutOption = (args: readonly string[], usagePrefix: string): numbe fail(`usage: ${usagePrefix} [--timeout-ms ]`); } - const timeoutMs = Number(args[1]); + return parsePositiveInteger(args[1] ?? "", "timeout"); +}; + +const parsePositiveInteger = (value: string, label: string): number => { + const parsed = Number(value); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - fail(`Invalid timeout: ${args[1] ?? ""}`); + if (!Number.isInteger(parsed) || parsed <= 0) { + fail(`Invalid ${label}: ${value}`); } - return timeoutMs; + return parsed; }; const formatNodeCondition = ({ expectation, nodeName }: NodeCondition): string => { @@ -335,6 +379,11 @@ const formatNodeCondition = ({ expectation, nodeName }: NodeCondition): string = return `${nodeName} ${expectation.state}${suffix}`; }; +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const parseLaunchArgs = (args: readonly string[]): LaunchArgs => { const appId = args[0]; @@ -379,7 +428,7 @@ usage: rokit active-app rokit wait-active [--timeout-ms ] rokit launch [--param key=value] - rokit press [key...] + rokit press [--delay-ms ] [key...] rokit query rokit sgnodes rokit assert-node [value] From 824e88d9b67ea9d9111b34217f44b31595024ba4 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 15 May 2026 19:17:25 +0300 Subject: [PATCH 4/4] chore: align repo readiness docs and templates --- .git-hooks/pre-push | 4 ++ .github/ISSUE_TEMPLATE/bug_report.md | 28 +++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++++++++++ .github/dependabot.yml | 18 +++++++++ .github/pull_request_template.md | 9 +++++ .github/workflows/ci.yml | 3 +- AGENTS.md | 3 ++ CLAUDE.md | 1 + CONTRIBUTING.md | 27 +++++++++++-- README.md | 2 + SECURITY.md | 40 +++++++++++++++--- docs/DISTRIBUTION.md | 49 +++++++++++++++++++++++ docs/READINESS.md | 45 +++++++++++++++++++++ package.json | 2 + 14 files changed, 244 insertions(+), 10 deletions(-) create mode 100755 .git-hooks/pre-push create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 120000 CLAUDE.md create mode 100644 docs/DISTRIBUTION.md create mode 100644 docs/READINESS.md diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push new file mode 100755 index 0000000..befd370 --- /dev/null +++ b/.git-hooks/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm verify diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..44f2a78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report a reproducible rokit problem +title: "" +labels: bug +assignees: "" +--- + +## What Happened + +## Expected Behavior + +## Reproduction + +```bash + +``` + +## Environment + +- `rokit` version: +- Node version: +- Roku model or emulator: +- OS: + +## Evidence + +Add logs, command output, screenshots, or generated artifacts when useful. Do not include device passwords, account tokens, signing keys, private content IDs, or local device identifiers. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..70d484d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest a generic Roku harness primitive +title: "" +labels: enhancement +assignees: "" +--- + +## Use Case + +## Proposed Command Or API + +```bash + +``` + +## Proof Artifact + +What should the harness leave behind so a human or agent can verify the behavior? + +## Boundaries + +What app-specific behavior should stay out of `rokit`? diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c82c1aa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + groups: + actions-patch-minor: + update-types: ["patch", "minor"] + actions-major: + update-types: ["major"] + commit-message: + prefix: "ci" + - package-ecosystem: npm + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..084edee --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## Summary + +## Changed + +## Risks + +## Verification + +## Follow-Ups diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d42136..fb62c36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: run: vp run verify release: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') && vars.PUTIO_RELEASE_BOT_CLIENT_ID != '' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') name: Release rokit needs: - verify @@ -74,7 +74,6 @@ jobs: uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 with: node-version-file: ".node-version" - cache: true - name: Install dependencies run: vp install diff --git a/AGENTS.md b/AGENTS.md index 6ceb5ef..2756268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ Keep it platform-focused, typed, and useful for both humans and agents. primary public contract. - Avoid sleeps in generic commands. App repos can add meaningful wait/assert loops around `rokit` primitives. +- Release details live in `docs/DISTRIBUTION.md`; readiness details live in + `docs/READINESS.md`. ## When Contracts Change @@ -58,6 +60,7 @@ vp run test Live Roku checks when a developer-enabled device exists: ```bash +ROKIT_TARGET= vp run live:smoke ROKIT_TARGET= vp exec rokit check ROKIT_TARGET= vp exec rokit launch dev ROKIT_TARGET= vp exec rokit press Info Back diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97c2960..cf0b167 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,18 @@ vp install ``` -## Verify +## Run Locally + +Use the built CLI through the package scripts while developing: + +```bash +vp run smoke +``` + +For live Roku checks, set local environment variables in your shell or +`.rokit/.env`. + +## Validation ```bash vp run verify @@ -20,8 +31,7 @@ run. Live checks require a developer-enabled Roku on the same network: ```bash -ROKIT_TARGET= vp exec rokit check -ROKIT_TARGET= vp exec rokit launch dev +ROKIT_TARGET= vp run live:smoke ROKIT_TARGET= vp exec rokit press Info Back ``` @@ -29,3 +39,14 @@ Screenshots and installs require `ROKIT_PASSWORD`. Keep local device details in `.rokit/.env` or your shell. Do not commit device IPs, passwords, signing keys, tokens, or account-specific app data. + +## Pull Requests + +- Keep changes focused. +- Add or update tests when command behavior, parsing, output, or public exports change. +- Include the most useful verification evidence for the change. +- Keep app-specific journeys out of `rokit`; add those to the consuming app repo instead. + +## Distribution + +Release and publishing details live in [Distribution](./docs/DISTRIBUTION.md). diff --git a/README.md b/README.md index 134974f..3cad3ff 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ HTML, or checking app-specific UI nodes. ## Docs - [Contributing](./CONTRIBUTING.md) +- [Distribution](./docs/DISTRIBUTION.md) +- [Agent readiness](./docs/READINESS.md) - [Security](./SECURITY.md) ## Repo Internals diff --git a/SECURITY.md b/SECURITY.md index 277a9bf..9b45616 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,38 @@ # Security -Report security issues privately through GitHub Security Advisories for this -repository. +If you believe you have found a security or privacy issue in this project, +please report it privately. -Do not include device passwords, signing keys, account tokens, private content -IDs, or local device identifiers in public issues, pull requests, examples, or -logs. +## Contact + +- email: devs@put.io + +Private reports are preferred for security and privacy issues. + +If you are unsure whether something is sensitive, email first instead of opening +a public issue. + +## Scope + +Useful reports usually include issues involving: + +- token, secret, or credential exposure +- unsafe handling of device passwords or signing keys +- command injection through CLI arguments, env values, or device responses +- publishing, release, or package integrity problems +- private device, account, or media identifier exposure + +## Guidelines + +- test only against devices, accounts, environments, and data you control +- keep testing non-destructive, low-volume, and service-safe +- do not include device passwords, signing keys, account tokens, private content IDs, or local device identifiers in public issues, pull requests, examples, or logs + +## Supported Versions + +Only the latest published version receives routine fixes. + +## Disclosure + +Please allow a reasonable amount of time to investigate and fix the issue before +sharing details publicly. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md new file mode 100644 index 0000000..2357fb3 --- /dev/null +++ b/docs/DISTRIBUTION.md @@ -0,0 +1,49 @@ +# Distribution + +`rokit` is a public npm package published as `@putdotio/rokit`. + +## Local Contract + +The release path starts with the repo-local verification command: + +```bash +pnpm verify +``` + +`verify` runs formatting/lint checks, TypeScript, package bundling, tests, and an npm pack dry run. GitHub Actions calls this same command before release. + +## Continuous Release + +Merges to `main` are considered publishable. The CI workflow runs: + +1. `verify` on pull requests and `main` pushes. +2. semantic-release on `main` after `verify` passes. + +semantic-release analyzes conventional commits, publishes to npm, creates GitHub Releases, and writes release metadata when needed. + +## Release Credentials + +The release job uses the `release` GitHub Environment with `deployment: false`. + +Required protected inputs: + +- `PUTIO_RELEASE_BOT_CLIENT_ID` as a repository or Environment variable +- `PUTIO_RELEASE_BOT_PRIVATE_KEY` as an Environment secret +- `NPM_TOKEN` as an Environment secret + +Release writes use the `putio-release-bot` installation token. The default `GITHUB_TOKEN` remains read-only. + +## Release Smoke + +After a release, confirm the tag and package are visible: + +```bash +gh release list --repo putdotio/rokit --limit 5 +npm view @putdotio/rokit version +``` + +Live Roku behavior is not required for npm release. Real-device checks are local/manual because they require a developer-enabled Roku: + +```bash +ROKIT_TARGET= pnpm live:smoke +``` diff --git a/docs/READINESS.md b/docs/READINESS.md new file mode 100644 index 0000000..6dba89f --- /dev/null +++ b/docs/READINESS.md @@ -0,0 +1,45 @@ +# Agent Readiness + +Status: current as of 2026-05-15 + +`rokit` is a CLI/package repo. There is no long-running app to boot; readiness is based on deterministic package verification plus optional live Roku proof. + +## Grade + +Overall: B- + +| Dimension | Status | Evidence | Gap | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| bootable | pass | `pnpm smoke` builds the CLI and checks `--version` plus `--help` | no server boot surface, by design | +| testable | pass | `pnpm verify` runs TypeScript, bundle, unit tests, and npm pack dry run | live Roku checks require local hardware | +| observable | partial | CLI commands print active app, device info, raw ECP state, SceneGraph XML, screenshots, and compact assertion failures | no structured JSON output mode yet | +| verifiable | pass | CI runs `pnpm verify`; `pnpm live:smoke` proves a configured Roku responds | no CI hardware lane | + +## Layers + +- Boot: `pnpm smoke` +- Smoke: `pnpm smoke` +- Interact: `rokit press`, `rokit launch`, `rokit query`, `rokit sgnodes` +- E2e: `pnpm live:smoke` for a configured developer-enabled Roku +- Enforce: GitHub Actions `verify`, optional `.git-hooks/pre-push` +- Observe: ECP responses, SceneGraph XML, screenshots, concise command output +- Isolate: `.rokit/` and `.env` stay local per worktree + +## Setup For Agents + +```bash +pnpm install +pnpm verify +``` + +Optional local hook: + +```bash +pnpm hooks:install +``` + +Optional live smoke: + +```bash +ROKIT_TARGET= pnpm live:smoke +``` diff --git a/package.json b/package.json index 07a24e7..2a5dc39 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "build": "vp pack src/index.ts src/rokit.ts --dts", "check": "vp check .", "clean": "rm -rf .turbo coverage dist", + "hooks:install": "git config core.hooksPath .git-hooks", + "live:smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs check", "prepack": "vp pack src/index.ts src/rokit.ts --dts", "smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null", "test": "vp test --passWithNoTests",