wdio-roku-service
v2.1.0
Published
WebdriverIO service to facilitate Roku testing
Readme
wdio-roku-service
This service overrides many parts of WebdriverIO to allow them to be used with Roku apps and provides access to the Roku ECP to control the Roku during testing.
Requirements
Roku
A test channel/channel.zip and a Roku device (with Developer Mode enabled) on the same network as your mac.
WebdriverIO
This is not a standalone product -- it is used as a WebdriverIO test framework plugin (or Service, in their vernacular). Before using this, you should go through the setup for WDIO by running npm init wdio@latest.
When going through the setup steps, so you don't have to navigate all the questions/options, you can just choose the following selections during the init phase:
- Roku Testing (NOTE: Use this if your repo will only be used for Roku testing as it will become the default and only service installed. Otherwise, use E2E Testing so you can install multiple services.)
- On my local machine (E2E only)
- Web (E2E only)
- Chrome (E2E only)
- Mocha
- Typescript [modules works for TS and JS, so choose whichever]
- autogenerate some test files (Y) -- default location
- page objects (Y) -- default location
- spec reporter
- additional plugins (N)
- Visual Testing (N)
- services (roku)
- npm install (Y)
Typescript Config
If you want to use Typescript for writing tests, you'll need to ensure the following options are set in the tsconfig.json file generated by Webdriverio.
"moduleResolution": "nodenext",
"module": "NodeNext",You can then use the service by importing into your tests as detailed below.
WDIO Config
Currently, testing is only supported for a single Roku device. The following config updates are required:
maxInstancesandmaxInstancesPerCapabilityshould be 1. Testing on multiple devices automatically isn't supported and will result in duplicated commands getting sent to the Roku. There should only be a single capability.
//wdio.conf.js
export const config: WebdriverIO.Config = {
maxInstances: 1,
capabilities: [{
browserName: 'chrome'
// or if you want headless mode:
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--disable-gpu']
}
}],
//...
}- It's recommended to increase the
waitforIntervalandwaitforTimeout, as each interval involves downloading the xml from the Roku. To get more out of thebrowser.debug()feature, you may also opt to extend your mocha testrunner timeout to 5+ minutes for development room.
//wdio.conf.js
export const config: WebdriverIO.Config = {
waitforTimeout: 30000,
//optional:
mochaOpts: {
ui: 'bdd',
timeout: 600000
},
//...
}You're ready to write your first test!
import { installFromZip } from 'wdio-roku-service/install'
import { exitChannel } from 'wdio-roku-service/channel'
import { Buttons, keyPress, keySequence } from 'wdio-roku-service/controller'
describe('first test', () => {
before('On the landing screen of the test channel', async () => {
await installFromZip(process.env.ROKU_APP_PATH)
})
it('should launch to the homescreen without login', async () => {
await $("//LoadingIndicator").waitForDisplayed({ reverse: true })
await expect($("//ContentCarousel")).toBeDisplayed()
})
after('should return to home', async () => {
await exitChannel()
})
})
It's also encouraged that you make use of the browser.debug() feature in wdio to halt your test for debugging and test authoring:
// ...
it('should launch to the homescreen without login', async () => {
await $("//LoadingIndicator").waitForDisplayed({ reverse: true })
await expect($("//ContentCarousel")).toBeDisplayed()
await browser.debug()
// the test halts, a REPL becomes available for commands
If chrome is not headless, you can see the last time that openRokuXML() was called (likely through a waitForX or expect). using the REPL in your terminal, you can make use of any valid $ commands, and a couple key custom ones added (browser.openRokuXML() and browser.saveScreenshot('path/to/ss.jpg')) -- the controller class is not attached to the browser object, so you can't currently use those. Luckily, you're probably sitting next to the Roku and have a remote you can use to navigate and occasionally call browser.openRokuXML() to see what happened to the page state! And remember that XML works natively with xpathing in the chrome browser itself, so you can evaluate/develop your selectors directly in the chrome console during debug.
.env
See the .env.example file. Copy it and rename it to .env within your WebdriverIO project that uses this service. You will probably want to put it in your .gitignore as well.
ROKU_IPshould be the IP of your Roku. The commands will use this IP to communicate with it. This is required.ROKU_USERandROKU_PW: Login credentials are needed to install an archive, as well as for taking screenshots.ROKU_APP_PATHshould be the absolute path of Roku channel zip file.ROKU_CHANNEL_IDshould be channel ID of your Roku channel (this is usually "dev).DEBUG=wdio-roku-servicewill enable debug messages. Remove the '#' at the start of the line if you want those.
Changed Functions
Browser
waitUntilwill fetch the xml from the Roku at each iteration to check for changes.saveScreenshotwill download a screenshot of the current screen from the Roku. Notably, these screenshots are in .jpg format, rather than the .png that WebdriverIO usually uses.openRokuXMLwill fetch the xml from the Roku if you need to do it manually rather than with waits.
Elements
- All waits are supported in the same way as Browser.
waitForClickableis mapped towaitForDisplayed, andwaitForStableis mapped towaitForExist. click,doubleClick, andmoveToaren't supported. You have to manually navigate the app.isFocusedwill check for an attributefocusedon the element being true.isDisplayedwill check for an attributeboundson the element, and thatvisibleis not set to false. IfwithinViewportis set, the bounds will be compared against the Roku's screen size.getSizeandgetLocationtake the values from theboundsattribute, returning 0 for size and -Infinity for position if it isn't present.
Other functions have not been changed, but many still work as expected.
Matchers
Most matchers have been updated to fetch the xml while waiting. Some have slightly different functionality.
toBeDisplayed,toBeDisplayedInViewport,toBeFocused,toBeExisting,toBePresent,toExist,toHaveSize,toHaveWidth,toHaveHeight, andtoHaveAttributeall work as expected, with the changes to Element considered.toHaveElementPropertyis mapped totoHaveAttribute.toHaveElementClasschecks thenameattribute of the element.toHaveIdis mapped totoHaveElementClass.toHaveTextchecks thetextattribute of the element.toHaveChildrenchecks thechildrenattribute of the element.toHaveHTMLwill treat the xml as if it were HTML, though is likely not very useful.
The following are not currently supported:
toBeSelected- Could be supported soon after determining what the xml for selected buttons look like, if there's a difference.toBeChecked- Could be supported soon after determining what the xml for checked checkboxes look like, if there's a difference.toHaveComputedLabel- If you have an equivalent of this on your Roku elements, check the attribute withtoHaveAttribute.toHaveComputedRole- If you have an equivalent of this on your Roku elements, check the attribute withtoHaveAttribute.toHaveHref- If you have URLs on your Roku elements, check the attribute withtoHaveAttribute.toHaveStyle- The xml elements don't have styles.toHaveClipboardText- This isn't known.toHaveTitle- The title will be the randomly generated temporary filename of the xml.toHaveUrl- The URL will be the path to the xml file on your computer.
Usage
Channel Installation
This requires your channel to have an assigned ID.
import { installByID } from 'wdio-roku-service/install';
async before() {
await installByID(process.env.ROKU_CHANNEL_ID);
}Archive Installation
It's recommended to store the path in the .env, especially if you have multiple developers who might have different locations and/or file names.
import { installFromZip } from 'wdio-roku-service/install';
async before() {
await installFromZip(process.env.ROKU_ARCHIVE_PATH);
}Pre-Installed Channel
If you've already installed the channel yourself prior to testing, you can simply launch it.
import { launchChannel, exitChannel } from 'wdio-roku-service/channel';
async before() {
// Close the channel if it's already open. If the channel supports instant resume, this will merely background it
await exitChannel();
// Using the channel ID of 'dev' will launch the sideloaded application.
await launchChannel('dev');
}Testing
wdio-roku-service/controller provides the ability to send button presses to the Roku. keySequence is the main one, sending several button presses in sequence.
import { Buttons, keySequence } from 'wdio-roku-service/controller';
// Navigate through the app
await keySequence(Buttons.LEFT, Buttons.LEFT, Buttons.SELECT, Buttons.DOWN, Buttons.SELECT);
// Fetch the current app UI from the Roku and load it into the browser
await browser.openRokuXML();
// Or, use waits, which will repeatedly load the XML until it times out or the condition passes
await browser.waitUntil(condition);
await element.waitForDisplayed();
// use WDIO matchers on the roku XML as if it was a webpage
await expect(element).toHaveAttr('focused');wdio-roku-service/controller also has functions for holding or releasing buttons as well as typing text into a keyboard.
import { Buttons, keyboardInput, keyPress, keySequence } from 'wdio-roku-service/controller';
await keySequence(Buttons.DOWN, Buttons.DOWN, Buttons.SELECT);
await keyboardInput('example');
await keyPress(Buttons.ENTER);
await browser.openRokuXML();Deeplinking
wdio-roku-service/channel provides channel-related functionality for deeplinking. Use deeplink to launch an app with deeplink parameters, or inputDeeplink to send deeplink parameters to an already-running app.
import { exitChannel, deeplink, inputDeeplink, MediaType } from 'wdio-roku-service/channel';
// Launch app with deeplink
await exitChannel();
await deeplink(process.env.ROKU_CHANNEL_ID, '1234567', MediaType.MOVIE);
// Send deeplink to already-running app
await inputDeeplink(anotherContent, MediaType.EPISODE);inputChannel allows you to send arbitrary custom parameters to your already-running app.
import { inputChannel } from 'wdio-roku-service/channel';
await inputChannel({customEvent: 'refresh', userId: '12345'});Other Functions
wdio-roku-service/info provides miscellaneous functionality, such as getting the app icon or orphaned nodes.
import { getAppIcon } from 'wdio-roku-service/info';
const response = await getAppIcon(process.env.ROKU_CHANNEL_ID);
expect(response.headers.get('Content-Type')).toBe('image/jpg');wdio-roku-service/ecp is the direct interface with the ECP if you need to do anything highly specific.
import { ECP } from 'wdio-roku-service/ecp';
await ECP('search/browse?keyword=voyage&type=movie&tmsid=MV000058030000', 'POST');Telnet Log Capture
wdio-roku-service/telnet provides the ability to capture debug logs from your Roku device via telnet (port 8085). This is useful for capturing BrightScript print statements and debug output during test execution.
Use connectTelnet for quick setup. Use new RokuTelnetLogger() + connect() when you need explicit lifecycle control.
Quick test setup (convenience method):
import { connectTelnet } from 'wdio-roku-service/telnet';
import { launchChannel } from 'wdio-roku-service/channel';
describe('App Logging (quick setup)', () => {
let logger;
before(async () => {
logger = await connectTelnet({ host: process.env.ROKU_IP });
});
after(async () => {
await logger.disconnect();
});
it('waits for startup log', async () => {
// Start waiting before the action that emits the log.
const waitForInit = logger.waitForLog(/\[INFO\] App initialized/, 10000);
await launchChannel('dev');
const line = await waitForInit;
expect(line).toContain('App initialized');
});
});Larger suite setup (manual lifecycle control):
import { RokuTelnetLogger } from 'wdio-roku-service/telnet';
import { launchChannel } from 'wdio-roku-service/channel';
describe('App Logging', () => {
let logger;
before(async () => {
logger = new RokuTelnetLogger({ host: process.env.ROKU_IP });
await logger.connect();
});
beforeEach(() => {
// Isolate captured logs per test while reusing one connection.
logger.startCapture();
});
after(async () => {
await logger.disconnect();
});
afterEach(async function () {
const captured = await logger.stopCapture();
if (this.currentTest?.state === 'failed') {
console.log(captured);
}
});
it('should log startup message', async () => {
const waitForInit = logger.waitForLog(/\[INFO\] App initialized/, 10000);
await launchChannel('dev');
const startupLog = await waitForInit;
expect(startupLog).toContain('App initialized');
});
it('can stream and filter live logs', async () => {
const stream = logger.createLogStream();
stream.on('data', (line) => {
if (line.includes('AppMeasurement')) {
console.log('Beacon:', line.trim());
}
});
// ... actions that produce logs ...
stream.destroy();
});
});Lifecycle guidance:
- In most suites, connect once in
beforeand disconnect once inafter. - Use
startCapture/stopCaptureor a per-test stream to keep logs isolated without reconnecting every test. - Reconnect per test only if you explicitly need test-level connection isolation.
Stream-Per-Test Pattern (Recommended)
This pattern keeps one logger connected across all tests, creates a fresh stream for each test, and writes logs to an artifact file only on failure.
Important:
onPrepareruns in the WDIO launcher process, which is a separate Node.js process from the workers where your tests execute. Module-level state (like a telnet logger instance) created inonPrepareis not shared with workers. Always initialize telnet inbefore(which runs in the worker).
Helper module (helpers/telnet-manager.ts):
import { RokuTelnetLogger } from 'wdio-roku-service/telnet';
import { PassThrough } from 'stream';
let logger: RokuTelnetLogger | null = null;
let currentStream: PassThrough | null = null;
let streamData: string[] = [];
export async function initTelnetLogger() {
if (!logger) {
logger = new RokuTelnetLogger({ host: process.env.ROKU_IP });
await logger.connect();
}
}
export async function teardownTelnetLogger() {
if (logger) {
await logger.disconnect();
logger = null;
}
}
export function startTestStream() {
if (!logger) throw new Error('Logger not initialized');
if (currentStream) {
currentStream.destroy();
currentStream = null;
}
streamData = [];
currentStream = logger.createLogStream();
currentStream.on('data', (chunk: Buffer | string) => {
const line = chunk.toString();
streamData.push(line);
});
}
export async function stopTestStream(filePath?: string): Promise<string[]> {
if (currentStream) {
currentStream.destroy();
currentStream = null;
}
const data = streamData;
streamData = [];
if (filePath && data.length > 0) {
const fs = await import('fs/promises');
const path = await import('path');
const content = data.join('\n');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
}
return data;
}
export function getLogger() {
if (!logger) throw new Error('Logger not initialized');
return logger;
}
export function getCurrentStreamData(): string[] {
return [...streamData];
}Config integration (wdio.conf.ts):
import { initTelnetLogger, teardownTelnetLogger, startTestStream, stopTestStream, getLogger } from './helpers/telnet-manager.js';
export const config: WebdriverIO.Config = {
// ... other config ...
// onPrepare runs in the launcher process — use it for sideloading/installation only.
// Do NOT initialize telnet here; the worker cannot access launcher-process state.
onPrepare: async () => {
await installFromZip(process.env.ROKU_APP_PATH);
},
// before runs in the worker process — initialize telnet here.
before: [
async () => {
await initTelnetLogger();
startTestStream();
// Re-launch channel after stream starts to capture startup logs
await ECP('launch/dev', 'POST');
}
],
// Ensure capture is active before each test (restart if previous afterTest stopped it)
beforeTest: [
(test) => {
if (!getLogger().capturing) {
startTestStream();
}
}
],
afterTest: [
async (test, context, result) => {
const filePath = !result.passed ? `./telnet-logs/${test.title}-telnet.log` : undefined;
const logs = await stopTestStream(filePath);
// Write artifact only on failure
if (!result.passed && logs.length > 0 && filePath) {
console.log(`Telnet logs written to: ${filePath}`);
}
}
],
after: [
async () => {
await teardownTelnetLogger();
}
],
// ... rest of config ...
};With this setup, every test automatically gets telnet log capture and failure artifacts — no test-file code needed. Tests that need to make assertions on log content can import getCurrentStreamData or createLogAnalyzer explicitly.
Attaching Telnet Logs to Reports
If you use a reporter like Allure, you can attach telnet logs (and other artifacts) to the test report on failure:
import { addAttachment } from '@wdio/allure-reporter';
// In afterTest:
afterTest: [
async (test, context, result) => {
const filePath = !result.passed ? `./telnet-logs/${test.title}-telnet.log` : undefined;
const logs = await stopTestStream(filePath);
if (!result.passed && logs.length > 0) {
// Attach telnet logs to the Allure report
addAttachment(`${test.title}-telnet.log`, logs.join('\n'), 'text/plain');
}
}
],In your test file:
import { getLogger } from '../helpers/telnet-manager.js';
import { launchChannel } from 'wdio-roku-service/channel';
describe('App with log assertions', () => {
it('waits for and extracts startup logs', async () => {
const logger = getLogger();
// Start waiting before triggering the action
const waitForInit = logger.waitForLog(/\[INFO\] App initialized/, 10000);
await launchChannel('dev');
const line = await waitForInit;
expect(line).toContain('App initialized');
});
});Structured Parsing
Built-in parsing in this package is focused on Roku OS signal beacons (for example [beacon.signal] |AppLaunchComplete ...).
For app-specific logs (analytics, SDK output, custom business events), define custom parsers in your WDIO project and register them with the analyzer.
Structured parsing with built-in Roku beacon parser plus a custom app parser:
import { createLogAnalyzer, type LogParser } from 'wdio-roku-service/logs';
import { getCurrentStreamData } from '../helpers/telnet-manager.js';
// Example custom parser defined in your WDIO project (not built into this package).
const mparticleLikeParser: LogParser<{ eventName: string }> = {
id: 'custom-analytics',
domain: 'analytics',
matches: (line) => line.includes('mParticle SDK') || line.includes('Logging message:'),
parse: (line) => {
const match = line.match(/"n":"([^"]+)"/);
if (!match) return null;
return {
parserId: 'custom-analytics',
domain: 'analytics',
type: 'analytics-event',
raw: line,
data: { eventName: match[1] },
};
},
};
describe('Launch telemetry', () => {
it('captures beacon signals and validates a custom analytics event', async () => {
const analyzer = createLogAnalyzer();
analyzer.registerParser(mparticleLikeParser);
// Perform actions that produce telnet output (navigation, deeplinks, etc.)
// Beacon signals and app logs are captured by the per-test stream automatically.
await Homepage.navigateTo();
await Homepage.isLoaded();
// Parse all lines collected so far for this test.
analyzer.ingestLines(getCurrentStreamData());
// Get all Roku beacons parsed from currentStream.
const allBeacons = analyzer.getEvents({ domain: 'performance' });
expect(allBeacons.length).toBeGreaterThan(0);
// Filter beacons by name.
const launchBeacons = allBeacons.filter((event) => event.type === 'AppLaunchComplete');
expect(launchBeacons.length).toBeGreaterThan(0);
const launchEvent = launchBeacons[0];
expect(launchEvent.data).toMatchObject({ eventName: 'AppLaunchComplete' });
const analyticsEvent = analyzer.findFirst({
domain: 'analytics',
predicate: (event) => {
const data = event.data as { eventName?: string };
return data.eventName === 'screen_viewed';
},
});
expect(analyticsEvent).toBeDefined();
});
});Note: Beacon signals like
AppLaunchCompletefire when the app launches. If your app is sideloaded inonPrepare, those beacons fire before the worker's telnet stream is active. To capture launch beacons, either trigger a re-launch from the worker (e.g.,await ECP('launch/dev', 'POST')) afterstartTestStream, or start telnet inonPreparebeforeinstallFromZipand accept that only the[TELNET RAW]event listener (not per-test streams) will see them.
With this approach:
- One connection per worker/spec file (efficient, minimal flakiness).
- One stream per test (isolated log capture).
- Logs written to artifacts only on failure (minimal disk footprint).
- Built-in structured parsing for Roku signal beacons.
- Custom parser hooks for team-specific logs.
Common Gotchas
- Roku elements have their text in a 'text' attribute, not between their tags. When doing selectors, doing
$('element=Text')won't work for almost every element. Instead, you'll have to do$('element[text=Text]').
Feature Roadmap
- Currently evaluating Socket communication with the Roku such that more features can be tooled, such as a means to wake a sleeping Roku.
- Network proxy feature(s) that allow for keying off of network activity.
Leveraging the Allure Reporting with attached Screenshots and XML files
Out of the box, Allure Reporting does not have a configuration in place to generate screenshots of the app or a copy of the XML code representative of the current state of the Roku app at any point of the test execution. The documentation that follows explains how to address this so that a screenshot of the app's current state is generated and attached to the Allure Report each time an it test completes its run. It also allows to obtain a source snapshot of the XML representative of the current Roku app's state whenever an it test run fails.
For the full documentation on Allure Reporter, please visit @wdio/allure-reporter docs https://webdriver.io/docs/allure-reporter/
Utils.js dependency
Add the following code to a file called Utils.js. This file may live in your /helpers folder or similar.
/**
* Returns a string representation of the 'now' timestamp in milliseconds for the epoch.
*/
export const getEpochTimestamp = async () => {
return Date.now().toString()
}
/**
* Returns a string representation of the 'now' timestamp following the pattern: {YYYY}-{MM}-{DD}_{hour in 24H}-{Minute}-{Second}-{Milliseconds}
*/
export const getLongFormatTimestamp = async () => {
const now = new Date(Date.now())
const result = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}-${now.getMilliseconds()}`
return result
}
/**
* An object containing the string representations of possible file extensions used for reporting purposes.
*/
export const FILE_EXTENSIONS = {
JPG: '.jpg',
XML: '.xml'
}
/**
* An object containing the string representations of possible MIME types used for reporting purposes.
*/
export const FILE_MIME_TYPES = {
JPG: 'image/jpeg',
XML: 'application/xml'
}
/**
* A function to generate a filename with a possible prefix, a timestamp and one of the possible extensions provided.
* @param {string} fileExtension Use one of the values from the FILE_EXTENSIONS object defined previously.
* @param {string} [fileNamePrefix] A prefix to be appended at the beginning of the filename if provided. Defaults to an empty string.
*/
export const getFileNameWithTimestamp = async (fileExtension, fileNamePrefix = '') => {
return (fileNamePrefix !== '')
? `${fileNamePrefix}_${await getLongFormatTimestamp()}${fileExtension}`
: `${await getLongFormatTimestamp()}${fileExtension}`
}
wdio.conf.js code
Add the following import statements on the wdio.conf.js file:
import { readFile, rm } from 'node:fs/promises'
import { addAttachment } from '@wdio/allure-reporter'
import { FILE_EXTENSIONS, FILE_MIME_TYPES, getFileNameWithTimestamp } from './<Utils.js file path>/Utils.js' // Replace <Utils.js file path> with actual relative path to file Utils.js
Define the following afterTest hook on the wdio.conf.js file. If you already have working code in this hook, append the below provided code to it.
afterTest: async function (test, context, result) {
// Screenshot saving and attaching logic regardless of test outcome.
const fileName = await getFileNameWithTimestamp(FILE_EXTENSIONS.JPG)
try {
const tempScreenshotPath = `./allure-results/${fileName}`
await browser.saveScreenshot(tempScreenshotPath)
const screenShotData = await readFile(tempScreenshotPath)
addAttachment(`${fileName}`, screenShotData, FILE_MIME_TYPES.JPG)
await rm(tempScreenshotPath).catch((rmError) => {
console.error(`Failed to remove file: ${tempScreenshotPath}`, rmError)
})
} catch (error) {
console.error('Error handling screenshot or attachment: ', error)
}
// XML attaching logic on test failure.
if (result.passed === false) {
const fileName = await getFileNameWithTimestamp(FILE_EXTENSIONS.XML, 'AppStateAfterTestFail')
const rawSourceString = String(await browser.getPageSource())
const extractedXMLSubstring = '<?xml version="1.0" encoding="UTF-8" ?>\n'.concat(rawSourceString.substring(rawSourceString.search('<app-ui xmlns="">'), rawSourceString.search('</app-ui>')).concat('</app-ui>')).replace('<app-ui xmlns="">', '<app-ui>')
try {
addAttachment(`${fileName}`, extractedXMLSubstring, FILE_MIME_TYPES.XML)
} catch (error) {
console.log(error)
}
}
},Expected behaviour
With this code in place in the project's config, the expectation is that each time an it test is run, regardless of the test's outcome, a screenshot will be taken at the end of the run and attached to its relevant section in the Allure report. In the specific instance of the test failing, a source snapshot of the app's state in XML format will also be attached to the test's section in the Allure report.
Notes
- Out of the box Allure reports support screenshots in
.pngformat. Method overrides in this service support the image in.jpgformat instead. - XML attachments may be browsed in the Allure report itself or opened in a separate tab in a browser.
