E2E testing framework for smart TVs. TypeScript, off-device, over HTTP. Roku first, with other platforms planned.
Uncle Jesse talks directly to the Roku External Control Protocol (ECP) on port 8060. No Appium, no WebdriverIO, no Selenium Grid, no Java runtime. Your tests run in Node and send HTTP requests to the device.
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 that re-queries the device on each call. It supports chained selectors, actions, and built-in assertions with polling. See the API reference for the full method list.
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());Wait for the UI to stop changing before proceeding. By default uses roku-ecp's tree-level stability check. Pass app-specific indicators and tracked attributes for custom stability definitions.
// 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 arbitrary events to the Roku app via ECP /input. Used for media transport controls, voice commands, and custom app events.
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');BasePage and BaseComponent provide the same structure used in production Roku test suites with WebdriverIO. If you're migrating from an Appium-based setup, this is the API you want. See the migration guide for a detailed walkthrough. For simpler cases, TVPage in @danecodes/uncle-jesse-test provides 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();
});$$ returns an ElementCollection with .get(index) and async .length. You can also pass a component class to get typed results.
const rows = home.$$('RowListItem');
const count = await rows.length; // number of matching elements
const first = rows.get(0); // LiveElement for the first match
await first.toBeDisplayed();
// Typed collections
const cards = home.$$('LinearCard', CardComponent);
const firstCard = cards.get(0); // returns a CardComponent instanceUncle Jesse uses CSS-like selectors against the Roku SceneGraph tree. See Writing Testable Channels for how to structure your app for best results.
| 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 |
Attribute values with spaces work: [text="Add to List"].
A chainable builder for verifying D-pad spatial navigation. Runs every step and collects all failures instead of stopping on the first one. After each key press, it waits for focus to stabilize (two consecutive tree queries agreeing) before checking the expectation. For details on how Roku handles focus, see Roku Focus Behavior.
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 to capture a device screenshot and UI tree snapshot at each step. The output is a self-contained HTML file with a scrubber, step details, and side-by-side screenshot and tree view.
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 using the vitest tv fixture, a device screenshot is automatically saved to test-results/ when a test fails. Configure with:
import { setScreenshotOnFailure } from '@danecodes/uncle-jesse-test';
setScreenshotOnFailure(true, './test-results');Stream and parse BrightScript console output during test runs using @danecodes/roku-log. Captures errors, crashes, backtraces, and performance beacons as structured data.
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();Generate CTRF (Common Test Reporting Format) reports for integration with Databricks, CI dashboards, and cross-team test analytics.
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 report includes device name, environment metadata, focusPath step failures, and maps to the CTRF schema for Parquet ingestion.
DevicePool manages a pool of devices for parallel test execution. Use poolTest instead of test to automatically acquire and release devices.
// 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');
});Read and write files on the Roku device filesystem. Requires @danecodes/roku-odc and an app with the ODC component injected.
import { OdcClient } from '@danecodes/roku-odc';
const odc = new OdcClient('192.168.1.100');
tv.setOdc(odc);
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:/');Use @danecodes/roku-mock for deterministic test data. Uncle Jesse provides MockTestHelper to manage the server lifecycle and verify API calls.
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 adapter waits for the target app to become active before returning.
Inject registry state before launching the app. This lets you skip onboarding flows, set language preferences, or configure any app state that's stored in the Roku registry. Compatible with apps that handle the odc_registry launch param convention.
import { RegistryState } from '@danecodes/uncle-jesse-core';
const registry = RegistryState.skipOnboarding();
const params = registry.toLaunchParams();
await tv.launchApp('dev', params);
// Or build custom state
const custom = new RegistryState()
.set('APP_CONFIG', 'isFirstLaunch', 'false')
.set('SETTINGS', 'subtitleLanguage', 'en');
await tv.launchApp('dev', custom.toLaunchParams());DevicePool manages a pool of devices for parallel test execution. Tests acquire a device from the pool, run against it, and release it when done. If all devices are busy, the next test waits until one becomes available.
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();
const pool = new DevicePool(devices, { acquireTimeout: 30000 });
// In each test worker
const device = await pool.acquire();
try {
// run tests against device
} finally {
pool.release(device);
}
// When done
await pool.drain();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 |
Direct registry read/write and file operations via ODC (port 8061) |
@danecodes/roku-log |
Structured BrightScript log parsing and streaming (included in roku adapter) |
The examples/ directory has working test suites 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)
See the docs/ directory for detailed guides:
MIT