system-testing
v1.0.142
Published
System testing with Selenium and browsers.
Maintainers
Readme
System testing
Rails inspired system testing for browser, React Native, and Expo apps.
Install
npm install --save-dev system-testingReleasing
Run patch releases with:
npm run release:patchThe release script now fetches origin, checks out local master, and merges origin/master before it bumps the package version and publishes, so releases start from the newest upstream master.
Choose the right layer
This package has three main entry points:
SystemTest: full app-oriented system testing with selector helpers, app bootstrapping, WebSocket communication, screenshots, logs, and Scoundrel support.Browser: lower-level driver session for opening URLs, taking screenshots, and reading HTML/logs without the rest of the system-test flow.system-testingCLI browser daemon: a long-running named browser process that can be controlled from CLI commands or WebSocket messages.useSystemTest*hooks: browser-side integration that lets your app respond tovisit/dismissTocommands fromSystemTest.
Use SystemTest if you are testing your app. Use Browser if you just want a Selenium/Appium-backed browser session.
Browser-side lifecycle callbacks
The useSystemTest* hooks support three browser-side lifecycle callbacks:
onFirstInitialize: runs only the first time the browser helper initializes in a browser sessiononInitialize: runs before eachSystemTest.run(...)example after the test bridge is readyonTeardown: runs after eachSystemTest.run(...)example, even when the example fails
Use onTeardown for per-example browser cleanup such as clearing auth state or resetting app-local fixtures. Keep destructive cleanup there instead of onInitialize so signed-in flows can still run normally during an example.
Framework-neutral start/stop lifecycle
Use StartStopLifecycle to coordinate an asynchronously started resource outside SystemTest. It keeps startup and shutdown single-flight, exposes idle, starting, running, stopping, and cleanup-failed through state, and does not report running until the startup callback resolves.
import {StartStopLifecycle} from "system-testing"
const lifecycle = new StartStopLifecycle({
start: async ({signal}) => {
await startBackend({signal})
},
stop: async () => {
await stopBackend()
}
})
await lifecycle.ensureRunning()
await lifecycle.stop()start() is an alias for ensureRunning(). Concurrent callers share the active operation. Calling stop() during startup aborts its signal, waits for the startup callback to settle, and runs cleanup once. A start requested during shutdown waits for shutdown before starting. Startup callbacks must observe the signal and settle when aborted; cleanup runs after every failed or aborted startup. If both startup and cleanup fail, the startup promise rejects with an AggregateError containing both failures.
If cleanup rejects, the lifecycle stays in cleanup-failed. New starts reject without invoking the start callback, and a later stop() retries cleanup. The lifecycle returns to idle only after that retry succeeds. Keep the generation passed to the start callback with the owned resource; after an unexpected resource completion is fully proven, notifyResourceStopped(generation) returns the matching running generation to idle. Notifications from stale generations, or notifications received while stopping, are ignored.
Owned Node processes
Use the Node-only OwnedProcess deep import when a test or development helper owns a command and its descendants:
import OwnedProcess from "system-testing/build/owned-process.js"
const ownedProcess = await OwnedProcess.spawn(command, args, {
cwd,
env,
stdout: "inherit",
stderr: "inherit",
killGraceMs: 1000,
forceKillWaitMs: 1000
})
await ownedProcess.stop()
const directChildResult = await ownedProcess.closedclosed reports the owned command's {code, signal, error?} and does not by itself prove that descendants are gone. stop() is single-flight. On supported POSIX platforms an internal IPC-held anchor remains the process-group leader for the full cleanup generation and performs signals against its own current group; the parent never signals a later process through a recycled numeric group ID. identity.pid identifies the owned command, while identity.processGroupId identifies that private anchored group. Shutdown sends SIGTERM, waits through the configured grace period, sends SIGKILL if necessary, and resolves only after command close and exact process-group inspection report no live survivors. Descendants created after shutdown starts remain in the owned group and are included. Terminal zombie entries are not survivors: inspection requires their OS-reported zombie state before excluding them, which avoids claiming a live process is gone when a container init delays reaping an already-dead descendant.
If termination finishes with survivors, stop() rejects with OwnedProcessTerminationError, exact survivingPids, and directChildClosed. Inspection or generation-verification failures reject with OwnedProcessInspectionError. Both retain ownership so a later stop() can retry; after a verified group-wide SIGKILL, retries only observe terminal progress and never signal a possibly reused group. Unsupported platforms reject with OwnedProcessUnsupportedPlatformError before spawning; the API never claims descendant cleanup where it cannot prove it. Spawn failures reject from OwnedProcess.spawn(...), while a process that closes after a successful spawn settles closed. After proven termination, the anchor, internal child/listener references, and exposed pipe references are released.
Getting started
- Add one of the browser-side hooks to your app:
useSystemTestExpofor Expo Router, oruseSystemTest/useSystemTestReactNativefor your own navigation stack. - Wrap your app in a root element with
testID="systemTestingComponent". - Make sure your root test route renders an element with
testID="blankText", or changeSystemTest.rootPath. - Start tests with
SystemTest.run(...)for app flows, or instantiateBrowserdirectly for ordinary browsing/capture.
Minimal app-side requirements:
<View testID="systemTestingComponent" dataSet={{focussed: "true"}}>
<Text testID="blankText">Blank</Text>
{children}
</View>Navigation discipline
App-flow specs should call systemTest.visit(...) once as the initial entry point, then use visible app UI, links, and actions to move around. A second visit, refresh, or direct route jump in one example should have a concrete documented reason, such as exercising an external callback or deep link.
Usage
import retry from "awaitery/build/retry.js"
import SystemTest from "system-testing/src/system-test.js"
import wait from "awaitery/build/wait.js"
import waitFor from "awaitery/build/wait-for.js"
import createUser from "@/src/testing/create-user.js"
import initialize from "@/src/initialize"
import Option from "@/src/models/option"
describe("Sign in page", () => {
test("it navigates to the sign in page and signs in", async () => {
await initialize()
await SystemTest.run(async (systemTest) => {
await createUser(userAttributes)
await systemTest.visit("/")
await systemTest.findByTestID("frontpageScreen", {useBaseSelector: false})
await wait(250)
await retry(async () => {
await systemTest.click("[data-testid='signInButton']")
await systemTest.findByTestID("app/sign-in")
})
await systemTest.interact("[data-testid='signInEmailInput']", "sendKeys", "[email protected]")
await systemTest.interact("[data-testid='signInPasswordInput']", "sendKeys", "password")
const emailInputValue = await systemTest.interact("[data-testid='signInEmailInput']", "getAttribute", "value")
const passwordInputValue = await systemTest.interact("[data-testid='signInPasswordInput']", "getAttribute", "value")
expect(emailInputValue).toEqual("[email protected]")
expect(passwordInputValue).toEqual("password")
await systemTest.click("[data-testid='signInSubmitButton']")
await systemTest.expectNotificationMessage("You were signed in.")
await waitFor(async () => {
const optionUserID = await Option.findBy({key: "userID"})
if (!optionUserID) {
throw new Error("Option for user ID didn't exist")
}
expect(optionUserID.value()).toEqual("805")
})
})
})
})Named steps
Group related commands into named steps with systemTest.step(name, callback) to make failures easier to read. The callback runs unchanged and its return value is passed through; on failure the original error is preserved and annotated with the active step path, and the step is recorded for trace/report tooling. Steps may be nested.
await SystemTest.run(async (systemTest) => {
await systemTest.step("sign in", async () => {
await systemTest.visit("/sign-in")
await systemTest.interact("[data-testid='signInEmailInput']", "sendKeys", "[email protected]")
await systemTest.click("[data-testid='signInSubmitButton']")
await systemTest.expectNotificationMessage("You were signed in.")
})
await systemTest.step("create project", async () => {
// ...
})
})If a command inside a step fails, the thrown error's message gains (in step: sign in) and an
error.systemTestStep property, and the failure screenshot/artifacts record the step. The recorded
boundaries are available via systemTest.getStepHistory() (and systemTest.currentStepPath() for the
active step) for future HTML trace reports.
Driver selection
SystemTest uses Selenium by default. To use Appium instead, pass a driver config when creating the instance.
On Linux, the Selenium driver lazily resolves and caches an exact-version Chrome Headless Shell and ChromeDriver pair when the browser session starts. The default cache is ~/.cache/system-testing/chrome; set SYSTEM_TEST_CHROME_RUNTIME_CACHE_PATH or pass driver.options.chromeRuntimeCachePath to use another location. SYSTEM_TEST_CHROME_BINARY and SYSTEM_TEST_CHROMEDRIVER_PATH take precedence and are validated as an exact pair. Other platforms continue to use Selenium Manager.
appium is an optional peer dependency, so browser-only installs stay lightweight. Install it (plus the Appium drivers you need) when you opt into the Appium driver:
npm install --save-dev appium appium-uiautomator2-driverSelecting {driver: {type: "appium"}} without appium installed throws an error with this instruction. If you point at an external Appium server with serverUrl, the appium package is not needed.
await SystemTest.run({
driver: {
type: "appium",
options: {
serverArgs: {
useDrivers: ["uiautomator2"],
port: 4723
},
capabilities: {
platformName: "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "Android Emulator",
"appium:app": "/path/to/app.apk"
}
}
}
}, async (systemTest) => {
await systemTest.findByTestID("loginScreen")
})If you already run an Appium server, provide serverUrl instead of serverArgs. By default, findByTestID uses the Appium accessibility id strategy, which matches the content-desc attribute on Android (set by React Native's accessibilityLabel prop). To use CSS instead (for web contexts), set options.testIdStrategy to "css" and optionally options.testIdAttribute (defaults to "data-testid"). For native Android apps, set options.testIdStrategy to "id"; system-testing matches React Native test IDs by resource-id suffix so package-qualified Android IDs work.
Native Android test ID lookups can scroll offscreen controls into the rendered hierarchy with findByTestID("target", {scrollTo: true}). If an app has a known scroll container test ID, pass it with scrollContainerTestIDs so system-testing tries that container before falling back to generic viewport gestures:
await systemTest.findByTestID("projectShowScreen/editButton", {
scrollContainerTestIDs: ["projectShowScreen/content/scroll"],
scrollTo: true,
useBaseSelector: false
})Native Android text or accessibility-label assertions can use findByNativeText("Visible label", {scrollTo: true}).
For local or CI web runs against Chrome, npm run test:appium:web now resolves and downloads a matching Chrome for Testing chromedriver binary before it starts Appium. That keeps the Appium web path reproducible even when the installed Chrome patch version changes.
Generic browser usage
Browser is the lower-level browser/session class behind SystemTest. Use it when you want driver-backed browsing, screenshots, logs, and HTML capture without the rest of the system-test bootstrapping.
import {Browser} from "system-testing"
const browser = new Browser()
browser.getDriverAdapter().setBaseUrl("https://example.com")
await browser.getDriverAdapter().start()
await browser.setTimeouts(10000)
await browser.visit("/")
const html = await browser.getHTML()
const logs = await browser.getBrowserLogs()
const screenshot = await browser.takeScreenshot()
await browser.stopDriver()If visit()/dismissTo() should drive in-app navigation through the browser-side helper instead of direct URL loads, inject a communicator when constructing Browser. Without one, it falls back to direct driver navigation, which makes it usable for ordinary website browsing as well.
Common Browser flow:
- Create the browser with the desired driver config.
- Set the base URL on the driver adapter.
- Start the driver and set timeouts.
- Call
visit(). - Read
getHTML(),getBrowserLogs(),getCurrentUrl(), ortakeScreenshot(). - Call
stopDriver()during teardown.
Useful browser methods:
visit(pathOrUrl): uses the helper communicator if present, otherwise loads directly through Selenium/Appium.dismissTo(pathOrUrl): same fallback behavior asvisit().getHTML(): returns the current page source.getBrowserLogs(): returns collected browser logs, or Appium logcat output for Android native runs.takeScreenshot(): writes screenshot, HTML, and logs to disk and returns the artifact paths.
If you want app-level navigation instead of direct URL loads, keep Browser for the driver/session side and use one of the useSystemTest* hooks in the app so the communicator has something to talk to.
react is an optional peer dependency. Install it only in apps that import the React hook helpers; CLI/browser-daemon consumers should not need React just to use system-testing. expo-router is not a package-level peer dependency. Install it only in Expo Router apps that import system-testing/build/expo.js or system-testing/build/use-system-test-expo.js.
Browser daemon CLI
If you want an external agent to drive a reusable browser process, start the browser daemon:
npx system-testing browser my-browserOptional arguments:
--port 1991: use a fixed WebSocket port instead of an ephemeral one--base-url https://example.com: set the browser base URL so relativevisitpaths work--driver selenium|appium: choose the driver type--debug: enable browser debug logging--host 127.0.0.1: bind host. Defaults to127.0.0.1(loopback), so the daemon is only reachable by local processes. Only set this if you intend to expose it.--token SECRET: require a shared token on every command (see below)
The process stays running until you stop it. On start it prints JSON with at least the browser name, pid, and port.
Local-only by default and the optional command token
The daemon exposes powerful commands (executeScript, addCookie, ...), so it binds to 127.0.0.1
by default and is not reachable from other hosts. Override that only deliberately with --host.
For extra protection you can require a shared token. Set it on the daemon and on each command with
--token, or via the SYSTEM_TEST_BROWSER_TOKEN environment variable:
SYSTEM_TEST_BROWSER_TOKEN=secret npx system-testing browser my-browser
SYSTEM_TEST_BROWSER_TOKEN=secret npx system-testing browser-command --name my-browser --visit=https://example.comWhen a token is configured, browser commands without the matching token are rejected
({"ok": false, "error": "Browser daemon command rejected: invalid or missing token"}). The token is
never written to the registry file or printed in logs. When no token is set, commands work as before.
List running browser daemons:
npx system-testing browser-listThis prints one line per browser with the name and port. Use --json if you want machine-readable output.
Stop a running browser daemon:
npx system-testing browser-stop --name my-browserIf only one browser daemon is running, browser-stop can omit --name.
Send commands from the CLI:
npx system-testing browser-command --name my-browser --visit=https://example.com/path
npx system-testing browser-command --name my-browser --find-by-test-id saveButton
npx system-testing browser-command --name my-browser --find-by-test-id saveButton --timeout 15
npx system-testing browser-command --name my-browser --click='[data-testid="saveButton"]'
npx system-testing browser-command --name my-browser --get-html
npx system-testing browser-command --name my-browser --get-browser-logs
npx system-testing browser-command --name my-browser --take-screenshotIf only one browser daemon is running, browser-command can omit --name. Results are printed as JSON so automation tools can parse them easily.
CLI --timeout values are supported on navigation and selector-based commands. Bare numbers are interpreted as seconds, and explicit ms / s suffixes are also accepted.
Generic commands are also supported:
npx system-testing browser-command \
--name my-browser \
--command=interact \
--selector='[data-testid="emailInput"]' \
--method=sendKeys \
--arg='[email protected]'sendKeys uses the driver's normal typing path and never silently falls back to anything. For the rare React Native Web controlled input that does not update from ordinary typing, the js value-setter escape hatch is a first-class interact method — call --method=replaceValueWithJs --arg='[email protected]' in the CLI/browser-command transport, or interact(selector, "replaceValueWithJs", value) in JS. Prefer fixing the input to be uncontrolled instead (see "Clearing inputs" below); the escape hatch, like the flakiness, is a symptom of a controlled input.
The browser daemon is intended for agent-style development workflows where an AI or script needs to open the app, inspect HTML, locate elements, click controls, and read logs while validating layout or behavior changes.
Browser daemon WebSocket protocol
The daemon also accepts WebSocket commands on its configured port. Send JSON payloads like:
{"type":"browser-command","command":"visit","url":"https://example.com/path"}Another example:
{"type":"browser-command","command":"findByTestID","args":{"testID":"saveButton"}}The server responds with JSON:
{"ok":true,"requestId":"...","type":"browser-command-result","result":{"ok":true}}If the command fails:
{"ok":false,"requestId":"...","type":"browser-command-result","error":"..."}Supported daemon commands currently include:
visitdismissTosetBaseSelectorgetCurrentUrlgetHTMLgetBrowserLogstakeScreenshotfindfindByTestIDclickwaitForNoSelectorexpectNoElementinteract
Using useSystemTestExpo in your Expo app
useSystemTestExpo wires your Expo app to the system-testing runner: it listens for WebSocket commands, initializes the browser helper, and lets tests navigate or reset state. Add it near the root layout of your Expo Router app (for example in _layout.tsx or a top-level provider component).
To enable system tests in native builds, set EXPO_PUBLIC_SYSTEM_TEST=true at build time (and optionally EXPO_PUBLIC_SYSTEM_TEST_HOST to reach the test runner from a device/emulator). For native Appium runs, set SYSTEM_TEST_HOST=native in the test environment and point Appium at your APK.
Minimal example:
import {Stack} from "expo-router"
import {useSystemTestExpo} from "system-testing/expo"
export default function RootLayout() {
const {enabled, systemTestBrowserHelper} = useSystemTestExpo({
// Optional: inject your own helper instance instead of using the shared default
// browserHelper: mySystemTestBrowserHelper,
onFirstInitialize: () => {
// One-time setup the first time the helper initializes
},
onInitialize: () => {
// Reset any app state before each test run
}
})
// Optionally register classes for remote eval once scoundrel is ready
// useEffect(() => {
// if (systemTestBrowserHelper) {
// systemTestBrowserHelper.getScoundrel().registerClass("MyModel", MyModel)
// }
// }, [systemTestBrowserHelper])
return (
<Stack screenOptions={{headerShown: false}}>
<Stack.Screen name="(tabs)" />
</Stack>
)
}Notes:
- The hook auto-connects when the page is opened with
?systemTest=true(as the runner does). - Pass
browserHelperif you want to inject a prebuiltSystemTestBrowserHelper; otherwise the hook creates and enables a shared default instance. onFirstInitializeruns only on the firstinitializecommand; use it for one-time setup.onInitializeis registered once when the helper is ready, but it runs on everyinitializecommand (eachSystemTest.run); use it to reset globals/session.- If you need scoundrel remote evaluation, wait for
systemTestBrowserHelperand register your classes there, as shown in the commented snippet above. - Add a root wrapper with
testID="systemTestingComponent"(and optionallydata-focussed="true") around your app so the runner has a stable element to detect and scope selectors against. - From your tests, use
await systemTest.getScoundrelClient()to obtain the browser Scoundrel client for remote evaluation. useSystemTestExpocallsuseRouter()fromexpo-router.
Using useSystemTest or useSystemTestReactNative without Expo Router
useSystemTest is the generic runtime-agnostic hook. Provide onNavigate and onDismissTo callbacks for your own navigation stack. useSystemTestReactNative is a convenience wrapper around the same generic API for non-Expo React Native apps.
Use these when:
- you are not using Expo Router
- you want to inject your own navigation behavior
- you want to share the same app-side helper integration across different routing setups
import useSystemTestReactNative from "system-testing/build/use-system-test-react-native.js"
export default function App({navigation}) {
useSystemTestReactNative({
onDismissTo: ({path}) => {
navigation.reset({
index: 0,
routes: [{name: path}]
})
},
onNavigate: ({path}) => {
navigation.navigate(path)
}
})
return <Navigator />
}The generic hook options are:
browserHelper: inject an existingSystemTestBrowserHelperinstance instead of using the shared defaultonFirstInitialize: one-time setup callbackonInitialize: callback that runs on everyinitializecommandonNavigate: handler forvisit(...)onDismissTo: handler fordismissTo(...)
Root path and blankText
SystemTest.run() visits SystemTest.rootPath (defaults to /blank?systemTest=true) and waits for an element with testID="blankText" inside the focused systemTestingComponent. If your app does not have a /blank route, set a custom root path and ensure the element exists on that screen.
Example setup:
import SystemTest from "system-testing/build/system-test.js"
SystemTest.rootPath = "/?platform=web&systemTest=true"<View testID="systemTestingComponent" dataSet={{focussed: "true"}}>
<Text testID="blankText">Blank</Text>
{children}
</View>Base selector and focused container
System tests scope selectors to the active screen by default. The app marks the active layout container with data-focussed="true" on the element with data-testid="systemTestingComponent". In the dummy app, the root layout wraps the navigator and sets data-focussed="true" once so the base selector stays stable across screens.
SystemTest.find and SystemTest.findByTestID use a base selector that targets the focused container:
[data-testid='systemTestingComponent'][data-focussed='true']This prevents tests from matching elements on inactive or background screens.
When to bypass base selector:
Some UI (modals, overlays, portals) can render outside the focused container. For those cases, use useBaseSelector: false so the selector is not scoped:
await systemTest.findByTestID("scannerModeExitPinInput", {useBaseSelector: false})Use useBaseSelector: false only for modal or overlay content. Keep the default scoping for regular screens to avoid false matches.
Finder options
Most selector helpers accept the same options:
timeout(number): override how long the lookup should wait.visible(boolean|null): require elements to be visible (true) or hidden (false), or disable visibility filtering withnull.useBaseSelector(boolean): scope the selector to the focused container.
These options are supported by find, findByTestID, and all. click also accepts the same options when a selector string is used:
await systemTest.click("[data-testid='signInButton']", {useBaseSelector: false, visible: true})findByTestID remains strict and rejects when more than one visible element has the same test ID. For UI transitions that intentionally retain duplicate test IDs, findFirstVisibleByTestID(testID, {timeout, useBaseSelector}) waits for visible matches and returns the first one in driver order. Visibility is always required; other finder and interaction options are rejected.
const activeScreen = await systemTest.findFirstVisibleByTestID("accountScreen", {timeout: 15000})interact supports a selector object so you can pass finder options inline:
await systemTest.interact({selector: "[data-testid='scanFooterMenuButton']", useBaseSelector: false}, "click")For clicks, prefer click(selector, options). interact(selector, "click", options) is rejected because the third argument is reserved for element-method arguments and cannot carry finder or click options.
Notification assertions
expectNotificationMessage(message, {timeout, dismiss}) polls visible [data-testid='notification-message'] elements without applying the global finder wait to every poll. timeout is one positive total budget for finding the message and, when dismiss is enabled, waiting for that specific notification to disappear. Bounded WebDriver cleanup may finish after that budget before the assertion returns. The timeout defaults to 5000 ms and rejects non-positive values. dismiss defaults to true.
Notification detection, dismissal and disappearance own their WebDriver commands through that deadline. Commands already sent to Selenium are observed through settlement; a timer does not cancel them. After ownership expires, retained elements cannot issue another command. An outstanding command when the operation times out or fails marks the session unusable; a settled sequence that cumulatively crosses the deadline, or an idle poll waiting for a missing/wrong message, does not by itself make the session terminal. Disappearance retains the finder's existing bounded cleanup/outcome ownership while guarding subsequent commands.
Dispatch checks the deadline again after preparing command metadata. Rejected commands that never reach the executor do not appear as pending wire work. Implicit-wait restoration has a separate, fixed cleanup owner using its existing one-second bound; it can finish after the application deadline only when no application command remains pending and the same session/change still owns restoration. The operation awaits this cleanup while rejecting subsequent application commands. Failed restoration makes the session terminal, including a settled refusal; an outgoing lookup error retains its identity, stack and cause and also receives the terminal marker. Cleanup diagnostics use the :implicit-timeout-restoration operation suffix and retain late settlement independently of the application operation.
Terminal failures carry error.terminalResource = {scope: "run", name: "webdriver-session"}. SystemTest.run(...) preserves the original failure before artifact collection and never automatically reinitializes a terminal session. Screenshot and teardown failures are retained in an AggregateError whose cause is the original failure. Runners must recognize this marker recursively through cause and AggregateError.errors, stop retries and later callbacks, report remaining cases as not run, perform cleanup once, and return an unsuccessful run. Older runners that ignore the marker must be upgraded together with this lifecycle change; the package does not depend on a particular runner.
Failed notification operations emit [WebDriver operation] JSON metadata: a local random session correlation (not the Selenium session id), operation name, bounded origin stack, deadline, pending command sequences, and the latest 32 command issue/settlement records. Late settlements emit another record without issuing new browser work. Command parameters, scripts, results, URLs and notification text are excluded. This evidence distinguishes pending work from a cumulative deadline crossing; it does not establish why ChromeDriver stalled. Primary errors and their full stacks/causes remain available separately.
Web startup root/component lookups use the same command ownership and diagnostics (startup-root and startup-component) with their existing finder and cleanup budgets. A successful lookup followed by terminal implicit-timeout restoration fails before startup advances. Both web and native startup preserve a lookup failure if its screenshot also fails: the resulting AggregateError.cause and first errors entry hold the original lookup error, and the second entry holds the screenshot error. A successful screenshot leaves the original error unchanged.
Default-checks startup evidence
The repository's Default checks enable SYSTEM_TEST_STARTUP_DIAGNOSTICS=true in their existing runner. The shared Jasmine helper observes Selenium startup through its normal success/failure and failed-start cleanup. Appium and ordinary consumers do not enable this collection. For one focused local acquisition, use the normal dist/Selenium prerequisites and add the same environment variable to the selected Jasmine command.
The helper writes spec/dummy/tmp/startup-diagnostics/selenium.json, included in CI artifacts. On failure it also prints a bounded [Selenium startup diagnostics] JSON snapshot before cleanup, then a second snapshot after cleanup completes or fails. Thus a stuck quit cannot erase the initial evidence. Successful startup writes the artifact without printing diagnostic events. Artifact-write failures are reported separately; they do not replace the originating startup/cleanup errors or their stacks/causes.
Interpret the observations together:
webdriver-requestmeans Selenium constructed an HTTP request; it does not prove socket delivery or receipt by the driver.webdriver-responserecords its HTTP status and local request sequence.driver-commandanddriver-responsecome from the managed ChromeDriver's INFO command headers. They supply independent receipt/settlement evidence.processSequencecorrelates these with that service's spawn/exit; runtime--versionprobes are excluded. A response may classify a crash, disconnect or driver error without retaining its message payload.app-requestandapp-responserecord delivery/completion and HTTP status for the owned dist server, using resource categories instead of URLs. A request with no completion remains in the bounded pending observations.- Runtime capabilities supply browser/driver versions and page-load strategy without extra WebDriver commands. Node and installed dependency versions, plus SHA-256 hashes of the exported index/blank documents and JavaScript, identify the local artifacts actually used.
Collection retains at most 128 recent events, 32 pending observations, eight process candidates and 512 characters of a driver-line prefix while parsing. Only allowlisted fields are persisted: no session/element IDs, argv, request/response bodies, scripts, cookies, capability payloads or URLs. Provenance hashes at most eight JavaScript files plus the two documents, each at most 8 MiB; larger/missing inputs are reported as truncated/unavailable. dropped denotes incomplete event coverage, so missing receipt evidence alone is not proof of a command never reaching ChromeDriver.
The collector uses Selenium's public logger/service builder and Node's documented diagnostics channels and process streams; it does not replace dependency methods. The active startup collector configures only its managed service's INFO output into consumed pipes. On startup completion, logger settings are restored, observers are removed, and the pipes remain drained without parsing or retention until normal owner teardown. Pipes add no event-loop ownership. Collection never queries a poisoned session, changes a deadline, retries a command, restarts a browser or changes teardown ownership. This is diagnostic hardening, not a demonstrated fix for the CI startup stall or the original TensorBuzz notification stall.
await systemTest.expectNotificationMessage("You were signed in.", {timeout: 1000})notificationMessages() returns the messages currently rendered without waiting for one to appear. dismissNotificationMessages() likewise takes an immediate snapshot, dismisses those notifications, and waits for them to disappear.
Clearing and filling inputs
Three orthogonal methods handle input values. Each defaults to a fast strategy; the slow keyboard and JS strategies are explicit opt-in overrides.
clear(target, {strategy, keyDelay})— empties the field.fill(target, value, {strategy, keyDelay})— entersvaluewithout clearing first (pure fill), then verifies the value landed, retrying a bounded number of times before throwing.clearAndFill(target, value, {clearStrategy, fillStrategy, keyDelay})—clear()thenfill(); the common replace-the-value flow.replaceTestIDInputValue(testID, value)targets a field by test id and usesclearAndFill.
Root cause first — flaky clearing/typing is a controlled-input smell. If a field needs a non-default strategy or DOM value injection to hold what you typed, the real bug is almost always a controlled input (a
valueprop fed from React state). Typing into it can drop or reorder characters for real users too, not just in tests. The fix is an uncontrolled input:defaultValue+onChangeTextwriting into your form state, with novalueprop. The non-native strategies below are escape hatches for when you cannot fix the input right now — reach for the uncontrolled input first.
clear strategies (strategy, default "native"):
"native"(default) — a normal Seleniumelement.clear(). Use this everywhere."js"— sets the value to""through a DOM prototype setter plusinput/changeevents. The only strategy that reliably drives React Native Web controlled-input state, kept as an escape hatch."backspace-keys"— deliberately slow, opt-in per-character clear: caret-safe on both sides (one BACK_SPACE per character before the caret, one DELETE per remaining character after it — the focusing click can land the caret anywhere, e.g. mid-line in a multiline textarea), re-reading the residual each pass and recovering from dropped keystrokes / a focus click that never landed. Never make this the default — it is intentionally very slow."delete-keys"— the forward-delete-first variant ofbackspace-keys.
fill strategies (strategy, default "native"):
"native"(default) — one fast whole-stringelement.sendKeys(value)with real keyboard events (not a per-character loop; the per-character loop was itself a controlled-input workaround)."js"— sets the value directly through a DOM prototype setter plusinput/changeevents (the RNW controlled-input escape hatch; also reachable as thereplaceValueWithJsinteract method)."per-character"— deliberately slow, opt-in char-by-char typing.
keyDelay (ms, default 0 — no artificial delay) pauses between individual key presses for the key-based clear strategies and the per-character fill. Like those strategies, it is a controlled-input band-aid knob, not a fix.
// Default: native clear + one whole-string sendKeys, verified. Use this.
await systemTest.clearAndFill("[data-testid='ticketCountInput']", "20")
await systemTest.replaceTestIDInputValue("ticketCountInput", "20")
// Just clear, or just fill (fill does not clear first):
await systemTest.clear("[data-testid='ticketCountInput']")
await systemTest.fill("[data-testid='ticketCountInput']", "20")
// Escape hatch for a controlled RNW input you cannot make uncontrolled yet:
await systemTest.clearAndFill("[data-testid='ticketCountInput']", "20", {clearStrategy: "js", fillStrategy: "js"})
// Deliberately slow per-character clear + fill, with a pause between keystrokes:
await systemTest.clearAndFill("[data-testid='ticketCountInput']", "20", {clearStrategy: "backspace-keys", fillStrategy: "per-character", keyDelay: 25})clearAndSendKeys(target, value, opts) remains as a thin convenience alias for clearAndFill with fast native defaults, so existing call sites keep working; prefer clear/fill/clearAndFill in new code.
Verified clicks
click/interact clicks with method: "actions" or method: "human" can opt into checkInterception: true to hit-test the click point first and throw an ElementClickInterceptedError-style error when another element (for example a flash-notification overlay or a fading modal backdrop) would receive the click, matching element.click() interception semantics instead of silently dropping the press on the overlay. These interception errors stay retryable through interact's normal retry handling. Without the flag, pointer-action clicks keep their default behavior and dispatch at the click point unconditionally.
await systemTest.interact({selector: "[data-testid='buyButton']", method: "actions", checkInterception: true}, "click")When a click reports success but the app effect is what actually matters (the silent-drop failure mode), use clickAndWaitForEffect to make the click count only once a caller-observable effect appears. The effect callback should throw while the effect has not happened yet; the click is re-sent until the effect appears or the time budget runs out. Only use it for clicks where clicking again before the effect has appeared is safe, such as opening a menu or navigating.
await systemTest.clickAndWaitForEffect(
"[data-testid='scanFooterMenuButton']",
async () => await systemTest.findByTestID("scanFooterMenu", {timeout: 0}),
{effectTimeout: 2000}
)clickAndWaitForEffect accepts the normal click/finder options plus effectTimeout (how long to await the effect after each click before re-clicking, default 2000 ms) and timeout (the overall budget).
Retry warnings
When a verified helper hits a retry (for example fill finding the wrong value after entering it, or clickAndWaitForEffect re-clicking because the effect has not appeared), it reports a warning so the retry does not happen silently. Configure the onWarning callback (a Browser/SystemTest constructor option) to handle or silence these warnings; without a callback they fall back to console.warn.
const systemTest = SystemTest.current({
onWarning: (message) => myLogger.warn(message)
})Reinitialize a system test
Some test failures can leave the app in a broken state (for example a crashed React tree or a stuck WebSocket session). In those cases, fully restart the SystemTest instance to restore a clean browser/app state before continuing.
await systemTest.reinitialize()This tears down the browser, servers, and sockets, then starts them again so subsequent steps run against a fresh app instance.
SystemTest.run(...) does this automatically after a failed callback or teardown by default, except when the driver session is terminal. Terminal sessions require the runner to end the affected run. Disable ordinary failure reinitialization for tests that intentionally inspect the broken session after failure:
await SystemTest.run({reinitializeAfterFailure: false}, async (systemTest) => {
await systemTest.findByTestID("brokenState")
})Dummy Expo app
A ready-to-run Expo Router dummy app that uses system-testing lives in spec/dummy.
Useful commands from the package root:
npm run export:web: build the dummy Expo app for webSYSTEM_TEST_HOST=dist npx jasmine spec/system-test.spec.js: run the sample system specs against the exported bundleSYSTEM_TEST_HOST=dist npx jasmine spec/system-test-logging.spec.js: run the browser-log capture spec
