Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/update-from-upstream/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The fork adds, on top of upstream Maestro:
| `feat(orchestra): add readFile command and document fork additions` | `readFile` loads a JSON file into a namespaced object variable (`${data.field}`); adds object-typed JS bindings (`JsEngine.putObjectEnv`) via GraalJS proxies. Also introduces `FORK.md`. |
| `feat(web): Support attaching to a running Chrome/Electron over CDP` | Attach mode (`--cdp-url` / `--cdp-target`) drives an already-running webview instead of launching Chrome; input routed over CDP since there is no Selenium session. |
| `chore(fork): add verify-fork-stack skill and resync fork docs` | Fork tooling: the `verify-fork-stack` skill plus the `FORK.md` / stack-table reconciliation it enforces. |
| `feat(roku): add Roku platform support` | `RokuDriver` over the External Control Protocol (HTTP on device port 8060 — no on-device agent), `RokuEcpClient`/`RokuAppUIParser`/`RokuKeyMapping`, device discovery (`MAESTRO_ROKU_HOST` pin or SSDP scan), `DeviceSpec.Roku`, `RokuLocale`, three new remote KeyCodes (`Remote Info`/`Instant Replay`/`Search`, also mapped on Android), Studio TV mode, and the `e2e/roku_demo_app` fixture. D-pad-only: `tapOn` sends `Select`, swipes are repeated D-pad presses. Unsupported primitives (`setLocation`, `setProxy`, `setDarkMode`, `addMedia`, screen recording) throw or no-op deliberately. Contributed by the Nami team. |

This table is the **fork commit stack** — keyed on commit subject, not SHA (rebasing rewrites SHAs, subjects are stable). Keep it in sync with `git log --oneline upstream/main..main`.

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Shared context for any Claude Code skill or subagent operating in this repo. Skills (`.claude/skills/*`) reference this file rather than restating module roles; if a description here drifts from reality, fix it here once and every skill follows.

> **This is a fork.** [`FORK.md`](FORK.md) lists every capability `plexinc/Maestro` adds on top of upstream — fork-only flow commands (`readFile`, `@alias` path references), platforms (tvOS, Vega/Fire TV), web-driver enhancements, bundled Studio, and distribution. Read it before writing flows or tests so you don't treat fork features as missing or reinvent them. Any new fork-only feature must be documented there in the same commit.
> **This is a fork.** [`FORK.md`](FORK.md) lists every capability `plexinc/Maestro` adds on top of upstream — fork-only flow commands (`readFile`, `@alias` path references), platforms (tvOS, Vega/Fire TV, Roku), web-driver enhancements, bundled Studio, and distribution. Read it before writing flows or tests so you don't treat fork features as missing or reinvent them. Any new fork-only feature must be documented there in the same commit.

## Module map

Expand All @@ -14,7 +14,7 @@ Top-level Gradle modules. Code lives under each module's `src/main/`.
| `maestro-ios-driver/` | Host side of iOS driver wrapper (Kotlin). The actual XCTest runner lives in `maestro-ios-xctest-runner/`. |
| `maestro-ios-xctest-runner/` | Swift XCTest runner that runs on the iOS device/simulator. The compiled artifacts (`maestro-driver-ios*.zip`) are checked in under `maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/Debug-iphonesimulator/`. |
| `maestro-ios/` | iOS host-side glue (small — most iOS host code lives in `maestro-client/`). |
| `maestro-client/` | Host-side Kotlin SDK that drives devices. Platform drivers live in `src/main/java/maestro/drivers/`: `AndroidDriver.kt`, `IOSDriver.kt`, `WebDriver.kt`, `CdpWebDriver.kt`. This is where most "auto-grant", "auto-dismiss", system-dialog handling and platform-specific quirks belong. |
| `maestro-client/` | Host-side Kotlin SDK that drives devices. Platform drivers live in `src/main/java/maestro/drivers/`: `AndroidDriver.kt`, `IOSDriver.kt`, `WebDriver.kt`, `CdpWebDriver.kt`, `VegaDriver.kt`, `RokuDriver.kt`. This is where most "auto-grant", "auto-dismiss", system-dialog handling and platform-specific quirks belong. |
| `maestro-orchestra/` | Command execution layer. `Orchestra.kt` interprets each Maestro command, applies retries, manages the command lifecycle. Sub-packages: `error/`, `filter/`, `workspace/`, `yaml/`. |
| `maestro-orchestra-models/` | Shared command/data models (used by `maestro-orchestra/` and consumers). |
| `maestro-cli/` | CLI entry point + MCP server. Mixed Kotlin (~100 files) + Swift (~56 files for iOS-related CLI bits). |
Expand Down
55 changes: 55 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,61 @@ Demo app + flows under `e2e/vega_demo_app/` and `e2e/workspaces/vega_demo_app/`.
Code: `DeviceSpec.Vega`, `maestro/device/DeviceService.kt`, `VegaLocale`.
Commit `1743bfdf`.

### Roku

Contributed by the [Nami](https://www.nami.ml) team ([rku.dev](https://www.rku.dev)).

Driver for Roku devices over the **External Control Protocol** (ECP — an HTTP
REST API on device port 8060). Platform id `ROKU` (`--platform roku`). No
on-device driver process: view hierarchy from `/query/app-ui` (SceneGraph XML,
dev-mode channels only), D-pad input via `/keypress/<key>`, text via
character-by-character `LIT_` keypresses, screenshots via the dev web server
(digest auth), app launch via `/launch/<channelId>`. Roku is D-pad-only — `tapOn`
sends `Select` (activates the focused element), swipes/scrolls become repeated
D-pad presses in the direction the *content* moves — `swipe: UP` reveals what is
below it, so it presses Down, and `scroll` is `swipe: UP`, as on Vega and web.
`launchApp` is a cold launch: a channel that is already running is exited to the
home screen first so it restarts from its initial state, and it fails the flow if
the channel never becomes the active app. A launch (or an `openLink` deep link)
carries only the parameters the flow asked for.

A rejected ECP command fails the flow rather than logging a warning — otherwise a
device with ECP access set to anything but Permissive serves `/query/app-ui` while
403-ing every keypress, and a flow passes green having never touched the device.
The 403 is reported with that setup hint. Nodes the device isn't rendering
(`visible="false"`, `opacity="0"`) are dropped from the hierarchy with their
subtrees, so `assertVisible` won't match a hidden element.

**Device setup** (physical hardware only — Roku has no emulator):

1. Enable developer mode: Home 3x, Up 2x, Right, Left, Right, Left, Right; set a
dev password.
2. Set ECP network access to Permissive: Settings > System > Advanced system
settings > Control by mobile apps > Network access > **Permissive** (recent
Roku OS versions return 403 on input commands otherwise).

**Device selection** — Roku devices surface through the normal device listing:

```bash
export MAESTRO_ROKU_HOST=192.168.1.100 # pin a device by IP (primary)
export MAESTRO_ROKU_PASSWORD=devpwd # dev-mode password (screenshots only)
export MAESTRO_ROKU_DISCOVERY=true # optional: SSDP LAN scan (~1s per listing)

maestro test --platform roku flow.yaml
maestro test --device 192.168.1.100 flow.yaml
```

**New KeyCodes** (also mapped on Android): `Remote Info` (the `*` options
button), `Remote Instant Replay`, `Remote Search`. `Remote Menu` maps to `Info`
on Roku. Studio's TV mode is auto-on for Roku, like tvOS.

Demo channel + flows under `e2e/roku_demo_app/` (BrightScript; sideload steps in
the flow headers) and `e2e/workspaces/roku_demo_app/` — mirrors the tvOS/Vega
demo apps (same screens, labels, and testIDs: Home menu, 2x2 navigation grid,
native-Keyboard text input, programmatic-focus test).
Code: `maestro/roku/` (`RokuEcpClient`, `RokuDeviceDiscovery`, `RokuAppUIParser`,
`RokuKeyMapping`), `drivers/RokuDriver.kt`, `DeviceSpec.Roku`, `RokuLocale`.

### Web driver enhancements (canvas / D-pad TV web apps)

For D-pad-driven or WebGL/canvas web apps (e.g. Lightning). Platform id `WEB`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

- **Apple TV (tvOS) support** — a tvOS driver, device management (`start-device --platform tvos`), an RN Expo tvOS demo app, and tvOS e2e flows.
- **Amazon Vega (Fire TV) support** — a driver for Amazon's Vega OS (a Linux/React Native OS, not Android), driven through the `vega`/`vda` CLI: view hierarchy from the on-device automation toolkit, D-pad/touch/swipe/text input, and screenshots.
- **Roku support** — a driver for Roku devices over the External Control Protocol (an HTTP API on the device, no on-device agent): SceneGraph view hierarchy, D-pad/remote-key input, text entry, screenshots, and network device discovery (`MAESTRO_ROKU_HOST` pin or SSDP scan). Contributed by the [Nami](https://www.nami.ml) team ([rku.dev](https://www.rku.dev)).
- **Canvas-based UI (web) support** — for D-pad-driven TV web apps (e.g. Lightning/WebGL): `data-testid`-first element selection, D-pad/arrow-key mapping (with Menu→back), URL-shaped `appId` detection, a `focused:` selector, and `--platform web` device selection.
- **Bundled Maestro Studio, restored and TV-aware** — re-adds the local `maestro studio` web IDE that upstream removed, plus a "TV mode" (auto-on for tvOS, toggleable for web) where taps/swipes and physical arrow keys / Enter / Esc drive the D-pad / select / back.
- **`major.minor.patch.build` versioning** — `CLI_VERSION` tracks upstream Maestro; a fork-owned `PLEX_BUILD` segment lets us ship fork-only builds without drifting from the inherited version.
Expand Down
45 changes: 45 additions & 0 deletions e2e/roku_demo_app/components/FocusScreen.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
' Focus — verifies programmatic focus: Button 2 (not the first button) receives
' focus when the screen opens, the Roku equivalent of the tvOS/Vega screen's
' requestTVFocus + hasTVPreferredFocus. Same labels and testIDs as those apps.
sub init()
m.buttons = [
m.top.findNode("focus-button-1"),
m.top.findNode("focus-button-2"),
m.top.findNode("back-button")
]
m.focusIndex = 1

m.top.findNode("back-button").observeField("buttonSelected", "onBackSelected")
m.top.observeField("visible", "onVisibleChanged")
end sub

sub onVisibleChanged()
if m.top.visible then
' Programmatic focus lands on Button 2, not the first button.
m.focusIndex = 1
m.buttons[m.focusIndex].setFocus(true)
end if
end sub

sub onBackSelected()
m.top.done = true
end sub

function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false

if key = "back" then
m.top.done = true
return true
else if key = "down" and m.focusIndex < m.buttons.count() - 1 then
m.focusIndex = m.focusIndex + 1
m.buttons[m.focusIndex].setFocus(true)
return true
else if key = "up" and m.focusIndex > 0 then
m.focusIndex = m.focusIndex - 1
m.buttons[m.focusIndex].setFocus(true)
return true
end if

return false
end function
13 changes: 13 additions & 0 deletions e2e/roku_demo_app/components/FocusScreen.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="FocusScreen" extends="Group">
<script type="text/brightscript" uri="FocusScreen.brs" />
<interface>
<field id="done" type="boolean" alwaysNotify="true" />
</interface>
<children>
<Label id="titleLabel" text="Focus Test" translation="[100, 60]" />
<Button id="focus-button-1" text="Button 1" translation="[100, 160]" minWidth="480" />
<Button id="focus-button-2" text="Button 2" translation="[100, 280]" minWidth="480" />
<Button id="back-button" text="Back" translation="[100, 400]" minWidth="480" />
</children>
</component>
57 changes: 57 additions & 0 deletions e2e/roku_demo_app/components/HomeScreen.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
' Home — vertical menu of buttons that navigate to each test screen.
' Mirrors the tvOS/Vega HomeScreen (same labels and testIDs).
sub init()
m.buttons = [
m.top.findNode("menu-navigation"),
m.top.findNode("menu-text-input"),
m.top.findNode("menu-focus")
]
m.selections = ["navigation", "textinput", "focus"]
m.focusIndex = 0

for each button in m.buttons
button.observeField("buttonSelected", "onButtonSelected")
end for
m.top.observeField("visible", "onVisibleChanged")

m.buttons[0].setFocus(true)
end sub

sub focusDefault()
m.focusIndex = 0
m.buttons[0].setFocus(true)
end sub

sub onVisibleChanged()
if m.top.visible then
m.focusIndex = 0
m.buttons[0].setFocus(true)
end if
end sub

sub onButtonSelected(event as object)
id = event.getRoSGNode().id
if id = "menu-navigation" then
m.top.selection = "navigation"
else if id = "menu-text-input" then
m.top.selection = "textinput"
else if id = "menu-focus" then
m.top.selection = "focus"
end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false

if key = "down" and m.focusIndex < m.buttons.count() - 1 then
m.focusIndex = m.focusIndex + 1
m.buttons[m.focusIndex].setFocus(true)
return true
else if key = "up" and m.focusIndex > 0 then
m.focusIndex = m.focusIndex - 1
m.buttons[m.focusIndex].setFocus(true)
return true
end if

return false
end function
14 changes: 14 additions & 0 deletions e2e/roku_demo_app/components/HomeScreen.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="HomeScreen" extends="Group">
<script type="text/brightscript" uri="HomeScreen.brs" />
<interface>
<field id="selection" type="string" alwaysNotify="true" />
<function name="focusDefault" />
</interface>
<children>
<Label id="titleLabel" text="Home" translation="[100, 60]" />
<Button id="menu-navigation" text="Navigation Test" translation="[100, 160]" minWidth="520" />
<Button id="menu-text-input" text="Text Input Test" translation="[100, 280]" minWidth="520" />
<Button id="menu-focus" text="Focus Test" translation="[100, 400]" minWidth="520" />
</children>
</component>
34 changes: 34 additions & 0 deletions e2e/roku_demo_app/components/MainScene.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
' Mirrors the tvOS/Vega demo apps (e2e/tvos_demo_app, e2e/vega_demo_app) — same
' screens, labels and testIDs — re-implemented in BrightScript/SceneGraph. The Home
' menu opens one test screen at a time; each screen reports "done" to return Home.
sub init()
m.home = m.top.findNode("homeScreen")
m.screens = {
navigation: m.top.findNode("navigationScreen"),
textinput: m.top.findNode("textInputScreen"),
focus: m.top.findNode("focusScreen")
}

m.home.observeField("selection", "onMenuSelection")
for each name in m.screens
m.screens[name].observeField("done", "onScreenDone")
end for

' Creating the (hidden) TextInputScreen's Keyboard can grab input focus after
' HomeScreen's init already claimed it — re-assert the default focus last.
m.home.callFunc("focusDefault")
end sub

sub onMenuSelection(event as object)
target = m.screens[event.getData()]
if target = invalid then return
m.home.visible = false
target.visible = true
end sub

sub onScreenDone()
for each name in m.screens
m.screens[name].visible = false
end for
m.home.visible = true
end sub
10 changes: 10 additions & 0 deletions e2e/roku_demo_app/components/MainScene.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="MainScene" extends="Scene">
<script type="text/brightscript" uri="MainScene.brs" />
<children>
<HomeScreen id="homeScreen" />
<NavigationScreen id="navigationScreen" visible="false" />
<TextInputScreen id="textInputScreen" visible="false" />
<FocusScreen id="focusScreen" visible="false" />
</children>
</component>
45 changes: 45 additions & 0 deletions e2e/roku_demo_app/components/NavigationScreen.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
' Navigation — 2x2 grid for testing directional D-pad navigation, plus a Back
' button. Mirrors the tvOS/Vega NavigationScreen (same labels and testIDs).
' SceneGraph has no spatial focus engine, so moves are an explicit transition map.
sub init()
m.transitions = {
"grid-top-left": { right: "grid-top-right", down: "grid-bottom-left" },
"grid-top-right": { left: "grid-top-left", down: "grid-bottom-right" },
"grid-bottom-left": { right: "grid-bottom-right", up: "grid-top-left", down: "back-button" },
"grid-bottom-right": { left: "grid-bottom-left", up: "grid-top-right", down: "back-button" },
"back-button": { up: "grid-bottom-left" }
}
m.currentId = "grid-top-left"

m.top.findNode("back-button").observeField("buttonSelected", "onBackSelected")
m.top.observeField("visible", "onVisibleChanged")
end sub

sub onVisibleChanged()
if m.top.visible then
m.currentId = "grid-top-left"
m.top.findNode(m.currentId).setFocus(true)
end if
end sub

sub onBackSelected()
m.top.done = true
end sub

function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false

if key = "back" then
m.top.done = true
return true
end if

moves = m.transitions[m.currentId]
if moves = invalid then return false
nextId = moves[key]
if nextId = invalid then return false

m.currentId = nextId
m.top.findNode(nextId).setFocus(true)
return true
end function
15 changes: 15 additions & 0 deletions e2e/roku_demo_app/components/NavigationScreen.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="NavigationScreen" extends="Group">
<script type="text/brightscript" uri="NavigationScreen.brs" />
<interface>
<field id="done" type="boolean" alwaysNotify="true" />
</interface>
<children>
<Label id="titleLabel" text="Navigation Test" translation="[100, 60]" />
<Button id="grid-top-left" text="Top Left" translation="[100, 160]" minWidth="480" />
<Button id="grid-top-right" text="Top Right" translation="[620, 160]" minWidth="480" />
<Button id="grid-bottom-left" text="Bottom Left" translation="[100, 280]" minWidth="480" />
<Button id="grid-bottom-right" text="Bottom Right" translation="[620, 280]" minWidth="480" />
<Button id="back-button" text="Back" translation="[100, 420]" minWidth="480" />
</children>
</component>
45 changes: 45 additions & 0 deletions e2e/roku_demo_app/components/TextInputScreen.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
' Text Input — a native Keyboard node plus a label echoing the typed text.
' Mirrors the tvOS/Vega TextInputScreen (same labels and testIDs). The Keyboard
' receives focus on entry so Maestro's inputText (ECP LIT_ keypresses) and
' eraseText (Backspace) land in it directly, no keyboard-open step needed.
sub init()
m.keyboard = m.top.findNode("text-field")
m.typedLabel = m.top.findNode("typed-label")
m.backButton = m.top.findNode("back-button")

m.keyboard.observeField("text", "onTextChanged")
m.backButton.observeField("buttonSelected", "onBackSelected")
m.top.observeField("visible", "onVisibleChanged")
end sub

sub onVisibleChanged()
if m.top.visible then
m.keyboard.text = ""
m.keyboard.setFocus(true)
end if
end sub

sub onTextChanged()
m.typedLabel.text = "Typed: " + m.keyboard.text
end sub

sub onBackSelected()
m.top.done = true
end sub

function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false

if key = "back" then
m.top.done = true
return true
else if key = "down" and m.keyboard.hasFocus() then
m.backButton.setFocus(true)
return true
else if key = "up" and m.backButton.hasFocus() then
m.keyboard.setFocus(true)
return true
end if

return false
end function
Loading
Loading