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.

PatternExampleMatches
Tag nameRowListElements with that tag
ID#screenTitleElement with name="screenTitle"
Tag + IDLabel#screenTitleLabel with that name
DescendantHomeScreen RowListRowList anywhere inside HomeScreen
ChildLayoutGroup > LabelDirect child only
Attribute[focused="true"]Element with that attribute value
Tag + attrLabel[text="Home"]Label with text="Home"
Adjacent siblingModule + ModuleModule preceded by another
nth-childNavTab: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