This tool works with both Expo and React Native CLI projects. Just install and go.
Press the remote without picking one up. The TV Remote tool sends real D-pad, select, menu, media and long presses — and typed text — to a connected TV app from Buoy Desktop, records what you pressed as a named macro, and replays that macro against one device, or every mapped device at once.
It exists because TV navigation regressions are the ones nobody catches: a card that used to be reachable with three RIGHTs now needs four, a modal traps focus, a row silently swallows UP. A recorded macro replays that exact path and tells you, step by step, whether the app actually received each press.
npm install @buoy-gg/tv-remoteimport { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
<FloatingDevTools headless /> {/* TV apps run headless — no bubble */}
</>
);
}
import { FloatingDevTools } from "@buoy-gg/core";
export default function App() {
return (
<>
{/* your app */}
<FloatingDevTools headless /> {/* TV apps run headless — no bubble */}
</>
);
}
Then open TV Remote in Buoy Desktop, pair your device with an injection target, and press away.
Buoy injects presses from your Mac, not from inside your app. Buoy Desktop shells out to
adb shell input keyevent for Android TV and idb ui key for the Apple TV simulator. Both go
through the platform's real input pipeline, so the press travels the same focus engine a physical
remote drives.
The package you install in the app does the other half: it observes. It reports the remote events your app actually received, which is what lets a replay tell "the app handled that press" apart from "something swallowed it."
Nothing is faked in-process, and that is deliberate:
requestTVFocus (focus jumps, but no event fires and nothing is
traversed). Each is half of a press, and the missing half is the half QA cares about.ViewRootImpl,
above the Activity. An in-process dispatchKeyEvent would fire JS events without moving
focus exactly on the screens that are broken.The package also renders nothing on screen. On Android TV any focusable view in an overlay becomes a D-pad stop in your app's focus order — a visible tool would change the navigation it is meant to be testing.
| Target | Replay | How |
|---|---|---|
| Android TV emulator | ✅ everything | adb -s <serial> shell input keyevent |
| Android TV device | ✅ everything | the same, over adb connect <ip>:5555 |
| Apple TV simulator | ✅ D-pad, select, menu, hold, Home, text — ❌ media keys | idb ui key / ui button / ui text |
| Apple TV device | ❌ record only | no supported host-side injection exists |
| Apple TV sim | Android TV | Echoed back by the app? | |
|---|---|---|---|
| D-pad, Select, Menu | ✅ | ✅ | yes (Menu: only with menu capture on) |
| Hold (long press) | ✅ | ✅ | yes — as longSelect, longLeft, … |
| Type text | ✅ | ✅ | no — see below |
| Play/pause, rewind, fast-forward, next, previous | ❌ | ✅ | yes, on Android |
| Home / TV | ✅ | ✅ | no — it backgrounds the app |
| Swipe / pan (touch surface) | ❌ | ❌ | — |
| Voice / Siri | ❌ | ❌ | — |
Some limits are worth stating plainly rather than discovering later:
idb ui key speaks the HID keyboard
page, which has no usages for play/pause, rewind, fast-forward, next or previous, and
idb ui button offers only Apple Pay, Home, Lock, Side button and Siri. Those steps are reported
skipped-unsupported on that target — flagged, never silently passed. They work on Android.idb ui swipe errors out and takes the app with it), and Android TV has no
touchscreen. So swipeUp/pan events reach the capture stream from a physical remote, but no
macro step can reproduce them.Type text sends.Recording works everywhere, including retail hardware, because capture is pure JS. That gives you the record-on-retail workflow: a tester presses the physical remote on a rack device, the macro is recorded from the app's own event stream, and replay then runs against emulators and simulators.
Android: adb — part of the Android SDK platform-tools.
Apple TV simulator: idb, which does not ship with Xcode:
brew tap facebook/fb && brew install idb-companion
pipx install fb-idb
brew tap facebook/fb && brew install idb-companion
pipx install fb-idb
Without it the Apple TV lane is disabled and the panel says so.
Buoy looks for both on your PATH and in the usual install locations, so a packaged desktop app
finds them even though a GUI app inherits a minimal PATH.
The panel lists every booted target it finds and suggests one that matches your device's platform. Pairing is manual and remembered per device. Buoy will not probe for a pairing by injecting keys into devices you didn't ask it to touch.
Type text sends a string to whatever field currently has focus
(idb ui text / adb shell input text). This is the practical stand-in for the Siri remote's
dictation, and it saves pecking out a search query on a D-pad keyboard grid one letter at a time.
One caveat, and it is a real one: text travels the platform's keyboard path, so it reaches the native text field without ever touching the app's TV event pipe. Verified: an app with capture armed reports nothing at all for injected text. A text step is therefore fire-and-wait — the tool can prove it was sent, not that it was received. (Menu and Home are fire-and-wait for the same kind of reason.)
Hold turns the next press into a long press — the same keycode with a duration flag
(--longpress / --duration). React Native reports it under its own event name, so a held Select
comes back as longSelect and is echo-confirmed like any other press. Holding is meaningful for
the D-pad, Select and Play/Pause; the fork emits no long variant for Menu.
A macro is a list of raw presses with the timing you recorded:
{
"version": 1,
"name": "search-for-arrow",
"createdAt": "2026-08-25T14:02:11.000Z",
"steps": [
{ "key": "right", "delayMs": 420 },
{ "key": "select", "delayMs": 800 },
{ "key": "select", "hold": true, "delayMs": 600 },
{ "kind": "text", "text": "arrow", "delayMs": 500 }
]
}
{
"version": 1,
"name": "search-for-arrow",
"createdAt": "2026-08-25T14:02:11.000Z",
"steps": [
{ "key": "right", "delayMs": 420 },
{ "key": "select", "delayMs": 800 },
{ "key": "select", "hold": true, "delayMs": 600 },
{ "kind": "text", "text": "arrow", "delayMs": 500 }
]
}
A step is either a key press (key, optionally hold) or typed text (kind: "text"). A step with
no kind has always meant a key press, so macros recorded before text steps existed still replay.
Record one two ways:
Macros export and import as JSON, so they can live in the repo next to the app under test.
Each step is injected, then Buoy waits for the app to echo the event back before pacing the recorded delay and moving on. Recorded delays are clamped to 150ms–5s and scaled by the speed multiplier, so a long pause while you were thinking doesn't become a long pause in CI.
Every step reports one of:
| Outcome | Means |
|---|---|
| ok | Injected, and the app reported receiving it (with the round-trip time). |
| no-echo | Injected, but the app never saw it. Something above it swallowed the key — a native video player holding focus, a modal, or a wedged JS thread. This is a finding, not noise. |
| skipped-unsupported | This target has no way to send that key (play/pause on a tvOS simulator). |
| inject-failed | The injector itself failed — target offline, adb unauthorized, idb missing. |
Menu is expected to produce no echo: on Android the back key goes to BackHandler rather than the
TV event pipe, and on tvOS the Menu key is handled natively. Turn on Capture the Menu key while
recording on Apple TV to route it to JS instead — the panel warns you that your app won't go back or
background while that is on, and it is always handed back when recording stops.
Run on N devices replays one macro against every paired device at once, one injection lane each, and lays the outcomes out as a device × step matrix with a final screenshot per device for eyeball diffing. Partial fleets are normal: an Apple TV simulator skipping a play/pause step, or one device being offline, marks that cell and never blocks the rest.
You can record on one — capture runs in your app and works on any hardware. You cannot replay against one in this version; there is no supported host-side way to inject remote presses into a retail Apple TV.
On purpose. On Android TV a focusable view in an overlay joins your app's D-pad focus order, so an on-screen tool would alter the navigation it is supposed to be testing.
no-echo on a video screen.That is the tool doing its job. Native players (ExoPlayer, AVPlayer) take focus and eat D-pad and play/pause input while video is mounted. A fixed-delay replay would have marched on and reported a pass.
No. idb does expose a SIRI button, but the simulator has no Siri, and on real hardware Siri is a
system service — the audio goes to Apple and your app never receives it. There is nothing to inject
and nothing for the app to echo. Use Type text instead: the text is the part your app actually
sees.
No. @buoy-gg/tv-remote is pure JavaScript, like every Buoy package. The shelling out happens in
the desktop app.