Documentation
Everything you need to write E2E tests for Roku channels with Uncle Jesse.
Installation
npm install @danecodes/uncle-jesse-core @danecodes/uncle-jesse-roku @danecodes/uncle-jesse-test Requires Node.js 18+ and a Roku device in developer mode on your network.
Quick Start
import { RokuAdapter } from '@danecodes/uncle-jesse-roku';
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');
const grid = await tv.$('HomeScreen RowList');
const title = await tv.$('Label#screenTitle');
await tv.press('right', { times: 3 });
await tv.select();
const focused = await tv.getFocusedElement();
console.log(focused?.getAttribute('title'));
await tv.disconnect(); Selectors
Uncle Jesse uses CSS-like selectors against the Roku SceneGraph tree.
| 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 |
| Tag + attr | Label[text="Home"] | Label with text="Home" |
| Adjacent sibling | Module + Module | Module preceded by another |
| nth-child | NavTab:nth-child(2) | Second NavTab child |
LiveElement
A persistent reference to a UI element that re-queries the device on each call. Supports chained selectors, actions, and built-in assertions with polling.
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();
await homeScreen.clear();
await settingsBtn.select({ ifNotDisplayedNavigate: 'down' });
// State queries
await homeScreen.isDisplayed();
await homeScreen.isExisting();
await homeScreen.isFocused();
await title.getText();
await title.getAttribute('color');
// Assertions with polling
await homeScreen.toBeDisplayed({ timeout: 10000 });
await homeScreen.toNotBeDisplayed();
await title.toHaveText('Home');
await title.toHaveAttribute('color', '0xffffffff');
await title.toHaveAttribute('text', /Episode \d+/);
await grid.toBeFocused({ timeout: 5000 }); Element Collections
$$ returns an ElementCollection with assertions, iteration, and indexed access.
const rows = home.$$('RowListItem');
const count = await rows.length;
const first = rows.get(0);
await rows.toHaveLength(3);
await rows.toHaveText(['Featured', 'Recently Added', 'Popular']);
const titles = await rows.map(async (el) => el.getText());
const visible = await rows.filter(async (el) => el.isDisplayed()); Stability & Loading
Wait for the UI to stop changing before proceeding.
// Default: wait until the UI tree stops changing
await tv.waitForStable();
// App-specific: custom stability definition
await tv.waitForStable({
indicators: ['BusySpinner', 'LoadingIndicator'],
trackedAttributes: ['focused', 'text', 'visible', 'opacity'],
settleCount: 2,
timeout: 15000,
}); App State
const state = await tv.getAppState('dev');
// 'foreground' | 'not-running' | 'not-installed'
await tv.waitForAppState('dev', 'foreground'); focusPath
Chainable builder for verifying D-pad spatial navigation. Runs every step and collects all failures instead of stopping at the first one.
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); When steps fail, you get clear output:
Step 1: After pressing RIGHT, expected focus on [title="featured-item-2"]
but found focus on RenderableNode[title="featured-item-1"] Page Objects
BasePage and BaseComponent provide the same structure used in production Roku test suites. If you're migrating from Appium, this is the API you want.
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(); }
}
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();
}
} Visual Replay Debugger
Pass { record: true } to focusPath to capture screenshots and UI tree snapshots at each step. Outputs a self-contained HTML file.
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');
} Log Capture
Stream and parse BrightScript console output during test runs.
await tv.startLogCapture();
await tv.launchApp('dev');
if (tv.hasErrors()) console.log('Errors:', tv.logs.errors);
if (tv.hasCrashes()) console.log('Crashes:', tv.logs.crashes);
const summary = tv.getLogSummary();
const networkErrors = tv.logs.filter({ file: 'NetworkTask.brs' });
tv.stopLogCapture(); Mock Server
Use @danecodes/roku-mock for deterministic test data.
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) => {
await device.sendInput({ apiBaseUrl: srv.baseUrl });
},
});
beforeEach(async () => {
await mock.setup(device);
mock.activateScenario('premiumUser');
});
it('loads profile', async () => {
await device.launchApp('dev');
expect(mock.requestCount('/v1/profile')).toBeGreaterThan(0);
}); Multi-Device Parallel Testing
import { DevicePool } from '@danecodes/uncle-jesse-core';
import { RokuAdapter } from '@danecodes/uncle-jesse-roku';
import { poolTest as test } from '@danecodes/uncle-jesse-test';
const devices = [
new RokuAdapter({ name: 'roku-1', ip: '192.168.1.50' }),
new RokuAdapter({ name: 'roku-2', ip: '192.168.1.51' }),
];
for (const d of devices) await d.connect();
setDevicePool(new DevicePool(devices));
test('navigate grid', async ({ tv }) => {
await tv.launchApp('dev');
// tv acquired from pool, released after test
}); Registry State
Inject registry state before launching to skip onboarding or configure preferences.
import { RegistryState } from '@danecodes/uncle-jesse-core';
const registry = RegistryState.skipOnboarding();
await tv.launchApp('dev', registry.toLaunchParams());
// Or build custom state
const custom = new RegistryState()
.set('MY_APP', 'isFirstLaunch', 'false')
.set('SETTINGS', 'subtitleLanguage', 'en');
await tv.launchApp('dev', custom.toLaunchParams()); Deep Linking
await tv.deepLink('dev', 'content-123', 'movie');
// Waits for target app to become active Touch Input
await tv.touch(640, 360); // tap center
await tv.touch(100, 200, 'down'); // touch down
await tv.touch(200, 200, 'move'); // drag
await tv.touch(200, 200, 'up'); // release ECP Input Events
await tv.sendInput({ command: 'pause', type: 'transport' });
await tv.sendInput({ command: 'seek', type: 'transport',
direction: 'forward', duration: 30 }); File Operations (ODC)
Requires @danecodes/roku-odc and an app with the ODC component.
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:/'); CTRF Reporting
import { CtrfReporter } from 'uncle-jesse';
const reporter = new CtrfReporter({
deviceName: 'Roku Ultra',
appName: 'MyApp',
appVersion: '2.0.0',
buildId: process.env.BUILD_ID,
outputDir: './test-results',
});
reporter.save(); // writes ctrf-report.json CLI
npx uncle-jesse test
npx uncle-jesse test --reporter junit
npx uncle-jesse test --reporter ctrf
npx uncle-jesse test --watch
npx uncle-jesse discover
npx uncle-jesse sideload ./build.zip --ip 192.168.1.100