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 until focused
await homeScreen.focus({ direction: 'down' }); // specify scroll direction
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 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 grid.toBeFocused({ timeout: 5000 });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();# Run tests
npx uncle-jesse test
npx uncle-jesse test --reporter junit
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('CR_ROKU', '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 interface, LiveElement, BasePage, BaseComponent, SelectorEngine, config |
@danecodes/uncle-jesse-roku |
Roku adapter wrapping @danecodes/roku-ecp |
@danecodes/uncle-jesse-test |
focusPath, vitest matchers, vitest plugin, replay debugger |
uncle-jesse |
CLI (test, discover, sideload) and reporters |
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