E2E testing framework for smart TVs. Roku first. TypeScript, runs off-device over HTTP.
Your tests run in Node and talk to the Roku ECP API on port 8060. No Appium, no WebdriverIO, no Selenium Grid, no Java.
npm install @danecodes/uncle-jesse-core @danecodes/uncle-jesse-roku @danecodes/uncle-jesse-testimport { RokuAdapter } from '@danecodes/uncle-jesse-roku';
import { BasePage } from '@danecodes/uncle-jesse-core';
const tv = new RokuAdapter({
name: 'dev-roku',
ip: process.env.ROKU_IP ?? '192.168.1.100',
devPassword: 'rokudev',
});
await tv.connect();
await tv.launchApp('dev');
// Query the UI tree with CSS-like selectors
const grid = await tv.$('HomeScreen RowList');
const title = await tv.$('Label#screenTitle');
// Navigate with D-pad
await tv.press('right', { times: 3 });
await tv.select();
// Check what has focus
const focused = await tv.getFocusedElement();
console.log(focused?.getAttribute('title'));
await tv.disconnect();LiveElement is a persistent reference to a UI element. It re-queries the device on every call, so you never work with stale data. Full method list in the API reference.
import { LiveElement } from '@danecodes/uncle-jesse-core';
const homeScreen = new LiveElement(tv, 'HomeScreen');
// Chained queries scope to the parent's subtree
const grid = homeScreen.$('RowList');
const title = homeScreen.$('Label#screenTitle');
// Actions
await homeScreen.select();
await homeScreen.focus(); // navigates via D-pad using bounds
await homeScreen.clear(); // backspace for each character
await settingsBtn.select({ ifNotDisplayedNavigate: 'down' }); // scroll until visible, then select
// State queries
await homeScreen.isDisplayed(); // true if visible attr is not "false"
await homeScreen.isExisting(); // true if element exists in tree
await homeScreen.isFocused(); // true if element has focused="true"
await homeScreen.isStale(); // true if element changed since first query
await title.getText(); // returns the text attribute value
await title.getAttribute('color'); // returns any attribute
// Assertions with polling (wait up to timeout for condition)
await homeScreen.toBeDisplayed({ timeout: 10000 });
await homeScreen.toNotBeDisplayed();
await homeScreen.toExist();
await title.toHaveText('Home');
await title.toHaveAttribute('color', '0xffffffff');
await title.toHaveAttribute('text', /Episode \d+/);
await grid.toBeFocused({ timeout: 5000 });$$ returns an ElementCollection with assertions, iteration, and indexed access.
const rows = home.$$('RowListItem');
const count = await rows.length;
const first = rows.get(0);
// Assertions
await rows.toHaveLength(3);
await rows.toHaveText(['Featured', 'Recently Added', 'Popular']);
await rows.toHaveTextInOrder(['Featured', /Recent/, 'Popular']);
// Iteration
const titles = await rows.map(async (el) => el.getText());
const visible = await rows.filter(async (el) => el.isDisplayed());
// Typed collections
const cards = home.$$('LinearCard', CardComponent);
const firstCard = cards.get(0); // returns a CardComponent instanceWait for the UI to stop changing before you do anything else. By default this just checks that the tree hasn't changed between two consecutive polls. You can also pass loading indicator selectors and tracked attributes if your app needs something more specific.
// Default: wait until the UI tree stops changing
await tv.waitForStable();
// App-specific: wait until spinners are gone and tracked attributes settle
await tv.waitForStable({
indicators: ['BusySpinner', 'LoadingIndicator'],
trackedAttributes: ['focused', 'text', 'visible', 'opacity'],
settleCount: 2,
timeout: 15000,
});Send events to the Roku app via the ECP /input endpoint. Transport controls, voice commands, custom app events, etc.
await tv.sendInput({ command: 'pause', type: 'transport' });
await tv.sendInput({ command: 'seek', type: 'transport', direction: 'forward', duration: 30 });Send touch events to the device screen. Coordinates use pixel positions.
await tv.touch(640, 360); // tap center of 1280x720 screen
await tv.touch(100, 200, 'down'); // touch down
await tv.touch(200, 200, 'move'); // drag
await tv.touch(200, 200, 'up'); // releaseQuery and wait for app lifecycle states.
const state = await tv.getAppState('dev'); // 'foreground' | 'not-running' | 'not-installed'
await tv.waitForAppState('dev', 'foreground');If you're coming from WebdriverIO, BasePage and BaseComponent work the same way you're used to. See the migration guide. For simpler tests, TVPage in @danecodes/uncle-jesse-test is a lighter base class that takes a device directly.
import { BasePage, BaseComponent } from '@danecodes/uncle-jesse-core';
class NavBar extends BaseComponent {
get homeTab() { return this.$('NavTab#tabHome'); }
get searchTab() { return this.$('NavTab#tabSearch'); }
async selectHome() { await this.homeTab.select(); }
async selectSearch() { await this.searchTab.select(); }
}
class HomePage extends BasePage {
get root() { return this.$('HomeScreen'); }
get navBar() { return new NavBar(this.$('NavBar')); }
get grid() { return this.$('HomeScreen RowList'); }
async waitForLoaded() {
await this.root.toBeDisplayed();
await this.grid.waitForExisting();
}
}Use them in tests:
import { beforeEach, it } from 'vitest';
let device: TVDevice;
let home: HomePage;
beforeEach(async () => {
device = new RokuAdapter({ name: 'test', ip: '192.168.1.100' });
await device.connect();
home = new HomePage(device, null);
await device.home();
await device.launchApp('dev');
await home.waitForLoaded();
});
it('navigate to search', async () => {
await device.press('up');
await home.navBar.selectSearch();
await home.root.toNotBeDisplayed();
});CSS-like selectors against the Roku SceneGraph tree. See Writing Testable Channels for tips on structuring your app so selectors don't suck.
| Pattern | Example | Matches |
|---|---|---|
| Tag name | RowList |
Elements with that tag |
| ID | #screenTitle |
Element with name="screenTitle" |
| Tag + ID | Label#screenTitle |
Label with that name |
| Descendant | HomeScreen RowList |
RowList anywhere inside HomeScreen |
| Child | LayoutGroup > Label |
Direct child only |
| Attribute | [focused="true"] |
Element with that attribute value |
| Attribute existence | [focusable] |
Element with that attribute present |
| Tag + attribute | Label[text="Home"] |
Label with text="Home" |
| Adjacent sibling | Module + Module |
Module preceded by another Module |
| nth-child | NavTab:nth-child(2) |
Second NavTab child |
| :has() | Item:has([text="Fantasy"]) |
Item containing a descendant with that text |
Attribute values with spaces work: [text="Add to List"]. :has() supports nesting: A:has(B:has(C)).
Chainable builder for verifying D-pad navigation. It runs every step and collects all failures instead of bailing on the first one. After each key press, it waits for focus to stabilize (two consecutive tree polls agreeing) before checking your expectation. See Roku Focus Behavior for the gory details on how Roku reports focus.
import { focusPath } from '@danecodes/uncle-jesse-test';
const result = await focusPath(tv)
.press('right').expectFocus('[title="featured-item-2"]')
.press('right').expectFocus('[title="featured-item-3"]')
.press('down').expectFocus('[title="recent-item-2"]')
.verify();
expect(result.passed).toBe(true);Supports #id, [attr="value"], Tag#id, and Tag[attr="value"] selectors for focus matching.
When steps fail:
Step 1: After pressing RIGHT, expected focus on [title="featured-item-2"]
but found focus on RenderableNode[title="featured-item-1"]
Pass { record: true } to focusPath and it captures a screenshot and UI tree snapshot at every step. You get a self-contained HTML file with a scrubber so you can step through the navigation and see exactly where focus went wrong.
const result = await focusPath(tv, { record: true, testName: 'grid-nav' })
.press('right').expectFocus('[title="featured-item-2"]')
.press('down').expectFocus('[title="recent-item-2"]')
.verify();
if (result.replay) {
const { saveReplay } = await import('@danecodes/uncle-jesse-test/replay');
await saveReplay(result.replay, './test-results');
}When a test fails, a screenshot is automatically saved to test-results/. Configure with:
import { setScreenshotOnFailure } from '@danecodes/uncle-jesse-test';
setScreenshotOnFailure(true, './test-results');Stream BrightScript console output during tests via @danecodes/roku-log. Errors, crashes, backtraces, and performance beacons get parsed into structured data you can query and assert against.
const tv = new RokuAdapter({ name: 'test', ip: '192.168.1.100' });
await tv.connect();
await tv.startLogCapture();
await tv.launchApp('dev');
// ... run tests ...
// Check for errors during the test
if (tv.hasErrors()) {
console.log('Errors:', tv.logs.errors);
}
if (tv.hasCrashes()) {
console.log('Crashes:', tv.logs.crashes);
}
// Get a summary
const summary = tv.getLogSummary();
console.log(`${summary.errorCount} errors, launch time: ${summary.launchTime}ms`);
// Filter and search logs
const networkErrors = tv.logs.filter({ file: 'NetworkTask.brs' });
const authLogs = tv.logs.search('authentication');
tv.stopLogCapture();CTRF (Common Test Reporting Format) reports. Useful if you feed test results into Databricks or CI dashboards.
import { CtrfReporter } from 'uncle-jesse';
const reporter = new CtrfReporter({
deviceName: 'Roku Ultra',
appName: 'MyApp',
appVersion: '2.0.0',
buildId: process.env.BUILD_ID,
testEnvironment: 'staging',
outputDir: './test-results',
});
// Feed test results to the reporter, then save
reporter.save(); // writes test-results/ctrf-report.jsonThe output includes device name, environment metadata, and focusPath step failures. It follows the CTRF schema so you can ingest it as Parquet or whatever your pipeline expects.
Run tests across multiple Rokus at once. DevicePool handles allocation. Use poolTest instead of test and the device gets acquired and released for you.
// setup.ts
import { setDevicePool } from '@danecodes/uncle-jesse-test';
import { DevicePool } from '@danecodes/uncle-jesse-core';
import { RokuAdapter } from '@danecodes/uncle-jesse-roku';
const devices = [
new RokuAdapter({ name: 'roku-1', ip: '192.168.1.50' }),
new RokuAdapter({ name: 'roku-2', ip: '192.168.1.51' }),
new RokuAdapter({ name: 'roku-3', ip: '192.168.1.52' }),
];
for (const d of devices) await d.connect();
setDevicePool(new DevicePool(devices));
// test file
import { poolTest as test } from '@danecodes/uncle-jesse-test';
test('navigate grid', async ({ tv }) => {
// tv is acquired from the pool, released after the test
await tv.launchApp('dev');
});Optional. Install @danecodes/roku-odc and inject the ODC component into your app to get direct access to node fields, field observation, and the device filesystem. Everything works without it (ECP only), but ODC cuts wait times on assertions and lets you inspect state that ECP doesn't expose.
import { OdcClient } from '@danecodes/roku-odc';
const odc = new OdcClient('192.168.1.100');
tv.setOdc(odc);Read/write node fields by ID, call interface functions, search the scene graph.
// Read a nested view model field
const isLoggedIn = await tv.getField('authManager', 'isLoggedIn');
// Write a field
await tv.setField('settingsPanel', 'selectedIndex', 2);
// Call a function on a node
await tv.callFunc('contentManager', 'refreshFeed', ['home']);
// Search nodes by subtype or field values
const buttons = await tv.findNodes({ subtype: 'Button', fields: { visible: true } });
// Get the focused node with all its fields
const focused = await tv.getOdcFocusedNode();Wait for a field to hit a specific value. Uses a long-poll under the hood, so it's one HTTP request instead of polling in a loop.
// Wait for a field to match a value
const result = await tv.observeField('videoPlayer', 'state', {
match: 'playing',
timeout: 10000,
});If ODC is configured, LiveElement assertions (toBeFocused, toHaveText, toHaveAttribute) will use observeField instead of polling. You don't have to change your tests. The polling fallback is still there for ECP-only setups.
await tv.pushFile('tmp:/test-data.json', Buffer.from('{"key":"value"}'));
const data = await tv.pullFile('tmp:/test-data.json');
const files = await tv.listFiles('tmp:/');@danecodes/roku-mock gives you a local HTTP mock server so your tests don't hit real APIs. MockTestHelper manages the server lifecycle.
import { MockTestHelper } from '@danecodes/uncle-jesse-test';
import { MockServer, ScenarioManager } from '@danecodes/roku-mock';
const server = new MockServer({ port: 3000 });
const scenarios = new ScenarioManager();
const mock = new MockTestHelper({
server,
scenarios,
configureDevice: async (srv, device) => {
// Point the app at the mock server
await device.sendInput({ apiBaseUrl: srv.baseUrl });
},
});
beforeEach(async () => {
await mock.setup(device);
mock.activateScenario('premiumUser');
});
afterEach(async () => {
await mock.teardown();
});
it('loads profile', async () => {
await device.launchApp('dev');
expect(mock.requestCount('/v1/profile')).toBeGreaterThan(0);
});# Run tests
npx uncle-jesse test
npx uncle-jesse test --reporter junit
npx uncle-jesse test --reporter ctrf
npx uncle-jesse test --watch
# Discover devices on the network
npx uncle-jesse discover
npx uncle-jesse discover --timeout 10000
# Sideload a channel (zip file or directory)
npx uncle-jesse sideload ./my-channel --ip 192.168.1.100
npx uncle-jesse sideload ./build.zip --ip 192.168.1.100 --password rokudevLaunch directly to a specific content item:
await tv.deepLink('dev', 'content-123', 'movie');The call blocks until the app is in the foreground.
Pre-load registry values before launching. Works with apps that handle the odc_registry launch param. Define your own app-specific factories in your test data layer.
import { RegistryState } from '@danecodes/uncle-jesse-core';
// Build registry state with your app's section names and keys
const registry = new RegistryState()
.set('MY_APP_PREFS', 'isFirstLaunch', 'false')
.set('MY_APP_SETTINGS', 'subtitleLanguage', 'en');
await tv.launchApp('dev', registry.toLaunchParams());
// Or from a data object
const state = RegistryState.from({
MY_APP_PREFS: { isFirstLaunch: 'false', theme: 'dark' },
});Test Script (user code)
|
@danecodes/uncle-jesse-test focusPath, assertions, vitest plugin, replay
|
@danecodes/uncle-jesse-core TVDevice, LiveElement, BasePage, selectors
|
@danecodes/uncle-jesse-roku RokuAdapter wrapping @danecodes/roku-ecp
|
ECP HTTP API port 8060 on the Roku device
| Package | Description |
|---|---|
@danecodes/uncle-jesse-core |
TVDevice, LiveElement, BasePage, BaseComponent, SelectorEngine, RegistryState, DevicePool |
@danecodes/uncle-jesse-roku |
Roku adapter, media player, log capture via @danecodes/roku-ecp and @danecodes/roku-log |
@danecodes/uncle-jesse-test |
focusPath, vitest matchers, vitest plugin, replay debugger |
uncle-jesse |
CLI (test, discover, sideload) and reporters (console, JUnit, CTRF) |
Optional integrations:
| Package | Description |
|---|---|
@danecodes/roku-odc |
Node introspection, field observation, file operations, registry access via ODC (port 8061) |
@danecodes/roku-log |
Structured BrightScript log parsing and streaming (included in roku adapter) |
Working test suites in examples/ that run against a bundled test channel:
roku-basic- smoke tests: launch, navigate, select, backroku-focus-path- focusPath with title-based selectors and replay recordingroku-page-objects- page object pattern with GridScreen and DetailsScreenroku-work-style- full test suite using BasePage/BaseComponent (23 tests covering navigation, search, settings, deep linking, focusPath)
More in docs/:
MIT