npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

stowaway

v0.2.1

Published

E2E testing framework for React Native apps using the Hermes CDP bridge

Readme

stowaway

E2E testing framework for React Native apps. Connects to the running app via the Hermes CDP bridge exposed by Metro, traverses the React fiber tree to find elements, and triggers interactions directly — no Appium, no coordinate math, no YAML.

Status: Active development. Now available on npm.


Documentation

| Guide | What's covered | |---|---| | Getting Started | Installation, config, writing your first test, entry point setup | | Querying | find, findAll, waitForElement, waitFor, scrollAndFind, selectors, reading element state | | Interactions | tap, longPress, typeText, focus, blur, scrollTo, device actions, screenshots | | Network Mocking | mockNetwork, networkRequests, clearNetworkMocks, scope rules, URL matching | | Assertions | All expect matchers with examples, negation, accessibility matchers | | Test Organisation | describe, it, hooks, .skip, .only, per-test timeout, retries | | Results & CI | Console output, JSON results, JUnit XML, failure screenshots, CI integration |


How it works

React Native + Hermes exposes a Chrome DevTools Protocol (CDP) endpoint via Metro on localhost:8081. Stowaway connects over WebSocket and uses Runtime.evaluate to execute JavaScript inside the live Hermes engine. A small bridge script is injected once per test run; it uses __REACT_DEVTOOLS_GLOBAL_HOOK__ to walk the React fiber tree and expose test utilities as globalThis.__testBridge__.

xcrun simctl (iOS) and adb (Android) handle app lifecycle only — launch, terminate, screenshot. Everything else goes through the fiber tree.


Requirements

  • Node >= 22
  • iOS: Xcode + xcrun simctl
  • Android: adb
  • A running React Native app built with Hermes (the default since RN 0.70)

Installation

npm install --save-dev stowaway

Quickstart

Run the init command from your app's root directory:

npx stowaway init

It will ask for your bundle ID and output directory, then scaffold:

  • e2e/run.ts — the test entry point
  • e2e/smoke.spec.ts — a smoke test that launches the app and prints the component tree
  • e2e:ios / e2e:android scripts in your package.json

Boot your simulator or emulator, start Metro, then:

npm run e2e:ios

The smoke test prints your component tree — use that output to find testIDs and write your first real spec. See Getting Started for manual setup and configuration options.


Usage

Config

All configuration is read from environment variables:

| Variable | Default | Description | |----------|---------|-------------| | PLATFORM | ios | ios or android | | BUNDLE_ID | required | App bundle identifier | | METRO_PORT | 8081 | Metro dev server port | | DEFAULT_TIMEOUT | 10000 | Element wait timeout (ms) | | TEST_RESULTS_DIR | test-results | JSON results output directory | | SUITE_NAME | — | Label printed in the run header | | VERBOSE | — | 1 or true — prints each step as it runs; also enabled by --verbose CLI flag | | SLOW_REPLAY | — | 1 or true — adds a delay between steps in failure replay videos | | SLOW_REPLAY_DELAY | 800 | Delay in ms between steps when SLOW_REPLAY is enabled |

Write tests

import { describe, it, expect } from 'stowaway';

describe('Home Screen', () => {
  it('shows the title', async (app) => {
    const el = await app.waitForElement('home-title');
    expect(await el.text()).toBe('My App');
  });

  it('tapping a card navigates to the detail screen', async (app) => {
    await (await app.waitForElement('card-1')).tap();
    await app.waitForElement('detail-screen');
  });
});

The app parameter passed to each test is an AppSession. The app is reset (terminated and relaunched) between every test automatically.

Entry point

Pass a directory and TestRunner auto-discovers all *.spec.ts files inside it:

import { TestRunner, loadConfig } from 'stowaway';

const runner = new TestRunner(loadConfig());
runner.run(__dirname);

Or pass an explicit ordered array:

runner.run([
  path.resolve(__dirname, 'auth.spec.ts'),
  path.resolve(__dirname, 'home.spec.ts'),
]);

API

AppSession queries

| Method | Description | |--------|-------------| | find(selector) | First visible match — throws immediately if not found | | findAll(selector) | All visible matches — empty array if none | | findNth(selector, n) | nth visible match (0-based) from findAll — throws if out of range | | waitForElement(selector \| testID, opts?) | Resolves on the first React commit containing the element; string arg treated as { testID } | | waitForElementToDisappear(selector \| testID, opts?) | Polls until the element leaves the tree | | waitFor(fn, opts?) | Polls an arbitrary () => Promise<boolean> | | scrollAndFind(testID, opts?) | Scrolls a FlatList/ScrollView in steps until the element appears | | getTree(maxDepth?) | Returns the serialized fiber tree | | printTree(maxDepth?) | Prints the fiber tree to stdout — preferred for debugging missing testIDs | | printVisibleTree(maxDepth?) | Like printTree but excludes prerendered/display:none subtrees |

Selector types:

{ testID: string }
{ component: string; props?: Record<string, unknown> }
{ text: string; exact?: boolean }        // exact defaults to true
{ text: RegExp }
{ accessibilityLabel: string; exact?: boolean }
{ accessibilityRole: string }
{ placeholder: string; exact?: boolean }

AppSession interactions

| Method | Description | |--------|-------------| | screenshot(name) | Saves <testResultsDir>/<name>-<timestamp>.png | | dismissKeyboard() | Blurs the first TextInput in the tree | | disableAnimations() | Patches Animated to zero duration — call in beforeAll to reduce flakiness | | waitForInteractions(opts?) | Sleeps delay ms (default 500) — use after waitForElement to let native nav animations finish | | step(name, fn) | Runs fn as a named step; prefixes the error message with [name] on failure |

Network:

| Method | Description | |--------|-------------| | setNetworkOffline(offline) | When true, all fetch calls reject with a network error | | mockNetwork(matcher, response) | Intercepts matching fetch calls and returns a controlled response | | networkRequests() | Returns all intercepted requests since the last app launch | | clearNetworkMocks() | Wipes all mocks and the request log | | waitForRequest(matcher, opts?) | Polls until a matching request appears in the log | | waitForResponse(matcher, opts?) | Same but waits until the response has settled |

Storage:

| Method | Description | |--------|-------------| | setStorage(key, value) | Writes a string to AsyncStorage | | getStorage(key) | Reads a string from AsyncStorage (null if absent) | | removeStorage(key) | Deletes a key from AsyncStorage | | clearStorage() | Clears all AsyncStorage keys |

Fake timers:

| Method | Description | |--------|-------------| | clock.install(baseTime?) | Patches setTimeout/setInterval/Date.now in Hermes to use fake time | | clock.tick(ms) | Advances fake time, firing all queued callbacks in order | | clock.restore() | Restores real timers (called automatically on reset()) | | clock.now() | Returns current fake timestamp |

Device state:

| Method | Description | |--------|-------------| | pressBack() | Android only — sends hardware Back key | | openURL(url) | Opens a URL via the OS deep-link mechanism | | setLocation(lat, lng) | Simulates GPS location (iOS only) | | setPermission(service, status) | Grants/revokes/resets a system permission | | setOrientation(orientation) | 'portrait' or 'landscape' — iOS via AppleScript, Android via adb | | startRecording(name?) | Starts video recording; saves to <testResultsDir>/<name>-<timestamp>.mp4 | | stopRecording() | Stops recording and returns the file path | | pushNotification(payload) | iOS only — delivers a push via xcrun simctl push; payload follows APNS format | | setStatusBar(opts) | iOS only — overrides status bar fields (time, battery, wifi, etc.) | | resetStatusBar() | iOS only — clears all status bar overrides | | setClipboard(text) | iOS only — writes to the host clipboard (synced with simulator) | | getClipboard() | iOS only — reads from the host clipboard | | setBiometricEnrollment(enrolled) | iOS only — enrolls or un-enrolls the biometric sensor | | matchBiometric() | iOS only — simulates a successful biometric match | | rejectBiometric() | iOS only — simulates a failed biometric attempt | | isAppRunning() | Returns true if the app process is alive — useful after a CDP connection loss |

Element

Interactions:

| Method | Description | |--------|-------------| | tap() | Nearest ancestor onPress | | longPress() | Nearest ancestor onLongPress | | doubleTap() | onDoublePress/onDoubleTap if found, otherwise onPress twice | | typeText(text) | Calls onChangeText | | clearText() | typeText('') | | pressKey(key) | Fires onKeyPress({ nativeEvent: { key } }) — e.g. 'Enter', 'Backspace' | | focus() / blur() | Calls onFocus/onBlur or stateNode.focus()/blur() | | submitEditing() | Calls onSubmitEditing | | check() / uncheck() | Calls onValueChange(true/false) — for Switch and custom toggles | | selectOption(value) | Calls onValueChange(value) — for pickers and segmented controls | | setDate(date) | Fires onDateChange, onChange, or onConfirm — for date pickers | | slideToValue(value) | Fires onValueChange then onSlidingComplete — for sliders | | swipe(direction, distance?) | Fires a PanResponder gesture sequence | | dragTo(target) | Measures both elements and fires a PanResponder drag | | scrollTo(offset) | Scrolls the element vertically to the given px offset | | scrollToX(offset) | Scrolls the element horizontally to the given px offset |

Reading state:

| Method | Description | |--------|-------------| | text() | Concatenated HostText descendants | | inputValue() | memoizedProps.value ?? defaultValue ?? '' — for TextInput | | prop(name) | Single named prop from memoizedProps | | props() | All serializable memoizedProps + accessibilityState | | exists() | Re-queries by testID; false if not found | | isEnabled() | false if disabled or accessibilityState.disabled | | isChecked() | !!memoizedProps.value — for Switch/checkbox | | isVisible() | Alias for exists() | | isFocused() | true if accessibilityState.focused | | getFrame() | { x, y, width, height } via stateNode.measure() |

Tree traversal:

| Method | Description | |--------|-------------| | find(selector) | Scoped query within this element's subtree | | findAll(selector) | Scoped query — all matches within this element's subtree | | parent() | Nearest meaningful ancestor; skips HOC wrappers and Fragments | | siblings() | All fiber siblings excluding this element | | sibling(selector) | First sibling matching selector — throws if none found | | nextSibling() | Immediately following sibling in fiber order, or null | | prevSibling() | Immediately preceding sibling in fiber order, or null | | closest(selector) | Walks up fiber.return and returns the first ancestor matching selector |

expect

When passed a primitive or object, returns a sync Assertion:

expect(value).toBe(x)             // Object.is
expect(value).toEqual(x)          // JSON.stringify deep equal
expect(value).toContain(x)        // string includes or array includes
expect(value).toMatchObject(obj)  // partial key match
expect(value).toBeTruthy() / toBeFalsy() / toBeNull() / toBeUndefined()
expect(value).toBeGreaterThan(n) / toBeGreaterThanOrEqual(n)
expect(value).toBeLessThan(n) / toBeLessThanOrEqual(n)
expect(value).toHaveLength(n)
expect(props).toHaveAccessibilityLabel(label)
expect(props).toHaveAccessibilityRole(role)
expect(value).not.<matcher>()     // negation

When passed an Element, returns an async auto-waiting AsyncElementExpect that polls every 250 ms until the assertion passes or times out (default 4 000 ms):

await expect(element).toHaveText('Done')       // string or RegExp
await expect(element).toHaveValue('Jane Doe')  // for inputs
await expect(element).toBeVisible()
await expect(element).toBeEnabled()
await expect(element).not.toHaveText('Error')
await expect(element).toHaveText('Done', { timeout: 8_000 })

expect.soft(value) works like expect(value) but queues failures instead of throwing immediately. All queued failures are reported together at the end of the test:

expect.soft(await label.text()).toBe('Done');  // continues even if this fails
await expect.soft(element).toBeVisible();

Caveats

  • Node IDs are invalidated after reset() — never hold an Element reference across test boundaries; always re-query.
  • The bridge script is evaluated fresh each reset() — any global test state inside the app is wiped.
  • CJS build only — currently compiles to CommonJS so it can be required by tsx in projects without "type": "module". A dual CJS+ESM build will be needed before publishing to npm.
  • Android requires a connected device or emulator — run adb reverse tcp:8081 tcp:8081 if Metro isn't reachable (the runner does this automatically on launch).