diff --git a/.trailblaze-sync b/.trailblaze-sync index e6da25467..beb7506a3 100644 --- a/.trailblaze-sync +++ b/.trailblaze-sync @@ -1 +1 @@ -7c171455076b64daf369218b3b72b9b5267404e9 +232c993d0a2648f77ea9c822f7de1f589de183dd diff --git a/docs/CLI.md b/docs/CLI.md index 32ae44dc6..f0277a58f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1115,6 +1115,7 @@ trailblaze config reset | `screenshot-format` | Image format used for screenshots sent to the LLM and shown in the timeline | png, jpeg, webp, or 'unset' to use the framework default (webp) | | `screenshot-max-dimensions` | Max screenshot dimensions as x (e.g. 1536x768, 2048x1024) | WIDTHxHEIGHT (positive ints), or 'unset' to use the framework default (1536x768) | | `screenshot-quality` | Compression quality 0.05..1.0 for lossy formats (jpeg, webp); ignored for png | 0.05..1.0, or 'unset' to use the framework default (0.80) | +| `android-stream-screenshots` | Experimental: serve Android agent-loop screenshots from the live device stream (default: off) | true, false, or 'unset' to inherit the default (off) | **Examples:** diff --git a/docs/showcase-trails.yml b/docs/showcase-trails.yml index c9075a059..c506a1ddd 100644 --- a/docs/showcase-trails.yml +++ b/docs/showcase-trails.yml @@ -26,7 +26,7 @@ ios: slug: ios-contacts - recording: trails/ios-contacts/test-create-then-delete/ios-iphone.trail.yaml + recording: trails/ios-contacts/test-create-then-delete/trail.yaml android: slug: clock @@ -34,4 +34,4 @@ android: web: slug: wikipedia - recording: trails/wikipedia/test-article-shakespeare/web.trail.yaml + recording: trails/wikipedia/test-article-shakespeare/trail.yaml diff --git a/examples/android-sample-app/trails/android-ondevice-instrumentation/mcp-tools/mobile-list-installed-apps/blaze.yaml b/examples/android-sample-app/trails/android-ondevice-instrumentation/mcp-tools/mobile-list-installed-apps/trail.yaml similarity index 100% rename from examples/android-sample-app/trails/android-ondevice-instrumentation/mcp-tools/mobile-list-installed-apps/blaze.yaml rename to examples/android-sample-app/trails/android-ondevice-instrumentation/mcp-tools/mobile-list-installed-apps/trail.yaml diff --git a/examples/dependencies/debugAndroidTestRuntimeClasspath.txt b/examples/dependencies/debugAndroidTestRuntimeClasspath.txt index 2050579b3..270b255b3 100644 --- a/examples/dependencies/debugAndroidTestRuntimeClasspath.txt +++ b/examples/dependencies/debugAndroidTestRuntimeClasspath.txt @@ -3,6 +3,7 @@ :trailblaze-android :trailblaze-common :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-quickjs-tools :trailblaze-tracing ai.koog:agents-core-android:1.0.0 @@ -102,6 +103,8 @@ com.squareup.okhttp3:okhttp-android:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:maestro-client:2.6.1 dev.mobile:maestro-orchestra-models:2.6.1 @@ -119,6 +122,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/examples/wikipedia/README.md b/examples/wikipedia/README.md index 03f028c6e..32b2320ca 100644 --- a/examples/wikipedia/README.md +++ b/examples/wikipedia/README.md @@ -7,7 +7,7 @@ Native driver. The trailmap ships: - **9 scripted tools** (in TypeScript) covering search, language switching, random article, main-page section verification, banner dismissal, article structure assertions, plus a composition example. -- **28 trails** under `trails/wikipedia/` exercising those tools +- **30 trails** under `trails/wikipedia/` exercising those tools + the built-in `web_*` toolset, with `tags` for selective runs and one `skip:` example for opting out gracefully. - **A target-scoped system prompt** that teaches the LLM when to reach for @@ -253,20 +253,23 @@ watching which tool the agent chose for each step. ## What a trail actually looks like -A trail directory's `blaze.yaml` is the source of truth — a natural-language +A trail directory's `trail.yaml` is the source of truth — a natural-language step the agent resolves against the live page. Here's -[`test-search-einstein/blaze.yaml`](../../trails/wikipedia/test-search-einstein/blaze.yaml) +[`test-search-einstein/trail.yaml`](../../trails/wikipedia/test-search-einstein/trail.yaml) verbatim: ```yaml -- config: - title: "Wikipedia: Search for Albert Einstein" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, search] -- prompts: - - step: Search Wikipedia for "Albert Einstein" and verify the resulting article page shows the heading "Albert Einstein". +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - search + title: 'Wikipedia: Search for Albert Einstein' + +trail: + - step: Search Wikipedia for "Albert Einstein" and verify the resulting article page shows the heading "Albert Einstein". ``` The agent sees the trailmap's `target.tools:` (which includes the scripted @@ -278,22 +281,23 @@ tools when a step matches their task patterns. Run it: trailblaze run trails/wikipedia/test-search-einstein --device web/playwright-native ``` -After a passing run, the CLI auto-saves a fresh `.trail.yaml` -alongside the `blaze.yaml` — that's the recording artifact you commit for -deterministic replay. **One caveat for this example specifically:** -scripted-tool calls (`wikipedia_web_*`) currently don't dispatch from a -saved `web.trail.yaml` recording — the Playwright agent rejects them at -replay (tracked under [Known issues](#known-issues)). 5/28 trails here -carry recordings — the ones that exercise pure `web_*` built-ins; the -rest stay NL. +After a passing run, the CLI folds a fresh recording into the trail's +`trail.yaml` — a `recording:` block nested under each step, keyed by device +classifier (`web`) — that's the artifact you commit for deterministic replay. +**One caveat for this example specifically:** scripted-tool calls +(`wikipedia_web_*`) currently don't dispatch from a saved `web` recording — +the Playwright agent rejects them at replay (tracked under +[Known issues](#known-issues)). 5/30 trails here carry recordings — the ones +that exercise pure `web_*` built-ins; the rest stay NL. ## Recording vs natural-language: when to use which -Each trail directory has a `blaze.yaml` (the source of truth). If a -`web.trail.yaml` is also present, the CLI replays it deterministically -instead of going through the LLM. **Use the decision tree:** +Each trail directory has a `trail.yaml` (the source of truth). If a step +carries an embedded `recording:` block for the device under test, the CLI +replays those tools deterministically instead of going through the LLM. +**Use the decision tree:** -| Property of the trail | NL (`blaze.yaml` only) | Recorded (`+ web.trail.yaml`) | +| Property of the trail | NL (no recording) | Recorded (embedded `recording:`) | |--------------------------------------------------------|------------------------|-------------------------------| | Should pass on any reasonable Wikipedia state | ✅ preferred | risky — recording fixes one DOM | | You want zero LLM cost on every run | ❌ ~$0.02-0.05/run | ✅ free after recording | @@ -305,9 +309,9 @@ The 5 recorded trails in this repo are the ones that hit structural anchors stable across days (`test-article-shakespeare`, `test-language-switch-spanish`, the 3 main-page section trails). -**To re-record a trail**: delete its `web.trail.yaml` and re-run the -`blaze.yaml`. The CLI auto-saves a fresh recording next to the source on -a passing run. +**To re-record a trail**: delete the step's `recording:` block(s) from its +`trail.yaml` and re-run it. The CLI folds a fresh recording back into the +trail file on a passing run. --- @@ -341,22 +345,28 @@ Conventions used in this example: ### Skipping a trail with a written reason -A trail's `config:` block can carry `skip: "reason..."` to opt out of every -run until the reason is removed. Better than commenting trails out or -maintaining a separate exclusion list — the reason is committed alongside -the trail and shows up in `--verbose` output. +A trail's `config:` block can carry a `skip:` map — keyed by device classifier +— to opt out until the reason is removed. Listing every device the trail +targets skips it entirely; keying a single device skips just that one while the +trail still runs on the others. Better than commenting trails out or +maintaining a separate exclusion list — the reason is committed alongside the +trail and shows up in `--verbose` output. ```yaml -- config: - title: "Wikipedia: Autocomplete suggestions visible while typing search" - tags: [search, flaky] - skip: "Autocomplete popup is timing-sensitive; remove `skip:` once the suggestion-list probe lands." -- prompts: - - step: … +config: + title: "Wikipedia: Autocomplete suggestions visible while typing search" + tags: [search, flaky] + devices: + web: PLAYWRIGHT_NATIVE + skip: + web: "Autocomplete popup is timing-sensitive; remove `skip:` once the suggestion-list probe lands." + +trail: + - step: … ``` -To run a skipped trail anyway, delete its `skip:` line (or set it to an -empty string). The CLI treats blank values as "not skipped". +To run a skipped trail anyway, delete the device's entry from `skip:` (or set +its reason to an empty string). The CLI treats blank values as "not skipped". --- @@ -372,10 +382,10 @@ when you're authoring new tools or trails for your own target: | Direct scripted-tool dispatch | `test-custom-search` | Trail prompts name the tool explicitly + pass typed args. Maximum determinism short of a recording. | | Composition (tool calls tool) | `test-search-multi-topic` | Exercises `wikipedia_web_searchAndVerify`, which internally calls two other scripted tools. | | Data-driven shape | `test-search-multi-topic` | Same workflow across multiple queries — copy-paste the prompt block + change the data. | -| Recorded deterministic replay | `test-article-shakespeare` | `blaze.yaml` + `web.trail.yaml` — replay path skips the LLM entirely. | +| Recorded deterministic replay | `test-article-shakespeare` | `trail.yaml` with an embedded `recording:` block — replay path skips the LLM entirely. | | Conditional UI (banner present?) | `test-main-page-featured-article` | Exercises `dismissBannerIfPresent`, which no-ops cleanly when no banner is shown. | | Branch coverage on a feature flag | `test-article-short-no-refs` + `test-article-references-section` | Pair covers both branches of `verifyArticleStructure`'s `requireReferences` flag. | -| Graceful skip | `test-search-autocomplete` | Shows the `skip: "reason..."` config field. | +| Graceful skip | `test-search-autocomplete` | Shows the per-device `skip:` config map. | --- @@ -468,7 +478,7 @@ daemon writes the SDK + `trailblaze-client.d.ts` on every restart. flake on long agent rounds. See **Known issues** below. **Tool calls that worked live now fail on replay** — `wikipedia_web_*` -scripted tools currently don't dispatch from a `web.trail.yaml` recording. +scripted tools currently don't dispatch from an embedded `web` recording. See **Known issues** below. --- @@ -478,11 +488,11 @@ See **Known issues** below. These are tracked framework gaps. The example works around them today; the canonical shape will improve when these land. -- **Scripted-tool replay-dispatch gap.** When a `web.trail.yaml` recording +- **Scripted-tool replay-dispatch gap.** When an embedded `web` recording captures a call to `wikipedia_web_*`, the Playwright agent rejects it at replay as `OtherTrailblazeTool` (the dispatcher only handles `web_*` built-ins). Until fixed, only trails that exercise pure `web_*` tools - get useful recordings. That's why 23/28 trails here stay NL-only. + get useful recordings. That's why 25/30 trails here stay NL-only. - **CLI poll-timeout on long LLM rounds.** `trailblaze run …` can return `FAILED: Daemon unreachable after 30 consecutive poll failures` while the daemon is still healthy and the session is making progress. diff --git a/scripts/dev-jar-cache.sh b/scripts/dev-jar-cache.sh index 52568a2d1..e475e47a6 100755 --- a/scripts/dev-jar-cache.sh +++ b/scripts/dev-jar-cache.sh @@ -127,12 +127,67 @@ dev_ensure_jar() { fi if [ "$need_build" = true ]; then - # Kill the daemon BEFORE building — it has stale code and must not survive - # into the new JAR. It auto-starts on the next command, so this is safe. - # We confirm the port is free before proceeding to the build. + # Stop the daemon BEFORE building — it has stale code and must not survive into the + # new JAR. It auto-starts on the next command, so this is safe. EXCEPTION: a daemon + # with in-flight runs is left running (see the busy-daemon guard below); it picks up + # the new JAR at its next restart. We confirm the port is free before building only + # when we actually stopped the daemon. local http_port="${TRAILBLAZE_PORT:-52525}" local pids pids=$(lsof -ti "tcp:$http_port" 2>/dev/null || true) + # NEVER stop a daemon with in-flight runs (unless TRAILBLAZE_FORCE_DAEMON_STOP is set). + # The daemon on this port may belong to a DIFFERENT checkout/worktree (jar staleness is + # per-checkout, the port is machine-global), so "stale from here" can mean "mid-run for + # someone else" — killing it severs that run (truncated /agentlog uploads, then 'Daemon + # unreachable' for the victim CLI). A busy daemon is left alone; it picks up the new JAR + # at its next restart. This staleness-stop only exists in the dev launcher — installed + # CLIs never rebuild JARs, so none of this applies outside a source checkout. + if [ -n "$pids" ]; then + local status_json curl_exit keep_reason="" + # --max-time bounds the WHOLE transfer, not just the connect (--connect-timeout): a + # wedged daemon can accept the TCP connection and then hang its /cli/status handler + # forever, and without --max-time this probe would block dev_ensure_jar indefinitely. + status_json=$(curl -s --connect-timeout 2 --max-time 5 "http://localhost:$http_port/cli/status" 2>/dev/null) + curl_exit=$? + if [ "$curl_exit" -ne 0 ]; then + # Something is listening (lsof found the pid) but /cli/status didn't answer within the + # timeout. The most likely cause is a daemon busy running a trail (saturated event + # loop) — precisely the daemon we must NOT kill. We can't confirm it's idle, so fail + # CLOSED (keep it) rather than open. A truly wedged daemon is handled by the FORCE + # override below. NOTE: an old daemon with no /cli/status route answers fast with 404 + # (curl_exit 0), so it still falls through to the stop — only a genuine no-answer keeps. + keep_reason="it is listening but /cli/status did not respond within the timeout (it may be busy running a trail)" + else + local active_runs + active_runs=$(printf '%s' "$status_json" | sed -n 's/.*"activeRuns"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') + # Empty/absent activeRuns (older daemon, or a genuinely idle one) → no keep_reason → + # fall through to the stop, same as before. + if [ -n "$active_runs" ] && [ "$active_runs" -gt 0 ]; then + keep_reason="stopping it would kill $active_runs in-flight run(s)" + fi + fi + if [ -n "$keep_reason" ]; then + # tr instead of ${var,,}: macOS ships bash 3.2, which lacks case conversion. + case "$(printf '%s' "${TRAILBLAZE_FORCE_DAEMON_STOP:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true) + echo "Daemon on port $http_port: $keep_reason, but TRAILBLAZE_FORCE_DAEMON_STOP is set — stopping it anyway (in-flight runs will fail)." >&2 + ;; + *) + echo "NOT stopping the daemon on port $http_port: its code is stale, but $keep_reason." >&2 + # Only parse run details when we actually got a status body (curl_exit 0). + if [ "$curl_exit" -eq 0 ] && command -v jq >/dev/null 2>&1; then + printf '%s' "$status_json" | jq -r '(.activeRunSummaries // [])[] | " - " + .' >&2 2>/dev/null || true + local daemon_workspace + daemon_workspace=$(printf '%s' "$status_json" | jq -r '.workspaceAnchor // empty' 2>/dev/null || true) + [ -n "$daemon_workspace" ] && echo " daemon workspace: $daemon_workspace" >&2 + fi + echo "The daemon keeps serving and picks up the newly built JAR at its next (re)start." >&2 + echo "To stop it now anyway: 'trailblaze stop', or re-run with TRAILBLAZE_FORCE_DAEMON_STOP=1." >&2 + pids="" + ;; + esac + fi + fi if [ -n "$pids" ]; then echo "Stopping daemon (stale code)..." >&2 # Path must match CliEndpoints.SHUTDOWN ("/cli/shutdown"). Posting to the diff --git a/scripts/test_ipc_replay.sh b/scripts/test_ipc_replay.sh index e52da4f85..065922e79 100755 --- a/scripts/test_ipc_replay.sh +++ b/scripts/test_ipc_replay.sh @@ -4,17 +4,16 @@ # (scripts/trailblaze). The fix it guards: # # `$(jq -r .stdout)` and `$(jq -r .stderr)` strip ALL trailing newlines -# from the captured bytes. Without restoring one with `printf '%s\n'`, -# stdout (e.g. a snapshot UI tree) visually smashes into the stderr -# replay that follows ("Connecting to …", "Connected: …"). The smashed -# form looked like: +# from the captured bytes. The shim now decodes all fields in one jq process +# with NUL delimiters, so bash preserves the exact stream endings. Without +# that preservation, stdout (e.g. a snapshot UI tree) visually smashed into +# the stderr replay that followed. The smashed form looked like: # # [n635] "Options"Connecting to Android device (emulator-5556)... # # This test mirrors the exact JSON-unmarshal + replay codepath against a -# synthetic CliExecResponse so a future revert of `printf '%s\n'` back to -# `printf '%s'` (or a refactor that loses the trailing newline) trips a -# loud, scriptable failure rather than silently re-introducing the bug. +# synthetic CliExecResponse so a refactor that loses the trailing newline +# trips a loud, scriptable failure rather than silently re-introducing the bug. # # Run: # bash scripts/test_ipc_replay.sh @@ -33,23 +32,40 @@ fi # (Console.error -> stderr) and emitted a UI tree (Console.info -> stdout). RESPONSE='{"stdout":"### Screen\nApp: com.android.camera2\n[i209] ImageView \"Shutter\"\n[n635] \"Options\"\n","stderr":"Connecting to Android device (emulator-5556)...\nConnected: android/emulator-5556\nEnded previous session.\n","exitCode":0,"forwarded":true}' -# Mirror the shim: jq-decode into bash vars (which strips trailing \n via $()), -# then run the EXACT replay lines from `ipc_try_forward`. -stdout=$(printf '%s' "$RESPONSE" | jq -r '.stdout // ""') -stderr=$(printf '%s' "$RESPONSE" | jq -r '.stderr // ""') +# Mirror the shim: one jq decode with NUL delimiters preserves trailing newlines. +fields=() +while IFS= read -r -d '' field; do + fields+=("$field") +done < <(printf '%s' "$RESPONSE" | jq -j ' + if (.forwarded // false) != true then + error("not forwarded") + else + (.stdout // ""), "\u0000", + (.stderr // ""), "\u0000", + ( + if (.exitCode | type) == "number" then + (.exitCode | floor | if . < 0 then 256 + (. % 256) elif . > 255 then . % 256 else . end) + else 1 end + | tostring + ), "\u0000" + end +') +[ "${#fields[@]}" -eq 3 ] || { printf 'FAIL: response did not decode into three fields\n' >&2; exit 1; } +stdout="${fields[0]}" +stderr="${fields[1]}" # Capture rendered output to inspect. combined=$( { - [ -n "$stdout" ] && printf '%s\n' "$stdout" - [ -n "$stderr" ] && printf '%s\n' "$stderr" >&2 + [ -n "$stdout" ] && printf '%s' "$stdout" + [ -n "$stderr" ] && printf '%s' "$stderr" >&2 } 2>&1 ) # Smashed form: the bug allowed "...Options\"Connecting" to appear on a # single rendered line. Assert the smashed sequence is gone. if printf '%s' "$combined" | grep -qE '"Options"Connecting'; then - printf 'FAIL: stderr replay smashes into stdout last line — printf %%s\\n regression\n' >&2 + printf 'FAIL: stderr replay smashes into stdout last line — trailing newline regression\n' >&2 printf 'Got (truncated):\n' >&2 printf '%s' "$combined" | head -10 >&2 exit 1 @@ -65,12 +81,16 @@ fi # Empty-output regression: no extra blank lines when both streams are empty. EMPTY='{"stdout":"","stderr":"","exitCode":0,"forwarded":true}' -estdout=$(printf '%s' "$EMPTY" | jq -r '.stdout // ""') -estderr=$(printf '%s' "$EMPTY" | jq -r '.stderr // ""') +empty_fields=() +while IFS= read -r -d '' field; do + empty_fields+=("$field") +done < <(printf '%s' "$EMPTY" | jq -j '(.stdout // ""), "\u0000", (.stderr // ""), "\u0000", "0", "\u0000"') +estdout="${empty_fields[0]}" +estderr="${empty_fields[1]}" empty_out=$( { - [ -n "$estdout" ] && printf '%s\n' "$estdout" - [ -n "$estderr" ] && printf '%s\n' "$estderr" >&2 + [ -n "$estdout" ] && printf '%s' "$estdout" + [ -n "$estderr" ] && printf '%s' "$estderr" >&2 } 2>&1 ) if [ -n "$empty_out" ]; then diff --git a/scripts/trailblaze b/scripts/trailblaze index 9122cd4a4..03a3d6a81 100755 --- a/scripts/trailblaze +++ b/scripts/trailblaze @@ -77,14 +77,23 @@ else fi # --------------------------------------------------------------------------- -# JDK version check +# JDK version check (lazy) # --------------------------------------------------------------------------- - -JAVA_VERSION=$("$JAVA_BIN" -version 2>&1 | head -1 | sed -E 's/.*"([0-9]+).*/\1/') -if [ -n "$JAVA_VERSION" ] && [ "$JAVA_VERSION" -lt 17 ] 2>/dev/null; then - echo "ERROR: Trailblaze requires JDK 17 or later, but found JDK ${JAVA_VERSION}." >&2 - exit 1 -fi +# +# A daemon-forwarded command never starts Java in this process, so probing `java -version` +# before the IPC decision was pure hot-path overhead. JVM fallbacks call this immediately +# before their first tb_run* invocation; the guard keeps auto-start + command execution from +# probing twice in one wrapper process. +tb_check_java_version() { + [ "${TB_JAVA_VERSION_CHECKED:-0}" = "1" ] && return 0 + TB_JAVA_VERSION_CHECKED=1 + local java_version + java_version=$("$JAVA_BIN" -version 2>&1 | head -1 | sed -E 's/.*"([0-9]+).*/\1/') + if [ -n "$java_version" ] && [ "$java_version" -lt 17 ] 2>/dev/null; then + echo "ERROR: Trailblaze requires JDK 17 or later, but found JDK ${java_version}." >&2 + return 1 + fi +} # --------------------------------------------------------------------------- # Execution @@ -320,16 +329,6 @@ record_daemon_pid() { # On any form of failure, returns 1 so the caller can fall through. ipc_try_forward() { command -v jq >/dev/null 2>&1 || return 1 - # Build the args JSON array by appending each arg with `jq --arg`, which - # passes the string through without any in-band delimiter parsing. The - # previous `printf '%s\n' | jq -R . | jq -s .` pipeline silently split - # on embedded newlines, corrupting multi-line args (realistic for `ask` - # prompts that contain quoted code or paragraphs). - local args_json="[]" - local a - for a in "$@"; do - args_json=$(jq --arg a "$a" '. + [$a]' <<<"$args_json") || return 1 - done # Forward the user's interactive shell env vars that drive CLI resolution. The # daemon's own JVM env was captured at `app start` time, so `System.getenv` # on the daemon side never sees an `export TRAILBLAZE_DEVICE=…` the user did @@ -344,33 +343,38 @@ ipc_try_forward() { # Don't add general shell env (PATH, HOME, etc.) — only TRAILBLAZE_* vars # that drive CLI resolution. Keep this list in sync with `CliCallerContext` # consumers in `CliInfrastructure.kt` — one `env*()` / `resolveCli*()` - # reader function per allowlisted var. The two near-identical if-blocks - # below are kept explicit (not factored into a bash helper) at this scale; - # revisit if the list grows past ~4 vars. - local env_json="{}" - if [ -n "${TRAILBLAZE_DEVICE:-}" ]; then - env_json=$(jq --arg v "$TRAILBLAZE_DEVICE" '. + {TRAILBLAZE_DEVICE: $v}' <<<"$env_json") || return 1 - fi - if [ -n "${TRAILBLAZE_TARGET:-}" ]; then - env_json=$(jq --arg v "$TRAILBLAZE_TARGET" '. + {TRAILBLAZE_TARGET: $v}' <<<"$env_json") || return 1 - fi - if [ -n "${TRAILBLAZE_SHELL_PID:-}" ]; then - env_json=$(jq --arg v "$TRAILBLAZE_SHELL_PID" '. + {TRAILBLAZE_SHELL_PID: $v}' <<<"$env_json") || return 1 - fi - if [ -n "${TRAILBLAZE_INTERACTIVE:-}" ]; then - env_json=$(jq --arg v "$TRAILBLAZE_INTERACTIVE" '. + {TRAILBLAZE_INTERACTIVE: $v}' <<<"$env_json") || return 1 - fi + # reader function per allowlisted var. + # + # Build argv + cwd + env in ONE jq process. The old implementation appended every + # argument and env var with a separate jq invocation, then spawned another jq for the + # final payload. A three-argument snapshot with the standard shell metadata paid for six + # short-lived jq processes before curl even started. `--args` preserves every argument + # byte (including embedded newlines) without an in-band delimiter. # Send the user's interactive cwd alongside argv so commands that walk relative # paths (e.g. `waypoint --target` resolving the workspace anchor) anchor at the # user's shell directory rather than the daemon's launch directory. Older daemons # ignore unknown fields (kotlinx.serialization with ignoreUnknownKeys=true); # newer daemons fall back to their own cwd/env if absent. See [CliCallerContext]. local payload - payload=$(jq -n \ - --argjson args "$args_json" \ + payload=$(jq -cn \ --arg cwd "$PWD" \ - --argjson env "$env_json" \ - '{args: $args, cwd: $cwd, env: $env}') || return 1 + --arg device "${TRAILBLAZE_DEVICE:-}" \ + --arg target "${TRAILBLAZE_TARGET:-}" \ + --arg shell_pid "${TRAILBLAZE_SHELL_PID:-}" \ + --arg interactive "${TRAILBLAZE_INTERACTIVE:-}" \ + --args ' + { + args: $ARGS.positional, + cwd: $cwd, + env: ( + {} + + (if $device == "" then {} else {TRAILBLAZE_DEVICE: $device} end) + + (if $target == "" then {} else {TRAILBLAZE_TARGET: $target} end) + + (if $shell_pid == "" then {} else {TRAILBLAZE_SHELL_PID: $shell_pid} end) + + (if $interactive == "" then {} else {TRAILBLAZE_INTERACTIVE: $interactive} end) + ) + } + ' -- "$@") || return 1 local response response=$(curl -sS --connect-timeout 2 --max-time 600 \ @@ -378,51 +382,39 @@ ipc_try_forward() { -H "Content-Type: application/json" \ -d "$payload" 2>/dev/null) || return 1 - # Daemon returned non-JSON (endpoint missing on older daemon, etc.) — fall through. - echo "$response" | jq -e . >/dev/null 2>&1 || return 1 - - local forwarded - forwarded=$(echo "$response" | jq -r '.forwarded // false') - [ "$forwarded" = "true" ] || return 1 - - local stdout stderr exit_code - stdout=$(echo "$response" | jq -r '.stdout // ""') - stderr=$(echo "$response" | jq -r '.stderr // ""') - # Clamp exitCode to a shell-valid integer 0-255. jq returns the literal - # string "null" if the field is missing, and bash `exit "$str"` fails - # noisily with "numeric argument required" on anything non-numeric. - exit_code=$(echo "$response" | jq -r ' - if .exitCode | type == "number" then - (.exitCode | floor | if . < 0 then 256 + (. % 256) elif . > 255 then . % 256 else . end) - else 1 end - ') + # Validate and decode the response in ONE jq process. NUL delimiters let bash `read` + # preserve trailing newlines exactly; the previous four command substitutions each + # spawned jq and stripped those newlines, forcing replay to guess one back with `%s\n`. + # A JSON/non-forwarded/shape error leaves fewer than three fields and falls through. + local fields=() + local field + while IFS= read -r -d '' field; do + fields+=("$field") + done < <(printf '%s' "$response" | jq -j ' + if (.forwarded // false) != true then + error("not forwarded") + else + (.stdout // ""), "\u0000", + (.stderr // ""), "\u0000", + ( + if (.exitCode | type) == "number" then + (.exitCode | floor | if . < 0 then 256 + (. % 256) elif . > 255 then . % 256 else . end) + else 1 end + | tostring + ), "\u0000" + end + ' 2>/dev/null) + [ "${#fields[@]}" -eq 3 ] || return 1 + + local stdout="${fields[0]}" + local stderr="${fields[1]}" + local exit_code="${fields[2]}" [[ "$exit_code" =~ ^[0-9]+$ ]] || exit_code=1 - # Bash `$(jq -r …)` command substitution strips ALL trailing newlines - # from the captured stream — so a daemon-side `println` ending with `\n` - # arrives here as no-newline. Without restoring it, stdout (e.g. the - # snapshot UI tree) visually smashes into the stderr replay that follows - # ("Connecting to …", "Connected: …") and into the shell prompt afterwards. - # `%s\n` restores the newline `println` already emitted at the source — - # this is replaying captured bytes, not injecting a spurious blank line. - # - # Invariant this depends on: every subcommand in FORWARDABLE_SUBCOMMANDS - # emits only line-terminated output (Console.log/info/error → println). - # A future candidate that uses Console.appendLog/appendInfo or raw print() - # before exit would render a phantom blank line here. See the note on - # TrailblazeCli.FORWARDABLE_SUBCOMMANDS. - # - # Regression guard: scripts/test_ipc_replay.sh asserts the smashed form - # is gone for a synthetic snapshot-shaped response. - # - # NOT fixed here: the two streams are still replayed sequentially (all - # stdout, then all stderr), so connection chatter that fires - # CHRONOLOGICALLY before the snapshot still RENDERS after it. Proper fix - # is interleaved capture (per-chunk stream tagging in CliOutCapture + - # wire-format change to CliExecResponse) — deferred as out-of-scope for - # the smashed-streams readability fix. - [ -n "$stdout" ] && printf '%s\n' "$stdout" - [ -n "$stderr" ] && printf '%s\n' "$stderr" >&2 + # Streams are still replayed sequentially (all stdout, then all stderr). Proper + # chronological interleaving requires a tagged-chunk wire format and remains separate. + [ -n "$stdout" ] && printf '%s' "$stdout" + [ -n "$stderr" ] && printf '%s' "$stderr" >&2 exit "$exit_code" } @@ -538,7 +530,10 @@ elif [ "${TRAILBLAZE_IPC:-1}" = "1" ] \ # Fast path: forward to the already-running daemon instead of spawning a JVM. # ipc_try_forward either exits (success) or returns nonzero so we fall through # to the normal JVM path below. - ipc_try_forward "${ARGS_ARRAY[@]}" || tb_run_quiet "${ARGS_ARRAY[@]}" + ipc_try_forward "${ARGS_ARRAY[@]}" || { + tb_check_java_version || exit 1 + tb_run_quiet "${ARGS_ARRAY[@]}" + } elif [ "$STDIO_MODE" = true ]; then # STDIO MCP mode: quiet output so only JSON-RPC goes to stdout. @@ -552,6 +547,7 @@ elif [ "$STDIO_MODE" = true ]; then abort_if_stuck_daemon echo "[mcp] Starting Trailblaze daemon..." >&2 # Ports are passed via exported TRAILBLAZE_PORT/TRAILBLAZE_HTTPS_PORT env vars + tb_check_java_version || exit 1 tb_run_background_quiet app --foreground --headless record_daemon_pid "$!" echo "[mcp] Daemon starting (PID $!)..." >&2 @@ -572,6 +568,7 @@ elif [ "$STDIO_MODE" = true ]; then fi fi + tb_check_java_version || exit 1 tb_run_exec_quiet "${ARGS_ARRAY[@]}" elif ([ "$1" = "trail" ] || [ "$1" = "run" ] || [ "$1" = "blaze" ] || [ "$1" = "ask" ] || ([ "$1" = "session" ] && [ "$2" != "info" ]) || [ "$1" = "tools" ]) && ! is_daemon_running && ! daemon_autostart_disabled; then abort_if_stuck_daemon @@ -579,6 +576,7 @@ elif ([ "$1" = "trail" ] || [ "$1" = "run" ] || [ "$1" = "blaze" ] || [ "$1" = " # Use stderr so --json output on stdout stays clean. echo "Starting Trailblaze daemon..." >&2 # Ports are passed via exported TRAILBLAZE_PORT/TRAILBLAZE_HTTPS_PORT env vars + tb_check_java_version || exit 1 tb_run_background app --foreground --headless record_daemon_pid "$!" @@ -612,8 +610,10 @@ elif ([ "$1" = "trail" ] || [ "$1" = "run" ] || [ "$1" = "blaze" ] || [ "$1" = " exit $RUN_EXIT elif [ ${#ARGS_ARRAY[@]} -gt 0 ]; then # CLI command mode (config, auth, etc.) + tb_check_java_version || exit 1 tb_run_quiet "${ARGS_ARRAY[@]}" else # No subcommand: show help + tb_check_java_version || exit 1 tb_run_quiet --help fi diff --git a/sdks/typescript/src/generated/trailrunner-dtos.ts b/sdks/typescript/src/generated/trailrunner-dtos.ts index 61988d8cf..a5441e70d 100644 --- a/sdks/typescript/src/generated/trailrunner-dtos.ts +++ b/sdks/typescript/src/generated/trailrunner-dtos.ts @@ -40,6 +40,26 @@ export interface CancelSessionResponse { reason?: string | null; } +export interface CompanionDirectiveDto { + seq: number; + payload?: string | null; +} + +export interface CompanionRequestDto { + requestId: string; + kind: string; + payload?: string | null; + status: string; + note?: string | null; +} + +export interface CompanionStateDto { + agentLabel?: string | null; + folder?: string | null; + directives?: Record; + requests?: Record; +} + export interface CreateTrailDirRequest { path: string; } @@ -164,6 +184,7 @@ export interface ExternalAgentRunDto { eventCount?: number; demo?: DemoStateDto | null; demoRunId?: string | null; + companion?: CompanionStateDto | null; pendingPermissions?: ExternalAgentPermissionRequestDto[]; autoApprove?: boolean; } @@ -323,16 +344,6 @@ export interface LlmSettingsDto { availableAgents?: AgentOptionDto[]; } -export interface MigrateFolderResponse { - success: boolean; - outputName?: string | null; - steps?: number; - driftCount?: number; - drift?: string[]; - removed?: string[]; - error?: string | null; -} - export interface NewComponentRequest { trailmap: string; kind: string; diff --git a/settings.gradle.kts b/settings.gradle.kts index 4de564f1d..9e5560560 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -73,6 +73,7 @@ include( ":trailblaze-desktop", ":trailblaze-host", ":trailblaze-models", + ":trailblaze-ondevice-rpc-proto", ":trailblaze-trailmap-bundler", ":trailblaze-compose", ":trailblaze-playwright", diff --git a/skills/trailblaze/SKILL.md b/skills/trailblaze/SKILL.md index e07d1c153..09c2645cf 100644 --- a/skills/trailblaze/SKILL.md +++ b/skills/trailblaze/SKILL.md @@ -158,6 +158,46 @@ load whichever matches the task at hand. trailmap composition via `dependencies:`, and the four-checkpoint workflow for diagnosing a missing tool. +## Companion mode + +An agent-attached authoring session: your coding agent is the single writer of a trail folder's +files, and Trail Runner opens a read-only live view of that folder for the human to watch and +steer. Start one with `trailblaze companion start --folder --title "" +--agent claude|codex`, then tail what the human does with `trailblaze companion listen `. + +Steer the window with standing directives - `banner`, `checklist`, `actions` (quick-reply chips), +`select-app-target`, `select-device`, `arm-recording`, and the one-shot `navigate`. Each is +latest-per-name state that survives window reloads; re-send one with no fields to retract it. + +**Single-writer rule:** the UI never writes trail files back. A human "Save" click or a guided +recording both go through the daemon, which writes the file and then tells every listening +session about it. + +Two events matter most on the listen stream: + +- **`recording-saved`** - a recording landed in your folder, whether from a companion save or + Trail Runner's own board record flow; it fans out to every companion session watching that + folder, not just the one that wrote it. +- **`run-started` / `run-finished`** - a human ran a trail from Trail Runner's UI whose path is + inside your folder; both carry the run's `sessionId` and your `folder`, and `run-finished` adds + `status: succeeded|failed|cancelled`. Only Trail Runner's own run endpoints announce, and only + for primary-root trail/bundle ids - a raw-YAML replay (e.g. via MCP) bypasses that dispatch + seam and stays silent. + +Shared-brain requests: if the human clicks "Review my trail" (or asks for proposed steps) in +Trail Runner while your listen stream is open, the daemon queues the ask on you instead of calling +its own LLM - watch for a `human_action` event titled `agent-request` with `{requestId, kind, +payload}` (kind `review-trail` or `propose-steps`). Do the review by editing the trail folder's +files yourself, then settle it with `trailblaze companion respond --request --status +done|error`. A request you never answer is cancelled when the session ends. + +Companion journals (`.companion/journal-.jsonl`) age out after 7 days, swept the next time +a session connects to that folder - never at disconnect, since a crashed agent resuming with +`--after` needs its own journal. + +`trailblaze companion --agent-help` prints the full event and directive contract; load that +instead of guessing at the wire format. + ## Self-heal Recorded trails replay deterministically by default — no LLM in the diff --git a/trailblaze-agent/dependencies/runtimeClasspath.txt b/trailblaze-agent/dependencies/runtimeClasspath.txt index 79c84eed8..e6ccd5acb 100644 --- a/trailblaze-agent/dependencies/runtimeClasspath.txt +++ b/trailblaze-agent/dependencies/runtimeClasspath.txt @@ -1,5 +1,6 @@ :trailblaze-common :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-tracing ai.koog:agents-core-jvm:1.0.0 ai.koog:agents-core:1.0.0 @@ -90,6 +91,8 @@ com.squareup.okhttp3:okhttp-jvm:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:dadb:1.2.10 dev.mobile:maestro-client:2.6.1 @@ -110,6 +113,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/trailblaze-android-ondevice-mcp/build.gradle.kts b/trailblaze-android-ondevice-mcp/build.gradle.kts index 85644d6ce..3e2a8f78b 100644 --- a/trailblaze-android-ondevice-mcp/build.gradle.kts +++ b/trailblaze-android-ondevice-mcp/build.gradle.kts @@ -43,6 +43,7 @@ android { dependencies { implementation(project(":trailblaze-common")) implementation(project(":trailblaze-models")) + implementation(project(":trailblaze-ondevice-rpc-proto")) implementation(project(":trailblaze-android")) implementation(project(":trailblaze-agent")) @@ -51,6 +52,7 @@ dependencies { implementation(libs.coroutines) implementation(libs.okhttp) implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.serialization.kotlinx.json) diff --git a/trailblaze-android-ondevice-mcp/dependencies/debugRuntimeClasspath.txt b/trailblaze-android-ondevice-mcp/dependencies/debugRuntimeClasspath.txt index 7cfbca2e1..9036144c6 100644 --- a/trailblaze-android-ondevice-mcp/dependencies/debugRuntimeClasspath.txt +++ b/trailblaze-android-ondevice-mcp/dependencies/debugRuntimeClasspath.txt @@ -2,6 +2,7 @@ :trailblaze-android :trailblaze-common :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-quickjs-tools :trailblaze-tracing ai.koog:agents-core-android:1.0.0 @@ -99,6 +100,8 @@ com.squareup.okhttp3:okhttp-android:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:maestro-client:2.6.1 dev.mobile:maestro-orchestra-models:2.6.1 @@ -116,6 +119,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcServer.kt b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcServer.kt index fee1eb04f..acf2727e1 100644 --- a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcServer.kt +++ b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcServer.kt @@ -13,6 +13,7 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import xyz.block.trailblaze.AgentMemory @@ -76,10 +77,26 @@ class OnDeviceRpcServer( factory = CIO, port = port, ) { + install(WebSockets) install(ContentNegotiation) { json(TrailblazeJsonInstance) } + val runYamlHandler = RunYamlRequestHandler( + loggingRule = loggingRule, + backgroundScope = backgroundScope, + getCurrentJob = { currPromptJob }, + setCurrentJob = { job -> currPromptJob = job }, + runTrailblazeYaml = runTrailblazeYaml, + trailblazeDeviceInfoProvider = trailblazeDeviceInfoProvider, + progressManager = progressManager, + ) + val screenStateHandler = GetScreenStateRequestHandler(deviceClassifiers) + val drainSessionHandler = DrainSessionRequestHandler() + val subscribeToProgressHandler = SubscribeToProgressRequestHandler(progressManager) + val getExecutionStatusHandler = GetExecutionStatusRequestHandler(progressManager) + val listActiveSessionsHandler = ListActiveSessionsRequestHandler(progressManager) + routing { get("/ping") { // Used to make sure the server is available @@ -87,36 +104,40 @@ class OnDeviceRpcServer( } // Register unified request handler that routes based on agentImplementation - registerRpcHandler( - RunYamlRequestHandler( - loggingRule = loggingRule, - backgroundScope = backgroundScope, - getCurrentJob = { currPromptJob }, - setCurrentJob = { job -> currPromptJob = job }, - runTrailblazeYaml = runTrailblazeYaml, - trailblazeDeviceInfoProvider = trailblazeDeviceInfoProvider, - progressManager = progressManager, - ) - ) + registerRpcHandler(runYamlHandler) // Register GetScreenState handler for MCP subagent screen state queries. // Host readiness polling via OnDeviceRpcClient.waitForReady uses this endpoint — // a successful GetScreenState proves HTTP server is up, accessibility service is // bound, and the window is populated, subsuming the old EnsureAccessibilityReady RPC. // Pass device classifiers so the host can learn the actual device type. - registerRpcHandler(GetScreenStateRequestHandler(deviceClassifiers)) + registerRpcHandler(screenStateHandler) + + // Preferred host transport: one persistent binary channel carrying typed Wire-generated + // protobuf messages for every RPC. The HTTP/JSON routes remain for compatibility and the + // explicit rollback mode. + registerOnDeviceRpcWebSocket( + handlers = OnDeviceProtoRpcHandlers( + getScreenState = screenStateHandler::handleBinary, + runYaml = runYamlHandler::handle, + drainSession = drainSessionHandler::handle, + subscribeToProgress = subscribeToProgressHandler::handle, + getExecutionStatus = getExecutionStatusHandler::handle, + listActiveSessions = listActiveSessionsHandler::handle, + ), + ) // DrainSession lets the host proactively clear UiAutomation cache before tearing down // its persistent driver — prevents the system_server-wedge pattern from build 5463 // where stale Instrumentation.mUiAutomation kept yielding DeadObjectException across // session re-connects. Host-old/APK-new: handler unused. Host-new/APK-old: hits the // catch-all 404 below and the host treats it as a no-op. - registerRpcHandler(DrainSessionRequestHandler()) + registerRpcHandler(drainSessionHandler) // Register progress-related handlers for MCP clients (Phase 6) - registerRpcHandler(SubscribeToProgressRequestHandler(progressManager)) - registerRpcHandler(GetExecutionStatusRequestHandler(progressManager)) - registerRpcHandler(ListActiveSessionsRequestHandler(progressManager)) + registerRpcHandler(subscribeToProgressHandler) + registerRpcHandler(getExecutionStatusHandler) + registerRpcHandler(listActiveSessionsHandler) // Catch-all for unregistered RPC endpoints post("/rpc/{...}") { @@ -147,4 +168,4 @@ class OnDeviceRpcServer( * - Register as event listeners via [ProgressSessionManager.onProgressEvent] */ fun getProgressManager(): ProgressSessionManager = progressManager -} \ No newline at end of file +} diff --git a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRoute.kt b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRoute.kt new file mode 100644 index 000000000..31df61811 --- /dev/null +++ b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRoute.kt @@ -0,0 +1,166 @@ +package xyz.block.trailblaze.android.runner.rpc + +import io.ktor.server.routing.Route +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readBytes +import io.ktor.websocket.send +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import xyz.block.trailblaze.mcp.android.ondevice.rpc.RpcResult +import xyz.block.trailblaze.llm.RunYamlRequest +import xyz.block.trailblaze.llm.RunYamlResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.DrainSessionRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.DrainSessionResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetExecutionStatusRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetExecutionStatusResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.ListActiveSessionsRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.ListActiveSessionsResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.SubscribeToProgressRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.SubscribeToProgressResponse +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec +import xyz.block.trailblaze.ondevice.rpc.proto.RpcFailure +import xyz.block.trailblaze.ondevice.rpc.proto.RpcRequestEnvelope +import xyz.block.trailblaze.ondevice.rpc.proto.RpcResponseEnvelope +import xyz.block.trailblaze.util.Console + +internal data class OnDeviceProtoRpcHandlers( + val getScreenState: suspend (GetScreenStateRequest) -> RpcResult, + val runYaml: suspend (RunYamlRequest) -> RpcResult, + val drainSession: suspend (DrainSessionRequest) -> RpcResult, + val subscribeToProgress: + suspend (SubscribeToProgressRequest) -> RpcResult, + val getExecutionStatus: + suspend (GetExecutionStatusRequest) -> RpcResult, + val listActiveSessions: + suspend (ListActiveSessionsRequest) -> RpcResult, +) + +/** Binary WebSocket transport for all host-to-Android RPC calls. */ +internal fun Route.registerOnDeviceRpcWebSocket( + handlers: OnDeviceProtoRpcHandlers, +) { + webSocket(ON_DEVICE_RPC_WEBSOCKET_PATH) { + val sendMutex = Mutex() + for (frame in incoming) { + if (frame !is Frame.Binary) continue + val bytes = frame.readBytes() + launch { + val response = handleBinaryRequest(bytes, handlers) + sendMutex.withLock { + send(Frame.Binary(fin = true, data = OnDeviceRpcProtoCodec.encode(response))) + } + } + } + } +} + +internal suspend fun handleBinaryRequest( + bytes: ByteArray, + handlers: OnDeviceProtoRpcHandlers, +): RpcResponseEnvelope { + val request = try { + OnDeviceRpcProtoCodec.decodeRequest(bytes) + } catch (e: Exception) { + return failureResponse( + requestId = 0, + errorType = RpcResult.ErrorType.SERIALIZATION_ERROR, + message = "Invalid protobuf request", + details = e.message, + ) + } + + return try { + val getScreenState = request.get_screen_state + val runYaml = request.run_yaml + val drainSession = request.drain_session + val subscribeToProgress = request.subscribe_to_progress + val getExecutionStatus = request.get_execution_status + val listActiveSessions = request.list_active_sessions + OnDeviceRpcProtoCodec.run { + when { + getScreenState != null -> handlers.getScreenState(getScreenState.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, get_screen_state = value.toProto()) + } + runYaml != null -> handlers.runYaml(runYaml.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, run_yaml = value.toProto()) + } + drainSession != null -> handlers.drainSession(drainSession.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, drain_session = value.toProto()) + } + subscribeToProgress != null -> + handlers.subscribeToProgress(subscribeToProgress.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, subscribe_to_progress = value.toProto()) + } + getExecutionStatus != null -> + handlers.getExecutionStatus(getExecutionStatus.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, get_execution_status = value.toProto()) + } + listActiveSessions != null -> + handlers.listActiveSessions(listActiveSessions.toModel()) + .toEnvelope(request.request_id) { id, value -> + RpcResponseEnvelope(request_id = id, list_active_sessions = value.toProto()) + } + else -> failureResponse( + requestId = request.request_id, + errorType = RpcResult.ErrorType.SERIALIZATION_ERROR, + message = "Protobuf request omitted its payload", + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: kotlinx.serialization.SerializationException) { + failureResponse( + requestId = request.request_id, + errorType = RpcResult.ErrorType.SERIALIZATION_ERROR, + message = e.message ?: "Binary RPC body failed to deserialize", + details = e::class.simpleName, + ) + } catch (e: Exception) { + Console.log("[OnDeviceRpcWebSocket] request failed: ${e.message}") + failureResponse( + requestId = request.request_id, + errorType = RpcResult.ErrorType.UNKNOWN_ERROR, + message = "Binary RPC request failed: ${e.message}", + details = e.stackTraceToString(), + ) + } +} + +private inline fun RpcResult.toEnvelope( + requestId: Long, + success: (Long, T) -> RpcResponseEnvelope, +): RpcResponseEnvelope = when (this) { + is RpcResult.Success -> success(requestId, data) + is RpcResult.Failure -> RpcResponseEnvelope( + request_id = requestId, + failure = OnDeviceRpcProtoCodec.run { toProto() }, + ) +} + +private fun failureResponse( + requestId: Long, + errorType: RpcResult.ErrorType, + message: String, + details: String? = null, +): RpcResponseEnvelope = + RpcResponseEnvelope( + request_id = requestId, + failure = RpcFailure( + error_type = errorType.name, + message = message, + details = details, + ), + ) + +internal const val ON_DEVICE_RPC_WEBSOCKET_PATH = "/rpc-ws" diff --git a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandler.kt b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandler.kt index 573c90130..b55e0e7e9 100644 --- a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandler.kt +++ b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandler.kt @@ -33,6 +33,37 @@ class GetScreenStateRequestHandler( ) : RpcHandler { override suspend fun handle(request: GetScreenStateRequest): RpcResult { + return when (val captured = capture(request)) { + is RpcResult.Failure -> captured + is RpcResult.Success -> RpcResult.Success( + buildResponse( + request = request, + screenState = captured.data.screenState, + deviceClassifiers = deviceClassifiers, + driverMigrationTreeNode = captured.data.driverMigrationTreeNode, + capturedAtDeviceMs = captured.data.capturedAtDeviceMs, + ), + ) + } + } + + /** Binary twin of [handle] that keeps screenshot bytes raw instead of base64 encoding them. */ + internal suspend fun handleBinary(request: GetScreenStateRequest): RpcResult { + return when (val captured = capture(request)) { + is RpcResult.Failure -> captured + is RpcResult.Success -> RpcResult.Success( + buildBinaryResponse( + request = request, + screenState = captured.data.screenState, + deviceClassifiers = deviceClassifiers, + driverMigrationTreeNode = captured.data.driverMigrationTreeNode, + capturedAtDeviceMs = captured.data.capturedAtDeviceMs, + ), + ) + } + } + + private suspend fun capture(request: GetScreenStateRequest): RpcResult { return try { val useAccessibility = TrailblazeAccessibilityService.isServiceRunning() if (request.requireAndroidAccessibilityService && !useAccessibility) { @@ -86,6 +117,11 @@ class GetScreenStateRequestHandler( ) } + // Stamped as soon as the ScreenState constructor returns — i.e. when the (screenshot, + // tree) pair is final. Device epoch, same clock as on-device session logs, so the host + // can correlate this capture against other device-clock timestamps. + val capturedAtDeviceMs = System.currentTimeMillis() + Console.log("📱 GetScreenStateRequestHandler: Screen captured (${screenState.deviceWidth}x${screenState.deviceHeight})") // Side-channel migration tree. Captured separately from [screenState] so the primary @@ -102,7 +138,7 @@ class GetScreenStateRequestHandler( null } - RpcResult.Success(buildResponse(request, screenState, deviceClassifiers, driverMigrationTreeNode)) + RpcResult.Success(CapturedScreenState(screenState, driverMigrationTreeNode, capturedAtDeviceMs)) } catch (e: Exception) { Console.log("❌ GetScreenStateRequestHandler: Failed to capture screen state: ${e.message}") e.printStackTrace() @@ -114,6 +150,13 @@ class GetScreenStateRequestHandler( } } + private data class CapturedScreenState( + val screenState: ScreenState, + val driverMigrationTreeNode: TrailblazeNode?, + /** Device-epoch stamp taken when the (screenshot, tree) pair was final. */ + val capturedAtDeviceMs: Long, + ) + companion object { /** * Builds the wire response from a captured [ScreenState] and the incoming @@ -128,6 +171,7 @@ class GetScreenStateRequestHandler( screenState: ScreenState, deviceClassifiers: List = emptyList(), driverMigrationTreeNode: TrailblazeNode? = null, + capturedAtDeviceMs: Long? = null, ): GetScreenStateResponse { val screenshotBase64 = if (request.includeScreenshot) { screenState.screenshotBytes?.encodeBase64() @@ -153,7 +197,40 @@ class GetScreenStateRequestHandler( driverMigrationTreeNode = driverMigrationTreeNode, pageContextSummary = screenState.pageContextSummary, deviceClassifiers = classifierStrings, + capturedAtDeviceMs = capturedAtDeviceMs, ) } + + /** Builds the same domain response without the JSON transport's base64 conversion. */ + internal fun buildBinaryResponse( + request: GetScreenStateRequest, + screenState: ScreenState, + deviceClassifiers: List = emptyList(), + driverMigrationTreeNode: TrailblazeNode? = null, + capturedAtDeviceMs: Long? = null, + ): GetScreenStateResponse { + val screenshotBytes = if (request.includeScreenshot) screenState.screenshotBytes else null + val annotatedScreenshotBytes = + if (request.includeScreenshot && request.includeAnnotatedScreenshot) { + screenState.annotatedScreenshotBytes + } else { + null + } + return GetScreenStateResponse( + viewHierarchy = screenState.viewHierarchy, + screenshotBase64 = null, + annotatedScreenshotBase64 = null, + deviceWidth = screenState.deviceWidth, + deviceHeight = screenState.deviceHeight, + trailblazeNodeTree = screenState.trailblazeNodeTree, + driverMigrationTreeNode = driverMigrationTreeNode, + pageContextSummary = screenState.pageContextSummary, + deviceClassifiers = deviceClassifiers.map { it.classifier }.takeIf { it.isNotEmpty() }, + capturedAtDeviceMs = capturedAtDeviceMs, + ).apply { + this.screenshotBytes = screenshotBytes + this.annotatedScreenshotBytes = annotatedScreenshotBytes + } + } } } diff --git a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/RunYamlRequestHandler.kt b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/RunYamlRequestHandler.kt index ccfefd22c..2e5ac95b2 100644 --- a/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/RunYamlRequestHandler.kt +++ b/trailblaze-android-ondevice-mcp/src/main/java/xyz/block/trailblaze/mcp/handlers/RunYamlRequestHandler.kt @@ -203,8 +203,12 @@ class RunYamlRequestHandler( if (requestDriverType != null) info.copy(trailblazeDriverType = requestDriverType) else info } val hasRecordedSteps = try { + // decodeTrailOrToolEnvelope (superset of decodeTrail): the host-drives-the-loop path sends a + // bare `- :` tool envelope on this same `yaml` field, which decodes to one + // ToolTrailItem (hasRecordedSteps → true). Plain decodeTrail throws on that shape, which would + // silently flip the flag to false and mislabel single-tool dispatch as agent-driven. trailblazeYaml.hasRecordedSteps( - trailblazeYaml.decodeTrail(request.yaml) + trailblazeYaml.decodeTrailOrToolEnvelope(request.yaml) ) } catch (e: Exception) { false diff --git a/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRouteTest.kt b/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRouteTest.kt new file mode 100644 index 000000000..41a00fd29 --- /dev/null +++ b/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/android/runner/rpc/OnDeviceRpcWebSocketRouteTest.kt @@ -0,0 +1,43 @@ +package xyz.block.trailblaze.android.runner.rpc + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking +import xyz.block.trailblaze.mcp.android.ondevice.rpc.DrainSessionRequest +import xyz.block.trailblaze.mcp.android.ondevice.rpc.DrainSessionResponse +import xyz.block.trailblaze.mcp.android.ondevice.rpc.RpcResult +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec +import xyz.block.trailblaze.ondevice.rpc.proto.RpcRequestEnvelope + +class OnDeviceRpcWebSocketRouteTest { + + @Test + fun `typed RPC is dispatched through the binary envelope`() = runBlocking { + val request = RpcRequestEnvelope( + request_id = 42, + drain_session = OnDeviceRpcProtoCodec.run { + DrainSessionRequest(reason = "test").toProto() + }, + ) + + val response = handleBinaryRequest( + bytes = OnDeviceRpcProtoCodec.encode(request), + handlers = OnDeviceProtoRpcHandlers( + getScreenState = { error("unexpected screen request") }, + runYaml = { error("unexpected run request") }, + drainSession = { + assertEquals("test", it.reason) + RpcResult.Success(DrainSessionResponse(uiAutomationCleared = true)) + }, + subscribeToProgress = { error("unexpected progress request") }, + getExecutionStatus = { error("unexpected status request") }, + listActiveSessions = { error("unexpected list request") }, + ), + ) + + assertEquals(42, response.request_id) + assertEquals(true, response.drain_session?.ui_automation_cleared) + assertNull(response.failure) + } +} diff --git a/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandlerTest.kt b/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandlerTest.kt index 87d3ed122..166924a12 100644 --- a/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandlerTest.kt +++ b/trailblaze-android-ondevice-mcp/src/test/java/xyz/block/trailblaze/mcp/handlers/GetScreenStateRequestHandlerTest.kt @@ -2,6 +2,7 @@ package xyz.block.trailblaze.mcp.handlers import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertContentEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import xyz.block.trailblaze.api.DriverNodeDetail @@ -12,6 +13,7 @@ import xyz.block.trailblaze.devices.TrailblazeDeviceClassifier import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateRequest import xyz.block.trailblaze.mcp.handlers.GetScreenStateRequestHandler.Companion.buildResponse +import xyz.block.trailblaze.mcp.handlers.GetScreenStateRequestHandler.Companion.buildBinaryResponse /** * JVM-only tests for the pure response-builder extracted from @@ -56,6 +58,36 @@ class GetScreenStateRequestHandlerTest { assertNotNull(response.annotatedScreenshotBase64) } + @Test + fun `buildBinaryResponse keeps screenshots raw and leaves base64 empty`() { + val screenState = FixedBytesScreenState(clean = byteArrayOf(1, 2), annotated = byteArrayOf(9, 9)) + val request = GetScreenStateRequest( + includeScreenshot = true, + includeAnnotatedScreenshot = true, + ) + + val response = buildBinaryResponse(request, screenState) + + assertContentEquals(byteArrayOf(1, 2), response.screenshotBytes) + assertContentEquals(byteArrayOf(9, 9), response.annotatedScreenshotBytes) + assertNull(response.screenshotBase64) + assertNull(response.annotatedScreenshotBase64) + } + + @Test + fun `both builders carry the capture timestamp onto the wire`() { + val screenState = FixedBytesScreenState(clean = byteArrayOf(1), annotated = byteArrayOf(2)) + val request = GetScreenStateRequest(includeScreenshot = false) + + // The stream-screenshot gate reads this stamp on whichever transport served the response, + // so the JSON and binary builders must both forward it. + assertEquals(1_234L, buildResponse(request, screenState, capturedAtDeviceMs = 1_234L).capturedAtDeviceMs) + assertEquals(1_234L, buildBinaryResponse(request, screenState, capturedAtDeviceMs = 1_234L).capturedAtDeviceMs) + + // Back-compat default: callers that don't stamp produce a null (older-server) response. + assertNull(buildResponse(request, screenState).capturedAtDeviceMs) + } + @Test fun `buildResponse carries trailblazeNodeTree from the screen state`() { // Guards that the pure builder faithfully forwards the captured tree — the diff --git a/trailblaze-android/dependencies/debugRuntimeClasspath.txt b/trailblaze-android/dependencies/debugRuntimeClasspath.txt index c53e2bff3..e76f3db78 100644 --- a/trailblaze-android/dependencies/debugRuntimeClasspath.txt +++ b/trailblaze-android/dependencies/debugRuntimeClasspath.txt @@ -1,6 +1,7 @@ :trailblaze-agent :trailblaze-common :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-quickjs-tools :trailblaze-tracing ai.koog:agents-core-android:1.0.0 @@ -98,6 +99,8 @@ com.squareup.okhttp3:okhttp-android:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:maestro-client:2.6.1 dev.mobile:maestro-orchestra-models:2.6.1 @@ -115,6 +118,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/trailblaze-android/src/main/java/xyz/block/trailblaze/TrailblazeAndroidLoggingRule.kt b/trailblaze-android/src/main/java/xyz/block/trailblaze/TrailblazeAndroidLoggingRule.kt index 98c9846da..7f352ac82 100644 --- a/trailblaze-android/src/main/java/xyz/block/trailblaze/TrailblazeAndroidLoggingRule.kt +++ b/trailblaze-android/src/main/java/xyz/block/trailblaze/TrailblazeAndroidLoggingRule.kt @@ -69,6 +69,8 @@ class TrailblazeAndroidLoggingRule( }, ) { + protected override val useBinaryLogTransport: Boolean = true + /** * Override the driver type reported in session logs. Set this before calling * [AndroidTrailblazeRule.runSuspend] so that the [SessionStatus.Started] log diff --git a/trailblaze-android/src/main/java/xyz/block/trailblaze/android/AndroidTrailblazeRule.kt b/trailblaze-android/src/main/java/xyz/block/trailblaze/android/AndroidTrailblazeRule.kt index 9419e140c..5afa9406b 100644 --- a/trailblaze-android/src/main/java/xyz/block/trailblaze/android/AndroidTrailblazeRule.kt +++ b/trailblaze-android/src/main/java/xyz/block/trailblaze/android/AndroidTrailblazeRule.kt @@ -609,7 +609,12 @@ open class AndroidTrailblazeRule( // the list. The guard in decodeTrail throws if we ever lose classifiers and // a v3 file has recordings, so the silent-LLM-fallback can't happen here. val classifiers = trailblazeLoggingRule.trailblazeDeviceInfoProvider().classifiers - val trailItems = trailblazeYaml.decodeTrail(testYaml, deviceClassifiers = classifiers) + // `testYaml` is either a full trail document (CLI/desktop "run this trail on device") or a + // per-tool dispatch envelope (host-drives-the-loop RPC). decodeTrailOrToolEnvelope decodes the + // per-tool envelope via decodeTools, so a single-tool RPC never depends on the legacy + // list-shape trail parser; a trail document still lowers via decodeTrail. + val trailItems = + trailblazeYaml.decodeTrailOrToolEnvelope(testYaml, deviceClassifiers = classifiers) val trailConfig = trailblazeYaml.extractTrailConfig(trailItems) // Honor `config.skip:` before sending SessionStarted — matches the CLI's pre-flight diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/CaptureSession.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/CaptureSession.kt index cd9830d83..a878356e7 100644 --- a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/CaptureSession.kt +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/CaptureSession.kt @@ -82,12 +82,8 @@ class CaptureSession(private val streams: List, private val optio when (platform) { TrailblazeDevicePlatform.ANDROID -> streams.add(xyz.block.trailblaze.capture.video.AndroidVideoCapture()) - // TODO: iOS video capture disabled — sprite sheet generation needs WebP migration - // (ffmpeg 8.0 JPEG encoder rejects iOS simulator's limited-range YUV, and the - // desktop UI's ImageIO.read() doesn't support WebP). See IosVideoCapture.kt for - // the recording + stale-lock fixes that are ready once the format is sorted out. - // TrailblazeDevicePlatform.IOS -> - // streams.add(xyz.block.trailblaze.capture.video.IosVideoCapture()) + TrailblazeDevicePlatform.IOS -> + streams.add(xyz.block.trailblaze.capture.video.IosVideoCapture()) TrailblazeDevicePlatform.WEB -> streams.add(xyz.block.trailblaze.capture.video.PlaywrightVideoCapture()) else -> Unit diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/BaguetteAvccStream.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/BaguetteAvccStream.kt new file mode 100644 index 000000000..23f3af0a1 --- /dev/null +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/BaguetteAvccStream.kt @@ -0,0 +1,188 @@ +package xyz.block.trailblaze.capture.video + +import java.io.ByteArrayOutputStream + +/** + * Converts `baguette serve` WebSocket video records into browser-decodable Annex-B H.264 access + * units — the same [H264AccessUnit] shape the Android `screenrecord` path produces, so the iOS live + * viewer reuses Android's exact WebCodecs decode path with no browser divergence. + * + * The iOS Simulator has no `adb screenrecord` equivalent: `simctl io recordVideo` only writes a + * seekable file (it refuses a pipe), so there is no stock way to get a live H.264 stream off a + * booted simulator. [baguette](https://github.com/tddworks/baguette) (`brew install baguette`) + * captures the simulator framebuffer through private SimulatorKit frameworks and hardware-encodes + * H.264 with VideoToolbox, exposed over a local `baguette serve` WebSocket. This parser adapts + * baguette's WS record format to the exact [H264AccessUnit] contract the `/devices/api/stream` + * endpoint already sends to the browser. + * + * ## baguette WS record format (`format=avcc&version=v2`) + * Each WebSocket **binary message is one complete record**: a 1-byte type tag followed by the + * payload (the WebSocket message boundary is the record boundary — there is no length prefix to + * reassemble). Types: + * - `0x01` **description** — an avcC `AVCDecoderConfigurationRecord` (SPS/PPS), emitted once, + * immediately before the first keyframe. + * - `0x02` **keyframe** — an IDR sample in avcc sample format (length-prefixed NAL units); the + * parameter sets live in the description, not the sample. + * - `0x03` **delta** — a non-IDR (P-frame) sample, same avcc sample format. + * - `0x04` **seed** — a JPEG still baguette sends for instant first paint. Ignored here: the + * browser decodes Annex-B H.264 only, and baguette forces an IDR at stream start so a real + * keyframe arrives immediately anyway. + * + * ## What this emits + * Each `0x02`/`0x03` sample becomes one [H264AccessUnit] in Annex-B (`00 00 00 01` start-code + * delimited). The SPS/PPS parsed from the `0x01` description are prepended to **every** keyframe, so + * each IDR access unit is self-contained (SPS+PPS+IDR) — matching the Android tee's cached keyframe + * and giving the browser's decoder the parameter sets it needs to start (and to recover after any + * mid-stream reset). Delta samples carry only their coded slices. + * + * Not thread-safe; drive it from a single WebSocket listener. + */ +class BaguetteAvccStreamParser { + + /** SPS/PPS in Annex-B, parsed from the most recent description record. Prepended to keyframes. */ + private var annexBParameterSets: ByteArray = EMPTY + + /** NAL length-prefix size (bytes) for avcc samples, read from the description. avcC default is 4. */ + private var nalLengthSize: Int = DEFAULT_NAL_LENGTH_SIZE + + /** + * Feed one complete baguette WS record — a 1-byte type tag followed by its payload. An empty + * record is ignored. Emits an [H264AccessUnit] for every keyframe/delta; description records + * update parser state and JPEG seeds (and unknown future tags) emit nothing. + */ + fun feed(record: ByteArray, emit: (H264AccessUnit) -> Unit) { + if (record.isEmpty()) return + val tag = record[0].toInt() and 0xff + val payload = record.copyOfRange(1, record.size) + when (tag) { + TAG_DESCRIPTION -> parseDescription(payload) + TAG_KEYFRAME -> + emit( + H264AccessUnit( + bytes = sampleToAnnexB(payload, nalLengthSize, prepend = annexBParameterSets), + isKeyFrame = true, + ), + ) + TAG_DELTA -> + emit( + H264AccessUnit( + bytes = sampleToAnnexB(payload, nalLengthSize, prepend = EMPTY), + isKeyFrame = false, + ), + ) + // TAG_SEED (0x04): JPEG still, not part of the H.264 access-unit stream. Any other tag is an + // unknown future extension — ignore it rather than corrupt the stream. + else -> Unit + } + } + + /** Discard parser state (parameter sets) for a fresh producer session. */ + fun reset() { + annexBParameterSets = EMPTY + nalLengthSize = DEFAULT_NAL_LENGTH_SIZE + } + + private fun parseDescription(avcc: ByteArray) { + parseAvccConfig(avcc)?.let { + annexBParameterSets = it.annexBParameterSets + nalLengthSize = it.nalLengthSize + } + } + + /** Parsed avcC `AVCDecoderConfigurationRecord`: SPS/PPS in Annex-B plus the sample NAL length size. */ + internal data class AvccConfig(val annexBParameterSets: ByteArray, val nalLengthSize: Int) + + companion object { + private const val TAG_DESCRIPTION = 0x01 + private const val TAG_KEYFRAME = 0x02 + private const val TAG_DELTA = 0x03 + + /** avcC default when the record is unreadable: 4-byte NAL length prefixes (VideoToolbox's shape). */ + private const val DEFAULT_NAL_LENGTH_SIZE = 4 + + private val EMPTY = ByteArray(0) + private val START_CODE = byteArrayOf(0, 0, 0, 1) + + /** + * Parses an avcC `AVCDecoderConfigurationRecord` into its SPS/PPS (as an Annex-B blob ready to + * prepend to a keyframe) and the NAL length-prefix size used by avcc samples. Returns null if + * the record is too short or self-inconsistent (truncated lengths) — the caller keeps its prior + * parameter sets rather than corrupting the stream. + * + * Layout (ISO/IEC 14496-15): + * ``` + * [0] configurationVersion (1) + * [1] AVCProfileIndication [2] profile_compatibility [3] AVCLevelIndication + * [4] 111111 + lengthSizeMinusOne(2 bits) -> nalLengthSize = (byte & 0x3) + 1 + * [5] 111 + numOfSequenceParameterSets(5 bits) + * per SPS: [2-byte BE length][SPS NAL bytes] + * [.] numOfPictureParameterSets (1 byte) + * per PPS: [2-byte BE length][PPS NAL bytes] + * ``` + */ + internal fun parseAvccConfig(avcc: ByteArray): AvccConfig? { + if (avcc.size < 7) return null + val nalLengthSize = (avcc[4].toInt() and 0x03) + 1 + val out = ByteArrayOutputStream() + var index = 5 + + val numSps = avcc[index].toInt() and 0x1f + index++ + repeat(numSps) { + index = appendParameterSet(avcc, index, out) ?: return null + } + if (index >= avcc.size) return null + val numPps = avcc[index].toInt() and 0xff + index++ + repeat(numPps) { + index = appendParameterSet(avcc, index, out) ?: return null + } + return AvccConfig(annexBParameterSets = out.toByteArray(), nalLengthSize = nalLengthSize) + } + + /** + * Reads one 2-byte-length-prefixed parameter-set NAL at [index], writes it Annex-B-framed into + * [out], and returns the index just past it — or null if the record is truncated. + */ + private fun appendParameterSet(avcc: ByteArray, index: Int, out: ByteArrayOutputStream): Int? { + if (index + 2 > avcc.size) return null + val length = readBigEndian(avcc, index, 2) + val start = index + 2 + val end = start + length + if (length <= 0 || end > avcc.size) return null + out.write(START_CODE) + out.write(avcc, start, length) + return end + } + + /** + * Converts one avcc sample (a sequence of `nalLengthSize`-prefixed NAL units) to Annex-B, + * optionally prepending [prepend] (the SPS/PPS blob for a keyframe). A truncated trailing NAL + * (corruption / desync) ends the conversion at the last whole NAL rather than throwing. + */ + internal fun sampleToAnnexB(sample: ByteArray, nalLengthSize: Int, prepend: ByteArray): ByteArray { + val out = ByteArrayOutputStream(prepend.size + sample.size + 8) + if (prepend.isNotEmpty()) out.write(prepend) + var index = 0 + while (index + nalLengthSize <= sample.size) { + val nalLength = readBigEndian(sample, index, nalLengthSize) + val start = index + nalLengthSize + val end = start + nalLength + if (nalLength <= 0 || end > sample.size) break + out.write(START_CODE) + out.write(sample, start, nalLength) + index = end + } + return out.toByteArray() + } + + /** Reads [count] big-endian bytes from [bytes] at [offset] as an unsigned int. */ + private fun readBigEndian(bytes: ByteArray, offset: Int, count: Int): Int { + var value = 0 + for (i in 0 until count) { + value = (value shl 8) or (bytes[offset + i].toInt() and 0xff) + } + return value + } + } +} diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264AccessUnitConsumer.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264AccessUnitConsumer.kt new file mode 100644 index 000000000..39ae1559f --- /dev/null +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264AccessUnitConsumer.kt @@ -0,0 +1,294 @@ +package xyz.block.trailblaze.capture.video + +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import xyz.block.trailblaze.util.Console + +/** + * Drains a shared Android screenrecord stream as browser-decodable H.264 access units. + * + * Unlike [LiveFrameConsumer], this path never decodes or re-encodes the video on the daemon. It + * only finds Annex-B NAL boundaries and groups slices belonging to one coded picture. Each + * callback therefore maps directly to one WebCodecs `EncodedVideoChunk` in the browser. + */ +class H264AccessUnitConsumer( + private val tee: H264Tee, + private val onAccessUnit: (H264AccessUnit) -> Unit, + private val ringBufferBytes: Int = DEFAULT_RING_BUFFER_BYTES, +) { + private var consumer: H264Tee.Consumer? = null + private var drainThread: Thread? = null + private val stopped = AtomicBoolean(false) + + fun start() { + check(consumer == null) { "H264 access-unit consumer already started" } + consumer = tee.attach(ringBufferBytes) + drainThread = + Thread(::drain, "live-h264-access-units").apply { + isDaemon = true + start() + } + } + + fun stop() { + if (!stopped.compareAndSet(false, true)) return + consumer?.detach() + drainThread?.join(2_000) + } + + private fun drain() { + val source = consumer ?: return + val splitter = AnnexBAccessUnitSplitter() + val buffer = ByteArray(64 * 1024) + var lastInputNanos = System.nanoTime() + try { + while (!stopped.get()) { + when (val count = source.read(buffer)) { + H264Tee.READ_RESULT_DETACHED -> break + H264Tee.READ_RESULT_RESTART -> { + splitter.finish(onAccessUnit) + splitter.reset() + } + 0 -> { + if (System.nanoTime() - lastInputNanos >= IDLE_ACCESS_UNIT_FLUSH_NANOS) { + splitter.flushPending(onAccessUnit) + } + Thread.sleep(IDLE_SLEEP_MILLIS) + } + else -> { + splitter.feed(buffer, 0, count, onAccessUnit) + lastInputNanos = System.nanoTime() + } + } + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: Exception) { + if (!stopped.get()) { + Console.log("[H264AccessUnitConsumer] drain failed: ${e.message}") + } + } finally { + splitter.finish(onAccessUnit) + } + } + + companion object { + private const val IDLE_SLEEP_MILLIS = 2L + // A raw Annex-B pipe has no explicit length for its final NAL. screenrecord writes every + // frame's bytes in a tight burst, then waits roughly 40 ms for the next 25 fps frame (or + // indefinitely on a still screen). Treating 20 ms without bytes as the end of that burst + // exposes the first/static frame without waiting for a following start code. + private const val IDLE_ACCESS_UNIT_FLUSH_NANOS = 20_000_000L + private const val DEFAULT_RING_BUFFER_BYTES = 20 * 1024 * 1024 + } +} + +/** One complete coded picture in Annex-B byte-stream format. */ +data class H264AccessUnit(val bytes: ByteArray, val isKeyFrame: Boolean) + +/** + * Streaming Annex-B parser that emits one access unit per coded picture. + * + * Android's `screenrecord` stream does not include access-unit-delimiter NALs. VCL NALs do carry + * the standard `first_mb_in_slice` Exp-Golomb field, so a zero value marks the first slice of a new + * picture. Parameter-set and supplemental NALs seen between pictures are retained and prepended to + * the next picture; this ensures an IDR chunk includes the SPS/PPS required by WebCodecs. + * + * The most recent SPS/PPS are *also* retained for the whole stream and re-prepended to any keyframe + * that arrives without them. screenrecord (MediaCodec) emits the parameter sets once in the + * codec-config buffer at stream start and then periodic IDR keyframes that do NOT repeat them, so + * without this a later IDR — or the first IDR seen by a mid-stream joiner / after the tee's cached + * keyframe is refreshed — would be emitted SPS-less and be undecodable by a freshly-configured + * WebCodecs decoder. + */ +internal class AnnexBAccessUnitSplitter { + private var unparsed = ByteArray(0) + private val prefixNals = mutableListOf() + private val pictureNals = mutableListOf() + private var pictureHasVcl = false + private var pictureIsKeyFrame = false + private var retainedSps: ByteArray? = null + private var retainedPps: ByteArray? = null + + fun feed(source: ByteArray, offset: Int, length: Int, emit: (H264AccessUnit) -> Unit) { + if (length <= 0) return + val combined = ByteArray(unparsed.size + length) + unparsed.copyInto(combined) + source.copyInto(combined, unparsed.size, offset, offset + length) + + val starts = findStartCodes(combined) + if (starts.isEmpty()) { + // Keep a short garbage prefix only until the first start code arrives. Once parsing has + // begun, [unparsed] always starts with a start code and this branch is only a split marker. + unparsed = combined.takeLast(MAX_START_CODE_BYTES - 1).toByteArray() + return + } + for (index in 0 until starts.lastIndex) { + processNal(combined.copyOfRange(starts[index], starts[index + 1]), emit) + } + unparsed = combined.copyOfRange(starts.last(), combined.size) + } + + /** Processes the final NAL and picture when a producer generation ends. */ + fun finish(emit: (H264AccessUnit) -> Unit) { + flushPending(emit) + prefixNals.clear() + } + + /** Emits the last NAL after the producer has been byte-idle long enough to delimit its burst. */ + fun flushPending(emit: (H264AccessUnit) -> Unit) { + if (unparsed.isNotEmpty() && nalHeaderIndex(unparsed) != null) { + processNal(unparsed, emit) + } + unparsed = ByteArray(0) + emitPicture(emit) + } + + fun reset() { + unparsed = ByteArray(0) + prefixNals.clear() + pictureNals.clear() + pictureHasVcl = false + pictureIsKeyFrame = false + // Drop the retained parameter sets: a new producer generation sends its own SPS/PPS at its + // head, and re-prepending a previous session's parameter sets to it would be incorrect. + retainedSps = null + retainedPps = null + } + + private fun nalTypeOf(nal: ByteArray): Int? = + nalHeaderIndex(nal)?.let { nal[it].toInt() and NAL_TYPE_MASK } + + private fun pictureContainsNalType(type: Int): Boolean = + pictureNals.any { nalTypeOf(it) == type } + + private fun processNal(nal: ByteArray, emit: (H264AccessUnit) -> Unit) { + val headerIndex = nalHeaderIndex(nal) ?: return + val nalType = nal[headerIndex].toInt() and NAL_TYPE_MASK + when { + nalType == NAL_ACCESS_UNIT_DELIMITER -> { + if (pictureHasVcl) emitPicture(emit) + prefixNals += nal + } + nalType in VCL_NAL_TYPES -> { + val firstMacroblock = readUnsignedExpGolomb(nal, headerIndex + 1) + if (pictureHasVcl && firstMacroblock == 0) emitPicture(emit) + if (!pictureHasVcl) { + pictureNals += prefixNals + prefixNals.clear() + } + pictureNals += nal + pictureHasVcl = true + pictureIsKeyFrame = pictureIsKeyFrame || nalType == NAL_IDR_SLICE + } + else -> { + when (nalType) { + NAL_SPS -> retainedSps = nal + NAL_PPS -> retainedPps = nal + } + prefixNals += nal + } + } + } + + private fun emitPicture(emit: (H264AccessUnit) -> Unit) { + if (!pictureHasVcl) return + // Guarantee an IDR keyframe carries its parameter sets in-band. SPS and PPS are checked + // independently: a periodic IDR carries neither, but an encoder that repeats only the SPS on + // its IDRs would otherwise leave the chunk without a PPS. Any set the picture didn't already + // pick up from the preceding NALs is re-prepended from the retained copy, keeping SPS→PPS order + // ahead of the slices so the chunk is self-contained for a freshly-configured decoder. + if (pictureIsKeyFrame) { + if (!pictureContainsNalType(NAL_SPS)) { + retainedSps?.let { pictureNals.add(0, it) } + } + if (!pictureContainsNalType(NAL_PPS)) { + // Insert right after the SPS (added just now or already present) so ordering stays + // SPS→PPS→slices; indexOfLast returns -1 when there's no SPS, landing PPS at the front. + val afterSps = pictureNals.indexOfLast { nalTypeOf(it) == NAL_SPS } + 1 + retainedPps?.let { pictureNals.add(afterSps, it) } + } + } + val output = ByteArrayOutputStream(pictureNals.sumOf { it.size }) + pictureNals.forEach(output::write) + emit(H264AccessUnit(bytes = output.toByteArray(), isKeyFrame = pictureIsKeyFrame)) + pictureNals.clear() + pictureHasVcl = false + pictureIsKeyFrame = false + } + + private fun findStartCodes(bytes: ByteArray): List { + val starts = mutableListOf() + var index = 0 + while (index <= bytes.size - 3) { + if (bytes[index] == 0.toByte() && bytes[index + 1] == 0.toByte()) { + when { + bytes[index + 2] == 1.toByte() -> { + starts += index + index += 3 + continue + } + index + 3 < bytes.size && + bytes[index + 2] == 0.toByte() && + bytes[index + 3] == 1.toByte() -> { + starts += index + index += 4 + continue + } + } + } + index++ + } + return starts + } + + private fun nalHeaderIndex(nal: ByteArray): Int? { + var index = 0 + while (index < nal.size && nal[index] == 0.toByte()) index++ + if (index >= nal.size || nal[index] != 1.toByte()) return null + return (index + 1).takeIf { it < nal.size } + } + + /** Reads the first unsigned Exp-Golomb value from an escaped RBSP payload. */ + private fun readUnsignedExpGolomb(nal: ByteArray, payloadOffset: Int): Int? { + val rbsp = ByteArrayOutputStream(nal.size - payloadOffset) + var zeros = 0 + for (index in payloadOffset until nal.size) { + val value = nal[index].toInt() and 0xff + if (zeros >= 2 && value == 0x03) { + zeros = 0 + continue + } + rbsp.write(value) + zeros = if (value == 0) zeros + 1 else 0 + } + val bytes = rbsp.toByteArray() + var bitIndex = 0 + var leadingZeros = 0 + while (bitIndex < bytes.size * 8 && bitAt(bytes, bitIndex) == 0) { + leadingZeros++ + bitIndex++ + } + if (bitIndex >= bytes.size * 8 || leadingZeros > 30) return null + bitIndex++ // delimiter one bit + var suffix = 0 + repeat(leadingZeros) { + if (bitIndex >= bytes.size * 8) return null + suffix = (suffix shl 1) or bitAt(bytes, bitIndex++) + } + return ((1 shl leadingZeros) - 1) + suffix + } + + private fun bitAt(bytes: ByteArray, bitIndex: Int): Int = + (bytes[bitIndex / 8].toInt() ushr (7 - (bitIndex % 8))) and 1 + + companion object { + private const val MAX_START_CODE_BYTES = 4 + private const val NAL_TYPE_MASK = 0x1f + private const val NAL_IDR_SLICE = 5 + private const val NAL_SPS = 7 + private const val NAL_PPS = 8 + private const val NAL_ACCESS_UNIT_DELIMITER = 9 + private val VCL_NAL_TYPES = 1..5 + } +} diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264Tee.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264Tee.kt index 4ba78bee9..e6f9db52d 100644 --- a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264Tee.kt +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/H264Tee.kt @@ -18,6 +18,15 @@ import xyz.block.trailblaze.util.Console * tee logs a warning and uses whichever attached first — last-writer-wins would silently swap * the live viewer's resolution mid-stream. * + * **Mid-stream joins.** The tee caches the most recent keyframe (SPS+PPS+IDR) and seeds it into + * every consumer that attaches while a producer is already running. screenrecord emits an IDR + * essentially only at stream start, so a late joiner would otherwise receive only P-slices that + * reference parameter sets it never saw — undecodable. This is what lets an MP4 recording that + * starts while the live viewer already holds the tee produce a valid file, and lets a browser + * WebCodecs decoder configure without waiting for a fresh IDR that may never come on a static + * screen. The first consumer is not seeded: it receives the live stream head, which already + * begins with SPS/PPS/IDR. + * * **Consumers and back-pressure.** Each consumer ([Consumer]) gets a non-blocking ring buffer * sized at construction time. A slow consumer drops *bytes* (not whole frames — the H.264 NAL * stream is self-synchronizing on the next IDR / start code, so a downstream decoder can @@ -25,10 +34,10 @@ import xyz.block.trailblaze.util.Console * consumer. * * **Restarts.** screenrecord caps invocations at 3 minutes on Android < 11 (API < 30); on - * Android 11+, `--time-limit 0` lets one invocation run indefinitely. When the subprocess - * exits, all consumers receive [RestartSignal] *before* the next subprocess's SPS/PPS arrives. - * The MP4 consumer rolls to a new segment file on that signal; the live consumer just keeps - * pushing the new SPS/PPS into its decoder. + * Android 11+, `--time-limit 0` lets one invocation run indefinitely. A capped or unexpectedly + * exited subprocess is restarted. All consumers receive [RestartSignal] *before* the next + * subprocess's SPS/PPS arrives. The MP4 consumer rolls to a new segment file on that signal; live + * consumers reset their parsers and continue with the new SPS/PPS. * * **Lifecycle.** Ref-counted via [attach] / [Consumer.detach]. The first attach spawns the * subprocess and reader thread; the last detach reaps both. The instance is reusable across @@ -54,6 +63,8 @@ class H264Tee internal constructor( * exercise the unlimited-time-limit path and < 30 to exercise the restart-on-exit chain. */ private val sdkLevelProvider: () -> Int = { sdkLevelFromDevice(deviceId) }, + /** Test seam for canned finite streams. Production always recovers an unexpected EOF. */ + private val restartOnUnexpectedExit: Boolean = true, ) { // Synchronized on this Object for refCount/state transitions only — never held across a @@ -71,6 +82,19 @@ class H264Tee internal constructor( // exit is intentional rather than a screenrecord 3-min cap that needs a restart. private val shuttingDown = AtomicBoolean(false) + // Latest decodable keyframe (SPS+PPS+IDR in Annex-B) seen on the current producer session, + // used to seed a consumer that attaches mid-stream. screenrecord emits an IDR essentially only + // at stream start, so without this a late joiner — e.g. the MP4 recording that begins while the + // live viewer already holds the tee open — would receive only P-slices referencing parameter + // sets it never saw, i.e. an undecodable stream. Written by the reader thread and by + // startProducer (which runs before the reader thread starts), read under refCountLock in attach; + // volatile guarantees the late joiner sees a complete array reference. The splitter is touched + // only by the reader thread (and by startProducer before that thread exists). Ordering + // invariant: the reader updates this cache BEFORE fanning a chunk out, so once any consumer + // has observed a keyframe, every later attach is seeded with it (or a fresher one). + @Volatile private var cachedKeyframe: ByteArray? = null + private val gopSplitter = AnnexBAccessUnitSplitter() + /** * Attach a new consumer with the given ring-buffer capacity. If this is the first consumer, * the producer subprocess is spawned. Returns a [Consumer] handle the caller drains via @@ -90,6 +114,14 @@ class H264Tee internal constructor( tee = this, ) synchronized(refCountLock) { + // Seed a mid-stream joiner with the last keyframe so its decoder has parameter sets and an + // IDR to start from. Only when a producer is already running (refCount > 0) — the first + // consumer receives the live stream head, which begins with SPS/PPS/IDR, and startProducer + // clears any stale cache. The seed is written before the consumer enters the map so it + // precedes any live fanOut bytes for this consumer. Trade-off: the seeded keyframe predates + // the following live P-slices, so the very first GOP can show a brief artifact until the + // next IDR — decodable-with-glitch beats the undecodable stream a late joiner got before. + if (refCount > 0) cachedKeyframe?.let { consumer.seed(it) } // Register the consumer in the map BEFORE starting the producer/reader thread. The // reader thread doesn't take refCountLock when iterating consumers in fanOut(); if we // started it first, it could read the very first chunk and fan it out to an empty map @@ -134,6 +166,11 @@ class H264Tee internal constructor( // ──────────────────────────────────────────────────────────────────────────── private fun startProducer() { + // Fresh producer session — discard any keyframe cached from a previous session so the first + // consumer isn't seeded with a stale one. Safe to touch the splitter here: startProducer runs + // under refCountLock on the first attach, before the reader thread that owns it exists. + cachedKeyframe = null + gopSplitter.reset() val sdk = runCatching { sdkLevelProvider() }.getOrDefault(0) val unlimited = sdk >= ANDROID_R_SDK Console.log( @@ -144,7 +181,7 @@ class H264Tee internal constructor( producerHandle = handle readerThread = Thread( { - runReaderLoop(handle, restartUntilShutdown = !unlimited) + runReaderLoop(handle, unlimited = unlimited) }, "h264-tee-reader-${deviceId.instanceId}", ).apply { @@ -164,10 +201,17 @@ class H264Tee internal constructor( readerThread = null } - private fun runReaderLoop(initialHandle: ProducerHandle, restartUntilShutdown: Boolean) { + private fun runReaderLoop(initialHandle: ProducerHandle, unlimited: Boolean) { var handle: ProducerHandle = initialHandle val buf = ByteArray(READ_CHUNK_BYTES) + // Consecutive generations that spawned but delivered zero bytes. A device whose screenrecord + // spawns then instantly EOFs (encoder unavailable, transport reset on every attempt) would + // otherwise respawn at a fixed 250ms — ~4 adb invocations/sec indefinitely. Escalate the delay + // on repeated empty exits; a generation that actually delivers bytes (a healthy stream, or a + // normal 3-min-cap restart) resets it, so normal restarts keep the fast 250ms turnaround. + var consecutiveEmptyExits = 0 while (true) { + var bytesThisGeneration = 0L try { while (true) { val n = try { @@ -176,12 +220,19 @@ class H264Tee internal constructor( -1 } if (n <= 0) break + bytesThisGeneration += n + // Cache before fan-out: once any consumer has observed a byte, a consumer attaching + // afterwards is guaranteed a seed at least as fresh as that byte. The reverse order + // left a window where a joiner missed both the fanned-out keyframe and the seed — + // an undecodable stream. (The joiner can now be seeded with a keyframe from a chunk + // it also receives live — a duplicated keyframe is decodable, a missing one is not.) + updateKeyframeCache(buf, n) fanOut(buf, n) } } catch (e: Exception) { Console.log("[H264Tee] reader thread for ${deviceId.instanceId}: ${e.message}") } - if (shuttingDown.get() || !restartUntilShutdown) { + if (shuttingDown.get() || (unlimited && !restartOnUnexpectedExit)) { // Don't close the handle here — stopProducer() (called from the detach path) handles // it, and on EOF a second close would be redundant. If we exited because the // subprocess died on its own with no restart policy, leaving close to the next @@ -189,14 +240,43 @@ class H264Tee internal constructor( Console.log("[H264Tee] producer exited (shuttingDown=${shuttingDown.get()}); reader stopping") return } - // screenrecord hit its 3-min cap; tell consumers to splice and start a new subprocess. - // Close the now-finished handle ourselves so the subprocess is reaped before we spawn - // the next one (encoder contention again). + // Pre-Android-11 screenrecord normally reaches its 3-minute cap. Newer screenrecord is + // unlimited, so any EOF there is unexpected (adb transport reset, encoder process death, + // device sleep, etc.) and must recover too; otherwise subscribers receive heartbeats but + // never another frame until every viewer disconnects and reconnects. runCatching { handle.close() } - Console.log("[H264Tee] screenrecord exited (likely 3-min cap); signaling restart and respawning") + // Flush the trailing NAL of the ending generation into the cache, then reset the splitter so + // the next generation parses from its own SPS/PPS. cachedKeyframe is intentionally kept — a + // consumer attaching during the restart gap still gets the last good keyframe until the new + // generation delivers a fresh one. + finishKeyframeGeneration() + Console.log( + "[H264Tee] screenrecord exited " + + (if (unlimited) "unexpectedly" else "at its time limit") + + "; signaling restart and respawning", + ) broadcastRestart() + // Exponential backoff on repeated empty exits, capped; a productive generation resets it. + consecutiveEmptyExits = if (bytesThisGeneration > 0) 0 else consecutiveEmptyExits + 1 + val backoffMillis = minOf( + RESPAWN_DELAY_MILLIS shl minOf(consecutiveEmptyExits, RESPAWN_MAX_BACKOFF_SHIFT), + RESPAWN_MAX_DELAY_MILLIS, + ) + if (consecutiveEmptyExits > 0) { + Console.log( + "[H264Tee] ${deviceId.instanceId} produced no bytes " + + "($consecutiveEmptyExits in a row); backing off ${backoffMillis}ms before respawn", + ) + } + try { + Thread.sleep(backoffMillis) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return + } + if (shuttingDown.get()) return handle = try { - producerFactory.spawn(deviceId, videoSize, bitRate, unlimited = false) + producerFactory.spawn(deviceId, videoSize, bitRate, unlimited = unlimited) } catch (e: Exception) { Console.log("[H264Tee] respawn failed: ${e.message}; reader stopping") return @@ -227,6 +307,25 @@ class H264Tee internal constructor( } } + /** + * Feeds the just-read chunk into the keyframe splitter and records the newest IDR access unit. + * The splitter prepends the retained SPS/PPS to each IDR picture, so [cachedKeyframe] is a + * self-contained, decodable start point. Runs only on the reader thread. + */ + private fun updateKeyframeCache(buf: ByteArray, len: Int) { + gopSplitter.feed(buf, 0, len) { accessUnit -> + if (accessUnit.isKeyFrame) cachedKeyframe = accessUnit.bytes + } + } + + /** Flushes the ending generation's pending picture into the cache and resets the splitter. */ + private fun finishKeyframeGeneration() { + gopSplitter.finish { accessUnit -> + if (accessUnit.isKeyFrame) cachedKeyframe = accessUnit.bytes + } + gopSplitter.reset() + } + // ──────────────────────────────────────────────────────────────────────────── // Consumer-facing API // ──────────────────────────────────────────────────────────────────────────── @@ -300,6 +399,17 @@ class H264Tee internal constructor( detached.set(true) } + /** + * Pre-loads bytes into this consumer's current generation before it starts receiving live + * fanOut. Used by [attach] to seed a mid-stream joiner with the cached keyframe so its + * downstream decoder has parameter sets and an IDR to start from. + */ + internal fun seed(bytes: ByteArray) { + synchronized(generationsLock) { + generations.last().writeOrDrop(bytes, 0, bytes.size) + } + } + internal fun writeBytes(src: ByteArray, off: Int, len: Int) { if (detached.get()) return val dropped = synchronized(generationsLock) { @@ -487,6 +597,14 @@ class H264Tee internal constructor( /** Android 11. `screenrecord --time-limit 0` works on this and later. */ internal const val ANDROID_R_SDK: Int = 30 + private const val RESPAWN_DELAY_MILLIS: Long = 250L + + /** Upper bound for the respawn backoff (a wedged device tops out at one respawn / 5s). */ + private const val RESPAWN_MAX_DELAY_MILLIS: Long = 5_000L + + /** Cap the left-shift so `RESPAWN_DELAY_MILLIS shl n` can't overflow before the delay cap. */ + private const val RESPAWN_MAX_BACKOFF_SHIFT: Int = 5 + /** Reader thread chunk size. 256 KB ≈ ~250 ms of 8 Mbps H.264, well below ring sizes. */ internal const val READ_CHUNK_BYTES: Int = 256 * 1024 diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/IosVideoCapture.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/IosVideoCapture.kt index c7b844d90..bf0f41a96 100644 --- a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/IosVideoCapture.kt +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/IosVideoCapture.kt @@ -84,11 +84,17 @@ class IosVideoCapture : CaptureStream { * Attempts to stop any stale recording on this simulator from a previous session. This can happen * when a previous recording process was killed without clean SIGINT shutdown (e.g., * destroyForcibly on cancellation), leaving the simulator's internal recording lock held. + * + * Only Trailblaze's own recorders are targeted: the pattern requires the session-dir output + * filename (`.../video.mp4`), so a deliberate recording started by someone else (e.g. a CI + * shard's `simctl io ... recordVideo logs/simulator_recording.mp4`) is left alone. When such a + * recorder holds the device, our own start fails fast with "Host recording is already in + * progress" and the session falls back to the screenshot timeline instead of killing theirs. */ private fun stopStaleRecording(deviceId: String) { try { val pgrep = - ProcessBuilder("pgrep", "-f", "simctl io $deviceId recordVideo") + ProcessBuilder("pgrep", "-f", "simctl io $deviceId recordVideo .*/video\\.mp4") .redirectErrorStream(true) .start() val pids = pgrep.inputStream.bufferedReader().readText().trim() diff --git a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/LiveFrameConsumer.kt b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/LiveFrameConsumer.kt index 21538f470..0fd2477d0 100644 --- a/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/LiveFrameConsumer.kt +++ b/trailblaze-capture/src/main/java/xyz/block/trailblaze/capture/video/LiveFrameConsumer.kt @@ -14,8 +14,11 @@ import xyz.block.trailblaze.util.Console * Pipes the raw H.264 NAL stream into a long-running `ffmpeg ... -c:v mjpeg -f image2pipe -` * sidecar process; reads JPEG frames out of the sidecar's stdout by splitting on the JPEG * Start-of-Image (`0xFFD8`) / End-of-Image (`0xFFD9`) markers. Each completed frame is - * SHA-256-hashed and compared against the last emitted hash; identical frames are suppressed - * so a still screen produces no wire traffic. + * SHA-256-hashed and compared against the last emitted hash. Identical frames are suppressed + * between periodic heartbeats, so a still screen produces at most one frame per second. + * Heartbeats fire only while the decoder keeps producing frames — a damage-driven encoder + * (the emulator's `screenrecord`) emits nothing at all for a static screen, so subscribers + * that need to distinguish "static screen" from "dead pipeline" use [onFeedAlive]. * * **Why JPEG?** WebP would require either a per-frame ffmpeg subprocess (high latency) or a * native encoder dependency. JPEG is browser-native, ffmpeg's mjpeg encoder is the fastest @@ -31,8 +34,24 @@ import xyz.block.trailblaze.util.Console */ class LiveFrameConsumer( private val tee: H264Tee, - /** Callback invoked once per *distinct* completed JPEG frame. Runs on the drain thread. */ - private val onFrame: (ByteArray) -> Unit, + /** + * Callback invoked for changed frames and periodic still-screen heartbeats. Runs on the + * drain thread. `isContentChange` is false for a heartbeat re-emit of an unchanged frame — + * exposed so subscribers that care about the distinction (e.g. stream-quiet detection) + * don't have to re-hash every frame this consumer already hashed. + */ + private val onFrame: (jpeg: ByteArray, isContentChange: Boolean) -> Unit, + /** + * Optional out-of-band liveness signal, invoked (throttled to ~2 Hz) from the tee drain + * loop while the capture pipeline stays attached — including when no bytes flow at all. + * Damage-driven encoders (the emulator's `screenrecord`) emit nothing for a static screen, + * so heartbeat re-emits via [onFrame] only prove liveness while frames keep decoding; this + * callback proves it when they don't. Runs on the tee drain thread. Note it attests to the + * tee attachment being drained, not to the ffmpeg sidecar's health — a dead sidecar is + * detected on the next write, i.e. the next content change, so a stale-accept window is + * bounded by the stall threshold. + */ + private val onFeedAlive: (() -> Unit)? = null, /** Ring-buffer capacity for this consumer. Default sized for live-viewer drop tolerance. */ private val ringBufferBytes: Int = DEFAULT_RING_BUFFER_BYTES, /** Test seam: ffmpeg binary path. */ @@ -49,14 +68,22 @@ class LiveFrameConsumer( fun start() { consumer = tee.attach(ringBufferBytes) - process = spawnFfmpeg() - teeToFfmpegThread = Thread(::pumpTeeIntoFfmpeg, "live-frame-tee-to-ffmpeg").apply { - isDaemon = true - start() - } - ffmpegToFramesThread = Thread(::pumpFfmpegIntoFrames, "live-frame-ffmpeg-to-frames").apply { - isDaemon = true - start() + try { + process = spawnFfmpeg() + teeToFfmpegThread = Thread(::pumpTeeIntoFfmpeg, "live-frame-tee-to-ffmpeg").apply { + isDaemon = true + start() + } + ffmpegToFramesThread = Thread(::pumpFfmpegIntoFrames, "live-frame-ffmpeg-to-frames").apply { + isDaemon = true + start() + } + } catch (e: Exception) { + // An attached tee owns the screenrecord producer. If the decoder cannot start, tear + // the attachment down immediately instead of leaving screenrecord running until the + // WebSocket eventually closes. + stop() + throw e } } @@ -78,17 +105,24 @@ class LiveFrameConsumer( private fun spawnFfmpeg(): Process { val pb = ProcessBuilder( ffmpegBinary, - // Decode the H.264 elementary stream from stdin. - "-fflags", "+nobuffer", + // Decode the H.264 elementary stream from stdin. Do not pass `-fflags nobuffer` here: + // despite its name, that input flag drops the packets ffmpeg needs to probe a live raw + // H.264 pipe, so no decoded frame is emitted until EOF. The screenrecord stream already + // arrives without a container-level buffer; low_delay is sufficient on this path. "-flags", "+low_delay", - // Tell ffmpeg the input is roughly 25 fps. Without this hint, the image2pipe muxer - // refuses to emit decoded frames (timestamps end up unspecified → "Output file is - // empty, nothing was encoded"). Verified by reproducing locally with a captured - // screenrecord sample. + // Raw H.264 has no container metadata to probe. The defaults may wait for megabytes of + // input before starting the decoder; SPS/PPS at the head of screenrecord's Annex-B stream + // is enough to identify it immediately. + "-probesize", "32", + "-analyzeduration", "0", + // Tell ffmpeg the input is roughly 25 fps. This assigns each decoded frame a constant-rate + // PTS, which the image2pipe muxer needs to emit frames at all (without it, timestamps are + // unspecified → "Output file is empty, nothing was encoded"). Do NOT also pass + // `-use_wallclock_as_timestamps 1`: screenrecord delivers a frame's bytes in a tight burst, + // so timestamping by arrival wallclock collapses a burst into near-identical PTS and the + // muxer withholds those frames until the pipe closes — a live viewer then sees nothing until + // the stream ends. The CFR hint alone keeps frames flowing while the pipe stays open. "-framerate", "25", - // Use wallclock for timestamps so a slow / bursty input (screenrecord emits little - // when the screen is static) still gets monotonic PTS the muxer accepts. - "-use_wallclock_as_timestamps", "1", "-f", "h264", "-i", "pipe:0", // Encode every decoded frame as JPEG and write the concatenated JPEGs to stdout. @@ -131,6 +165,7 @@ class LiveFrameConsumer( val proc = process ?: return val sink: OutputStream = proc.outputStream val buf = ByteArray(64 * 1024) + var lastAlivePingMs = Long.MIN_VALUE try { while (!stopped.get()) { val n = cons.read(buf) @@ -152,6 +187,16 @@ class LiveFrameConsumer( n == H264Tee.READ_RESULT_DETACHED -> return n == 0 -> Thread.sleep(IDLE_SLEEP_MS) } + // Reaching here means the attachment is alive and being drained (a detached tee or a + // dead ffmpeg pipe returned above) — including the n == 0 idle case, which is exactly + // the state a static screen leaves us in. + if (onFeedAlive != null) { + val nowMs = System.nanoTime() / NANOS_PER_MILLISECOND + if (nowMs - lastAlivePingMs >= FEED_ALIVE_PING_INTERVAL_MS) { + lastAlivePingMs = nowMs + runCatching { onFeedAlive?.invoke() } + } + } } } finally { runCatching { sink.flush() } @@ -164,7 +209,7 @@ class LiveFrameConsumer( val input: InputStream = proc.inputStream val splitter = JpegFrameSplitter() val readBuf = ByteArray(64 * 1024) - var lastSentHash: ByteArray? = null + val emissionGate = FrameEmissionGate(MAX_IDENTICAL_FRAME_SILENCE_MS) try { while (!stopped.get()) { val n = try { @@ -175,10 +220,10 @@ class LiveFrameConsumer( if (n <= 0) break splitter.feed(readBuf, 0, n) { jpeg -> val hash = sha256(jpeg) - if (!hash.contentEquals(lastSentHash)) { - lastSentHash = hash + val emission = emissionGate.admit(hash, System.nanoTime() / NANOS_PER_MILLISECOND) + if (emission != null) { try { - onFrame(jpeg) + onFrame(jpeg, emission == FrameEmissionGate.Emission.CONTENT_CHANGE) } catch (e: Exception) { Console.log("[LiveFrameConsumer] onFrame callback threw: ${e.message}") } @@ -248,8 +293,34 @@ class LiveFrameConsumer( } } + /** + * Content-dedup policy for the live wire. Changed frames pass immediately; an unchanged + * screen passes only after [maxSilenceMillis] so the browser's stall watchdog keeps seeing + * proof of life without paying full frame rate for a static display. + */ + internal class FrameEmissionGate(private val maxSilenceMillis: Long) { + enum class Emission { CONTENT_CHANGE, HEARTBEAT } + + private var lastSentHash: ByteArray? = null + private var lastSentAtMillis: Long = Long.MIN_VALUE + + /** Returns how this frame should be emitted, or null to suppress it. */ + fun admit(hash: ByteArray, nowMillis: Long): Emission? { + val changed = !hash.contentEquals(lastSentHash) + val heartbeatDue = + lastSentAtMillis == Long.MIN_VALUE || nowMillis - lastSentAtMillis >= maxSilenceMillis + if (!changed && !heartbeatDue) return null + lastSentHash = hash.copyOf() + lastSentAtMillis = nowMillis + return if (changed) Emission.CONTENT_CHANGE else Emission.HEARTBEAT + } + } + companion object { private const val IDLE_SLEEP_MS: Long = 2L + private const val MAX_IDENTICAL_FRAME_SILENCE_MS: Long = 1_000L + private const val FEED_ALIVE_PING_INTERVAL_MS: Long = 500L + private const val NANOS_PER_MILLISECOND: Long = 1_000_000L /** * 20 MB ring at 4 Mbps screenrecord ≈ ~40 s of slack. Generous because if the live diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/CaptureSessionTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/CaptureSessionTest.kt index 13bc4fb0c..6882a8931 100644 --- a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/CaptureSessionTest.kt +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/CaptureSessionTest.kt @@ -3,6 +3,7 @@ package xyz.block.trailblaze.capture import xyz.block.trailblaze.capture.logcat.AndroidLogcatCapture import xyz.block.trailblaze.capture.logcat.IosLogCapture import xyz.block.trailblaze.capture.video.AndroidVideoCapture +import xyz.block.trailblaze.capture.video.IosVideoCapture import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import kotlin.test.Test import kotlin.test.assertContains @@ -79,8 +80,7 @@ class CaptureSessionTest { } // ────────────────────────────────────────────────────────────────────────── - // captureVideo × platform — Android wires up; iOS is intentionally disabled - // until the WebP migration noted in CaptureSession's TODO comment lands. + // captureVideo × platform // ────────────────────────────────────────────────────────────────────────── @Test @@ -95,14 +95,14 @@ class CaptureSessionTest { } @Test - fun `captureVideo true on IOS produces no video stream (intentionally disabled)`() { + fun `captureVideo true on IOS adds IosVideoCapture`() { val session = CaptureSession.fromOptions( // Disable the log streams (now on by default) so this isolates the video-on-iOS case. CaptureOptions(captureVideo = true, captureLogcat = false, captureIosLogs = false), TrailblazeDevicePlatform.IOS, ) - // iOS video capture is gated by the TODO in CaptureSession — no stream selected. - assertNull(session) + assertNotNull(session) + assertTrue(streamsOf(session).any { it is IosVideoCapture }) } // ────────────────────────────────────────────────────────────────────────── diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/BaguetteAvccStreamParserTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/BaguetteAvccStreamParserTest.kt new file mode 100644 index 000000000..d7c5a5872 --- /dev/null +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/BaguetteAvccStreamParserTest.kt @@ -0,0 +1,264 @@ +package xyz.block.trailblaze.capture.video + +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Behavioral tests for [BaguetteAvccStreamParser]: they assert the observable contract the browser + * relies on — that baguette's WS video records become self-contained Annex-B [H264AccessUnit]s with + * the right key/delta flags and start codes — not the parser's internal steps. + */ +class BaguetteAvccStreamParserTest { + + private val startCode = byteArrayOf(0, 0, 0, 1) + + // ── canned-byte builders for baguette's WS record format ────────────────────────────────────── + + /** One baguette WS record: a 1-byte type tag followed by the payload. */ + private fun record(tag: Int, payload: ByteArray): ByteArray = byteArrayOf(tag.toByte()) + payload + + /** avcC AVCDecoderConfigurationRecord carrying one SPS + one PPS with a 4-byte NAL length size. */ + private fun avccConfig(sps: ByteArray, pps: ByteArray, nalLengthSize: Int = 4): ByteArray = + avccConfigMulti(listOf(sps), listOf(pps), nalLengthSize) + + /** An avcc sample: each NAL prefixed by its big-endian [nalLengthSize]-byte length. */ + private fun avccSample(vararg nals: ByteArray, nalLengthSize: Int = 4): ByteArray { + val out = ByteArrayOutputStream() + for (nal in nals) { + for (i in nalLengthSize - 1 downTo 0) out.write((nal.size ushr (8 * i)) and 0xff) + out.write(nal) + } + return out.toByteArray() + } + + /** Feeds each record in order and returns every access unit emitted across them. */ + private fun BaguetteAvccStreamParser.feedRecords(vararg records: ByteArray): List { + val units = mutableListOf() + for (r in records) feed(r, units::add) + return units + } + + /** Finds every start-code-delimited NAL in an Annex-B blob and returns each NAL's first byte. */ + private fun nalHeaderBytes(annexB: ByteArray): List { + val headers = mutableListOf() + var i = 0 + while (i + 4 <= annexB.size) { + if (annexB[i] == 0.toByte() && annexB[i + 1] == 0.toByte() && + annexB[i + 2] == 0.toByte() && annexB[i + 3] == 1.toByte() + ) { + if (i + 4 < annexB.size) headers.add(annexB[i + 4].toInt() and 0xff) + i += 4 + } else { + i++ + } + } + return headers + } + + private fun nal(type: Int, payload: ByteArray): ByteArray = + byteArrayOf((type and 0x1f).toByte()) + payload + + // ── tests ───────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `keyframe carries the description's SPS and PPS as Annex-B before the IDR slice`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte(), 0x84.toByte())) + + val units = + parser.feedRecords(record(0x01, avccConfig(sps, pps)), record(0x02, avccSample(idr))) + + assertEquals(1, units.size) + val unit = units.single() + assertTrue(unit.isKeyFrame, "0x02 tag must map to a key access unit") + // SPS(7), PPS(8), then the IDR slice(5) — each Annex-B framed, in order. + assertEquals(listOf(7, 8, 5), nalHeaderBytes(unit.bytes)) + assertTrue(unit.bytes.copyOfRange(0, 4).contentEquals(startCode), "must begin with a start code") + } + + @Test + fun `delta frame is Annex-B slices only, no parameter sets`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + val pSlice = nal(type = 1, payload = byteArrayOf(0x9a.toByte())) + + val units = + parser.feedRecords( + record(0x01, avccConfig(sps, pps)), + record(0x02, avccSample(idr)), + record(0x03, avccSample(pSlice)), + ) + + assertEquals(2, units.size) + assertFalse(units[1].isKeyFrame, "0x03 tag must map to a delta access unit") + assertEquals(listOf(1), nalHeaderBytes(units[1].bytes), "delta carries only its coded slice") + } + + @Test + fun `multiple NAL units in one sample all survive as separate Annex-B NALs`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idrA = nal(type = 5, payload = byteArrayOf(0x11)) + val idrB = nal(type = 5, payload = byteArrayOf(0x22, 0x33)) + + val unit = + parser + .feedRecords(record(0x01, avccConfig(sps, pps)), record(0x02, avccSample(idrA, idrB))) + .single() + + assertEquals(listOf(7, 8, 5, 5), nalHeaderBytes(unit.bytes)) + } + + @Test + fun `JPEG seed records are ignored on the H264 path`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + + // baguette sends a 0x04 JPEG seed before the first description/keyframe. + val jpeg = byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0x00, 0x11, 0xff.toByte(), 0xd9.toByte()) + val units = + parser.feedRecords( + record(0x04, jpeg), + record(0x01, avccConfig(sps, pps)), + record(0x02, avccSample(idr)), + ) + + assertEquals(1, units.size, "the seed must not produce an access unit") + assertTrue(units.single().isKeyFrame) + } + + @Test + fun `honors a non-default NAL length size from the description`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte(), 0x84.toByte())) + + val unit = + parser + .feedRecords( + record(0x01, avccConfig(sps, pps, nalLengthSize = 2)), + record(0x02, avccSample(idr, nalLengthSize = 2)), + ) + .single() + + assertEquals(listOf(7, 8, 5), nalHeaderBytes(unit.bytes)) + } + + @Test + fun `an empty record is ignored`() { + val parser = BaguetteAvccStreamParser() + assertTrue(parser.feedRecords(ByteArray(0)).isEmpty(), "an empty WS message must emit nothing") + } + + @Test + fun `keyframe before any description still emits its slice`() { + // Defensive: if a description never arrives, a keyframe should still yield its coded slice + // (undecodable without SPS/PPS, but the parser must not drop or crash). + val parser = BaguetteAvccStreamParser() + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + val unit = parser.feedRecords(record(0x02, avccSample(idr))).single() + + assertTrue(unit.isKeyFrame) + assertEquals(listOf(5), nalHeaderBytes(unit.bytes)) + } + + @Test + fun `reset clears parameter sets so a later keyframe carries none until re-described`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + + parser.feedRecords(record(0x01, avccConfig(sps, pps))) + parser.reset() + val unit = parser.feedRecords(record(0x02, avccSample(idr))).single() + + assertEquals(listOf(5), nalHeaderBytes(unit.bytes), "reset must forget the earlier SPS/PPS") + } + + @Test + fun `a malformed re-description keeps the earlier parameter sets`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + + parser.feedRecords(record(0x01, avccConfig(sps, pps))) + // A description too short to parse (< 7 bytes): parseAvccConfig returns null, prior sets stand. + parser.feedRecords(record(0x01, byteArrayOf(1, 0x42, 0x00, 0x1f))) + val unit = parser.feedRecords(record(0x02, avccSample(idr))).single() + + assertEquals( + listOf(7, 8, 5), + nalHeaderBytes(unit.bytes), + "a bad re-description must not drop the working SPS/PPS", + ) + } + + @Test + fun `a truncated trailing NAL is dropped and the whole NALs survive`() { + val parser = BaguetteAvccStreamParser() + val sps = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val pps = nal(type = 8, payload = byteArrayOf(0x01)) + val idr = nal(type = 5, payload = byteArrayOf(0x11, 0x22)) + + // One whole NAL, then a 4-byte length claiming 99 bytes with only 2 present (corruption/desync). + val truncated = avccSample(idr) + byteArrayOf(0, 0, 0, 99, 0xab.toByte(), 0xcd.toByte()) + val unit = + parser.feedRecords(record(0x01, avccConfig(sps, pps)), record(0x02, truncated)).single() + + assertEquals(listOf(7, 8, 5), nalHeaderBytes(unit.bytes), "conversion stops at the last whole NAL") + } + + @Test + fun `multiple SPS and PPS in one description are all prepended in order`() { + val parser = BaguetteAvccStreamParser() + val sps1 = nal(type = 7, payload = byteArrayOf(0x42, 0xc0.toByte(), 0x1f)) + val sps2 = nal(type = 7, payload = byteArrayOf(0x4d)) + val pps1 = nal(type = 8, payload = byteArrayOf(0x01)) + val pps2 = nal(type = 8, payload = byteArrayOf(0x02)) + val idr = nal(type = 5, payload = byteArrayOf(0x88.toByte())) + + val unit = + parser + .feedRecords( + record(0x01, avccConfigMulti(listOf(sps1, sps2), listOf(pps1, pps2))), + record(0x02, avccSample(idr)), + ) + .single() + + assertEquals(listOf(7, 7, 8, 8, 5), nalHeaderBytes(unit.bytes)) + } + + /** avcC config carrying an arbitrary number of SPS and PPS NALs. */ + private fun avccConfigMulti( + spsList: List, + ppsList: List, + nalLengthSize: Int = 4, + ): ByteArray { + val out = ByteArrayOutputStream() + out.write(1) // configurationVersion + out.write(0x42); out.write(0); out.write(0x1f) // profile / compat / level (cosmetic here) + out.write(0xfc or (nalLengthSize - 1)) // 6 reserved bits + lengthSizeMinusOne + out.write(0xe0 or spsList.size) // 3 reserved bits + numSPS + for (sps in spsList) { + out.write(sps.size ushr 8); out.write(sps.size and 0xff); out.write(sps) + } + out.write(ppsList.size) // numPPS + for (pps in ppsList) { + out.write(pps.size ushr 8); out.write(pps.size and 0xff); out.write(pps) + } + return out.toByteArray() + } +} diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264AccessUnitConsumerTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264AccessUnitConsumerTest.kt new file mode 100644 index 000000000..714d0b8c6 --- /dev/null +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264AccessUnitConsumerTest.kt @@ -0,0 +1,206 @@ +package xyz.block.trailblaze.capture.video + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class H264AccessUnitConsumerTest { + + @Test + fun `groups parameter sets and one slice into browser access units`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + val stream = + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(type = 8, payload = byteArrayOf(0x01)) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + // Deliberately split start codes and NAL payloads across feeds. + stream.asList().chunked(2).forEach { chunk -> + val bytes = chunk.toByteArray() + splitter.feed(bytes, 0, bytes.size, units::add) + } + splitter.finish(units::add) + + assertEquals(3, units.size) + assertTrue(units.first().isKeyFrame) + assertTrue(containsNalType(units.first().bytes, 7), "key chunk should retain SPS") + assertTrue(containsNalType(units.first().bytes, 8), "key chunk should retain PPS") + assertFalse(units[1].isKeyFrame) + assertFalse(units[2].isKeyFrame) + } + + @Test + fun `keeps multiple slices from one picture in one access unit`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + // first_mb_in_slice=0 is Exp-Golomb `1`; first_mb_in_slice=1 is `010`. + val stream = + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + nal(type = 5, payload = byteArrayOf(0x40)) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + splitter.feed(stream, 0, stream.size, units::add) + splitter.finish(units::add) + + assertEquals(2, units.size) + assertEquals(2, countNalType(units.first().bytes, 5)) + assertTrue(units.first().isKeyFrame) + } + + @Test + fun `access unit delimiters close the previous picture`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + val stream = + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + nal(type = 9, payload = byteArrayOf(0xF0.toByte())) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + splitter.feed(stream, 0, stream.size, units::add) + splitter.finish(units::add) + + assertEquals(2, units.size) + assertTrue(containsNalType(units[1].bytes, 9)) + } + + @Test + fun `idle flush exposes a static first frame without a following start code`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + val stream = + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(type = 8, payload = byteArrayOf(0x01)) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + splitter.feed(stream, 0, stream.size, units::add) + assertTrue(units.isEmpty(), "the last NAL has no following start code yet") + + splitter.flushPending(units::add) + + assertEquals(1, units.size) + assertTrue(units.single().isKeyFrame) + assertTrue(containsNalType(units.single().bytes, 7)) + assertTrue(containsNalType(units.single().bytes, 8)) + } + + @Test + fun `re-prepends parameter sets to a later IDR that omits them`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + // screenrecord sends SPS/PPS once at stream start, then periodic IDR keyframes that do NOT + // repeat them. A freshly-configured WebCodecs decoder (mid-stream joiner, reconnect, cached + // seed) needs the parameter sets in-band on whichever keyframe it configures from, so every + // keyframe the splitter emits must carry them. + val stream = + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(type = 8, payload = byteArrayOf(0x01)) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + // later IDR, no SPS/PPS in-band + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + splitter.feed(stream, 0, stream.size, units::add) + splitter.finish(units::add) + + val keyframes = units.filter { it.isKeyFrame } + assertEquals(2, keyframes.size, "both IDRs should surface as keyframes") + keyframes.forEach { unit -> + assertTrue(containsNalType(unit.bytes, 7), "every keyframe must carry SPS in-band") + assertTrue(containsNalType(unit.bytes, 8), "every keyframe must carry PPS in-band") + } + } + + @Test + fun `re-prepends only the missing PPS when a later IDR repeats the SPS`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + // Establish SPS+PPS, then a later IDR that repeats only the SPS and omits the PPS. The + // keyframe must still come out with both parameter sets in SPS→PPS order. + val stream = + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(type = 8, payload = byteArrayOf(0x01)) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + // later IDR repeats SPS... + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + // ...but no PPS precedes it + nal(type = 1, payload = byteArrayOf(0x80.toByte())) + + splitter.feed(stream, 0, stream.size, units::add) + splitter.finish(units::add) + + val keyframes = units.filter { it.isKeyFrame } + assertEquals(2, keyframes.size, "both IDRs should surface as keyframes") + keyframes.forEach { unit -> + assertTrue(containsNalType(unit.bytes, 7), "every keyframe must carry SPS in-band") + assertTrue(containsNalType(unit.bytes, 8), "every keyframe must carry PPS in-band") + assertTrue( + firstNalTypeIndex(unit.bytes, 7) < firstNalTypeIndex(unit.bytes, 8), + "SPS must precede PPS in the keyframe", + ) + } + } + + @Test + fun `forgets parameter sets after reset so a new generation is not seeded with stale SPS`() { + val splitter = AnnexBAccessUnitSplitter() + val units = mutableListOf() + // First generation establishes SPS/PPS. + val first = + nal(type = 7, payload = byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(type = 8, payload = byteArrayOf(0x01)) + + nal(type = 5, payload = byteArrayOf(0x80.toByte())) + splitter.feed(first, 0, first.size, units::add) + splitter.finish(units::add) + splitter.reset() + + // Second generation opens with a bare IDR (no SPS yet). Nothing retained should leak into it. + units.clear() + val second = nal(type = 5, payload = byteArrayOf(0x80.toByte())) + splitter.feed(second, 0, second.size, units::add) + splitter.finish(units::add) + + assertEquals(1, units.size) + assertFalse(containsNalType(units.single().bytes, 7), "a reset must not carry SPS across generations") + } + + private fun nal(type: Int, payload: ByteArray): ByteArray = + byteArrayOf(0, 0, 0, 1, type.toByte()) + payload + + private fun containsNalType(bytes: ByteArray, type: Int): Boolean = countNalType(bytes, type) > 0 + + /** Byte offset of the first Annex-B start code introducing a NAL of [type], or -1. */ + private fun firstNalTypeIndex(bytes: ByteArray, type: Int): Int { + for (index in 0 until bytes.size - 4) { + if ( + bytes[index] == 0.toByte() && + bytes[index + 1] == 0.toByte() && + bytes[index + 2] == 0.toByte() && + bytes[index + 3] == 1.toByte() && + (bytes[index + 4].toInt() and 0x1f) == type + ) { + return index + } + } + return -1 + } + + private fun countNalType(bytes: ByteArray, type: Int): Int { + var count = 0 + for (index in 0 until bytes.size - 4) { + if ( + bytes[index] == 0.toByte() && + bytes[index + 1] == 0.toByte() && + bytes[index + 2] == 0.toByte() && + bytes[index + 3] == 1.toByte() && + (bytes[index + 4].toInt() and 0x1f) == type + ) { + count++ + } + } + return count + } +} diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264TeeTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264TeeTest.kt index c562bac80..ec23ed762 100644 --- a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264TeeTest.kt +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/H264TeeTest.kt @@ -10,6 +10,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import xyz.block.trailblaze.devices.TrailblazeDeviceId import xyz.block.trailblaze.devices.TrailblazeDevicePlatform @@ -84,7 +85,8 @@ class H264TeeTest { videoSize = "720x1280", bitRate = "4000000", producerFactory = singleShotProducer(pipeIn), - sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, // unlimited, no restart + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, ) val consumerA = tee.attach(ringBufferBytes = 64 * 1024) @@ -124,6 +126,7 @@ class H264TeeTest { bitRate = "4000000", producerFactory = singleShotProducer(pipeIn), sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, ) val fastConsumer = tee.attach(ringBufferBytes = 512 * 1024) // plenty of room @@ -182,6 +185,208 @@ class H264TeeTest { ) } + // ────────────────────────────────────────────────────────────────────────── + // Keyframe seeding — a consumer that joins mid-stream gets a decodable start point + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `consumer joining mid-stream is seeded with the last keyframe`() { + // Reproduces the bug where a session's MP4 recording started while the live viewer already + // held the tee: the recorder joined after screenrecord's only IDR and captured pure P-slices, + // producing an undecodable file. With keyframe seeding, the late joiner's first bytes carry + // the SPS/PPS/IDR even though it attached after they were sent. + val pipeOut = PipedOutputStream() + val pipeIn = PipedInputStream(pipeOut, 1 shl 16) + val tee = H264Tee( + deviceId = deviceId, + videoSize = "720x1280", + bitRate = "4000000", + producerFactory = singleShotProducer(pipeIn), + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, + ) + + // Early consumer attaches first and receives the live stream head. + val early = tee.attach(ringBufferBytes = 64 * 1024) + + // Producer emits one keyframe (SPS+PPS+IDR) then P-slices. The trailing P-slice start code is + // what lets the splitter close the IDR picture and populate the cache. + val keyframe = nal(7, byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(8, byteArrayOf(0x01)) + + nal(5, byteArrayOf(0x80.toByte())) + val pSlice = nal(1, byteArrayOf(0x80.toByte())) + val head = keyframe + pSlice + pSlice + pipeOut.write(head) + pipeOut.flush() + + // Draining the early consumer of the whole head guarantees the reader thread processed those + // bytes, so the keyframe cache is populated before the late consumer attaches. + val earlyBytes = drainConsumer(early, expected = head.size) + assertEquals(head.size, earlyBytes.size, "early consumer should receive the full live head") + + // Late consumer joins AFTER the IDR was already sent. + val late = tee.attach(ringBufferBytes = 64 * 1024) + pipeOut.write(pSlice) // one more live P-slice so the late consumer also has post-seed bytes + pipeOut.flush() + + val lateBytes = drainConsumer(late, expected = keyframe.size + pSlice.size) + + early.detach() + late.detach() + runCatching { pipeOut.close() } + + assertTrue(containsNalType(lateBytes, 7), "late consumer's seed should include SPS") + assertTrue(containsNalType(lateBytes, 8), "late consumer's seed should include PPS") + assertTrue(containsNalType(lateBytes, 5), "late consumer's seed should include the IDR keyframe") + // The seed precedes the live P-slice, so the very first NAL the late consumer sees is the SPS. + assertEquals(7, firstNalType(lateBytes), "seed must come before live bytes") + } + + @Test + fun `first consumer is not seeded and receives the live head verbatim`() { + // The first consumer must get exactly the producer's bytes — no duplicated keyframe from a + // previous session's cache. Guards the refCount>0 gate in attach(). + val pipeOut = PipedOutputStream() + val pipeIn = PipedInputStream(pipeOut, 1 shl 16) + val tee = H264Tee( + deviceId = deviceId, + videoSize = "720x1280", + bitRate = "4000000", + producerFactory = singleShotProducer(pipeIn), + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, + ) + + val only = tee.attach(ringBufferBytes = 64 * 1024) + val head = nal(7, byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(8, byteArrayOf(0x01)) + + nal(5, byteArrayOf(0x80.toByte())) + + nal(1, byteArrayOf(0x80.toByte())) + pipeOut.write(head) + pipeOut.flush() + + val got = drainConsumer(only, expected = head.size) + only.detach() + runCatching { pipeOut.close() } + + assertTrue(got.contentEquals(head), "first consumer must receive the head with no injected seed") + } + + @Test + fun `cached keyframe is retained across a producer restart and seeds a gap joiner`() { + // A consumer that attaches during the restart gap (after one screenrecord exits, before the + // next delivers its own IDR) must still get a decodable start point. The tee keeps the last + // keyframe across the generation boundary precisely so this joiner isn't stranded on P-slices. + val keyframe = nal(7, byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(8, byteArrayOf(0x01)) + + nal(5, byteArrayOf(0x80.toByte())) + val pSlice = nal(1, byteArrayOf(0x80.toByte())) + // Generation 0 delivers the only IDR then EOFs, triggering a restart. Generation 1 is a pipe we + // leave un-written so the reader blocks there, holding the tee in the post-restart state long + // enough to attach the gap joiner deterministically. + val gen1Out = PipedOutputStream() + val gen1In = PipedInputStream(gen1Out, 1 shl 16) + val callCount = AtomicInteger(0) + val factory = H264Tee.ProducerFactory { _, _, _, _ -> + if (callCount.getAndIncrement() == 0) { + object : H264Tee.ProducerHandle { + override val input: InputStream = ByteArrayInputStream(keyframe + pSlice) + override fun close() {} + } + } else { + object : H264Tee.ProducerHandle { + override val input: InputStream = gen1In + override fun close() = runCatching { gen1In.close() }.let { Unit } + } + } + } + val tee = H264Tee( + deviceId = deviceId, + videoSize = "720x1280", + bitRate = "4000000", + producerFactory = factory, + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + ) + + val early = tee.attach(ringBufferBytes = 64 * 1024) + // Draining to the restart boundary guarantees the reader parsed the IDR into the cache and + // generation 1 has spawned. + val gen0 = readBytesUntilRestart(early, timeoutMs = 2_000L) + assertTrue(gen0.contentEquals(keyframe + pSlice), "early consumer should receive generation 0 verbatim") + + // Gap joiner attaches after the restart but before generation 1 delivers any keyframe. + val gap = tee.attach(ringBufferBytes = 64 * 1024) + gen1Out.write(pSlice) // one live P-slice on generation 1 so the joiner also has post-seed bytes + gen1Out.flush() + val gapBytes = drainConsumer(gap, expected = keyframe.size + pSlice.size) + + early.detach() + gap.detach() + runCatching { gen1Out.close() } + + assertTrue(containsNalType(gapBytes, 7), "gap joiner should be seeded with the retained SPS") + assertTrue(containsNalType(gapBytes, 5), "gap joiner should be seeded with the retained IDR") + assertEquals(7, firstNalType(gapBytes), "retained seed must precede generation 1 live bytes") + } + + @Test + fun `keyframe cache is cleared when a new producer session starts`() { + // After a detach-to-zero ends a session, the next attach starts a fresh producer that must + // discard the previous session's cached keyframe — otherwise a joiner in the new session would + // be seeded with a stale IDR that references parameter sets the new stream never sent. + val keyframe = nal(7, byteArrayOf(0x42, 0xC0.toByte(), 0x20)) + + nal(8, byteArrayOf(0x01)) + + nal(5, byteArrayOf(0x80.toByte())) + val pSlice = nal(1, byteArrayOf(0x80.toByte())) + // Session 1 carries a keyframe; session 2 carries only P-slices. Fresh pipes per session because + // a detach-to-zero closes the producer handle. + val s1Out = PipedOutputStream() + val s1In = PipedInputStream(s1Out, 1 shl 16) + val s2Out = PipedOutputStream() + val s2In = PipedInputStream(s2Out, 1 shl 16) + val callCount = AtomicInteger(0) + val factory = H264Tee.ProducerFactory { _, _, _, _ -> + val stream = if (callCount.getAndIncrement() == 0) s1In else s2In + object : H264Tee.ProducerHandle { + override val input: InputStream = stream + override fun close() = runCatching { stream.close() }.let { Unit } + } + } + val tee = H264Tee( + deviceId = deviceId, + videoSize = "720x1280", + bitRate = "4000000", + producerFactory = factory, + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, + ) + + // Session 1: populate the cache with a keyframe, then detach to zero to end the session. + val a = tee.attach(ringBufferBytes = 64 * 1024) + s1Out.write(keyframe + pSlice) // trailing pSlice start code closes the IDR picture into the cache + s1Out.flush() + drainConsumer(a, expected = keyframe.size + pSlice.size) + a.detach() + runCatching { s1Out.close() } + Thread.sleep(100) // let the session-1 reader observe shutdown and exit + + // Session 2: first attach starts a new producer, which must clear the stale cache. + val b = tee.attach(ringBufferBytes = 64 * 1024) + // Joiner attaches while session 2 is running but before it emits any keyframe. + val c = tee.attach(ringBufferBytes = 64 * 1024) + s2Out.write(pSlice) + s2Out.flush() + val cBytes = drainConsumer(c, expected = pSlice.size) + + b.detach() + c.detach() + runCatching { s2Out.close() } + + assertFalse(containsNalType(cBytes, 7), "session-2 joiner must NOT be seeded with session-1's SPS") + assertFalse(containsNalType(cBytes, 5), "session-2 joiner must NOT be seeded with session-1's IDR") + assertTrue(containsNalType(cBytes, 1), "session-2 joiner should still receive live P-slices") + } + // ────────────────────────────────────────────────────────────────────────── // Restart signal — when one screenrecord subprocess exits before shutdown // ────────────────────────────────────────────────────────────────────────── @@ -226,6 +431,50 @@ class H264TeeTest { assertTrue(callCount.get() >= 2, "producer factory should be invoked at least twice (got ${callCount.get()})") } + @Test + fun `unexpected producer exit restarts an unlimited Android stream`() { + val first = byteArrayOf(1, 2, 3, 4) + val second = byteArrayOf(5, 6, 7, 8) + val pipeOut = PipedOutputStream() + val pipeIn = PipedInputStream(pipeOut, 64) + val callCount = AtomicInteger(0) + val factory = H264Tee.ProducerFactory { _, _, _, unlimited -> + assertTrue(unlimited, "API 30+ restarts should preserve the unlimited time limit") + when (callCount.getAndIncrement()) { + 0 -> + object : H264Tee.ProducerHandle { + override val input: InputStream = ByteArrayInputStream(first) + override fun close() {} + } + else -> + object : H264Tee.ProducerHandle { + override val input: InputStream = pipeIn + override fun close() = runCatching { pipeIn.close() }.let { Unit } + } + } + } + val tee = + H264Tee( + deviceId = deviceId, + videoSize = "720x1280", + bitRate = "4000000", + producerFactory = factory, + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + ) + val consumer = tee.attach(ringBufferBytes = 64 * 1024) + + val firstChunk = readBytesUntilRestart(consumer, timeoutMs = 2_000L) + pipeOut.write(second) + pipeOut.flush() + val secondChunk = readBytesUntilEmpty(consumer, expected = second.size, timeoutMs = 2_000L) + + consumer.detach() + runCatching { pipeOut.close() } + assertTrue(firstChunk.contentEquals(first)) + assertTrue(secondChunk.contentEquals(second)) + assertEquals(2, callCount.get()) + } + // ────────────────────────────────────────────────────────────────────────── // Lifecycle — ref-counted start/stop // ────────────────────────────────────────────────────────────────────────── @@ -256,7 +505,7 @@ class H264TeeTest { videoSize = "720x1280", bitRate = "4000000", producerFactory = factory, - sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, // unlimited so no auto-restart + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, ) assertEquals(0, spawns.get()) @@ -314,6 +563,7 @@ class H264TeeTest { bitRate = "4000000", producerFactory = factory, sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, ) // First attach throws — recovery happens silently in the tee, but `attach` rethrows so @@ -353,6 +603,34 @@ class H264TeeTest { // Helpers // ────────────────────────────────────────────────────────────────────────── + /** Builds an Annex-B NAL (4-byte start code + header byte + payload) of the given type. */ + private fun nal(type: Int, payload: ByteArray): ByteArray = + byteArrayOf(0, 0, 0, 1, type.toByte()) + payload + + /** True if [bytes] contains at least one Annex-B NAL of [type]. */ + private fun containsNalType(bytes: ByteArray, type: Int): Boolean { + for (i in 0..bytes.size - 5) { + if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 0.toByte() && + bytes[i + 3] == 1.toByte() && (bytes[i + 4].toInt() and 0x1f) == type + ) { + return true + } + } + return false + } + + /** NAL type of the first Annex-B NAL in [bytes], or -1 if none. */ + private fun firstNalType(bytes: ByteArray): Int { + for (i in 0..bytes.size - 5) { + if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 0.toByte() && + bytes[i + 3] == 1.toByte() + ) { + return bytes[i + 4].toInt() and 0x1f + } + } + return -1 + } + /** Producer factory that returns the same single InputStream once and then EOFs. */ private fun singleShotProducer(stream: InputStream): H264Tee.ProducerFactory = H264Tee.ProducerFactory { _, _, _, _ -> diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/LiveFrameConsumerTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/LiveFrameConsumerTest.kt index 67922dc36..24fe4be45 100644 --- a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/LiveFrameConsumerTest.kt +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/LiveFrameConsumerTest.kt @@ -1,19 +1,152 @@ package xyz.block.trailblaze.capture.video +import java.io.File +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue +import xyz.block.trailblaze.devices.TrailblazeDeviceId +import xyz.block.trailblaze.devices.TrailblazeDevicePlatform /** - * Unit tests for [LiveFrameConsumer.JpegFrameSplitter] — the byte-level marker logic that - * reassembles individual JPEG frames out of ffmpeg's image2pipe concatenated output. - * - * Don't need real JPEG bytes: we just synthesize SOI/EOI markers with arbitrary payload in - * between. The splitter is pure logic; the production path is exercised end-to-end via the - * live integration test Sam runs pre-merge. + * Tests the live H.264 → JPEG contract end-to-end against ffmpeg, plus the pure byte-level + * splitter and unchanged-frame throttling policies. */ class LiveFrameConsumerTest { + @Test + fun `emits a jpeg before the live h264 producer reaches eof`() { + if (!ffmpegOnPath()) { + println("skipping: ffmpeg not on PATH") + return + } + + val tempDir = Files.createTempDirectory("live-frame-consumer-").toFile() + val producerInput = PipedInputStream(256 * 1024) + val producerOutput = PipedOutputStream(producerInput) + val producerClosed = AtomicBoolean(false) + val firstFrame = AtomicReference() + val frameArrived = CountDownLatch(1) + val tee = H264Tee( + deviceId = TrailblazeDeviceId("live-test", TrailblazeDevicePlatform.ANDROID), + videoSize = "320x240", + bitRate = "500000", + producerFactory = H264Tee.ProducerFactory { _, _, _, _ -> + object : H264Tee.ProducerHandle { + override val input = producerInput + + override fun close() { + producerClosed.set(true) + producerInput.close() + } + } + }, + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + ) + val consumer = LiveFrameConsumer( + tee = tee, + onFrame = { frame, _ -> + firstFrame.compareAndSet(null, frame) + frameArrived.countDown() + }, + ) + + try { + val h264 = generateH264Fixture(File(tempDir, "live.h264")) + consumer.start() + producerOutput.write(h264.readBytes()) + producerOutput.flush() + + assertTrue( + frameArrived.await(5, TimeUnit.SECONDS), + "the decoder should emit while the producer pipe remains open", + ) + assertFalse(producerClosed.get(), "the frame must arrive before producer EOF or teardown") + val jpeg = assertNotNull(firstFrame.get()) + assertTrue( + jpeg.size >= 4 && + jpeg[0] == 0xFF.toByte() && + jpeg[1] == 0xD8.toByte() && + jpeg[jpeg.lastIndex - 1] == 0xFF.toByte() && + jpeg[jpeg.lastIndex] == 0xD9.toByte(), + "callback should receive one complete JPEG", + ) + } finally { + consumer.stop() + runCatching { producerOutput.close() } + tempDir.deleteRecursively() + } + } + + @Test + fun `unchanged frames are throttled but still emit a heartbeat`() { + val gate = LiveFrameConsumer.FrameEmissionGate(maxSilenceMillis = 1_000) + val first = byteArrayOf(1, 2, 3) + + assertEquals( + LiveFrameConsumer.FrameEmissionGate.Emission.CONTENT_CHANGE, + gate.admit(first, nowMillis = 100), + ) + assertNull(gate.admit(first, nowMillis = 1_099)) + assertEquals( + LiveFrameConsumer.FrameEmissionGate.Emission.HEARTBEAT, + gate.admit(first, nowMillis = 1_100), + ) + assertEquals( + LiveFrameConsumer.FrameEmissionGate.Emission.CONTENT_CHANGE, + gate.admit(byteArrayOf(4, 5, 6), nowMillis = 1_101), + ) + } + + @Test + fun `decoder startup failure releases the h264 producer`() { + val producerInput = PipedInputStream() + val producerOutput = PipedOutputStream(producerInput) + val producerClosed = CountDownLatch(1) + val tee = H264Tee( + deviceId = TrailblazeDeviceId("failed-decoder-test", TrailblazeDevicePlatform.ANDROID), + videoSize = "320x240", + bitRate = "500000", + producerFactory = H264Tee.ProducerFactory { _, _, _, _ -> + object : H264Tee.ProducerHandle { + override val input = producerInput + + override fun close() { + producerInput.close() + producerClosed.countDown() + } + } + }, + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + ) + val consumer = LiveFrameConsumer( + tee = tee, + onFrame = { _, _ -> }, + ffmpegBinary = "definitely-not-a-real-ffmpeg-binary", + ) + + try { + assertFailsWith { consumer.start() } + assertTrue( + producerClosed.await(2, TimeUnit.SECONDS), + "a failed decoder start should stop the screenrecord producer", + ) + } finally { + consumer.stop() + runCatching { producerOutput.close() } + } + } + @Test fun `splits a single frame in one feed`() { val frame = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 3, 0xFF.toByte(), 0xD9.toByte()) @@ -72,4 +205,38 @@ class LiveFrameConsumerTest { val expected = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 3, 4, 0xFF.toByte(), 0xD9.toByte()) assertTrue(frames[0].contentEquals(expected)) } + + private fun generateH264Fixture(target: File): File { + val process = ProcessBuilder( + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", "error", + "-f", "lavfi", + "-i", "testsrc=duration=1:size=320x240:rate=15", + "-c:v", "libx264", + "-preset", "ultrafast", + "-tune", "zerolatency", + "-f", "h264", + target.absolutePath, + ).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + check(process.waitFor(30, TimeUnit.SECONDS) && process.exitValue() == 0) { + "failed to generate H.264 fixture: $output" + } + return target + } + + private fun ffmpegOnPath(): Boolean = try { + ProcessBuilder("ffmpeg", "-version") + .redirectErrorStream(true) + .start() + .let { process -> + val finished = process.waitFor(5, TimeUnit.SECONDS) + if (!finished) process.destroyForcibly() + finished && process.exitValue() == 0 + } + } catch (_: Exception) { + false + } } diff --git a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/MuxToMp4ConsumerTest.kt b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/MuxToMp4ConsumerTest.kt index 170fbd19a..4c891162c 100644 --- a/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/MuxToMp4ConsumerTest.kt +++ b/trailblaze-capture/src/test/kotlin/xyz/block/trailblaze/capture/video/MuxToMp4ConsumerTest.kt @@ -56,7 +56,8 @@ class MuxToMp4ConsumerTest { videoSize = "320x240", bitRate = "500000", producerFactory = streamFileOnceProducer(h264), - sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, // unlimited — no restart, single segment + sdkLevelProvider = { H264Tee.ANDROID_R_SDK }, + restartOnUnexpectedExit = false, ) val consumer = MuxToMp4Consumer(sessionDir = tempDir, tee = tee) diff --git a/trailblaze-common/build.gradle.kts b/trailblaze-common/build.gradle.kts index ce7c16e06..76e2b89d1 100644 --- a/trailblaze-common/build.gradle.kts +++ b/trailblaze-common/build.gradle.kts @@ -325,6 +325,7 @@ kotlin { api(libs.ktor.client.core) api(project(":trailblaze-models")) + implementation(project(":trailblaze-ondevice-rpc-proto")) implementation(project(":trailblaze-tracing")) implementation(libs.exp4j) @@ -333,6 +334,7 @@ kotlin { implementation(libs.kotlinx.serialization.core) implementation(libs.ktor.client.logging) implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.http) implementation(libs.ktor.utils) implementation(libs.kotlin.reflect) diff --git a/trailblaze-common/dependencies/jvmRuntimeClasspath.txt b/trailblaze-common/dependencies/jvmRuntimeClasspath.txt index d4bc04d98..d2ad66d5f 100644 --- a/trailblaze-common/dependencies/jvmRuntimeClasspath.txt +++ b/trailblaze-common/dependencies/jvmRuntimeClasspath.txt @@ -1,4 +1,5 @@ :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-tracing ai.koog:agents-core-jvm:1.0.0 ai.koog:agents-core:1.0.0 @@ -87,6 +88,8 @@ com.squareup.okhttp3:okhttp-jvm:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:dadb:1.2.10 dev.mobile:maestro-client:2.6.1 @@ -107,6 +110,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/TrailblazeAgentContext.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/TrailblazeAgentContext.kt index d41a202fd..fde2a52c3 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/TrailblazeAgentContext.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/TrailblazeAgentContext.kt @@ -10,9 +10,11 @@ import xyz.block.trailblaze.logs.client.TrailblazeSessionProvider import xyz.block.trailblaze.logs.model.TraceId import xyz.block.trailblaze.toolcalls.DelegatingTrailblazeTool import xyz.block.trailblaze.toolcalls.ExecutableTrailblazeTool +import xyz.block.trailblaze.toolcalls.SensitiveArgsTrailblazeTool import xyz.block.trailblaze.toolcalls.TrailblazeTool import xyz.block.trailblaze.toolcalls.buildLogSafeResolvedPayload import xyz.block.trailblaze.toolcalls.scrubSensitiveValues +import xyz.block.trailblaze.toolcalls.withSensitiveArgsRedacted import xyz.block.trailblaze.toolcalls.TrailblazeToolExecutionContext import xyz.block.trailblaze.toolcalls.TrailblazeToolResult import xyz.block.trailblaze.toolcalls.getIsRecordableFromAnnotation @@ -76,7 +78,14 @@ fun TrailblazeAgentContext.logToolExecution( // generator a name/args mismatch. Without an override, `trailblazeTool` is the log-safe // RESOLVED form (sensitive tokens kept literal — see [buildLogSafeResolvedPayload]) and // `rawTrailblazeTool` the authored form, elided when scrubbing left them identical. - val rawPayload = if (recordedToolOverride == null) rawTool?.toLogPayload() else null + // The authored form may be a raw-args wrapper that doesn't itself implement + // [SensitiveArgsTrailblazeTool], so its payload is additionally masked with the EXECUTED + // instance's declared sensitive args (`toLogPayload()` only self-redacts). + val rawPayload = if (recordedToolOverride == null) { + rawTool?.toLogPayload()?.withSensitiveArgsRedacted(tool.sensitiveArgNamesOrEmpty()) + } else { + null + } val resolvedPayload = if (rawPayload != null) { buildLogSafeResolvedPayload(rawPayload, memory) } else { @@ -115,6 +124,15 @@ fun TrailblazeAgentContext.logToolExecution( trailblazeLogger.log(session, toolLog) } +/** + * The sensitive-arg names the EXECUTED tool declares, for masking the authored (`rawTool`) + * payload. The authored form is often a raw-args wrapper (name + JSON) that doesn't implement + * [SensitiveArgsTrailblazeTool] itself, so `toLogPayload()`'s self-redaction can't fire for it — + * the executed class instance is the source of truth for which args are secret. + */ +private fun TrailblazeTool.sensitiveArgNamesOrEmpty(): Set = + (this as? SensitiveArgsTrailblazeTool)?.sensitiveArgNames ?: emptySet() + /** * Kill-switch for the nested-dispatch recording filter: forces `isRecordable` back to the tool's * annotation value even inside a nested `ctx.tools.*` dispatch. Read per call so it flips on a @@ -171,7 +189,9 @@ fun TrailblazeAgentContext.logToolExecution( rawTool: TrailblazeTool? = null, ) { val session = sessionProvider.invoke() - val rawPayload = rawTool?.toLogPayload() + // Same masking rationale as the context-carrying overload: the authored wrapper doesn't + // implement [SensitiveArgsTrailblazeTool], so apply the executed instance's declared args. + val rawPayload = rawTool?.toLogPayload()?.withSensitiveArgsRedacted(tool.sensitiveArgNamesOrEmpty()) val resolvedPayload = if (rawPayload != null) { buildLogSafeResolvedPayload(rawPayload, memory) } else { diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTarget.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTarget.kt index 92482803a..27399f551 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTarget.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTarget.kt @@ -227,6 +227,8 @@ class YamlBackedHostAppTarget( override fun getSystemPromptTemplate(): String? = config.systemPrompt + override fun getElectronAppConfig(): xyz.block.trailblaze.yaml.ElectronAppConfig? = config.electron + // --- Version info --- override fun getMinBuildVersion(platform: TrailblazeDevicePlatform): String? = diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlDefinedTrailblazeTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlDefinedTrailblazeTool.kt index ac57c49ee..bd98402a9 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlDefinedTrailblazeTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/YamlDefinedTrailblazeTool.kt @@ -60,7 +60,7 @@ import xyz.block.trailblaze.yaml.TrailblazeYaml * `InstanceNamedTrailblazeTool` check. */ @TrailblazeToolClass(name = "_yaml_defined") -class YamlDefinedTrailblazeTool( +data class YamlDefinedTrailblazeTool( val config: ToolYamlConfig, val params: Map, ) : DelegatingTrailblazeTool, InstanceNamedTrailblazeTool { diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/http/TrailblazeHttpClientFactory.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/http/TrailblazeHttpClientFactory.kt index dda9bc151..d65952a89 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/http/TrailblazeHttpClientFactory.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/http/TrailblazeHttpClientFactory.kt @@ -6,6 +6,7 @@ import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.websocket.WebSockets import okhttp3.Interceptor import okhttp3.OkHttpClient import java.security.SecureRandom @@ -64,11 +65,16 @@ object TrailblazeHttpClientFactory { } } + private fun HttpClientConfig<*>.enableWebSockets() { + install(WebSockets) + } + fun createInsecureTrustAllCertsHttpClient( timeoutInSeconds: Long, ) = HttpClient(OkHttp) { enablePerfettoTracing() enableNetworkLogging() + enableWebSockets() engine { config { configureOkHttpClient(timeoutInSeconds, true) @@ -82,6 +88,7 @@ object TrailblazeHttpClientFactory { ) = HttpClient(OkHttp) { enablePerfettoTracing() enableNetworkLogging() + enableWebSockets() if (reverseProxyUrl != null) { install(ReverseProxyPlugin) { this.reverseProxyEnabled = true // Disable reverse proxy for this client diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodec.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodec.kt new file mode 100644 index 000000000..4c8bb9cd5 --- /dev/null +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodec.kt @@ -0,0 +1,133 @@ +package xyz.block.trailblaze.logs.client + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okio.ByteString.Companion.toByteString +import xyz.block.trailblaze.ondevice.rpc.proto.AgentLogUpload +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec + +/** Keeps persisted log models unchanged while lifting large tree fields onto protobuf. */ +object TrailblazeLogProtoCodec { + private val wireJson by lazy { Json(TrailblazeJsonInstance) { prettyPrint = false } } + + fun TrailblazeLog.toProto(): AgentLogUpload { + val trees = trees() + val encoded = + wireJson.encodeToJsonElement(TrailblazeLog.serializer(), withoutProtobufTrees()).jsonObject + val metadata = JsonObject(encoded.filterKeys { it !in trees.protobufFieldNames }) + return OnDeviceRpcProtoCodec.run { + AgentLogUpload( + log_type = encoded["class"]?.jsonPrimitive?.content ?: this@toProto::class.simpleName.orEmpty(), + session_id = session.value, + timestamp_epoch_millis = timestamp.toEpochMilliseconds(), + metadata_json = metadata.toString().encodeToByteArray().toByteString(), + view_hierarchy = trees.viewHierarchy?.toProto(), + view_hierarchy_filtered = trees.viewHierarchyFiltered?.toProto(), + trailblaze_node_tree = trees.trailblazeNodeTree?.toProto(), + driver_migration_tree_node = trees.driverMigrationTreeNode?.toProto(), + ) + } + } + + fun AgentLogUpload.toModel(): TrailblazeLog { + val metadata = wireJson.parseToJsonElement(metadata_json.utf8()).jsonObject.toMutableMap() + OnDeviceRpcProtoCodec.run { + view_hierarchy?.let { + metadata["viewHierarchy"] = wireJson.encodeToJsonElement(it.toModel()) + } + view_hierarchy_filtered?.let { + metadata["viewHierarchyFiltered"] = wireJson.encodeToJsonElement(it.toModel()) + } + trailblaze_node_tree?.let { + metadata["trailblazeNodeTree"] = wireJson.encodeToJsonElement(it.toModel()) + } + driver_migration_tree_node?.let { + metadata["driverMigrationTreeNode"] = wireJson.encodeToJsonElement(it.toModel()) + } + } + return wireJson.decodeFromJsonElement(TrailblazeLog.serializer(), JsonObject(metadata)) + } + + private fun TrailblazeLog.trees(): LogTrees = when (this) { + is TrailblazeLog.TrailblazeLlmRequestLog -> LogTrees( + viewHierarchy = viewHierarchy, + viewHierarchyFiltered = viewHierarchyFiltered, + trailblazeNodeTree = trailblazeNodeTree, + driverMigrationTreeNode = driverMigrationTreeNode, + protobufFieldNames = setOf( + "viewHierarchy", + "viewHierarchyFiltered", + "trailblazeNodeTree", + "driverMigrationTreeNode", + ), + ) + is TrailblazeLog.AgentDriverLog -> LogTrees( + viewHierarchy = viewHierarchy, + trailblazeNodeTree = trailblazeNodeTree, + driverMigrationTreeNode = driverMigrationTreeNode, + protobufFieldNames = setOf( + "viewHierarchy", + "trailblazeNodeTree", + "driverMigrationTreeNode", + ), + ) + is TrailblazeLog.TrailblazeSnapshotLog -> LogTrees( + viewHierarchy = viewHierarchy, + trailblazeNodeTree = trailblazeNodeTree, + driverMigrationTreeNode = driverMigrationTreeNode, + protobufFieldNames = setOf( + "viewHierarchy", + "trailblazeNodeTree", + "driverMigrationTreeNode", + ), + ) + is TrailblazeLog.McpSamplingLog -> LogTrees( + viewHierarchy = viewHierarchy, + viewHierarchyFiltered = viewHierarchyFiltered, + protobufFieldNames = setOf("viewHierarchy", "viewHierarchyFiltered"), + ) + else -> LogTrees() + } + + /** + * Avoid walking hierarchy trees through kotlinx.serialization only to discard that JSON. The + * required hierarchy properties use an empty placeholder which is removed from the encoded + * object; nullable properties can be cleared directly. + */ + private fun TrailblazeLog.withoutProtobufTrees(): TrailblazeLog = when (this) { + is TrailblazeLog.TrailblazeLlmRequestLog -> copy( + viewHierarchy = EMPTY_VIEW_HIERARCHY, + viewHierarchyFiltered = null, + trailblazeNodeTree = null, + driverMigrationTreeNode = null, + ) + is TrailblazeLog.AgentDriverLog -> copy( + viewHierarchy = null, + trailblazeNodeTree = null, + driverMigrationTreeNode = null, + ) + is TrailblazeLog.TrailblazeSnapshotLog -> copy( + viewHierarchy = EMPTY_VIEW_HIERARCHY, + trailblazeNodeTree = null, + driverMigrationTreeNode = null, + ) + is TrailblazeLog.McpSamplingLog -> copy( + viewHierarchy = null, + viewHierarchyFiltered = null, + ) + else -> this + } + + private data class LogTrees( + val viewHierarchy: xyz.block.trailblaze.api.ViewHierarchyTreeNode? = null, + val viewHierarchyFiltered: xyz.block.trailblaze.api.ViewHierarchyTreeNode? = null, + val trailblazeNodeTree: xyz.block.trailblaze.api.TrailblazeNode? = null, + val driverMigrationTreeNode: xyz.block.trailblaze.api.TrailblazeNode? = null, + val protobufFieldNames: Set = emptySet(), + ) + + private val EMPTY_VIEW_HIERARCHY = xyz.block.trailblaze.api.ViewHierarchyTreeNode() +} diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogServerClient.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogServerClient.kt index 595d8bba7..2fd65b81e 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogServerClient.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogServerClient.kt @@ -10,13 +10,25 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import kotlinx.datetime.Clock +import okio.ByteString.Companion.toByteString import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.ondevice.rpc.proto.LogUploadEnvelope +import xyz.block.trailblaze.ondevice.rpc.proto.ScreenshotUpload +import xyz.block.trailblaze.ondevice.rpc.proto.TraceUpload +import xyz.block.trailblaze.transport.AndroidWireTransport +import xyz.block.trailblaze.transport.AndroidWireTransportMode import xyz.block.trailblaze.util.Console class TrailblazeLogServerClient( val httpClient: HttpClient, val baseUrl: String, + private val useBinaryTransport: Boolean, ) { + constructor(httpClient: HttpClient, baseUrl: String) : this(httpClient, baseUrl, false) + + private val webSocketClientDelegate = lazy { TrailblazeLogWebSocketClient(httpClient, baseUrl) } + private val webSocketClient by webSocketClientDelegate + private suspend fun ping(): HttpResponse = httpClient.get("$baseUrl/ping") suspend fun isServerRunning(): Boolean { @@ -38,8 +50,21 @@ class TrailblazeLogServerClient( } } - suspend fun postScreenshot(screenshotFilename: String, sessionId: SessionId, - screenshotBytes: ByteArray + suspend fun sendAgentLog(log: TrailblazeLog): Boolean = + sendWithPreferredTransport( + protobuf = { id -> + LogUploadEnvelope( + upload_id = id, + agent_log = TrailblazeLogProtoCodec.run { log.toProto() }, + ) + }, + jsonHttp = { postAgentLog(log).status == HttpStatusCode.OK }, + ) + + suspend fun postScreenshot( + screenshotFilename: String, + sessionId: SessionId, + screenshotBytes: ByteArray, ): HttpResponse = httpClient.post("$baseUrl/log/screenshot") { parameter(key = "filename", value = screenshotFilename) parameter(key = "session", value = sessionId.value) @@ -47,10 +72,66 @@ class TrailblazeLogServerClient( setBody(screenshotBytes) } + suspend fun sendScreenshot( + screenshotFilename: String, + sessionId: SessionId, + screenshotBytes: ByteArray, + ): Boolean = sendWithPreferredTransport( + protobuf = { id -> + LogUploadEnvelope( + upload_id = id, + screenshot = ScreenshotUpload( + filename = screenshotFilename, + session_id = sessionId.value, + image = screenshotBytes.toByteString(), + ), + ) + }, + jsonHttp = { + postScreenshot(screenshotFilename, sessionId, screenshotBytes).status == HttpStatusCode.OK + }, + ) + suspend fun postTrace(sessionId: SessionId, traceJson: String): HttpResponse = httpClient.post("$baseUrl/log/trace") { parameter(key = "session", value = sessionId.value) contentType(ContentType.Application.Json) setBody(traceJson) } + + suspend fun sendTrace(sessionId: SessionId, traceJson: String): Boolean = + sendWithPreferredTransport( + protobuf = { id -> + LogUploadEnvelope( + upload_id = id, + trace = TraceUpload( + session_id = sessionId.value, + trace_json = traceJson.encodeToByteArray().toByteString(), + ), + ) + }, + jsonHttp = { postTrace(sessionId, traceJson).status == HttpStatusCode.OK }, + ) + + private suspend fun sendWithPreferredTransport( + protobuf: (Long) -> LogUploadEnvelope, + jsonHttp: suspend () -> Boolean, + ): Boolean { + if (!useBinaryTransport) return jsonHttp() + if (AndroidWireTransport.mode == AndroidWireTransportMode.JSON) return jsonHttp() + return when (val attempt = webSocketClient.send(protobuf)) { + TrailblazeLogWebSocketClient.Attempt.Success -> true + TrailblazeLogWebSocketClient.Attempt.FallbackToHttp -> + if (AndroidWireTransport.mode == AndroidWireTransportMode.AUTO) jsonHttp() else false + is TrailblazeLogWebSocketClient.Attempt.Failure -> { + Console.log("[TrailblazeLogWebSocket] ${attempt.message}") + false + } + } + } + + fun close() { + if (webSocketClientDelegate.isInitialized()) webSocketClientDelegate.value.close() + httpClient.close() + } } diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogWebSocketClient.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogWebSocketClient.kt new file mode 100644 index 000000000..f845c258f --- /dev/null +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogWebSocketClient.kt @@ -0,0 +1,149 @@ +package xyz.block.trailblaze.logs.client + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.url +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readBytes +import io.ktor.websocket.send +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import xyz.block.trailblaze.ondevice.rpc.proto.LogUploadAck +import xyz.block.trailblaze.ondevice.rpc.proto.LogUploadEnvelope +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec + +/** Persistent device-to-host protobuf upload channel. */ +internal class TrailblazeLogWebSocketClient( + private val httpClient: HttpClient, + baseUrl: String, +) { + sealed interface Attempt { + data object Success : Attempt + /** Connection failed before an upload frame was sent, so HTTP is safe. */ + data object FallbackToHttp : Attempt + /** Delivery after send is uncertain; callers must use their durable disk fallback. */ + data class Failure(val message: String) : Attempt + } + + private val webSocketUrl = when { + baseUrl.startsWith("https://") -> baseUrl.replaceFirst("https://", "wss://") + else -> baseUrl.replaceFirst("http://", "ws://") + } + WEBSOCKET_PATH + private val requestIds = AtomicLong(0) + private val pending = ConcurrentHashMap>() + private val connectionMutex = Mutex() + private val sendMutex = Mutex() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var session: WebSocketSession? = null + @Volatile private var readerJob: Job? = null + @Volatile private var reconnectAfterEpochMs = 0L + + suspend fun send(payload: (Long) -> LogUploadEnvelope): Attempt { + val activeSession = ensureConnected() ?: return Attempt.FallbackToHttp + val uploadId = requestIds.incrementAndGet() + val ack = CompletableDeferred() + pending[uploadId] = ack + try { + sendMutex.withLock { + activeSession.send( + Frame.Binary( + fin = true, + data = OnDeviceRpcProtoCodec.encode(payload(uploadId)), + ), + ) + } + } catch (e: CancellationException) { + pending.remove(uploadId) + throw e + } catch (e: Exception) { + pending.remove(uploadId) + clearSession(activeSession) + return Attempt.Failure("Protobuf log upload failed after send: ${e.message}") + } + + val response = try { + withTimeoutOrNull(ACK_TIMEOUT_MS) { ack.await() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } finally { + pending.remove(uploadId) + } + return when { + response == null -> Attempt.Failure("Protobuf log upload timed out waiting for acknowledgement") + response.success -> Attempt.Success + else -> Attempt.Failure(response.error_message ?: "Host rejected protobuf log upload") + } + } + + private suspend fun ensureConnected(): WebSocketSession? = connectionMutex.withLock { + session?.let { return@withLock it } + if (System.currentTimeMillis() < reconnectAfterEpochMs) return@withLock null + val newSession = try { + httpClient.webSocketSession { url(webSocketUrl) } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + reconnectAfterEpochMs = System.currentTimeMillis() + CONNECT_RETRY_DELAY_MS + return@withLock null + } + reconnectAfterEpochMs = 0L + session = newSession + readerJob = scope.launch { + try { + for (frame in newSession.incoming) { + if (frame !is Frame.Binary) continue + val ack = OnDeviceRpcProtoCodec.decodeLogUploadAck(frame.readBytes()) + pending.remove(ack.upload_id)?.complete(ack) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + failPending(e) + } finally { + clearSession(newSession) + failPending(IOException("Protobuf log WebSocket closed")) + } + } + newSession + } + + private fun clearSession(expected: WebSocketSession) { + if (session === expected) session = null + } + + private fun failPending(error: Throwable) { + pending.entries.toList().forEach { (id, deferred) -> + if (pending.remove(id, deferred)) deferred.completeExceptionally(error) + } + } + + fun close() { + readerJob?.cancel() + readerJob = null + session = null + failPending(IOException("Protobuf log client closed")) + scope.cancel() + } + + private companion object { + const val WEBSOCKET_PATH = "/logs-ws" + const val ACK_TIMEOUT_MS = 2_000L + const val CONNECT_RETRY_DELAY_MS = 30_000L + } +} diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/mobile/tools/AndroidWriteBytesToFileTrailblazeTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/mobile/tools/AndroidWriteBytesToFileTrailblazeTool.kt index dfa02ea7d..09eaaa86f 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/mobile/tools/AndroidWriteBytesToFileTrailblazeTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/mobile/tools/AndroidWriteBytesToFileTrailblazeTool.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import xyz.block.trailblaze.toolcalls.ExecutableTrailblazeTool +import xyz.block.trailblaze.toolcalls.SensitiveArgsTrailblazeTool import xyz.block.trailblaze.toolcalls.TrailblazeToolClass import xyz.block.trailblaze.toolcalls.TrailblazeToolExecutionContext import xyz.block.trailblaze.toolcalls.TrailblazeToolResult @@ -75,7 +76,17 @@ data class AndroidWriteBytesToFileTrailblazeTool( "writing, so any binary payload is supported.", ) val base64Content: String, -) : ExecutableTrailblazeTool { +) : ExecutableTrailblazeTool, SensitiveArgsTrailblazeTool { + + /** + * `base64Content` is masked in persisted session logs: it is opaque bulk bytes that routinely + * carry secret material (seeded session/auth files with live tokens), and logging it verbatim + * ships that material in CI artifacts. Masking also keeps multi-hundred-KB blobs out of session + * logs. The write itself is unaffected — redaction applies only at the log-encode boundary. + */ + override val sensitiveArgNames: Set + get() = setOf("base64Content") + override suspend fun execute( toolExecutionContext: TrailblazeToolExecutionContext, ): TrailblazeToolResult { diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/rules/TrailblazeLoggingRule.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/rules/TrailblazeLoggingRule.kt index 9b5d810b7..b239d7ad1 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/rules/TrailblazeLoggingRule.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/rules/TrailblazeLoggingRule.kt @@ -1,6 +1,5 @@ package xyz.block.trailblaze.rules -import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock @@ -47,6 +46,9 @@ abstract class TrailblazeLoggingRule( abstract val trailblazeDeviceInfoProvider: () -> TrailblazeDeviceInfo + /** Android runners override this to use the persistent protobuf upload socket. */ + protected open val useBinaryLogTransport: Boolean = false + /** * Current session for this test. * Updated automatically during test lifecycle (ruleCreation, afterTestExecution). @@ -121,16 +123,16 @@ abstract class TrailblazeLoggingRule( runBlocking(Dispatchers.IO) { if (isServerAvailable) { try { - val httpResult = trailblazeLogServerClient.postAgentLog(log) - if (httpResult.status.value != 200) { - // A non-200 means the server rejected the log — fall back to disk just like the + val sent = trailblazeLogServerClient.sendAgentLog(log) + if (!sent) { + // A rejected upload falls back to disk just like the // exception path below, so the log still lands somewhere durable. Without this, a // reachable-but-erroring log server silently drops the log. That matters for the // on-device tool-log count (#3818): the device counts a `TrailblazeToolLog` at // emit time and the host skips its own catch-all emit on the strength of that // count, so a dropped persist here would leave the tool absent from the report. // The disk fallback keeps "counted" ⇒ "persisted (server or disk)" true. - Console.log("Error while posting agent log: ${httpResult.status.value} ${httpResult.bodyAsText()}") + Console.log("Error while uploading agent log; falling back to disk") writeLogToDisk(sessionId, log) } } catch (e: Exception) { @@ -169,6 +171,7 @@ abstract class TrailblazeLoggingRule( timeoutInSeconds = 2, ), baseUrl = logsBaseUrl, + useBinaryTransport = useBinaryLogTransport, ) } @@ -341,13 +344,14 @@ private class ServerScreenStateLogger( return runBlocking(Dispatchers.IO) { if (isServerAvailable) { try { - val logResult = trailblazeLogServerClient.postScreenshot( + val sent = trailblazeLogServerClient.sendScreenshot( screenshotFilename = screenState.fileName, sessionId = screenState.sessionId, screenshotBytes = screenState.screenState.screenshotBytes ?: ByteArray(0), ) - if (logResult.status.value != 200) { - Console.log("Error while posting screenshot: ${logResult.status.value}") + if (!sent) { + Console.log("Error while uploading screenshot; falling back to disk") + writeScreenshotToDisk(screenState) } } catch (e: Exception) { Console.log("Failed to post screenshot to server: ${e.message}") diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/TrailblazeTools.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/TrailblazeTools.kt index f5a5a1eee..36e8c3c24 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/TrailblazeTools.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/TrailblazeTools.kt @@ -104,8 +104,16 @@ fun TrailblazeTool.requiresHostInstance(): Boolean { * common-main canonical encoder [toOtherTrailblazeToolPayload], which is also what * [TrailblazeToolJsonSerializer] uses for `@Contextual TrailblazeTool` JSON encoding so * both paths produce identical wire formats. + * + * A [SensitiveArgsTrailblazeTool] instance gets its declared arg values masked in the returned + * payload — this is the LOG encode, so secrets (credentials, session seeds) must not survive it. + * The wire/execution encode ([toOtherTrailblazeToolPayload] called directly) stays unredacted. */ -fun TrailblazeTool.toLogPayload(): OtherTrailblazeTool { +fun TrailblazeTool.toLogPayload(): OtherTrailblazeTool = toLogPayloadUnredacted().let { payload -> + if (this is SensitiveArgsTrailblazeTool) payload.withSensitiveArgsRedacted(sensitiveArgNames) else payload +} + +private fun TrailblazeTool.toLogPayloadUnredacted(): OtherTrailblazeTool { if (this is OtherTrailblazeTool) return this if (this is RawArgumentTrailblazeTool) { return OtherTrailblazeTool(instanceToolName, rawToolArguments) diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/RequestDetailedViewHierarchyTrailblazeTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/RequestDetailedViewHierarchyTrailblazeTool.kt index 8a3b03b94..8e6ee67ae 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/RequestDetailedViewHierarchyTrailblazeTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/RequestDetailedViewHierarchyTrailblazeTool.kt @@ -50,7 +50,7 @@ import xyz.block.trailblaze.toolcalls.TrailblazeToolResult "expect isn't in the current screen view, when you need surrounding static text for context, " + "or to discover what's reachable by scrolling — BEFORE blindly scrolling or guessing.", ) -class RequestDetailedViewHierarchyTrailblazeTool( +data class RequestDetailedViewHierarchyTrailblazeTool( override val reasoning: String? = null, ) : ExecutableTrailblazeTool, ReadOnlyTrailblazeTool, ReasoningTrailblazeTool { diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/ScrollUntilTextIsVisibleTrailblazeTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/ScrollUntilTextIsVisibleTrailblazeTool.kt index df712cc70..f82a2c824 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/ScrollUntilTextIsVisibleTrailblazeTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/ScrollUntilTextIsVisibleTrailblazeTool.kt @@ -45,7 +45,7 @@ every element). If both 'text' and 'textRegex' are given, 'textRegex' takes prec additional disambiguation fields (e.g. 'index') when multiple elements match the same target. """, ) -class ScrollUntilTextIsVisibleTrailblazeTool( +data class ScrollUntilTextIsVisibleTrailblazeTool( @param:LLMDescription("Text to search for while scrolling (substring match). Provide this OR 'textRegex'.") val text: String = "", @param:LLMDescription( diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/SwipeTrailblazeTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/SwipeTrailblazeTool.kt index 0e5340e3c..93016f826 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/SwipeTrailblazeTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/SwipeTrailblazeTool.kt @@ -23,7 +23,7 @@ Swipe the screen in the specified direction to navigate long lists or pages. Sta are calculated automatically from the direction and screen dimensions. """, ) -class SwipeTrailblazeTool( +data class SwipeTrailblazeTool( @param:LLMDescription( """The direction of the finger swipe gesture (not the scroll direction). To see more content BELOW (scroll down), use 'UP' (finger swipes upward). diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/TakeSnapshotTool.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/TakeSnapshotTool.kt index 5c8d20875..18083bbd1 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/TakeSnapshotTool.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/toolcalls/commands/TakeSnapshotTool.kt @@ -16,7 +16,7 @@ import xyz.block.trailblaze.util.Console Take a snapshot of the current page and save it under the provided screen name. """, ) -class TakeSnapshotTool( +data class TakeSnapshotTool( @param:LLMDescription("Name for the screen being captured (e.g., 'login_screen', 'payment_confirmation').") val screenName: String, @param:LLMDescription("Optional description of what this snapshot captures or why it was taken.") diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/tracing/TrailblazeTraceExporter.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/tracing/TrailblazeTraceExporter.kt index 55895489b..752454257 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/tracing/TrailblazeTraceExporter.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/tracing/TrailblazeTraceExporter.kt @@ -1,6 +1,5 @@ package xyz.block.trailblaze.tracing -import io.ktor.http.HttpStatusCode import xyz.block.trailblaze.logs.client.TrailblazeLogServerClient import xyz.block.trailblaze.logs.model.SessionId import xyz.block.trailblaze.util.Console @@ -29,11 +28,11 @@ object TrailblazeTraceExporter { val traceJson = TrailblazeTracer.exportJson() try { if (isServerAvailable) { - val response = client.postTrace(sessionId, traceJson) - if (response.status == HttpStatusCode.OK) { + val sent = client.sendTrace(sessionId, traceJson) + if (sent) { Console.info("Trace posted to server for session ${sessionId.value}") } else { - Console.log("Trace POST returned ${response.status} for session ${sessionId.value}, falling back to disk") + Console.log("Trace upload failed for session ${sessionId.value}, falling back to disk") writeToDisk?.invoke(traceJson) } } else { diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/transport/AndroidWireTransport.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/transport/AndroidWireTransport.kt new file mode 100644 index 000000000..e63a49666 --- /dev/null +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/transport/AndroidWireTransport.kt @@ -0,0 +1,28 @@ +package xyz.block.trailblaze.transport + +import xyz.block.trailblaze.util.Console + +enum class AndroidWireTransportMode { + AUTO, + PROTOBUF, + JSON, +} + +/** Shared rollback switch for host RPC and device-to-host log uploads. */ +object AndroidWireTransport { + const val ENVIRONMENT_VARIABLE = "TRAILBLAZE_ANDROID_WIRE_TRANSPORT" + + val mode: AndroidWireTransportMode by lazy { + when (val value = System.getenv(ENVIRONMENT_VARIABLE)?.trim()?.lowercase()) { + null, "", "auto" -> AndroidWireTransportMode.AUTO + "protobuf", "proto", "websocket", "ws" -> AndroidWireTransportMode.PROTOBUF + "json", "http" -> AndroidWireTransportMode.JSON + else -> { + Console.log( + "[AndroidWireTransport] Ignoring invalid $ENVIRONMENT_VARIABLE=$value; using auto", + ) + AndroidWireTransportMode.AUTO + } + } + } +} diff --git a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/SensitiveArgsLogRedactionTest.kt b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/SensitiveArgsLogRedactionTest.kt new file mode 100644 index 000000000..598addb79 --- /dev/null +++ b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/SensitiveArgsLogRedactionTest.kt @@ -0,0 +1,122 @@ +package xyz.block.trailblaze + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.datetime.Clock +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import xyz.block.trailblaze.devices.TrailblazeDeviceId +import xyz.block.trailblaze.devices.TrailblazeDeviceInfo +import xyz.block.trailblaze.devices.TrailblazeDevicePlatform +import xyz.block.trailblaze.devices.TrailblazeDriverType +import xyz.block.trailblaze.logs.client.LogEmitter +import xyz.block.trailblaze.logs.client.ScreenStateLogger +import xyz.block.trailblaze.logs.client.TrailblazeLog +import xyz.block.trailblaze.logs.client.TrailblazeLogger +import xyz.block.trailblaze.logs.client.TrailblazeSession +import xyz.block.trailblaze.logs.client.TrailblazeSessionProvider +import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.logs.model.TraceId +import xyz.block.trailblaze.mobile.tools.AndroidWriteBytesToFileTrailblazeTool +import xyz.block.trailblaze.toolcalls.REDACTED_TOOL_ARG_PLACEHOLDER +import xyz.block.trailblaze.toolcalls.RawArgumentTrailblazeTool +import xyz.block.trailblaze.toolcalls.TrailblazeToolResult +import xyz.block.trailblaze.toolcalls.toLogPayload + +/** + * Pins the log-encode redaction contract for [xyz.block.trailblaze.toolcalls.SensitiveArgsTrailblazeTool] + * at the two places secrets could leak into persisted session logs (which ship as CI + * artifacts): + * + * 1. `toLogPayload()` — the single encode boundary every `TrailblazeToolLog` payload passes + * through — must mask a sensitive-args tool's declared values. + * 2. `logToolExecution`'s authored `rawTool` payload: the authored form is often a raw-args + * wrapper that does NOT implement the marker itself, so the executed instance's declared + * args must be applied to it too. + * + * Only observable log output is asserted (the emitted [TrailblazeLog.TrailblazeToolLog] / the + * returned payload), never internals — the execution/wire encode is deliberately NOT redacted + * and stays covered by the tools' own execute tests. + */ +class SensitiveArgsLogRedactionTest { + + private val secretBase64 = "c2VjcmV0LXNlc3Npb24tdG9rZW4=" + + /** Authored raw-args wrapper, as the dispatch boundary produces for a scripted `ctx.tools` call. */ + private data class AuthoredRawArgsTool( + override val instanceToolName: String, + override val rawToolArguments: kotlinx.serialization.json.JsonObject, + ) : RawArgumentTrailblazeTool + + private class CapturingAgentContext : TrailblazeAgentContext { + val emitted = mutableListOf() + override val trailblazeLogger = TrailblazeLogger( + logEmitter = LogEmitter { log -> emitted.add(log) }, + screenStateLogger = ScreenStateLogger { "" }, + ) + override val trailblazeDeviceInfoProvider: () -> TrailblazeDeviceInfo = { + TrailblazeDeviceInfo( + trailblazeDeviceId = TrailblazeDeviceId( + instanceId = "fixture-device", + trailblazeDevicePlatform = TrailblazeDevicePlatform.ANDROID, + ), + trailblazeDriverType = TrailblazeDriverType.ANDROID_ONDEVICE_INSTRUMENTATION, + widthPixels = 1080, + heightPixels = 1920, + ) + } + override val sessionProvider = TrailblazeSessionProvider { + TrailblazeSession(sessionId = SessionId("fixture-session"), startTime = Clock.System.now()) + } + override val memory = AgentMemory() + } + + @Test + fun toLogPayloadMasksDeclaredSensitiveArgs() { + val payload = AndroidWriteBytesToFileTrailblazeTool( + devicePath = "/data/local/tmp/seed.json", + base64Content = secretBase64, + ).toLogPayload() + + assertEquals(JsonPrimitive(REDACTED_TOOL_ARG_PLACEHOLDER), payload.raw["base64Content"]) + assertEquals(JsonPrimitive("/data/local/tmp/seed.json"), payload.raw["devicePath"]) + } + + @Test + fun logToolExecutionMasksBothResolvedAndAuthoredPayloads() { + val context = CapturingAgentContext() + val executed = AndroidWriteBytesToFileTrailblazeTool( + devicePath = "/data/local/tmp/seed.json", + base64Content = secretBase64, + ) + // The authored wrapper carries the secret literally and does not implement the marker — + // exactly the shape that would leak the full seeded session into shipped tool logs. + val authored = AuthoredRawArgsTool( + instanceToolName = "android_writeBytesToFile", + rawToolArguments = buildJsonObject { + put("devicePath", "/data/local/tmp/seed.json") + put("base64Content", secretBase64) + }, + ) + + context.logToolExecution( + tool = executed, + timeBeforeExecution = Clock.System.now(), + traceId = TraceId.generate(TraceId.Companion.TraceOrigin.TOOL), + result = TrailblazeToolResult.Success(message = "Wrote 20 bytes."), + rawTool = authored, + ) + + val toolLog = context.emitted.filterIsInstance().single() + val persistedPayloads = listOfNotNull(toolLog.trailblazeTool, toolLog.rawTrailblazeTool) + assertTrue(persistedPayloads.isNotEmpty()) + persistedPayloads.forEach { payload -> + assertEquals(JsonPrimitive(REDACTED_TOOL_ARG_PLACEHOLDER), payload.raw["base64Content"]) + assertEquals(JsonPrimitive("/data/local/tmp/seed.json"), payload.raw["devicePath"]) + // Belt-and-braces: the secret must not survive anywhere in the persisted payload JSON. + assertTrue(!payload.raw.toString().contains(secretBase64)) + } + } +} diff --git a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/ToolDispatchMemoryBoundaryTest.kt b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/ToolDispatchMemoryBoundaryTest.kt index da328976b..1ce464097 100644 --- a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/ToolDispatchMemoryBoundaryTest.kt +++ b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/ToolDispatchMemoryBoundaryTest.kt @@ -147,7 +147,10 @@ class ToolDispatchMemoryBoundaryTest { val captured = mutableListOf() val agent = FixtureAgent(logger = capturingLogger(captured)) agent.memory.remember("user", "sam") - agent.memory.rememberSensitive("pin", "9999") + // The canary must stay non-numeric: the whole-log doesNotContain assertions scan the + // serialized entry, and a digits-only value collides with timestamp microseconds / + // durationMs (a bare "9999" matched `…50.599998Z`). + agent.memory.rememberSensitive("pin", "9999zq") val result = agent.runTrailblazeTools( tools = listOf(InputTextTrailblazeTool(text = "{{user}}:{{pin}}")), @@ -156,7 +159,7 @@ class ToolDispatchMemoryBoundaryTest { // The driver got the real secret — that's the whole point of the pass-through. val typed = agent.executedCommands.filterIsInstance().single() - assertThat(typed.text).isEqualTo("sam:9999") + assertThat(typed.text).isEqualTo("sam:9999zq") // The executed-tools ledger (feeds LLM chat history) keeps the authored token form. val executed = result.executedTools.single() as InputTextTrailblazeTool assertThat(executed.text).isEqualTo("{{user}}:{{pin}}") @@ -166,14 +169,14 @@ class ToolDispatchMemoryBoundaryTest { assertThat(log.trailblazeTool.raw["text"]).isEqualTo(JsonPrimitive("sam:{{pin}}")) assertThat(log.rawTrailblazeTool!!.raw["text"]).isEqualTo(JsonPrimitive("{{user}}:{{pin}}")) // The whole persisted log entry — payloads, messages, everything — is secret-free. - assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999") + assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999zq") } @Test fun `a fully sensitive arg elides the raw payload and logs only the token`() { val captured = mutableListOf() val agent = FixtureAgent(logger = capturingLogger(captured)) - agent.memory.rememberSensitive("pin", "9999") + agent.memory.rememberSensitive("pin", "9999zq") agent.runTrailblazeTools( tools = listOf(InputTextTrailblazeTool(text = "{{pin}}")), @@ -181,20 +184,20 @@ class ToolDispatchMemoryBoundaryTest { ) assertThat(agent.executedCommands.filterIsInstance().single().text) - .isEqualTo("9999") + .isEqualTo("9999zq") // Scrubbing put the token back, making resolved == raw — so the split is elided and the // single payload is the token-bearing form. val log = captured.filterIsInstance().single() assertThat(log.trailblazeTool.raw["text"]).isEqualTo(JsonPrimitive("{{pin}}")) assertThat(log.rawTrailblazeTool).isNull() - assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999") + assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999zq") } @Test fun `failure metadata carries the authored tool identity`() { val captured = mutableListOf() val agent = FixtureAgent(logger = capturingLogger(captured)) - agent.memory.rememberSensitive("pin", "9999") + agent.memory.rememberSensitive("pin", "9999zq") val authored = FailingEchoTool(secret = "{{pin}}") val result = agent.runTrailblazeTools( @@ -213,13 +216,13 @@ class ToolDispatchMemoryBoundaryTest { // the RAW result (before the loop's return-value scrub) and so is scrubbed at the log boundary. val log = captured.filterIsInstance().single() assertThat(log.exceptionMessage).isEqualTo("deliberate failure on {{pin}}") - assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999") + assertThat(TrailblazeJsonInstance.encodeToString(log)).doesNotContain("9999zq") } @Test fun `a remember-tool failure scrubs the resolved secret from its error message`() { val agent = FixtureAgent() - agent.memory.rememberSensitive("pin", "9999") + agent.memory.rememberSensitive("pin", "9999zq") // The prompt carries a sensitive token; the boundary resolves it before execute() runs, and // the comparator always misses (getElementValue → null), so the tool throws @@ -236,7 +239,7 @@ class ToolDispatchMemoryBoundaryTest { // Command identity is swapped back to the authored, token-bearing instance. assertThat((error.command as RememberTextTrailblazeTool).prompt).isEqualTo("the field showing {{pin}}") // The free-form error message no longer carries the resolved secret — it's back to the token. - assertThat(error.errorMessage).doesNotContain("9999") + assertThat(error.errorMessage).doesNotContain("9999zq") assertThat(error.errorMessage).contains("{{pin}}") } } diff --git a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTargetTest.kt b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTargetTest.kt index aa7dde65c..b835935b0 100644 --- a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTargetTest.kt +++ b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/config/YamlBackedHostAppTargetTest.kt @@ -64,6 +64,40 @@ class YamlBackedHostAppTargetTest { assertNull(target.getSystemPromptTemplate()) } + @Test + fun `electron launch config round-trips from YAML to getElectronAppConfig`() { + val target = AppTargetYamlLoader.loadFromYaml( + """ + id: goose + display_name: Goose Desktop + electron: + command: /Applications/Goose.app/Contents/MacOS/Goose + env: + ENABLE_PLAYWRIGHT: "true" + cdpTimeoutSeconds: 60 + platforms: + web: {} + """.trimIndent(), + toolNameResolver = resolver, + ) + val electron = target.getElectronAppConfig() + assertEquals("/Applications/Goose.app/Contents/MacOS/Goose", electron?.command) + assertEquals("true", electron?.env?.get("ENABLE_PLAYWRIGHT")) + assertEquals(60, electron?.cdpTimeoutSeconds) + } + + @Test + fun `electron absent returns null`() { + val target = AppTargetYamlLoader.loadFromYaml( + """ + id: test + display_name: Test + """.trimIndent(), + toolNameResolver = resolver, + ) + assertNull(target.getElectronAppConfig()) + } + @Test fun `app ids resolve by platform`() { val target = AppTargetYamlLoader.loadFromYaml( diff --git a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodecTest.kt b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodecTest.kt new file mode 100644 index 000000000..252e38316 --- /dev/null +++ b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLogProtoCodecTest.kt @@ -0,0 +1,94 @@ +package xyz.block.trailblaze.logs.client + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import xyz.block.trailblaze.api.DriverNodeDetail +import xyz.block.trailblaze.api.TrailblazeNode +import xyz.block.trailblaze.api.ViewHierarchyTreeNode +import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.ondevice.rpc.proto.LogUploadEnvelope +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec + +class TrailblazeLogProtoCodecTest { + @Test + fun `tree-bearing log round trips without duplicating hierarchy JSON`() { + val original = TrailblazeLog.TrailblazeSnapshotLog( + displayName = "checkout", + screenshotFile = "checkout.png", + viewHierarchy = ViewHierarchyTreeNode( + text = "Checkout", + children = listOf(ViewHierarchyTreeNode(text = "Pay now", clickable = true)), + ), + trailblazeNodeTree = TrailblazeNode( + nodeId = 1, + children = listOf( + TrailblazeNode( + nodeId = 2, + driverDetail = DriverNodeDetail.AndroidAccessibility(text = "Pay now"), + ), + ), + driverDetail = DriverNodeDetail.AndroidAccessibility(text = "Checkout"), + ), + viewHierarchyText = "Checkout\n Pay now", + deviceWidth = 1080, + deviceHeight = 1920, + session = SessionId("proto-log"), + timestamp = Clock.System.now(), + ) + + val proto = TrailblazeLogProtoCodec.run { original.toProto() } + val metadata = Json.parseToJsonElement(proto.metadata_json.utf8()).jsonObject + val decoded = TrailblazeLogProtoCodec.run { proto.toModel() } + + assertFalse("viewHierarchy" in metadata) + assertFalse("trailblazeNodeTree" in metadata) + assertNotNull(proto.view_hierarchy) + assertNotNull(proto.trailblaze_node_tree) + assertEquals(original, decoded) + } + + @Test + fun `protobuf substantially reduces a hierarchy-heavy log payload`() { + val children = (1..1_000).map { index -> + ViewHierarchyTreeNode( + nodeId = index.toLong(), + text = "Item $index with a representative accessibility label", + resourceId = "example:id/item_$index", + className = "android.widget.TextView", + x1 = 0, + y1 = index * 10, + x2 = 1080, + y2 = index * 10 + 50, + clickable = index % 3 == 0, + enabled = true, + ) + } + val log = TrailblazeLog.TrailblazeSnapshotLog( + displayName = "large", + screenshotFile = "large.png", + viewHierarchy = ViewHierarchyTreeNode(text = "Root", children = children), + deviceWidth = 1080, + deviceHeight = 1920, + session = SessionId("large-log"), + timestamp = Clock.System.now(), + ) + val jsonBytes = TrailblazeJsonInstance + .encodeToString(TrailblazeLog.serializer(), log) + .encodeToByteArray() + val protoBytes = OnDeviceRpcProtoCodec.encode( + LogUploadEnvelope( + upload_id = 1, + agent_log = TrailblazeLogProtoCodec.run { log.toProto() }, + ), + ) + + println("hierarchy log bytes: JSON=${jsonBytes.size}, protobuf=${protoBytes.size}") + assertTrue(protoBytes.size < jsonBytes.size / 2) + } +} diff --git a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/yaml/TrailblazeRecordingGeneratorTest.kt b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/yaml/TrailblazeRecordingGeneratorTest.kt index 462f09efe..d14a9b8b4 100644 --- a/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/yaml/TrailblazeRecordingGeneratorTest.kt +++ b/trailblaze-common/src/jvmAndAndroidTest/kotlin/xyz/block/trailblaze/yaml/TrailblazeRecordingGeneratorTest.kt @@ -523,8 +523,8 @@ class TrailblazeRecordingGeneratorTest { @Test fun configCarriesEveryFieldIntoTheSavedRecording() { // The generator must carry the session config wholesale — a rebuilt field list here - // silently dropped electron/tags/skip/memory before any save-back merge saw them, so a - // re-recorded trail lost its memory seed / tags / Electron launch config on save. + // silently dropped tags/skip/memory before any save-back merge saw them, so a + // re-recorded trail lost its memory seed / tags on save. val config = TrailConfig( id = "test/case_123", title = "Login test", @@ -533,7 +533,6 @@ class TrailblazeRecordingGeneratorTest { tags = listOf("smoke"), skip = "blocked on #123", memory = mapOf("email" to "tb+test@example.com"), - electron = ElectronAppConfig(command = "/opt/app/electron-app"), ) val step = DirectionStep(step = "Enter text") val logs = listOf( diff --git a/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/util/AndroidHostAdbUtils.kt b/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/util/AndroidHostAdbUtils.kt index 4727e5b6c..e2e908f9d 100644 --- a/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/util/AndroidHostAdbUtils.kt +++ b/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/util/AndroidHostAdbUtils.kt @@ -395,20 +395,25 @@ object AndroidHostAdbUtils { * `adb forward tcp:$localPort localabstract:$socketName`. dadb's [Dadb.tcpForward] only supports * TCP-to-TCP, so this routes through the `adb` binary. The forward is removable via * [removePortForward] which falls back to the binary for forwards it didn't track. + * + * @throws IOException if the `adb forward` command times out or exits non-zero (e.g. the adb + * binary is missing or the local port cannot be bound). A silent failure here would surface + * later as an unrelated connection-refused error on the local port. */ fun adbPortForwardLocalAbstract( deviceId: TrailblazeDeviceId, localPort: Int, socketName: String, ) { - runCatching { - runProcessBuilderWithTimeout( - createAdbCommandProcessBuilder( - deviceId = deviceId, - args = listOf("forward", "tcp:$localPort", "localabstract:$socketName"), - ), - timeoutMs = DEFAULT_SHORT_CALL_TIMEOUT_MS, - ) + val installed = runProcessBuilderWithTimeout( + createAdbCommandProcessBuilder( + deviceId = deviceId, + args = listOf("forward", "tcp:$localPort", "localabstract:$socketName"), + ), + timeoutMs = DEFAULT_SHORT_CALL_TIMEOUT_MS, + ) + if (!installed) { + throw IOException("adb forward tcp:$localPort localabstract:$socketName failed or timed out") } } diff --git a/trailblaze-compose/dependencies/runtimeClasspath.txt b/trailblaze-compose/dependencies/runtimeClasspath.txt index 8917d895b..bd0cce449 100644 --- a/trailblaze-compose/dependencies/runtimeClasspath.txt +++ b/trailblaze-compose/dependencies/runtimeClasspath.txt @@ -2,6 +2,7 @@ :trailblaze-common :trailblaze-compose-target :trailblaze-models +:trailblaze-ondevice-rpc-proto :trailblaze-tracing ai.koog:agents-core-jvm:1.0.0 ai.koog:agents-core:1.0.0 @@ -129,6 +130,8 @@ com.squareup.okhttp3:okhttp-jvm:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 dev.mobile:dadb:1.2.10 dev.mobile:maestro-client:2.6.1 @@ -149,6 +152,8 @@ io.ktor:ktor-client-logging-jvm:3.5.0 io.ktor:ktor-client-logging:3.5.0 io.ktor:ktor-client-okhttp-jvm:3.5.0 io.ktor:ktor-client-okhttp:3.5.0 +io.ktor:ktor-client-websockets-jvm:3.5.0 +io.ktor:ktor-client-websockets:3.5.0 io.ktor:ktor-events-jvm:3.5.0 io.ktor:ktor-events:3.5.0 io.ktor:ktor-http-cio-jvm:3.5.0 diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeClickTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeClickTool.kt index d6af58581..ba27327b9 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeClickTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeClickTool.kt @@ -17,7 +17,7 @@ Identify the element using its element ID from the view hierarchy (e.g., 'e5'), or by text content. """, ) -class ComposeClickTool( +data class ComposeClickTool( @param:LLMDescription("Element ID from the view hierarchy, e.g., 'e5'. Preferred method.") val elementId: String? = null, @param:LLMDescription("Accessibility identifier of the element to click.") diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeRequestDetailsTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeRequestDetailsTool.kt index 860f716b9..4b3008060 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeRequestDetailsTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeRequestDetailsTool.kt @@ -35,7 +35,7 @@ Available detail types: or disambiguating visually similar elements by location. """, ) -class ComposeRequestDetailsTool( +data class ComposeRequestDetailsTool( @param:LLMDescription( "List of detail types to include in the next view hierarchy. " + "Supported: [\"BOUNDS\"]. " + diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeScrollTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeScrollTool.kt index bc4492a95..1ce330cce 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeScrollTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeScrollTool.kt @@ -18,7 +18,7 @@ Identify the container using its element ID or text. Leave all identifiers empty to scroll the first scrollable container found. """, ) -class ComposeScrollTool( +data class ComposeScrollTool( @param:LLMDescription("Element ID from the view hierarchy, e.g., 'e2'.") val elementId: String? = null, @param:LLMDescription("Accessibility identifier of the scrollable container.") diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeTypeTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeTypeTool.kt index 661695148..c8a95231d 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeTypeTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeTypeTool.kt @@ -18,7 +18,7 @@ or by existing text content. By default this clears the field first. Set clearFirst to false to append text instead. """, ) -class ComposeTypeTool( +data class ComposeTypeTool( @param:LLMDescription("The text to type into the element.") val text: String, @param:LLMDescription("Element ID from the view hierarchy, e.g., 'e3'. Preferred method.") diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyElementVisibleTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyElementVisibleTool.kt index 165f94dca..36b399e90 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyElementVisibleTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyElementVisibleTool.kt @@ -17,7 +17,7 @@ Identify the element using its element ID from the view hierarchy. This is a test assertion — it will fail if the element is not found. """, ) -class ComposeVerifyElementVisibleTool( +data class ComposeVerifyElementVisibleTool( @param:LLMDescription("Accessibility identifier of the element to verify.") val testTag: String? = null, @param:LLMDescription("Element ID from the view hierarchy, e.g., 'e5'.") diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyTextVisibleTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyTextVisibleTool.kt index 6a473446f..ab9e8ead1 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyTextVisibleTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeVerifyTextVisibleTool.kt @@ -17,7 +17,7 @@ Verify that specific text is visible on screen. This is a test assertion — it will fail if the text is not found. """, ) -class ComposeVerifyTextVisibleTool( +data class ComposeVerifyTextVisibleTool( @param:LLMDescription("The text content to verify is visible.") val text: String, ) : ComposeExecutableTool { diff --git a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeWaitTool.kt b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeWaitTool.kt index 47208b0a2..57a13c8d4 100644 --- a/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeWaitTool.kt +++ b/trailblaze-compose/src/main/java/xyz/block/trailblaze/compose/driver/tools/ComposeWaitTool.kt @@ -17,7 +17,7 @@ Wait for a specified number of seconds before continuing. Use this when you need to wait for animations or async operations to complete. """, ) -class ComposeWaitTool( +data class ComposeWaitTool( @param:LLMDescription("Number of seconds to wait (e.g., 1, 2, 5). Maximum 30 seconds.") val seconds: Int = 1, ) : ComposeExecutableTool { diff --git a/trailblaze-desktop/dependencies/runtimeClasspath.txt b/trailblaze-desktop/dependencies/runtimeClasspath.txt index d55166352..d5be95123 100644 --- a/trailblaze-desktop/dependencies/runtimeClasspath.txt +++ b/trailblaze-desktop/dependencies/runtimeClasspath.txt @@ -166,6 +166,8 @@ com.squareup.okhttp3:okhttp-jvm:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 commons-io:commons-io:2.16.1 de.upb.cs.swt:axml:2.1.2 diff --git a/trailblaze-desktop/src/main/java/xyz/block/trailblaze/desktop/OpenSourceTrailblazeDesktopApp.kt b/trailblaze-desktop/src/main/java/xyz/block/trailblaze/desktop/OpenSourceTrailblazeDesktopApp.kt index 5a6b8c33c..c9e530c13 100644 --- a/trailblaze-desktop/src/main/java/xyz/block/trailblaze/desktop/OpenSourceTrailblazeDesktopApp.kt +++ b/trailblaze-desktop/src/main/java/xyz/block/trailblaze/desktop/OpenSourceTrailblazeDesktopApp.kt @@ -57,6 +57,7 @@ class OpenSourceTrailblazeDesktopApp : TrailblazeDesktopApp( deviceManager = deviceManager, headless = headless, daemonAlreadyRunning = daemonAlreadyRunning, + trailRunnerPath = "/trailrunner/", // Serve the Trail Runner web UI (/trailrunner/) on the daemon. The open-source build runs it // with the config's extension (DefaultTrailRunnerExtension unless a downstream build // overrides it): no integrations, no analytics, no LLM authoring assists — the UI degrades diff --git a/trailblaze-host/dependencies/runtimeClasspath.txt b/trailblaze-host/dependencies/runtimeClasspath.txt index adaf0fa27..d36986483 100644 --- a/trailblaze-host/dependencies/runtimeClasspath.txt +++ b/trailblaze-host/dependencies/runtimeClasspath.txt @@ -166,6 +166,8 @@ com.squareup.okhttp3:okhttp-jvm:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.17.0 com.squareup.okio:okio:3.17.0 +com.squareup.wire:wire-runtime-jvm:5.3.3 +com.squareup.wire:wire-runtime:5.3.3 com.typesafe:config:1.4.8 commons-io:commons-io:2.16.1 de.upb.cs.swt:axml:2.1.2 diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliConfigHelper.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliConfigHelper.kt index e1242412a..78dc3a087 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliConfigHelper.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliConfigHelper.kt @@ -6,6 +6,7 @@ import xyz.block.trailblaze.api.TrailblazeImageFormat import xyz.block.trailblaze.config.project.TrailblazeWorkspaceConfigResolver import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import xyz.block.trailblaze.devices.TrailblazeDriverType +import xyz.block.trailblaze.host.recording.EffectiveStreamScreenshotConfig import xyz.block.trailblaze.llm.TrailblazeLlmProvider import xyz.block.trailblaze.logs.client.TrailblazeJson import xyz.block.trailblaze.mcp.AgentImplementation @@ -340,6 +341,23 @@ val CONFIG_KEYS: Map = listOf( } }, ), + ConfigKey( + // Experimental. Tri-state like `unified-recordings`: `null` (default) is off; an explicit + // true/false is the user's persisted choice. The `TRAILBLAZE_ANDROID_STREAM_SCREENSHOT` / + // `_AB` env vars still win (env = one-off / CI / A/B validation; this = discoverable + // persistent toggle). Only the host-driven Android accessibility agent-loop reads it. + name = "android-stream-screenshots", + description = "Experimental: serve Android agent-loop screenshots from the live device stream (default: off)", + validValues = "true, false, or 'unset' to inherit the default (off)", + get = { config -> config.androidStreamScreenshotsEnabled?.toString() ?: "(not set)" }, + set = { config, value -> + if (value.equals("unset", ignoreCase = true)) { + config.copy(androidStreamScreenshotsEnabled = null) + } else { + value.toBooleanStrictOrNull()?.let { config.copy(androidStreamScreenshotsEnabled = it) } + } + }, + ), ).associateBy { it.name } /** @@ -431,6 +449,7 @@ object CliConfigHelper { // Pass null when nothing is overridden so the web path can fall back to its own default // (see EffectiveScreenshotScalingConfig.effectiveForWeb). EffectiveScreenshotScalingConfig.setEffectiveDefault(it.screenshotScalingConfigOrNull()) + EffectiveStreamScreenshotConfig.androidEnabled = it.androidStreamScreenshotsEnabled ?: false } /** diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliInfrastructure.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliInfrastructure.kt index bd9c818ad..b88002a41 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliInfrastructure.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliInfrastructure.kt @@ -1418,14 +1418,20 @@ fun cliReusableWithDevice( */ fun cliWithDaemon( verbose: Boolean, + sessionScope: String? = null, + targetAppId: String? = null, action: suspend (CliMcpClient) -> Int, ): Int { if (!verbose) Console.enableQuietMode() val port = CliConfigHelper.resolveEffectiveHttpPort() - val targetAppId = CliConfigHelper.getOrCreateConfig().selectedTargetAppId + val effectiveTargetAppId = targetAppId ?: CliConfigHelper.getOrCreateConfig().selectedTargetAppId return runBlocking { - val mcpClient = connectOrStartDaemonReusable(port, targetAppId = targetAppId) + val mcpClient = connectOrStartDaemonReusable( + port, + targetAppId = effectiveTargetAppId, + sessionScope = sessionScope, + ) ?: return@runBlocking INFRA_FAILED.code mcpClient.use { client -> runActionWithIoEnvelope(target = null, action = { action(client) }) } } @@ -1725,6 +1731,32 @@ private fun loadTargetIds(anchorFile: File): Set = try { emptySet() } +/** + * What to do about a daemon whose version may not match the CLI's. + * + * - [KEEP_OK] — versions match (or the daemon didn't report a version). Nothing to do. + * - [RESTART] — version mismatch AND the daemon is idle. Safe to stop and restart. + * - [KEEP_BUSY] — version mismatch BUT the daemon has in-flight runs. Leave it running; + * stopping it would sever those runs (the daemon may be mid-run for another + * shell/checkout — the port is machine-global, version staleness is per-checkout). + * The restart happens once it goes idle. + */ +internal enum class StaleDaemonAction { KEEP_OK, RESTART, KEEP_BUSY } + +/** + * Pure decision for [checkAndRestartStaleDaemon] — separated from the daemon I/O so the + * three-way matrix (match / mismatch-idle / mismatch-busy) is unit-testable without a + * live daemon. See `StaleDaemonActionTest`. + */ +internal fun staleDaemonAction( + cliVersion: String, + daemonVersion: String?, + activeRuns: Int, +): StaleDaemonAction { + if (daemonVersion == null || daemonVersion == cliVersion) return StaleDaemonAction.KEEP_OK + return if (activeRuns > 0) StaleDaemonAction.KEEP_BUSY else StaleDaemonAction.RESTART +} + /** * Check if the running daemon has a different version than the CLI. * If so, stop it so it gets restarted with the current version. @@ -1740,18 +1772,32 @@ private fun checkAndRestartStaleDaemon(port: Int): Boolean { DaemonClient(port = port).use { daemon -> val status = daemon.getStatusBlocking() ?: return true val daemonVersion = status.version - if (daemonVersion != null && daemonVersion != cliVersion) { - Console.log( - "Restarting daemon (version mismatch: daemon=$daemonVersion, cli=$cliVersion)..." - ) - daemon.shutdownBlocking() - // Wait for daemon to stop - repeat(20) { - if (!daemon.isRunningBlocking()) return true - Thread.sleep(500) + when (staleDaemonAction(cliVersion, daemonVersion, status.activeRuns)) { + StaleDaemonAction.KEEP_OK -> return true + StaleDaemonAction.KEEP_BUSY -> { + // Console.info (not Console.log) so the developer actually sees WHY their newer + // CLI is talking to an older daemon — this line is the only explanation, and + // Console.log is suppressed in CLI quiet mode. + Console.info( + "Daemon version mismatch (daemon=$daemonVersion, cli=$cliVersion) but it has " + + "${status.activeRuns} in-flight run(s) — leaving it running; it restarts once idle:" + + status.activeRunSummaries.joinToString("") { "\n - $it" }, + ) + return true + } + StaleDaemonAction.RESTART -> { + Console.log( + "Restarting daemon (version mismatch: daemon=$daemonVersion, cli=$cliVersion)..." + ) + daemon.shutdownBlocking() + // Wait for daemon to stop + repeat(20) { + if (!daemon.isRunningBlocking()) return true + Thread.sleep(500) + } + // Timed out — stale daemon is still running + return false } - // Timed out — stale daemon is still running - return false } } } catch (_: Exception) { diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliMcpClient.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliMcpClient.kt index 84a81f6dc..d3ba13408 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliMcpClient.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/CliMcpClient.kt @@ -86,6 +86,9 @@ class CliMcpClient( var hasExistingDevice: Boolean = false internal set + /** Device binding verified by the reusable-session INFO probe. */ + private var existingDeviceId: TrailblazeDeviceId? = null + val isInitialized: Boolean get() = sessionId != null /** @@ -506,44 +509,34 @@ class CliMcpClient( deviceSpec: String? = null, webHeadless: Boolean = true, ): String? { - // When reusing a session that already has a device, we still issue a lightweight - // device(action=PLATFORM, deviceId=ID) call via [rebindDeviceQuiet] to refresh the - // daemon's per-session `associatedDeviceId`. A previous CLI invocation in the same - // MCP session (e.g. `tool` before `step`) leaves `selectedDeviceId` set on the - // bridge, but the per-session `associatedDeviceId` — which the screen-state provider - // reads — can drift out of sync. When that happens, the next `step` call sees - // "No device connected" from inside the daemon even though INFO still reports the - // device as bound. The rebind is a single MCP roundtrip (no LIST, no banners) and - // is cheap on the daemon side when a persistent driver already exists. + // connectReusable already validated the persisted MCP session with device(INFO). For Android, + // a parsed session-scoped device is enough to skip the platform rebind that walks all host + // discovery and waits for CoreSimulator. Web still needs the lightweight rebind: its session + // association can outlive the process-wide browser selection used by screen-state tools. if (hasExistingDevice) { - val infoResult = callTool(DEVICE_TOOL_NAME, mapOf(ACTION_KEY to DEVICE_ACTION_INFO)) - val currentPlatform = if (!infoResult.isError) parseDevicePlatform(infoResult) else null - val currentInstanceId = if (!infoResult.isError) parseConnectedInstanceId(infoResult) else null - val currentSpec = currentPlatform?.let { - "${it.name.lowercase()}${if (currentInstanceId != null) "/$currentInstanceId" else ""}" - } + val currentDevice = existingDeviceId + ?: return "Session reports an existing device but device(INFO) returned no device ID. " + + "Reconnect explicitly with --device [/]." + val currentPlatform = currentDevice.trailblazeDevicePlatform + val currentSpec = currentDevice.toFullyQualifiedDeviceId() if (deviceSpec == null) { - if (currentPlatform != null) { - logSessionReuse(currentSpec) - return rebindDeviceQuiet(currentPlatform, currentInstanceId, webHeadless = webHeadless) - } - // Reused session reports `hasExistingDevice` yet INFO returned no platform — - // an inconsistent daemon state. Return a clear error instead of silently - // falling through to LIST/auto-select, which could bind to an unrelated device. - return "Session reports an existing device but device(INFO) returned no platform. " + - "Reconnect explicitly with --device [/]." + logSessionReuse(currentSpec) + return reuseExistingDevice(currentDevice, webHeadless) } - if (currentPlatform != null) { - // Explicit device spec — check if it matches what's already connected - val platformName = currentPlatform.name.lowercase() - val specMatchesFull = currentInstanceId != null && deviceSpec.equals(currentSpec, ignoreCase = true) - val specMatchesPlatformOnly = deviceSpec.equals(platformName, ignoreCase = true) - if (specMatchesFull || specMatchesPlatformOnly) { - logSessionReuse(currentSpec) - return rebindDeviceQuiet(currentPlatform, currentInstanceId, webHeadless = webHeadless) - } + // Explicit device spec — check if it matches what's already connected. + val platformName = currentPlatform.name.lowercase() + val specMatchesFull = deviceSpec.equals(currentSpec, ignoreCase = true) + val specMatchesPlatformOnly = deviceSpec.equals(platformName, ignoreCase = true) + if (specMatchesFull || specMatchesPlatformOnly) { + logSessionReuse(currentSpec) + return reuseExistingDevice(currentDevice, webHeadless) } + // The next connection targets a different device. Drop the probe cache before switching so + // another ensureDevice call on this client cannot mistake the old association for the new + // one if the switch fails or the caller retries. + hasExistingDevice = false + existingDeviceId = null // Routed to stderr (not stdout) so it doesn't poison `eval $(trailblaze device connect …)`: // any non-`export` byte on stdout before the export lines makes the shell try to execute // it as a command. Also: em-dashes get replaced with `--` and Unicode `→` with `->` so a @@ -593,6 +586,22 @@ class CliMcpClient( } } + /** Keep Android's hot path discovery-free; refresh other platforms' runtime selection. */ + private suspend fun reuseExistingDevice( + deviceId: TrailblazeDeviceId, + webHeadless: Boolean, + ): String? { + if (deviceId.trailblazeDevicePlatform == TrailblazeDevicePlatform.ANDROID) { + hasConnectedDevice = true + return null + } + return rebindDeviceQuiet( + platform = deviceId.trailblazeDevicePlatform, + instanceId = deviceId.instanceId, + webHeadless = webHeadless, + ) + } + /** * Returns the daemon's currently-bound device as a typed [TrailblazeDeviceId], or * `null` if no device is bound (or the daemon's response is missing either platform @@ -616,24 +625,9 @@ class CliMcpClient( } /** - * Lightweight re-select used on session reuse: issues `device(action=PLATFORM, deviceId=…)` - * and nothing else — no LIST/validation roundtrip, no "Connecting to…" / "Connected:…" - * banners, no session-command menu. The full [connectToDevice] would re-spam the - * connection banner on every reused-session invocation; this path keeps the user-visible - * output to the single `Reusing session …` line that [logSessionReuse] just emitted. - * - * Refreshing the daemon-side per-session `associatedDeviceId` is the only behavioral - * goal here — the device list, validation, busy-error block, Playwright install waiter, - * and session-id printout are all relevant only on cold-start `connectToDevice`. - * - * Idempotency: `device(action=PLATFORM, deviceId=…)` on the daemon side calls - * (a) `DeviceClaimRegistry.claim` — for a same-session re-claim this just refreshes - * the timestamp and returns null (no displacement, no on-device teardown), - * (b) `mcpBridge.selectDevice` — for an already-selected device the persistent driver - * is reused (no new driver creation), and - * (c) `sessionContext.startImplicitRecording` — guarded by `if (!isRecording)` so it's - * a no-op when recording is already in progress. - * All three are safe to re-run on every reused-session invocation. + * Lightweight re-select for explicit recovery workflows after a session action cleared its + * associated device. Ordinary reusable commands trust their session-scoped INFO probe and do + * not call this path. */ private suspend fun rebindDeviceQuiet( platform: TrailblazeDevicePlatform, @@ -738,12 +732,10 @@ class CliMcpClient( args[DeviceManagerToolSet.PARAM_HEADLESS] = webHeadless } val result = callTool("device", args) - // Server emits the rich device-busy block (`DeviceBusyException`) as a - // successful tool response with an "Error:"-prefixed body. Pass it through - // verbatim — the daemon already formatted it for end-user consumption. - if (result.content.trimStart().startsWith("Error:") && - "is busy" in result.content - ) { + // Device selection failures are user-facing `Error:` strings rather than MCP protocol + // errors. Pass them through so a stale explicit pin is rejected and evicted instead of being + // announced as connected. The daemon already formats busy errors and not-found errors. + if (result.content.trimStart().startsWith("Error:")) { return result.content } if (result.isError) { @@ -1007,6 +999,8 @@ class CliMcpClient( private const val DEVICE_TOOL_NAME = "device" private const val DEVICE_ACTION_INFO = "INFO" private const val ACTION_KEY = "action" + private const val SESSION_ONLY_KEY = "sessionOnly" + private const val DRIVER_STATUS_PREFIX = "Driver status:" private const val TMP_DIR_PROPERTY = "java.io.tmpdir" /** @@ -1185,11 +1179,31 @@ class CliMcpClient( } else { client.sessionId = savedSessionId try { - val result = client.callTool(DEVICE_TOOL_NAME, mapOf(ACTION_KEY to DEVICE_ACTION_INFO)) + val result = client.callTool( + DEVICE_TOOL_NAME, + mapOf(ACTION_KEY to DEVICE_ACTION_INFO, SESSION_ONLY_KEY to true), + ) val sessionIsAlive = !result.isError || result.content.contains("No device connected") if (sessionIsAlive) { // Session is alive — reuse it - client.hasExistingDevice = !result.isError + val reportsNoDevice = result.content.contains("No device connected") + // A driver status means the stored association is not ready. This includes the + // Android direct-ADB liveness status emitted when the serial disappeared. Reuse the + // MCP session, but make ensureDevice take the normal reconnect path. + val reportsDriverStatus = result.content.contains(DRIVER_STATUS_PREFIX) + val platform = if (!result.isError && !reportsNoDevice && !reportsDriverStatus) { + client.parseDevicePlatform(result) + } else null + val instanceId = if (!result.isError && !reportsNoDevice && !reportsDriverStatus) { + client.parseConnectedInstanceId(result) + } else null + client.existingDeviceId = if (platform != null && instanceId != null) { + TrailblazeDeviceId( + instanceId = instanceId, + trailblazeDevicePlatform = platform, + ) + } else null + client.hasExistingDevice = client.existingDeviceId != null return client } // Daemon responded but doesn't recognize our session — it was restarted @@ -1229,6 +1243,34 @@ class CliMcpClient( } } + /** + * Moves a reusable-session pointer from a provisional scope to its resolved scope. + * + * `device connect android` only learns the concrete instance id after binding, while + * device-driving commands use `cli-android/` from the outset. Preserving the same + * MCP session across that resolution step keeps its target/tool registry warm for the first + * follow-up command. The destination is written before the source is removed, so a failed + * write leaves the still-usable provisional pointer intact. + */ + internal fun migrateSessionFile( + port: Int, + fromSessionScope: String, + toSessionScope: String, + ) { + if (fromSessionScope == toSessionScope) return + val source = sessionFile(port, fromSessionScope) + val (savedSessionId, savedTargetAppId) = readSessionFile(source) + if (savedSessionId == null) return + val destination = sessionFile(port, toSessionScope) + writeSessionFile(destination, savedSessionId, savedTargetAppId) + try { + source.delete() + } catch (_: Exception) { + // Best effort. Both files pointing at the same session is safe; a later connect can + // overwrite either scope normally. + } + } + /** * Reads the session file. Format: line 1 = session ID, line 2 = target app ID (optional). * Backwards-compatible: old files with only a session ID still work. diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/ConfigCommand.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/ConfigCommand.kt index 826138adc..6bfc75730 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/ConfigCommand.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/ConfigCommand.kt @@ -191,6 +191,15 @@ class ConfigCommand : Callable { Console.info(" $label: ${configKey.get(currentConfig)}") } + // Experimental section: opt-in toggles that aren't yet on by default. Printed so they're + // discoverable via `config show` rather than only findable in env-var docs. + Console.info("") + Console.info("Experimental:") + Console.info( + " Android stream screenshots: " + + "${CONFIG_KEYS["android-stream-screenshots"]!!.get(currentConfig)}", + ) + Console.info("") Console.info(" trailblaze config llm Set LLM") Console.info(" trailblaze config llm none Disable LLM") @@ -200,6 +209,7 @@ class ConfigCommand : Callable { Console.info(" trailblaze config screenshot-format Set screenshot format (png|jpeg|webp|unset)") Console.info(" trailblaze config screenshot-max-dimensions Set max screenshot dimensions") Console.info(" trailblaze config screenshot-quality <0..1> Set lossy compression quality") + Console.info(" trailblaze config android-stream-screenshots (experimental) Serve Android screenshots from the live device stream") Console.info(" trailblaze config models List available models") Console.info(" trailblaze config reset Reset all settings to defaults") Console.info("") diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DeviceCommand.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DeviceCommand.kt index 31dc284d2..860116788 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DeviceCommand.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DeviceCommand.kt @@ -341,7 +341,17 @@ class DeviceConnectCommand : Callable { val headlessOption: HeadlessOption = HeadlessOption() override fun call(): Int { - return cliWithDaemon(verbose = false) { client -> + val resolvedTarget = normalizeTargetId(target) + val effectiveTarget = resolvedTarget ?: CliConfigHelper.getOrCreateConfig().selectedTargetAppId + // Warm the same device-scoped session that snapshot/tool/step will reuse. Previously this + // command warmed the unscoped session, so the first follow-up command created another MCP + // session and paid the full target + scripted-tool registration cost. + val provisionalSessionScope = cliDeviceSessionScope(platform) + return cliWithDaemon( + verbose = false, + sessionScope = provisionalSessionScope, + targetAppId = effectiveTarget, + ) { client -> // 1. Bind the device (existing daemon-side machinery via TrailblazeDeviceManager). val deviceError = client.ensureDevice( platform, @@ -364,18 +374,10 @@ class DeviceConnectCommand : Callable { // the user's shell without the daemon-side lookup failing on the // unintentional whitespace. // - // The "announcing" variant is the key piece. When the user re-runs - // `device connect --target Y` after a prior connect - // with `--target X`, [connectReusable]'s file-tier target check - // matches (both reflect the config-tier value, NOT the --target arg - // — see [cliWithDaemon]'s `targetAppId = config.selectedTargetAppId` - // above) and the session is reused. The daemon then hot-swaps the - // per-device override silently, leaving the user with a "Reusing - // session …" line and no notice that their target changed. The - // announcing helper closes that gap by emitting `Target app changed - // (X -> Y) -- pinned on existing session.` to stderr when a prior - // session-override is being replaced. - val resolvedTarget = normalizeTargetId(target) + // Passing the effective target into [cliWithDaemon] lets [connectReusable] create a fresh + // session when the saved target differs, since its driver and custom tool registry can + // differ too. The announcing setter still covers legacy target-less session files and a + // daemon-side override that changed independently of the reusable-session pointer. if (resolvedTarget != null) { val targetError = client.setSessionTargetForBoundDeviceAnnouncingChange(resolvedTarget) if (targetError != null) { @@ -399,6 +401,16 @@ class DeviceConnectCommand : Callable { val boundId = client.getBoundDeviceId() val deviceIdString = boundId?.toFullyQualifiedDeviceId() ?: platform + // A platform-only spec (for example, `android`) learns its concrete instance only after + // binding. Move the reusable-session pointer to the resolved per-device scope so the first + // bare `snapshot` attaches to this already-hydrated session. A fully-qualified input is + // already in the final scope, making this a no-op. + CliMcpClient.migrateSessionFile( + port = CliConfigHelper.resolveEffectiveHttpPort(), + fromSessionScope = provisionalSessionScope, + toSessionScope = cliDeviceSessionScope(deviceIdString), + ) + // 4b. If a real MCP client (Claude Desktop, Cursor, Goose) is open and // hasn't picked a device yet, adopt it into the same device + target // we just bound. This gives MCP clients the same per-session OOBE diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/MigrateTrailsCommand.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/MigrateTrailsCommand.kt deleted file mode 100644 index aeabe419f..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/MigrateTrailsCommand.kt +++ /dev/null @@ -1,212 +0,0 @@ -package xyz.block.trailblaze.cli - -import picocli.CommandLine.Command -import picocli.CommandLine.Option -import picocli.CommandLine.Parameters -import xyz.block.trailblaze.migration.UnifiedTrailMigrator -import xyz.block.trailblaze.recordings.TrailRecordings -import xyz.block.trailblaze.util.Console -import xyz.block.trailblaze.yaml.TrailblazeYaml -import java.io.File -import java.util.concurrent.Callable - -/** - * Migrate a directory of legacy v1 `*.trail.yaml` files (plus optional - * `blaze.yaml`) into a single unified `trail.yaml` file. See - * `docs/devlog/2026-05-22-trail-yaml-unified-syntax.md` for the unified-format - * spec. - * - * Usage: - * - * ``` - * trailblaze migrate-trails - * trailblaze migrate-trails --output - * ``` - * - * The command does NOT delete the input files — removing them is an operator - * decision once the unified file has been reviewed. - * - * A lossy migration (input content the decoder couldn't round-trip) still writes - * a usable unified file with leading `DROPPED` warning comments and exits 0 by - * default. Pass `--fail-on-dropped-content` to make that case exit non-zero so a - * chained pipeline (`migrate-trails && commit`) refuses to proceed on a lossy - * migration. - */ -@Command( - name = "migrate-trails", - mixinStandardHelpOptions = true, - hidden = true, - description = [ - "Migrate a directory of legacy *.trail.yaml files (plus optional blaze.yaml) " + - "into a single unified trail.yaml file. Does NOT delete the input files. " + - "Hidden from `--help` because almost no one has v1 trails — this is a " + - "one-shot migration utility, not part of the public surface. Still callable " + - "via `trailblaze migrate-trails ` when needed.", - ], -) -class MigrateTrailsCommand : Callable { - - @Parameters( - arity = "1", - paramLabel = "", - description = [ - "Directory containing one or more `.trail.yaml` files. " + - "The filename minus `.trail.yaml` is the device classifier (e.g. " + - "`android-phone.trail.yaml` → `android-phone`).", - ], - ) - var inputDir: File? = null - - @Option( - names = ["--output", "-o"], - description = [ - "Path to write the unified file. Defaults to `/trail.yaml`.", - ], - ) - var outputPath: File? = null - - @Option( - names = ["--fail-on-dropped-content"], - description = [ - "Exit non-zero (ASSERTION_FAILED) when the migration is lossy: either input content that " + - "did not round-trip (schema-unknown keys, or sibling keys in a tool entry the tool decoder " + - "discards), or a round-trip fidelity mismatch (the emitted file decodes back to different " + - "tools/config than intended). Off by default: a lossy migration still writes a usable file " + - "with leading WARNING comments and exits 0. Turn this on in a pipeline that must refuse to " + - "chain (e.g. `migrate-trails && commit`) on a lossy migration.", - ], - ) - var failOnDroppedContent: Boolean = false - - override fun call(): Int { - val dir = inputDir ?: run { - Console.error("trailblaze migrate-trails: is required.") - return EXIT_USAGE - } - if (!dir.isDirectory) { - Console.error("trailblaze migrate-trails: $dir is not a directory.") - return EXIT_USAGE - } - - val result = try { - UnifiedTrailMigrator(TrailblazeYaml.Default).migrate(dir) - } catch (e: IllegalArgumentException) { - Console.error("trailblaze migrate-trails: ${e.message ?: e::class.simpleName}") - return EXIT_USAGE - } catch (e: Exception) { - Console.error("trailblaze migrate-trails: migration failed: ${e.message ?: e::class.simpleName}") - return EXIT_INFRA - } - - val drift = UnifiedTrailMigrator.driftComments(result.report.drift) + - UnifiedTrailMigrator.kindDriftComments(result.report.kindDrift) + - UnifiedTrailMigrator.memoryDriftComments(result.report.memoryDrift) + - UnifiedTrailMigrator.configDriftComments(result.report.configDrift) + - UnifiedTrailMigrator.droppedContentComments(result.report.droppedContent) + - UnifiedTrailMigrator.roundTripMismatchComments(result.report.roundTripMismatches) - val yamlText = TrailblazeYaml.Default.encodeUnifiedTrailToString( - trail = result.trail, - leadingComments = drift, - ) - - val output = outputPath ?: File(dir, "trail.yaml") - - // Refuse to overwrite a source file — `--output` could accidentally point - // at one of the inputs (e.g. `--output android-phone.trail.yaml`, or - // `--output blaze.yaml` on a blaze-only migration) and we would otherwise - // destroy that input mid-migration. - val inputNames = result.report.platformFilesLoaded + - if (result.report.blazeLoaded) listOf(TrailRecordings.BLAZE_DOT_YAML) else emptyList() - val inputPaths = inputNames - .map { File(dir, it).canonicalPath } - .toSet() - if (output.canonicalPath in inputPaths) { - Console.error( - "trailblaze migrate-trails: refusing to write output to $output — it is one " + - "of the input files. Pick a different --output path.", - ) - return EXIT_USAGE - } - - try { - output.writeText(yamlText) - } catch (e: java.io.IOException) { - Console.error( - "trailblaze migrate-trails: failed to write output to $output: " + - "${e.message ?: e::class.simpleName}", - ) - return EXIT_INFRA - } - - val sourceCount = result.report.platformFilesLoaded.size + - if (result.report.blazeLoaded) 1 else 0 - Console.log("Migrated $sourceCount source file(s) → 1 unified file") - Console.log("Output: ${output.absolutePath}") - Console.log("Steps: ${result.trail.trail.size}") - Console.log("Drift warnings: ${result.report.drift.size}") - if (result.report.kindDrift.isNotEmpty()) { - Console.log("Kind drift warnings (step: vs verify:): ${result.report.kindDrift.size}") - } - if (result.report.droppedContent.isNotEmpty()) { - Console.log("Dropped (un-round-trippable) keys: ${result.report.droppedContent.size}") - } - if (result.report.roundTripMismatches.isNotEmpty()) { - Console.log("Round-trip fidelity mismatches: ${result.report.roundTripMismatches.size}") - } - if (result.report.familyCollapses.isNotEmpty()) { - val summary = result.report.familyCollapses.groupBy { it.family }.map { (family, entries) -> - val collapsed = entries.count { !it.diverged } - val diverged = entries.count { it.diverged } - "$family(${collapsed}c/${diverged}d)" - }.joinToString(", ") - Console.log("Family-collapses: $summary") - } - - // The file is always written (with its DROPPED / round-trip warnings) so the artifact is - // reviewable; --fail-on-dropped-content only changes the exit code so a chained pipeline can - // refuse to proceed on a lossy migration. Off by default → today's exit-0 behavior is preserved. - // Both classes of loss trip the gate: `droppedContent` (input keys the schema can't carry) and - // `roundTripMismatches` (the emitted file decodes back to different tools/config than intended). - val dropped = result.report.droppedContent - val mismatches = result.report.roundTripMismatches - if (failOnDroppedContent && UnifiedTrailMigrator.isLossyMigration(result.report)) { - Console.error( - "trailblaze migrate-trails: --fail-on-dropped-content is set and the migration was lossy — " + - "${dropped.size} un-round-trippable key(s), ${mismatches.size} round-trip mismatch(es):", - ) - // List the first few so a CI log is actionable without opening the artifact. The full set - // (with YAML paths / details) is always in the warning block at the top of the output file. - for (entry in dropped.take(MAX_DROPPED_LISTED)) { - Console.error(" dropped `${entry.key}` at ${entry.path} (${entry.file} line ${entry.line})") - } - if (dropped.size > MAX_DROPPED_LISTED) { - Console.error(" ... and ${dropped.size - MAX_DROPPED_LISTED} more dropped key(s)") - } - for (entry in mismatches.take(MAX_DROPPED_LISTED)) { - Console.error(" round-trip mismatch at ${entry.location}") - } - if (mismatches.size > MAX_DROPPED_LISTED) { - Console.error(" ... and ${mismatches.size - MAX_DROPPED_LISTED} more mismatch(es)") - } - Console.error(" See the WARNING block in ${output.absolutePath}.") - return EXIT_ASSERTION - } - return EXIT_OK - } - - private companion object { - val EXIT_OK: Int = TrailblazeExitCode.SUCCESS.code - // The lossy-migration gate (--fail-on-dropped-content) is ASSERTION_FAILED (1): the migrator - // ran and produced a usable file, but content was dropped — a "ran successfully, wrong-ish - // outcome" a chained `migrate-trails && commit` should treat as "do not proceed." - val EXIT_ASSERTION: Int = TrailblazeExitCode.ASSERTION_FAILED.code - // A migrator crash or an output-write IOException is INFRA_FAILED (2) per TrailblazeExitCode — - // "we couldn't do the work," distinct from the assertion-tier gate above. - val EXIT_INFRA: Int = TrailblazeExitCode.INFRA_FAILED.code - val EXIT_USAGE: Int = TrailblazeExitCode.MISUSE.code - - // Cap on how many dropped-content entries the --fail-on-dropped-content error lists to - // stderr — enough to be actionable in a CI log; the full set lives in the output file. - const val MAX_DROPPED_LISTED = 5 - } -} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/TrailblazeCli.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/TrailblazeCli.kt index 5773e60ad..2c42ba13e 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/TrailblazeCli.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/TrailblazeCli.kt @@ -230,16 +230,9 @@ object TrailblazeCli { * under in-process execution. They keep using the JVM-spawn path until we * have a clearer reason to pull them in. * - * **Output-shape invariant.** Candidates added to this set must emit only - * line-terminated output (i.e. `Console.log/info/error` → `println`). The - * bash shim's `/cli/exec` replay (`ipc_try_forward` in - * `scripts/trailblaze`) restores the trailing newline that bash - * `$(jq -r …)` strips with `printf '%s\n'`. A subcommand that uses - * partial-line output (`Console.appendLog`/`Console.appendInfo` or a raw - * `print(...)` before exit) would render a phantom blank line in the - * forwarded path while looking fine on the JVM-spawn path — a confusing - * divergence. If a candidate truly needs partial-line output, fix the - * shim contract first (interleaved capture is the in-progress design). + * The bash shim's `/cli/exec` replay (`ipc_try_forward` in `scripts/trailblaze`) + * decodes stdout/stderr as NUL-delimited fields rather than through command + * substitution, preserving trailing newlines and partial-line output byte-for-byte. */ private val FORWARDABLE_SUBCOMMANDS = setOf("snapshot", "ask", "config", "tool") @@ -445,7 +438,6 @@ class TrailblazeVersionProvider : IVersionProvider { McpCommand::class, CheckCommand::class, SkillCommand::class, - MigrateTrailsCommand::class, // (No standalone `test` subcommand — bun unit tests run as part of `trailblaze // check`'s third phase. `trailblaze test` collided with "Trailblaze runs trails" // and was deleted in favor of the bundled-in-check flow. If a finer-grained @@ -600,11 +592,7 @@ internal class GroupedCommandListRenderer( ), Group( "Trail:", - // `migrate-trails` is listed so that when `--all` surfaces hidden subcommands it - // lands under Trail: (its natural home — it operates on trail YAML files) rather - // than the `Other:` catch-all. With #3385 making it `hidden = true` by default, - // the renderer's hidden-filter drops it from normal `--help` output anyway. - listOf("run", "session", "report", "results", "waypoint", "migrate-trails"), + listOf("run", "session", "report", "results", "waypoint"), ), Group( "Setup:", diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostAccessibilityRpcClient.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostAccessibilityRpcClient.kt index e351bad66..5dbd9637c 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostAccessibilityRpcClient.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostAccessibilityRpcClient.kt @@ -26,7 +26,6 @@ import xyz.block.trailblaze.toolcalls.interpolateMemoryInTool import xyz.block.trailblaze.toolcalls.requiresHostInstance import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.yaml.TrailArgBinder -import xyz.block.trailblaze.yaml.TrailYamlItem import xyz.block.trailblaze.yaml.createTrailblazeYaml import xyz.block.trailblaze.yaml.fromTrailblazeTool @@ -169,8 +168,9 @@ class HostAccessibilityRpcClient( // dispatch boundary resolves {{var}}/${var} against the memorySnapshot below, so the // device-side tool log carries both the raw and resolved forms and a recording // regenerated from it keeps its tokens. - val toolItems = listOf(TrailYamlItem.ToolTrailItem(listOf(fromTrailblazeTool(tool)))) - val yaml = trailblazeYaml.encodeToString(toolItems) + // Bare tool-wrapper list (`- :`), decoded on-device via decodeTrailOrToolEnvelope + // → decodeTools — never the legacy list-shape trail parser. + val yaml = trailblazeYaml.encodeTools(listOf(fromTrailblazeTool(tool))) // Reuse the host's top-level session ID so every per-tool RunYamlRequest writes // into the same on-device session directory. When pulled back to the host via diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostOnDeviceRpcTrailblazeAgent.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostOnDeviceRpcTrailblazeAgent.kt index 3313340b2..5cadd1177 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostOnDeviceRpcTrailblazeAgent.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/HostOnDeviceRpcTrailblazeAgent.kt @@ -27,7 +27,12 @@ import xyz.block.trailblaze.mcp.AgentImplementation import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateRequest import xyz.block.trailblaze.mcp.android.ondevice.rpc.OnDeviceRpcClient import xyz.block.trailblaze.mcp.android.ondevice.rpc.RpcResult +import xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateResponse import xyz.block.trailblaze.mcp.utils.RpcScreenStateAdapter +import xyz.block.trailblaze.host.recording.EffectiveStreamScreenshotConfig +import xyz.block.trailblaze.host.recording.StreamFrameMonitor +import xyz.block.trailblaze.host.recording.StreamScreenshotScreenState +import xyz.block.trailblaze.host.recording.StreamScreenshotSource import xyz.block.trailblaze.toolcalls.DelegatingTrailblazeTool import xyz.block.trailblaze.toolcalls.ExecutableTrailblazeTool import xyz.block.trailblaze.toolcalls.HostLocalExecutableTrailblazeTool @@ -40,7 +45,6 @@ import xyz.block.trailblaze.toolcalls.requiresHostInstance import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.util.UiAutomationHandleErrors import xyz.block.trailblaze.yaml.TrailArgBinder -import xyz.block.trailblaze.yaml.TrailYamlItem import xyz.block.trailblaze.yaml.createTrailblazeYaml import xyz.block.trailblaze.yaml.fromTrailblazeTool import kotlin.reflect.KClass @@ -134,6 +138,20 @@ class HostOnDeviceRpcTrailblazeAgent( /** Last observed RPC failure from [captureScreenState], surfaced by [screenStateProvider]. */ @Volatile private var lastCaptureFailure: String? = null + /** + * Experimental: serve screenshots from the device's live screenrecord stream instead of the + * per-capture on-device `UiAutomation.takeScreenshot`. Read once at agent construction + * (i.e. per run) — see [StreamScreenshotMode]. + */ + private val streamScreenshotMode = StreamScreenshotMode.resolve() + + /** + * Lazily initialized on the first successful capture (needs the response's device + * dimensions to size the shared H.264 tee). Null when [streamScreenshotMode] is OFF or + * initialization failed. Closed via [closeStreamScreenshotSource]. + */ + @Volatile private var streamScreenshotSource: StreamScreenshotSource? = null + /** Consecutive wedge-signature [captureScreenState] failures; trips circuit breaker * at [MAX_CONSECUTIVE_DEVICE_WEDGE_FAILURES] to fail fast on dead `system_server`. */ private val consecutiveDeviceWedgeFailures = AtomicInteger(0) @@ -164,15 +182,25 @@ class HostOnDeviceRpcTrailblazeAgent( // `trailblaze config screenshot-*` and the desktop Settings panel) so the on-device // agent scales/encodes screenshots the way the user asked. Skipped when the request // doesn't include a screenshot — no point spending the bytes. - val request = GetScreenStateRequest(includeScreenshot = includeScreenshot).let { - if (includeScreenshot) it.withScreenshotScalingConfig(EffectiveScreenshotScalingConfig.effective) + // Stream-screenshot mode skips the on-device Bitmap pull / WEBP encode / base64 leg and + // fills the screenshot from the live screenrecord stream instead. Only once the source is + // initialized (first capture always fetches the on-device screenshot — the source needs + // that response's device dimensions), and never in AB mode (the on-device screenshot + // stays authoritative there so the two can be compared). Deliberate trade-off: stream + // frames come at the shared tee's recording size and JPEG quality, so the user's + // `trailblaze config screenshot-*` scaling/format settings do NOT apply to them (they + // still govern the warm-up and fallback on-device captures). + val deviceScreenshot = includeScreenshot && + (streamScreenshotMode != StreamScreenshotMode.STREAM || streamScreenshotSource == null) + val request = GetScreenStateRequest(includeScreenshot = deviceScreenshot).let { + if (deviceScreenshot) it.withScreenshotScalingConfig(EffectiveScreenshotScalingConfig.effective) else it } when (val first = rpcClient.rpcCall(request)) { is RpcResult.Success -> { consecutiveDeviceWedgeFailures.set(0) lastCaptureFailure = null - return RpcScreenStateAdapter.from(first.data) + return adaptScreenState(first.data, includeScreenshot) } is RpcResult.Failure -> { val detail = first.message + (first.details?.let { " | $it" } ?: "") @@ -201,7 +229,7 @@ class HostOnDeviceRpcTrailblazeAgent( is RpcResult.Success -> { consecutiveDeviceWedgeFailures.set(0) lastCaptureFailure = null - RpcScreenStateAdapter.from(retry.data) + adaptScreenState(retry.data, includeScreenshot) } is RpcResult.Failure -> { val detail = retry.message + (retry.details?.let { " | $it" } ?: "") @@ -217,6 +245,107 @@ class HostOnDeviceRpcTrailblazeAgent( } } + /** + * Wraps the RPC response in a [ScreenState], substituting the screenshot from the live + * screenrecord stream when stream mode is active. First successful capture initializes the + * source (and keeps that response's on-device screenshot); later captures pair the tree's + * device-epoch stamp with a stream frame via [StreamScreenshotSource.awaitFrameMatching]. + */ + private suspend fun adaptScreenState( + data: GetScreenStateResponse, + includeScreenshot: Boolean, + ): ScreenState { + val base = RpcScreenStateAdapter.from(data) + if (!includeScreenshot || streamScreenshotMode == StreamScreenshotMode.OFF) return base + + val existing = streamScreenshotSource + val source = if (existing != null) { + existing + } else { + val created = StreamScreenshotSource( + deviceId = runYamlRequestTemplate.trailblazeDeviceId, + deviceWidth = data.deviceWidth, + deviceHeight = data.deviceHeight, + ) + try { + created.start() + streamScreenshotSource = created + } catch (e: Exception) { + Console.log("[stream-screenshot] failed to start stream source, staying on the on-device path: ${e.message}") + return base + } + // This response already carries the on-device screenshot (the source didn't exist when + // the request was built) — keep it while the stream warms up. AB mode falls through so + // even the warm-up capture logs a comparison line. + if (streamScreenshotMode == StreamScreenshotMode.STREAM) return base + created + } + + val result = source.awaitFrameMatching( + treeCapturedAtDeviceMs = data.capturedAtDeviceMs, + timeoutMs = STREAM_FRAME_TIMEOUT_MS, + ) + return when (streamScreenshotMode) { + StreamScreenshotMode.AB_COMPARE -> { + // On-device screenshot stays authoritative; log enough per capture to judge the + // stream path's viability (match rate, clock skew, payload sizes) from a normal run. + when (result) { + is StreamFrameMonitor.Result.Matched -> Console.log( + "[stream-screenshot] AB matched: skewMs=${result.frameVsTreeSkewMs} " + + "streamBytes=${result.jpegBytes.size} " + + "deviceBytes=${base.screenshotBytes?.size} treeTs=${data.capturedAtDeviceMs}", + ) + is StreamFrameMonitor.Result.Unavailable -> Console.log( + "[stream-screenshot] AB unmatched: ${result.reason} treeTs=${data.capturedAtDeviceMs}", + ) + } + base + } + StreamScreenshotMode.STREAM -> when (result) { + is StreamFrameMonitor.Result.Matched -> { + Console.log( + "[stream-screenshot] matched: skewMs=${result.frameVsTreeSkewMs} " + + "bytes=${result.jpegBytes.size} treeTs=${data.capturedAtDeviceMs}", + ) + StreamScreenshotScreenState(delegate = base, streamJpegBytes = result.jpegBytes) + } + is StreamFrameMonitor.Result.Unavailable -> { + Console.log("[stream-screenshot] unmatched (${result.reason}) — falling back to on-device screenshot") + fetchOnDeviceScreenshotFallback() ?: base + } + } + StreamScreenshotMode.OFF -> base // unreachable — guarded above + } + } + + /** + * One direct re-request with the on-device screenshot included, used when the stream can't + * produce a matching frame. No re-warm loop — the tree RPC just succeeded, so the channel + * is warm; a failure here degrades to the tree-only state rather than failing the capture. + */ + private suspend fun fetchOnDeviceScreenshotFallback(): ScreenState? { + val request = GetScreenStateRequest(includeScreenshot = true) + .withScreenshotScalingConfig(EffectiveScreenshotScalingConfig.effective) + return when (val result = rpcClient.rpcCall(request)) { + is RpcResult.Success -> RpcScreenStateAdapter.from(result.data) + is RpcResult.Failure -> { + Console.log("[stream-screenshot] fallback re-capture failed: ${result.message}") + null + } + } + } + + /** + * Detaches from the shared H.264 tee (reaping the underlying `screenrecord` if this was the + * last consumer). Call at session teardown; no-op when stream mode never engaged. + */ + fun closeStreamScreenshotSource() { + streamScreenshotSource?.let { + streamScreenshotSource = null + runCatching { it.close() } + } + } + /** Circuit breaker for a wedged `system_server`: cache-clear-and-reconnect can't recover * a dead remote, so fail fast after N consecutive wedge-signature failures. Also arms the * on-device server relaunch before throwing — see the comment at the throw site. */ @@ -259,6 +388,14 @@ class HostOnDeviceRpcTrailblazeAgent( "Error while disconnecting UiAutomation", "DeadObjectException", ) + + /** + * Bound on waiting for the stream to produce a frame matching a tree capture. Covers the + * quiet window + normal encode/transport latency with margin; a stream that can't match + * within this falls back to one on-device screenshot RPC, so a busted stream costs about + * as much as the pre-stream path per capture rather than wedging it. + */ + private const val STREAM_FRAME_TIMEOUT_MS = 2_500L } override fun executeTool( @@ -493,8 +630,9 @@ class HostOnDeviceRpcTrailblazeAgent( // The tool is encoded AS AUTHORED (memory tokens intact) — the device's dispatch loop // resolves them against the memory snapshot below, so the device-side tool log carries // the raw + resolved pair and recordings keep their tokens. - val toolItems = listOf(TrailYamlItem.ToolTrailItem(listOf(fromTrailblazeTool(tool)))) - val yaml = trailblazeYaml.encodeToString(toolItems) + // Bare tool-wrapper list (`- :`), decoded on-device via decodeTrailOrToolEnvelope + // → decodeTools — never the legacy list-shape trail parser. + val yaml = trailblazeYaml.encodeTools(listOf(fromTrailblazeTool(tool))) // Reuse the host's top-level session ID so every per-tool RunYamlRequest writes // into the same on-device session directory. When pulled back to the host via @@ -731,3 +869,56 @@ class HostOnDeviceRpcTrailblazeAgent( deletions.forEach { memory.variables.remove(it) } } } + +/** + * Experimental screenshot-source selection for the host-side Android accessibility/RPC agent, + * resolved once at agent construction (i.e. per run — a persistent daemon picks up changes on the + * next trail, not mid-session) from two sources, env-over-config: + * + * - `trailblaze config android-stream-screenshots true` — the discoverable, persistent toggle. + * Serves LLM-loop screenshots from the stream (equivalent to [STREAM]). Read via the JVM-wide + * [EffectiveStreamScreenshotConfig] holder. + * - `TRAILBLAZE_ANDROID_STREAM_SCREENSHOT=1` — env override (one-off / CI). Also selects [STREAM]; + * redundant with the config toggle when both are on. + * - `TRAILBLAZE_ANDROID_STREAM_SCREENSHOT_AB=1` — A/B validation mode ([AB_COMPARE]). Keeps the + * on-device screenshot authoritative but also runs the stream matcher on every capture and logs + * a `[stream-screenshot] AB …` line (match/mismatch, clock skew, payload sizes). Env-only — it's + * a validation tool, not a persistent user setting — and takes precedence over both STREAM + * sources so setting it always compares rather than switches. + * + * [STREAM] serves screenshots from the device's live screenrecord stream (shared H.264 tee) + * instead of per-capture on-device `UiAutomation.takeScreenshot`; the on-device RPC then ships + * tree-only responses. Stream frames use the tee's recording size and JPEG quality — the + * `trailblaze config screenshot-*` scaling/format settings apply only to the warm-up and fallback + * captures. + * + * Env values `1` / `true` (case-insensitive) enable, matching the other Trailblaze env toggles. + */ +internal enum class StreamScreenshotMode { + OFF, + STREAM, + AB_COMPARE, + ; + + companion object { + fun resolve(): StreamScreenshotMode = fromValues( + stream = System.getenv("TRAILBLAZE_ANDROID_STREAM_SCREENSHOT"), + abCompare = System.getenv("TRAILBLAZE_ANDROID_STREAM_SCREENSHOT_AB"), + configEnabled = EffectiveStreamScreenshotConfig.androidEnabled, + ) + + /** Pure seam for tests — [resolve] just feeds it the real environment + config holder. */ + internal fun fromValues( + stream: String?, + abCompare: String?, + configEnabled: Boolean, + ): StreamScreenshotMode = when { + abCompare.isEnabled() -> AB_COMPARE + stream.isEnabled() || configEnabled -> STREAM + else -> OFF + } + + private fun String?.isEnabled(): Boolean = + this != null && (this == "1" || this.equals("true", ignoreCase = true)) + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/OnDeviceRpcClientPool.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/OnDeviceRpcClientPool.kt new file mode 100644 index 000000000..15a3a1630 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/OnDeviceRpcClientPool.kt @@ -0,0 +1,25 @@ +package xyz.block.trailblaze.host + +import java.util.concurrent.ConcurrentHashMap +import xyz.block.trailblaze.devices.TrailblazeDeviceId + +/** Owns one long-lived RPC client per device so daemon commands reuse the same connection. */ +internal class OnDeviceRpcClientPool( + private val createClient: (TrailblazeDeviceId) -> T, +) : AutoCloseable { + + private val clients = ConcurrentHashMap() + + fun get(deviceId: TrailblazeDeviceId): T = + clients.computeIfAbsent(deviceId, createClient) + + fun evict(deviceId: TrailblazeDeviceId) { + clients.remove(deviceId)?.close() + } + + override fun close() { + val snapshot = clients.values.toSet() + clients.clear() + snapshot.forEach { it.close() } + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/TrailblazeHostYamlRunner.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/TrailblazeHostYamlRunner.kt index ec6680891..8fa0ec716 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/TrailblazeHostYamlRunner.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/TrailblazeHostYamlRunner.kt @@ -536,8 +536,10 @@ object TrailblazeHostYamlRunner { * using [PlaywrightTrailblazeAgent] with web-native tools. * * Electron app configuration is resolved from: - * 1. Trail YAML `config.electron` block - * 2. Env vars (`TRAILBLAZE_ELECTRON_CDP_URL`, `TRAILBLAZE_ELECTRON_COMMAND`) as fallback + * 1. The resolved target's launch config + * 2. The `TRAILBLAZE_ELECTRON_*` env vars as fallback (`TRAILBLAZE_ELECTRON_CDP_URL`, + * `TRAILBLAZE_ELECTRON_COMMAND`, `TRAILBLAZE_ELECTRON_ARGS`, `TRAILBLAZE_ELECTRON_CDP_PORT`, + * `TRAILBLAZE_ELECTRON_HEADLESS`) */ private suspend fun runPlaywrightElectronYaml( dynamicLlmClient: DynamicLlmClient, @@ -570,8 +572,8 @@ object TrailblazeHostYamlRunner { else "Initializing Playwright-electron test runner..." ) - // Resolve ElectronAppConfig from trail YAML or environment variables - val electronConfig = resolveElectronAppConfig(runYamlRequest.yaml) + // Resolve ElectronAppConfig from the resolved target or environment variables + val electronConfig = resolveElectronAppConfig(runOnHostParams.targetTestApp) val electronTest = existingTest ?: BasePlaywrightElectronTest( electronAppConfig = electronConfig, @@ -664,20 +666,19 @@ object TrailblazeHostYamlRunner { } /** - * Resolves [ElectronAppConfig] from the trail YAML config block, falling back to - * environment variables if not specified in the YAML. + * Resolves [ElectronAppConfig] in priority order: + * 1. The resolved target's [TrailblazeHostAppTarget.getElectronAppConfig] — the target-level + * home for Electron launch config. A trail carries no per-trail launch block; it selects the + * target + `PLAYWRIGHT_ELECTRON` driver and the launch config comes from the target. + * 2. Environment variables (`TRAILBLAZE_ELECTRON_*`) as the final fallback. */ - private fun resolveElectronAppConfig(yaml: String): ElectronAppConfig { - val trailConfig = try { - createTrailblazeYaml().extractTrailConfig(yaml) - } catch (_: Exception) { - null - } - - // If the trail YAML has an electron config block, use it - trailConfig?.electron?.let { return it } + private fun resolveElectronAppConfig( + targetTestApp: TrailblazeHostAppTarget?, + ): ElectronAppConfig { + // 1. Take the resolved target's launch config. + targetTestApp?.getElectronAppConfig()?.let { return it } - // Fall back to environment variables + // 2. Fall back to environment variables val cdpUrl = System.getenv("TRAILBLAZE_ELECTRON_CDP_URL") val command = System.getenv("TRAILBLAZE_ELECTRON_COMMAND") val args = System.getenv("TRAILBLAZE_ELECTRON_ARGS") @@ -890,7 +891,10 @@ object TrailblazeHostYamlRunner { onProgressMessage("Executing YAML test via Compose RPC...") Console.log("▶️ Starting Compose RPC execution for device: ${trailblazeDeviceId.instanceId}") - val trailItems: List = trailblazeYaml.decodeTrail( + // decodeTrailOrToolEnvelope (superset of decodeTrail): a trail document decodes exactly as + // before; a bare tool-wrapper envelope (single-tool MCP dispatch) decodes via decodeTools, + // never the legacy list-shape trail parser. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope( runYamlRequest.yaml, deviceClassifiers = trailblazeDeviceInfo.classifiers, ) @@ -1226,7 +1230,9 @@ object TrailblazeHostYamlRunner { onProgressMessage("Executing YAML test via Revyl cloud device...") Console.log("▶️ Starting Revyl execution for device: ${trailblazeDeviceId.instanceId}") - val trailItems: List = trailblazeYaml.decodeTrail( + // See the Compose runner above: envelope-tolerant decode keeps single-tool MCP dispatch off + // the legacy list-shape parser while trail documents decode unchanged. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope( runYamlRequest.yaml, deviceClassifiers = trailblazeDeviceInfo.classifiers, ) @@ -1581,9 +1587,10 @@ object TrailblazeHostYamlRunner { listOf(TrailblazeDevicePlatform.ANDROID.asTrailblazeDeviceClassifier()) } - // Decode trail YAML to extract prompt steps for V3 + // Decode trail YAML to extract prompt steps for V3. Envelope-tolerant so single-tool MCP + // dispatch decodes via decodeTools rather than the legacy list-shape parser. val trailItems = try { - trailblazeYaml.decodeTrail(runYamlRequest.yaml, deviceClassifiers = classifiers) + trailblazeYaml.decodeTrailOrToolEnvelope(runYamlRequest.yaml, deviceClassifiers = classifiers) } catch (e: Exception) { Console.log("❌ Failed to decode V3 trail YAML: ${e::class.simpleName}: ${e.message}") onProgressMessage("Failed to decode trail YAML: ${e.message}") @@ -1829,9 +1836,9 @@ object TrailblazeHostYamlRunner { var preActionFailure: String? = null preActionLoop@ for (toolItem in toolItems) { for (toolWrapper in toolItem.tools) { - val toolYaml = trailblazeYaml.encodeToString( - listOf(TrailYamlItem.ToolTrailItem(listOf(toolWrapper))), - ) + // Bare tool-wrapper list (`- :`), decoded on-device via + // decodeTrailOrToolEnvelope → decodeTools — never the legacy list-shape trail parser. + val toolYaml = trailblazeYaml.encodeTools(listOf(toolWrapper)) val singleToolRequest = runYamlRequest.copy( yaml = toolYaml, agentImplementation = AgentImplementation.TRAILBLAZE_RUNNER, @@ -1955,8 +1962,10 @@ object TrailblazeHostYamlRunner { listOf(TrailblazeDevicePlatform.ANDROID.asTrailblazeDeviceClassifier()) } + // Envelope-tolerant decode: single-tool MCP dispatch decodes via decodeTools, not the legacy + // list-shape parser; trail documents are unaffected. val trailItems = try { - trailblazeYaml.decodeTrail(runYamlRequest.yaml, deviceClassifiers = classifiers) + trailblazeYaml.decodeTrailOrToolEnvelope(runYamlRequest.yaml, deviceClassifiers = classifiers) } catch (e: Exception) { Console.log("❌ Failed to decode on-device-RPC trail YAML: ${e::class.simpleName}: ${e.message}") onProgressMessage("Failed to decode trail YAML: ${e.message}") @@ -2252,6 +2261,9 @@ object TrailblazeHostYamlRunner { cleanup = { withContext(NonCancellable) { subprocessRuntimes.forEach { it.shutdownAll() } + // Detach from the shared H.264 tee (no-op unless TRAILBLAZE_ANDROID_STREAM_SCREENSHOT + // engaged) so the underlying screenrecord doesn't outlive the session. + agent.closeStreamScreenshotSource() } }, ) { session -> diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/WorkspaceCompileBootstrap.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/WorkspaceCompileBootstrap.kt index c2aa64b89..805476dba 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/WorkspaceCompileBootstrap.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/WorkspaceCompileBootstrap.kt @@ -58,6 +58,33 @@ object WorkspaceCompileBootstrap { */ internal const val HASH_FILENAME = ".bundle.hash" + /** + * Filename storing the target YAML filenames emitted by the last successful compile. + * + * A trailmap manifest is not necessarily an app target: library-only trailmaps contribute + * dependencies and exports but intentionally emit no `dist/targets/.yaml`. The old cache + * validator treated every manifest id as an expected target file, so any workspace containing a + * library trailmap recompiled on every daemon start. Persisting the compiler's actual output set + * lets the hot path validate missing files without guessing from manifest shape. + */ + internal const val TARGETS_FILENAME = ".bundle.targets" + + /** + * Filename storing workspace-relative generated typed-binding paths from the last successful + * codegen pass. The typed-bindings input hash covers authored workspace config and the framework + * version; this file adds the other half of the cache contract by detecting a user who deleted + * one generated artifact while leaving the inputs unchanged. + */ + internal const val CODEGEN_FILES_FILENAME = ".typed-bindings.files" + + /** + * Filename storing the full workspace-content hash from the last successful typed-bindings + * pass. Typed surfaces depend on more than `trailmap.yaml` + local scripted tools: project + * config, toolsets, providers, and exported dependency metadata can all change their output. + * Keeping a separate broad hash avoids both stale bindings and analyzer work on every startup. + */ + internal const val CODEGEN_HASH_FILENAME = ".typed-bindings.hash" + /** * Filename of the inter-process lock that serializes concurrent daemon-init * compiles in the same workspace. Grabbed via [FileLock] so the OS handles @@ -145,19 +172,19 @@ object WorkspaceCompileBootstrap { internal fun bootstrap(configDir: File, version: String): BootstrapResult { val trailmapsDir = File(configDir, TrailblazeConfigPaths.TRAILMAPS_SUBDIR) - // Typed-bindings + SDK extraction live OUTSIDE the workspace-trailmaps gate below and - // OUTSIDE the hash-skip gate inside it. Two regeneration invariants ride on this: - // 1. A deleted `/tools/trailblaze-client.d.ts` regenerates on the next - // daemon start without needing the input hash to invalidate first. + // SDK extraction lives outside the workspace-trailmaps gate below. Two regeneration + // invariants ride on the bootstrap ordering: + // 1. A deleted `/tools/trailblaze-client.d.ts` is detected by the generated + // files manifest and regenerates on the next daemon start. // 2. The workspace SDK declaration bundle exists as soon as the daemon sees a // workspace, even when there are no workspace trailmaps yet (a fresh clone or a // classpath-only consumer). Without this, the very first trailmap a user authors // would point at a non-existent `.trailblaze/sdk/dist/index.d.ts`. // - // Both helpers are idempotent (skip-write-if-content-matches) so re-running them - // every bootstrap is sub-millisecond on the common case. Failures downgrade to - // `Console.error` rather than aborting the daemon; the CLI path (`trailblaze - // compile`) elevates the same failures to non-zero exit. + // SDK setup is idempotent (skip-write-if-content-matches). Typed-bindings generation is + // analyzer-backed and can be expensive, so the full workspace-content hash plus generated + // files manifest gates it below. Failures downgrade to `Console.error` rather than aborting + // the daemon; the CLI path (`trailblaze compile`) elevates the same failures to non-zero exit. // Order matters: per-trailmap codegen asserts the SDK bundle exists (so it // doesn't write per-trailmap tsconfigs pointing at a missing `paths` target), and // that file is written by `runWorkspaceTypeScriptSetup`. If setup fails, @@ -165,15 +192,6 @@ object WorkspaceCompileBootstrap { // would produce a misleading second "codegen failed" diagnostic that masks // the SDK-extraction root cause. val workspaceSetupOk = runWorkspaceTypeScriptSetup(configDir) - if (workspaceSetupOk) { - runPerTrailmapTypedBindingsCodegen(configDir) - } else { - Console.error( - "Per-trailmap typed-bindings codegen skipped because workspace TypeScript setup " + - "failed above (see prior error). Restart the daemon after fixing the SDK " + - "extraction failure to regenerate per-trailmap trailblaze-client.d.ts / tsconfig.json / .gitignore.", - ) - } if (!trailmapsDir.isDirectory) return BootstrapResult.NoWorkspaceTrailmaps @@ -183,9 +201,11 @@ object WorkspaceCompileBootstrap { val outputDir = File(configDir, TrailblazeConfigPaths.WORKSPACE_DIST_TARGETS_SUBPATH) val distDir = File(configDir, TrailblazeConfigPaths.WORKSPACE_DIST_SUBDIR) val hashFile = File(distDir, HASH_FILENAME) + val targetsFile = File(distDir, TARGETS_FILENAME) + val codegenFilesFile = File(distDir, CODEGEN_FILES_FILENAME) + val codegenHashFile = File(distDir, CODEGEN_HASH_FILENAME) val expectedHash = computeWorkspaceHash(trailmapManifests, version) - val expectedTargetIds = trailmapManifests.map { it.id } // Cross-process serialization. Two daemons starting at once in the same workspace // would both observe a stale hash, both call TrailblazeCompiler.compile() against @@ -195,7 +215,49 @@ object WorkspaceCompileBootstrap { // is fresh and the hash check below short-circuits. return withDistLock(distDir) { val storedHash = readStoredHash(hashFile) - if (storedHash == expectedHash && allTargetsPresent(outputDir, expectedTargetIds)) { + val inputsUnchanged = storedHash == expectedHash + val expectedCodegenHash = + xyz.block.trailblaze.config.project.WorkspaceContentHasher.compute(configDir, version) + val codegenInputsUnchanged = readStoredHash(codegenHashFile) == expectedCodegenHash + + // Analyzer-backed typed bindings are expensive in a large workspace. The input hash already + // invalidates them on every authored config/tool/framework change, so only re-resolve and + // re-analyze when those inputs changed or a path recorded by the previous pass disappeared. + // WorkspaceTypeScriptSetup remains outside this gate: its cheap idempotent extraction must + // still restore a deleted SDK bundle and prune legacy files on every daemon start. + if (workspaceSetupOk) { + if (!codegenInputsUnchanged || !allGeneratedFilesPresent(configDir.parentFile, codegenFilesFile)) { + val generatedFiles = runPerTrailmapTypedBindingsCodegen(configDir) + if (generatedFiles != null) { + writeGeneratedFilesManifest( + workspaceRoot = configDir.parentFile, + manifestFile = codegenFilesFile, + generatedFiles = generatedFiles, + ) + // Compute after emission because the framework-owned tools/tsconfig.json participates + // in the broad workspace hash. This makes the freshly generated state the cache key; + // the next unchanged startup compares equal instead of paying one extra regeneration. + writeHash( + codegenHashFile, + xyz.block.trailblaze.config.project.WorkspaceContentHasher.compute(configDir, version), + ) + } else { + // A manifest from an older successful pass must not make this failed pass appear + // current on the next startup. Codegen is best-effort, but it should keep retrying. + codegenFilesFile.delete() + codegenHashFile.delete() + } + } + } else { + codegenHashFile.delete() + Console.error( + "Per-trailmap typed-bindings codegen skipped because workspace TypeScript setup " + + "failed above (see prior error). Restart the daemon after fixing the SDK " + + "extraction failure to regenerate per-trailmap trailblaze-client.d.ts / tsconfig.json / .gitignore.", + ) + } + + if (inputsUnchanged && allTargetsPresent(outputDir, targetsFile)) { return@withDistLock BootstrapResult.UpToDate } @@ -252,6 +314,10 @@ object WorkspaceCompileBootstrap { hashFile.delete() throw WorkspaceCompileException(result.errors) } + // Write the output manifest before the hash commit marker. If the process dies between the + // writes, the absent/stale hash forces a clean compile next time; the inverse order could + // incorrectly bless an incomplete output manifest as current. + writeTargetManifest(targetsFile, result.emittedTargets) writeHash(hashFile, expectedHash) BootstrapResult.Recompiled(emitted = result.emittedTargets.size) } @@ -287,8 +353,8 @@ object WorkspaceCompileBootstrap { * Zero-trailmap pools no-op cleanly inside the emitters. Failures downgrade to a * warning for the same daemon-must-come-up reason as [runWorkspaceTypeScriptSetup]. */ - private fun runPerTrailmapTypedBindingsCodegen(configDir: File) { - try { + private fun runPerTrailmapTypedBindingsCodegen(configDir: File): List? { + return try { val loaded = LoadedTrailblazeProjectConfig( raw = TrailblazeProjectConfig(), sourceFile = File(configDir, TrailblazeProjectConfigLoader.CONFIG_FILENAME), @@ -300,22 +366,31 @@ object WorkspaceCompileBootstrap { scriptedToolEnrichment = resolveScriptedToolEnrichment(), ) .resolvedTrailmaps - PerTrailmapClientDtsEmitter.emit(resolvedTrailmaps = resolvedTrailmaps) - PerTrailmapTsconfigEmitter.emit( + val clientDtsFiles = PerTrailmapClientDtsEmitter.emit(resolvedTrailmaps = resolvedTrailmaps) + val configFiles = PerTrailmapTsconfigEmitter.emit( workspaceRoot = configDir.parentFile.toPath(), resolvedTrailmaps = resolvedTrailmaps, ) + // The client emitter writes a validation sidecar next to each d.ts but returns only the d.ts + // paths. Record sidecars that were actually created so deleting one invalidates the codegen + // cache without turning a best-effort sidecar write failure into a permanent startup loop. + val sidecarFiles = clientDtsFiles.map { dts -> + dts.parent.resolve(TrailValidationDescriptorSidecar.FILE_NAME) + }.filter { java.nio.file.Files.isRegularFile(it) } + (clientDtsFiles + sidecarFiles + configFiles).distinct() } catch (e: TrailblazeProjectConfigException) { Console.error( "Typed-bindings codegen skipped — trailmap re-resolution failed " + "(daemon will continue without per-trailmap trailblaze-client.d.ts / tsconfig.json / .gitignore files): " + "${e.message ?: e.javaClass.simpleName}", ) + null } catch (e: Exception) { Console.error( "Typed-bindings codegen failed (daemon will continue without per-trailmap " + "trailblaze-client.d.ts / tsconfig.json / .gitignore files): ${e.message ?: e.javaClass.simpleName}", ) + null } } @@ -466,15 +541,48 @@ object WorkspaceCompileBootstrap { * even though the input hash matches. We don't try to distinguish "user-deleted the * file" from "compile never produced it"; either way the right answer is recompile. * - * Note: [TrailblazeCompiler] only writes a YAML file for *app* trailmaps (those with a - * `target:` block). Library trailmaps contribute defaults but produce no output. We can't - * tell the two apart cheaply here — and adding the parse step would defeat the - * point of the hash-skip — so we err on the side of running compile when ANY expected - * id is missing. The compile itself is fast on an unchanged input set. + * [targetsFile] records the compiler's actual prior output set. This matters because library + * trailmaps intentionally produce no target YAML; deriving expected filenames from every + * manifest id would make a valid library look like a missing output forever. */ - private fun allTargetsPresent(outputDir: File, expectedIds: List): Boolean { - if (!outputDir.isDirectory) return false - return expectedIds.all { id -> File(outputDir, "$id.yaml").isFile } + private fun allTargetsPresent(outputDir: File, targetsFile: File): Boolean { + val expectedFiles = readManifestLines(targetsFile) ?: return false + return expectedFiles.all { name -> File(outputDir, name).isFile } + } + + private fun allGeneratedFilesPresent(workspaceRoot: File, manifestFile: File): Boolean { + val expectedFiles = readManifestLines(manifestFile) ?: return false + return expectedFiles.all { relativePath -> File(workspaceRoot, relativePath).isFile } + } + + private fun readManifestLines(file: File): List? = try { + if (!file.isFile) null else file.readLines().map { it.trim() }.filter { it.isNotEmpty() } + } catch (_: Exception) { + null + } + + private fun writeTargetManifest(file: File, emittedTargets: List) { + writeManifestLines(file, emittedTargets.map { it.name }) + } + + private fun writeGeneratedFilesManifest( + workspaceRoot: File, + manifestFile: File, + generatedFiles: List, + ) { + val root = workspaceRoot.toPath().toAbsolutePath().normalize() + val relativePaths = generatedFiles.mapNotNull { path -> + val normalized = path.toAbsolutePath().normalize() + if (normalized.startsWith(root)) root.relativize(normalized).toString().replace(File.separatorChar, '/') + else null + } + writeManifestLines(manifestFile, relativePaths) + } + + private fun writeManifestLines(file: File, lines: List) { + file.parentFile?.mkdirs() + val content = lines.distinct().sorted().joinToString(separator = "\n", postfix = if (lines.isEmpty()) "" else "\n") + file.writeText(content) } private fun readStoredHash(hashFile: File): String? = try { diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/AndroidLiveFrameStream.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/AndroidLiveFrameStream.kt new file mode 100644 index 000000000..17b103357 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/AndroidLiveFrameStream.kt @@ -0,0 +1,99 @@ +package xyz.block.trailblaze.host.recording + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import xyz.block.trailblaze.capture.video.AndroidVideoCapture +import xyz.block.trailblaze.capture.video.H264AccessUnit +import xyz.block.trailblaze.capture.video.H264AccessUnitConsumer +import xyz.block.trailblaze.capture.video.H264Tee +import xyz.block.trailblaze.capture.video.LiveFrameConsumer +import xyz.block.trailblaze.devices.TrailblazeDeviceId + +/** + * Streams the freshest decoded JPEGs from Android's shared H.264 screen-recording encoder. + * + * The callback from [LiveFrameConsumer] runs on its ffmpeg drain thread, so it must never block on + * a slow WebSocket. A one-frame, drop-oldest channel keeps latency bounded: consumers see the most + * recent screen rather than replaying a backlog. Both the generic `/rpc-ws` device viewer and Trail + * Runner's recorder use this bridge so they share the same capture and backpressure behavior. + */ +internal suspend fun streamAndroidLiveJpegFrames( + deviceId: TrailblazeDeviceId, + deviceWidth: Int, + deviceHeight: Int, + onFrame: suspend (ByteArray) -> Unit, +): Nothing = coroutineScope { + val videoSize = AndroidVideoCapture.scaleToRecordingSize(deviceWidth, deviceHeight) + val tee = H264Tee.forDevice(deviceId, videoSize = videoSize, bitRate = "4000000") + val outbound = + Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val sender = launch { + for (frame in outbound) { + onFrame(frame) + } + } + val consumer = LiveFrameConsumer(tee = tee, onFrame = { jpeg, _ -> outbound.trySend(jpeg) }) + + try { + consumer.start() + awaitCancellation() + } finally { + withContext(NonCancellable) { + runCatching { consumer.stop() } + outbound.close() + sender.cancel() + } + } +} + +/** + * Streams complete Annex-B H.264 access units without decoding or re-encoding them on the host. + * + * The bounded channel preserves access-unit order and applies backpressure rather than dropping + * predictive frames, which would make the browser decoder wait for another IDR. Under normal local + * daemon use the WebSocket drains much faster than the 4 Mbps encoder produces data. + */ +internal suspend fun streamAndroidLiveH264AccessUnits( + deviceId: TrailblazeDeviceId, + deviceWidth: Int, + deviceHeight: Int, + onAccessUnit: suspend (H264AccessUnit) -> Unit, +): Nothing = coroutineScope { + val videoSize = AndroidVideoCapture.scaleToRecordingSize(deviceWidth, deviceHeight) + val tee = H264Tee.forDevice(deviceId, videoSize = videoSize, bitRate = "4000000") + val outbound = Channel(capacity = H264_OUTBOUND_ACCESS_UNITS) + val sender = launch { + for (accessUnit in outbound) { + onAccessUnit(accessUnit) + } + } + val consumer = + H264AccessUnitConsumer( + tee = tee, + // runCatching: teardown closes [outbound] before consumer.stop(), whose drain finally-block + // flushes one last access unit through splitter.finish(). That send races the close and would + // throw ClosedSendChannelException on the drain thread. Swallowing it is correct — the stream + // is ending and this trailing frame has nowhere to go. + onAccessUnit = { accessUnit -> runCatching { runBlocking { outbound.send(accessUnit) } } }, + ) + + try { + consumer.start() + awaitCancellation() + } finally { + withContext(NonCancellable) { + // Close first so a producer blocked on a full channel wakes before [stop] joins it. + outbound.close() + runCatching { consumer.stop() } + sender.cancel() + } + } +} + +private const val H264_OUTBOUND_ACCESS_UNITS = 30 diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/EffectiveStreamScreenshotConfig.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/EffectiveStreamScreenshotConfig.kt new file mode 100644 index 000000000..b9e4f7287 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/EffectiveStreamScreenshotConfig.kt @@ -0,0 +1,23 @@ +package xyz.block.trailblaze.host.recording + +/** + * JVM-wide effective value of the persisted `android-stream-screenshots` config toggle, mirroring + * the [xyz.block.trailblaze.api.EffectiveScreenshotScalingConfig] pattern: the daemon's + * `TrailblazeSettingsRepo` collector and the standalone-CLI `CliConfigHelper.readConfig()` both + * push the user's saved preference here, and the host agent reads it once at construction. + * + * A JVM-wide holder (rather than threading the config into the agent constructor) is used because + * the host agent runs both under the daemon and in standalone `--no-daemon` CLI runs, and neither + * path has the `SavedTrailblazeAppConfig` in hand at agent-construction time — the same reason the + * screenshot scaling config uses this seam. + */ +object EffectiveStreamScreenshotConfig { + /** Whether the persisted config opts Android runs into stream-sourced screenshots. */ + @Volatile + var androidEnabled: Boolean = false + + /** Test-only reset so a suite that mutates the singleton can restore it in `@After`. */ + fun clearForTests() { + androidEnabled = false + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosBaguetteServer.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosBaguetteServer.kt new file mode 100644 index 000000000..3054e503d --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosBaguetteServer.kt @@ -0,0 +1,209 @@ +package xyz.block.trailblaze.host.recording + +import java.io.File +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request +import xyz.block.trailblaze.util.Console +import xyz.block.trailblaze.util.TrailblazeProcessBuilderUtils.isCommandAvailable + +/** + * Owns the machine-local `baguette serve` process that backs iOS Simulator live streaming for the + * `/devices` viewer — the iOS analogue of Android's shared `screenrecord` H.264 producer. + * + * The iOS Simulator has no stock live-H.264 primitive: `simctl io recordVideo` only writes a + * seekable file (it refuses a pipe / `/dev/stdout`), and baguette's own `stream`-to-stdout mode is + * broken in the shipping release (it writes an HTTP header and no frames). The working transport is + * [baguette](https://github.com/tddworks/baguette)'s `serve` HTTP/WebSocket server: it captures the + * simulator framebuffer via private SimulatorKit frameworks, hardware-encodes H.264 with + * VideoToolbox, and streams avcc records over `ws:///simulators//stream?format=avcc` + * (see [streamIosLiveH264AccessUnits]). + * + * **One shared server, fanned out.** A single `baguette serve` handles every booted simulator and + * every concurrent client — unlike a subprocess-per-connection model, one server multiplexes all + * viewers. [ensureServing] starts it lazily on first use and, if a server is already listening + * (started by this daemon or another), reuses it rather than spawning a second. So multiple + * Trailblaze daemons on one machine share the same video source, and one simulator can be watched + * from several viewers at once. Only a server this JVM started is torn down on shutdown; a reused + * one is left alone. + * + * **Optional dependency.** baguette is macOS + Apple-Silicon only and is not bundled. When it isn't + * installed ([isAvailable] is false) the `/devices/api/stream` endpoint declines the iOS H.264 path + * and the browser falls back to the JPEG poll — so the viewer keeps working everywhere, just at + * screenshot cadence. The binary is resolved once (lazily); install baguette before starting the + * daemon. + */ +internal object IosBaguetteServer { + + /** Env override pointing directly at a `baguette` binary (skips PATH / Homebrew resolution). */ + private const val ENV_BAGUETTE = "TRAILBLAZE_BAGUETTE" + + /** Env override for the `baguette serve` port (default [DEFAULT_SERVE_PORT]). */ + private const val ENV_SERVE_PORT = "TRAILBLAZE_BAGUETTE_SERVE_PORT" + + /** Homebrew's native (arm64) install location — baguette only ships for Apple Silicon. */ + private const val HOMEBREW_BAGUETTE = "/opt/homebrew/bin/baguette" + + /** baguette's own default `serve` port; matches its web UI so a manually-started server is reused. */ + private const val DEFAULT_SERVE_PORT = 8421 + + /** Loopback only — the server is a local video source, never exposed off-host. */ + private const val SERVE_HOST = "127.0.0.1" + + /** Max wait for a freshly-spawned server to answer before giving up (generous for loaded CI). */ + private const val SERVE_READY_TIMEOUT_MS = 15_000L + + /** Resolved `baguette` command (or null when not installed). Read once. */ + private val command: String? by lazy { resolveBaguette() } + + /** Port `baguette serve` listens on. Read once. */ + private val servePort: Int by lazy { resolveServePort() } + + /** Short-timeout client for the readiness probe; the WS stream uses its own client. */ + private val probeClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(500, TimeUnit.MILLISECONDS) + .readTimeout(500, TimeUnit.MILLISECONDS) + .callTimeout(1, TimeUnit.SECONDS) + .build() + } + + private val startLock = Any() + @Volatile private var spawnedProcess: Process? = null + + /** True when a `baguette` binary was found; the iOS H.264 stream path is only offered when true. */ + fun isAvailable(): Boolean = command != null + + /** + * Guarantees a `baguette serve` is reachable and returns its `host:port` authority (e.g. + * `127.0.0.1:8421`), or null when baguette isn't installed or the server never became ready. + * Idempotent: reuses an already-listening server and only spawns one when none is up. + */ + fun ensureServing(): String? { + command ?: return null + if (isServeHealthy()) return authority() + synchronized(startLock) { + if (isServeHealthy()) return authority() + if (!startServe()) return null + } + return authority() + } + + private fun authority(): String = "$SERVE_HOST:$servePort" + + /** GET `/simulators`; a 200 means a baguette serve (ours or another daemon's) is already up. */ + private fun isServeHealthy(): Boolean = + runCatching { + probeClient + .newCall(Request.Builder().url("http://${authority()}/simulators").get().build()) + .execute() + .use { it.isSuccessful } + } + .getOrDefault(false) + + /** Spawns `baguette serve` and waits until it answers. Returns false if it never comes up. */ + private fun startServe(): Boolean { + val baguette = command ?: return false + Console.log("[IosBaguetteServer] starting baguette serve on ${authority()}") + val process = + ProcessBuilder(baguette, "serve", "--port", servePort.toString(), "--host", SERVE_HOST) + // Keep stderr off stdout; drain it to the daemon log so serve diagnostics are greppable. + .redirectErrorStream(false) + .start() + spawnedProcess = process + drainStderr(process) + // Tear down only the server this JVM spawned — a reused one belongs to another daemon. + Runtime.getRuntime() + .addShutdownHook( + Thread { + runCatching { + process.destroy() + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly() + } + }, + ) + + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SERVE_READY_TIMEOUT_MS) + while (System.nanoTime() < deadline) { + if (!process.isAlive) { + Console.log("[IosBaguetteServer] baguette serve exited before becoming ready") + return false + } + if (isServeHealthy()) { + Console.log("[IosBaguetteServer] baguette serve ready on ${authority()} (pid ${process.pid()})") + return true + } + runCatching { Thread.sleep(200) } + } + Console.log( + "[IosBaguetteServer] baguette serve did not become ready within ${SERVE_READY_TIMEOUT_MS}ms; " + + "iOS live stream will fall back to JPEG polling", + ) + return false + } + + private fun drainStderr(process: Process) { + Thread( + { + runCatching { + process.errorStream.bufferedReader().useLines { lines -> + lines.forEach { Console.log("[IosBaguetteServer/baguette] $it") } + } + } + }, + "ios-baguette-serve-stderr", + ) + .apply { + isDaemon = true + start() + } + } + + private fun resolveServePort(): Int { + val raw = System.getenv(ENV_SERVE_PORT)?.trim().orEmpty() + if (raw.isEmpty()) return DEFAULT_SERVE_PORT + val parsed = raw.toIntOrNull()?.takeIf { it in 1..65535 } + if (parsed == null) { + Console.log("[IosBaguetteServer] $ENV_SERVE_PORT='$raw' is not a valid port; using $DEFAULT_SERVE_PORT") + return DEFAULT_SERVE_PORT + } + return parsed + } + + /** + * Resolves the `baguette` binary against the real environment: `TRAILBLAZE_BAGUETTE` override, + * then PATH, then the native Homebrew location. Delegates the decision to the pure + * [resolveBaguettePath] so the resolution order is unit-testable without touching the filesystem. + */ + private fun resolveBaguette(): String? = + resolveBaguettePath( + envOverride = System.getenv(ENV_BAGUETTE), + isExecutable = { File(it).canExecute() }, + isOnPath = { isCommandAvailable(it) }, + ) + + /** + * Pure resolution of which `baguette` command to run, given injected environment lookups. Returns + * null (not a bare `"baguette"`) when none resolves, so the caller can cleanly decline the H.264 + * path instead of failing later with an opaque "cannot run program". Order: an executable + * `TRAILBLAZE_BAGUETTE` override wins; otherwise a `baguette` on PATH; otherwise the executable + * native Homebrew binary; otherwise null. + */ + internal fun resolveBaguettePath( + envOverride: String?, + isExecutable: (String) -> Boolean, + isOnPath: (String) -> Boolean, + ): String? { + envOverride?.takeIf { it.isNotBlank() }?.let { path -> + if (isExecutable(path)) return path + Console.log("[IosBaguetteServer] $ENV_BAGUETTE=$path is not executable; falling back") + } + if (isOnPath("baguette")) return "baguette" + if (isExecutable(HOMEBREW_BAGUETTE)) return HOMEBREW_BAGUETTE + Console.log( + "[IosBaguetteServer] baguette not found on PATH; iOS H.264 streaming unavailable " + + "(install with `brew install baguette`). Falling back to JPEG polling.", + ) + return null + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosLiveFrameStream.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosLiveFrameStream.kt new file mode 100644 index 000000000..fd35cdaf1 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/IosLiveFrameStream.kt @@ -0,0 +1,138 @@ +package xyz.block.trailblaze.host.recording + +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import xyz.block.trailblaze.capture.video.BaguetteAvccStreamParser +import xyz.block.trailblaze.capture.video.H264AccessUnit +import xyz.block.trailblaze.devices.TrailblazeDeviceId +import xyz.block.trailblaze.util.Console + +/** + * Streams complete Annex-B H.264 access units from a booted iOS Simulator, without decoding or + * re-encoding on the host — the iOS counterpart to [streamAndroidLiveH264AccessUnits]. + * + * The frames come from the shared `baguette serve` process ([IosBaguetteServer]) over a WebSocket + * (`/simulators//stream?format=avcc`). Each binary message is one avcc record, converted to + * the same [H264AccessUnit] Annex-B shape the Android tee produces (see [BaguetteAvccStreamParser]) + * and pushed to the browser over the shared `/devices/api/stream` endpoint, where WebCodecs decodes + * it. The wire and the browser decode path are byte-for-byte the same as Android's. + * + * baguette forces an IDR at stream start, so a fresh WebSocket always begins with a decodable + * keyframe — no mid-stream keyframe cache is needed (unlike the shared Android tee, whose + * screenrecord encoder emits an IDR essentially only once). The bounded channel applies backpressure + * (OkHttp stops reading the socket) rather than dropping predictive frames, which would otherwise + * make the decoder wait for the next IDR. + * + * Returns when the WebSocket closes or fails (baguette exit, network error) as well as on + * cancellation, so a died producer lets the caller close the browser WebSocket and fall back to JPEG + * polling — rather than parking here forever while heartbeats mask a frozen frame. + * + * `baguette serve` fans one capture out to many clients, so several viewers of one simulator each + * open their own WebSocket here against the same shared server (no per-viewer capture subprocess). + */ +internal suspend fun streamIosLiveH264AccessUnits( + deviceId: TrailblazeDeviceId, + onAccessUnit: suspend (H264AccessUnit) -> Unit, +) = coroutineScope { + val authority = + IosBaguetteServer.ensureServing() + ?: run { + Console.log( + "[IosBaguetteServer] no baguette serve available for ${deviceId.instanceId}; " + + "nothing to stream (browser will JPEG-poll)", + ) + return@coroutineScope + } + // OkHttp takes an http(s) URL and performs the WebSocket upgrade itself; it sends no Origin + // header by default, which is exactly what baguette serve's trust gate requires. + val url = "http://$authority/simulators/${deviceId.instanceId}/stream?format=avcc&version=v2" + + val outbound = Channel(capacity = IOS_OUTBOUND_ACCESS_UNITS) + val ended = CompletableDeferred() + var sender: Job? = null + var webSocket: WebSocket? = null + try { + sender = launch { + for (accessUnit in outbound) { + onAccessUnit(accessUnit) + } + } + + val parser = BaguetteAvccStreamParser() + val listener = + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + // Each binary WS message is one complete [type][payload] avcc record. + runCatching { + parser.feed(bytes.toByteArray()) { accessUnit -> + // runCatching: teardown closes `outbound` before this returns; a send racing the + // close throws and the trailing frame simply has nowhere to go. + runCatching { runBlocking { outbound.send(accessUnit) } } + } + } + } + + // Text frames (describe-ui results / server errors) piggyback on the same socket. The live + // viewer only wants video, so ignore them here. + override fun onMessage(webSocket: WebSocket, text: String) = Unit + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(NORMAL_CLOSURE, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + outbound.close() + ended.complete(Unit) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Console.log( + "[IosBaguetteServer] baguette WS ended for ${deviceId.instanceId}: ${t.message}", + ) + outbound.close() + ended.complete(Unit) + } + } + webSocket = iosStreamHttpClient.newWebSocket(Request.Builder().url(url).build(), listener) + + // Parks while frames flow. Completes when the socket closes/fails on a died producer (draining + // `sender`), or throws on cancellation when the client disconnects. Either way we fall through + // to teardown and the caller closes the browser WebSocket. + ended.await() + } finally { + withContext(NonCancellable) { + outbound.close() + runCatching { webSocket?.cancel() } + sender?.cancel() + } + } +} + +/** Outbound buffer of converted access units bridging the OkHttp reader thread to the sender. */ +private const val IOS_OUTBOUND_ACCESS_UNITS = 30 + +private const val NORMAL_CLOSURE = 1000 + +/** + * Shared client for the baguette WebSocket. No read timeout — the stream is long-lived and + * continuous; a ping keeps an idle socket from being reaped by an intermediary. + */ +private val iosStreamHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(20, TimeUnit.SECONDS) + .build() +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/OnDeviceRpcDeviceScreenStream.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/OnDeviceRpcDeviceScreenStream.kt index 0dc5d37c9..964b8a827 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/OnDeviceRpcDeviceScreenStream.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/OnDeviceRpcDeviceScreenStream.kt @@ -22,7 +22,6 @@ import xyz.block.trailblaze.toolcalls.commands.PressKeyTrailblazeTool import xyz.block.trailblaze.toolcalls.commands.SwipeWithRelativeCoordinatesTool import xyz.block.trailblaze.toolcalls.commands.TapOnPointTrailblazeTool import xyz.block.trailblaze.util.Console -import xyz.block.trailblaze.yaml.TrailYamlItem import xyz.block.trailblaze.yaml.TrailblazeYaml import xyz.block.trailblaze.yaml.fromTrailblazeTool @@ -104,7 +103,7 @@ class OnDeviceRpcDeviceScreenStream( includeScreenshot = true, includeTree = false, ) ?: return null - return response.screenshotBase64?.decodeBase64Bytes() + return response.screenshotBytes ?: response.screenshotBase64?.decodeBase64Bytes() } /** @@ -121,7 +120,7 @@ class OnDeviceRpcDeviceScreenStream( includeTree = true, ) ?: return null lastResponse = response - return response.screenshotBase64?.decodeBase64Bytes() + return response.screenshotBytes ?: response.screenshotBase64?.decodeBase64Bytes() } override suspend fun tap(x: Int, y: Int) { @@ -235,8 +234,9 @@ class OnDeviceRpcDeviceScreenStream( } private suspend fun dispatchTool(tool: TrailblazeTool) { - val toolItems = listOf(TrailYamlItem.ToolTrailItem(listOf(fromTrailblazeTool(tool)))) - val yaml = trailblazeYaml.encodeToString(toolItems) + // Bare tool-wrapper list (`- :`), decoded on-device via decodeTrailOrToolEnvelope + // → decodeTools — never the legacy list-shape trail parser. + val yaml = trailblazeYaml.encodeTools(listOf(fromTrailblazeTool(tool))) val request = runYamlRequestTemplate.copy( yaml = yaml, // TRAILBLAZE_RUNNER dispatches a fixed tool list directly — no LLM call, no agent diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamFrameMonitor.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamFrameMonitor.kt new file mode 100644 index 000000000..0051d0ae6 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamFrameMonitor.kt @@ -0,0 +1,145 @@ +package xyz.block.trailblaze.host.recording + +import kotlinx.coroutines.delay + +/** + * Platform-neutral core of stream-sourced screenshots: tracks the most recent frame from a + * live device stream and pairs it with a UI-tree capture via the pure [StreamScreenshotGate]. + * + * A platform feed adapter (Android: [StreamScreenshotSource] over the `screenrecord` H.264 + * tee; future iOS: the baguette WebSocket; future web: CDP screencast) pushes every emitted + * frame through [recordFrame], flagging whether it is a content change or a liveness + * heartbeat — the feed already knows (it deduplicates frames to implement heartbeats), so + * this class never hashes bytes itself. The capture path then calls [awaitFrameMatching] + * with the tree capture's stamp. + * + * Clock domains: frames are stamped with host receipt time here; the tree stamp lives on the + * "tree clock" of whoever captured it. Android's tree is stamped on the device clock, so its + * adapter measures a real [treeClockOffsetMs]; platforms whose tree capture is host-initiated + * (iOS, web) stamp on the host clock and use the default offset of zero. + */ +class StreamFrameMonitor( + /** Measured `treeClockEpoch - hostEpoch` (ms), so `hostMs + offset ≈ treeClockMs`. */ + private val treeClockOffsetMs: Long = 0L, + /** See [StreamScreenshotGate.evaluate]. Tune per feed if its cadence differs. */ + private val quietWindowMs: Long = DEFAULT_QUIET_WINDOW_MS, + private val stallThresholdMs: Long = DEFAULT_STALL_THRESHOLD_MS, + private val latencyAllowanceMs: Long = DEFAULT_LATENCY_ALLOWANCE_MS, +) { + + /** Immutable (bytes, receipt-time) pair so readers never see a torn update. */ + private class FrameRecord(val jpegBytes: ByteArray, val receivedAtHostMs: Long) + + @Volatile private var latestFrame: FrameRecord? = null + @Volatile private var lastContentChangeAtHostMs: Long? = null + @Volatile private var lastFeedAliveAtHostMs: Long? = null + + sealed interface Result { + class Matched(val jpegBytes: ByteArray, val frameVsTreeSkewMs: Long?) : Result + class Unavailable(val reason: String) : Result + } + + /** + * Records a frame emitted by the feed, stamped with host receipt time. [isContentChange] + * is false for liveness heartbeats (a re-emit of unchanged content). Called on the feed's + * drain thread. + * + * Write order matters: the content-change stamp is published before the frame itself so + * [awaitFrameMatching]'s snapshot (which reads in the opposite order) can only ever pair a + * frame with a change-time of the same age or newer — biasing the gate toward waiting, + * never toward accepting a stale pairing. + */ + fun recordFrame(jpegBytes: ByteArray, isContentChange: Boolean) { + val nowHostMs = System.currentTimeMillis() + if (isContentChange) { + lastContentChangeAtHostMs = nowHostMs + } + latestFrame = FrameRecord(jpegBytes, nowHostMs) + } + + /** + * Records an out-of-band proof of life from the feed's drain loop — the capture pipeline + * is attached and draining even though nothing is decoding. Damage-driven encoders (the + * emulator's `screenrecord`) emit no frames at all for a static screen, so without this + * signal the gate cannot tell a static screen from a dead pipeline and would refuse + * exactly the captures a settled UI produces. Called on the feed's drain thread. + */ + fun recordFeedAlive() { + lastFeedAliveAtHostMs = System.currentTimeMillis() + } + + /** + * Waits (bounded by [timeoutMs]) for the stream to reach a state where its latest frame + * provably matches the tree capture stamped [treeCapturedAtMs] (tree clock; null when + * unstamped), then returns that exact frame. Returns [Result.Unavailable] — caller falls + * back to a direct screenshot capture — when the stream is stalled, the screen changed + * after the tree capture, or the timeout elapses. + */ + suspend fun awaitFrameMatching(treeCapturedAtMs: Long?, timeoutMs: Long): Result { + val deadline = System.currentTimeMillis() + timeoutMs + while (true) { + // Snapshot before evaluating so the bytes returned on Accept are exactly the frame + // the gate judged — a frame arriving mid-decision must not be returned against the + // already-proven quiet window. Frame is read before change-time (see [recordFrame] + // for why this order is the safe one). + val frame = latestFrame + val lastChange = lastContentChangeAtHostMs + val decision = StreamScreenshotGate.evaluate( + nowHostMs = System.currentTimeMillis(), + lastFrameReceivedAtHostMs = frame?.receivedAtHostMs, + lastContentChangeAtHostMs = lastChange, + treeCapturedAtMs = treeCapturedAtMs, + treeClockOffsetMs = treeClockOffsetMs, + quietWindowMs = quietWindowMs, + stallThresholdMs = stallThresholdMs, + latencyAllowanceMs = latencyAllowanceMs, + lastFeedAliveAtHostMs = lastFeedAliveAtHostMs, + ) + when (decision) { + is StreamScreenshotGate.Decision.Accept -> + // Non-null whenever the gate can Accept — it saw this frame's receipt time. + return Result.Matched(checkNotNull(frame).jpegBytes, decision.frameVsTreeSkewMs) + is StreamScreenshotGate.Decision.Stalled -> + return Result.Unavailable( + "stream stalled (${decision.silentForMs}ms without a frame or liveness ping)", + ) + is StreamScreenshotGate.Decision.ContentNewerThanTree -> + return Result.Unavailable( + "screen changed ${decision.contentChangeAfterTreeMs}ms after the tree capture", + ) + is StreamScreenshotGate.Decision.AwaitFirstFrame, + is StreamScreenshotGate.Decision.AwaitQuiet, + -> { + if (System.currentTimeMillis() >= deadline) { + return Result.Unavailable("timed out after ${timeoutMs}ms in state $decision") + } + delay(POLL_INTERVAL_MS) + } + } + } + } + + companion object { + /** + * Content must be unchanged this long before the latest frame counts as settled. Kept + * below typical tree-capture settle caps so the stream wait usually overlaps the tree + * capture's own settle rather than adding to it. + */ + const val DEFAULT_QUIET_WINDOW_MS = 300L + + /** + * Feeds prove liveness at ≥ ~1 Hz even for a static screen — via frames when the encoder + * emits them, and via [recordFeedAlive] drain-loop pings when it doesn't (damage-driven + * encoders like the emulator's `screenrecord` go fully silent on a static screen). 3s of + * total silence therefore means the pipeline died, not that the screen is static. A feed + * with a slower idle cadence must raise this. + */ + const val DEFAULT_STALL_THRESHOLD_MS = 3_000L + + /** Slack for encode + transport + decode when comparing a host-observed content change + * against the tree stamp. */ + const val DEFAULT_LATENCY_ALLOWANCE_MS = 500L + + private const val POLL_INTERVAL_MS = 25L + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotGate.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotGate.kt new file mode 100644 index 000000000..0fc0238f6 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotGate.kt @@ -0,0 +1,116 @@ +package xyz.block.trailblaze.host.recording + +/** + * Pure decision logic for pairing a live device-stream frame with a UI-tree capture. + * + * Platform-neutral: nothing here knows where frames come from (Android `screenrecord` tee, + * iOS baguette WebSocket, web CDP screencast) or how the tree was captured. Feeds supply the + * observations via [StreamFrameMonitor]; this function only reasons about their timing. + * + * The consistency argument is **dual-quiet + liveness**, not timestamp equality: the device + * streams are damage-driven (a static screen emits no new content), so once the stream has + * been content-quiet for a window AND the pipeline is provably alive (frames or feed + * liveness pings keep arriving), the latest frame *is* the current screen. The tree capture + * ran behind its own settle gate, so a quiet stream around the tree-capture instant means + * frame and tree describe the same screen. + * + * Timestamps close the one hole quiet-detection can't see: the screen changing *after* the + * tree was captured. The tree carries a capture stamp on the "tree clock" — the clock of + * whatever stamped it. On Android that's the device clock + * ([xyz.block.trailblaze.mcp.android.ondevice.rpc.GetScreenStateResponse.capturedAtDeviceMs], + * mapped from host time via a measured offset); on platforms whose tree capture is + * host-initiated (iOS, web) the tree clock IS the host clock and the offset is zero. A + * content change whose tree-clock time is later than the tree stamp (beyond the + * encode/transport latency allowance) means the cached frame shows newer content than the + * tree describes. + * + * All inputs are plain values so the gate is unit-testable without a device, a stream, or a + * clock — the IO wrapper ([StreamFrameMonitor]) samples state and polls this function. + */ +object StreamScreenshotGate { + + sealed interface Decision { + /** No frame has arrived yet (stream still warming up) — keep waiting. */ + data object AwaitFirstFrame : Decision + + /** Screen content changed too recently — keep waiting for the quiet window to elapse. */ + data class AwaitQuiet(val remainingQuietMs: Long) : Decision + + /** + * Neither a frame nor a feed liveness ping has arrived within the stall threshold — the + * capture pipeline is dead, so the cached frame can't be trusted to be current. A static + * screen alone must not land here: damage-driven encoders emit no frames for it, which + * is exactly why feeds report liveness out-of-band (see `lastFeedAliveAtHostMs`). + */ + data class Stalled(val silentForMs: Long) : Decision + + /** + * The screen changed *after* the tree was captured: the latest frame shows newer content + * than the tree describes. Terminal for this tree capture — waiting longer can't fix it. + */ + data class ContentNewerThanTree(val contentChangeAfterTreeMs: Long) : Decision + + /** + * Frame accepted as matching the tree capture. [frameVsTreeSkewMs] is the tree-clock + * delta between the latest frame's receipt and the tree capture (positive = frame + * received after the tree stamp); null when the tree capture wasn't stamped. + */ + data class Accept(val frameVsTreeSkewMs: Long?) : Decision + } + + /** + * @param nowHostMs Host wall-clock now. + * @param lastFrameReceivedAtHostMs Host receipt time of the most recent frame of any kind + * (content changes AND heartbeat repeats). Null when no frame has arrived yet. + * @param lastContentChangeAtHostMs Host receipt time of the most recent frame whose content + * differed from its predecessor. Null when no frame has arrived yet. + * @param lastFeedAliveAtHostMs Host time of the feed's most recent out-of-band proof of + * life — its drain loop confirming the capture pipeline is attached and draining even + * when nothing decodes (a damage-driven encoder emits no frames for a static screen, so + * frame arrival alone can't distinguish "static screen" from "dead pipeline"). Null when + * the feed doesn't report liveness; the last frame receipt then stands in. + * @param treeCapturedAtMs Tree-clock stamp of the tree capture; null when unstamped (e.g. + * an older on-device server — the timestamp check is skipped, dual-quiet still applies). + * @param treeClockOffsetMs Measured `treeClockEpoch - hostEpoch`, so + * `hostMs + offset ≈ treeClockMs`. Zero when the tree stamp is on the host clock. + * @param quietWindowMs How long the stream content must be unchanged before the latest + * frame is considered settled. + * @param stallThresholdMs Max silence (no frames AND no liveness pings) before the stream + * is declared dead. Must comfortably exceed the feed's idle liveness cadence. + * @param latencyAllowanceMs Slack for encode + transport delay when comparing a host-side + * content-change observation against the tree stamp. A change *observed* at host time T + * happened on-screen at T-minus-latency, and the latency is unmeasured. + */ + fun evaluate( + nowHostMs: Long, + lastFrameReceivedAtHostMs: Long?, + lastContentChangeAtHostMs: Long?, + treeCapturedAtMs: Long?, + treeClockOffsetMs: Long, + quietWindowMs: Long, + stallThresholdMs: Long, + latencyAllowanceMs: Long, + lastFeedAliveAtHostMs: Long? = null, + ): Decision { + if (lastFrameReceivedAtHostMs == null) return Decision.AwaitFirstFrame + + val lastSignOfLifeMs = maxOf(lastFrameReceivedAtHostMs, lastFeedAliveAtHostMs ?: Long.MIN_VALUE) + val silentForMs = nowHostMs - lastSignOfLifeMs + if (silentForMs > stallThresholdMs) return Decision.Stalled(silentForMs) + + val lastChangeHostMs = lastContentChangeAtHostMs ?: lastFrameReceivedAtHostMs + val quietForMs = nowHostMs - lastChangeHostMs + if (quietForMs < quietWindowMs) return Decision.AwaitQuiet(quietWindowMs - quietForMs) + + if (treeCapturedAtMs != null) { + val changeTreeClockMs = lastChangeHostMs + treeClockOffsetMs + val changeAfterTreeMs = changeTreeClockMs - latencyAllowanceMs - treeCapturedAtMs + if (changeAfterTreeMs > 0) return Decision.ContentNewerThanTree(changeAfterTreeMs) + } + + val skewMs = treeCapturedAtMs?.let { + (lastFrameReceivedAtHostMs + treeClockOffsetMs) - it + } + return Decision.Accept(skewMs) + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotScreenState.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotScreenState.kt new file mode 100644 index 000000000..291b87212 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotScreenState.kt @@ -0,0 +1,36 @@ +package xyz.block.trailblaze.host.recording + +import xyz.block.trailblaze.api.ScreenState +import xyz.block.trailblaze.setofmark.SetOfMarkAnnotator + +/** + * [ScreenState] whose tree comes from the platform's own capture path but whose screenshot is + * a frame from the live device stream (matched via [StreamFrameMonitor]). Platform-neutral — + * the delegate carries the platform and the device dimensions. + * + * Set-of-mark annotation moves host-side: whatever renderer produced the delegate's + * screenshot never saw this frame, so the stream frame is annotated here with the delegate's + * own [annotationElements] (built from the tree that was just matched against the frame). + * [SetOfMarkAnnotator] scales the element bounds from device coordinates onto the frame's + * actual pixel size, so the stream's downscale doesn't misplace marks. + */ +class StreamScreenshotScreenState( + private val delegate: ScreenState, + private val streamJpegBytes: ByteArray, +) : ScreenState by delegate { + + override val screenshotBytes: ByteArray = streamJpegBytes + + private val _annotatedScreenshotBytes: ByteArray by lazy { + SetOfMarkAnnotator.annotate( + screenshotBytes = streamJpegBytes, + screenWidth = delegate.deviceWidth, + screenHeight = delegate.deviceHeight, + platform = delegate.trailblazeDevicePlatform, + annotationElements = delegate.annotationElements, + ) ?: streamJpegBytes + } + + override val annotatedScreenshotBytes: ByteArray + get() = _annotatedScreenshotBytes +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotSource.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotSource.kt new file mode 100644 index 000000000..975a30eef --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/StreamScreenshotSource.kt @@ -0,0 +1,98 @@ +package xyz.block.trailblaze.host.recording + +import java.util.concurrent.atomic.AtomicBoolean +import xyz.block.trailblaze.capture.DeviceClock +import xyz.block.trailblaze.capture.video.AndroidVideoCapture +import xyz.block.trailblaze.capture.video.H264Tee +import xyz.block.trailblaze.capture.video.LiveFrameConsumer +import xyz.block.trailblaze.devices.TrailblazeDeviceId +import xyz.block.trailblaze.util.Console + +/** + * Android feed adapter for [StreamFrameMonitor]: serves screenshots for host-side screen-state + * captures from the device's live screenrecord stream instead of a per-capture on-device + * `UiAutomation.takeScreenshot`. + * + * Attaches a [LiveFrameConsumer] to the same per-device [H264Tee] the `/devices` live viewer + * uses (same size/bitrate parameters, so the two share one `screenrecord` invocation) and + * forwards each emitted frame — with the consumer's own change-vs-heartbeat classification — + * into the monitor. All matching logic (dual-quiet + timestamp, see [StreamScreenshotGate]) + * lives in the platform-neutral monitor; the Android-specific parts here are the tee + * attachment and the tree-clock offset: Android trees are stamped on the *device* clock, so + * [start] measures `deviceEpoch - hostEpoch` once via `adb shell date` ([DeviceClock]). + * + * Lifecycle: [start] attaches (spawning `screenrecord` if this is the tee's first consumer); + * [close] detaches (reaping it if last). One instance per (agent, device) session. + */ +class StreamScreenshotSource( + private val deviceId: TrailblazeDeviceId, + private val deviceWidth: Int, + private val deviceHeight: Int, +) : AutoCloseable { + + private var monitor: StreamFrameMonitor? = null + private var consumer: LiveFrameConsumer? = null + private val started = AtomicBoolean(false) + private val closed = AtomicBoolean(false) + + /** Measured `deviceEpoch - hostEpoch` (ms), so `hostMs + offset ≈ deviceMs`. */ + var deviceClockOffsetMs: Long = 0L + private set + + /** + * Idempotent. Measures the device clock offset (one `adb shell date` round-trip) and + * attaches the JPEG decoder to the shared tee. Callers should invoke this lazily off the + * first capture rather than at connect time so sessions that never read a screenshot don't + * hold a `screenrecord` open. + */ + fun start() { + if (!started.compareAndSet(false, true)) return + deviceClockOffsetMs = DeviceClock.nowMs(deviceId.instanceId) - System.currentTimeMillis() + val frameMonitor = StreamFrameMonitor(treeClockOffsetMs = deviceClockOffsetMs) + monitor = frameMonitor + val tee = H264Tee.forDevice( + deviceId = deviceId, + videoSize = AndroidVideoCapture.scaleToRecordingSize(deviceWidth, deviceHeight), + bitRate = STREAM_BIT_RATE, + ) + val liveConsumer = LiveFrameConsumer( + tee = tee, + onFrame = frameMonitor::recordFrame, + // screenrecord is damage-driven: a static screen emits no frames at all, so the drain + // loop's liveness pings are what keep the gate from misreading quiet as a dead stream. + onFeedAlive = frameMonitor::recordFeedAlive, + ) + consumer = liveConsumer + liveConsumer.start() + Console.log( + "[stream-screenshot] attached to ${deviceId.instanceId} " + + "(deviceClockOffsetMs=$deviceClockOffsetMs)", + ) + } + + /** See [StreamFrameMonitor.awaitFrameMatching]; [treeCapturedAtDeviceMs] is device-epoch. */ + suspend fun awaitFrameMatching( + treeCapturedAtDeviceMs: Long?, + timeoutMs: Long, + ): StreamFrameMonitor.Result { + if (closed.get()) return StreamFrameMonitor.Result.Unavailable("source closed") + val frameMonitor = monitor + ?: return StreamFrameMonitor.Result.Unavailable("source not started") + return frameMonitor.awaitFrameMatching( + treeCapturedAtMs = treeCapturedAtDeviceMs, + timeoutMs = timeoutMs, + ) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + runCatching { consumer?.stop() } + consumer = null + Console.log("[stream-screenshot] detached from ${deviceId.instanceId}") + } + + private companion object { + /** Matches the live-viewer tee parameters so both consumers share one screenrecord. */ + const val STREAM_BIT_RATE = "4000000" + } +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DeviceApiEndpoint.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DeviceApiEndpoint.kt index ee2124f0d..f68ed7493 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DeviceApiEndpoint.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DeviceApiEndpoint.kt @@ -1,7 +1,11 @@ package xyz.block.trailblaze.host.recording.rpc import io.ktor.server.routing.Routing +import io.ktor.server.websocket.webSocket import io.ktor.util.encodeBase64 +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close import java.security.MessageDigest import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CancellationException @@ -17,6 +21,8 @@ import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import xyz.block.trailblaze.compose.driver.rpc.RpcWsHandlerRegistry import xyz.block.trailblaze.compose.driver.rpc.WsSessionContext import xyz.block.trailblaze.compose.driver.rpc.registerRpcWebSocket @@ -47,10 +53,15 @@ import xyz.block.trailblaze.host.rpc.ws.SubscribeFramesRequest import xyz.block.trailblaze.host.rpc.ws.SubscribeFramesResponse import xyz.block.trailblaze.host.rpc.ws.UnsubscribeFramesRequest import xyz.block.trailblaze.host.rpc.ws.UnsubscribeFramesResponse -import xyz.block.trailblaze.capture.video.AndroidVideoCapture +import xyz.block.trailblaze.capture.video.H264AccessUnit import xyz.block.trailblaze.capture.video.H264Tee import xyz.block.trailblaze.capture.video.LiveFrameConsumer import xyz.block.trailblaze.devices.TrailblazeDevicePlatform +import xyz.block.trailblaze.host.recording.IosBaguetteServer +import xyz.block.trailblaze.host.recording.streamAndroidLiveJpegFrames +import xyz.block.trailblaze.host.recording.streamAndroidLiveH264AccessUnits +import xyz.block.trailblaze.host.recording.streamIosLiveH264AccessUnits +import xyz.block.trailblaze.playwright.recording.PlaywrightDeviceScreenStream import xyz.block.trailblaze.mcp.android.ondevice.rpc.RpcResult import xyz.block.trailblaze.ui.TrailblazeDeviceManager import xyz.block.trailblaze.util.Console @@ -87,6 +98,8 @@ import xyz.block.trailblaze.util.Console * * WebSocket (single connection, dispatches all the above + the streaming RPCs): * /rpc-ws — multiplexed RPC channel. See [RpcWsEnvelope] for the wire format. + * /devices/api/stream — fast binary frames: Android Annex-B H.264 (browser WebCodecs) or + * Web JPEG (Chromium CDP screencast). Falls back to SubscribeFrames. * /rpc/SubscribeFramesRequest — start server-pushed frames (WS-only — no HTTP route). * /rpc/UnsubscribeFramesRequest — stop server-pushed frames (WS-only — no HTTP route). */ @@ -157,6 +170,117 @@ object DeviceApiEndpoint { registry = wsRegistry, json = RPC_WS_JSON, ) + + // Fast mirror path, Trailblaze-owned end to end. Live streaming is the default for every + // primary platform over this one socket; no ws-scrcpy process, external proxy, or second + // service port is required. Three encoders feed it: + // - Android: the shared `screenrecord` H.264 encoder → binary Annex-B access units. + // - iOS: a `baguette` subprocess whose avcc output is converted to the identical Annex-B wire + // (see streamIosLiveH264AccessUnits), so it shares Android's exact browser WebCodecs path. + // - Web (Playwright/Chromium): binary JPEG frames from a CDP screencast. + // The generic RPC socket above stays available for controls and the JPEG-poll fallback + // (SubscribeFrames). iOS streaming is optional: when baguette isn't installed the endpoint + // declines here and the browser falls back to JPEG polling. + webSocket("/devices/api/stream") { + val instanceId = call.request.queryParameters["instanceId"]?.takeIf { it.isNotBlank() } + val platform = + call.request.queryParameters["platform"] + // Locale.ROOT: the platform param is a protocol enum, not user-facing text; a + // Turkish-locale default would lowercase-fold "I" to a dotless "ı" and break valueOf. + ?.uppercase(java.util.Locale.ROOT) + ?.let { runCatching { TrailblazeDevicePlatform.valueOf(it) }.getOrNull() } + if (instanceId == null || platform == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "instanceId and platform are required")) + return@webSocket + } + when (platform) { + TrailblazeDevicePlatform.ANDROID, + TrailblazeDevicePlatform.WEB -> Unit + TrailblazeDevicePlatform.IOS -> + if (!IosBaguetteServer.isAvailable()) { + // No baguette binary: decline so the browser degrades to the JPEG poll rather than + // hang. `brew install baguette` (macOS + Apple Silicon) enables the live H.264 path. + Console.log( + "[devices-stream] iOS live stream declined for $instanceId; baguette not installed — " + + "browser will fall back to JPEG polling", + ) + close( + CloseReason( + CloseReason.Codes.CANNOT_ACCEPT, + "iOS H.264 streaming requires baguette (brew install baguette)", + ), + ) + return@webSocket + } + else -> { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "live stream not supported for $platform")) + return@webSocket + } + } + val deviceId = xyz.block.trailblaze.devices.TrailblazeDeviceId(instanceId, platform) + val stream = sessionManager.get(deviceId) + if (stream == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "device is not connected")) + return@webSocket + } + // The web fast path needs a Playwright-backed page to drive the CDP screencast; any + // other web stream shape has to use the JPEG-poll fallback, so refuse here cleanly. + val webStream = if (platform == TrailblazeDevicePlatform.WEB) { + stream as? PlaywrightDeviceScreenStream + ?: run { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "web stream is not Playwright-backed")) + return@webSocket + } + } else { + null + } + + val format = if (platform == TrailblazeDevicePlatform.WEB) "jpeg" else "annexb" + outgoing.send( + Frame.Text( + buildJsonObject { + put("type", "configuration") + put("deviceWidth", stream.deviceWidth) + put("deviceHeight", stream.deviceHeight) + put("format", format) + } + .toString(), + ), + ) + val heartbeat = launch { + while (isActive) { + delay(H264_HEARTBEAT_INTERVAL_MS) + outgoing.send(Frame.Text("{\"type\":\"heartbeat\"}")) + } + } + val onAccessUnit: suspend (H264AccessUnit) -> Unit = { accessUnit -> + outgoing.send(Frame.Binary(fin = true, data = accessUnit.bytes)) + } + try { + when { + webStream != null -> + webStream.streamScreencastJpegFrames { jpegBytes -> + outgoing.send(Frame.Binary(fin = true, data = jpegBytes)) + } + platform == TrailblazeDevicePlatform.IOS -> + streamIosLiveH264AccessUnits(deviceId = deviceId, onAccessUnit = onAccessUnit) + else -> + streamAndroidLiveH264AccessUnits( + deviceId = deviceId, + deviceWidth = stream.deviceWidth, + deviceHeight = stream.deviceHeight, + onAccessUnit = onAccessUnit, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Console.log("[devices-stream] $format stream failed for ${deviceId.toFullyQualifiedDeviceId()}: ${e.message}") + close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, "device stream failed")) + } finally { + heartbeat.cancel() + } + } } } @@ -190,8 +314,9 @@ object DeviceApiEndpoint { * `xcrun simctl io recordVideo` and Playwright to `Page.startScreencast` to match the * Android shape; not done in this change. * - * Common to both branches: content-hash dedup against the last hash sent to *this* subscriber, - * so a still screen produces no wire traffic. + * Both branches content-deduplicate unchanged frames. The Android stream emits a lightweight + * periodic repeat so the browser can distinguish a still screen from a stalled decoder; the + * polling branch relies on its existing activity-aware cadence and browser fallback. * * The producer is registered with the session context under `"frames:"`, so: * - re-subscribing the same device replaces the prior producer (no leak), and @@ -220,23 +345,26 @@ private fun startFrameSubscription( val deviceHeight = stream.deviceHeight val key = req.trailblazeDeviceId.toFullyQualifiedDeviceId() - // All platforms currently use the polling subscription path. The H.264 streaming - // alternative (see [launchAndroidH264JpegSubscription]) is structurally blocked by - // ffmpeg-as-subprocess output buffering on live pipes — frames never flush from the - // mjpeg muxer until clean EOF, so JPEGs accumulate in ffmpeg's mux buffer indefinitely - // and the wasm watchdog gives up before any frame arrives. The polling path with the - // recent `getMirrorScreenshot` (tree-less) fast path delivers ~15-25 fps on Android - // without any of the H.264 subprocess complexity. We keep [launchAndroidH264JpegSubscription] - // around as a reference for when a JCodec or on-device-server approach replaces ffmpeg. - val job = launchLegacyPollingSubscription( - stream = stream, - req = req, - ctx = ctx, - deviceManager = deviceManager, - deviceWidth = deviceWidth, - deviceHeight = deviceHeight, - key = key, - ) + val job = + if (req.trailblazeDeviceId.trailblazeDevicePlatform == TrailblazeDevicePlatform.ANDROID) { + launchAndroidH264JpegSubscription( + req = req, + ctx = ctx, + deviceWidth = deviceWidth, + deviceHeight = deviceHeight, + key = key, + ) + } else { + launchLegacyPollingSubscription( + stream = stream, + req = req, + ctx = ctx, + deviceManager = deviceManager, + deviceWidth = deviceWidth, + deviceHeight = deviceHeight, + key = key, + ) + } ctx.replacePushTask("frames:$key", job) return RpcResult.Success( @@ -254,8 +382,8 @@ private fun startFrameSubscription( * * No polling cadence on this path — the screenrecord encoder produces frames at its own rate * and we emit them as they arrive. The activity-driven idle clamp (PR #3018) is moot here - * because there's no poll loop to clamp; dedup-via-SHA-256 in [LiveFrameConsumer] already - * suppresses identical frames so a still screen produces no wire traffic. + * because there's no poll loop to clamp; dedup-via-SHA-256 in [LiveFrameConsumer] suppresses + * identical frames between periodic proof-of-life repeats. */ private fun launchAndroidH264JpegSubscription( req: SubscribeFramesRequest, @@ -264,40 +392,15 @@ private fun launchAndroidH264JpegSubscription( deviceHeight: Int, key: String, ): kotlinx.coroutines.Job { - // Use the same encoder params AndroidVideoCapture uses, so a concurrent MP4 capture shares - // the same tee instance. If sizes diverge between callers, H264Tee.forDevice logs and uses - // whichever attached first. - val videoSize = AndroidVideoCapture.scaleToRecordingSize(deviceWidth, deviceHeight) - val tee = H264Tee.forDevice(req.trailblazeDeviceId, videoSize = videoSize, bitRate = "4000000") return ctx.pushScope.launch { var sentFrames = 0L var lastLogMillis = 0L - // Single bounded queue between the decoder's drain thread (producer) and the WS sender - // coroutine (consumer). Capacity 1 + DROP_OLDEST: at most one frame waits to go on the - // wire; if a new frame arrives while the wire is still busy, we drop the older queued - // one. The user sees the freshest frame the device produced, not a backlog. - // - // Replaces a previous `ctx.pushScope.launch { ctx.emit(event) }` per frame, which would - // spawn unbounded coroutines (each holding ~base64-of-1080p bytes) when the socket - // writer fell behind, and orphan stale frames after a subscription replace because - // those launches weren't children of the subscription job. Review feedback on PR #3021 - // caught both. Channel + sender below is owned by *this* job, so cancel propagates. - val outbound = Channel( - capacity = 1, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, - ) - val senderJob = launch { - try { - for (event in outbound) { - ctx.emit(event) - } - } catch (_: CancellationException) { - // normal teardown - } - } - val consumer = LiveFrameConsumer( - tee = tee, - onFrame = { jpegBytes -> + try { + streamAndroidLiveJpegFrames( + deviceId = req.trailblazeDeviceId, + deviceWidth = deviceWidth, + deviceHeight = deviceHeight, + ) { jpegBytes -> val cycleStart = System.currentTimeMillis() val event = RpcWsEnvelope.Event( path = FrameEvent.EVENT_PATH, @@ -312,30 +415,20 @@ private fun launchAndroidH264JpegSubscription( ), ), ) - // trySend never suspends — drops oldest queued frame if the channel is full. The - // drain thread is non-coroutine, so we couldn't suspend here even if we wanted to. - outbound.trySend(event) + ctx.emit(event) sentFrames++ if (cycleStart - lastLogMillis >= 1000L) { Console.log("[FrameProducer/android-h264] device=$key sent=$sentFrames") lastLogMillis = cycleStart } - }, - ) - consumer.start() - try { - // Park until the coroutine is cancelled (socket close, re-subscribe). - while (isActive) { - delay(1_000) } } catch (_: CancellationException) { // normal teardown - } finally { - withContext(NonCancellable) { - runCatching { consumer.stop() } - outbound.close() - senderJob.cancel() - } + } catch (e: Exception) { + // Keep the multiplexed RPC socket alive if screenrecord or ffmpeg cannot start. The + // browser's frame-stall watchdog will fall back to snapshot polling, while controls + // and unrelated RPCs continue using the existing WebSocket. + Console.log("[FrameProducer/android-h264] stream failed for $key: ${e.message}") } } } @@ -531,6 +624,9 @@ private fun launchLegacyPollingSubscription( */ private const val IDLE_INTERVAL_MS = 1000L +/** Keeps the browser's stall watchdog alive while Android suppresses unchanged video frames. */ +private const val H264_HEARTBEAT_INTERVAL_MS = 1_000L + /** * Consecutive duplicate hashes before the loop slows down. 5 dedups at the 200 ms active * cadence is ~1 s of stillness — long enough to skip transient pauses (e.g. an animation diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DevicesPageEndpoint.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DevicesPageEndpoint.kt index 276f4f387..e1b78944a 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DevicesPageEndpoint.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/DevicesPageEndpoint.kt @@ -1,30 +1,20 @@ package xyz.block.trailblaze.host.recording.rpc import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode +import io.ktor.http.HttpHeaders import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respondText import io.ktor.server.routing.Routing import io.ktor.server.routing.get -import xyz.block.trailblaze.report.ReportTemplateResolver /** - * Serves the Compose/WASM device viewer at the `/devices` URL (and every sub-path). + * Serves the lightweight standalone device mirror at `/devices` (and every sub-path). * - * The page reuses the bundled report template ([ReportTemplateResolver.resolveTemplate]) - * — a single self-contained HTML file with `composeApp.js` and every `.wasm` binary - * inlined as gzip+base64 strings, plus a runtime loader that intercepts the WASM - * bundle's `fetch` / `XMLHttpRequest` / `WebAssembly.instantiateStreaming` calls and - * serves them from memory. The browser never fetches anything else under `/devices`, - * so no separate JS/WASM routes are needed. - * - * The Compose entry point ([xyz.block.trailblaze.ui.main]) branches on - * `window.location.pathname`: bare `/devices` renders the single-device picker - * ([xyz.block.trailblaze.ui.devices.WebDevicesPage]), and `/devices/all` renders the - * multi-device live grid ([xyz.block.trailblaze.ui.devices.WebDevicesGridPage]). Both - * sub-paths get the same HTML bundle — the routing branch is client-side. The empty - * `window.trailblaze_report_compressed` placeholder shipped in the template is fine — - * the devices views don't read session data. + * This deliberately does not depend on the report/Compose WASM bundle. The self-contained HTML + * resource talks directly to the daemon's existing host-device RPC API: HTTP for discovery, + * connect, and input; `/rpc-ws` for server-pushed frames. That makes mirroring available from a + * source build without `-Ptrailblaze.wasm=true` and keeps it outside the main Trail Runner UX. + * Bare `/devices` renders a single-device picker; `/devices/all` renders the connected-device grid. * * Registration mirrors [xyz.block.trailblaze.graph.WaypointGraphEndpoint] — lives in * trailblaze-host and is injected into the server via `additionalRouteRegistration`. @@ -34,31 +24,22 @@ object DevicesPageEndpoint { private const val PATH = "/devices" /** - * Sub-path matcher that lets the client-side router (`main.kt`) own URLs like - * `/devices/all`. Ktor matches the longer pattern in addition to the bare `/devices`, - * so a direct hit on either form serves the same WASM bundle and the in-bundle - * pathname branch decides which page to render. + * Sub-path matcher that lets the page own URLs like `/devices/all`. Ktor matches the longer + * pattern in addition to bare `/devices`; the page chooses its layout from `location.pathname`. * * **Ordering hazard:** this wildcard catches *every* `/devices/`. If a future * HTTP-layer route like `/devices/api/...` or `/devices/rpc/...` is ever added under the * same routing tree, declare it **before** this wildcard or it will be shadowed. - * Routes under `/devices/` that only need client-side rendering should keep adding their - * paths to the in-bundle pathname branch in `main.kt` — they don't need new HTTP routes - * here. + * Routes under `/devices/` that only need client-side rendering do not need new HTTP routes. */ private const val SUBPATH = "/devices/{...}" fun register(routing: Routing) { - // Single handler body, called from both routes — the only difference between the bare - // `/devices` and the `/devices/{...}` wildcard registration is the pattern. Pulling the - // body out keeps future template/error changes applying uniformly. + // Single handler body for bare `/devices` and `/devices/{...}`. The resource is packaged in + // trailblaze-host, so the endpoint works identically from the source launcher and release JAR. val serveBundle: suspend ApplicationCall.() -> Unit = { - val template = ReportTemplateResolver.resolveTemplate() - if (template == null) { - respondText(missingBundleMessage(), ContentType.Text.Plain, HttpStatusCode.NotFound) - } else { - respondText(text = template.readText(), contentType = ContentType.Text.Html) - } + response.headers.append(HttpHeaders.CacheControl, "no-store") + respondText(text = standaloneMirrorHtml, contentType = ContentType.Text.Html) } routing.apply { get(PATH) { call.serveBundle() } @@ -66,8 +47,10 @@ object DevicesPageEndpoint { } } - private fun missingBundleMessage(): String = - "WASM bundle not found. Rebuild with `-Ptrailblaze.wasm=true` (or " + - "`BUNDLE_WASM=true ./scripts/install-trailblaze-source.sh`) so the report " + - "template is bundled into the uber JAR." + private val standaloneMirrorHtml: String by lazy { + requireNotNull(DevicesPageEndpoint::class.java.getResource("/xyz/block/trailblaze/devices/index.html")) { + "Missing standalone device mirror resource" + } + .readText() + } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/HostDeviceSessionManager.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/HostDeviceSessionManager.kt index b52f5476a..4eb4ba0b8 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/HostDeviceSessionManager.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/recording/rpc/HostDeviceSessionManager.kt @@ -13,24 +13,32 @@ import java.util.concurrent.ConcurrentHashMap * here so that subsequent [GetHostDeviceScreenHandler] and [DeviceInteractionHandler] calls * can reach the already-running connection without reconnecting. * + * Ownership: sessions created through [connectIfAbsent] are owned by this manager ([remove] + * closes them). Sessions published through [attach] are owned by the caller — this manager + * never closes them, whichever removal path drops the entry. + * * Thread-safety: * - [get], [isConnected]: plain [ConcurrentHashMap] reads — no lock needed. * - [connectIfAbsent]: per-device [Mutex] prevents two concurrent connect calls from both - * racing past the "already connected?" check and spinning up duplicate streams. - * - [remove]: removes from the map and closes the stream if it implements [AutoCloseable]. + * racing past the "already connected?" check. Its final publication is atomic with [attach], + * which can publish an externally-owned stream while physical connection setup is suspended. + * - [remove]: removes from the map and closes the stream if this manager owns it. */ class HostDeviceSessionManager { - private val sessions = ConcurrentHashMap() + private class Session(val stream: DeviceScreenStream, val externallyOwned: Boolean) + + private val sessions = ConcurrentHashMap() private val connectMutexes = ConcurrentHashMap() - fun get(deviceId: TrailblazeDeviceId): DeviceScreenStream? = sessions[deviceId] + fun get(deviceId: TrailblazeDeviceId): DeviceScreenStream? = sessions[deviceId]?.stream /** * Returns the existing stream for [deviceId] if one is already connected, otherwise - * calls [connect] to produce a new stream, stores it, and returns it. The check-then-act - * is protected by a per-device [Mutex] so only one connect attempt can run at a time for - * any given device. + * calls [connect] to produce a new stream, stores it, and returns it. The check-then-act is + * protected by a per-device [Mutex] so only one connect attempt can run at a time for any given + * device. If [attach] publishes first while [connect] is suspended, its stream wins and the + * unused candidate is closed. */ suspend fun connectIfAbsent( deviceId: TrailblazeDeviceId, @@ -38,13 +46,51 @@ class HostDeviceSessionManager { ): DeviceScreenStream? { val mutex = connectMutexes.getOrPut(deviceId) { Mutex() } return mutex.withLock { - sessions[deviceId] ?: connect()?.also { sessions[deviceId] = it } + sessions[deviceId]?.stream ?: run { + val candidate = connect() ?: return@withLock sessions[deviceId]?.stream + val winner = sessions.putIfAbsent(deviceId, Session(candidate, externallyOwned = false)) + if (winner == null) { + candidate + } else { + if (candidate !== winner.stream) (candidate as? AutoCloseable)?.close() + winner.stream + } + } } } + /** + * Publishes an already-open, **externally-owned** [stream] for [deviceId] so the streaming and + * screen-poll handlers can reach it, without this manager taking over its lifecycle. Used by + * Trail Runner's recorder, which holds the connection (and its interaction tool factory) in its + * own registry and closes it itself — see [detach]. + * + * If a session is already registered for [deviceId] (e.g. a viewer-owned one from + * [connectIfAbsent]) this is a no-op: clobbering it would leak the displaced stream, and the + * existing one serves the same device's pixels anyway. That also makes re-attaching on every + * recorder connect safe, which is how the recorder self-heals after a viewer-side [remove]. + */ + fun attach(deviceId: TrailblazeDeviceId, stream: DeviceScreenStream) { + sessions.putIfAbsent(deviceId, Session(stream, externallyOwned = true)) + } + + /** + * Removes [deviceId]'s **externally-owned** entry ([attach]) from the registry **without** + * closing its stream — the caller owns the lifecycle. A manager-owned session ([connectIfAbsent]) + * in the same slot is left untouched: it belongs to the viewer path, not the detaching caller. + */ + fun detach(deviceId: TrailblazeDeviceId) { + sessions.computeIfPresent(deviceId) { _, session -> if (session.externallyOwned) null else session } + } + + /** + * Drops [deviceId] from the registry, closing the stream only when this manager owns it (the + * [connectIfAbsent] path). An externally-owned entry is removed without closing — its owner + * (Trail Runner's recorder) keeps using it and re-publishes via [attach] on its next connect. + */ fun remove(deviceId: TrailblazeDeviceId) { - val stream = sessions.remove(deviceId) - (stream as? AutoCloseable)?.close() + val session = sessions.remove(deviceId) ?: return + if (!session.externallyOwned) (session.stream as? AutoCloseable)?.close() } fun isConnected(deviceId: TrailblazeDeviceId): Boolean = sessions.containsKey(deviceId) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseComposeTest.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseComposeTest.kt index fd0b36b58..4d7e0b94f 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseComposeTest.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseComposeTest.kt @@ -162,7 +162,10 @@ class BaseComposeTest( sharedToolBatch = { block -> agent.runInSharedToolBatch(block) }, ) - val trailItems: List = trailblazeYaml.decodeTrail( + // decodeTrailOrToolEnvelope (superset of decodeTrail): a trail document decodes identically; a + // bare `- :` envelope (single-tool dispatch) additionally decodes to one ToolTrailItem. + // Kept consistent with the other host runner-rule decoders that receive dispatched YAML. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope( yaml, deviceClassifiers = trailblazeDeviceInfo.classifiers, ) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt index 5f7527bea..8f4a1dd5e 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt @@ -612,7 +612,11 @@ abstract class BaseHostTrailblazeTest( // Resolve device classifiers BEFORE decoding so a v3 trail lowers with the // right closest-wins recording for this device. v1 inputs ignore the list. val classifiers = loggingRule.trailblazeDeviceInfoProvider().classifiers - val trailItems: List = trailblazeYaml.decodeTrail(yaml, deviceClassifiers = classifiers) + // decodeTrailOrToolEnvelope (superset of decodeTrail): a trail document decodes identically; a + // bare `- :` envelope (single-tool MCP/CLI dispatch on host Maestro / iOS-host) + // additionally decodes to one ToolTrailItem. Host-runner single-tool dispatch now sends the bare + // envelope, not the legacy `- tools:` list shape. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope(yaml, deviceClassifiers = classifiers) val trailConfig = trailblazeYaml.extractTrailConfig(trailItems) // Honor `config.skip:` before SessionStarted is logged — matches the CLI's pre-flight diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightElectronTest.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightElectronTest.kt index d40bded8d..f48a4c042 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightElectronTest.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightElectronTest.kt @@ -262,7 +262,10 @@ class BasePlaywrightElectronTest( ): SessionId = withContext(browserManager.playwrightDispatcher) { playwrightAgent.workingDirectory = trailFilePath?.let { java.io.File(it).absoluteFile.parentFile } - val trailItems: List = trailblazeYaml.decodeTrail( + // decodeTrailOrToolEnvelope (superset of decodeTrail): a trail document decodes identically; a + // bare `- :` envelope (single-tool MCP/CLI dispatch) additionally decodes to one + // ToolTrailItem. Host-runner single-tool dispatch now sends the bare envelope, not `- tools:`. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope( yaml, deviceClassifiers = trailblazeDeviceInfo.classifiers, ) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightNativeTest.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightNativeTest.kt index 2bcd6cfce..94bb6cbc0 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightNativeTest.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BasePlaywrightNativeTest.kt @@ -333,7 +333,11 @@ open class BasePlaywrightNativeTest( // up the new dir (or drops recording on the next trail if capture is disabled). (browserManager as? PlaywrightBrowserManager)?.syncRecordingWithRegistry() - val trailItems: List = trailblazeYaml.decodeTrail( + // decodeTrailOrToolEnvelope (superset of decodeTrail): a trail document decodes identically; a + // bare `- :` envelope (single-tool MCP/CLI dispatch, e.g. `trailblaze tool`) additionally + // decodes to one ToolTrailItem. Required because host-runner single-tool dispatch now sends the + // bare envelope instead of the legacy `- tools:` list shape. + val trailItems: List = trailblazeYaml.decodeTrailOrToolEnvelope( yaml, deviceClassifiers = trailblazeDeviceInfo.classifiers, ) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/yaml/DesktopYamlRunner.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/yaml/DesktopYamlRunner.kt index c554d16a7..51d204d50 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/yaml/DesktopYamlRunner.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/yaml/DesktopYamlRunner.kt @@ -5,7 +5,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import xyz.block.trailblaze.cli.DeviceClassifierResolver import xyz.block.trailblaze.devices.TrailblazeConnectedDeviceSummary +import xyz.block.trailblaze.devices.TrailblazeDeviceClassifier import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import xyz.block.trailblaze.devices.TrailblazeDriverType import xyz.block.trailblaze.exception.TrailblazeSessionCancelledException @@ -39,6 +41,7 @@ import xyz.block.trailblaze.util.HostAndroidDeviceConnectUtils import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.util.UiAutomationHandleErrors import xyz.block.trailblaze.devices.TrailblazeDeviceId +import xyz.block.trailblaze.yaml.createTrailblazeYaml import java.io.File import java.io.IOException import java.util.concurrent.ConcurrentHashMap @@ -98,6 +101,26 @@ class DesktopYamlRunner( UiAutomationHandleErrors.isNonRecoverableStaleHandleSignature(log.exceptionMessage) } } + + /** + * The driver [trailYaml] pins for the device described by [deviceClassifiers], or null when + * it pins none reachable from that device's classifier chain. Covers both formats: a v1 + * `driver:` scalar (classifier-independent) and a unified per-classifier `devices:` map + * (closest-wins). A YAML that fails to parse resolves to null — the trail decode inside the + * run surfaces the real error. + * + * This is the runner's own read of the pin because upstream request builders (the daemon's + * `/cli/run` handler, the desktop Run path) extract trail config without a device and so + * send `RunYamlRequest.driverType = null` for every unified trail; the connected device is + * only concrete here. + */ + internal fun trailPinnedDriverType( + trailYaml: String, + deviceClassifiers: List, + ): TrailblazeDriverType? = runCatching { + createTrailblazeYaml().extractTrailConfig(trailYaml, deviceClassifiers) + ?.driver?.let { TrailblazeDriverType.fromString(it) } + }.getOrNull() } /** @@ -215,15 +238,33 @@ class DesktopYamlRunner( MobileDeviceUtils.ensureAppsAreForceStopped(possibleAppIds, trailblazeDeviceId) } - // Resolve driver type: request (CLI --driver / trail config) > app setting > connected device default. + // Resolve driver type: request (CLI --driver / trail config) > the trail's own driver pin + // resolved against THIS device > app setting > connected device default. The trail-pin rung + // matches the CLI in-process precedence (trail config over app setting) — see + // [trailPinnedDriverType] for why unified pins can only resolve here. val appConfig = trailblazeDeviceManager.settingsRepo.serverStateFlow.value.appConfig val appSettingDriverType = appConfig.selectedTrailblazeDriverTypes[ trailblazeDeviceId.trailblazeDevicePlatform ] val trailblazeDriverType = runYamlRequest.driverType + ?: trailPinnedDriverType( + trailYaml = runYamlRequest.yaml, + deviceClassifiers = DeviceClassifierResolver.classifiersFor( + platform = connectedTrailblazeDevice.platform, + instanceId = connectedTrailblazeDevice.instanceId, + ), + ) ?: appSettingDriverType ?: connectedTrailblazeDevice.trailblazeDriverType + // The host runner selects its driver from `RunOnHostParams.trailblazeDriverType`, which + // derives from the device summary — so a host-side pin (e.g. a unified trail pinning iOS + // Axe over the simulator's default IOS_HOST) is only honored if the device carries the + // resolved driver. Tag it here; the on-device branches propagate the same value via + // `runYamlRequest.copy(driverType = …)`. The swap is same-platform (resolution is + // platform-scoped), so instanceId / platform / deviceId are unchanged. + val hostRunDevice = connectedTrailblazeDevice.copy(trailblazeDriverType = trailblazeDriverType) + // Per-session video / sprite / logcat capture used to be started here against a // temp dir and moved into the session log dir in the finally block. That worked // for the CLI/daemon path but bypassed every MCP-driven session — the `step`, @@ -327,7 +368,7 @@ class DesktopYamlRunner( dynamicLlmClient = dynamicLlmClientProvider(runYamlRequest.trailblazeLlmModel), runOnHostParams = RunOnHostParams( runYamlRequest = runYamlRequest, - device = connectedTrailblazeDevice, + device = hostRunDevice, onProgressMessage = prefixedProgressMessage, forceStopTargetApp = forceStopTargetApp, targetTestApp = targetTestApp, @@ -463,7 +504,7 @@ class DesktopYamlRunner( dynamicLlmClient = dynamicLlmClientProvider(desktopAppRunYamlParams.runYamlRequest.trailblazeLlmModel), runOnHostParams = RunOnHostParams( runYamlRequest = runYamlRequest, - device = connectedTrailblazeDevice, + device = hostRunDevice, onProgressMessage = prefixedProgressMessage, forceStopTargetApp = forceStopTargetApp, targetTestApp = targetTestApp, diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt index 14ff8f31c..0f0242005 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt @@ -67,6 +67,7 @@ import xyz.block.trailblaze.compose.driver.rpc.ExecuteToolsRequest as ComposeExe import xyz.block.trailblaze.compose.driver.rpc.GetScreenStateResponse as ComposeGetScreenStateResponse import xyz.block.trailblaze.compose.driver.tools.ComposeToolSetIds import xyz.block.trailblaze.devices.TrailblazeDevicePort +import xyz.block.trailblaze.host.OnDeviceRpcClientPool import xyz.block.trailblaze.host.networkcapture.AndroidNetworkCaptureRegistry import xyz.block.trailblaze.host.networkcapture.CompositeAndroidNetworkCaptureActivator import xyz.block.trailblaze.host.rules.BasePlaywrightNativeTest @@ -85,7 +86,7 @@ import xyz.block.trailblaze.util.AndroidHostAdbUtils import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.util.HostAndroidDeviceConnectUtils import xyz.block.trailblaze.yaml.createTrailblazeYaml -import xyz.block.trailblaze.yaml.models.TrailblazeYamlBuilder +import xyz.block.trailblaze.yaml.fromTrailblazeTool import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -170,6 +171,20 @@ class TrailblazeMcpBridgeImpl( */ private val persistentDevices = ConcurrentHashMap() + /** + * One host-to-runner RPC client per Android device. The bridge lives in the daemon, so this + * keeps its WebSocket open across separate CLI `snapshot`, `tool`, and `step` requests instead + * of reconnecting once per command. + */ + private val onDeviceRpcClients = OnDeviceRpcClientPool { deviceId -> + OnDeviceRpcClient( + trailblazeDeviceId = deviceId, + sendProgressMessage = { + Console.log("[MCP Bridge] [rpc ${deviceId.instanceId}] $it") + }, + ) + } + /** Per-device locks to prevent two threads from simultaneously opening Maestro connections. */ private val persistentDeviceLocks = ConcurrentHashMap() @@ -507,6 +522,15 @@ class TrailblazeMcpBridgeImpl( } companion object { + internal fun androidDisconnectStatus( + deviceId: TrailblazeDeviceId, + connectedDevices: Collection, + ): String? { + if (deviceId.trailblazeDevicePlatform != TrailblazeDevicePlatform.ANDROID) return null + if (deviceId in connectedDevices) return null + return "Android device '${deviceId.instanceId}' is no longer connected to adb. Reconnect it and retry." + } + /** * Pure expansion helper: synthesizes the [TrailblazeToolExecutionContext] needed by * `DelegatingTrailblazeTool.toExecutableTrailblazeTools` and returns the flattened @@ -703,6 +727,7 @@ class TrailblazeMcpBridgeImpl( // drop) is logged and swallowed; teardown must not block on a remote that's likely already // gone. Skipped on non-Android because the on-device server only ships in the test APK. drainOnDeviceSessionBestEffort(deviceId) + onDeviceRpcClients.evict(deviceId) // Synchronize on the same lock used by selectDevice() to prevent a race where // another thread creates a new connection between our remove and the lock cleanup. // We intentionally never remove from persistentDeviceLocks — they are tiny Any objects @@ -735,10 +760,7 @@ class TrailblazeMcpBridgeImpl( */ private fun drainOnDeviceSessionBestEffort(deviceId: TrailblazeDeviceId) { if (deviceId.trailblazeDevicePlatform != TrailblazeDevicePlatform.ANDROID) return - val rpcClient = OnDeviceRpcClient( - trailblazeDeviceId = deviceId, - sendProgressMessage = { Console.log("[MCP Bridge] [drain ${deviceId.instanceId}] $it") }, - ) + val rpcClient = onDeviceRpcClients.get(deviceId) try { runBlocking { when (val result = rpcClient.rpcCall(DrainSessionRequest(reason = "host_close_persistent_device"))) { @@ -759,8 +781,6 @@ class TrailblazeMcpBridgeImpl( "[MCP Bridge] Drain RPC threw for ${deviceId.instanceId} " + "(${t::class.java.simpleName}: ${t.message}) — continuing teardown", ) - } finally { - rpcClient.close() } } @@ -840,19 +860,11 @@ class TrailblazeMcpBridgeImpl( // not merely that a PID exists). fun awaitReady(timeoutMs: Long) { runBlocking { - val rpcClient = OnDeviceRpcClient( - trailblazeDeviceId = trailblazeDeviceId, - sendProgressMessage = { Console.log("[MCP Bridge] [$key] $it") }, + onDeviceRpcClients.get(trailblazeDeviceId).waitForReady( + timeoutMs = timeoutMs, + requireAndroidAccessibilityService = + driverType == TrailblazeDriverType.ANDROID_ONDEVICE_ACCESSIBILITY, ) - try { - rpcClient.waitForReady( - timeoutMs = timeoutMs, - requireAndroidAccessibilityService = - driverType == TrailblazeDriverType.ANDROID_ONDEVICE_ACCESSIBILITY, - ) - } finally { - rpcClient.close() - } } } @@ -1095,6 +1107,14 @@ class TrailblazeMcpBridgeImpl( val id = deviceId ?: getEffectiveDeviceId() ?: return null val key = id.instanceId + // Session-scoped INFO uses this status to decide whether an Android association is safe to + // reuse without full device discovery. Querying adb's host:devices service checks the exact + // serial in one local round-trip and does not touch CoreSimulator. Cached driver/agent maps can + // outlive a disconnected emulator, so they are not sufficient as a liveness signal. + if (id.trailblazeDevicePlatform == TrailblazeDevicePlatform.ANDROID) { + androidDisconnectStatus(id, AndroidHostAdbUtils.listConnectedAdbDevices())?.let { return it } + } + // WEB: check Playwright browser initialization state separately from Maestro drivers. if (id.trailblazeDevicePlatform == TrailblazeDevicePlatform.WEB) { // Ready — test is cached, nothing more to report. @@ -1235,28 +1255,22 @@ class TrailblazeMcpBridgeImpl( return null } - val rpcClient = OnDeviceRpcClient( - trailblazeDeviceId = deviceId, - sendProgressMessage = { }, + val request = GetScreenStateRequest( + includeScreenshot = includeScreenshot, + screenshotMaxDimension1 = screenshotScalingConfig.maxDimension1, + screenshotMaxDimension2 = screenshotScalingConfig.maxDimension2, + screenshotImageFormat = screenshotScalingConfig.imageFormat, + screenshotCompressionQuality = screenshotScalingConfig.compressionQuality, + includeAnnotatedScreenshot = includeAnnotatedScreenshot, + includeAllElements = includeAllElements, ) - return try { - val request = GetScreenStateRequest( - includeScreenshot = includeScreenshot, - screenshotMaxDimension1 = screenshotScalingConfig.maxDimension1, - screenshotMaxDimension2 = screenshotScalingConfig.maxDimension2, - screenshotImageFormat = screenshotScalingConfig.imageFormat, - screenshotCompressionQuality = screenshotScalingConfig.compressionQuality, - includeAnnotatedScreenshot = includeAnnotatedScreenshot, - includeAllElements = includeAllElements, - ) - - when (val result: RpcResult = rpcClient.rpcCall(request)) { - is RpcResult.Success -> result.data - is RpcResult.Failure -> null - } - } finally { - rpcClient.close() + return when ( + val result: RpcResult = + onDeviceRpcClients.get(deviceId).rpcCall(request) + ) { + is RpcResult.Success -> result.data + is RpcResult.Failure -> null } } @@ -1323,11 +1337,10 @@ class TrailblazeMcpBridgeImpl( // Default implementation: convert tool to YAML and run via runYaml() Console.log("Executing TrailblazeTool via YAML conversion: ${tool::class.simpleName}") - val yaml = createTrailblazeYaml().encodeToString( - TrailblazeYamlBuilder() - .tools(listOf(tool)) - .build() - ) + // Single-tool dispatch encodes a bare tool-wrapper envelope (`- :`), decoded by the + // host runners via decodeTrailOrToolEnvelope → decodeTools — never the legacy list-shape trail + // parser. Same resulting trail item (one ToolTrailItem) the old `- tools:` synthesis produced. + val yaml = createTrailblazeYaml().encodeTools(listOf(fromTrailblazeTool(tool))) Console.log("Generated YAML:\n$yaml") @@ -1352,7 +1365,11 @@ class TrailblazeMcpBridgeImpl( // as soon as the session is created), so tool actions like taps return "executed" // before the on-device agent actually performs them. if (isOnDeviceInstrumentation()) { - val result = executeToolViaRpc(tool, trailblazeDeviceId, yaml, blocking, traceId) + // On-device RPC dispatch sends a bare tool-wrapper envelope (`- :`), decoded on + // the device via decodeTrailOrToolEnvelope → decodeTools — never the legacy list-shape trail + // parser. (The host/Maestro path below keeps the trail-item `yaml` it decodes host-side.) + val toolEnvelopeYaml = createTrailblazeYaml().encodeTools(listOf(fromTrailblazeTool(tool))) + val result = executeToolViaRpc(tool, trailblazeDeviceId, toolEnvelopeYaml, blocking, traceId) cachedScreenStates.remove(trailblazeDeviceId.instanceId) return result } @@ -1635,12 +1652,9 @@ class TrailblazeMcpBridgeImpl( blocking: Boolean = false, traceId: TraceId? = null, ): String { - val rpcClient = OnDeviceRpcClient( - trailblazeDeviceId = trailblazeDeviceId, - sendProgressMessage = { Console.log("[executeToolViaRpc] $it") }, - ) + val rpcClient = onDeviceRpcClients.get(trailblazeDeviceId) - return try { + return run { val sessionResolution = trailblazeDeviceManager.getOrCreateSessionResolution( trailblazeDeviceId = trailblazeDeviceId, forceNewSession = false, @@ -1777,8 +1791,6 @@ class TrailblazeMcpBridgeImpl( error("On-device tool execution failed: ${result.message}") } } - } finally { - rpcClient.close() } } @@ -2115,7 +2127,8 @@ class TrailblazeMcpBridgeImpl( // Refresh device list so the just-launched slot becomes visible to subsequent // `loadDevicesSuspend` calls — without this, the slot is live in // WebBrowserManager but the cached device list (read by the trail-run path) - // still doesn't include it. + // still doesn't include it. `loadDevices()` invalidates the short-TTL discovery + // cache synchronously and forces a fresh pass, so no stale slot list can leak. trailblazeDeviceManager.loadDevices() return when (terminal) { is xyz.block.trailblaze.host.devices.WebBrowserState.Running -> null diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/DroppedContentEntry.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/DroppedContentEntry.kt deleted file mode 100644 index 36e494924..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/DroppedContentEntry.kt +++ /dev/null @@ -1,18 +0,0 @@ -package xyz.block.trailblaze.migration - -/** - * One input key that a migration decode silently drops. Produced by [TrailRoundTripDropDetector] and - * surfaced in [UnifiedTrailMigrator.Report.droppedContent] plus the migrated file's leading comments. - * Top-level (rather than nested in either producer or consumer) so the detector stays reusable - * without depending on the migrator. - */ -data class DroppedContentEntry( - /** Input file the dropped key was found in (e.g. `android-phone.trail.yaml`). */ - val file: String, - /** Human-readable YAML path to the dropped key (e.g. `[1].prompts[0]...nodeSelector.rightOf.textRegex`). */ - val path: String, - /** The dropped key's name (e.g. `below`, `textRegex`). */ - val key: String, - /** 1-based source line of the dropped key in [file]. */ - val line: Int, -) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripDropDetector.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripDropDetector.kt deleted file mode 100644 index 2d4433a85..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripDropDetector.kt +++ /dev/null @@ -1,206 +0,0 @@ -package xyz.block.trailblaze.migration - -import com.charleskorn.kaml.Location -import com.charleskorn.kaml.UnknownPropertyException -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlList -import com.charleskorn.kaml.YamlMap -import com.charleskorn.kaml.YamlNode -import com.charleskorn.kaml.YamlScalar -import com.charleskorn.kaml.YamlTaggedNode -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.ListSerializer -import xyz.block.trailblaze.yaml.TrailYamlItem -import xyz.block.trailblaze.yaml.TrailblazeYaml - -/** - * Finds v1 trail-file content that the migrator's (lenient) decode silently drops. Two distinct drop - * mechanisms are covered: - * - * 1. **Schema-unknown keys.** The runtime [TrailblazeYaml] decodes with kaml `strictMode = false` + - * `ignoreUnknownKeys = true`, so any key the schema doesn't recognize is discarded during decode. - * This detector re-decodes the SAME text against a **strict** [TrailblazeYaml] (kaml - * `strictMode = true`), which throws [UnknownPropertyException] on the first such key. kaml aborts - * on the first one, so to enumerate ALL of them this decodes from a parsed [YamlNode] tree and, on - * each throw, records the drop, prunes that one key, and re-decodes until strict decode succeeds. - * Strict re-parse beats a re-encode diff here: the custom serializers omit default-valued fields - * (e.g. `recordable: true`) on encode, which a structural diff can't tell apart from a real drop. - * - * 2. **Extra keys in a tool-list item.** `TrailblazeToolYamlWrapperSerializer` decodes only the FIRST - * entry of each `tools:` list-item map (the tool name), so any sibling key at the item level — e.g. - * a positional anchor accidentally dedented out of the tool's args — is silently dropped. Strict - * decode can't catch this: the serializer never hands the extra keys to any decoder, so they never - * read as unknown properties. A structural pre-scan of `tools:` items flags them instead. - * - * Both malformed shapes have really bitten hand-authored trails (positional anchors written where the - * schema can't carry them); with no signal, the only defense was a human eyeballing the diff. - * - * Strictly a diagnostic: it never mutates what migrates and never throws — any unexpected failure - * degrades to "report what we found so far," so a detector bug can't break a migration. [detect] - * enforces the never-throws contract itself rather than leaning on callers. - */ -internal object TrailRoundTripDropDetector { - - /** Backstop against a non-converging prune loop; far above any real file's unknown-key count. */ - private const val MAX_ITERATIONS = 500 - - /** - * Detect content in [yamlText] (a single v1 `*.trail.yaml`, named [fileName] for reporting) that a - * lenient decode drops. Returns one [DroppedContentEntry] per dropped key, de-duplicated and - * ordered by source line. Empty when nothing is dropped, when [yamlText] isn't a decodable v1 trail - * list, or on any unexpected failure (best-effort by contract — never throws). - */ - fun detect( - strictYaml: TrailblazeYaml, - fileName: String, - yamlText: String, - ): List { - val instance = strictYaml.getInstance() - - // Parse once; both detectors read this tree (the strict loop re-decodes it after each prune). - // A parse failure here means the file isn't a decodable v1 trail (e.g. it's the unified format), - // which the lenient migrator path would also reject — nothing for us to say. - val root: YamlNode = try { - instance.parseToYamlNode(yamlText) - } catch (_: Throwable) { - return emptyList() - } - - // Enforce the "never throws" contract at the seam rather than relying on the caller: an - // unexpected failure in either detector degrades to what that detector could produce (empty on a - // hard failure), so a detector bug can never propagate out of a migration. - val strictDrops = runCatching { strictDecodeUnknownKeys(instance, root, fileName) }.getOrDefault(emptyList()) - val extraToolKeyDrops = runCatching { extraToolEntryKeys(root, fileName) }.getOrDefault(emptyList()) - - return (strictDrops + extraToolKeyDrops) - .distinctBy { Triple(it.path, it.line, it.key) } - .sortedWith(compareBy({ it.line }, { it.path }, { it.key })) - } - - /** - * Mechanism 1 (see class kdoc): enumerate every schema-unknown key by strict-decoding [root], and - * on each [UnknownPropertyException] recording the key, pruning it, and re-decoding until decode - * succeeds. Returns empty when [root] isn't a decodable v1 trail list, or on any non-unknown-key - * failure (best-effort). - */ - @OptIn(ExperimentalSerializationApi::class) - private fun strictDecodeUnknownKeys( - instance: Yaml, - root: YamlNode, - fileName: String, - ): List { - val itemSerializer = instance.serializersModule.getContextual(TrailYamlItem::class) ?: return emptyList() - val listSerializer: KSerializer> = ListSerializer(itemSerializer) - - var current = root - val dropped = mutableListOf() - val seen = mutableSetOf>() - repeat(MAX_ITERATIONS) { - try { - instance.decodeFromYamlNode(listSerializer, current) - return dropped // strict decode succeeded — nothing (else) was dropped - } catch (e: Throwable) { - // kaml wraps a nested unknown-key error in `InvalidPropertyValueException` at every level it - // bubbles up through (e.g. `recording` → `tools` → the tool's args), so the real signal is in - // the cause chain, not the top-level throw. No `UnknownPropertyException` anywhere in the - // chain means strict mode failed for some OTHER reason the lenient decode didn't hit — - // unexpected, so degrade to what we've found rather than break the migration. - val unknown = findUnknownProperty(e) ?: return dropped - val loc = unknown.location - if (!seen.add(loc.line to loc.column)) { - // The same unknown key resurfaced, so the prune didn't converge — stop rather than loop. - return dropped - } - dropped += DroppedContentEntry( - file = fileName, - path = unknown.path.toHumanReadableString(), - key = unknown.propertyName, - line = unknown.line, - ) - current = pruneKeyByLocation(current, unknown.propertyName, loc) - } - } - return dropped - } - - /** - * Mechanism 2 (see class kdoc): flag every sibling key in a multi-entry `tools:` list item. The - * wrapper serializer keeps only the first entry (document order), so entries 2..N are dropped - * without any decode error — a structural scan is the only way to see them. - */ - private fun extraToolEntryKeys( - root: YamlNode, - fileName: String, - ): List { - val out = mutableListOf() - fun visit(node: YamlNode) { - when (node) { - is YamlMap -> { - for ((key, value) in node.entries) { - // A `tools:` value (or its items) may be wrapped in a YAML tag; unwrap before inspecting. - val toolsList = value.untag() - if (key.content == TrailYamlItem.KEYWORD_TOOLS && toolsList is YamlList) { - for (rawItem in toolsList.items) { - val item = rawItem.untag() - if (item is YamlMap && item.entries.size > 1) { - // First entry is the tool the serializer keeps; the rest are silently dropped. - item.entries.entries.drop(1).forEach { (extraKey, _) -> - out += DroppedContentEntry( - file = fileName, - path = extraKey.path.toHumanReadableString(), - key = extraKey.content, - line = extraKey.location.line, - ) - } - } - } - } - visit(value) - } - } - is YamlList -> node.items.forEach(::visit) - is YamlTaggedNode -> visit(node.innerNode) - else -> {} - } - } - visit(root) - return out - } - - /** Strip a YAML tag wrapper if present, so structural checks see the underlying node. */ - private fun YamlNode.untag(): YamlNode = if (this is YamlTaggedNode) innerNode else this - - /** Walk [t]'s cause chain for the underlying [UnknownPropertyException] kaml wraps on the way up. */ - private fun findUnknownProperty(t: Throwable): UnknownPropertyException? { - var current: Throwable? = t - val visited = mutableSetOf() - while (current != null && visited.add(current)) { - if (current is UnknownPropertyException) return current - current = current.cause - } - return null - } - - /** - * Rebuild [node] with the single map entry whose key is [key] at source [location] removed. - * Matching on the exact (name + line + column) location targets one key precisely, so a same-named - * key elsewhere in the tree is untouched. Remaining nodes keep their original paths, so a later - * [UnknownPropertyException]'s reported line stays accurate. Pure — unit-tested in isolation from - * the strict-decode loop. - */ - internal fun pruneKeyByLocation(node: YamlNode, key: String, location: Location): YamlNode = - when (node) { - is YamlMap -> { - val kept = LinkedHashMap() - for ((k, v) in node.entries) { - if (k.content == key && k.location == location) continue // drop this entry (and its subtree) - kept[k] = pruneKeyByLocation(v, key, location) - } - YamlMap(kept, node.path) - } - is YamlList -> YamlList(node.items.map { pruneKeyByLocation(it, key, location) }, node.path) - is YamlTaggedNode -> node.copy(innerNode = pruneKeyByLocation(node.innerNode, key, location)) - else -> node - } -} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripFidelityVerifier.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripFidelityVerifier.kt deleted file mode 100644 index e1a590552..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/TrailRoundTripFidelityVerifier.kt +++ /dev/null @@ -1,133 +0,0 @@ -package xyz.block.trailblaze.migration - -import xyz.block.trailblaze.yaml.TrailblazeYaml -import xyz.block.trailblaze.yaml.unified.UnifiedTrail -import xyz.block.trailblaze.yaml.unified.UnifiedTrailStep - -/** - * One place where the migrated unified file does NOT decode back to the tools the migrator intended - * to write — a genuine, behavior-changing fidelity loss (a serializer that fails to round-trip a - * non-default value, a nested arg the concrete tool model can't carry, a config field lost on - * re-encode). Surfaced in [UnifiedTrailMigrator.Report.roundTripMismatches] and the migrated file's - * leading comments, and (like [DroppedContentEntry]) gated by `--fail-on-dropped-content`. - */ -data class RoundTripFidelityEntry( - /** Where the mismatch is (e.g. `config`, `trailhead`, `step 3 · recording[android-phone]`). */ - val location: String, - /** Short human description of what differs (intended vs re-decoded). */ - val detail: String, -) - -/** - * Confidence check for the migrator's serialize-based reshape: encode the unified trail the migrator - * built, decode that emitted text back through the SAME serializers, and compare the re-decoded trail - * to the intended one at the **typed-object** level. - * - * Why object-equality and not a text diff: the encoders omit default-valued fields (`textMatchMode: - * EXACT`, `currency: USD`, `recordable: true`) and normalize some scalars (a Maestro `timeout: 1000` - * emits as `"1000"`, which Maestro re-coerces). A text diff flags all of those as changes even though - * they decode back to the identical value — the exact false positives the sibling - * [TrailRoundTripDropDetector] kdoc calls out. Comparing the DECODED objects tolerates every - * behavior-preserving normalization (defaults restored, scalars re-coerced → equal) while catching a - * real loss (a value that decodes to something different, or vanishes). - * - * Strictly diagnostic: never mutates what migrates and never throws — a verifier failure degrades to - * "no mismatches found" so it can't break a migration. The one non-empty degrade is an emitted file - * that fails to re-parse at all, which is itself a fidelity finding worth reporting. - */ -internal object TrailRoundTripFidelityVerifier { - - /** Cap on how much of a differing object we render into a finding, so a big tool stays readable. */ - private const val RENDER_MAX = 300 - - fun verify(yaml: TrailblazeYaml, trail: UnifiedTrail): List = - runCatching { - val emitted = yaml.encodeUnifiedTrailToString(trail = trail, leadingComments = emptyList()) - val reDecoded = try { - yaml.decodeUnifiedTrail(emitted) - } catch (e: Throwable) { - return@runCatching listOf( - RoundTripFidelityEntry( - location = "trail", - detail = "emitted unified YAML failed to re-parse: ${e.message ?: e::class.simpleName}", - ), - ) - } - diff(intended = trail, reDecoded = reDecoded) - }.getOrDefault(emptyList()) - - /** - * Pure comparison of the trail the migrator built ([intended]) against the trail its emitted YAML - * decodes back to ([reDecoded]). Extracted so the catch logic can be unit-tested with a tampered - * [reDecoded] — no broken serializer required to exercise the negative path. - */ - internal fun diff(intended: UnifiedTrail, reDecoded: UnifiedTrail): List { - val out = mutableListOf() - - if (intended.config != reDecoded.config) { - out += RoundTripFidelityEntry( - location = "config", - detail = renderMismatch(intended.config, reDecoded.config), - ) - } - - diffStep("trailhead", intended.trailhead, reDecoded.trailhead, out) - - if (intended.trail.size != reDecoded.trail.size) { - out += RoundTripFidelityEntry( - location = "trail", - detail = "step count changed: intended ${intended.trail.size}, re-decoded ${reDecoded.trail.size}", - ) - } - val stepCount = minOf(intended.trail.size, reDecoded.trail.size) - for (i in 0 until stepCount) { - diffStep("step ${i + 1}", intended.trail[i], reDecoded.trail[i], out) - } - return out - } - - private fun diffStep( - label: String, - intended: UnifiedTrailStep?, - reDecoded: UnifiedTrailStep?, - out: MutableList, - ) { - if (intended == null && reDecoded == null) return - if (intended == null || reDecoded == null) { - out += RoundTripFidelityEntry(label, if (intended == null) "appeared only after re-decode" else "lost on re-decode") - return - } - // Compare the step's scalar shape (NL, kind, retry budget, recordable) apart from recordings so a - // finding names exactly what changed. - if ( - intended.step != reDecoded.step || - intended.verify != reDecoded.verify || - intended.recordable != reDecoded.recordable || - intended.maxRetries != reDecoded.maxRetries - ) { - out += RoundTripFidelityEntry( - location = label, - detail = renderMismatch( - "step=${intended.step.take(60)} verify=${intended.verify} recordable=${intended.recordable} maxRetries=${intended.maxRetries}", - "step=${reDecoded.step.take(60)} verify=${reDecoded.verify} recordable=${reDecoded.recordable} maxRetries=${reDecoded.maxRetries}", - ), - ) - } - // Sorted so the mismatch list (and the emitted WARNING block) is deterministic across runs — - // the recordings maps iterate in unspecified order otherwise, which would make CI diffs noisy. - val classifiers = (intended.recordings.keys + reDecoded.recordings.keys).sorted() - for (classifier in classifiers) { - val a = intended.recordings[classifier] - val b = reDecoded.recordings[classifier] - if (a != b) { - out += RoundTripFidelityEntry( - location = "$label · recording[$classifier]", - detail = renderMismatch(a, b), - ) - } - } - } - - private fun renderMismatch(intended: Any?, reDecoded: Any?): String = - "intended: ${intended.toString().take(RENDER_MAX)}\n re-decoded: ${reDecoded.toString().take(RENDER_MAX)}" -} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/UnifiedTrailMigrator.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/UnifiedTrailMigrator.kt deleted file mode 100644 index fb5c53fc3..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/migration/UnifiedTrailMigrator.kt +++ /dev/null @@ -1,803 +0,0 @@ -package xyz.block.trailblaze.migration - -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.builtins.ListSerializer -import xyz.block.trailblaze.logs.client.temp.YamlJsonBridge -import xyz.block.trailblaze.recordings.TrailRecordings -import xyz.block.trailblaze.yaml.PromptStep -import xyz.block.trailblaze.yaml.TrailYamlItem -import xyz.block.trailblaze.yaml.TrailblazeToolYamlWrapper -import xyz.block.trailblaze.yaml.TrailblazeYaml -import xyz.block.trailblaze.yaml.VerificationStep -import xyz.block.trailblaze.yaml.createTrailblazeYaml -import xyz.block.trailblaze.yaml.unified.UnifiedTrail -import xyz.block.trailblaze.yaml.unified.UnifiedTrailAdapter -import xyz.block.trailblaze.yaml.unified.UnifiedTrailConfig -import xyz.block.trailblaze.yaml.unified.UnifiedTrailStep -import java.io.File - -/** - * Migrates a directory of legacy v1 `*.trail.yaml` files (plus optional - * `blaze.yaml`) into a single unified `trail.yaml` file. - * - * Algorithm: - * - * 1. Load every `.trail.yaml` in the directory; filename minus - * `.trail.yaml` is the classifier. - * 2. Canonicalize config across every file: for each device-agnostic scalar - * (`id` / `target` / `title` / `description` / `priority` / `context` / - * `memory`) the first file to declare it wins (platform files in filename - * order, then `blaze.yaml`), so a field only one file carries is never - * dropped; `metadata` (which also carries the bridged v1 `source:` — - * see [xyz.block.trailblaze.yaml.unified.UnifiedTrailConfig.metadata]) - * merges per-key the same first-wins way. `platform:` is retired (the - * device set derives from the classifier slots) and a v1 `electron:` block - * is refused by the shared seed helper (fail loud, never silently drop). - * Two v1 fields are per-platform-file and become per-classifier maps keyed by - * each file's classifier: `driver:` → `devices:`, and `skip:` → `skip:` (v1's - * scalar skip reason keyed under that file's classifier, so a trail can be - * skipped on one device family while running on others — closest-wins at run - * time). Both maps are emitted only when at least one file contributed an - * entry; otherwise omitted (the device set is derived from the recorded - * classifiers at run time). Blank skip reasons are dropped (v1 semantics: - * `skip: ""` is not a skip). A non-blank `skip:` on `blaze.yaml` is - * device-agnostic (the CLI runs `blaze.yaml` standalone when no platform - * recording matches), so it's copied onto every present classifier that - * doesn't already declare its own skip — the unified map has no universal - * wildcard key. `tags:` is trail-level (device-agnostic), so every file's - * tags are unioned (de-duplicated, first-seen order) — a tag on any file - * describes the whole test. `memory:` (the AgentMemory pre-seed block) - * round-trips through the migration intact. - * 3. For each step index, gather per-classifier NL, step kind (`step:` vs - * `verify:`) and tool recordings. Disagreeing NL becomes a drift warning - * surfaced in the output's leading comments; canonical NL is the first - * platform's. The kind is carried the same way (a v1 `verify:` step becomes - * a unified `verify:` step) with the same canonical preference, and - * platforms disagreeing on kind at an index is surfaced as drift — never - * silently flattened. - * 4. Steps with no recording on any classifier are left at the default - * `recordable = true` — "not recorded yet" (runs via the agent, can be - * recorded later), not "never record". The migrator never auto-emits - * `recordable: false`; that flag is reserved for authors who deliberately - * want an LLM-only step. - * 5. Collapse equivalent sub-classifiers into their family classifier. Sub- - * classifiers are inferred from the filename prefixes present in the - * directory — any two-or-more classifiers sharing a `-` prefix form - * a family. When every present sub-classifier has equivalent recordings - * (`reason:` stripped before comparison), the recordings collapse to a - * single `:` entry. - * - * Idempotent: re-running on already-merged input would already be the unified - * format (and the CLI command refuses non-v1 inputs). Drift is surfaced via - * comments, never silently flattened. - */ -class UnifiedTrailMigrator( - private val trailblazeYaml: TrailblazeYaml = TrailblazeYaml.Default, -) { - - /** - * Strict-mode sibling of [trailblazeYaml], used only by [TrailRoundTripDropDetector] to detect - * input content the lenient decode drops. Built from the full classpath-discovered tool set (as - * [TrailblazeYaml.Default] is) so recorded tool args validate against their real schemas. Lazy so - * constructing a migrator stays cheap when `migrate()` is never called. - * - * Invariant: the detector only reports a drop when this strict schema and the lenient - * [trailblazeYaml] share the same tool set. That holds because every caller injects - * [TrailblazeYaml.Default] (also full-classpath), so strict decode = lenient decode + the - * unknown-key throw. Injecting a narrower custom instance would make strict a superset and could - * surface false positives — until a `withStrict()`-style affordance lets this derive from the - * injected instance, keep `trailblazeYaml` `.Default`-compatible. - */ - private val strictTrailblazeYaml: TrailblazeYaml by lazy { createTrailblazeYaml(strict = true) } - - /** - * Migrate the per-platform v1 files in [inputDir] to a single [UnifiedTrail] - * plus a structured [Report]. The caller chooses what to do with the - * report (typically: print a summary, write the trail to disk). - */ - fun migrate(inputDir: File): Result { - require(inputDir.isDirectory) { "Input must be a directory: $inputDir" } - val platformFiles = inputDir - .listFiles { f -> f.isFile && f.name.endsWith(TRAIL_YAML_SUFFIX) && f.name != BLAZE_FILENAME } - ?.sortedBy { it.name } - .orEmpty() - // A blaze.yaml-only directory is migratable: blaze.yaml is a v1 trail file with the same - // schema as a `.trail.yaml`, just device-agnostic and recording-less. The - // blaze.yaml handling below already yields a clean recording-less unified trail, so this - // guard only needs to refuse a directory with no v1 source at all. - val blazeFile = File(inputDir, BLAZE_FILENAME) - require(platformFiles.isNotEmpty() || blazeFile.isFile) { - "No `*.trail.yaml` or `blaze.yaml` files found in $inputDir; nothing to migrate." - } - - // Load every platform file as v1. - val perClassifier = linkedMapOf>() - val memoryByClassifier = linkedMapOf?>() - // Per-classifier driver pins. v1's `driver:` is per-platform-file, so each file contributes - // one entry keyed by its classifier — a multi-platform trail keeps each platform's driver - // (android accessibility, ios host, …) rather than collapsing to one. - val driversByClassifier = linkedMapOf() - // Per-classifier skip reasons. v1's `skip:` is a scalar per-platform-file, so each file's - // reason keys under its classifier — same shape as the driver map above. Blank reasons are - // ignored (v1 semantics: `skip: ""` means "not skipped"). Divergence across files here is - // expected by design (a trail can be skipped on one device family but not another), unlike - // NL/memory below, where divergence signals an authoring bug and gets drift-detected instead. - val skipByClassifier = linkedMapOf() - // Trail-level tags. Unlike driver/skip these aren't device-specific, so they're unioned across - // every file (a tag on any platform describes the whole test) rather than keyed by classifier. - // LinkedHashSet keeps first-seen order and de-duplicates a tag shared by multiple files. - // Files disagreeing on tags is expected (each just contributes what it knows), not drift. - val tagsUnion = linkedSetOf() - // blaze.yaml's own skip reason (device-agnostic — see the propagation comment below). - var blazeSkip: String? = null - var canonicalConfig: UnifiedTrailConfig? = null - // Every file's scalar config, keyed by classifier (or BLAZE_KEY), retained so scalar - // disagreements can be surfaced as drift after the fold picks a canonical value. - val configsBySource = linkedMapOf() - // Input content the lenient decode drops (unknown keys the schema can't carry — e.g. malformed - // positional anchors). Detected per input file against a strict decode; surfaced as leading - // comments so a reviewer sees what vanished rather than catching it by eyeballing the diff. - val droppedContent = mutableListOf() - for (file in platformFiles) { - val classifier = file.name.removeSuffix(TRAIL_YAML_SUFFIX) - val fileText = file.readText() - droppedContent += detectDroppedContent(file.name, fileText) - val items = trailblazeYaml.decodeTrail(fileText) - assertNoTopLevelTools(items, file.name) - assertNoTrailhead(items, file.name) - val v1Config = trailblazeYaml.extractTrailConfig(items) - if (v1Config != null) { - val unified = v1ConfigToUnified(v1Config) - configsBySource[classifier] = unified - canonicalConfig = canonicalConfig.foldConfig(unified) - } - v1Config?.driver?.let { driversByClassifier[classifier] = it } - v1Config?.skip?.takeIf { it.isNotBlank() }?.let { skipByClassifier[classifier] = it } - v1Config?.tags?.let { tagsUnion.addAll(it) } - // Track each platform's memory block so divergence across files surfaces as a drift - // warning instead of silently using the first file's values. Mirrors the NL drift - // pass below — same migrator philosophy: when platforms disagree, the user sees it. - memoryByClassifier[classifier] = v1Config?.memory - val prompts = items - .filterIsInstance() - .flatMap { it.promptSteps } - perClassifier[classifier] = prompts - } - - // Load blaze.yaml if present. It contributes canonical NL only (no - // recordings) and is the authoritative NL source when its step is - // present — that matches the v1-era convention where blaze.yaml is the - // hand-authored NL definition that platform files were recorded against. - val blazePrompts: List = if (blazeFile.isFile) { - val blazeText = blazeFile.readText() - droppedContent += detectDroppedContent(blazeFile.name, blazeText) - val items = trailblazeYaml.decodeTrail(blazeText) - assertNoTopLevelTools(items, blazeFile.name) - assertNoTrailhead(items, blazeFile.name) - val cfg = trailblazeYaml.extractTrailConfig(items) - // A blaze-only migration (no platform files) has no device classifier, and the unified - // `skip:`/`devices:` maps are classifier-keyed with no universal-wildcard key. So a - // blaze.yaml `skip:`/`driver:` can't be represented and would be silently dropped — a - // skipped trail would start running, or a pinned driver fall back to runtime resolution. - // Refuse until the case gains a recording (whose classifier the value keys onto) or drops - // the field. (With platform files present, `blazeSkip` below propagates onto each classifier - // instead, so this only guards the classifier-less case.) - if (platformFiles.isEmpty()) { - require(cfg?.skip.isNullOrBlank()) { - "Cannot migrate a blaze.yaml-only directory in $inputDir: it declares `skip:`, but a " + - "recording-less unified trail has no device classifier to key the skip onto, so the " + - "skip would be lost. Add a `.trail.yaml` recording or remove the skip first." - } - require(cfg?.driver.isNullOrBlank()) { - "Cannot migrate a blaze.yaml-only directory in $inputDir: it pins `driver:`, but a " + - "recording-less unified trail has no device classifier to key the driver onto, so it " + - "would fall back to runtime resolution. Add a `.trail.yaml` recording or " + - "remove the driver first." - } - } - if (cfg != null) { - val unified = v1ConfigToUnified(cfg) - configsBySource[BLAZE_KEY] = unified - canonicalConfig = canonicalConfig.foldConfig(unified) - } - // blaze.yaml is device-agnostic, so its tags join the trail-level union too. - cfg?.tags?.let { tagsUnion.addAll(it) } - blazeSkip = cfg?.skip?.takeIf { it.isNotBlank() } - items.filterIsInstance().flatMap { it.promptSteps } - } else emptyList() - - // Classifiers present across the per-platform files — used for the family-collapse pass. - val presentClassifiers = perClassifier.keys.toList().sorted() - // blaze.yaml's `skip:` is honored by the CLI as a standalone runnable trail file (the - // device-agnostic fallback when no platform recording matches), so a non-blank reason there - // means "skip this trail everywhere" — not just wherever a platform file happens to repeat it. - // The unified `skip:` map has no universal wildcard key (closest-wins resolves through a - // classifier's own lineage only), so the only faithful translation is to copy the reason onto - // every present classifier that doesn't already declare its own (more specific) skip — a - // platform file's own `skip:` still wins for that platform. - blazeSkip?.let { reason -> - for (classifier in presentClassifiers) { - skipByClassifier.putIfAbsent(classifier, reason) - } - } - // Merge the per-classifier maps onto the canonical config. `devices:` (driver pins) and - // `skip:` (skip reasons) are each emitted only when at least one classifier contributed an - // entry; otherwise omitted — the supported classifiers are derivable from the steps' - // recordings, and a driverless trail resolves the driver at run time. - val mergedConfig = (canonicalConfig ?: UnifiedTrailConfig()) - .copy( - devices = driversByClassifier.ifEmpty { null }, - skip = skipByClassifier.ifEmpty { null }, - tags = tagsUnion.ifEmpty { null }?.toList(), - ) - - // Per-step reconciliation across platforms; step count is the max of - // (any platform's prompts length, blaze.yaml's prompts length). - val platformMax = perClassifier.values.maxOfOrNull { it.size } ?: 0 - val maxSteps = maxOf(platformMax, blazePrompts.size) - val driftReports = mutableListOf() - val kindDriftReports = mutableListOf() - val steps = mutableListOf() - - for (i in 0 until maxSteps) { - val nlByClassifier = linkedMapOf() - val verifyByClassifier = linkedMapOf() - val toolsByClassifier = linkedMapOf>() - for ((classifier, prompts) in perClassifier) { - if (i < prompts.size) { - val step = prompts[i] - nlByClassifier[classifier] = step.prompt - verifyByClassifier[classifier] = step is VerificationStep - val tools = step.recording?.tools.orEmpty() - if (tools.isNotEmpty()) { - toolsByClassifier[classifier] = tools - } - } - } - val blazeStep: PromptStep? = blazePrompts.getOrNull(i) - val blazeNl: String? = blazeStep?.prompt - if (nlByClassifier.isEmpty() && blazeNl == null) continue - - // Canonical NL preference: blaze.yaml (if present) > first platform's NL. - // blaze.yaml is the hand-authored intent statement; platform files were - // recorded against it and may have drifted. - val canonicalNl = blazeNl ?: nlByClassifier.values.first() - val nlsForDrift = buildMap { - if (blazeNl != null) put(BLAZE_KEY, blazeNl) - putAll(nlByClassifier) - } - val uniqueNls = nlsForDrift.values.map { it.trim() }.toSet() - if (uniqueNls.size > 1) { - driftReports.add(DriftEntry(stepIndex = i, nlByClassifier = nlsForDrift)) - } - - // Step kind (`step:` vs `verify:`) carries through with the same canonical preference as - // the NL. Files disagreeing on kind at the same index is an authoring bug (verify semantics - // are load-bearing at run time), so it's surfaced as drift — never silently flattened. - val kindsForDrift = buildMap { - if (blazeStep != null) put(BLAZE_KEY, blazeStep is VerificationStep) - putAll(verifyByClassifier) - } - val canonicalVerify = kindsForDrift.values.first() - if (kindsForDrift.values.toSet().size > 1) { - kindDriftReports.add(KindDriftEntry(stepIndex = i, verifyByClassifier = kindsForDrift)) - } - - // recordable stays at its default (true): a step with no recording just runs via the - // agent and can be recorded later. We deliberately do NOT emit `recordable: false` for - // no-recording steps — that would mean "never record", which isn't the intent and is noise. - steps.add( - UnifiedTrailStep( - step = canonicalNl, - verify = canonicalVerify, - recordings = toolsByClassifier, - ), - ) - } - - // Family-collapse pass. - val families = inferFamilies(presentClassifiers) - val collapseReports = mutableListOf() - val collapsedSteps = steps.map { step -> - var current = step - for ((family, members) in families) { - val (collapsed, action) = collapseFamily(current, family, members) - current = collapsed - when (action) { - CollapseAction.COLLAPSED -> collapseReports.add( - FamilyCollapseEntry(family = family, members = members, diverged = false), - ) - CollapseAction.DIVERGED -> collapseReports.add( - FamilyCollapseEntry(family = family, members = members, diverged = true), - ) - CollapseAction.NOT_APPLICABLE -> Unit - } - } - current - } - - // Detect cross-file memory drift. Normalize null → emptyMap before comparing so a - // platform that simply omits `config.memory:` (vs one that explicitly sets it) only - // surfaces as drift when the SETS differ — not when one is "absent" and another is - // "absent but the YAML had an empty map". - val normalizedMemories = memoryByClassifier.mapValues { (_, v) -> v.orEmpty() } - val memoryDrift: List = - if (normalizedMemories.values.toSet().size > 1) { - listOf(MemoryDriftEntry(memoryByClassifier = normalizedMemories)) - } else { - emptyList() - } - - val migratedTrail = UnifiedTrail(config = mergedConfig, trail = collapsedSteps) - - return Result( - trail = migratedTrail, - report = Report( - platformFilesLoaded = platformFiles.map { it.name }, - blazeLoaded = blazeFile.isFile, - drift = driftReports, - memoryDrift = memoryDrift, - kindDrift = kindDriftReports, - configDrift = detectConfigDrift(configsBySource), - familyCollapses = collapseReports, - unrecordableSteps = collapsedSteps.withIndex().count { !it.value.recordable }, - droppedContent = droppedContent, - // Confidence check: prove the file we're about to write decodes back to the tools we intended. - // Behavior-preserving normalization (elided defaults, Maestro scalar re-coercion) round-trips - // equal and is silent here; only a value that decodes to something different is reported. - roundTripMismatches = TrailRoundTripFidelityVerifier.verify(trailblazeYaml, migratedTrail), - ), - ) - } - - /** - * Best-effort wrapper around [TrailRoundTripDropDetector.detect]: a detector failure must never - * break a migration, so any throw degrades to "no drops found" for this file. - */ - private fun detectDroppedContent(fileName: String, yamlText: String): List = - runCatching { TrailRoundTripDropDetector.detect(strictTrailblazeYaml, fileName, yamlText) } - .getOrDefault(emptyList()) - - /** - * Detect cross-file scalar-config drift: two files meaningfully declaring DIFFERENT values for - * the same device-agnostic scalar. The fold resolves these first-file-wins, which is silent — - * surface the disagreement like NL/memory/kind drift so a divergent title or priority isn't - * invisibly dropped during a bulk migration. "Meaningfully declared" matches the fold's absence - * rules (blank strings don't count). `metadata` is compared per-KEY (reported as - * `metadata.`, e.g. `metadata.source` for the bridged v1 field) to match its per-key - * merge — files contributing disjoint keys is a clean union, not drift. `memory` has its own - * dedicated drift pass; per-classifier `devices`/`skip` and the unioned `tags` diverge by - * design and are not scalars. - */ - private fun detectConfigDrift( - configsBySource: Map, - ): List { - val extractors: List String?>> = listOf( - "id" to { it.id?.takeUnless(String::isBlank) }, - "target" to { it.target?.takeUnless(String::isBlank) }, - "title" to { it.title?.takeUnless(String::isBlank) }, - "description" to { it.description?.takeUnless(String::isBlank) }, - "priority" to { it.priority?.takeUnless(String::isBlank) }, - "context" to { it.context?.takeUnless(String::isBlank) }, - ) - val fieldDrift = extractors.mapNotNull { (field, extract) -> - val valueBySource = configsBySource.mapNotNull { (source, cfg) -> - extract(cfg)?.let { source to it } - }.toMap() - if (valueBySource.values.toSet().size > 1) ConfigDriftEntry(field, valueBySource) else null - } - val metadataKeys = configsBySource.values.flatMap { it.metadata?.keys ?: emptySet() }.toSet() - val metadataDrift = metadataKeys.sorted().mapNotNull { key -> - val valueBySource = configsBySource.mapNotNull { (source, cfg) -> - cfg.metadata?.get(key)?.let { source to it } - }.toMap() - if (valueBySource.values.toSet().size > 1) ConfigDriftEntry("metadata.$key", valueBySource) else null - } - return fieldDrift + metadataDrift - } - - /** - * Fail fast when a v1 input contains a top-level `- tools:` block (a - * `ToolTrailItem`). Those blocks carry setup tool calls that the migrator - * doesn't currently know how to translate into the unified format's - * per-step / per-classifier shape — silently dropping them would change - * the trail's behavior at runtime (e.g. login state never gets set up). - * - * Translating top-level tools into a unified step is a follow-up; for now - * we refuse migration so the operator notices and either inlines the - * setup tools into the first step manually or waits for the translation - * pass to land. - */ - private fun assertNoTopLevelTools(items: List, filename: String) { - val toolItems = items.filterIsInstance() - require(toolItems.isEmpty()) { - "Refusing to migrate $filename: it contains a top-level `- tools:` block " + - "(${toolItems.sumOf { it.tools.size }} tool call(s)) which the migrator does " + - "not yet know how to lower into the unified-format per-step / per-classifier " + - "shape. Silently dropping those tools would change runtime behavior. " + - "Either inline them into the first step's recording manually or skip this " + - "directory until the migrator learns to translate top-level tools." - } - } - - /** - * Fail fast when a v1 input contains a `- trailhead:` block. Mapping the per-classifier trailhead - * into [UnifiedTrail.trailhead] (with NL-drift + family-collapse reconciliation, mirroring the - * per-step pass) is a follow-up; until then, refuse migration rather than silently drop the - * deterministic step 0 — the same policy [assertNoTopLevelTools] applies to top-level tools. - */ - private fun assertNoTrailhead(items: List, filename: String) { - require(items.none { it is TrailYamlItem.TrailheadTrailItem }) { - "Refusing to migrate $filename: it contains a `- trailhead:` block, which this migrator does " + - "not lower into the unified format's per-classifier `trailhead:`. Silently dropping it " + - "would lose the trail's deterministic step 0. Author the unified `trailhead:` directly, or " + - "skip this directory." - } - } - - // Identity fields come from the shared [UnifiedTrailAdapter.v1ConfigToUnifiedConfig] mapping (one - // source of truth with the recorder's first-write seed). `devices:` / `skip:` (per-classifier - // maps) and `tags:` (a trail-level union) are populated by the caller from every file — this - // single first-file config can't express them — so the helper leaves them null. - private fun v1ConfigToUnified(v1: xyz.block.trailblaze.yaml.TrailConfig): UnifiedTrailConfig = - UnifiedTrailAdapter.v1ConfigToUnifiedConfig(v1) - - // Fold [next] into the canonical config being accumulated: the first file to meaningfully - // declare a scalar wins, and files seen later only FILL fields the canonical still lacks - // (blank strings / empty placeholders don't shadow a later populated value; metadata — which - // carries the bridged v1 `source:` — merges per-key). Without the fill, a field - // present only in a later file — e.g. `source:` declared in `blaze.yaml` but not in the - // platform files — was silently dropped because the first config seen became canonical - // wholesale. The field list lives in the shared [UnifiedTrailAdapter.fillMissingConfigScalars]; - // `devices` / `skip` / `tags` are per-classifier / union-merged by the caller and stay - // untouched (null) here. - private fun UnifiedTrailConfig?.foldConfig(next: UnifiedTrailConfig): UnifiedTrailConfig = - if (this == null) next else UnifiedTrailAdapter.fillMissingConfigScalars(this, next) - - /** - * Group [classifiers] into families based on shared `-` prefix. - * Returns one entry per family that has 2+ sub-classifiers; classifiers with - * no `-` (or with no siblings under their prefix) are not collapsible and - * are omitted from the result. - */ - internal fun inferFamilies(classifiers: List): Map> { - val byPrefix = linkedMapOf>() - for (c in classifiers) { - val dashIdx = c.indexOf('-') - if (dashIdx <= 0 || dashIdx == c.length - 1) continue - val prefix = c.substring(0, dashIdx) - // Skip if the prefix itself is already a classifier in the input — - // that means the family is already declared explicitly and shouldn't - // be re-collapsed. - if (prefix in classifiers) continue - byPrefix.getOrPut(prefix) { mutableListOf() }.add(c) - } - return byPrefix.filterValues { it.size >= 2 } - } - - private fun collapseFamily( - step: UnifiedTrailStep, - family: String, - members: List, - ): Pair { - val present = members.filter { it in step.recordings } - if (present.size < 2) return step to CollapseAction.NOT_APPLICABLE - // Require EVERY member of the family to have a recording for this step - // before collapsing. If we collapsed when only a subset recorded, the - // emitted `:` entry would be picked up at runtime by closest-wins - // resolution for the missing members too — silently giving them a - // recording they were never tested with. In v1 those devices ran the - // step in LLM mode (no recording); we preserve that by leaving the - // present members un-collapsed. - if (present.size < members.size) return step to CollapseAction.NOT_APPLICABLE - val canonicalForm = canonicalKey(step.recordings[present[0]]!!) - val allEqual = present.drop(1).all { canonicalKey(step.recordings[it]!!) == canonicalForm } - if (!allEqual) return step to CollapseAction.DIVERGED - - val newRecordings = linkedMapOf>() - var inserted = false - for ((classifier, tools) in step.recordings) { - if (classifier in present) { - if (!inserted) { - newRecordings[family] = step.recordings[present[0]]!! - inserted = true - } - // skip — collapsed into family - } else { - newRecordings[classifier] = tools - } - } - return step.copy(recordings = newRecordings) to CollapseAction.COLLAPSED - } - - /** - * Produce a canonical representation of [tools] with `reason:` keys - * removed at any depth, so two recordings that differ only in their - * recording-time reason annotations compare equal. - */ - private fun canonicalKey(tools: List): JsonElement { - val yamlText = trailblazeYaml.getInstance().encodeToString( - ListSerializer(trailblazeYaml.toolWrapperSerializer()), - tools, - ) - val node = trailblazeYaml.getInstance().parseToYamlNode(yamlText) - val asJson = YamlJsonBridge.yamlNodeToJsonElement(node) - return stripReasonRecursively(asJson) - } - - private fun stripReasonRecursively(element: JsonElement): JsonElement = when (element) { - is JsonObject -> JsonObject( - element.entries - .filter { (k, _) -> k != REASON_KEY } - .associate { (k, v) -> k to stripReasonRecursively(v) }, - ) - is JsonArray -> JsonArray(element.map { stripReasonRecursively(it) }) - else -> element - } - - data class Result( - val trail: UnifiedTrail, - val report: Report, - ) - - data class Report( - val platformFilesLoaded: List, - /** - * True when a `blaze.yaml` in the input directory contributed to the migration (its - * device-agnostic NL / config). Distinct from [platformFilesLoaded] because blaze.yaml is - * recording-less and carries no device classifier — for a blaze-only case this is the only - * source, so `platformFilesLoaded` is empty while this is true. - */ - val blazeLoaded: Boolean = false, - val drift: List, - val familyCollapses: List, - val unrecordableSteps: Int, - /** - * Cross-file memory drift — non-empty when two or more per-platform v1 files declared - * `config.memory:` blocks that differ. Always 0 or 1 entries (one entry holds every - * platform's memory map for comparison). Empty when all files agreed or no file had - * a memory block. - */ - val memoryDrift: List = emptyList(), - /** - * Step-kind drift — one entry per step index where the files disagree on `step:` vs - * `verify:`. The canonical kind follows the same preference as NL (blaze.yaml when - * present, otherwise the first platform); the disagreement is surfaced here so it is - * never silently flattened (verify semantics are load-bearing at run time). - */ - val kindDrift: List = emptyList(), - /** - * Scalar-config drift — one entry per device-agnostic scalar (`title`, `priority`, `source`, - * …) that two or more files meaningfully declare with different values. The fold resolves - * these first-file-wins; the losers are surfaced here so a divergent value is never - * invisibly dropped. - */ - val configDrift: List = emptyList(), - /** - * Un-round-trippable content — one entry per input key the lenient decode silently drops (a - * key the schema doesn't recognize, e.g. a malformed positional anchor). Detected via a strict - * re-decode of each input file (see [TrailRoundTripDropDetector]). Empty for clean inputs. - */ - val droppedContent: List = emptyList(), - /** - * Round-trip fidelity mismatches — non-empty when the emitted unified file does NOT decode back - * to the tools/config the migrator intended to write (a serializer that can't round-trip a - * non-default value, a nested arg a concrete tool model drops, a config field lost on re-encode). - * Produced by [TrailRoundTripFidelityVerifier] by comparing DECODED objects, so behavior-preserving - * normalization (elided defaults, Maestro scalar re-coercion) round-trips equal and never appears - * here. Empty for a faithful migration. - */ - val roundTripMismatches: List = emptyList(), - ) - - data class ConfigDriftEntry( - /** The config field name (e.g. `title`, `priority`, `source`). */ - val field: String, - /** Meaningfully-declared value keyed by source (classifier or `blaze.yaml`) — at least 2 that differ. */ - val valueBySource: Map, - ) - - data class DriftEntry( - val stepIndex: Int, - /** NL string keyed by classifier — at least 2 entries that differ. */ - val nlByClassifier: Map, - ) - - data class KindDriftEntry( - val stepIndex: Int, - /** `true` = `verify:`, `false` = `step:`; keyed by classifier (plus `blaze.yaml` when present). */ - val verifyByClassifier: Map, - ) - - data class MemoryDriftEntry( - /** - * Normalized memory map keyed by classifier. Null `memory:` blocks are normalized to - * `emptyMap()` before comparison so "absent" and "explicitly empty" don't show as drift. - * At least 2 entries that differ when present. - */ - val memoryByClassifier: Map>, - ) - - data class FamilyCollapseEntry( - val family: String, - val members: List, - /** True if the family's sub-classifiers diverged at some step and could not be collapsed. */ - val diverged: Boolean, - ) - - private enum class CollapseAction { COLLAPSED, DIVERGED, NOT_APPLICABLE } - - companion object { - private const val TRAIL_YAML_SUFFIX = ".trail.yaml" - private const val BLAZE_FILENAME = TrailRecordings.BLAZE_DOT_YAML - private const val BLAZE_KEY = "blaze.yaml" - private const val REASON_KEY = "reason" - - /** Build leading-comment lines summarizing drift for inclusion in the migrated file. */ - fun driftComments(drift: List): List = stepDriftComments( - entries = drift, - warning = "WARNING: ${drift.size} step(s) had divergent NL across platforms during migration.", - preference = "Canonical NL preference: blaze.yaml when present, otherwise the first platform. Review the diff:", - stepIndex = { it.stepIndex }, - perClassifierLines = { entry -> - entry.nlByClassifier.map { (classifier, nl) -> - " $classifier: \"${nl.take(SNIPPET_MAX_LEN).replace('\n', ' ')}\"" - } - }, - ) - - /** - * Leading-comment lines summarizing step-kind drift (`step:` vs `verify:`). Kind drift is rarer - * but higher-stakes than NL drift — a step that runs as `step:` on one platform and `verify:` - * on another has different runtime semantics per device. - */ - fun kindDriftComments(kindDrift: List): List = stepDriftComments( - entries = kindDrift, - warning = "WARNING: ${kindDrift.size} step(s) had divergent step kinds (step: vs verify:) across platforms.", - preference = "Canonical kind preference: blaze.yaml when present, otherwise the first platform. Review the diff:", - stepIndex = { it.stepIndex }, - perClassifierLines = { entry -> - entry.verifyByClassifier.map { (classifier, isVerify) -> - " $classifier: ${if (isVerify) "verify" else "step"}" - } - }, - ) - - /** - * Shared scaffold for the per-step drift comment blocks: warning header, canonical-preference - * line, up to [MAX_DRIFT_DETAIL_LINES] step entries, then an overflow tail — so the NL and - * kind variants can't drift apart in shape. - */ - private fun stepDriftComments( - entries: List, - warning: String, - preference: String, - stepIndex: (E) -> Int, - perClassifierLines: (E) -> List, - ): List { - if (entries.isEmpty()) return emptyList() - val lines = mutableListOf(warning, preference) - for (entry in entries.take(MAX_DRIFT_DETAIL_LINES)) { - lines += " step ${stepIndex(entry) + 1}:" - lines += perClassifierLines(entry) - } - if (entries.size > MAX_DRIFT_DETAIL_LINES) { - lines += " ... and ${entries.size - MAX_DRIFT_DETAIL_LINES} more" - } - return lines - } - - /** - * Leading-comment lines summarizing cross-file `config.memory:` drift. Same shape as - * [driftComments] but for the memory block — surfaces every per-platform memory map - * so the user can pick the right canonical set after migration (the migrator picks - * the first file to declare a memory block, which may not be what the user wants). - */ - fun memoryDriftComments(memoryDrift: List): List { - if (memoryDrift.isEmpty()) return emptyList() - val entry = memoryDrift.first() - val lines = mutableListOf() - lines += "WARNING: per-platform v1 files declared divergent `config.memory:` blocks." - lines += "The first file to declare a memory block was used as canonical. Review and reconcile:" - for ((classifier, memory) in entry.memoryByClassifier) { - val rendered = if (memory.isEmpty()) "{}" else memory.entries.joinToString(", ") { - "${it.key}=${it.value.take(SNIPPET_MAX_LEN)}" - } - lines += " $classifier: $rendered" - } - return lines - } - - /** - * Leading-comment lines summarizing scalar-config drift — one block per diverging field, - * listing every file's declared value so the user can fix the canonical pick if the - * first-file-wins fold chose wrong. - */ - fun configDriftComments(configDrift: List): List { - if (configDrift.isEmpty()) return emptyList() - val lines = mutableListOf() - lines += "WARNING: input files declared divergent config values; the first file to declare each field won." - lines += "Review the alternatives below and edit the migrated config if the canonical pick is wrong:" - for (entry in configDrift) { - lines += " ${entry.field}:" - for ((source, value) in entry.valueBySource) { - lines += " $source: \"${value.take(SNIPPET_MAX_LEN).replace('\n', ' ')}\"" - } - } - return lines - } - - /** - * Leading-comment lines naming input content the migration could not carry — keys the lenient - * decode silently drops (a malformed positional anchor, a stale/typo'd field, a tool arg the - * tool doesn't declare). Unlike the drift warnings (which flag a resolved-but-lossy choice), - * this flags content that is simply GONE, so a reviewer can restore or fix it. Groups by file - * and names the dropped key, its YAML path, and its source line. - */ - fun droppedContentComments(droppedContent: List): List { - if (droppedContent.isEmpty()) return emptyList() - val lines = mutableListOf( - "WARNING: ${droppedContent.size} input key(s) did not round-trip through migration and were DROPPED.", - "Each is either a key the schema doesn't recognize (e.g. a malformed positional anchor) or a", - "sibling key in a tool entry that the tool decoder ignores — silently discarded on decode.", - "Review each and re-author it in a valid shape:", - ) - for ((file, entries) in droppedContent.groupBy { it.file }) { - lines += " $file:" - for (entry in entries) { - lines += " dropped `${entry.key}` at ${entry.path} (line ${entry.line})" - } - } - return lines - } - - /** - * Leading-comment lines naming any place the emitted unified file does not decode back to the - * intended tools/config — a genuine, behavior-changing fidelity loss (distinct from the - * behavior-preserving normalization the verifier tolerates). Empty for a faithful migration. - */ - fun roundTripMismatchComments(mismatches: List): List { - if (mismatches.isEmpty()) return emptyList() - val lines = mutableListOf( - "WARNING: ${mismatches.size} location(s) did NOT round-trip — the emitted file decodes back to", - "different tools/config than the migrator intended. This is a real fidelity loss (not cosmetic default", - "elision). Review each and fix the tool/config serializer or re-author the affected step:", - ) - for (entry in mismatches.take(MAX_DRIFT_DETAIL_LINES)) { - lines += " ${entry.location}:" - entry.detail.split('\n').forEach { lines += " $it" } - } - if (mismatches.size > MAX_DRIFT_DETAIL_LINES) { - lines += " ... and ${mismatches.size - MAX_DRIFT_DETAIL_LINES} more" - } - return lines - } - - /** - * A migration is lossy when the emitted unified file no longer carries everything the inputs - * meant — either input content the decode couldn't round-trip ([Report.droppedContent]) or a - * value that doesn't survive the migrator's serialize/re-decode reshape ([Report.roundTripMismatches]). - * Both callers of "was this lossy?" — the CLI's `--fail-on-dropped-content` exit-code gate and the - * Trail Runner bundle path's decision to RETAIN the v1 inputs rather than delete them — must agree, - * so the definition lives here once. - */ - fun isLossyMigration(report: Report): Boolean = - report.droppedContent.isNotEmpty() || report.roundTripMismatches.isNotEmpty() - - private const val MAX_DRIFT_DETAIL_LINES = 5 - private const val SNIPPET_MAX_LEN = 100 - } -} - -/** - * Helper extension that returns the contextual tool-wrapper serializer - * registered on the [TrailblazeYaml] instance. [TrailblazeYaml] keeps it - * private; the migrator pulls it back out via the serializers module so it - * can re-encode tool lists into YAML for the canonical-form comparison - * (with `reason:` stripped) used by family collapse. - */ -private fun TrailblazeYaml.toolWrapperSerializer(): kotlinx.serialization.KSerializer = - getInstance().serializersModule.getContextual(TrailblazeToolYamlWrapper::class) - ?: error("TrailblazeYaml is missing the TrailblazeToolYamlWrapper contextual serializer") diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BlazeRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BlazeRoutes.kt index ad5922af3..38f2ff24c 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BlazeRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BlazeRoutes.kt @@ -2,7 +2,6 @@ package xyz.block.trailblaze.trailrunner import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode -import io.ktor.server.application.ApplicationCall import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.response.respondText @@ -12,7 +11,8 @@ import io.ktor.server.routing.post import io.ktor.server.routing.put import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import xyz.block.trailblaze.ui.TrailblazeDesktopUtil import xyz.block.trailblaze.util.Console @@ -21,19 +21,6 @@ import xyz.block.trailblaze.util.Console * (create / detail / file edit / record variants / delete). A bundle is a library folder with a * `blaze.yaml` spec plus accumulating `.trail.yaml` recordings - see [BundleStore]. */ -// This server has no ContentNegotiation plugin: a `call.respond(status, )` throws at -// runtime instead of serializing. Every DTO body in this file goes through this hand-encoded -// respond instead. -private suspend fun ApplicationCall.respondJson( - serializer: KSerializer, - body: T, - status: HttpStatusCode = HttpStatusCode.OK, -) = respondText( - text = JSON.encodeToString(serializer, body), - contentType = ContentType.Application.Json, - status = status, -) - /** Resolves a bundle folder id against the current trail roots, on the IO dispatcher. */ private suspend fun resolveBundle(deps: TrailRunnerDeps, id: String, requireBlaze: Boolean): BundleStore.ResolvedBundle? = withContext(Dispatchers.IO) { @@ -46,18 +33,46 @@ internal fun Route.blazeRoutes(deps: TrailRunnerDeps) { // pass. The actual LLM/exploration work is supplied by the desktop app via [deps.proposeStepsProvider] // (the route module has no LLM credentials of its own). post("$PATH_BASE/api/blaze/propose") { - val provider = deps.proposeStepsProvider - if (provider == null) { - call.respondJson(ProposeResponse.serializer(), ProposeResponse(error = "step proposer not available"), HttpStatusCode.ServiceUnavailable) - return@post - } + // Body first, provider second: the shared-brain defer below must work with no provider wired + // at all, so a bad body is now a 400 even when the proposer is absent (was a blanket 503). val body = runCatching { call.receive() }.getOrNull() val objective = body?.objective?.trim().orEmpty() if (objective.isEmpty()) { call.respondJson(ProposeResponse.serializer(), ProposeResponse(error = "objective is required"), HttpStatusCode.BadRequest) return@post } - if (body!!.ground && body.trailblazeDeviceId == null) { + // Shared brain: when the human's own agent CLI is attached to the folder this ask concerns, + // hand the ask to it instead of the daemon's wired LLM. No matching companion -> the normal + // provider path below, untouched. + val folderRel = body!!.folder?.trim()?.trim('/')?.takeIf { it.isNotEmpty() } ?: companionRelFor(body.bundleId, null) + val payload = JsonObject( + buildMap { + put("objective", JsonPrimitive(objective)) + body.target?.trim()?.takeIf { it.isNotEmpty() }?.let { put("target", JsonPrimitive(it)) } + body.platform?.trim()?.takeIf { it.isNotEmpty() }?.let { put("platform", JsonPrimitive(it)) } + folderRel?.let { put("folder", JsonPrimitive(it)) } + }, + ) + when (val deferred = ExternalAgentSupervisor.deferToCompanion("propose-steps", folderRel, payload)) { + is DeferOutcome.Deferred -> { + call.respondJson(ProposeResponse.serializer(), ProposeResponse(deferred = true, requestId = deferred.requestId, runId = deferred.runId)) + return@post + } + is DeferOutcome.Degraded -> { + call.respondJson( + ProposeResponse.serializer(), + ProposeResponse(degraded = true, runId = deferred.runId, error = COMPANION_AGENT_NOT_LISTENING), + ) + return@post + } + DeferOutcome.None -> {} + } + val provider = deps.proposeStepsProvider + if (provider == null) { + call.respondJson(ProposeResponse.serializer(), ProposeResponse(error = "step proposer not available"), HttpStatusCode.ServiceUnavailable) + return@post + } + if (body.ground && body.trailblazeDeviceId == null) { call.respondJson(ProposeResponse.serializer(), ProposeResponse(error = "grounding requires a connected device"), HttpStatusCode.BadRequest) return@post } @@ -209,55 +224,6 @@ internal fun Route.blazeRoutes(deps: TrailRunnerDeps) { call.respondJson(OkResponse.serializer(), OkResponse(ok = ok)) } - // Migrate a legacy per-platform bundle folder into a single unified `.trail.yaml` (deleting - // the per-platform inputs + blaze.yaml it consumed). Backs the Trails "Migrate to unified" button. - // The heavy lifting is BundleMigration/UnifiedTrailMigrator; this just resolves the folder and maps - // the migrator's refusals (top-level tools, trailhead, already-migrated) to a 400 with a reason. - post("$PATH_BASE/api/folder/migrate-unified") { - val id = call.request.queryParameters["id"]?.trim().orEmpty() - if (id.isEmpty()) { - call.respondJson(MigrateFolderResponse.serializer(), MigrateFolderResponse(success = false, error = "id is required"), HttpStatusCode.BadRequest) - return@post - } - val resolved = resolveBundle(deps, id, requireBlaze = false) - // resolve() only path-validates - it returns a ResolvedBundle for a directory that does not exist. - // Without the isDirectory check, a missing folder would fall through to the migrator and surface - // as a 400 with its internal "no *.trail.yaml files" message instead of a plain 404. - if (resolved == null || !withContext(Dispatchers.IO) { resolved.dir.isDirectory }) { - call.respondJson(MigrateFolderResponse.serializer(), MigrateFolderResponse(success = false, error = "folder not found"), HttpStatusCode.NotFound) - return@post - } - val outcome = withContext(Dispatchers.IO) { runCatching { BundleMigration.migrateFolder(resolved.dir) } } - outcome.fold( - onSuccess = { o -> - // The one server-side record of this destructive action — which folder, what was written, - // and exactly which inputs were deleted (the response's `removed` list isn't persisted). - Console.log( - "[BlazeRoutes] migrate-unified '$id': wrote ${o.outputName}, " + - "removed [${o.removed.joinToString()}], ${o.driftComments.size} drift warning(s)", - ) - call.respondJson( - MigrateFolderResponse.serializer(), - MigrateFolderResponse( - success = true, - outputName = o.outputName, - steps = o.steps, - driftCount = o.driftComments.size, - drift = o.driftComments, - removed = o.removed, - ), - ) - }, - onFailure = { e -> - // IllegalArgumentException = the migrator (or BundleMigration) refused the input: no v1 files, - // a top-level `- tools:` block, a `- trailhead:`, or an already-migrated folder. Everything - // else is an unexpected server error. - val status = if (e is IllegalArgumentException) HttpStatusCode.BadRequest else HttpStatusCode.InternalServerError - call.respondJson(MigrateFolderResponse.serializer(), MigrateFolderResponse(success = false, error = e.message ?: (e::class.simpleName ?: "migration failed")), status) - }, - ) - } - // Record one variant per selected device into a library folder: dispatch the folder's blaze steps // to each device with recording capture on. Each run carries bundleId+variant so the recorded YAML // lands back in the folder on completion (see [maybeWriteBundleVariant]). Requires a blaze.yaml. diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleMigration.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleMigration.kt deleted file mode 100644 index e87bfed27..000000000 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleMigration.kt +++ /dev/null @@ -1,136 +0,0 @@ -package xyz.block.trailblaze.trailrunner - -import xyz.block.trailblaze.migration.UnifiedTrailMigrator -import xyz.block.trailblaze.yaml.TrailblazeYaml -import xyz.block.trailblaze.yaml.unified.TrailDocument -import java.io.File - -/** - * Converts a legacy per-platform bundle folder into a single unified `.trail.yaml`, backing the Trail - * Runner "Migrate to unified" action. Thin wrapper over [UnifiedTrailMigrator] (which does the real - * reconciliation — NL-drift detection, per-classifier drivers/skip, family collapse, memory drift) - * that adds the file mutation the UI needs: write the unified file into the folder as - * `.trail.yaml`, then delete the per-platform inputs (+ `blaze.yaml`) the migrator consumed — - * except when the migration was lossy (dropped input content, or a round-trip fidelity mismatch), - * when the inputs are retained (see [Outcome.inputsRetained]) so the only surviving copy isn't destroyed. - * - * The side effects live in [migrateFolder] so it can be unit-tested against a temp dir with real - * bundle content (see `BundleMigrationTest`), rather than only through the daemon route. - */ -object BundleMigration { - - private const val TRAIL_SUFFIX = ".trail.yaml" - private const val BLAZE_FILENAME = "blaze.yaml" - - data class Outcome( - /** Name of the unified file written into the folder (`.trail.yaml`). */ - val outputName: String, - /** Number of steps in the unified trail. */ - val steps: Int, - /** - * Migration warnings surfaced to the route/UI: NL / kind / config / memory drift + dropped-content - * warnings, plus a leading retention note when [inputsRetained]. Superset of the leading comments - * written into the file (the file omits the transient retention note). - */ - val driftComments: List, - /** Per-platform input files (+ `blaze.yaml`) that were deleted. Empty when [inputsRetained]. */ - val removed: List, - /** - * True when the migration was lossy — the migrator dropped input content it could not carry, or - * the emitted file did not round-trip back to the intended tools/config — and the v1 inputs were - * therefore LEFT IN PLACE beside the written `trail.yaml` for manual reconciliation rather than - * deleted. [driftComments] leads with a note explaining the retention. - */ - val inputsRetained: Boolean = false, - ) - - /** - * Migrate [dir] in place: write `.trail.yaml`, then delete the consumed v1 inputs — - * UNLESS the migration was lossy (dropped input content, or a round-trip fidelity mismatch), in - * which case the inputs are left in place so the lost content isn't destroyed (see - * [Outcome.inputsRetained]). - * - * Throws [IllegalArgumentException] when the migrator refuses the input (no `*.trail.yaml` files, a - * top-level `- tools:` block, or a `- trailhead:` block — see [UnifiedTrailMigrator]), or when [dir] - * already contains the target unified file (looks already migrated). Never deletes the output file. - */ - fun migrateFolder(dir: File, yaml: TrailblazeYaml = TrailblazeYaml.Default): Outcome { - // Guard BEFORE migrating: the target unified file would otherwise be picked up as a v1 input by - // the migrator (it globs `*.trail.yaml`) and fail to decode as a list — refuse cleanly instead. - val outName = dir.name + TRAIL_SUFFIX - val outFile = File(dir, outName) - require(!outFile.exists()) { - "A unified file ($outName) already exists in ${dir.name} — it looks already migrated." - } - - // Defense-in-depth: migration is only meaningful for a folder of LEGACY v1 per-platform files. - // The migrator only refuses a unified file that HAS recordings — a folder of distinct *prompt-only* - // unified trails would otherwise be merged into one and its files deleted (silent data loss; that - // folder is reachable in the UI via the "back to implementations" arrow). Refuse if ANY input is - // already unified — including a (nonsensical but possible) unified-format `blaze.yaml`, since the - // migrator would fold + delete it too. A file that decodes as neither shape is left for the - // migrator to reject. A genuine v1 file/blaze decodes as V1, so this never false-refuses a bundle. - val candidateFiles = dir - .listFiles { f -> f.isFile && (f.name.endsWith(TRAIL_SUFFIX) || f.name == BLAZE_FILENAME) } - .orEmpty() - val unifiedInputs = candidateFiles.filter { f -> - runCatching { yaml.decodeTrailDocument(f.readText()) }.getOrNull() is TrailDocument.Unified - } - require(unifiedInputs.isEmpty()) { - "Refusing to migrate ${dir.name}: ${unifiedInputs.size} file(s) are already unified " + - "(${unifiedInputs.joinToString { it.name }}) — this isn't a legacy per-platform bundle." - } - - val result = UnifiedTrailMigrator(yaml).migrate(dir) // throws IllegalArgumentException on refuse - // All four drift channels, matching the CLI path (MigrateTrailsCommand) — kind drift (step-vs- - // verify disagreement across platforms) and config drift (divergent scalar config resolved - // first-file-wins) are the higher-stakes ones and must not be silently flattened. - val comments = UnifiedTrailMigrator.driftComments(result.report.drift) + - UnifiedTrailMigrator.kindDriftComments(result.report.kindDrift) + - UnifiedTrailMigrator.memoryDriftComments(result.report.memoryDrift) + - UnifiedTrailMigrator.configDriftComments(result.report.configDrift) + - UnifiedTrailMigrator.droppedContentComments(result.report.droppedContent) + - UnifiedTrailMigrator.roundTripMismatchComments(result.report.roundTripMismatches) - val text = yaml.encodeUnifiedTrailToString(result.trail, comments) - outFile.writeText(text) - - // Files the migrator consumed that we'd otherwise delete (per-platform files + blaze.yaml), - // narrowed to the ones actually present. Never the output; only files that canonicalize inside - // dir (defense in depth against a crafted name). - val consumed = (result.report.platformFilesLoaded + BLAZE_FILENAME).distinct() - .filter { it != outName } - .map { File(dir, it) } - .filter { it.isFile && it.canonicalPath.startsWith(dir.canonicalPath + File.separator) } - - // A lossy migration must not destroy its own source. When the migrated file only WARNS about - // content the migrator couldn't faithfully carry, the v1 file is the only place that content - // still exists — so leave both the v1 inputs and trail.yaml on disk for a human to reconcile, - // rather than deleting the inputs. Lossy = dropped input keys OR a round-trip fidelity mismatch - // (a value that doesn't survive serialize/re-decode); the definition is shared with the CLI's - // lossy-migration exit-code decision (MigrateTrailsCommand) via isLossyMigration so the two - // paths can't contradict each other. - val inputsRetained = UnifiedTrailMigrator.isLossyMigration(result.report) - - val removed = mutableListOf() - if (!inputsRetained) { - for (f in consumed) { - if (f.delete()) removed += f.name - } - } - - // Surface WHY the inputs were retained so the route/UI (which shows driftComments) can explain - // the v1 files still sitting beside trail.yaml. Kept out of the file's own leading comments, - // which describe content — not this transient on-disk state that ends once the files are reconciled. - val outcomeComments = - if (inputsRetained) inputsRetainedComments(consumed.map { it.name }) + comments else comments - return Outcome(outName, result.trail.trail.size, outcomeComments, removed, inputsRetained) - } - - /** Leading lines explaining that a lossy migration left the v1 inputs in place (see [migrateFolder]). */ - private fun inputsRetainedComments(retained: List): List = listOf( - "NOTE: kept the v1 input file(s) in place — this migration lost content that did not round-trip", - "(see the DROPPED / round-trip WARNING lines below), so the originals are the only surviving record.", - "Reconcile the lost content into the migrated file, then delete the v1 file(s) manually:", - " ${retained.joinToString()}", - ) -} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleStore.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleStore.kt index 7a63e0495..224c3e1d8 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleStore.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/BundleStore.kt @@ -116,20 +116,29 @@ internal object BundleStore { /** Recursively deletes the bundle folder. Returns false if the OS reports the delete didn't fully succeed. */ fun delete(dir: File): Boolean = dir.deleteRecursively() + /** The file-name slug a variant writes as: `.trail.yaml`. */ + fun variantSlug(variant: String): String = + variant.lowercase().replace(Regex("[^a-z0-9_-]"), "-").trim('-').ifEmpty { "variant" } + /** Writes a recorded variant produced from a finished run into the bundle folder. Writes to a temp * sibling then atomically moves it into place, so a concurrent reader (the board reload, an inline - * edit) never observes a partially-written YAML and a re-record can't interleave a half file. */ - fun writeVariant(dir: File, variant: String, yaml: String) { - val safe = variant.lowercase().replace(Regex("[^a-z0-9_-]"), "-").trim('-').ifEmpty { "variant" } + * edit) never observes a partially-written YAML and a re-record can't interleave a half file. + * Returns the written file, or null when the write failed (callers that must announce the save - + * the companion recording-saved event - gate on it; the run paths ignore it). */ + fun writeVariant(dir: File, variant: String, yaml: String): File? { + val safe = variantSlug(variant) // Unique tmp name per write so two concurrent re-records of the same platform each own their own // scratch file (a shared `..trail.yaml.tmp` would let them interleave bytes before the move). val tmp = File(dir, ".$safe.trail.yaml.${java.util.UUID.randomUUID()}.tmp") - runCatching { + return runCatching { tmp.writeText(yaml) - Files.move(tmp.toPath(), File(dir, "$safe.trail.yaml").toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) - }.onFailure { + val target = File(dir, "$safe.trail.yaml") + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + target + }.getOrElse { runCatching { tmp.delete() } // don't leak the scratch file if the move fails (e.g. cross-device workspace) Console.log("[BundleStore] failed to write variant $safe into ${dir.name}: ${it.message}") + null } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionDtos.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionDtos.kt new file mode 100644 index 000000000..2e66bd7be --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionDtos.kt @@ -0,0 +1,178 @@ +package xyz.block.trailblaze.trailrunner + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// ─── Companion sessions ────────────────────────────────────────────────────── +// +// The inverse of a spawned run: the agent CLI already exists OUTSIDE Trail Runner (the human's own +// Claude Code / Codex session, running in their own repo) and attaches here as a companion. The +// daemon owns no process - the external agent streams its narration as events and declares the +// trail folder it is authoring on disk, which the live trail rail watches. + +/** Companion attachment details, present only on companion runs. */ +@Serializable +data class CompanionStateDto( + /** How the external agent introduced itself, e.g. "Claude Code · myapp". */ + val agentLabel: String? = null, + /** Trail folder the agent is authoring, relative to the primary trails root; null until declared. */ + val folder: String? = null, + /** + * The standing directives (latest per name), keyed by directive. The daemon owns this state so + * it survives event retention and window reloads - the companion screen derives its guidance + * cards from here, not by replaying the transcript. Never contains `navigate` (live-only). + */ + val directives: Map = emptyMap(), + /** + * The shared-brain request queue (all requests this session, keyed by requestId). Daemon-owned + * like [directives] so a reloaded window reconciles its spinners from here, not the transcript. + */ + val requests: Map = emptyMap(), +) + +/** One standing companion directive: the payload the agent last sent under this name. */ +@Serializable +data class CompanionDirectiveDto( + /** Seq of the UI_COMMAND event that set it - the correlation id quick replies carry back. */ + val seq: Int, + /** The directive's payload as compact JSON, same encoding as the event's `input` field. */ + val payload: String? = null, +) + +/** + * One shared-brain request: a Trail Runner "brainy" UI action (propose steps, review my trail) + * deferred to the attached agent instead of a second LLM. Enqueued only by the daemon's defer + * path; the agent may only settle it via respond. + */ +@Serializable +data class CompanionRequestDto( + val requestId: String, + /** What is being asked, e.g. "review-trail" or "propose-steps". */ + val kind: String, + /** Request context as compact JSON, same encoding as the agent-request event's `input`. */ + val payload: String? = null, + /** pending | done | error | cancelled. */ + val status: String, + /** Optional agent-supplied note carried on the respond. */ + val note: String? = null, +) + +/** Body of `/companion/{id}/respond`: the agent settling a pending shared-brain request. */ +@Serializable +data class CompanionRespondRequest( + val requestId: String? = null, + /** done | error. */ + val status: String? = null, + val note: String? = null, +) + +/** + * Body of `/companion/connect`. [folder] is validated (containment under the primary trails root) + * but does not need to exist yet - the agent may attach before its first write. + */ +@Serializable +data class CompanionConnectRequest( + /** Which vendor CLI is attaching (display/labeling only - no process is spawned). Default claude. */ + val agentType: ExternalAgentType? = null, + val agentLabel: String? = null, + val title: String? = null, + val folder: String? = null, +) + +/** + * Response to `/companion/connect`. [primaryRoot] is the daemon's effective primary trails root so + * the attaching CLI can detect a workspace mismatch (daemon rooted at a different workspace) and + * warn the human instead of silently watching the wrong tree. [runId] is flat and first so the + * shell launcher can extract it with a plain regex. + */ +@Serializable +data class CompanionConnectResponse( + val ok: Boolean, + val runId: String? = null, + val primaryRoot: String? = null, + val error: String? = null, +) + +/** Body of `/companion/{id}/event`: one narration event from the attached external agent. */ +@Serializable +data class CompanionEventRequest( + /** assistant_message (default), lifecycle, or error. */ + val kind: String? = null, + val title: String? = null, + val text: String? = null, +) + +/** Body of `/companion/{id}/disconnect`. */ +@Serializable +data class CompanionDisconnectRequest(val note: String? = null) + +/** + * Body of `/companion/{id}/directive`: one UI directive from the attached external agent - the + * agent steering what the companion window shows (a banner, a checklist, quick replies, an armed + * recording). Rides the run's event stream as a UI_COMMAND event whose title is the directive and + * whose input is [payload], and (except `navigate`, which is live-only) also lands in the run's + * standing-directive state ([CompanionStateDto.directives]), which is what the companion screen + * renders - so a reloaded window rebuilds the same view without replaying the transcript. + */ +@Serializable +data class CompanionDirectiveRequest( + /** navigate, banner, checklist, actions, select-device, select-app-target, or arm-recording. */ + val directive: String? = null, + /** Directive-specific fields (text, items, route, …). An empty or absent payload RETRACTS the directive. */ + val payload: JsonElement? = null, +) + +/** + * Body of `/companion/{id}/user-action`: something the human did in the companion window, streamed + * back to the attached agent (which tails the run's SSE stream / journal). Rides the event stream + * as a HUMAN_ACTION event whose title is the [type] and whose input is [payload]. + */ +@Serializable +data class CompanionUserActionRequest( + /** user-action (a quick reply), handback, or device-connected. `recording-saved` is NOT postable: only the daemon's own save path emits it, so hearing it means the write really landed. */ + val type: String? = null, + val payload: JsonElement? = null, +) + +/** + * Body of `/companion/{id}/save-recording`: the single sanctioned UI write in companion mode - a + * recorded variant saved into the session's declared folder. The daemon resolves the destination + * itself and emits the recording-saved user action after the write lands. + */ +@Serializable +data class CompanionSaveRecordingRequest( + /** Variant name, normally the platform key (ios, android); becomes `.trail.yaml`. */ + val variant: String? = null, + val yaml: String? = null, + /** Device platform that recorded it (ios, android); echoed on the recording-saved event so the agent needn't infer it from the variant name. */ + val platform: String? = null, +) + +@Serializable +data class CompanionSaveRecordingResponse( + val ok: Boolean, + val savedPath: String? = null, + val error: String? = null, +) + +/** One entry of the demo-files tree: a file or directory inside the declared trail folder. */ +@Serializable +data class CompanionFolderEntryDto( + /** Path relative to the declared folder, `/`-separated. */ + val path: String, + val dir: Boolean = false, + /** Size in bytes; 0 for directories. */ + val size: Long = 0, +) + +/** Response to `/companion/{id}/folder-tree`: the declared folder's recursive listing. */ +@Serializable +data class CompanionFolderTreeResponse( + val ok: Boolean, + val entries: List = emptyList(), + val error: String? = null, +) + +/** Body of `/companion/{id}/open-file`: open one file of the declared folder in the human's editor. */ +@Serializable +data class CompanionOpenFileRequest(val path: String? = null) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionRoutes.kt new file mode 100644 index 000000000..706db9ab4 --- /dev/null +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/CompanionRoutes.kt @@ -0,0 +1,362 @@ +package xyz.block.trailblaze.trailrunner + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.contentLength +import io.ktor.server.request.contentType +import io.ktor.server.request.receive +import io.ktor.server.request.receiveParameters +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import xyz.block.trailblaze.ui.TrailblazeDesktopUtil +import java.io.File + +/** + * Companion sessions: the attach surface for an agent CLI running OUTSIDE Trail Runner - the + * human's own Claude Code / Codex session, working in their own repo. The file contract stays on + * disk (the agent authors the trail folder directly; the folder-content route lets the UI watch + * it); this surface carries only what files can't: attach, narration events, and detach. + * + * Request bodies are accepted as JSON or form-urlencoded. The form path exists for the + * `trailblaze companion` launcher verbs, which build requests with `curl --data-urlencode` so + * narration text needs no shell-side JSON escaping. + */ +internal fun Route.companionRoutes(deps: TrailRunnerDeps) { + post("$PATH_BASE/api/companion/connect") { + if (call.rejectedCrossOrigin()) return@post + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> + CompanionConnectRequest( + agentType = p["agentType"]?.trim()?.takeIf { it.isNotEmpty() }?.let(::parseCompanionAgentType), + agentLabel = p["agentLabel"], + title = p["title"], + folder = p["folder"], + ) + }, + ).getOrElse { failure -> + call.respondJson( + CompanionConnectResponse.serializer(), + CompanionConnectResponse(ok = false, error = bodyErrorMessage(failure)), + HttpStatusCode.BadRequest, + ) + return@post + } + // The env-var override (TRAILBLAZE_TRAILS_DIR) must apply here: a daemon spawned by + // `trailblaze companion start` is rooted through exactly that variable. canonicalPath stays + // inside the runCatching so an I/O hiccup surfaces as structured JSON, not a naked 500. + val result = runCatching { resolvePrimaryRoot(deps.trailsRootProvider).canonicalFile } + .mapCatching { root -> root.path to ExternalAgentSupervisor.startCompanion(body, root).getOrThrow() } + val response = result.fold( + onSuccess = { (root, run) -> CompanionConnectResponse(ok = true, runId = run.id, primaryRoot = root) }, + onFailure = { CompanionConnectResponse(ok = false, error = it.message ?: "could not connect the companion session") }, + ) + call.respondJson( + CompanionConnectResponse.serializer(), + response, + if (response.ok) HttpStatusCode.OK else HttpStatusCode.BadRequest, + ) + } + + post("$PATH_BASE/api/companion/{id}/event") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionEventRequest(kind = p["kind"], title = p["title"], text = p["text"]) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + val result = ExternalAgentSupervisor.companionEvent(id, body.kind, body.title, body.text) + call.respondCompanionResult(result, "could not record the event") + } + + post("$PATH_BASE/api/companion/{id}/directive") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionDirectiveRequest(directive = p["directive"], payload = payloadFromForm(p)) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + val result = ExternalAgentSupervisor.companionDirective(id, body.directive, body.payload) + call.respondCompanionResult(result, "could not send the directive") + } + + // The UI's report of what the human did (a quick reply, a handback, a connected device). Guarded + // like every mutating POST; the attached agent reads these off the run's SSE stream / journal. + post("$PATH_BASE/api/companion/{id}/user-action") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionUserActionRequest(type = p["type"], payload = payloadFromForm(p)) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + val result = ExternalAgentSupervisor.companionUserAction(id, body.type, body.payload) + call.respondCompanionResult(result, "could not record the user action") + } + + // The one sanctioned UI write in companion mode: a recorded variant into the declared folder. + post("$PATH_BASE/api/companion/{id}/save-recording") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionSaveRecordingRequest(variant = p["variant"], yaml = p["yaml"], platform = p["platform"]) }, + ).getOrElse { failure -> + call.respondJson( + CompanionSaveRecordingResponse.serializer(), + CompanionSaveRecordingResponse(ok = false, error = bodyErrorMessage(failure)), + HttpStatusCode.BadRequest, + ) + return@post + } + val result = runCatching { resolvePrimaryRoot(deps.trailsRootProvider) } + .mapCatching { root -> ExternalAgentSupervisor.companionSaveRecording(id, body.variant, body.yaml, root, body.platform).getOrThrow() } + val status = result.exceptionOrNull().toCompanionStatus() + val response = result.fold( + onSuccess = { CompanionSaveRecordingResponse(ok = true, savedPath = it) }, + onFailure = { CompanionSaveRecordingResponse(ok = false, error = it.message ?: "could not save the recording") }, + ) + call.respondJson(CompanionSaveRecordingResponse.serializer(), response, status) + } + + // The agent settles a shared-brain request the daemon queued on this session (see + // deferToCompanion) - "I reviewed the trail: done" / "couldn't: error". + post("$PATH_BASE/api/companion/{id}/respond") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionRespondRequest(requestId = p["requestId"], status = p["status"], note = p["note"]) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + val result = ExternalAgentSupervisor.companionRespond(id, body.requestId, body.status, body.note) + call.respondCompanionResult(result, "could not record the response") + } + + post("$PATH_BASE/api/companion/{id}/disconnect") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + // A missing/empty body is a plain disconnect with no note; a body that is present but + // unreadable is a caller mistake (the note it meant to attach would be silently dropped), + // and gets the same 400 as the other verbs. + val body = if ((call.request.contentLength() ?: 0L) == 0L) { + CompanionDisconnectRequest() + } else { + call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionDisconnectRequest(note = p["note"]) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + } + val result = ExternalAgentSupervisor.disconnectCompanion(id, body.note) + call.respondCompanionResult(result, "could not disconnect") + } + + // The live folder view: the files of the trail folder this companion session declared. Same + // response shape as `/demo/trail-content`, so the web's live trail rail renders both. + get("$PATH_BASE/api/companion/{id}/folder-content") { + if (call.rejectedNonLocalHost()) return@get + val id = call.parameters["id"]?.trim().orEmpty() + val response = runCatching { resolvePrimaryRoot(deps.trailsRootProvider) } + .mapCatching { root -> + val result = ExternalAgentSupervisor.companionFolderContent(id, root) + ?: return@mapCatching DemoTrailContentResponse(ok = false, error = "unknown or non-companion run: $id") + DemoTrailContentResponse(ok = true, trailId = result.trailId, files = result.files) + } + .getOrElse { DemoTrailContentResponse(ok = false, error = it.message ?: "could not read the folder") } + call.respondJson( + DemoTrailContentResponse.serializer(), + response, + if (response.ok) HttpStatusCode.OK else HttpStatusCode.NotFound, + ) + } + + // The demo-files tree: the declared folder's recursive listing, names and sizes only - file + // contents stay behind the per-file affordances (folder-content, open-file). + get("$PATH_BASE/api/companion/{id}/folder-tree") { + if (call.rejectedNonLocalHost()) return@get + val id = call.parameters["id"]?.trim().orEmpty() + val response = runCatching { resolvePrimaryRoot(deps.trailsRootProvider) } + .mapCatching { root -> + val entries = ExternalAgentSupervisor.companionFolderTree(id, root) + ?: return@mapCatching CompanionFolderTreeResponse(ok = false, error = "unknown or non-companion run: $id") + CompanionFolderTreeResponse(ok = true, entries = entries) + } + .getOrElse { CompanionFolderTreeResponse(ok = false, error = it.message ?: "could not list the folder") } + call.respondJson( + CompanionFolderTreeResponse.serializer(), + response, + if (response.ok) HttpStatusCode.OK else HttpStatusCode.NotFound, + ) + } + + // "Open in Finder" on the demo-files tab: reveal the declared folder in the platform file browser. + post("$PATH_BASE/api/companion/{id}/reveal-folder") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val result = runCatching { + val root = resolvePrimaryRoot(deps.trailsRootProvider) + val dir = ExternalAgentSupervisor.companionFolderDir(id, root) + ?: throw NoSuchElementException("this session has no trail folder on disk") + TrailblazeDesktopUtil.revealFileInFinder(dir) + } + call.respondCompanionResult(result, "could not reveal the folder") + } + + // Right-click on a demo file: open it in the human's editor. The path resolves strictly inside + // the session's declared folder - the same canonical-containment check as every read of it. + post("$PATH_BASE/api/companion/{id}/open-file") { + if (call.rejectedCrossOrigin()) return@post + val id = call.parameters["id"]?.trim().orEmpty() + val body = call.receiveJsonOrForm( + json = { call.receive() }, + form = { p -> CompanionOpenFileRequest(path = p["path"]) }, + ).getOrElse { failure -> + call.respondJson(OkResponse.serializer(), OkResponse(ok = false, error = bodyErrorMessage(failure)), HttpStatusCode.BadRequest) + return@post + } + val result = runCatching { + val rel = body.path?.trim().orEmpty() + require(rel.isNotEmpty()) { "path is required" } + val root = resolvePrimaryRoot(deps.trailsRootProvider) + val dir = ExternalAgentSupervisor.companionFolderDir(id, root) + ?: throw NoSuchElementException("this session has no trail folder on disk") + val file = File(dir, rel).canonicalFile + require(file.path.startsWith(dir.path + File.separator) && file.isFile) { "no such file in this session's folder: $rel" } + require(openInEditor(file)) { "no editor could open the file" } + } + call.respondCompanionResult(result, "could not open the file") + } +} + +/** Unknown run -> 404 (the codebase convention); validation failures -> 400; success -> 200. */ +private fun Throwable?.toCompanionStatus(): HttpStatusCode = when (this) { + null -> HttpStatusCode.OK + is NoSuchElementException -> HttpStatusCode.NotFound + else -> HttpStatusCode.BadRequest +} + +private suspend fun ApplicationCall.respondCompanionResult(result: Result, fallbackError: String) { + val failure = result.exceptionOrNull() + val response = if (failure == null) OkResponse(ok = true) else OkResponse(ok = false, error = failure.message ?: fallbackError) + respondJson(OkResponse.serializer(), response, failure.toCompanionStatus()) +} + +/** + * CSRF guard for the form-accepting POSTs: form-urlencoded is a browser "simple request" (no CORS + * preflight), so without this a drive-by web page could create companion runs on the local daemon. + * Browsers always attach an Origin header to cross-origin POSTs; curl (the launcher) sends none. + * Allowlisted by Origin HOST, not Origin-equals-Host equality: a DNS-rebound page carries its own + * hostname in both headers, so equality would pass exactly the attack this guard exists to stop. + * Matched structurally (not URL-parsed): parsers default missing hosts to localhost, which would + * wave through the literal `Origin: null` a sandboxed iframe sends. Anything non-local fails closed. + */ +private val LOCAL_ORIGIN = Regex("""https?://(localhost|127\.0\.0\.1|\[::1])(:\d+)?""") + +private suspend fun ApplicationCall.rejectedCrossOrigin(): Boolean { + val origin = request.headers["Origin"] ?: return false + if (LOCAL_ORIGIN.matches(origin.trim())) return false + respondJson( + OkResponse.serializer(), + OkResponse(ok = false, error = "cross-origin requests are not allowed"), + HttpStatusCode.Forbidden, + ) + return true +} + +/** + * DNS-rebinding guard for the companion GET routes. Browsers omit Origin on same-origin GETs, so + * [rejectedCrossOrigin] can't cover them: a page at attacker.com rebound to 127.0.0.1 could read + * the trail folder through the daemon. The Host header still carries the attacker's name, so any + * present Host that isn't loopback is refused. Absent Host (non-browser clients like the curl + * launcher) fails open, matching the Origin guard's stance. + */ +private val LOCAL_HOST = Regex("""(localhost|127\.0\.0\.1|\[::1]|::1)(:\d+)?""") + +private suspend fun ApplicationCall.rejectedNonLocalHost(): Boolean { + val host = request.headers["Host"] ?: return false + if (LOCAL_HOST.matches(host.trim())) return false + respondJson( + OkResponse.serializer(), + OkResponse(ok = false, error = "requests to a non-local host are not allowed"), + HttpStatusCode.Forbidden, + ) + return true +} + +/** + * The form path's payload builder: the launcher sends flat convenience fields (curl + * --data-urlencode, so no shell-side JSON assembly), and this reconstructs the payload object the + * JSON path would have carried. `items` is newline-separated - checklist entries and quick-reply + * labels legitimately contain commas - and `payload` accepts one raw JSON object for anything + * beyond the conveniences (a parse failure surfaces as the route's 400). + */ +private fun payloadFromForm(p: Parameters): JsonObject? { + val fields = buildMap { + p["payload"]?.trim()?.takeIf { it.isNotEmpty() }?.let { raw -> + // Strict parse: the lenient instance would accept `{text: hello}` with an unquoted value + // that then fails the "must be a string" type check - a 400 blaming the wrong thing. + // Relaxed JSON should fail here, as a parse error naming the payload. + val parsed = Json.parseToJsonElement(raw) as? JsonObject ?: error("payload must be a JSON object") + putAll(parsed) + } + // Empty values are dropped, not carried: an all-empty form means "no payload", which the + // directive path reads as a retract - `send banner --text ""` clears the banner. + for (key in listOf("text", "route", "variant", "platform", "app", "title", "note", "actionId", "label")) { + p[key]?.takeIf { it.isNotEmpty() }?.let { put(key, JsonPrimitive(it)) } + } + p["items"]?.let { items -> + // Every --item is carried, blanks included: a blank must FAIL validation loudly like it + // does on the JSON path - filtering here would let an all-blank list decay into "no + // payload", which the directive path reads as a RETRACT the agent never asked for. + put("items", JsonArray(items.lineSequence().map { JsonPrimitive(it.trim()) }.toList())) + } + } + return if (fields.isEmpty()) null else JsonObject(fields) +} + +/** Receives the body as form parameters when form-urlencoded, else as JSON. */ +private suspend fun ApplicationCall.receiveJsonOrForm( + json: suspend () -> T, + form: (Parameters) -> T, +): Result = runCatching { + if (request.contentType().withoutParameters().match(ContentType.Application.FormUrlEncoded)) { + form(receiveParameters()) + } else { + json() + } +} + +/** + * An unreadable body is a caller mistake worth explaining (the agentType vocabulary, a require + * message), but deserializer failures can be paragraphs - keep the first line only. + */ +private fun bodyErrorMessage(failure: Throwable): String = + failure.message?.trim()?.lineSequence()?.firstOrNull()?.take(300)?.takeIf { it.isNotEmpty() } + ?: "invalid request body" + +/** The form path's agent-type parse; unknown values are rejected, like the JSON path's enum decode. */ +private fun parseCompanionAgentType(value: String): ExternalAgentType = when (value.trim().lowercase()) { + "claude" -> ExternalAgentType.CLAUDE + "codex" -> ExternalAgentType.CODEX + else -> error("unknown agentType: $value (use claude or codex)") +} diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentRoutes.kt index 8583bef45..5cdac97e4 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentRoutes.kt @@ -30,9 +30,9 @@ private const val EVIDENCE_FILE_MAX_BYTES = 25L * 1024 * 1024 /** Hand-encoded error body with real JSON escaping for caller-supplied fragments. */ private fun errorJson(message: String): String = buildJsonObject { put("error", message) }.toString() -// This server has no ContentNegotiation plugin: a `call.respond(status, )` throws at -// serialization time and reaches the client as a naked HTTP 500. Every DTO body goes through this. -private suspend fun io.ktor.server.application.ApplicationCall.respondJson( +// Response bodies are hand-encoded (rather than relying on content negotiation) so error paths +// reach the client as structured JSON, never a naked 500. `internal` so CompanionRoutes shares it. +internal suspend fun io.ktor.server.application.ApplicationCall.respondJson( serializer: kotlinx.serialization.KSerializer, body: T, status: HttpStatusCode = HttpStatusCode.OK, @@ -474,6 +474,10 @@ internal fun Route.externalAgentRoutes(deps: TrailRunnerDeps) { } } + // `companion listen` tags its stream so the shared-brain defer can tell "an agent is actually + // going to answer" from "only browser windows are watching" (the SPA stream carries no marker). + val agentConsumer = call.request.queryParameters["consumer"] == "agent" + if (agentConsumer) ExternalAgentSupervisor.addAgentConsumer(id) try { while (true) { flushNew() @@ -487,6 +491,8 @@ internal fun Route.externalAgentRoutes(deps: TrailRunnerDeps) { } } catch (e: Throwable) { Console.log("[ExternalAgentRoutes] stream for $id closed: ${e.message}") + } finally { + if (agentConsumer) ExternalAgentSupervisor.removeAgentConsumer(id) } } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentSupervisor.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentSupervisor.kt index d696b3af4..fe6920aa6 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentSupervisor.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ExternalAgentSupervisor.kt @@ -12,6 +12,7 @@ import com.charleskorn.kaml.YamlConfiguration import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -29,6 +30,7 @@ import xyz.block.trailblaze.host.networkcapture.AndroidNetworkCaptureRegistry import xyz.block.trailblaze.scripting.callback.JsScriptingCallbackBaseUrl import xyz.block.trailblaze.util.Console import java.io.File +import java.nio.file.Files import java.util.Collections import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -64,6 +66,56 @@ private const val DEMO_GENERATE_CONTINUE_PROMPT = private const val EXTERNAL_AGENT_IDLE_TIMEOUT_ENV = "TRAILRUNNER_EXTERNAL_AGENT_IDLE_TIMEOUT_MS" private const val EXTERNAL_AGENT_IDLE_TIMEOUT_DEFAULT_MS = 600_000L +// Companion sessions have no child process, so nothing ever exits them on its own: an agent CLI +// that crashes without disconnecting would stay RUNNING (and exempt from pruning) for the daemon's +// lifetime. A slow idle clock reaps them - companion agents legitimately go quiet while they work, +// so this is hours where the process watchdog is minutes. The session cap bounds a caller that +// loops `companion start` without ever disconnecting. +private const val COMPANION_MAX_SESSIONS = 8 +private const val COMPANION_IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000L +private const val COMPANION_IDLE_POLL_MS = 60_000L + +// The save-recording yaml is the one companion input that legitimately exceeds the 32k payload +// bound, but "no bound at all" would let a buggy client push arbitrary heap and disk per request. +// Recorded trails are small YAML; 4M chars is orders of magnitude of headroom. +private const val COMPANION_MAX_RECORDING_CHARS = 4_000_000 + +// Companion journals are crash-recovery state living (gitignored) in the user's repo; kept forever +// they grow .companion/ without bound. Swept at CONNECT, not deleted at disconnect: the sessions +// that need their journal (a crashed or idle-reaped agent resuming with --after) are exactly the +// ones that never ran a clean disconnect. +private const val COMPANION_JOURNAL_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000 + +// Where the journal lives inside the declared folder. Named because the demo-files tree must +// hide exactly this directory - it is the daemon's own state, not something the agent authored. +private const val COMPANION_JOURNAL_DIR_NAME = ".companion" + +// The demo-files tree is a polled listing of an agent-authored folder; a runaway session (or a +// folder declared over something huge) must not turn every poll into a full-disk walk. +private const val COMPANION_TREE_MAX_ENTRIES = 500 + +// The companion directive vocabulary: how the attached agent steers what the companion window +// shows. Closed on purpose - the UI reduces these into view state, so an unknown directive would +// silently render nothing; rejecting it tells the agent immediately. +private val COMPANION_DIRECTIVES = setOf( + "navigate", "banner", "checklist", "actions", "select-device", "select-app-target", "arm-recording", +) + +// What the companion window may report back about the human. Closed like the directive vocabulary: +// the agent scripts against these names (`companion listen`), so a typo'd type must fail loudly at +// the poster, not vanish into the stream. `recording-saved` is deliberately absent: only the +// daemon's own save path emits it, so an agent hearing it knows the write really landed - a +// postable version would make that signal forgeable. +private val COMPANION_USER_ACTIONS = setOf("user-action", "handback", "device-connected") + +// LIFECYCLE receipts the daemon mints itself. The narration surface shares the LIFECYCLE kind, so +// without this blocklist a posted `companion event --kind lifecycle` could wear one of these +// titles in the transcript (it still couldn't carry a payload or touch the requests map). +private val COMPANION_RESERVED_TITLES = setOf( + "agent-request", "request-responded", "run-started", "run-finished", + "recording-saved", "Companion session connected", "Companion session ended", +) + private val TRAILRUNNER_UI_CONTRACT = """ You are running as an external coding-agent CLI supervised by Trail Runner, helping a human author Trailblaze trails: automated UI tests written as short, observable steps against a real device. @@ -160,6 +212,22 @@ internal object ExternalAgentSupervisor { // vendor CLI process. Compile-time visibility widening only — zero runtime behavior change. internal val runs = ConcurrentHashMap() + // Live agent-tagged SSE consumers per run (`companion listen` marks its stream `consumer=agent`; + // the SPA narration stream doesn't, so an open window never reads as a listening agent). This is + // the "is anyone actually going to answer" signal the shared-brain defer checks - a count, not a + // flag, because an agent may legitimately run several listens (e.g. one resuming with --after). + private val agentStreamConsumers = ConcurrentHashMap() + + fun addAgentConsumer(runId: String) { + agentStreamConsumers.merge(runId, 1, Int::plus) + } + + fun removeAgentConsumer(runId: String) { + agentStreamConsumers.compute(runId) { _, n -> if (n == null || n <= 1) null else n - 1 } + } + + fun hasAgentConsumer(runId: String): Boolean = (agentStreamConsumers[runId] ?: 0) > 0 + // The provider registry. Adding a provider in a later PR means: a new ExternalAgentType enum // value, one entry here (display + executable + setup hints), a commandFor branch in // externalAgentCommand, and a stream parser in parseExternalAgentLine. The UI (picker, setup @@ -244,6 +312,13 @@ internal object ExternalAgentSupervisor { fun cancel(id: String): Boolean { val run = runs[id] ?: return false run.cancelRequested = true + // A companion run has no process to kill: Stop is just a disconnect. Routed through + // endCompanion so it serializes with companionEvent and finishes idempotently - a bare + // finish here could append past a racing disconnect's terminal events or flip its status. + if (run.companion != null) { + endCompanion(run, "stopped from Trail Runner") + return true + } run.process?.let { process -> run.emit( kind = ExternalAgentEventKind.LIFECYCLE, @@ -386,15 +461,24 @@ internal object ExternalAgentSupervisor { * into an external-agent conversation as a [ExternalAgentEventKind.HUMAN_ACTION] event. Goes * through the same [MutableExternalAgentRun.emit] path as every other event, so seq assignment, * retention capping, and SSE flush all apply identically. Works regardless of run status. + * Holds the run lock like the companion emitters, so seq order in the events list (and the + * companion journal) matches assignment order even against lock-held emits. */ fun emitHumanAction(runId: String, title: String, input: JsonElement?, output: JsonElement?): Boolean { val run = runs[runId] ?: return false - run.emit( - kind = ExternalAgentEventKind.HUMAN_ACTION, - title = title, - input = input, - output = output, - ) + // Companion runs are off-limits: their HUMAN_ACTION vocabulary is a closed contract the + // attached agent scripts against, and this path's title is caller-controlled - it could mint + // `recording-saved` without a file ever landing. No legitimate caller passes a companion + // runId (the guided-recording surface never hands gestures this run), so refuse them all. + if (run.companion != null) return false + synchronized(run) { + run.emit( + kind = ExternalAgentEventKind.HUMAN_ACTION, + title = title, + input = input, + output = output, + ) + } return true } @@ -599,6 +683,494 @@ internal object ExternalAgentSupervisor { run.dto() } + /** + * A companion session: the inverse of [start]. The agent process already exists OUTSIDE Trail + * Runner (the human's own CLI session, in their own repo) and attaches here so the human can + * follow the collaboration in Trail Runner. No child process is spawned; the run is born RUNNING + * because an external agent is attached and may stream more events - that keeps the live SSE + * stream and run-list polling open. [disconnectCompanion] (or Stop) finishes it. + */ + fun startCompanion(request: CompanionConnectRequest, trailsRoot: File): Result = runCatching { + val agentType = request.agentType ?: ExternalAgentType.CLAUDE + require(agentType != ExternalAgentType.SOLO) { "a companion session needs a real external agent type (claude or codex)" } + val folder = normalizeCompanionFolder(request.folder, trailsRoot) + val id = "agent-" + UUID.randomUUID().toString() + val title = request.title?.trim()?.takeIf { it.isNotEmpty() } ?: "Companion session" + val run = MutableExternalAgentRun( + id = id, + request = ExternalAgentRunRequest(agentType = agentType, prompt = "", title = title, cwd = trailsRoot.path), + title = title, + prompt = "", + cwd = trailsRoot.canonicalFile, + ) + run.companion = CompanionRunState( + agentLabel = request.agentLabel?.trim()?.takeIf { it.isNotEmpty() }, + folder = folder, + journalRoot = folder?.let { trailsRoot.canonicalFile }, + ) + // Check-then-insert under one lock, so N concurrent connects can't all pass the count at + // MAX-1 and land past the cap together. + synchronized(runs) { + require(runs.values.count { it.companion != null && it.status == ExternalAgentSessionStatus.RUNNING } < COMPANION_MAX_SESSIONS) { + "too many active companion sessions (max $COMPANION_MAX_SESSIONS) - disconnect one first (trailblaze companion disconnect )" + } + runs[id] = run + } + pruneFinishedRuns() + // Sweep stale sibling journals while root+folder are resolved and off the event hot path. A + // live session's journal is touched on every append, so only long-dead ones age out. + folder?.let { pruneCompanionJournals(trailsRoot.canonicalFile, it) } + run.emit( + kind = ExternalAgentEventKind.LIFECYCLE, + status = ExternalAgentSessionStatus.RUNNING, + title = "Companion session connected", + text = run.companion?.agentLabel ?: agentType.displayName(), + ) + // No child process means no exit to observe: an agent that dies without disconnecting would + // stay "Live" forever. Reap on idleness, measured from the last emitted event like the + // process watchdog, just on a much longer clock. + scope.launch { + while (run.status == ExternalAgentSessionStatus.RUNNING) { + delay(COMPANION_IDLE_POLL_MS) + reapCompanionIfIdle(run, System.currentTimeMillis()) + } + } + run.dto() + } + + /** + * One watchdog poll: ends the companion session when it has been idle past the timeout. + * Extracted (and internal) so the reap decision is testable without the 60s clock. + * The whole decision holds the run lock: companionEvent refreshes the activity clock under + * the same lock, so an event that lands while the watchdog is deciding spares the session + * instead of being reaped over. + */ + internal fun reapCompanionIfIdle(run: MutableExternalAgentRun, nowMs: Long): Boolean = synchronized(run) { + if (run.status != ExternalAgentSessionStatus.RUNNING) return false + val idle = nowMs - run.lastActivityAtMs + if (idle < COMPANION_IDLE_TIMEOUT_MS) return false + endCompanion(run, "auto-disconnected: no events for ${idle / 60_000} minutes") + true + } + + /** + * One narration event from an attached external agent. The kind vocabulary is deliberately + * small - the companion surface is a narration channel, not a raw event injector; everything + * else about the run (tool activity, file writes) is visible through the folder it authors. + */ + fun companionEvent(runId: String, kind: String?, title: String?, text: String?): Result = runCatching { + val run = companionRun(runId) + require(!title.isNullOrBlank() || !text.isNullOrBlank()) { "title or text is required" } + val eventKind = when (kind?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }) { + null, "assistant_message" -> ExternalAgentEventKind.ASSISTANT_MESSAGE + "lifecycle" -> ExternalAgentEventKind.LIFECYCLE + "error" -> ExternalAgentEventKind.ERROR + else -> error("unsupported event kind: $kind (use assistant_message, lifecycle, or error)") + } + // The HUMAN_ACTION receipts are structurally unpostable here (the kind vocabulary above), + // but the daemon's LIFECYCLE receipts share a kind with agent narration - refuse their + // titles so a posted lifecycle event can't cosplay as one in the transcript. + require(title?.trim() !in COMPANION_RESERVED_TITLES) { "reserved event title: $title (the daemon emits it)" } + // Serialized against disconnect: an event racing the terminal events would append after them + // (and past the SSE loop's final flush, so live subscribers would never see it). + synchronized(run) { + require(run.status == ExternalAgentSessionStatus.RUNNING) { "this companion session has ended" } + run.emit(kind = eventKind, title = title?.trim()?.takeIf { it.isNotEmpty() }, text = text) + } + Unit + } + + /** + * One UI directive from the attached agent: what the companion window should show (a banner, a + * checklist, quick replies, an armed recording). Rides the event stream as UI_COMMAND with the + * directive as title and the payload as input - so `companion listen` echoes the agent's own + * directives back in order with everything else - and, except for navigate, also lands in the + * run's standing directive state, which is what the window renders: latest per name, survives + * reloads and event retention, retracted by an empty payload. + */ + fun companionDirective(runId: String, directive: String?, payload: JsonElement?): Result = runCatching { + val run = companionRun(runId) + val name = directive?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: error("directive is required") + require(name in COMPANION_DIRECTIVES) { "unsupported directive: $directive (use ${COMPANION_DIRECTIVES.sorted().joinToString(", ")})" } + val body = payload?.let { it as? JsonObject ?: error("payload must be a JSON object") } + validateCompanionPayload(name, body) + // An armed recording on a folderless session is un-fulfillable: the window's Start button + // would sit permanently disabled and the save would refuse anyway. Tell the agent now. + if (name == "arm-recording") { + require(run.companion?.folder != null) { "this session declared no trail folder - arm-recording has nowhere to save" } + } + if (name == "navigate") { + // The one directive with no meaning without its field: an empty payload RETRACTS a standing + // directive, but "navigate nowhere" is always an agent mistake worth surfacing at the caller. + val route = (body?.get("route") as? JsonPrimitive)?.contentOrNull?.trim() + require(!route.isNullOrEmpty()) { "navigate needs a route in the payload" } + } + // Serialized against disconnect, like companionEvent: a directive racing the terminal events + // would land after the SSE loop's final flush and never reach a live window. The standing + // state mutates under the same lock, so it can never disagree with the transcript's order. + synchronized(run) { + require(run.status == ExternalAgentSessionStatus.RUNNING) { "this companion session has ended" } + val event = run.emit(kind = ExternalAgentEventKind.UI_COMMAND, title = name, input = body) + val state = run.companion ?: return@synchronized + when { + // navigate is live-only: it moves the window NOW, it isn't a standing instruction a + // reloaded window should re-apply (that would trap the user on the agent's last route). + name == "navigate" -> {} + // Empty or absent payload retracts: the card disappears and a reload won't resurrect it. + body.isNullOrEmpty() -> state.directives.remove(name) + else -> state.directives[name] = CompanionDirectiveDto(seq = event.seq, payload = body.toString()) + } + } + Unit + } + + /** + * Type-checks the fields the companion window renders, so a malformed agent payload fails at + * the poster (400) instead of surfacing as a blank or broken card. Oversize payloads are also + * rejected here: past the retention bound the emitted `input` would be truncated to invalid + * JSON - a 200 whose directive renders as nothing (or clears standing state) is a lie. + */ + private fun validateCompanionPayload(name: String, body: JsonObject?) { + require((body?.toString()?.length ?: 0) <= EXTERNAL_AGENT_MAX_FIELD_CHARS) { + "payload too large (max $EXTERNAL_AGENT_MAX_FIELD_CHARS chars)" + } + // An empty payload is a retract; the shape rules below apply only to payloads that render. + if (body.isNullOrEmpty()) return + fun stringField(key: String) { + val v = body[key] ?: return + require(v is JsonPrimitive && v.isString) { "$key must be a string" } + } + when (name) { + "navigate" -> stringField("route") + "banner" -> { + // The card renders `text` and nothing else, and the window trims it: a 200 for a banner + // with missing, mistyped, or whitespace-only text (--title mistaken for --text) would + // leave the agent believing a banner is up when nothing shows. + val text = body["text"] + require(text is JsonPrimitive && text.isString && text.content.isNotBlank()) { + "banner needs non-blank text (an empty payload retracts)" + } + } + "checklist", "actions" -> { + stringField("title") + val items = body["items"] + // Blank items are rejected too: the window drops them, so all-blank items would leave + // standing state with no card - the same invisible-directive lie the shape checks stop. + require(items is JsonArray && items.isNotEmpty() && items.all { it is JsonPrimitive && it.isString && it.content.isNotBlank() }) { + "$name needs items: a non-empty array of non-blank strings (an empty payload retracts)" + } + } + "select-device" -> stringField("platform") + "select-app-target" -> { + stringField("app") + stringField("label") + } + "arm-recording" -> { + stringField("variant") + stringField("platform") + stringField("text") + } + } + } + + /** + * Something the human did in the companion window, reported back to the attached agent (which + * tails the run's SSE stream or journal). HUMAN_ACTION with the type as title, mirroring how + * demonstrated gestures already land on a run. + */ + fun companionUserAction(runId: String, type: String?, payload: JsonElement?): Result = runCatching { + val run = companionRun(runId) + val name = type?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: error("type is required") + require(name in COMPANION_USER_ACTIONS) { "unsupported user-action type: $type (use ${COMPANION_USER_ACTIONS.sorted().joinToString(", ")})" } + val body = payload?.let { it as? JsonObject ?: error("payload must be a JSON object") } + require((body?.toString()?.length ?: 0) <= EXTERNAL_AGENT_MAX_FIELD_CHARS) { + "payload too large (max $EXTERNAL_AGENT_MAX_FIELD_CHARS chars)" + } + synchronized(run) { + require(run.status == ExternalAgentSessionStatus.RUNNING) { "this companion session has ended" } + run.emit(kind = ExternalAgentEventKind.HUMAN_ACTION, title = name, input = body) + // The device pick the select-device card asked for has happened; retract it here (not in + // the UI) so every window - including one opened later - agrees the ask is satisfied. An + // ask that named a platform is only satisfied by a matching connect: plugging in an + // Android phone doesn't answer "connect an iOS simulator". + if (name == "device-connected") { + val state = run.companion + val asked = state?.directives?.get("select-device")?.payload + ?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() } + ?.let { (it["platform"] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { p -> p.isNotEmpty() } } + val connected = (body?.get("platform") as? JsonPrimitive)?.contentOrNull?.trim() + if (asked == null || asked.equals(connected, ignoreCase = true)) state?.directives?.remove("select-device") + } + } + Unit + } + + /** + * The attached agent settles a shared-brain request (see [deferToCompanion]). Like every + * companion receipt, request-responded is daemon-titled with the settled entry written here + * only - the UI resolves its "sent to your agent" spinner off the requests map and trusts it. + */ + fun companionRespond(runId: String, requestId: String?, status: String?, note: String?): Result = runCatching { + val run = companionRun(runId) + val state = run.companion ?: error("not a companion session: $runId") + val id = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: error("requestId is required") + val outcome = status?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: error("status is required") + require(outcome == "done" || outcome == "error") { "unsupported status: $status (use done or error)" } + val message = note?.trim()?.takeIf { it.isNotEmpty() } + require((message?.length ?: 0) <= EXTERNAL_AGENT_MAX_FIELD_CHARS) { "note too large (max $EXTERNAL_AGENT_MAX_FIELD_CHARS chars)" } + synchronized(run) { + require(run.status == ExternalAgentSessionStatus.RUNNING) { "this companion session has ended" } + val req = state.requests[id] ?: error("unknown request: $id") + require(req.status == "pending") { "request $id is already ${req.status}" } + state.requests[id] = req.copy(status = outcome, note = message) + run.emit( + kind = ExternalAgentEventKind.LIFECYCLE, + title = "request-responded", + input = JsonObject( + buildMap { + put("requestId", JsonPrimitive(id)) + put("status", JsonPrimitive(outcome)) + message?.let { put("note", JsonPrimitive(it)) } + }, + ), + ) + } + Unit + } + + /** + * Writes one recorded variant (`.trail.yaml`) into the companion session's declared + * folder - the single sanctioned UI write in companion mode (the agent owns every other file). + * The daemon resolves the destination itself from the folder validated at connect, and emits the + * recording-saved user action only after the atomic write landed, so the agent hears about the + * save from the same authority that performed it. Returns the written file's path. + */ + fun companionSaveRecording(runId: String, variant: String?, yaml: String?, trailsRoot: File, platform: String? = null): Result = runCatching { + val run = companionRun(runId) + val folder = run.companion?.folder ?: error("this companion session declared no trail folder") + val name = variant?.trim()?.takeIf { it.isNotEmpty() } ?: error("variant is required") + require(!yaml.isNullOrBlank()) { "yaml is required" } + require(yaml.length <= COMPANION_MAX_RECORDING_CHARS) { "yaml too large (max $COMPANION_MAX_RECORDING_CHARS chars)" } + // Re-anchor under the CURRENT root: connect validated the relative path, but the write must + // still refuse a root swap or a symlink introduced since (same re-check as the folder view). + val rootCanon = trailsRoot.canonicalFile + val dir = File(rootCanon, folder).canonicalFile + require(dir.path.startsWith(rootCanon.path + File.separator)) { "folder must be inside the trails root: $folder" } + val written = synchronized(run) { + require(run.status == ExternalAgentSessionStatus.RUNNING) { "this companion session has ended" } + dir.mkdirs() + val file = BundleStore.writeVariant(dir, name, yaml) ?: error("could not write the recording into $folder") + run.emit( + kind = ExternalAgentEventKind.HUMAN_ACTION, + title = "recording-saved", + input = recordingSavedInput(file.name, folder, platform), + ) + retractArmRecordingIfFulfilled(run, name) + file + } + // Sibling sessions on the same folder hear the save too - outside this run's lock (the + // fan-out takes other runs' locks, and run locks must never nest). + announceRecordingSavedForFolder(folder, written.name, platform, excludeRunId = run.id) + written.path + } + + /** + * Announces a recorded variant that landed in [relFolder] (relative to the primary root) to + * every OTHER running companion session that declared that folder - the design allows several + * sessions on one folder ("last save wins"), and each attached agent needs the receipt. + * recording-saved stays an unforgeable daemon receipt: the title is hard-coded here and in + * [companionSaveRecording], the only two emitters, both daemon-internal. [excludeRunId] skips + * the session whose own save already announced inline, so no run hears its receipt twice. + */ + internal fun announceRecordingSavedForFolder(relFolder: String, file: String, platform: String?, excludeRunId: String? = null) { + val folder = relFolder.trim().trim('/') + if (folder.isEmpty()) return + for (run in runs.values) { + if (run.id == excludeRunId || run.companion?.folder != folder) continue + synchronized(run) { + if (run.status != ExternalAgentSessionStatus.RUNNING) return@synchronized + run.emit( + kind = ExternalAgentEventKind.HUMAN_ACTION, + title = "recording-saved", + input = recordingSavedInput(file, folder, platform), + ) + // The written file name is already slugged; variantSlug is idempotent, so it matches an + // armed raw variant ("iOS Phone") the same way the inline retract does. + retractArmRecordingIfFulfilled(run, file.removeSuffix(".trail.yaml")) + } + } + } + + /** + * Announces a trail-run lifecycle transition to every running companion session whose declared + * folder contains [relPath] (the run's trail or bundle path, relative to the primary root). + * Emitted only from the daemon's run-dispatch seam - agents read run-started/run-finished as + * "the human pressed Run in Trail Runner", so the titles are constants here, never caller text. + */ + internal fun announceRunStatusForFolder(relPath: String, started: Boolean, sessionId: String, status: String? = null) { + val rel = relPath.trim().trim('/') + if (rel.isEmpty()) return + for (run in runs.values) { + val folder = run.companion?.folder ?: continue + if (rel != folder && !rel.startsWith("$folder/")) continue + synchronized(run) { + if (run.status != ExternalAgentSessionStatus.RUNNING) return@synchronized + run.emit( + kind = ExternalAgentEventKind.LIFECYCLE, + title = if (started) "run-started" else "run-finished", + input = JsonObject( + buildMap { + put("sessionId", JsonPrimitive(sessionId)) + put("folder", JsonPrimitive(folder)) + status?.let { put("status", JsonPrimitive(it)) } + }, + ), + ) + } + } + } + + /** + * Tries to route an LLM-shaped ask ("review this trail", "propose steps") to an attached + * companion agent - the shared-brain seam: when the human's own agent CLI sits on the folder, + * it answers instead of the daemon's wired provider. Target selection: running companion + * sessions whose declared folder contains [folderRel] (newest wins when several match); when the + * caller has no folder to key on, the sole running companion session, else [DeferOutcome.None]. + * A matched session with no agent-tagged stream open is [DeferOutcome.Degraded] - queueing there + * would spin forever, so the caller tells the human to ask in their CLI instead. The enqueue is + * a daemon receipt like recording-saved: the agent-request title and the requests-map entry are + * minted here only. An identical still-pending ask (same kind + payload) returns the existing + * requestId instead of spamming the agent. + */ + fun deferToCompanion(kind: String, folderRel: String?, payload: JsonObject): DeferOutcome { + val folder = folderRel?.trim()?.trim('/')?.takeIf { it.isNotEmpty() } + val candidates = runs.values.filter { run -> + val rel = run.companion?.folder + run.status == ExternalAgentSessionStatus.RUNNING && run.companion != null && + (folder == null || (rel != null && (rel == folder || folder.startsWith("$rel/")))) + } + val target = when { + folder != null -> candidates.maxByOrNull { it.startedAtMs } + candidates.size == 1 -> candidates.single() + else -> null + } ?: return DeferOutcome.None + if (!hasAgentConsumer(target.id)) return DeferOutcome.Degraded(target.id) + return synchronized(target) { + // The RUNNING filter above was advisory; the session may have ended while we selected it. + if (target.status != ExternalAgentSessionStatus.RUNNING) return@synchronized DeferOutcome.None + val state = target.companion ?: return@synchronized DeferOutcome.None + val payloadJson = payload.toString() + state.requests.values.firstOrNull { it.status == "pending" && it.kind == kind && it.payload == payloadJson } + ?.let { return@synchronized DeferOutcome.Deferred(target.id, it.requestId) } + val requestId = "r_${state.requestCounter.incrementAndGet()}" + target.emit( + kind = ExternalAgentEventKind.HUMAN_ACTION, + title = "agent-request", + input = JsonObject( + buildMap { + put("requestId", JsonPrimitive(requestId)) + put("kind", JsonPrimitive(kind)) + put("payload", payload) + }, + ), + ) + state.requests[requestId] = CompanionRequestDto( + requestId = requestId, + kind = kind, + payload = payloadJson, + status = "pending", + ) + DeferOutcome.Deferred(target.id, requestId) + } + } + + private fun recordingSavedInput(file: String, folder: String, platform: String?): JsonObject = JsonObject( + buildMap { + put("file", JsonPrimitive(file)) + put("folder", JsonPrimitive(folder)) + // Which device platform recorded it - the variant is just a file name and may not say + // (an agent can arm a custom variant), so the receipt carries it explicitly. + platform?.trim()?.takeIf { it.isNotEmpty() }?.let { put("platform", JsonPrimitive(it.lowercase())) } + }, + ) + + // Must run with the run's lock held. The armed recording is fulfilled; retract it daemon-side + // so every window agrees. Only the variant it asked for fulfills it - saving android.trail.yaml + // doesn't answer "record the ios variant" (same rule as the platform-matched select-device + // retract). + private fun retractArmRecordingIfFulfilled(run: MutableExternalAgentRun, savedVariant: String) { + val armedVariant = run.companion?.directives?.get("arm-recording")?.payload + ?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() } + ?.let { (it["variant"] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { v -> v.isNotEmpty() } } + if (armedVariant == null || BundleStore.variantSlug(armedVariant) == BundleStore.variantSlug(savedVariant)) { + run.companion?.directives?.remove("arm-recording") + } + } + + // Best-effort: a failed sweep must never block a connect. + private fun pruneCompanionJournals(root: File, folder: String) { + runCatching { + val dir = companionJournalDir(root, folder) + val cutoff = System.currentTimeMillis() - COMPANION_JOURNAL_MAX_AGE_MS + dir.listFiles { f -> f.isFile && f.name.startsWith("journal-") && f.name.endsWith(".jsonl") } + // lastModified() == 0 means "couldn't stat" - skip rather than treat as ancient. + ?.filter { it.lastModified() in 1 until cutoff } + ?.forEach { stale -> + if (stale.delete()) Console.log("[companion] pruned stale journal ${stale.name} in $folder") + } + }.onFailure { Console.log("[companion] journal prune failed: ${it.message}") } + } + + /** Ends a companion session. Idempotent; the run keeps its transcript and folder view. */ + fun disconnectCompanion(runId: String, note: String?): Result = runCatching { + endCompanion(companionRun(runId), note) + } + + // Serialized with companionEvent (see there); the status re-check under the lock makes every + // end path (disconnect, UI Stop, idle reap) idempotent - the terminal events emit exactly once. + private fun endCompanion(run: MutableExternalAgentRun, note: String?) { + synchronized(run) { + if (run.status != ExternalAgentSessionStatus.RUNNING) return + // Nobody is left to answer: settle every pending shared-brain request so the UI's spinner + // resolves from the requests map instead of waiting on a respond that can never come. + run.companion?.requests?.let { requests -> + for ((id, req) in requests) { + if (req.status == "pending") requests[id] = req.copy(status = "cancelled") + } + } + run.emit( + kind = ExternalAgentEventKind.LIFECYCLE, + title = "Companion session ended", + text = note?.trim()?.takeIf { it.isNotEmpty() }, + ) + run.finishIfNeeded(ExternalAgentSessionStatus.COMPLETED, exitCode = null, error = null) + } + // Like every other run-ending path: a permission request somehow registered against this run + // must not leave its HTTP handler suspended on a decision that can never come. + run.permissions.failAllPending("The run ended before this was approved").forEach { emitPermissionDecision(run, it) } + } + + /** Unknown run distinguishes itself (404 at the route) from a run that exists but isn't a companion (400). */ + private fun companionRun(runId: String): MutableExternalAgentRun { + val run = runs[runId] ?: throw NoSuchElementException("companion run not found: $runId") + requireNotNull(run.companion) { "not a companion session: $runId" } + return run + } + + /** + * Normalizes a caller-supplied companion folder to a clean relative path under [trailsRoot], or + * null when absent. The folder need not exist yet (the agent may attach before its first write), + * but a path that escapes the root is rejected - the trail rail will read files from it. + */ + private fun normalizeCompanionFolder(folder: String?, trailsRoot: File): String? { + val rel = folder?.trim()?.trim('/')?.takeIf { it.isNotEmpty() } ?: return null + require(!rel.contains("..") && !rel.contains("\\") && rel.none { it.isISOControl() }) { "invalid folder: $folder" } + val rootCanon = trailsRoot.canonicalFile + val canon = File(rootCanon, rel).canonicalFile + require(canon.path.startsWith(rootCanon.path + File.separator)) { "folder must be inside the trails root: $folder" } + return rel + } + /** * The trailhead moment: capture the start-state evidence, write the bundle manifest, start network * capture (when available), and move the demo from positioning to recording. Only valid from the @@ -975,6 +1547,85 @@ internal object ExternalAgentSupervisor { return DemoTrailContentResult(trailId = demo.trailId, files = files) } + /** + * The files of the trail folder a companion session declared, for the live trail rail. Same + * shape and caps as [demoTrailContent]. The folder is re-resolved and containment-checked + * against the CURRENT primary root on every read, so a workspace switch after connect can't + * turn a once-valid relative path into a read outside the active root. Null for an unknown or + * non-companion run; an empty file list while the folder doesn't exist yet. + */ + fun companionFolderContent(runId: String, trailsRoot: File): DemoTrailContentResult? { + val run = runs[runId] ?: return null + val comp = run.companion ?: return null + val rel = comp.folder ?: return DemoTrailContentResult(trailId = null, files = emptyList()) + val rootCanon = runCatching { trailsRoot.canonicalFile }.getOrNull() + ?: return DemoTrailContentResult(trailId = rel, files = emptyList()) + val dirCanon = companionFolderDir(comp, rootCanon) + ?: return DemoTrailContentResult(trailId = rel, files = emptyList()) + val files = (dirCanon.listFiles()?.toList().orEmpty()) + .filter { it.isFile && (it.extension.equals("yaml", ignoreCase = true) || it.extension.equals("md", ignoreCase = true)) } + .sortedBy { it.name } + .mapNotNull { readContainedTrailFile(rootCanon, it) } + return DemoTrailContentResult(trailId = rel, files = files) + } + + /** + * The declared trail folder as a canonical, containment-checked directory - the single resolve + * the folder-content/tree/reveal/open surfaces all share. Null when the run isn't a companion, + * no folder was declared, or the folder is missing/escapes the trails root. + */ + fun companionFolderDir(runId: String, trailsRoot: File): File? { + val comp = runs[runId]?.companion ?: return null + val rootCanon = runCatching { trailsRoot.canonicalFile }.getOrNull() ?: return null + return companionFolderDir(comp, rootCanon) + } + + private fun companionFolderDir(comp: CompanionRunState, rootCanon: File): File? { + val rel = comp.folder ?: return null + val dirCanon = runCatching { File(rootCanon, rel).canonicalFile }.getOrNull() ?: return null + if (!dirCanon.isDirectory || !dirCanon.path.startsWith(rootCanon.path + File.separator)) return null + return dirCanon + } + + /** + * Recursive listing of the declared folder (the demo-files tree): every file and directory the + * agent's session has produced, paths relative to the folder. Names and sizes only - contents + * stay behind per-file affordances (open in editor). Entries whose canonical path escapes the + * folder (a symlink pointing out) are dropped, same containment paranoia as every read, and + * symlinked directories are never descended (escape and cycle guard - their targets, when + * contained, already appear at their real paths). Capped so a runaway folder can't melt the + * poll loop; the UI shows what fits. + */ + fun companionFolderTree(runId: String, trailsRoot: File): List? { + val run = runs[runId] ?: return null + val comp = run.companion ?: return null + val rootCanon = runCatching { trailsRoot.canonicalFile }.getOrNull() ?: return emptyList() + val dirCanon = companionFolderDir(comp, rootCanon) ?: return emptyList() + val entries = mutableListOf() + // The .companion journal dir is the daemon's own crash-recovery state (created at connect, + // gitignored) - it is not part of what the agent authored, so the tree hides it. + val walk = dirCanon.walkTopDown().onEnter { + it == dirCanon || (it.name != COMPANION_JOURNAL_DIR_NAME && !Files.isSymbolicLink(it.toPath())) + } + for (f in walk) { + if (f == dirCanon) continue + // Finder litter, not content - revealing the folder (the tab's own button) would otherwise + // make it appear in the very tree the human is looking at. + if (f.name == ".DS_Store") continue + if (entries.size >= COMPANION_TREE_MAX_ENTRIES) break + val canon = runCatching { f.canonicalFile }.getOrNull() ?: continue + if (!canon.path.startsWith(dirCanon.path + File.separator)) continue + entries.add( + CompanionFolderEntryDto( + path = f.relativeTo(dirCanon).invariantSeparatorsPath, + dir = f.isDirectory, + size = if (f.isFile) f.length() else 0, + ), + ) + } + return entries.sortedBy { it.path } + } + /** * Captures the start-state screenshot + view hierarchy into the tape dir, using the same live * connection the gesture path drives (via [TrailRunnerRecordingHolder]). Best-effort: a daemon @@ -1041,6 +1692,7 @@ internal object ExternalAgentSupervisor { val run = runs[id] requireNotNull(run) { "external agent run not found: $id" } require(run.agentType != ExternalAgentType.SOLO) { "this is a solo session - there is no agent to reply to" } + require(run.companion == null) { "this session is driven by an external agent outside Trail Runner - reply in that agent's own CLI" } val text = prompt.trim() require(text.isNotEmpty()) { "prompt is required" } require(run.status != ExternalAgentSessionStatus.RUNNING) { @@ -1287,6 +1939,15 @@ private const val DEMO_MANIFEST_NAME = "demo.yaml" /** File name of the cross-platform draft manifest at the draft dir root. */ private const val DRAFT_MANIFEST_NAME = "draft.yaml" +// Re-resolved and containment-checked per use (same discipline as companionSaveRecording): a +// symlink swapped in under the root after connect must not aim journal writes - or the sweep's +// deletes - outside it. Top-level so both the supervisor's sweep and the run's append path reach it. +private fun companionJournalDir(root: File, folder: String): File { + val dir = File(File(root, folder), COMPANION_JOURNAL_DIR_NAME).canonicalFile + require(dir.path.startsWith(root.path + File.separator)) { "journal dir escapes the trails root" } + return dir +} + /** * Ensures the drafts root exists and carries a self-ignoring `*` `.gitignore`, so demonstration * working data can never be committed regardless of the workspace's own ignore rules. Best-effort @@ -1834,6 +2495,62 @@ internal data class ExternalAgentEventDraft( val raw: JsonElement? = null, ) +/** + * Companion-session state: an external agent CLI (running in the human's own terminal, outside + * Trail Runner) attached to this run. The daemon owns no process for it - the agent streams its + * narration through the companion routes, and [folder] names the trail folder (relative to the + * primary trails root) it is authoring on disk, which the live trail rail watches. + */ +internal class CompanionRunState( + val agentLabel: String?, + val folder: String?, + /** + * Canonical trails root captured at connect; the journal dir (`/.companion`) is + * re-resolved and containment-checked under it on every append, so a symlink introduced after + * connect can't route journal writes outside the root. Deliberately NOT re-read from settings: + * emit has no route context, and a workspace root swapped mid-session just leaves a stale + * best-effort journal behind (the save path, which matters, does re-anchor per request). + * Null when no folder was declared. + */ + val journalRoot: File? = null, +) { + /** + * The standing directives (latest per name; never `navigate`). Mutated only under the run lock + * so it can't disagree with the transcript's order; a ConcurrentHashMap so [dto] snapshots it + * safely without that lock. Daemon-owned so directives survive event retention and reloads. + */ + val directives: MutableMap = ConcurrentHashMap() + + /** + * The shared-brain request queue, keyed by requestId - same lock discipline as [directives] + * (mutated only under the run lock, ConcurrentHashMap so [dto] snapshots without it). Only the + * daemon's defer path enqueues; the agent settles entries via respond. + */ + val requests: MutableMap = ConcurrentHashMap() + + /** + * Mints requestIds ("r_1", "r_2", ...). A dedicated counter rather than the event seq: the + * requestId must ride INSIDE the agent-request event's input, which is sealed before emit + * assigns the seq. + */ + val requestCounter = AtomicInteger(0) +} + +/** Outcome of trying to hand an LLM-shaped ask to an attached companion agent instead of the wired provider. */ +internal sealed class DeferOutcome { + /** No running companion session claims this work - the caller proceeds with its normal path. */ + object None : DeferOutcome() + + /** A session matched, but no agent is listening on its stream - queueing would hang the human. */ + data class Degraded(val runId: String) : DeferOutcome() + + /** The ask is queued on companion session [runId] as [requestId]; the agent settles it via respond. */ + data class Deferred(val runId: String, val requestId: String) : DeferOutcome() +} + +/** What the human sees on a [DeferOutcome.Degraded] ask - shared by every route that defers. */ +internal const val COMPANION_AGENT_NOT_LISTENING = "agent not listening - ask it in your CLI" + // Widened from `private` to `internal` (Task B testability seam): ExternalAgentSupervisorTest // constructs a MutableExternalAgentRun directly to seed a run without spawning a real vendor CLI // process. Compile-time visibility widening only — zero runtime behavior change. @@ -1868,6 +2585,9 @@ internal class MutableExternalAgentRun( /** Generation-agent state, when this run authors a trail from a demonstration (else null). */ @Volatile var generation: GenerationRunState? = null + /** Companion state, when an external agent CLI drives this run from outside Trail Runner (else null). */ + @Volatile var companion: CompanionRunState? = null + /** * Human-approvable permissions for this run's spawned CLI. A `val` (never reset by [beginTurn]), * so an `allow_always` grant or an auto-approve toggle persists for the whole run's lifetime. @@ -1946,6 +2666,7 @@ internal class MutableExternalAgentRun( }, // Present only on a generation run; the web hides it from the sidebar and embeds its transcript. demoRunId = generation?.demoRunId, + companion = companion?.let { c -> CompanionStateDto(agentLabel = c.agentLabel, folder = c.folder, directives = c.directives.toMap(), requests = c.requests.toMap()) }, pendingPermissions = permissions.pendingSnapshot(), autoApprove = permissions.autoApprove, ) @@ -2014,9 +2735,32 @@ internal class MutableExternalAgentRun( } lastActivityAtMs = event.timeMs raw?.let { captureExternalThreadId(it) } + // Mirror EVERY event of a companion run - whichever path emitted it - into the on-disk + // journal, so a crashed agent recovers the same stream `companion listen` would have shown + // it. Best-effort, like actions.ndjson. + companion?.let { appendCompanionJournal(it, event) } return event } + private fun appendCompanionJournal(state: CompanionRunState, event: ExternalAgentEventDto) { + val root = state.journalRoot ?: return + val folder = state.folder ?: return + runCatching { + val dir = companionJournalDir(root, folder) + val gitignore = File(dir, ".gitignore") + if (!gitignore.isFile) { + dir.mkdirs() + // Self-ignoring, like the drafts dir: the journal is ephemeral daemon state living inside + // the user's own repo, and must never show up as an untracked surprise. + gitignore.writeText("*\n") + } + // One file per run (concurrent sessions on the same folder must not interleave), encoded + // with the same compact instance as the SSE frames, so a journal line and a + // `companion listen` line are byte-identical for the same event. + File(dir, "journal-${event.runId}.jsonl").appendText(JSON.encodeToString(ExternalAgentEventDto.serializer(), event) + "\n") + }.onFailure { Console.log("[companion] journal append failed: ${it.message}") } + } + // Truncated payloads stop being valid JSON; the UI's JsonBlock already falls back to plain-text // rendering for unparseable strings, so a truncated blob still displays (with the marker). private fun String.boundForRetention(): String = diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RecordRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RecordRoutes.kt index 33df6e5a6..2f8311e39 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RecordRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RecordRoutes.kt @@ -6,6 +6,11 @@ import io.ktor.server.request.receive import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.post +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -29,6 +34,7 @@ import xyz.block.trailblaze.devices.TrailblazeDriverType import xyz.block.trailblaze.devices.WebInstanceIds import xyz.block.trailblaze.recording.RecordedInteraction import xyz.block.trailblaze.recording.RecordingYamlCodec +import xyz.block.trailblaze.host.recording.streamAndroidLiveJpegFrames import xyz.block.trailblaze.toolcalls.TrailblazeTool import xyz.block.trailblaze.toolcalls.commands.AssertVisibleBySelectorTrailblazeTool import xyz.block.trailblaze.toolcalls.commands.TapOnByElementSelector @@ -64,6 +70,7 @@ import java.util.concurrent.ConcurrentHashMap * * Routes (all POST; the device id rides in the JSON body since [TrailblazeDeviceId] is structured): * /api/record/connect — open a live connection, hold the stream for subsequent calls + * /api/record/stream — push Android mirror frames as binary JPEG WebSocket messages * /api/record/screen — poll one mirror frame (base64) for the live view * /api/record/gesture — dispatch a tap/longPress/swipe/inputText/pressKey AND record it as a tool * /api/record/disconnect — release the connection @@ -111,6 +118,53 @@ internal fun Route.recordRoutes(deps: TrailRunnerDeps) { ) } + webSocket("$PATH_BASE/api/record/stream") { + if (service == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "deviceManager not available")) + return@webSocket + } + val instanceId = call.request.queryParameters["instanceId"]?.takeIf { it.isNotBlank() } + val platform = + call.request.queryParameters["platform"] + // Fixed locale: this is protocol parsing, and a locale-sensitive uppercase (e.g. Turkish + // "ios" -> "İOS") would fail TrailblazeDevicePlatform.valueOf and reject valid requests. + ?.uppercase(java.util.Locale.ROOT) + ?.let { runCatching { TrailblazeDevicePlatform.valueOf(it) }.getOrNull() } + if (instanceId == null || platform == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "instanceId and platform are required")) + return@webSocket + } + val deviceId = TrailblazeDeviceId(instanceId, platform) + val connection = service.connection(deviceId) + if (connection == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "recording device is not connected")) + return@webSocket + } + if (platform != TrailblazeDevicePlatform.ANDROID) { + // The browser falls back to /record/screen polling for iOS and Playwright. Keeping this + // socket Android-only avoids changing those capture paths as part of the H.264 rollout. + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "continuous stream is Android-only")) + return@webSocket + } + + try { + streamAndroidLiveJpegFrames( + deviceId = deviceId, + deviceWidth = connection.stream.deviceWidth, + deviceHeight = connection.stream.deviceHeight, + ) { jpeg -> + // Binary frames avoid the 33% base64 expansion paid by the generic JSON RPC envelope. + // One WebSocket message is one complete JPEG, so the browser can hand it directly to img. + outgoing.send(Frame.Binary(fin = true, data = jpeg)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Console.log("[record-stream] stream failed for ${deviceId.toFullyQualifiedDeviceId()}: ${e.message}") + close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, "device stream failed")) + } + } + post("$PATH_BASE/api/record/screen") { if (service == null) { call.respondJson(HttpStatusCode.ServiceUnavailable, RecordScreenResponse.serializer(), RecordScreenResponse(ok = false, error = "deviceManager not available")) @@ -668,8 +722,14 @@ internal class TrailRunnerRecordingService( private val connections = ConcurrentHashMap() private val connectMutexes = ConcurrentHashMap() + fun connection(deviceId: TrailblazeDeviceId): RecordingDeviceConnection? = connections[deviceId] + suspend fun connect(deviceId: TrailblazeDeviceId): RecordConnectResponse { connections[deviceId]?.let { + // Re-publish into the shared registry (no-op while an entry exists) — a `/devices` viewer + // disconnect may have dropped the entry while this recorder connection stayed live, and the + // mirror's "not connected" self-heal lands back here to restore it. + deviceManager.hostDeviceSessionManager.attach(deviceId, it.stream) return RecordConnectResponse(ok = true, deviceWidth = it.stream.deviceWidth, deviceHeight = it.stream.deviceHeight) } val device = resolveDevice(deviceId) @@ -683,6 +743,11 @@ internal class TrailRunnerRecordingService( when (val state = deviceManager.connectionService.connectToDevice(device)) { is ConnectionState.Connected -> { connections[deviceId] = state.connection + // Publish the SAME live stream into the shared session registry so the `/devices/api/stream` + // H.264/CDP live-video socket (and its `/rpc` SubscribeFrames + screen-poll fallbacks) can + // serve this recorder-owned connection — no second physical connection. This service stays + // the lifecycle owner and detaches (without closing) on disconnect. + deviceManager.hostDeviceSessionManager.attach(deviceId, state.connection.stream) RecordConnectResponse(ok = true, deviceWidth = state.connection.stream.deviceWidth, deviceHeight = state.connection.stream.deviceHeight) } is ConnectionState.Error -> RecordConnectResponse(ok = false, error = state.message) @@ -1027,6 +1092,9 @@ internal class TrailRunnerRecordingService( fun disconnect(deviceId: TrailblazeDeviceId) { val conn = connections.remove(deviceId) ?: return + // Detach (without closing) before we close it ourselves — this service owns the stream's + // lifecycle; the shared registry only held a published view of it (see [connect]). + deviceManager.hostDeviceSessionManager.detach(deviceId) (conn.stream as? AutoCloseable)?.let { runCatching { it.close() } } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ReviewRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ReviewRoutes.kt index b7944e5ce..95b6b52fc 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ReviewRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/ReviewRoutes.kt @@ -8,7 +8,10 @@ import io.ktor.server.routing.Route import io.ktor.server.routing.post import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import xyz.block.trailblaze.logs.model.SessionId +import java.io.File import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.yaml.createTrailblazeYaml import xyz.block.trailblaze.yaml.generateRecordedYaml @@ -27,10 +30,44 @@ import xyz.block.trailblaze.yaml.generateRecordedYaml internal fun Route.reviewRoutes(deps: TrailRunnerDeps) { post("$PATH_BASE/api/session/{id}/review") { val id = call.parameters["id"]?.trim().orEmpty() - if (resolveSafeSessionDir(deps.logsRepo.logsDir, id) == null) { + val sessionDir = resolveSafeSessionDir(deps.logsRepo.logsDir, id) + if (sessionDir == null) { call.respond(HttpStatusCode.NotFound) return@post } + // Shared brain: when the human's own agent CLI is attached to this trail's folder, hand the + // review to it instead of the wired LLM. The dispatch marker names which trail the session + // ran; sessions without one (raw-YAML replays) have no folder to key on and never defer. + val trailId = withContext(Dispatchers.IO) { + runCatching { File(sessionDir, ".trailrunner-trail-id").readText().trim().takeIf { it.isNotEmpty() } }.getOrNull() + } + val folderRel = companionRelFor(null, trailId) + if (folderRel != null) { + val payload = JsonObject( + buildMap { + put("folder", JsonPrimitive(folderRel)) + put("sessionId", JsonPrimitive(id)) + put("trailId", JsonPrimitive(trailId!!)) + }, + ) + when (val deferred = ExternalAgentSupervisor.deferToCompanion("review-trail", folderRel, payload)) { + is DeferOutcome.Deferred -> { + call.respondText( + text = JSON.encodeToString(ReviewTrailResponse.serializer(), ReviewTrailResponse(deferred = true, requestId = deferred.requestId, runId = deferred.runId)), + contentType = ContentType.Application.Json, + ) + return@post + } + is DeferOutcome.Degraded -> { + call.respondText( + text = JSON.encodeToString(ReviewTrailResponse.serializer(), ReviewTrailResponse(degraded = true, runId = deferred.runId, error = COMPANION_AGENT_NOT_LISTENING)), + contentType = ContentType.Application.Json, + ) + return@post + } + DeferOutcome.None -> {} + } + } val provider = deps.reviewTrailProvider if (provider == null) { call.respondText( diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RunRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RunRoutes.kt index 7e3cc4ac0..da8a48ea0 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RunRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/RunRoutes.kt @@ -17,6 +17,7 @@ import xyz.block.trailblaze.host.ios.MobileDeviceUtils import xyz.block.trailblaze.llm.TrailblazeReferrer import xyz.block.trailblaze.mcp.AgentImplementation import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.model.TrailExecutionResult import xyz.block.trailblaze.ui.getVersionInfo import xyz.block.trailblaze.util.Console import java.io.File @@ -203,6 +204,7 @@ internal suspend fun buildRunDispatchResult(deps: TrailRunnerDeps, body: RunRequ File(dir, ".trailrunner-trail-id").writeText(trailId) } } + val companionRel = companionRelFor(body.bundleId, body.trailId) deviceManager.runYaml( yamlToRun = yaml, trailblazeDeviceId = id, @@ -218,14 +220,27 @@ internal suspend fun buildRunDispatchResult(deps: TrailRunnerDeps, body: RunRequ initialMemorySeeds = body.memory, initialMemorySensitiveSeeds = body.secrets, captureNetworkTrafficOverride = if (captureEventsOn) true else body.captureNetworkTraffic, - onComplete = { + onComplete = { result -> analyticsCapture?.let { c -> runCatching { c.close() } } eventCapture?.let { c -> runCatching { c.close() } } // When this run was a bundle recording (bundleId + variant set), write the recorded // .trail.yaml back into the bundle folder. No-op for ordinary runs. maybeWriteBundleVariant(deps, body, sessionId) + companionRel?.let { + ExternalAgentSupervisor.announceRunStatusForFolder( + relPath = it, + started = false, + sessionId = sessionId, + status = runFinishedStatus(result), + ) + } }, ) + // Companion sessions watching this trail's folder hear the dispatch. Only after runYaml + // accepts it - a dispatch that throws above never announces a start it didn't make. The + // fan-out appends each listener's journal synchronously, but it's bounded by the companion + // session cap, so the dispatch response is delayed by at most a handful of small file writes. + companionRel?.let { ExternalAgentSupervisor.announceRunStatusForFolder(it, started = true, sessionId = sessionId) } // Dispatch is async: the caller gets the sessionId immediately and follows the // run through the session status; failures surface there, not on this response. RunResponse(success = true, sessionId = sessionId) @@ -265,6 +280,27 @@ internal fun Route.runRoutes(deps: TrailRunnerDeps) { } } +// The run's library path relative to the primary root, when the dispatch named one - the key that +// routes run-started/run-finished to companion sessions. "0/" is the primary-root marker in +// both trailId and bundleId (bundleId wins: it names the folder, not a variant file); extras roots +// (1/, 2/, ...) are outside companion scope, so runs from them never announce. Raw-YAML dispatches +// (no id at all) stay silent too - there's no folder to attribute them to. trailId is a +// caller-claimed field (unlike the @Transient bundleId), so a local caller can aim these events at +// any folder - accepted: the events are advisory LIFECYCLE only (title and payload stay +// daemon-built), and anyone who can POST /api/run could generate the same events genuinely by +// running a real trail there. Same trust stance as the caller-claimed .trailrunner-trail-id marker. +internal fun companionRelFor(bundleId: String?, trailId: String?): String? = sequenceOf(bundleId, trailId) + .mapNotNull { id -> id?.takeIf { it.startsWith("0/") }?.substringAfter('/')?.takeIf { it.isNotEmpty() } } + .firstOrNull() + +// The run-finished status vocabulary the companion contract promises agents; exhaustive over +// [TrailExecutionResult] so a new outcome is a compile error here, not a silent contract gap. +internal fun runFinishedStatus(result: TrailExecutionResult): String = when (result) { + is TrailExecutionResult.Success -> "succeeded" + is TrailExecutionResult.Failed -> "failed" + is TrailExecutionResult.Cancelled -> "cancelled" +} + // Materialize a finished bundle-recording run into its bundle folder. No-op unless the run carried // both [RunRequest.bundleId] and [RunRequest.variant] - @Transient fields only the server-side // `/api/folder/record` dispatch can set, so a raw REST/RPC caller can never land a recorded @@ -281,6 +317,17 @@ private fun maybeWriteBundleVariant(deps: TrailRunnerDeps, body: RunRequest, ses return Console.log("[BlazeRoutes] no logs for session $sessionId; variant '$variant' not written") } val yaml = logs.generateRecordedYaml(createTrailblazeYaml()) - BundleStore.writeVariant(resolved.dir, variant, yaml) + val written = BundleStore.writeVariant(resolved.dir, variant, yaml) + // A recording landing in the folder is the same fact whether a human saved it from the + // companion view or the board's record flow wrote it here - companion sessions watching the + // folder hear both. Primary root only: companion folders resolve against rootIdx 0. On this + // path the variant IS the recording device's platform name (see /api/folder/record). + if (written != null && resolved.rootIdx == 0) { + ExternalAgentSupervisor.announceRecordingSavedForFolder( + relFolder = resolved.home, + file = written.name, + platform = variant, + ) + } }.onFailure { Console.log("[BlazeRoutes] bundle variant write failed: ${it.message}") } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/SessionRoutes.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/SessionRoutes.kt index 0a783f53a..0a5aa9573 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/SessionRoutes.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/SessionRoutes.kt @@ -376,7 +376,6 @@ internal fun Route.sessionRoutes(deps: TrailRunnerDeps) { SessionEventStreamDto( streamId = stream.name, label = eventStreamLabel(stream.name), - style = stream.style, count = stream.count, truncated = stream.truncated, events = diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/TrailRunnerDtos.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/TrailRunnerDtos.kt index 0b6bbb287..1cc8e21d4 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/TrailRunnerDtos.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/trailrunner/TrailRunnerDtos.kt @@ -216,6 +216,8 @@ data class ExternalAgentRunDto( * the demonstration's view instead. */ val demoRunId: String? = null, + /** Present only for companion sessions (an external agent CLI attached from outside). */ + val companion: CompanionStateDto? = null, /** * Permission requests from the spawned CLI that are waiting on a human decision. The web renders * one approve/deny card per entry; the events stream carries the transcript record separately. @@ -471,7 +473,7 @@ data class AnalyticsResponse(val available: Boolean, val events: List/events/. + + +
+

Device Mirror

+ Standalone Trailblaze viewer + + +
+
+ + + + +
+
+
+ + + + diff --git a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-core.tsx b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-core.tsx index 99c825eaf..abbb4fbd1 100644 --- a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-core.tsx +++ b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-core.tsx @@ -489,6 +489,20 @@ async function proposeSteps(objective, opts = {}) { } catch (e) { return { steps: [], error: String(e) }; } } +// "Review my trail" for a run's session. Normally answered by the daemon's wired LLM; when the +// human's own agent CLI is attached to the trail's folder (companion mode), the daemon defers to +// it instead and the response carries {deferred, requestId, runId} - the companion screen then +// follows the request to completion on the run's stream. {degraded} means a session matched but +// no agent is listening, so the human should ask in their CLI. +async function reviewSession(sessionId) { + try { + const r = await fetch(`/trailrunner/api/session/${encodeURIComponent(sessionId)}/review`, { method: 'POST' }); + const body = await r.json().catch(() => ({})); + if (!r.ok) return { ok: false, error: body.error || `HTTP ${r.status}` }; + return { ok: !body.error, ...body }; + } catch (e) { return { ok: false, error: 'the daemon could not be reached: ' + String(e && e.message || e) }; } +} + // ─── Library trail folder editing (/api/folder/*) ────────────────────────────────────────────── // The bundle surface: a trail folder in the library (`blaze.yaml` + `.trail.yaml` // siblings), identified by its folder id `/` (e.g. `0/sample/login`). Create @@ -568,18 +582,6 @@ async function deleteTrailFolderFile(folderId, name) { } catch (e) { return { ok: false, error: String(e) }; } } -// Convert a legacy per-platform bundle folder into a single unified `.trail.yaml` (deleting -// the per-platform files + blaze.yaml it folds in). The server runs the Kotlin UnifiedTrailMigrator; -// on refusal (a trailhead / top-level tools / already migrated) it returns { success:false, error }. -async function migrateTrailFolder(folderId) { - try { - const r = await fetch(`/trailrunner/api/folder/migrate-unified?id=${encodeURIComponent(folderId)}`, { method: 'POST' }); - const body = await r.json().catch(() => ({})); - if (!r.ok || body.success === false) return { success: false, error: body.error || (r.ok ? 'the server refused the migration' : `HTTP ${r.status}`) }; - return body; - } catch (e) { return { success: false, error: String(e) }; } -} - // Record one variant per device into a committed trail folder. The recorded .trail.yaml // lands back in the bundle on completion. Requires the folder to carry a blaze.yaml to drive the run. async function recordTrailFolder(folderId, deviceIds, options) { @@ -626,6 +628,33 @@ async function recordScreen(trailblazeDeviceId) { } catch (e) { return { ok: false, error: String(e) }; } } +// Continuous Android mirror. The daemon sends one complete JPEG per binary WebSocket message; +// callers own rendering and fall back to recordScreen when the socket closes or stalls. Keeping the +// fallback in the screen component means iOS/Playwright retain their existing polling behavior. +function recordFrameStream(trailblazeDeviceId, handlers) { + if (!trailblazeDeviceId || !trailblazeDeviceId.instanceId) throw new Error('trailblazeDeviceId is required'); + const platform = trailblazeDeviceId.trailblazeDevicePlatform || ''; + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = new URL(`${scheme}//${window.location.host}/trailrunner/api/record/stream`); + url.searchParams.set('instanceId', trailblazeDeviceId.instanceId); + url.searchParams.set('platform', platform); + const socket = new WebSocket(url.toString()); + socket.binaryType = 'arraybuffer'; + socket.onopen = () => handlers?.onOpen?.(); + socket.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) handlers?.onFrame?.(event.data); + }; + socket.onerror = () => handlers?.onError?.(); + socket.onclose = (event) => handlers?.onClose?.(event); + return { + close: () => { + socket.onclose = null; + socket.onerror = null; + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close(); + }, + }; +} + // `gesture`: { type: 'tap'|'longPress'|'swipe'|'inputText'|'pressKey', ...fields }. async function recordGesture(trailblazeDeviceId, gesture) { try { @@ -925,13 +954,11 @@ async function setExternalAgentAutoApprove(id, enabled) { } catch (e) { return { ok: false, error: 'the daemon could not be reached: ' + String(e && e.message || e) }; } } -// The trail file(s) the generation agent is writing, polled while it works so the live trail rail can -// stream them in as they appear. Files fill progressively (empty until the agent creates the trail); +// One fetcher for both live trail-folder views (they share a response shape by design). // 404-graceful so a daemon build without the endpoint yet just yields no files rather than erroring. -async function demoTrailContent(id) { - if (!id) return { ok: false, files: [] }; +async function fetchTrailContent(url) { try { - const r = await fetch(`/trailrunner/api/external-agent/${encodeURIComponent(id)}/demo/trail-content`); + const r = await fetch(url); const raw = await r.text().catch(() => ''); let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch (_) {} if (!r.ok || body.ok === false) return { ok: false, error: externalAgentErrorDetail(r.status, raw, body), status: r.status, files: [] }; @@ -939,6 +966,89 @@ async function demoTrailContent(id) { } catch (e) { return { ok: false, error: 'the daemon could not be reached: ' + String(e && e.message || e), files: [] }; } } +// The trail file(s) the generation agent is writing, polled while it works so the live trail rail +// can stream them in as they appear. Files fill progressively (empty until the agent creates them). +async function demoTrailContent(id) { + if (!id) return { ok: false, files: [] }; + return fetchTrailContent(`/trailrunner/api/external-agent/${encodeURIComponent(id)}/demo/trail-content`); +} + +// The files of the trail folder a COMPANION session declared (an external agent CLI authoring the +// trail from its own repo). Same response shape as demoTrailContent, so the same rail renders both; +// polled while the session runs so external edits stream into the read-only view. +async function companionFolderContent(id) { + if (!id) return { ok: false, files: [] }; + return fetchTrailContent(`/trailrunner/api/companion/${encodeURIComponent(id)}/folder-content`); +} + +// The demo-files tree: the declared folder's recursive listing (names and sizes only), for the +// folder rail's DEMO FILES tab. Polled while that tab is open on a running session. +async function companionFolderTree(id) { + if (!id) return { ok: false, entries: [] }; + return await safeJson(`/trailrunner/api/companion/${encodeURIComponent(id)}/folder-tree`) || { ok: false, entries: [] }; +} + +// "Open in Finder" on the demo-files tab: reveal the session's declared folder. +async function companionRevealFolder(id) { + try { + const r = await fetch(`/trailrunner/api/companion/${encodeURIComponent(id)}/reveal-folder`, { method: 'POST' }); + const body = await r.json().catch(() => ({})); + if (!r.ok || body.ok === false) return { ok: false, error: body.error || `HTTP ${r.status}` }; + return body; + } catch (e) { return { ok: false, error: String(e) }; } +} + +// Right-click on a demo file: open it in the user's editor. The daemon resolves the path strictly +// inside the session's declared folder. +async function companionOpenFile(id, path) { + try { + const r = await fetch(`/trailrunner/api/companion/${encodeURIComponent(id)}/open-file`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok || body.ok === false) return { ok: false, error: body.error || `HTTP ${r.status}` }; + return body; + } catch (e) { return { ok: false, error: String(e) }; } +} + +// The UI reporting what the human did in a companion session (a quick reply, a handback, a +// connected device). The attached agent hears it on the run's event stream / journal. +async function companionUserAction(id, type, payload) { + try { + const r = await fetch(`/trailrunner/api/companion/${encodeURIComponent(id)}/user-action`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, payload: payload || null }), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok || body.ok === false) return { ok: false, error: body.error || `HTTP ${r.status}` }; + return body; + } catch (e) { return { ok: false, error: String(e) }; } +} + +// Mirror of the daemon's BundleStore.variantSlug: the filename a variant ACTUALLY writes as. +// Any card or footer that promises ".trail.yaml" must show this slug, not the raw variant +// name, or the promise and the written file diverge for names like "iOS Phone". +function variantSlug(variant) { + const s = String(variant || '').toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, ''); + return s || 'variant'; +} + +// The one sanctioned UI write in companion mode: save a recorded variant into the session's +// declared folder. The daemon resolves the destination itself and emits recording-saved to the +// agent only after the atomic write lands. +async function companionSaveRecording(id, variant, yaml, platform) { + try { + const r = await fetch(`/trailrunner/api/companion/${encodeURIComponent(id)}/save-recording`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variant, yaml, platform: platform || null }), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok || body.ok === false) return { ok: false, error: body.error || `HTTP ${r.status}` }; + return body; + } catch (e) { return { ok: false, error: String(e) }; } +} + // `afterSeq`: only deliver events strictly newer than this — pass the max seq already fetched so // a stream opened mid-run never re-delivers history as if it were live. function streamExternalAgentEvents(id, afterSeq, onEvent, onDone, onError) { @@ -1035,16 +1145,18 @@ Object.assign(window, { WORKSPACE_BLURB, WORKSPACE_EMPTY_NOTICE, workspaceRestartNotice, setTargetsRestartNeeded, getTargetsRestartNeeded, recordPendingRun, getPendingRun, clearPendingRun, failPendingRun, setPendingRunSession, API, safeJson, safeText, useFetched, fileUrl, - recordConnect, recordScreen, recordGesture, recordTree, recordDisconnect, recordSelectorAdvice, recordToolParams, scriptedToolParams, toolToolUsages, toolToolUsageCounts, + recordConnect, recordScreen, recordFrameStream, recordGesture, recordTree, recordDisconnect, recordSelectorAdvice, recordToolParams, scriptedToolParams, toolToolUsages, toolToolUsageCounts, resolveRunDevice, connectDevice, connectDeviceDetailed, fetchTrailYaml, dispatchRun, retrySession, withTimeout, getTargetApps, setTargetApp, updateTrail, createTrail, createTrailDir, fetchEditedTrails, runToolQuick, updateToolSource, fetchDeviceApps, fetchInstalledApps, fetchInstalledAppBadge, installedAppIconUrl, validateTrail, rebuildDaemon, openSessionFile, revealTrailsRoot, pickDirectoryViaShell, addTrailRoot, removeTrailRoot, updateSetting, runIntegrationAction, deleteSession, clearSessions, cancelSession, revealSession, revealLogsRoot, revealToolSource, openTrailInEditor, revealTrail, exportSessionUrl, sessionArchiveUrl, importSessionArchive, fetchComponentSource, createTrailmapComponent, saveTargetConfig, proposeSteps, createBundle, fetchBundleDetail, deleteTrailFolder, revealTrailFolder, - fetchTrailFolderFile, saveTrailFolderFile, deleteTrailFolderFile, recordTrailFolder, migrateTrailFolder, + fetchTrailFolderFile, saveTrailFolderFile, deleteTrailFolderFile, recordTrailFolder, fetchExternalAgents, startExternalAgent, fetchExternalAgentEvents, cancelExternalAgent, streamExternalAgentEvents, fetchAgentSkills, replyExternalAgent, applyTrailRunnerUiCommand, mergeExternalAgentEvents, startDemo, demoMarkStart, demoFinish, demoGenerate, demoAddPlatform, demoDeleteStep, demoRevealBundle, - decideExternalAgentPermission, setExternalAgentAutoApprove, demoTrailContent, + decideExternalAgentPermission, setExternalAgentAutoApprove, demoTrailContent, companionFolderContent, + companionUserAction, companionSaveRecording, variantSlug, reviewSession, + companionFolderTree, companionRevealFolder, companionOpenFile, }); diff --git a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-extract.tsx b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-extract.tsx index e4e246f02..1abd91d55 100644 --- a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-extract.tsx +++ b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-extract.tsx @@ -466,9 +466,11 @@ window.TB = { recordPendingRun, getPendingRun, clearPendingRun, failPendingRun, useGlobalTarget, getGlobalTarget, setGlobalTarget, buildBlazeYaml, mergeBlazeYaml, proposeSteps, createBundle, fetchBundleDetail, deleteTrailFolder, revealTrailFolder, - fetchTrailFolderFile, saveTrailFolderFile, deleteTrailFolderFile, recordTrailFolder, migrateTrailFolder, - recordConnect, recordScreen, recordGesture, recordTree, recordDisconnect, recordSelectorAdvice, recordToolParams, scriptedToolParams, + fetchTrailFolderFile, saveTrailFolderFile, deleteTrailFolderFile, recordTrailFolder, + recordConnect, recordScreen, recordFrameStream, recordGesture, recordTree, recordDisconnect, recordSelectorAdvice, recordToolParams, scriptedToolParams, useExternalAgents, useExternalAgentEvents, startExternalAgent, cancelExternalAgent, replyExternalAgent, applyTrailRunnerUiCommand, fetchAgentSkills, startDemo, demoMarkStart, demoFinish, demoGenerate, demoAddPlatform, demoDeleteStep, demoRevealBundle, - decideExternalAgentPermission, setExternalAgentAutoApprove, demoTrailContent, + decideExternalAgentPermission, setExternalAgentAutoApprove, demoTrailContent, companionFolderContent, + companionUserAction, companionSaveRecording, variantSlug, reviewSession, + companionFolderTree, companionRevealFolder, companionOpenFile, }; diff --git a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-hooks.tsx b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-hooks.tsx index e1a026ec0..b4b39e73f 100644 --- a/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-hooks.tsx +++ b/trailblaze-host/src/main/resources/xyz/block/trailblaze/trailrunner/web/app/data-hooks.tsx @@ -277,7 +277,7 @@ function useSessionAnalytics(sessionId, isRunning) { return hook; } -// Per-stream event capture for a run (logs//events/.

${esc(title)}

`; downloadBlob([html], 'text/html;charset=utf-8', `trailblaze_run_${fileSlug(session.meta.title)}_screenshots.html`); }; + // Sessions past the driver's inline threshold embed their events gzipped (eventsGz). Inflated + // streams are cached OUTSIDE the session object so exportReport re-embeds the compact form; + // everything below reads events via sessionEvents(). Inflation kicks off when a session opens + // and re-renders on completion — until then renderers see null and show a decompressing note. + const eventsCache = new Map(); + const eventsPending = new Set(); + const eventsFailed = new Set(); + const sessionEvents = (session) => session.events || eventsCache.get(session) || null; + // The session has events to show: inflated (or inline) streams, or a compressed payload that + // will inflate once the session opens. + const hasEvents = (session) => Boolean((sessionEvents(session) || []).length || session.eventsGz); + const ensureEventsInflated = (session) => { + if (!session.eventsGz || session.events || eventsCache.has(session) || eventsPending.has(session) || eventsFailed.has(session)) return; + eventsPending.add(session); + inflateEventsGz(session.eventsGz).then((streams) => { + eventsPending.delete(session); + if (streams) eventsCache.set(session, streams); else eventsFailed.add(session); + if (st.view === 'detail' && D === session) render(); + }); + }; + const logPayload = (session) => ({ run: session.meta || {}, deviceLog: session.deviceLog || null, network: session.network || [], - events: session.events || [], + events: sessionEvents(session) || [], llm: session.llm || [], }); - const hasLogs = (session) => Boolean(session.deviceLog || (session.network && session.network.length) || (session.events && session.events.length) || (session.llm && session.llm.length)); + const hasLogs = (session) => + Boolean(session.deviceLog || (session.network && session.network.length) || hasEvents(session) || (session.llm && session.llm.length)); const exportLogs = (session) => { if (!hasLogs(session)) return; downloadBlob([JSON.stringify(logPayload(session), null, 2)], 'application/json;charset=utf-8', `trailblaze_run_${fileSlug(session.meta.title)}_logs.json`); @@ -1154,16 +1313,22 @@ function RUN_REPORT_VIEWER(): void { let D: SessionPayload = SESSIONS[0]; const st = { view: MULTI ? 'index' : 'detail', session: 0, tab: 'timeline', step: 0, llmSel: 0, evStream: 0, tlStreams: [], tlMenuOpen: false, trailheadOpen: true, trailOpen: true, collapsedGroups: [], lightboxAll: false, runSort: 'grouped', runFilter: '', playing: false, vSpeed: 1, pageTransition: '' }; const TIMELINE_PLAY_MS = 900; // per-step dwell when auto-playing the screenshot timeline - // Timeline screenshot-playback timer. Declared up here (before openSession, which stops it) so the + const TIMELINE_VIDEO_TICK_MS = 100; // playback-clock granularity when the timeline plays real video + // Timeline playback timer. Declared up here (before openSession, which stops it) so the // init-time openSession() call for a single-session report doesn't hit a temporal-dead-zone ref. let timelineTimer = null; const stopTimeline = () => { if (timelineTimer) { clearInterval(timelineTimer); timelineTimer = null; } st.playing = false; }; + // Per-frame aspect ratio of the current session's video sprite (`w / h`). The sprite layout + // carries frame height but not width, so it's measured once from the sprite's natural size + // (measureSpriteAspect) and inlined by every later render of a frame box. + let spriteAspect = null; // Open a session's detail. Failed runs lead with the actionable tool; passing runs start at the // authored trail so any recovery summary remains the first thing visible above it. Incidental // failed polling rows in a passing run are intentionally ignored. const openSession = (i) => { - stopTimeline(); st.session = i; D = SESSIONS[i]; st.view = 'detail'; st.tab = 'timeline'; st.llmSel = 0; st.evStream = 0; st.tlStreams = []; st.tlMenuOpen = false; st.trailOpen = true; st.collapsedGroups = []; st.lightboxAll = false; + stopTimeline(); spriteAspect = null; st.session = i; D = SESSIONS[i]; st.view = 'detail'; st.tab = 'timeline'; st.llmSel = 0; st.evStream = 0; st.tlStreams = []; st.tlMenuOpen = false; st.trailOpen = true; st.collapsedGroups = []; st.lightboxAll = false; + ensureEventsInflated(D); const runFailed = ['failed', 'error'].indexOf(String((D.meta && D.meta.status) || '').toLowerCase()) >= 0; const failedTool = runFailed ? D.trace.findIndex((t) => !t.objective && !t.ok) : -1; const firstFail = failedTool >= 0 ? failedTool : (runFailed ? D.trace.findIndex((t) => !t.ok) : -1); @@ -1223,8 +1388,11 @@ function RUN_REPORT_VIEWER(): void { if (allowed.indexOf(requestedTab) >= 0) st.tab = requestedTab; if (r.step != null && Number.isFinite(r.step) && D.trace.some((t) => t.i === r.step)) { st.step = r.step; revealTimelineStep(st.step); } if (Number.isFinite(r.llm) && r.llm >= 0 && r.llm < D.llm.length) st.llmSel = r.llm; - if (Number.isFinite(r.stream) && r.stream >= 0 && r.stream < (D.events || []).length) st.evStream = r.stream; - if (r.streams != null) st.tlStreams = r.streams.split(',').map(Number).filter((i) => Number.isInteger(i) && i >= 0 && i < (D.events || []).length); + // No upper-bound check here: stream counts may be unknown while a compressed events payload + // is still inflating, so the consumers own the clamp (renderEvents clamps st.evStream; + // streamEvents ignores unknown tlStreams indices). + if (Number.isFinite(r.stream) && r.stream >= 0) st.evStream = r.stream; + if (r.streams != null) st.tlStreams = r.streams.split(',').map(Number).filter((i) => Number.isInteger(i) && i >= 0); }; const writeRoute = (replace) => { if (typeof history === 'undefined' || typeof location === 'undefined') return; @@ -1368,6 +1536,39 @@ function RUN_REPORT_VIEWER(): void { return null; }; + // The session's video, but only when it can be mapped onto the run clock (its capture-start + // timestamp and at least one step timestamp exist). Otherwise the timeline keeps screenshots. + const tlVideo = () => (D.video && D.video.startMs != null && traceT0() != null) ? D.video : null; + // Wall-clock ms a step represents on the run clock: its own timestamp, else the nearest earlier + // (then next) timed row — mirroring shotForStep's never-empty fallback. Non-null whenever + // tlVideo() is non-null (its traceT0 gate guarantees a timed row exists). + const stepClockMs = (i) => { + const at = idxOf(i); + for (let k = at; k >= 0; k--) { if (D.trace[k].ts != null) return D.trace[k].ts; } + for (let k = at + 1; k < D.trace.length; k++) { if (D.trace[k].ts != null) return D.trace[k].ts; } + return null; + }; + const videoFrameAt = (v, clockMs) => Math.max(v.startFrame, Math.min(v.endFrame, Math.floor(((clockMs - v.startMs) * v.fps) / 1000))); + const videoEndMs = (v) => v.startMs + ((v.endFrame + 1) * 1000) / v.fps; + // CSS background geometry for one logical sprite frame (shared by the timeline preview and the + // Video tab player): frames are laid out column-major, shown via background-position. + const spriteFrameCss = (v, logical) => { + const physical = (v.frameMap[logical] != null) ? v.frameMap[logical] : logical; + const col = Math.floor(physical / v.rows); + const row = physical % v.rows; + return { + size: `${v.columns * 100}% ${v.rows * 100}%`, + position: `${v.columns > 1 ? (col / (v.columns - 1)) * 100 : 0}% ${v.rows > 1 ? (row / (v.rows - 1)) * 100 : 0}%`, + }; + }; + // One-shot measurement backing `spriteAspect` (frame boxes are background-image divs with no + // intrinsic size); `done` runs on first resolution so the caller can apply it to the live box. + const measureSpriteAspect = (v, done) => { + const img = new Image(); + img.onload = () => { const fw = img.naturalWidth / v.columns; if (fw > 0 && v.frameHeight > 0 && spriteAspect == null) { spriteAspect = `${fw} / ${v.frameHeight}`; done(); } }; + img.src = v.sprite; + }; + // The report-time action overlay on a step's screenshot: a tap/long-press dot, a swipe arrow, an // assertion ok-dot, or a failed-assertion red border. Positioned by device-pixel ratio over an // that's width:100% and preserves the screenshot's aspect, so percentages map directly. @@ -1442,10 +1643,15 @@ function RUN_REPORT_VIEWER(): void { // Match Trail Runner's high-volume stream behavior: streams are opt-in on the timeline. The // selected indices live in the URL so a filtered timeline can be shared exactly as viewed. - const streamEvents = () => (D.events || []).flatMap((stream, streamIndex) => - st.tlStreams.indexOf(streamIndex) < 0 ? [] - : (stream.events || []).map((e, n) => ({ ...e, stream: stream.name, streamIndex, key: `${stream.name}-${n}` })), - ).sort((a, b) => (a.t || 0) - (b.t || 0)); + const streamEvents = () => (sessionEvents(D) || []).flatMap((stream, streamIndex): Array<{ t: number | null; d?: string; row?: FormattedRow; stream: string; streamIndex: number; key: string }> => { + if (st.tlStreams.indexOf(streamIndex) < 0) return []; + // A formatted stream contributes its formatter-produced rows to the timeline; a generic one + // contributes its raw events. Both carry the same clock + stream identity downstream. + if (stream.rows && stream.rows.length) { + return stream.rows.map((row, n) => ({ t: row.t, row, stream: stream.name, streamIndex, key: `${stream.name}-${n}` })); + } + return (stream.events || []).map((e, n) => ({ ...e, stream: stream.name, streamIndex, key: `${stream.name}-${n}` })); + }).sort((a, b) => (a.t || 0) - (b.t || 0)); const eventBuckets = (events) => { const buckets = D.trace.map(() => []); @@ -1469,98 +1675,35 @@ function RUN_REPORT_VIEWER(): void { // status color; the producer name and diamond remain redundant cues when color is unavailable. const streamColor = (index) => `oklch(74% .14 ${(70 + index * 137.508) % 360})`; - const eventPayloadCache = new WeakMap(); - const parseEventJsonish = (value, depth = 0) => { - if (depth > 8 || value == null) return value; - if (typeof value !== 'string') { - if (Array.isArray(value)) return value.map((v) => parseEventJsonish(v, depth + 1)); - if (typeof value === 'object') { - const out = {}; - Object.keys(value).forEach((k) => { out[k] = parseEventJsonish(value[k], depth + 1); }); - return out; - } - return value; - } - const raw = value.trim(); - if (!raw) return value; - const candidates = [raw]; - if (raw.indexOf('\\"') >= 0 || raw.indexOf('\\\\') >= 0) { - // Let the JSON parser decode one quoting layer at a time. Manually replacing escape - // sequences here can double-unescape producer-controlled text and change its meaning. - candidates.push(`"${raw}"`); - } - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate); - return parseEventJsonish(parsed, depth + 1); - } catch (_) {} - } - return value; - }; - - const eventValueText = (value) => { - if (value == null) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - return JSON.stringify(value); - }; - - const eventFieldKinds: Array<[string, string[]]> = [ - ['Event', ['event', 'eventname', 'eventvalue', 'name', 'label', 'title', 'message']], - ['Action', ['action', 'actiontext', 'blockeraction', 'cdfaction']], - ['Entity', ['entity', 'cdfentity', 'namespace']], - ['Path', ['path', 'urlpath', 'finalpath', 'uniquefinalpath']], - ['Status', ['status', 'statuscode', 'code']], - ['Method', ['method']], - ['Journey', ['journey', 'journeyname', 'flow', 'clientscenario']], - ['ID', ['id', 'messageuuid', 'blockerid', 'flowtoken']], - ]; - - const normalizeEventPayload = (event, source) => { - const cached = eventPayloadCache.get(event); - if (cached) return cached; - const raw = String(event.d == null ? '' : event.d); - const parsed = parseEventJsonish(raw); - const found = new Map(); - const queue = [{ value: parsed, depth: 0 }]; - let visited = 0; - while (queue.length && visited++ < 240) { - const current = queue.shift(); - const value = current.value; - if (current.depth > 6 || value == null || typeof value !== 'object') continue; - if (Array.isArray(value)) { - value.slice(0, 40).forEach((item) => queue.push({ value: item, depth: current.depth + 1 })); - continue; - } - Object.keys(value).slice(0, 80).forEach((key) => { - const child = value[key]; - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); - if (!found.has(normalized) && child != null && child !== '') found.set(normalized, child); - if (child && typeof child === 'object') queue.push({ value: child, depth: current.depth + 1 }); - }); - } - const fields = []; - eventFieldKinds.forEach(([label, names]) => { - const name = names.find((candidate) => found.has(candidate)); - const text = name ? eventValueText(found.get(name)) : ''; - if (text && !fields.some((field) => field.value === text)) fields.push({ label, value: text }); - }); - const labelField = eventFieldKinds[0][1].find((name) => found.has(name)); - const label = (labelField && eventValueText(found.get(labelField))) || source || 'Event'; - let pretty = raw; - try { if (parsed !== raw) pretty = JSON.stringify(parsed, null, 2); } catch (_) {} - const normalized = { raw, parsed, fields: fields.slice(0, 8), semanticLabel: labelField ? eventValueText(found.get(labelField)) : '', pretty }; - eventPayloadCache.set(event, normalized); - return normalized; - }; - - const renderEventPayload = (event, source) => { - const { fields, semanticLabel, pretty } = normalizeEventPayload(event, source); + // Payload bodies stay EMPTY at render time (data-lazyevent marks the expando); wireLazyEventBodies + // fills them on first open. Rendering 8k+ events with multi-MB payloads inline would freeze the tab. + const renderEventPayload = (event, source, lazyIdx) => { + const { fields, semanticLabel } = normalizeEventPayload(event); const label = semanticLabel || source || 'Event'; const fieldHtml = fields.length ? `
${fields.map((f) => `
${esc(f.label)}
${esc(f.value)}
`).join('')}
` : ''; - return `
${esc(label)}${source ? `${esc(source)}` : ''}
${fieldHtml}
Raw JSON
${esc(pretty)}
`; - }; - + return `
${esc(label)}${source ? `${esc(source)}` : ''}
${fieldHtml}
Raw JSON
`; + }; + + // Formatter-produced rows (EventStream.rows): the netlog-style rendering. Rows are pure data + // built at report-generation time (see run-report-events.ts) — the viewer owns ALL markup, so a + // formatter can never inject HTML or depend on the report's internals. + const rowToneClass = (tone) => tone === 'error' ? ' e' : tone === 'warn' ? ' w' : ''; + const rowBadgesHtml = (row) => (row.badges || []).map((b) => `${esc(b.text)}`).join(''); + const formattedRowBody = (row) => { + const fields = (row.fields || []).length ? `
${row.fields.map((f) => `
${esc(f.k)}
${esc(f.v)}
`).join('')}
` : ''; + const kvHtml = (kv) => `
${kv.map((f) => `
${esc(f.k)}${esc(f.v)}
`).join('')}
`; + const sections = (row.sections || []).map((s) => + `
${esc(s.title)}${s.kv ? kvHtml(s.kv) : `
${esc(s.text || '')}
`}
`).join(''); + const raw = (row.raw || []).length ? `
Raw JSON${row.raw.map((r) => `
${esc(r)}
`).join('')}
` : ''; + return `${fields}${sections}${raw}`; + }; + const formattedRowSummary = (rel, row) => + `${esc(rel)}${esc(row.label)}${rowBadgesHtml(row)}`; + + // Timeline event bodies are lazy like the Events tab's (payloads are untruncated): each + // rendered
carries a data-lazykey resolved through this map by wireLazyTimelineBodies. + // Rebuilt on every timeline render (streamGroupHtml runs per step bucket within one render pass). + const tlEventByKey = new Map(); const streamGroupHtml = (events) => { if (!events.length) return ''; const t0 = traceT0(); @@ -1573,36 +1716,41 @@ function RUN_REPORT_VIEWER(): void { return groups.map((group) => { const rel = (e) => e.t != null && t0 != null ? `+${((e.t - t0) / 1000).toFixed(2)}s` : ''; const items = group.events.map((e) => { - const { semanticLabel, pretty } = normalizeEventPayload(e, 'Event'); + tlEventByKey.set(e.key, e); + if (e.row) { + const badges = rowBadgesHtml(e.row); + return `
${esc(rel(e))}${esc(e.row.label)}${badges ? ` ${badges}` : ''}
`; + } + const { semanticLabel } = normalizeEventPayload(e); const label = semanticLabel || 'Event'; - return `
${esc(rel(e))}${esc(label)}
${esc(pretty)}
`; + return `
${esc(rel(e))}${esc(label)}
`; }).join(''); return `
${esc(group.stream)}${group.events.length} event${group.events.length === 1 ? '' : 's'}
${items}
`; }).join(''); }; const scrubberHtml = (axis, events, pos) => { - const ticks = D.trace.map((t, i) => ``).join(''); + const ticks = D.trace.map((t, i) => ``).join(''); const eventTicks = events.map((e) => { const f = axis.tsFrac(e.t); if (f == null) return ''; - return ``; + return ``; }).join(''); const frac = axis.stepFrac[pos] || 0; const trailStart = D.trace.findIndex((t) => t.objective && !t.trailhead); const hasTrailhead = D.trace.some((t) => t.objective && t.trailhead); const trailFrac = trailStart >= 0 ? (axis.stepFrac[trailStart] || 0) : 1; const rail = hasTrailhead && trailStart < 0 - ? `
` + ? `
` : hasTrailhead - ? `
` - : `
`; + ? `
` + : `
`; const phaseLabel = hasTrailhead && trailStart < 0 ? 'Timeline for Trailhead setup. The dotted rail marks deterministic setup.' : hasTrailhead ? 'Timeline. Dotted segment is Trailhead setup; solid segment is the authored Trail.' : 'Timeline for the authored Trail.'; const current = D.trace[pos]; const phase = hasTrailhead && (trailStart < 0 || pos < trailStart) ? 'Trailhead' : 'Trail'; const valueText = `${phase}, item ${pos + 1} of ${D.trace.length}: ${(current && current.label) || 'Timeline item'}`; - return `
0:00
${rail}${ticks}${eventTicks}
${fmtClock(axis.totalMs)}
`; + return `
0:00
${rail}${ticks}${eventTicks}
${fmtClock(axis.totalMs)}
`; }; const stepRowHtml = (t, child) => { @@ -1633,7 +1781,8 @@ function RUN_REPORT_VIEWER(): void { const failureSummary = renderFailureSummary(groups); const selfHealSummary = renderSelfHealSummary(groups); if (!D.trace.length) return `${failureSummary}${selfHealSummary}
This run didn't emit any agent-task steps.
`; - const streams = D.events || []; + const streams = sessionEvents(D) || []; + tlEventByKey.clear(); const events = streamEvents(); const buckets = eventBuckets(events); const streamChooser = streams.length ? `
Event streams${st.tlStreams.length} of ${streams.length}
Include in timeline
${streams.map((stream, i) => ``).join('')}
` : ''; @@ -1682,13 +1831,20 @@ function RUN_REPORT_VIEWER(): void { const cur = D.trace.find((t) => t.i === st.step) || D.trace[0]; const shot = shotForStep(st.step); const pos = idxOf(st.step); - const axis = timelineAxis(); + // Prefer the captured video over per-step screenshots: show the video frame at this step's + // run-clock time. Screenshot (then the empty note) remains the fallback. + const v = tlVideo(); + const cell = v ? spriteFrameCss(v, videoFrameAt(v, stepClockMs(st.step))) : null; + const pane = cell + ? `
${markHtml(cur)}
` + : shot + ? `
${esc(cur.label)} at step ${pos + 1}${cur.screenshotFile ? markHtml(cur) : ''}
` + : `
No screenshot captured before this step.
`; return `
${failureSummary}${selfHealSummary}${streamChooser ? `
${streamChooser}
` : ''}${hasSteps ? stepsHtml : `
${stepsHtml}
`}
- ${scrubberHtml(axis, events, pos)}
-
- ${shot ? `
${esc(cur.label)} at step ${pos + 1}${cur.screenshotFile ? markHtml(cur) : ''}
` : `
No screenshot captured before this step.
`} +
+ ${pane}
@@ -1829,27 +1985,100 @@ function RUN_REPORT_VIEWER(): void { apply(); }; - // Generic session-events tab: one selectable stream per `events/. -

Trailblaze Server

-

${sessions.size} session(s)

- - - - - - - - - - - - $rows - -
StatusSessionWhenDuration
+
+
+
Trailblaze
+ Daemon running +
+
+
+
+

Trailblaze daemon

+

Your testing workspace is ready.

+

$heroDescription

+
+ $trailRunnerButton + View all sessions +
+
+ $trailRunnerWorkflow +
+ + + +
+
+
+

Run history

+

Recent sessions

+
+ ${sessions.size} total +
+
+ + + + + + + + + + + $rows + +
StatusSessionWhenDuration
+
+
+
+
""" @@ -105,7 +453,7 @@ object HomeEndpoint { $statusLabel $title -
$sessionIdEscaped
+
$sessionIdEscaped
Report Storyboard @@ -164,10 +512,11 @@ object HomeEndpoint { routing: Routing, logsRepo: LogsRepo, homeCallbackHandler: ((parameters: Map>) -> Result)? = null, + trailRunnerPath: String? = null, ) = with(routing) { get("/") { val callbackHandlerResult = homeCallbackHandler?.invoke(call.request.queryParameters.toMap()) - val defaultPage = defaultHtml(logsRepo) + val defaultPage = defaultHtml(logsRepo, trailRunnerPath) val htmlResult = callbackHandlerResult?.getOrNull() ?: defaultPage call.respondText(text = htmlResult, contentType = ContentType.Text.Html) } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpoint.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpoint.kt new file mode 100644 index 000000000..8c457d712 --- /dev/null +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpoint.kt @@ -0,0 +1,116 @@ +package xyz.block.trailblaze.logs.server.endpoints + +import io.ktor.server.routing.Routing +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readBytes +import io.ktor.websocket.send +import java.io.File +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jetbrains.annotations.TestOnly +import xyz.block.trailblaze.logs.client.TrailblazeLogProtoCodec +import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.ondevice.rpc.proto.LogUploadAck +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec +import xyz.block.trailblaze.report.utils.LogsRepo + +/** Persistent protobuf endpoint for device-to-host logs, screenshots, and traces. */ +internal object LogWebSocketEndpoint { + @TestOnly private var connectionListener: () -> Unit = {} + + @TestOnly + fun setServerConnectionListener(listener: () -> Unit) { + connectionListener = listener + } + + fun register(routing: Routing, logsRepo: LogsRepo) = with(routing) { + webSocket("/logs-ws") { + connectionListener() + val sendMutex = Mutex() + for (frame in incoming) { + if (frame !is Frame.Binary) continue + val frameBytes = frame.readBytes() + var uploadId = 0L + val ack = try { + val upload = OnDeviceRpcProtoCodec.decodeLogUpload(frameBytes) + uploadId = upload.upload_id + val agentLog = upload.agent_log + val screenshot = upload.screenshot + val trace = upload.trace + when { + agentLog != null -> { + val log = TrailblazeLogProtoCodec.run { agentLog.toModel() } + AgentLogEndpoint.accept(log, logsRepo) + } + screenshot != null -> saveScreenshot( + logsRepo = logsRepo, + session = screenshot.session_id, + filename = screenshot.filename, + bytes = screenshot.image.toByteArray(), + ) + trace != null -> saveTrace( + logsRepo = logsRepo, + session = trace.session_id, + json = trace.trace_json.utf8(), + ) + else -> error("Protobuf log upload omitted its payload") + } + LogUploadAck(upload_id = upload.upload_id, success = true) + } catch (e: Exception) { + LogUploadAck( + upload_id = uploadId, + success = false, + error_message = e.message ?: e::class.simpleName, + ) + } + sendMutex.withLock { + send( + Frame.Binary( + fin = true, + data = OnDeviceRpcProtoCodec.encode(ack), + ), + ) + } + } + } + } + + private fun saveScreenshot( + logsRepo: LogsRepo, + session: String, + filename: String, + bytes: ByteArray, + ) { + val sessionDir = validatedSessionDir(logsRepo, session) + requireSafeSegment(filename, "filename") + val destination = File(sessionDir, filename) + require(destination.canonicalPath.startsWith(sessionDir.canonicalPath + File.separator)) { + "Invalid filename" + } + destination.writeBytes(bytes) + } + + private fun saveTrace(logsRepo: LogsRepo, session: String, json: String) { + validatedSessionDir(logsRepo, session).resolve("trace.json").writeText(json) + } + + private fun validatedSessionDir(logsRepo: LogsRepo, session: String): File { + requireSafeSegment(session, "session ID") + val candidate = File(logsRepo.logsDir, session) + require(candidate.canonicalPath.startsWith(logsRepo.logsDir.canonicalPath + File.separator)) { + "Invalid session ID" + } + return logsRepo.getSessionDir(SessionId(session)) + } + + private fun requireSafeSegment(value: String, label: String) { + require( + value.isNotBlank() && + !value.contains("..") && + !value.contains('/') && + !value.contains('\\') && + !value.contains('\u0000'), + ) { "Invalid $label" } + } +} diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcClient.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcClient.kt index 17c7f1414..f45596439 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcClient.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcClient.kt @@ -1,6 +1,7 @@ package xyz.block.trailblaze.mcp.android.ondevice.rpc import kotlinx.coroutines.delay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerializationException import xyz.block.trailblaze.devices.TrailblazeDeviceId @@ -12,6 +13,8 @@ import xyz.block.trailblaze.mcp.utils.HttpRequestUtils import xyz.block.trailblaze.mcp.utils.HttpRequestUtils.HttpRpcException import xyz.block.trailblaze.util.AndroidHostAdbUtils import xyz.block.trailblaze.util.UiAutomationHandleErrors +import xyz.block.trailblaze.transport.AndroidWireTransport +import xyz.block.trailblaze.transport.AndroidWireTransportMode import java.io.IOException import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -102,6 +105,9 @@ class OnDeviceRpcClient( baseUrl = baseUrl, ) + @PublishedApi + internal val webSocketClient = OnDeviceRpcWebSocketClient(baseUrl) + /** * Generic RPC call function that handles request serialization, routing, and response deserialization. * Wraps the result in RpcResult to handle errors gracefully. @@ -132,6 +138,14 @@ class OnDeviceRpcClient( val methodName = TRequest::class.simpleName return try { + if (AndroidWireTransport.mode != AndroidWireTransportMode.JSON) { + tryBinaryRpc( + request = request, + urlPath = urlPath, + )?.let { binaryResult -> + return binaryResult + } + } val jsonInputString = TrailblazeJsonInstance.encodeToString(request) val responseJson = httpRequestUtils.postRequest( urlPath = urlPath, @@ -140,6 +154,8 @@ class OnDeviceRpcClient( ) val response: TResponse = TrailblazeJsonInstance.decodeFromString(responseJson) RpcResult.Success(response) + } catch (e: CancellationException) { + throw e } catch (e: HttpRpcException) { RpcResult.Failure( errorType = RpcResult.ErrorType.HTTP_ERROR, @@ -181,6 +197,53 @@ class OnDeviceRpcClient( } } + /** + * Uses the persistent, typed protobuf WebSocket for every on-device RPC. `null` means no bytes + * were sent and the caller may safely fall back to HTTP/JSON (older runner or not-yet-listening + * server). + */ + @PublishedApi + internal suspend fun tryBinaryRpc( + request: RpcRequest<*>, + urlPath: String, + ): RpcResult? { + val timeoutMs = request.requestTimeoutMs ?: DEFAULT_REQUEST_TIMEOUT_MS + val attempt = try { + webSocketClient.call(request, timeoutMs) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return RpcResult.Failure( + errorType = RpcResult.ErrorType.NETWORK_ERROR, + message = "Binary RPC failed: ${e.message}", + details = e.stackTraceToString(), + method = request::class.simpleName, + url = "$baseUrl$urlPath", + ).also { noteIfNonRecoverableWedge(it.message, it.details) } + } + + return when (attempt) { + is OnDeviceRpcWebSocketClient.Attempt.FallbackToHttp -> { + if (AndroidWireTransport.mode == AndroidWireTransportMode.AUTO) { + null + } else { + RpcResult.Failure( + errorType = RpcResult.ErrorType.NETWORK_ERROR, + message = "Protobuf RPC transport is unavailable", + method = request::class.simpleName, + url = "$baseUrl$urlPath", + ) + } + } + is OnDeviceRpcWebSocketClient.Attempt.Failure -> + attempt.failure.also { noteIfNonRecoverableWedge(it.message, it.details) } + is OnDeviceRpcWebSocketClient.Attempt.Success<*> -> { + @Suppress("UNCHECKED_CAST") + RpcResult.Success(attempt.value as TResponse) + } + } + } + /** * Convenience method for RPC calls with success and failure callbacks. * This is more ergonomic than manually handling RpcResult in a when expression. @@ -336,6 +399,7 @@ class OnDeviceRpcClient( } private companion object { + const val DEFAULT_REQUEST_TIMEOUT_MS = 300_000L /** How often to emit a "still waiting" progress message while polling for readiness. */ const val PROGRESS_REPORT_INTERVAL_MS = 5_000L @@ -349,6 +413,7 @@ class OnDeviceRpcClient( } override fun close() { + webSocketClient.close() httpRequestUtils.close() } } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClient.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClient.kt new file mode 100644 index 000000000..8d02ffc44 --- /dev/null +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClient.kt @@ -0,0 +1,298 @@ +package xyz.block.trailblaze.mcp.android.ondevice.rpc + +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec +import xyz.block.trailblaze.ondevice.rpc.proto.RpcRequestEnvelope +import xyz.block.trailblaze.ondevice.rpc.proto.RpcResponseEnvelope +import xyz.block.trailblaze.util.Console + +/** Persistent, multiplexed binary channel to the Android on-device runner. */ +internal class OnDeviceRpcWebSocketClient( + baseUrl: String, + private val client: OkHttpClient = defaultClient(), +) : AutoCloseable { + + sealed interface Attempt { + data class Success(val value: T) : Attempt + data class Failure(val failure: RpcResult.Failure) : Attempt + /** No request was sent, so the caller may safely use HTTP instead. */ + data object FallbackToHttp : Attempt + } + + private val webSocketUrl = baseUrl.replaceFirst("http://", "ws://") + WEBSOCKET_PATH + private val requestIds = AtomicLong(0) + private val pending = ConcurrentHashMap>() + private val connectionLock = Any() + + @Volatile private var socket: WebSocket? = null + @Volatile private var connectingSocket: WebSocket? = null + @Volatile private var connecting: CompletableDeferred? = null + @Volatile private var unsupported = false + @Volatile private var closed = false + + suspend fun call(request: RpcRequest<*>, timeoutMs: Long): Attempt { + return when ( + val attempt = exchange( + requestId = requestIds.incrementAndGet(), + timeoutMs = timeoutMs, + payload = { id -> request.toEnvelope(id) }, + ) + ) { + is Attempt.FallbackToHttp -> attempt + is Attempt.Failure -> attempt + is Attempt.Success -> decodeResponse(request, attempt.value) + } + } + + private fun RpcRequest<*>.toEnvelope(requestId: Long): RpcRequestEnvelope? = + OnDeviceRpcProtoCodec.run { + when (this@toEnvelope) { + is GetScreenStateRequest -> RpcRequestEnvelope( + request_id = requestId, + get_screen_state = toProto(), + ) + is xyz.block.trailblaze.llm.RunYamlRequest -> RpcRequestEnvelope( + request_id = requestId, + run_yaml = toProto(), + ) + is DrainSessionRequest -> RpcRequestEnvelope( + request_id = requestId, + drain_session = toProto(), + ) + is SubscribeToProgressRequest -> RpcRequestEnvelope( + request_id = requestId, + subscribe_to_progress = toProto(), + ) + is GetExecutionStatusRequest -> RpcRequestEnvelope( + request_id = requestId, + get_execution_status = toProto(), + ) + is ListActiveSessionsRequest -> RpcRequestEnvelope( + request_id = requestId, + list_active_sessions = toProto(), + ) + else -> null + } + } + + private fun decodeResponse( + request: RpcRequest<*>, + response: RpcResponseEnvelope, + ): Attempt = OnDeviceRpcProtoCodec.run { + val model = when (request) { + is GetScreenStateRequest -> response.get_screen_state?.toModel() + is xyz.block.trailblaze.llm.RunYamlRequest -> response.run_yaml?.toModel() + is DrainSessionRequest -> response.drain_session?.toModel() + is SubscribeToProgressRequest -> response.subscribe_to_progress?.toModel() + is GetExecutionStatusRequest -> response.get_execution_status?.toModel() + is ListActiveSessionsRequest -> response.list_active_sessions?.toModel() + else -> null + } ?: return protocolFailure( + "Binary ${request::class.simpleName} response omitted its payload", + ) + Attempt.Success(model) + } + + private suspend fun exchange( + requestId: Long, + timeoutMs: Long, + payload: (Long) -> RpcRequestEnvelope?, + ): Attempt { + // Preserve the generic client's HTTP behavior for legacy or future request types until they + // gain an explicit protobuf mapping. Strict protobuf mode turns this fallback into a failure. + val envelope = payload(requestId) ?: return Attempt.FallbackToHttp + val activeSocket = ensureConnected() ?: return Attempt.FallbackToHttp + val deferred = CompletableDeferred() + pending[requestId] = deferred + val sent = activeSocket.send(OnDeviceRpcProtoCodec.encode(envelope).toByteString()) + if (!sent) { + pending.remove(requestId) + discardSocket(activeSocket, IOException("Binary RPC WebSocket rejected the request")) + return Attempt.FallbackToHttp + } + + val response = try { + withTimeoutOrNull(timeoutMs) { deferred.await() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return protocolFailure("Binary RPC connection failed after send: ${e.message}") + } finally { + pending.remove(requestId) + } + if (response == null) { + discardSocket( + activeSocket, + IOException("Binary RPC timed out after ${timeoutMs}ms"), + ) + return protocolFailure("Binary RPC timed out after ${timeoutMs}ms") + } + response.failure?.let { failure -> + return Attempt.Failure( + OnDeviceRpcProtoCodec.run { + failure.toModel(method = null, url = webSocketUrl) + }, + ) + } + return Attempt.Success(response) + } + + private suspend fun ensureConnected(): WebSocket? { + socket?.let { return it } + if (unsupported || closed) return null + + val waiter = synchronized(connectionLock) { + socket?.let { return@synchronized CompletableDeferred().also { d -> d.complete(it) } } + connecting?.let { return@synchronized it } + CompletableDeferred().also { deferred -> + connecting = deferred + connectingSocket = client.newWebSocket(Request.Builder().url(webSocketUrl).build(), listener) + } + } + val connected = try { + withTimeoutOrNull(CONNECT_TIMEOUT_MS) { waiter.await() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (connected == null) { + synchronized(connectionLock) { + if (connecting === waiter) { + connecting = null + connectingSocket?.cancel() + connectingSocket = null + } + } + } + return connected + } + + private val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + val waiter = synchronized(connectionLock) { + if (connectingSocket !== webSocket || closed) { + null + } else { + socket = webSocket + connectingSocket = null + connecting.also { connecting = null } + } + } + if (waiter == null) { + webSocket.close(1000, "superseded connection") + } else { + waiter.complete(webSocket) + } + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + val response = try { + OnDeviceRpcProtoCodec.decodeResponse(bytes.toByteArray()) + } catch (e: Exception) { + failPending(IOException("Invalid protobuf response: ${e.message}", e)) + webSocket.cancel() + return + } + pending.remove(response.request_id)?.complete(response) + ?: Console.log("[OnDeviceRpcWebSocket] response for unknown id ${response.request_id}") + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (clearSocket(webSocket)) { + failPending(IOException("Binary RPC WebSocket closed ($code): $reason")) + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (response != null) { + // A real HTTP response to the upgrade means this runner predates the WebSocket route. + unsupported = true + } + val wasActive = clearSocket(webSocket) + val waiter = synchronized(connectionLock) { + if (connectingSocket === webSocket) { + connectingSocket = null + connecting.also { connecting = null } + } else { + null + } + } + waiter?.completeExceptionally(t) + if (wasActive) failPending(t) + } + } + + private fun failPending(cause: Throwable) { + val snapshot = pending.entries.toList() + snapshot.forEach { (id, deferred) -> + if (pending.remove(id, deferred)) deferred.completeExceptionally(cause) + } + } + + private fun clearSocket(expected: WebSocket): Boolean { + return synchronized(connectionLock) { + if (socket === expected) { + socket = null + true + } else { + false + } + } + } + + private fun discardSocket(expected: WebSocket, cause: Throwable) { + if (clearSocket(expected)) { + expected.cancel() + failPending(cause) + } + } + + private fun protocolFailure(message: String): Attempt.Failure = + Attempt.Failure( + RpcResult.Failure( + errorType = RpcResult.ErrorType.NETWORK_ERROR, + message = message, + url = webSocketUrl, + ), + ) + + override fun close() { + closed = true + socket?.close(1000, "client closed") + socket = null + connectingSocket?.cancel() + connectingSocket = null + failPending(IOException("Binary RPC client closed")) + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } + + private companion object { + const val WEBSOCKET_PATH = "/rpc-ws" + const val CONNECT_TIMEOUT_MS = 2_000L + + fun defaultClient(): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + } +} diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSet.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSet.kt index 2d0a08f8d..162960a02 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSet.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSet.kt @@ -20,7 +20,9 @@ import xyz.block.trailblaze.mcp.TrailblazeMcpSessionContext import xyz.block.trailblaze.toolcalls.toKoogToolDescriptor import xyz.block.trailblaze.toolcalls.TrailblazeKoogTool.Companion.toTrailblazeToolDescriptor import xyz.block.trailblaze.yaml.createTrailblazeYaml -import xyz.block.trailblaze.yaml.models.TrailblazeYamlBuilder +import xyz.block.trailblaze.yaml.unified.UnifiedTrail +import xyz.block.trailblaze.yaml.unified.UnifiedTrailConfig +import xyz.block.trailblaze.yaml.unified.UnifiedTrailStep /** * Minimal MCP tool for device connection. @@ -147,6 +149,8 @@ class DeviceManagerToolSet( headless: Boolean? = null, @LLMDescription("For CREATE_WEB action: Playwright `devices` preset name (e.g. 'iPhone 14', 'Pixel 7', 'iPad Pro 11') OR raw 'x' viewport like '375x812'. Sets the slot's viewport / emulation profile. Pass null to clear.") viewport: String? = null, + @LLMDescription("For INFO action: inspect only this MCP session's device binding. Internal CLI reuse probe; default false preserves the current process-wide device view.") + sessionOnly: Boolean = false, ): String { return when (action) { DeviceAction.LIST -> { @@ -183,7 +187,13 @@ class DeviceManagerToolSet( } DeviceAction.INFO -> { - val currentDeviceId = mcpBridge.getCurrentlySelectedDeviceId() + // Ordinary INFO retains its process-wide behavior for existing lifecycle commands. The + // reusable CLI session probe opts into the session-only view so another MCP session's + // selected device cannot be mistaken for this session's intact association. + val currentDeviceId = when { + sessionOnly && sessionContext != null -> sessionContext.associatedDeviceId + else -> mcpBridge.getCurrentlySelectedDeviceId() + } ?: return "Error: No device connected. Use device(action=LIST) to see available devices, then connect with ANDROID, IOS, WEB, or CONNECT." // Driver status (non-null means the driver is installing, initializing, or failed @@ -637,14 +647,13 @@ class DeviceManagerToolSet( "or drive the device directly with step(tools=[...]) / the primitive device tools." } - val yaml = createTrailblazeYaml().encodeToString( - TrailblazeYamlBuilder() - .apply { - steps.forEach { promptLine -> - this.prompt(promptLine) - } - } - .build() + // Emit the unified format (a `trail:` of `step:` entries), not the legacy v1 list shape, so the + // dispatched YAML decodes without the v1 parser. + val yaml = createTrailblazeYaml().encodeUnifiedTrailToString( + UnifiedTrail( + config = UnifiedTrailConfig(), + trail = steps.map { promptLine -> UnifiedTrailStep(step = promptLine) }, + ), ) val sessionId = mcpBridge.runYaml( diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/HttpRequestUtils.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/HttpRequestUtils.kt index d323fd3b6..cb4fffd63 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/HttpRequestUtils.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/HttpRequestUtils.kt @@ -9,7 +9,6 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType -import xyz.block.trailblaze.util.Console class HttpRequestUtils( private val baseUrl: String, @@ -23,10 +22,6 @@ class HttpRequestUtils( } val responseBody = response.bodyAsText() - Console.log("Response Body: $responseBody") - Console.log("Response Code: ${response.status.value}") - Console.log("Response Message: ${response.status.description}") - if (response.status.value !in 200..299) { throw HttpRpcException("HTTP ${response.status.value}: ${response.status.description}", responseBody) } @@ -58,10 +53,6 @@ class HttpRequestUtils( } val responseBody = response.bodyAsText() - Console.log("Response Body: $responseBody") - Console.log("Response Code: ${response.status.value}") - Console.log("Response Message: ${response.status.description}") - if (response.status.value !in 200..299) { throw HttpRpcException("HTTP ${response.status.value}: ${response.status.description}", responseBody) } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/RpcScreenStateAdapter.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/RpcScreenStateAdapter.kt index 105e9e7ca..5d980dd09 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/RpcScreenStateAdapter.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/RpcScreenStateAdapter.kt @@ -46,11 +46,13 @@ class RpcScreenStateAdapter( } private val _screenshotBytes: ByteArray? by lazy { - response.screenshotBase64?.decodeBase64Bytes() + response.screenshotBytes ?: response.screenshotBase64?.decodeBase64Bytes() } private val _annotatedScreenshotBytes: ByteArray? by lazy { - response.annotatedScreenshotBase64?.decodeBase64Bytes() ?: _screenshotBytes + response.annotatedScreenshotBytes + ?: response.annotatedScreenshotBase64?.decodeBase64Bytes() + ?: _screenshotBytes } override val screenshotBytes: ByteArray? diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/AgentLogEndpointTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/AgentLogEndpointTest.kt index 2f1b87122..8e9cbfda5 100644 --- a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/AgentLogEndpointTest.kt +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/AgentLogEndpointTest.kt @@ -364,7 +364,44 @@ class AgentLogEndpointTest { Console.log("Invalid JSON response status: ${response.status}") Console.log("Invalid JSON response body: ${response.bodyAsText()}") - assertEquals(HttpStatusCode.InternalServerError, response.status) + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `server keeps serving after a truncated log body`() = testApplication { + // Regression pin: a truncated /agentlog upload (device connection severed mid-body, e.g. the + // daemon shut down while a run was in flight) must be that one request's failure — 400, not an + // unhandled exception — and the very next well-formed log must still be accepted. + val logsRepo = createTestLogsRepo() + application { + logsServerKtorEndpoints(logsRepo) + } + + val validLog = TrailblazeLog.MaestroCommandLog( + maestroCommandJsonObj = JsonObject(mapOf("command" to JsonPrimitive("tap"))), + traceId = TraceId.generate(TraceOrigin.MAESTRO), + successful = true, + trailblazeToolResult = TrailblazeToolResult.Success(), + session = xyz.block.trailblaze.logs.model.SessionId("test-session"), + timestamp = Clock.System.now(), + durationMs = 300L, + ) + val validJson = TrailblazeJsonInstance.encodeToString(TrailblazeLog.serializer(), validLog) + val truncatedJson = validJson.substring(0, validJson.length / 2) + + val truncatedResponse = client.post("/agentlog") { + contentType(ContentType.Application.Json) + setBody(truncatedJson) + } + assertEquals(HttpStatusCode.BadRequest, truncatedResponse.status) + assertTrue(truncatedResponse.bodyAsText().contains("Failed to decode log event")) + + val followUpResponse = client.post("/agentlog") { + contentType(ContentType.Application.Json) + setBody(validJson) + } + assertEquals(HttpStatusCode.OK, followUpResponse.status) + assertTrue(followUpResponse.bodyAsText().contains("Log received and saved")) } @Test @@ -403,7 +440,7 @@ class AgentLogEndpointTest { Console.log("No type discriminator response body: ${response.bodyAsText()}") // This should fail with the serialization error we found - assertEquals(HttpStatusCode.InternalServerError, response.status) + assertEquals(HttpStatusCode.BadRequest, response.status) assertTrue(response.bodyAsText().contains("Class discriminator was missing")) } } diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/CliRunManagerTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/CliRunManagerTest.kt new file mode 100644 index 000000000..412d87045 --- /dev/null +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/CliRunManagerTest.kt @@ -0,0 +1,79 @@ +package xyz.block.trailblaze.logs.server.endpoints + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins [CliRunManager.activeRunCount], the "is this daemon busy?" signal exposed on + * `/cli/status` (as `activeRuns`) and logged by the shutdown endpoint. External tooling — + * the dev launcher's stale-JAR restart, the CLI's version-mismatch restart — uses it to + * avoid stopping a daemon mid-run, so it must count exactly the runs that a shutdown + * would abandon: pending and running, never completed/failed/cancelled ones. + */ +class CliRunManagerTest { + + private val releaseRun = CompletableDeferred() + private val manager = CliRunManager( + onRunRequest = { _, _ -> + releaseRun.await() + CliRunResponse(success = true) + }, + ) + + @AfterTest + fun tearDown() { + releaseRun.complete(Unit) + manager.close() + } + + @Test + fun `in-flight run is counted until it completes`() { + assertEquals(0, manager.activeRunCount()) + + val runId = manager.submitRun(CliRunRequest(trailFilePath = "test.trail.yaml")) + awaitUntil { manager.getStatus(runId)?.state == RunState.RUNNING } + assertEquals(1, manager.activeRunCount()) + + releaseRun.complete(Unit) + awaitUntil { manager.getStatus(runId)?.state == RunState.COMPLETED } + assertEquals(0, manager.activeRunCount()) + } + + @Test + fun `active run summaries name the trail so operators can see who is using the daemon`() { + assertEquals(emptyList(), manager.activeRunSummaries()) + + val runId = manager.submitRun(CliRunRequest(trailFilePath = "trails/checkout/smoke.trail.yaml")) + awaitUntil { manager.getStatus(runId)?.state == RunState.RUNNING } + val summaries = manager.activeRunSummaries() + assertEquals(1, summaries.size) + assertTrue(summaries.single().contains("trails/checkout/smoke.trail.yaml")) + + releaseRun.complete(Unit) + awaitUntil { manager.getStatus(runId)?.state == RunState.COMPLETED } + assertEquals(emptyList(), manager.activeRunSummaries()) + } + + @Test + fun `cancelled run stops counting as active`() { + val runId = manager.submitRun(CliRunRequest(trailFilePath = "test.trail.yaml")) + awaitUntil { manager.getStatus(runId)?.state == RunState.RUNNING } + assertEquals(1, manager.activeRunCount()) + + manager.cancelRun(runId) + awaitUntil { manager.getStatus(runId)?.state == RunState.CANCELLED } + assertEquals(0, manager.activeRunCount()) + } + + private fun awaitUntil(timeoutMs: Long = 5_000, condition: () -> Boolean) = runBlocking { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition()) { + check(System.currentTimeMillis() < deadline) { "condition not met within ${timeoutMs}ms" } + kotlinx.coroutines.delay(10) + } + } +} diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/HomeEndpointTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/HomeEndpointTest.kt index bd51aafc8..138e0f2df 100644 --- a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/HomeEndpointTest.kt +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/HomeEndpointTest.kt @@ -20,6 +20,7 @@ import xyz.block.trailblaze.logs.server.ServerEndpoints.logsServerKtorEndpoints import xyz.block.trailblaze.report.utils.LogsRepo import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class HomeEndpointTest { @@ -53,6 +54,32 @@ class HomeEndpointTest { assertTrue("/report\"" in body || "href=\"/report\"" in body, "expected unfiltered /report fallback link") } + @Test + fun `home page makes Trail Runner prominent while preserving daemon utilities`() = testApplication { + val logsRepo = createTestLogsRepo() + application { logsServerKtorEndpoints(logsRepo, trailRunnerPath = "/trailrunner/") } + + val body = client.get("/").bodyAsText() + + assertTrue("href=\"/trailrunner/\"" in body, "expected a direct Trail Runner link") + assertTrue("Open Trail Runner" in body, "expected Trail Runner to be the primary action") + assertTrue("href=\"/report\"" in body, "expected the all-session report link") + assertTrue("href=\"/devices\"" in body, "expected the devices link") + assertTrue("href=\"/ping\"" in body, "expected the health-check link") + } + + @Test + fun `home page only links Trail Runner when its route is registered`() = testApplication { + val logsRepo = createTestLogsRepo() + application { logsServerKtorEndpoints(logsRepo) } + + val body = client.get("/").bodyAsText() + + assertFalse("href=\"/trailrunner/\"" in body, "must not link an unavailable Trail Runner route") + assertTrue("href=\"/report\"" in body, "expected the report to remain the primary destination") + assertFalse("fonts.googleapis.com" in body, "daemon home must not depend on an external font request") + } + @Test fun `home page renders a live storyboard link for every session and no dead label`() = testApplication { val logsRepo = createTestLogsRepo() diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpointTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpointTest.kt new file mode 100644 index 000000000..6666018eb --- /dev/null +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/logs/server/endpoints/LogWebSocketEndpointTest.kt @@ -0,0 +1,122 @@ +package xyz.block.trailblaze.logs.server.endpoints + +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.server.testing.testApplication +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.datetime.Clock +import kotlinx.coroutines.runBlocking +import xyz.block.trailblaze.api.DriverNodeDetail +import xyz.block.trailblaze.api.TrailblazeNode +import xyz.block.trailblaze.api.ViewHierarchyTreeNode +import xyz.block.trailblaze.logs.client.TrailblazeLog +import xyz.block.trailblaze.logs.client.TrailblazeLogServerClient +import xyz.block.trailblaze.logs.model.SessionId +import xyz.block.trailblaze.logs.server.ServerEndpoints.logsServerKtorEndpoints +import xyz.block.trailblaze.logs.server.ServerEndpoints.logsServerKtorEndpointsWithWireTransport +import xyz.block.trailblaze.report.utils.LogsRepo +import xyz.block.trailblaze.transport.AndroidWireTransportMode + +class LogWebSocketEndpointTest { + @Test + fun `one protobuf socket persists logs screenshots and traces`() = testApplication { + val logsDir = File.createTempFile("protobuf-logs", "").apply { + delete() + mkdirs() + } + val logsRepo = LogsRepo(logsDir, watchFileSystem = false) + val connectionCount = AtomicInteger() + LogWebSocketEndpoint.setServerConnectionListener { connectionCount.incrementAndGet() } + application { logsServerKtorEndpoints(logsRepo) } + val websocketHttpClient = createClient { install(WebSockets) } + val uploadClient = TrailblazeLogServerClient( + httpClient = websocketHttpClient, + baseUrl = "http://localhost", + useBinaryTransport = true, + ) + val session = SessionId("binary-session") + val log = TrailblazeLog.TrailblazeSnapshotLog( + displayName = "home", + screenshotFile = "home.png", + viewHierarchy = ViewHierarchyTreeNode(text = "Home"), + trailblazeNodeTree = TrailblazeNode( + nodeId = 1, + driverDetail = DriverNodeDetail.AndroidAccessibility(text = "Home"), + ), + deviceWidth = 1080, + deviceHeight = 1920, + session = session, + timestamp = Clock.System.now(), + ) + val screenshot = byteArrayOf(1, 2, 3, 4) + + assertTrue(runBlocking { uploadClient.sendAgentLog(log) }) + assertTrue(runBlocking { uploadClient.sendScreenshot("home.png", session, screenshot) }) + assertTrue(runBlocking { uploadClient.sendTrace(session, "{\"trace\":true}") }) + + assertEquals(log, logsRepo.getLogsForSession(session).single()) + assertContentEquals(screenshot, logsRepo.getSessionDir(session).resolve("home.png").readBytes()) + assertEquals("{\"trace\":true}", logsRepo.getSessionDir(session).resolve("trace.json").readText()) + assertEquals(1, connectionCount.get()) + LogWebSocketEndpoint.setServerConnectionListener {} + uploadClient.close() + } + + @Test + fun `json rollback mode falls back before sending a protobuf log`() = testApplication { + val logsDir = + File.createTempFile("json-rollback-logs", "").apply { + delete() + mkdirs() + } + val logsRepo = LogsRepo(logsDir, watchFileSystem = false) + val connectionCount = AtomicInteger() + LogWebSocketEndpoint.setServerConnectionListener { connectionCount.incrementAndGet() } + application { + logsServerKtorEndpointsWithWireTransport( + logsRepo = logsRepo, + homeCallbackHandler = null, + trailRunnerPath = null, + installContentNegotiation = true, + cliCallbacks = null, + resolvedAuths = null, + androidWireTransportMode = AndroidWireTransportMode.JSON, + additionalRouteRegistration = null, + ) + } + val websocketHttpClient = createClient { install(WebSockets) } + val uploadClient = + TrailblazeLogServerClient( + httpClient = websocketHttpClient, + baseUrl = "http://localhost", + useBinaryTransport = true, + ) + val session = SessionId("json-rollback-session") + val log = + TrailblazeLog.TrailblazeSnapshotLog( + displayName = "home", + screenshotFile = "home.png", + viewHierarchy = ViewHierarchyTreeNode(text = "Home"), + trailblazeNodeTree = + TrailblazeNode( + nodeId = 1, + driverDetail = DriverNodeDetail.AndroidAccessibility(text = "Home"), + ), + deviceWidth = 1080, + deviceHeight = 1920, + session = session, + timestamp = Clock.System.now(), + ) + + assertTrue(runBlocking { uploadClient.sendAgentLog(log) }) + + assertEquals(log, logsRepo.getLogsForSession(session).single()) + assertEquals(0, connectionCount.get()) + LogWebSocketEndpoint.setServerConnectionListener {} + uploadClient.close() + } +} diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClientTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClientTest.kt new file mode 100644 index 000000000..270adc280 --- /dev/null +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/android/ondevice/rpc/OnDeviceRpcWebSocketClientTest.kt @@ -0,0 +1,164 @@ +package xyz.block.trailblaze.mcp.android.ondevice.rpc + +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readBytes +import io.ktor.websocket.send +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.runBlocking +import org.junit.Test +import xyz.block.trailblaze.api.DriverNodeDetail +import xyz.block.trailblaze.api.TrailblazeNode +import xyz.block.trailblaze.api.ViewHierarchyTreeNode +import xyz.block.trailblaze.ondevice.rpc.proto.OnDeviceRpcProtoCodec +import xyz.block.trailblaze.ondevice.rpc.proto.RpcResponseEnvelope +import xyz.block.trailblaze.mcp.android.ondevice.rpc.models.SelectToolSet +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class OnDeviceRpcWebSocketClientTest { + + @Test + fun `unmapped legacy requests fall back before connecting`() { + val client = OnDeviceRpcWebSocketClient("http://localhost:1") + try { + val result = runBlocking { + client.call(SelectToolSet(toolSetNames = listOf("legacy")), timeoutMs = 5_000) + } + + assertIs(result) + } finally { + client.close() + } + } + + @Test + fun `typed RPC calls share one binary socket`() { + val port = ServerSocket(0).use { it.localPort } + val connectionCount = AtomicInteger() + val screenResponse = GetScreenStateResponse( + viewHierarchy = ViewHierarchyTreeNode(text = "Home"), + screenshotBase64 = null, + deviceWidth = 1080, + deviceHeight = 1920, + trailblazeNodeTree = TrailblazeNode( + nodeId = 1, + driverDetail = DriverNodeDetail.AndroidAccessibility(text = "Home"), + ), + ).apply { screenshotBytes = byteArrayOf(1, 2, 3) } + val server = embeddedServer(CIO, port = port) { + install(WebSockets) + routing { + webSocket("/rpc-ws") { + connectionCount.incrementAndGet() + for (frame in incoming) { + if (frame !is Frame.Binary) continue + val request = OnDeviceRpcProtoCodec.decodeRequest(frame.readBytes()) + val response = when { + request.get_screen_state != null -> RpcResponseEnvelope( + request_id = request.request_id, + get_screen_state = OnDeviceRpcProtoCodec.run { screenResponse.toProto() }, + ) + request.drain_session != null -> RpcResponseEnvelope( + request_id = request.request_id, + drain_session = OnDeviceRpcProtoCodec.run { + DrainSessionResponse(uiAutomationCleared = true).toProto() + }, + ) + else -> error("request omitted payload") + } + send(Frame.Binary(true, OnDeviceRpcProtoCodec.encode(response))) + } + } + } + }.start(wait = false) + + try { + val client = OnDeviceRpcWebSocketClient("http://localhost:$port") + try { + val first = runBlocking { + client.call(GetScreenStateRequest(includeScreenshot = true), timeoutMs = 5_000) + } + val second = runBlocking { + client.call(DrainSessionRequest(reason = "test"), timeoutMs = 5_000) + } + + val decodedScreen = assertIs>(first).value + assertContentEquals(byteArrayOf(1, 2, 3), decodedScreen.screenshotBytes) + assertEquals("Home", decodedScreen.viewHierarchy.text) + assertEquals( + true, + assertIs>(second) + .value.uiAutomationCleared, + ) + assertEquals(1, connectionCount.get()) + } finally { + client.close() + } + } finally { + server.stop(gracePeriodMillis = 0, timeoutMillis = 500) + } + } + + @Test + fun `timed out socket is replaced before the next call`() { + val port = ServerSocket(0).use { it.localPort } + val connectionCount = AtomicInteger() + val server = embeddedServer(CIO, port = port) { + install(WebSockets) + routing { + webSocket("/rpc-ws") { + val connection = connectionCount.incrementAndGet() + for (frame in incoming) { + if (frame !is Frame.Binary) continue + if (connection == 1) continue + val request = OnDeviceRpcProtoCodec.decodeRequest(frame.readBytes()) + send( + Frame.Binary( + true, + OnDeviceRpcProtoCodec.encode( + RpcResponseEnvelope( + request_id = request.request_id, + drain_session = OnDeviceRpcProtoCodec.run { + DrainSessionResponse(uiAutomationCleared = true).toProto() + }, + ), + ), + ), + ) + } + } + } + }.start(wait = false) + + try { + val client = OnDeviceRpcWebSocketClient("http://localhost:$port") + try { + val timedOut = runBlocking { + client.call(DrainSessionRequest(reason = "timeout"), timeoutMs = 500) + } + assertIs(timedOut) + + val recovered = runBlocking { + client.call(DrainSessionRequest(reason = "retry"), timeoutMs = 5_000) + } + assertEquals( + true, + assertIs>(recovered) + .value.uiAutomationCleared, + ) + } finally { + client.close() + } + } finally { + server.stop(gracePeriodMillis = 0, timeoutMillis = 500) + } + } +} diff --git a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSetTest.kt b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSetTest.kt index 476df2476..9b8179b39 100644 --- a/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSetTest.kt +++ b/trailblaze-server/src/test/kotlin/xyz/block/trailblaze/mcp/newtools/DeviceManagerToolSetTest.kt @@ -427,6 +427,61 @@ class DeviceManagerToolSetTest { assertContains(result, "No device connected") } + @Test + fun `session-only device INFO does not leak another session's globally selected device`() = runTest { + val bridge = DeviceTestBridge(devices = setOf(androidDevice, iosDevice)) + val sessionContext = createSessionContext() + val toolSet = DeviceManagerToolSet( + sessionContext = sessionContext, + mcpBridge = bridge, + ) + + // Simulate another MCP session selecting Android on the shared bridge while this session + // remains unbound. + bridge.selectDevice( + TrailblazeDeviceId( + instanceId = androidDevice.instanceId, + trailblazeDevicePlatform = androidDevice.platform, + ), + ) + + val processWideResult = toolSet.device(action = DeviceManagerToolSet.DeviceAction.INFO) + val result = toolSet.device( + action = DeviceManagerToolSet.DeviceAction.INFO, + sessionOnly = true, + ) + + assertContains(processWideResult, androidDevice.instanceId) + assertContains(result, "No device connected") + } + + @Test + fun `session-only device INFO keeps this session's device when global selection changes`() = runTest { + val bridge = DeviceTestBridge(devices = setOf(androidDevice, iosDevice)) + val sessionContext = createSessionContext() + val toolSet = DeviceManagerToolSet( + sessionContext = sessionContext, + mcpBridge = bridge, + ) + toolSet.device(action = DeviceManagerToolSet.DeviceAction.ANDROID) + + // Another session moves the process-wide bridge selection to iOS. + bridge.selectDevice( + TrailblazeDeviceId( + instanceId = iosDevice.instanceId, + trailblazeDevicePlatform = iosDevice.platform, + ), + ) + + val result = toolSet.device( + action = DeviceManagerToolSet.DeviceAction.INFO, + sessionOnly = true, + ) + + assertContains(result, androidDevice.instanceId) + assertTrue(iosDevice.instanceId !in result) + } + @Test fun `device INFO APPS returns installed apps`() = runTest { val bridge = DeviceTestBridge( @@ -680,12 +735,14 @@ class DeviceManagerToolSetTest { driverConnectionStatus = "Playwright browser installing (12s elapsed, timeout in 888s): [42%] Downloading Chromium", ) - bridge.lastSelectedDeviceId = TrailblazeDeviceId( + val selectedDeviceId = TrailblazeDeviceId( instanceId = "playwright-chromium", trailblazeDevicePlatform = TrailblazeDevicePlatform.WEB, ) + bridge.lastSelectedDeviceId = selectedDeviceId + val sessionContext = createSessionContext().apply { setAssociatedDevice(selectedDeviceId) } val toolSet = DeviceManagerToolSet( - sessionContext = createSessionContext(), + sessionContext = sessionContext, mcpBridge = bridge, ) @@ -708,12 +765,14 @@ class DeviceManagerToolSetTest { driverConnectionStatus = "Device driver failed to create: adb connection refused", ) - bridge.lastSelectedDeviceId = TrailblazeDeviceId( + val selectedDeviceId = TrailblazeDeviceId( instanceId = "emulator-5554", trailblazeDevicePlatform = TrailblazeDevicePlatform.ANDROID, ) + bridge.lastSelectedDeviceId = selectedDeviceId + val sessionContext = createSessionContext().apply { setAssociatedDevice(selectedDeviceId) } val toolSet = DeviceManagerToolSet( - sessionContext = createSessionContext(), + sessionContext = sessionContext, mcpBridge = bridge, ) @@ -731,12 +790,14 @@ class DeviceManagerToolSetTest { driverType = TrailblazeDriverType.ANDROID_ONDEVICE_INSTRUMENTATION, driverConnectionStatus = null, ) - bridge.lastSelectedDeviceId = TrailblazeDeviceId( + val selectedDeviceId = TrailblazeDeviceId( instanceId = "emulator-5554", trailblazeDevicePlatform = TrailblazeDevicePlatform.ANDROID, ) + bridge.lastSelectedDeviceId = selectedDeviceId + val sessionContext = createSessionContext().apply { setAssociatedDevice(selectedDeviceId) } val toolSet = DeviceManagerToolSet( - sessionContext = createSessionContext(), + sessionContext = sessionContext, mcpBridge = bridge, ) diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/models/TrailblazeServerState.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/models/TrailblazeServerState.kt index 37251bd2b..609169190 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/models/TrailblazeServerState.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/models/TrailblazeServerState.kt @@ -50,6 +50,17 @@ data class TrailblazeServerState( * save-back is restored. */ val unifiedRecordingsEnabled: Boolean? = null, + /** + * Experimental: serve Android host-driven agent-loop screenshots from the device's live + * screenrecord stream instead of a per-capture on-device screenshot. Tri-state like + * [unifiedRecordingsEnabled]: `null` (default) means off; an explicit `true`/`false` from + * `trailblaze config android-stream-screenshots ` is a non-default value, so it + * survives serialization (`encodeDefaults = false` omits only `null`) and keeps meaning what + * the user said even if the framework default ever changes. The + * `TRAILBLAZE_ANDROID_STREAM_SCREENSHOT` / `_AB` env vars still take precedence (env is the + * one-off / CI / A/B-validation override; this is the discoverable persistent toggle). + */ + val androidStreamScreenshotsEnabled: Boolean? = null, /** Agent implementation to use. Defaults to [AgentImplementation.DEFAULT]. */ val agentImplementation: AgentImplementation = AgentImplementation.DEFAULT, val yamlContent: String = """ diff --git a/trails/benchmarks/llm-model/counter-arithmetic/blaze.yaml b/trails/benchmarks/llm-model/counter-arithmetic/blaze.yaml deleted file mode 100644 index e4fbe3efc..000000000 --- a/trails/benchmarks/llm-model/counter-arithmetic/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- config: - title: "LLM Benchmark: Counter arithmetic with increment and decrement" - driver: PLAYWRIGHT_NATIVE - description: |- - Exercises both the + (Increment) and - (Decrement) buttons in - sequence so the running total has to land on the right value - after a mix of additions and subtractions. The existing counter - trail only uses + and Reset; this one adds the - button and - stresses arithmetic state tracking across opposing actions. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Counter" navigation link - - step: Verify the counter value shows "0" - - step: Click the Increment button (the + button) five times - - step: Verify the counter value shows "5" - - step: Click the Decrement button (the - button) two times - - step: Verify the counter value shows "3" diff --git a/trails/benchmarks/llm-model/counter-arithmetic/trail.yaml b/trails/benchmarks/llm-model/counter-arithmetic/trail.yaml new file mode 100644 index 000000000..b745897e1 --- /dev/null +++ b/trails/benchmarks/llm-model/counter-arithmetic/trail.yaml @@ -0,0 +1,25 @@ +config: + description: |- + Exercises both the + (Increment) and - (Decrement) buttons in + sequence so the running total has to land on the right value + after a mix of additions and subtractions. The existing counter + trail only uses + and Reset; this one adds the - button and + stresses arithmetic state tracking across opposing actions. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Counter arithmetic with increment and decrement' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Counter\" navigation link" + + - step: "Verify the counter value shows \"0\"" + + - step: "Click the Increment button (the + button) five times" + + - step: "Verify the counter value shows \"5\"" + + - step: "Click the Decrement button (the - button) two times" + + - step: "Verify the counter value shows \"3\"" diff --git a/trails/benchmarks/llm-model/counter/blaze.yaml b/trails/benchmarks/llm-model/counter/blaze.yaml deleted file mode 100644 index 55af2f893..000000000 --- a/trails/benchmarks/llm-model/counter/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -- config: - title: "LLM Benchmark: Counter interactions" - driver: PLAYWRIGHT_NATIVE - description: |- - Click the Counter nav link on the local sample-app index page, then - exercise the increment / reset controls. Deterministic — no external - network, no relative file paths. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Counter" navigation link - - step: Verify the counter value shows "0" - - step: Click the Increment button (the + button) three times - - step: Verify the counter value shows "3" - - step: Click the Reset button - - step: Verify the counter value shows "0" diff --git a/trails/benchmarks/llm-model/counter/trail.yaml b/trails/benchmarks/llm-model/counter/trail.yaml new file mode 100644 index 000000000..9a3d30a68 --- /dev/null +++ b/trails/benchmarks/llm-model/counter/trail.yaml @@ -0,0 +1,23 @@ +config: + description: |- + Click the Counter nav link on the local sample-app index page, then + exercise the increment / reset controls. Deterministic — no external + network, no relative file paths. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Counter interactions' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Counter\" navigation link" + + - step: "Verify the counter value shows \"0\"" + + - step: "Click the Increment button (the + button) three times" + + - step: "Verify the counter value shows \"3\"" + + - step: "Click the Reset button" + + - step: "Verify the counter value shows \"0\"" diff --git a/trails/benchmarks/llm-model/duplicate-list/blaze.yaml b/trails/benchmarks/llm-model/duplicate-list/blaze.yaml deleted file mode 100644 index 39fa2de4a..000000000 --- a/trails/benchmarks/llm-model/duplicate-list/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- config: - title: "LLM Benchmark: Disambiguate duplicate items in a list" - driver: PLAYWRIGHT_NATIVE - description: |- - Navigate to the local duplicate-list fixture and click specific items - among multiple rows with the same visible text. Exercises the model's - ability to use positional/contextual cues. -- prompts: - - step: Navigate to http://localhost:8765/duplicate-list.html - - step: Verify the heading "Product List" is visible - - step: Click the "View" button on the first "Premium Cable" item in the Electronics section - - step: Verify the text "elec-1" is visible in the detail panel - - step: Click the "View" button on the second "Premium Cable" item in the Electronics section - - step: Verify the text "elec-2" is visible in the detail panel diff --git a/trails/benchmarks/llm-model/duplicate-list/trail.yaml b/trails/benchmarks/llm-model/duplicate-list/trail.yaml new file mode 100644 index 000000000..3ee4085ce --- /dev/null +++ b/trails/benchmarks/llm-model/duplicate-list/trail.yaml @@ -0,0 +1,21 @@ +config: + description: |- + Navigate to the local duplicate-list fixture and click specific items + among multiple rows with the same visible text. Exercises the model's + ability to use positional/contextual cues. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Disambiguate duplicate items in a list' + +trail: + - step: "Navigate to http://localhost:8765/duplicate-list.html" + + - step: "Verify the heading \"Product List\" is visible" + + - step: "Click the \"View\" button on the first \"Premium Cable\" item in the Electronics section" + + - step: "Verify the text \"elec-1\" is visible in the detail panel" + + - step: "Click the \"View\" button on the second \"Premium Cable\" item in the Electronics section" + + - step: "Verify the text \"elec-2\" is visible in the detail panel" diff --git a/trails/benchmarks/llm-model/form/blaze.yaml b/trails/benchmarks/llm-model/form/blaze.yaml deleted file mode 100644 index 131c872ec..000000000 --- a/trails/benchmarks/llm-model/form/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- config: - title: "LLM Benchmark: Contact form submission" - driver: PLAYWRIGHT_NATIVE - description: |- - Navigate to the Form section via the nav link, fill out every field, - submit, and verify the success message. Exercises typed input, select - dropdowns, and submission feedback. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Form" navigation link - - step: Type "Trail Blaze" into the Name input field - - step: Type "trail@example.com" into the Email input field - - step: Select "Feedback" from the Category dropdown - - step: Type "Trailblaze LLM benchmark message" into the Message textarea - - step: Click the "Submit" button - - step: Verify the text "Form submitted!" is visible - - step: Verify the text "Trail Blaze" is visible in the form summary diff --git a/trails/benchmarks/llm-model/form/trail.yaml b/trails/benchmarks/llm-model/form/trail.yaml new file mode 100644 index 000000000..9c680e5fe --- /dev/null +++ b/trails/benchmarks/llm-model/form/trail.yaml @@ -0,0 +1,27 @@ +config: + description: |- + Navigate to the Form section via the nav link, fill out every field, + submit, and verify the success message. Exercises typed input, select + dropdowns, and submission feedback. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Contact form submission' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Form\" navigation link" + + - step: "Type \"Trail Blaze\" into the Name input field" + + - step: "Type \"trail@example.com\" into the Email input field" + + - step: "Select \"Feedback\" from the Category dropdown" + + - step: "Type \"Trailblaze LLM benchmark message\" into the Message textarea" + + - step: "Click the \"Submit\" button" + + - step: "Verify the text \"Form submitted!\" is visible" + + - step: "Verify the text \"Trail Blaze\" is visible in the form summary" diff --git a/trails/benchmarks/llm-model/multi-section/blaze.yaml b/trails/benchmarks/llm-model/multi-section/blaze.yaml deleted file mode 100644 index 0661af249..000000000 --- a/trails/benchmarks/llm-model/multi-section/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- config: - title: "LLM Benchmark: Multi-section navigation + state preservation" - driver: PLAYWRIGHT_NATIVE - description: |- - Drive the agent across multiple SPA sections (Counter → About → Counter) - and verify the counter value survives the navigation round-trip. Tests - that the model keeps track of accumulated state across nav clicks - instead of resetting its mental model on every section switch. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Counter" navigation link - - step: Click the Increment button (the + button) three times - - step: Verify the counter value shows "3" - - step: Click the "About" navigation link - - step: Verify the text "Trailblaze is an AI-powered UI testing framework" is visible - - step: Click the "Counter" navigation link - - step: Verify the counter value shows "3" diff --git a/trails/benchmarks/llm-model/multi-section/trail.yaml b/trails/benchmarks/llm-model/multi-section/trail.yaml new file mode 100644 index 000000000..6895b8202 --- /dev/null +++ b/trails/benchmarks/llm-model/multi-section/trail.yaml @@ -0,0 +1,26 @@ +config: + description: |- + Drive the agent across multiple SPA sections (Counter → About → Counter) + and verify the counter value survives the navigation round-trip. Tests + that the model keeps track of accumulated state across nav clicks + instead of resetting its mental model on every section switch. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Multi-section navigation + state preservation' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Counter\" navigation link" + + - step: "Click the Increment button (the + button) three times" + + - step: "Verify the counter value shows \"3\"" + + - step: "Click the \"About\" navigation link" + + - step: "Verify the text \"Trailblaze is an AI-powered UI testing framework\" is visible" + + - step: "Click the \"Counter\" navigation link" + + - step: "Verify the counter value shows \"3\"" diff --git a/trails/benchmarks/llm-model/scroll-find/blaze.yaml b/trails/benchmarks/llm-model/scroll-find/blaze.yaml deleted file mode 100644 index d953b9a76..000000000 --- a/trails/benchmarks/llm-model/scroll-find/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -- config: - title: "LLM Benchmark: Scroll-within-container to find an offscreen item" - driver: PLAYWRIGHT_NATIVE - description: |- - Navigate to the Scroll section, where the right-hand content panel - is independently scrollable and only items 1-N are visible without - scrolling. Exercises the model's ability to scroll inside a specific - container (not the page) until a target item comes into view. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Scroll" navigation link - - step: Verify the heading "Scroll Containers" is visible - - step: Verify the text "Item 1" is visible in the content panel - - step: Scroll the content panel down until the text "Item 18" is visible - - step: Verify the text "Item 18" is visible diff --git a/trails/benchmarks/llm-model/scroll-find/trail.yaml b/trails/benchmarks/llm-model/scroll-find/trail.yaml new file mode 100644 index 000000000..3e97b6989 --- /dev/null +++ b/trails/benchmarks/llm-model/scroll-find/trail.yaml @@ -0,0 +1,22 @@ +config: + description: |- + Navigate to the Scroll section, where the right-hand content panel + is independently scrollable and only items 1-N are visible without + scrolling. Exercises the model's ability to scroll inside a specific + container (not the page) until a target item comes into view. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Scroll-within-container to find an offscreen item' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Scroll\" navigation link" + + - step: "Verify the heading \"Scroll Containers\" is visible" + + - step: "Verify the text \"Item 1\" is visible in the content panel" + + - step: "Scroll the content panel down until the text \"Item 18\" is visible" + + - step: "Verify the text \"Item 18\" is visible" diff --git a/trails/benchmarks/llm-model/scroll-sidebar/blaze.yaml b/trails/benchmarks/llm-model/scroll-sidebar/blaze.yaml deleted file mode 100644 index 5b71a3f58..000000000 --- a/trails/benchmarks/llm-model/scroll-sidebar/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- config: - title: "LLM Benchmark: Scroll the correct container of two" - driver: PLAYWRIGHT_NATIVE - description: |- - The Scroll section has two independently-scrollable panels: a left - "Sidebar navigation" with 15 categories and a right "Content panel" - with 20 items. The earlier scroll-find trail exercises the right - panel; this one targets the left sidebar so the agent has to - disambiguate which container to scroll. Models that scroll the - wrong panel (or scroll the page) will fail the final verify. -- prompts: - - step: Navigate to http://localhost:8765/index.html - - step: Click the "Scroll" navigation link - - step: Verify the heading "Scroll Containers" is visible - - step: Verify the text "Category 1" is visible - - step: Scroll within the "Sidebar navigation" panel (the left panel containing the Category links) until "Category 14" is visible - - step: Verify the text "Category 14" is visible diff --git a/trails/benchmarks/llm-model/scroll-sidebar/trail.yaml b/trails/benchmarks/llm-model/scroll-sidebar/trail.yaml new file mode 100644 index 000000000..9bec25915 --- /dev/null +++ b/trails/benchmarks/llm-model/scroll-sidebar/trail.yaml @@ -0,0 +1,24 @@ +config: + description: |- + The Scroll section has two independently-scrollable panels: a left + "Sidebar navigation" with 15 categories and a right "Content panel" + with 20 items. The earlier scroll-find trail exercises the right + panel; this one targets the left sidebar so the agent has to + disambiguate which container to scroll. Models that scroll the + wrong panel (or scroll the page) will fail the final verify. + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Scroll the correct container of two' + +trail: + - step: "Navigate to http://localhost:8765/index.html" + + - step: "Click the \"Scroll\" navigation link" + + - step: "Verify the heading \"Scroll Containers\" is visible" + + - step: "Verify the text \"Category 1\" is visible" + + - step: "Scroll within the \"Sidebar navigation\" panel (the left panel containing the Category links) until \"Category 14\" is visible" + + - step: "Verify the text \"Category 14\" is visible" diff --git a/trails/benchmarks/llm-model/search-duplicates/blaze.yaml b/trails/benchmarks/llm-model/search-duplicates/blaze.yaml deleted file mode 100644 index 83e28c94d..000000000 --- a/trails/benchmarks/llm-model/search-duplicates/blaze.yaml +++ /dev/null @@ -1,16 +0,0 @@ -- config: - title: "LLM Benchmark: Search + disambiguate by attribute" - driver: PLAYWRIGHT_NATIVE - description: |- - Search for "Mouse" on the product list, then pick the specific - result whose subtitle says "USB-C, Gray" out of four "Wireless Mouse" - entries that differ only by subtitle and price. Exercises filtering - then attribute-based disambiguation (the prior duplicate-list trail - tests positional disambiguation; this one tests semantic). -- prompts: - - step: 'Navigate to http://localhost:8765/search-duplicates.html' - - step: 'Type "Mouse" into the "Search products..." input field' - - step: 'Click the "Search" button' - - step: 'Verify that "4 results for ''Mouse''" is visible' - - step: 'Click the "Wireless Mouse" result whose subtitle is "USB-C, Gray"' - - step: 'Verify that the text "ID: 4" is visible in the detail panel' diff --git a/trails/benchmarks/llm-model/search-duplicates/trail.yaml b/trails/benchmarks/llm-model/search-duplicates/trail.yaml new file mode 100644 index 000000000..5ab321aef --- /dev/null +++ b/trails/benchmarks/llm-model/search-duplicates/trail.yaml @@ -0,0 +1,23 @@ +config: + description: |- + Search for "Mouse" on the product list, then pick the specific + result whose subtitle says "USB-C, Gray" out of four "Wireless Mouse" + entries that differ only by subtitle and price. Exercises filtering + then attribute-based disambiguation (the prior duplicate-list trail + tests positional disambiguation; this one tests semantic). + devices: + web: PLAYWRIGHT_NATIVE + title: 'LLM Benchmark: Search + disambiguate by attribute' + +trail: + - step: "Navigate to http://localhost:8765/search-duplicates.html" + + - step: "Type \"Mouse\" into the \"Search products...\" input field" + + - step: "Click the \"Search\" button" + + - step: "Verify that \"4 results for 'Mouse'\" is visible" + + - step: "Click the \"Wireless Mouse\" result whose subtitle is \"USB-C, Gray\"" + + - step: "Verify that the text \"ID: 4\" is visible in the detail panel" diff --git a/trails/compose-desktop/test-add-todo/blaze.yaml b/trails/compose-desktop/test-add-todo/blaze.yaml deleted file mode 100644 index 0f465a913..000000000 --- a/trails/compose-desktop/test-add-todo/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Test: Add a todo item" - platform: compose -- prompts: - - step: Type "Buy groceries" into the todo input field - - step: Click the add button - - step: Verify the text "1 items" is visible - - step: Verify the text "Buy groceries" is visible diff --git a/trails/compose-desktop/test-add-todo/desktop.trail.yaml b/trails/compose-desktop/test-add-todo/desktop.trail.yaml deleted file mode 100644 index d4ba38497..000000000 --- a/trails/compose-desktop/test-add-todo/desktop.trail.yaml +++ /dev/null @@ -1,25 +0,0 @@ -- config: - title: "Test: Add a todo item" - platform: compose -- prompts: - - step: Type "Buy groceries" into the todo input field - recording: - tools: - - compose_type: - text: Buy groceries - testTag: todo_input - - step: Click the add button - recording: - tools: - - compose_click: - testTag: add_button - - step: Verify the text "1 items" is visible - recording: - tools: - - compose_verify_text_visible: - text: 1 items - - step: Verify the text "Buy groceries" is visible - recording: - tools: - - compose_verify_text_visible: - text: Buy groceries diff --git a/trails/compose-desktop/test-add-todo/trail.yaml b/trails/compose-desktop/test-add-todo/trail.yaml new file mode 100644 index 000000000..e798ae4ea --- /dev/null +++ b/trails/compose-desktop/test-add-todo/trail.yaml @@ -0,0 +1,28 @@ +config: + title: 'Test: Add a todo item' + +trail: + - step: "Type \"Buy groceries\" into the todo input field" + recording: + desktop: + - compose_type: + text: Buy groceries + testTag: todo_input + + - step: "Click the add button" + recording: + desktop: + - compose_click: + testTag: add_button + + - step: "Verify the text \"1 items\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: 1 items + + - step: "Verify the text \"Buy groceries\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: Buy groceries diff --git a/trails/compose-desktop/test-ambiguous-elements/desktop.trail.yaml b/trails/compose-desktop/test-ambiguous-elements/desktop.trail.yaml deleted file mode 100644 index 2376c6d4b..000000000 --- a/trails/compose-desktop/test-ambiguous-elements/desktop.trail.yaml +++ /dev/null @@ -1,36 +0,0 @@ -- config: - title: "Test: Ambiguous element disambiguation" - platform: compose -- prompts: - - step: Type "Profile name" into the profile text field - recording: - tools: - - compose_type: - text: Profile name - testTag: profile_field - - step: Click the profile save button - recording: - tools: - - compose_click: - testTag: profile_save_button - - step: Verify the text "Profile saved!" is visible - recording: - tools: - - compose_verify_text_visible: - text: Profile saved! - - step: Type "Settings value" into the settings text field - recording: - tools: - - compose_type: - text: Settings value - testTag: settings_field - - step: Click the settings save button - recording: - tools: - - compose_click: - testTag: settings_save_button - - step: Verify the text "Settings saved!" is visible - recording: - tools: - - compose_verify_text_visible: - text: Settings saved! diff --git a/trails/compose-desktop/test-ambiguous-elements/trail.yaml b/trails/compose-desktop/test-ambiguous-elements/trail.yaml new file mode 100644 index 000000000..46bdc60e1 --- /dev/null +++ b/trails/compose-desktop/test-ambiguous-elements/trail.yaml @@ -0,0 +1,41 @@ +config: + title: 'Test: Ambiguous element disambiguation' + +trail: + - step: "Type \"Profile name\" into the profile text field" + recording: + desktop: + - compose_type: + text: Profile name + testTag: profile_field + + - step: "Click the profile save button" + recording: + desktop: + - compose_click: + testTag: profile_save_button + + - step: "Verify the text \"Profile saved!\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: Profile saved! + + - step: "Type \"Settings value\" into the settings text field" + recording: + desktop: + - compose_type: + text: Settings value + testTag: settings_field + + - step: "Click the settings save button" + recording: + desktop: + - compose_click: + testTag: settings_save_button + + - step: "Verify the text \"Settings saved!\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: Settings saved! diff --git a/trails/compose-desktop/test-manage-todos/blaze.yaml b/trails/compose-desktop/test-manage-todos/blaze.yaml deleted file mode 100644 index 31e2e0b32..000000000 --- a/trails/compose-desktop/test-manage-todos/blaze.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- config: - title: "Test: Manage multiple todos" - platform: compose -- prompts: - - step: Type "Buy groceries" into the todo input field and click the add button - - step: Type "Walk the dog" into the todo input field and click the add button - - step: Type "Learn Compose" into the todo input field and click the add button - - step: Verify the text "3 items" is visible - - step: Delete the first todo item - - step: Verify the text "2 items" is visible diff --git a/trails/compose-desktop/test-manage-todos/desktop.trail.yaml b/trails/compose-desktop/test-manage-todos/desktop.trail.yaml deleted file mode 100644 index 88c13165b..000000000 --- a/trails/compose-desktop/test-manage-todos/desktop.trail.yaml +++ /dev/null @@ -1,43 +0,0 @@ -- config: - title: "Test: Manage multiple todos" - platform: compose -- prompts: - - step: Type "Buy groceries" into the todo input field and click the add button - recording: - tools: - - compose_type: - text: Buy groceries - testTag: todo_input - - compose_click: - testTag: add_button - - step: Type "Walk the dog" into the todo input field and click the add button - recording: - tools: - - compose_type: - text: Walk the dog - testTag: todo_input - - compose_click: - testTag: add_button - - step: Type "Learn Compose" into the todo input field and click the add button - recording: - tools: - - compose_type: - text: Learn Compose - testTag: todo_input - - compose_click: - testTag: add_button - - step: Verify the text "3 items" is visible - recording: - tools: - - compose_verify_text_visible: - text: 3 items - - step: Delete the first todo item - recording: - tools: - - compose_click: - testTag: delete_button_0 - - step: Verify the text "2 items" is visible - recording: - tools: - - compose_verify_text_visible: - text: 2 items diff --git a/trails/compose-desktop/test-manage-todos/trail.yaml b/trails/compose-desktop/test-manage-todos/trail.yaml new file mode 100644 index 000000000..949a9ca1c --- /dev/null +++ b/trails/compose-desktop/test-manage-todos/trail.yaml @@ -0,0 +1,48 @@ +config: + title: 'Test: Manage multiple todos' + +trail: + - step: "Type \"Buy groceries\" into the todo input field and click the add button" + recording: + desktop: + - compose_type: + text: Buy groceries + testTag: todo_input + - compose_click: + testTag: add_button + + - step: "Type \"Walk the dog\" into the todo input field and click the add button" + recording: + desktop: + - compose_type: + text: Walk the dog + testTag: todo_input + - compose_click: + testTag: add_button + + - step: "Type \"Learn Compose\" into the todo input field and click the add button" + recording: + desktop: + - compose_type: + text: Learn Compose + testTag: todo_input + - compose_click: + testTag: add_button + + - step: "Verify the text \"3 items\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: 3 items + + - step: "Delete the first todo item" + recording: + desktop: + - compose_click: + testTag: delete_button_0 + + - step: "Verify the text \"2 items\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: 2 items diff --git a/trails/compose-desktop/test-widget-interactions/blaze.yaml b/trails/compose-desktop/test-widget-interactions/blaze.yaml deleted file mode 100644 index 7c5b16c95..000000000 --- a/trails/compose-desktop/test-widget-interactions/blaze.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- config: - title: "Test: Widget interactions" - platform: compose -- prompts: - - step: Click the dark mode switch to toggle it on - - step: Click the "Option B" radio button to select it - - step: Click the counter button three times - - step: 'Verify the text "Clicked: 3" is visible' - - step: Scroll the list to index 20 - - step: Verify the text "Item 20" is visible diff --git a/trails/compose-desktop/test-widget-interactions/desktop.trail.yaml b/trails/compose-desktop/test-widget-interactions/desktop.trail.yaml deleted file mode 100644 index 8c47f1e10..000000000 --- a/trails/compose-desktop/test-widget-interactions/desktop.trail.yaml +++ /dev/null @@ -1,39 +0,0 @@ -- config: - title: "Test: Widget interactions" - platform: compose -- prompts: - - step: Click the dark mode switch to toggle it on - recording: - tools: - - compose_click: - testTag: dark_mode_switch - - step: Click the "Option B" radio button to select it - recording: - tools: - - compose_click: - testTag: radio_option_b - - step: Click the counter button three times - recording: - tools: - - compose_click: - testTag: counter_button - - compose_click: - testTag: counter_button - - compose_click: - testTag: counter_button - - step: 'Verify the text "Clicked: 3" is visible' - recording: - tools: - - compose_verify_text_visible: - text: "Clicked: 3" - - step: Scroll the list to index 20 - recording: - tools: - - compose_scroll: - testTag: scrollable_list - index: 20 - - step: Verify the text "Item 20" is visible - recording: - tools: - - compose_verify_text_visible: - text: Item 20 diff --git a/trails/compose-desktop/test-widget-interactions/trail.yaml b/trails/compose-desktop/test-widget-interactions/trail.yaml new file mode 100644 index 000000000..7d2d93ad2 --- /dev/null +++ b/trails/compose-desktop/test-widget-interactions/trail.yaml @@ -0,0 +1,44 @@ +config: + title: 'Test: Widget interactions' + +trail: + - step: "Click the dark mode switch to toggle it on" + recording: + desktop: + - compose_click: + testTag: dark_mode_switch + + - step: "Click the \"Option B\" radio button to select it" + recording: + desktop: + - compose_click: + testTag: radio_option_b + + - step: "Click the counter button three times" + recording: + desktop: + - compose_click: + testTag: counter_button + - compose_click: + testTag: counter_button + - compose_click: + testTag: counter_button + + - step: "Verify the text \"Clicked: 3\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: 'Clicked: 3' + + - step: "Scroll the list to index 20" + recording: + desktop: + - compose_scroll: + testTag: scrollable_list + index: 20 + + - step: "Verify the text \"Item 20\" is visible" + recording: + desktop: + - compose_verify_text_visible: + text: Item 20 diff --git a/trails/config/trailblaze.yaml b/trails/config/trailblaze.yaml index 176f62721..fc9e3acf9 100644 --- a/trails/config/trailblaze.yaml +++ b/trails/config/trailblaze.yaml @@ -33,3 +33,4 @@ targets: - contacts - wikipedia - calendar + - goose diff --git a/trails/config/trailmaps/goose/trailheads/web/goose_launchDesktop.trailhead.yaml b/trails/config/trailmaps/goose/trailheads/web/goose_launchDesktop.trailhead.yaml new file mode 100644 index 000000000..4a1f654eb --- /dev/null +++ b/trails/config/trailmaps/goose/trailheads/web/goose_launchDesktop.trailhead.yaml @@ -0,0 +1,13 @@ +# Goose desktop trailhead — bootstrap into the Goose home / chat screen from any state. +# +# The framework launches the Goose Electron app (see `target.electron:` in trailmap.yaml) and +# connects via CDP before this runs; `playwright_desktop_launchGoose` then confirms the connection +# is live and the app is ready for interaction. Use as a trail's step-0 trailhead so a test starts +# from a known launched state regardless of where the agent currently is. +id: goose_launchDesktop +description: Launch the Goose desktop app (Electron) and confirm it is connected and ready on the home / chat screen. +parameters: [] +trailhead: + to: goose/home +tools: + - playwright_desktop_launchGoose: {} diff --git a/trails/config/trailmaps/goose/trailmap.yaml b/trails/config/trailmaps/goose/trailmap.yaml new file mode 100644 index 000000000..5bb729b29 --- /dev/null +++ b/trails/config/trailmaps/goose/trailmap.yaml @@ -0,0 +1,30 @@ +# Goose Desktop target trailmap. +# +# Goose is an Electron desktop app, driven through the PLAYWRIGHT_ELECTRON driver: the framework +# launches the app executable and connects to it over CDP *before* any trail step runs. The +# `target.electron:` block below is that launch config's home — it is what a v1 trail used to carry +# per-trail under `config.electron`. Because an Electron launch is a property of the app under test +# (not of an individual test), it belongs on the target: a unified trail just selects +# `config.target: goose` + `devices: {web: PLAYWRIGHT_ELECTRON}` and needs no per-trail launch block. +# +# Web tool sets and the playwright-electron driver are inherited from the bundled `trailblaze` +# framework trailmap via `dependencies: [trailblaze]`; `platforms.web: {}` opts the web platform in +# and fills its `drivers:` / `tool_sets:` from those defaults. Loaded as a workspace trailmap from +# this repo's `trails/config/trailblaze.yaml` — not bundled into the Trailblaze JAR. +id: goose +dependencies: + - trailblaze +target: + display_name: Goose Desktop + # Electron launch config. `command` is the default local macOS install path; point at a + # different install (or a CI build) with the TRAILBLAZE_ELECTRON_* env vars, or attach to an + # already-running instance by setting `cdpUrl` instead of `command`. + electron: + command: /Applications/Goose.app/Contents/MacOS/Goose + env: + ENABLE_PLAYWRIGHT: "true" + cdpTimeoutSeconds: 60 + platforms: + web: {} +# Waypoint YAMLs are auto-discovered from `waypoints/**.waypoint.yaml`; trailheads from +# `trailheads/**.trailhead.yaml`. diff --git a/trails/config/trailmaps/goose/waypoints/goose_home.waypoint.yaml b/trails/config/trailmaps/goose/waypoints/goose_home.waypoint.yaml new file mode 100644 index 000000000..b584a9be4 --- /dev/null +++ b/trails/config/trailmaps/goose/waypoints/goose_home.waypoint.yaml @@ -0,0 +1,10 @@ +# Goose desktop home / chat screen — the landing state the `goose_launchDesktop` trailhead +# bootstraps to. +# +# Selectors are intentionally left off until Goose's real accessibility / DOM tree is captured +# on-device. The trailhead's post-condition (`to: goose/home`) resolves to this named landing +# state, while the concrete "app is up" verification comes from `playwright_desktop_launchGoose` +# reading the connected page's URL + title. Add a `web.required` selector (e.g. the chat composer +# textbox) once the DOM is confirmed, to tighten the landing assertion. +id: goose/home +description: "Goose desktop home / chat screen — the app is launched and the chat composer is ready for input." diff --git a/trails/contacts/enter-name-in-create-form/android.trail.yaml b/trails/contacts/enter-name-in-create-form/android.trail.yaml deleted file mode 100644 index 8d740d32f..000000000 --- a/trails/contacts/enter-name-in-create-form/android.trail.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Open the new-contact form and type a name into the First-name field. Exercises -# the custom `contacts_android_launchApp` scripted tool (declared by the `contacts` -# workspace trailmap) for setup, then drives the agent through form interaction. -- config: - id: "contacts/enter-name-in-create-form" - driver: ANDROID_ONDEVICE_INSTRUMENTATION - target: contacts - description: "Launch Contacts via the custom scripted tool, then enter a name in the create-contact form." - -- prompts: - - step: Launch the Contacts app via the custom scripted tool - recording: - tools: - - contacts_android_launchApp: {} - -- prompts: - - step: If a sign-in / account-setup interstitial appears (e.g. a "Sign in" or "Skip" button), tap "Skip". - - step: Tap the floating action button (the "+" icon, usually labeled "Create contact") to open the new-contact form. - - step: Tap the "First name" text field to focus it. - - step: Type "Trailblaze Test" into the focused field. - - step: Verify the First name field now contains "Trailblaze Test". diff --git a/trails/contacts/enter-name-in-create-form/trail.yaml b/trails/contacts/enter-name-in-create-form/trail.yaml new file mode 100644 index 000000000..88e5eadd0 --- /dev/null +++ b/trails/contacts/enter-name-in-create-form/trail.yaml @@ -0,0 +1,22 @@ +config: + id: contacts/enter-name-in-create-form + target: contacts + description: Launch Contacts via the custom scripted tool, then enter a name in the create-contact form. + devices: + android: ANDROID_ONDEVICE_INSTRUMENTATION + +trail: + - step: "Launch the Contacts app via the custom scripted tool" + recording: + android: + - contacts_android_launchApp: {} + + - step: "If a sign-in / account-setup interstitial appears (e.g. a \"Sign in\" or \"Skip\" button), tap \"Skip\"." + + - step: "Tap the floating action button (the \"+\" icon, usually labeled \"Create contact\") to open the new-contact form." + + - step: "Tap the \"First name\" text field to focus it." + + - step: "Type \"Trailblaze Test\" into the focused field." + + - step: "Verify the First name field now contains \"Trailblaze Test\"." diff --git a/trails/contacts/open-contacts-app/android-phone.trail.yaml b/trails/contacts/open-contacts-app/android-phone.trail.yaml deleted file mode 100644 index edc62a3ec..000000000 --- a/trails/contacts/open-contacts-app/android-phone.trail.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - id: contacts/open-contacts-app - description: Open the Contacts app and verify the main screen is visible. - platform: android - driver: ANDROID_ONDEVICE_INSTRUMENTATION -- prompts: - - step: Launch the Google Contacts app (com.google.android.contacts) with a clean state. - - step: If a sign-in / account-setup interstitial appears (e.g. a "Sign in" or "Skip" button), dismiss it by tapping "Skip" so the main contacts screen loads. If the main contacts screen is already visible, do nothing. - - step: Verify the main contacts screen is visible. The screen should show either an empty state ("No contacts" / "Add a contact") or a list of contacts. Do not tap anything else. diff --git a/trails/contacts/open-contacts-app/android.trail.yaml b/trails/contacts/open-contacts-app/android.trail.yaml deleted file mode 100644 index 57667885e..000000000 --- a/trails/contacts/open-contacts-app/android.trail.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Demo trail: launch Google Contacts and verify the main screen loads. -# Authored to validate the runtime-YAML-config path: the `contacts` target is -# declared in trails/config/trailmaps/contacts/trailmap.yaml as a workspace -# trailmap — the framework JAR ships no `contacts` target, so this trail can only -# resolve via the workspace anchor. -- config: - id: "contacts/open-contacts-app" - driver: ANDROID_ONDEVICE_INSTRUMENTATION - description: "Open the Contacts app and verify the main screen is visible." - -- prompts: - - step: Launch the Google Contacts app (com.google.android.contacts) with a clean state. - - step: If a sign-in / account-setup interstitial appears (e.g. a "Sign in" or "Skip" button), dismiss it by tapping "Skip" so the main contacts screen loads. If the main contacts screen is already visible, do nothing. - - step: Verify the main contacts screen is visible. The screen should show either an empty state ("No contacts" / "Add a contact") or a list of contacts. Do not tap anything else. diff --git a/trails/contacts/open-contacts-app/trail.yaml b/trails/contacts/open-contacts-app/trail.yaml new file mode 100644 index 000000000..34ef12648 --- /dev/null +++ b/trails/contacts/open-contacts-app/trail.yaml @@ -0,0 +1,13 @@ +config: + id: contacts/open-contacts-app + description: Open the Contacts app and verify the main screen is visible. + devices: + android-phone: ANDROID_ONDEVICE_INSTRUMENTATION + android: ANDROID_ONDEVICE_INSTRUMENTATION + +trail: + - step: "Launch the Google Contacts app (com.google.android.contacts) with a clean state." + + - step: "If a sign-in / account-setup interstitial appears (e.g. a \"Sign in\" or \"Skip\" button), dismiss it by tapping \"Skip\" so the main contacts screen loads. If the main contacts screen is already visible, do nothing." + + - step: "Verify the main contacts screen is visible. The screen should show either an empty state (\"No contacts\" / \"Add a contact\") or a list of contacts. Do not tap anything else." diff --git a/trails/contacts/open-create-contact-form/android-phone.trail.yaml b/trails/contacts/open-create-contact-form/android-phone.trail.yaml deleted file mode 100644 index 7c7c025a7..000000000 --- a/trails/contacts/open-create-contact-form/android-phone.trail.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - id: contacts/open-create-contact-form - description: Open the Contacts app and tap the create-contact action. - platform: android - driver: ANDROID_ONDEVICE_INSTRUMENTATION -- prompts: - - step: Launch the Google Contacts app (com.google.android.contacts) with a clean state. - - step: Tap the floating action button (the "+" icon, usually labeled "Create contact") to open the new-contact form. - - step: Verify the new-contact form is visible — the screen should show name and phone-number text fields ready for input. Do not enter any data. diff --git a/trails/contacts/open-create-contact-form/android.trail.yaml b/trails/contacts/open-create-contact-form/android.trail.yaml deleted file mode 100644 index 54eb946b7..000000000 --- a/trails/contacts/open-create-contact-form/android.trail.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Demo trail: launch Google Contacts and open the new-contact form. -# Authored to exercise one level deeper than the simple smoke trail. -- config: - id: "contacts/open-create-contact-form" - driver: ANDROID_ONDEVICE_INSTRUMENTATION - description: "Open the Contacts app and tap the create-contact action." - -- prompts: - - step: Launch the Google Contacts app (com.google.android.contacts) with a clean state. - - step: Tap the floating action button (the "+" icon, usually labeled "Create contact") to open the new-contact form. - - step: Verify the new-contact form is visible — the screen should show name and phone-number text fields ready for input. Do not enter any data. diff --git a/trails/contacts/open-create-contact-form/trail.yaml b/trails/contacts/open-create-contact-form/trail.yaml new file mode 100644 index 000000000..087743f6a --- /dev/null +++ b/trails/contacts/open-create-contact-form/trail.yaml @@ -0,0 +1,13 @@ +config: + id: contacts/open-create-contact-form + description: Open the Contacts app and tap the create-contact action. + devices: + android-phone: ANDROID_ONDEVICE_INSTRUMENTATION + android: ANDROID_ONDEVICE_INSTRUMENTATION + +trail: + - step: "Launch the Google Contacts app (com.google.android.contacts) with a clean state." + + - step: "Tap the floating action button (the \"+\" icon, usually labeled \"Create contact\") to open the new-contact form." + + - step: "Verify the new-contact form is visible — the screen should show name and phone-number text fields ready for input. Do not enter any data." diff --git a/trails/evals/click-back/android.trail.yaml b/trails/evals/click-back/android.trail.yaml deleted file mode 100644 index 03b050e9f..000000000 --- a/trails/evals/click-back/android.trail.yaml +++ /dev/null @@ -1,5 +0,0 @@ -- config: - id: "evals/click-back" - driver: ANDROID_ONDEVICE_INSTRUMENTATION -- prompts: - - step: Click the back button diff --git a/trails/evals/click-back/trail.yaml b/trails/evals/click-back/trail.yaml new file mode 100644 index 000000000..a5bb15b76 --- /dev/null +++ b/trails/evals/click-back/trail.yaml @@ -0,0 +1,7 @@ +config: + id: evals/click-back + devices: + android: ANDROID_ONDEVICE_INSTRUMENTATION + +trail: + - step: "Click the back button" diff --git a/trails/goose-desktop/blaze.yaml b/trails/goose-desktop/blaze.yaml deleted file mode 100644 index 8c29fb98a..000000000 --- a/trails/goose-desktop/blaze.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Launch Goose, send a prompt, and verify the response. -- config: - title: "Goose Desktop - Prompt and Verify Response" - driver: PLAYWRIGHT_ELECTRON - electron: - command: /Applications/Goose.app/Contents/MacOS/Goose - env: - ENABLE_PLAYWRIGHT: "true" - cdpTimeoutSeconds: 60 -- prompts: - - step: > - Launch the Goose desktop application using the playwright_desktop_launchGoose tool - and verify it started successfully. - - step: > - Find the chat input field and type the prompt: - "the capital of the state of virginia is __________" - Then submit the prompt by pressing Enter. - - step: > - Wait for Goose to respond, then verify that the word "Richmond" - appears somewhere in the response. diff --git a/trails/goose-desktop/trail.yaml b/trails/goose-desktop/trail.yaml new file mode 100644 index 000000000..db506fca0 --- /dev/null +++ b/trails/goose-desktop/trail.yaml @@ -0,0 +1,24 @@ +# Launch Goose, send a prompt, and verify the response. +# +# The launch is the trail's trailhead (deterministic step 0). The Goose Electron app's launch +# config lives on the `goose` target trailmap (`trails/config/trailmaps/goose/`), so this trail +# just selects that target + the playwright-electron driver — no per-trail `electron:` block. +config: + title: "Goose Desktop - Prompt and Verify Response" + target: goose + devices: + web: PLAYWRIGHT_ELECTRON + +trailhead: + step: > + Launch the Goose desktop application using the playwright_desktop_launchGoose tool + and verify it started successfully. + +trail: + - step: > + Find the chat input field and type the prompt: + "the capital of the state of virginia is __________" + Then submit the prompt by pressing Enter. + - step: > + Wait for Goose to respond, then verify that the word "Richmond" + appears somewhere in the response. diff --git a/trails/ios-contacts/test-app-launches/blaze.yaml b/trails/ios-contacts/test-app-launches/blaze.yaml deleted file mode 100644 index a4e030afa..000000000 --- a/trails/ios-contacts/test-app-launches/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): App launches to a contacts list" - platform: ios - driver: IOS_HOST - target: contacts - tags: [smoke] -- prompts: - - step: Launch the iOS Contacts app (com.apple.MobileAddressBook) with a clean state, and verify the contacts list root rendered — the "Contacts" navbar title should be visible. diff --git a/trails/ios-contacts/test-app-launches/ios-iphone.trail.yaml b/trails/ios-contacts/test-app-launches/ios-iphone.trail.yaml deleted file mode 100644 index 31c971c2f..000000000 --- a/trails/ios-contacts/test-app-launches/ios-iphone.trail.yaml +++ /dev/null @@ -1,21 +0,0 @@ -- config: - title: 'Contacts (iOS): App launches to a contacts list' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Launch the iOS Contacts app (com.apple.MobileAddressBook) with a clean state, and verify the contacts list root rendered — the "Contacts" navbar title should be visible. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - assertVisibleBySelector: - reason: Verify the contacts list root rendered by asserting the "Contacts" navbar title is visible. The contacts_ios_openApp trailhead already cold-starts Contacts and lands on the list root, so no home-screen icon tap is needed. - nodeSelector: - iosMaestro: - textRegex: Contacts - - assertVisibleBySelector: - reason: Also assert the list-only "Add" button is visible to confirm this is the contacts list root specifically (not some other screen that happens to show the text "Contacts" — e.g. a detail screen's back button also carries the label "Contacts"). The list root shows "Add"; a contact detail screen shows "Edit" there instead. - nodeSelector: - iosMaestro: - accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-app-launches/trail.yaml b/trails/ios-contacts/test-app-launches/trail.yaml new file mode 100644 index 000000000..6d88f1a8d --- /dev/null +++ b/trails/ios-contacts/test-app-launches/trail.yaml @@ -0,0 +1,24 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - smoke + title: 'Contacts (iOS): App launches to a contacts list' + +trail: + - step: "Launch the iOS Contacts app (com.apple.MobileAddressBook) with a clean state, and verify the contacts list root rendered — the \"Contacts\" navbar title should be visible." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - assertVisibleBySelector: + reason: Verify the contacts list root rendered by asserting the "Contacts" navbar title is visible. The contacts_ios_openApp trailhead already cold-starts Contacts and lands on the list root, so no home-screen icon tap is needed. + nodeSelector: + iosMaestro: + textRegex: Contacts + - assertVisibleBySelector: + reason: Also assert the list-only "Add" button is visible to confirm this is the contacts list root specifically (not some other screen that happens to show the text "Contacts" — e.g. a detail screen's back button also carries the label "Contacts"). The list root shows "Add"; a contact detail screen shows "Edit" there instead. + nodeSelector: + iosMaestro: + accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-back-navigation/blaze.yaml b/trails/ios-contacts/test-back-navigation/blaze.yaml deleted file mode 100644 index d8d0dd4d3..000000000 --- a/trails/ios-contacts/test-back-navigation/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Contacts (iOS): Back from contact detail returns to list" - platform: ios - driver: IOS_HOST - target: contacts - tags: [nav] -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - - step: Tap the back-navigation control (the "<" button in the navbar). Verify the contacts list root is visible again — the "Contacts" navbar title should reappear at the top of the list. diff --git a/trails/ios-contacts/test-back-navigation/ios-iphone.trail.yaml b/trails/ios-contacts/test-back-navigation/ios-iphone.trail.yaml deleted file mode 100644 index 8597b8817..000000000 --- a/trails/ios-contacts/test-back-navigation/ios-iphone.trail.yaml +++ /dev/null @@ -1,30 +0,0 @@ -- config: - title: 'Contacts (iOS): Back from contact detail returns to list' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - tapOnElementBySelector: - reason: '''John Appleseed'' is visible in the contacts list. Tapping on this contact will open the contact details, fulfilling the objective.' - nodeSelector: - iosMaestro: - textRegex: John Appleseed - index: 1 - - step: Tap the back-navigation control (the "<" button in the navbar). Verify the contacts list root is visible again — the "Contacts" navbar title should reappear at the top of the list. - recording: - tools: - - tapOnElementBySelector: - reason: Tap the navbar back button to pop back to the contacts list root. On the iOS 18.6 simulators CI uses, the detail-screen back control exposes the accessibility label "Back" (verified live), and there is no element with a "BackButton" resourceId — so match on the label. The post-tap assertion below targets the list-only "Add" button rather than the navbar title, since the detail screen's back button also carries the label "Contacts". - nodeSelector: - iosMaestro: - accessibilityTextRegex: Back - - assertVisibleBySelector: - reason: Verify we returned to the contacts list root by asserting the list-only "Add" button (the "+" in the list navbar) is visible. The contact detail screen exposes an "Edit" button here instead of "Add", so this distinguishes the list root from the detail screen even if the back tap were to no-op. (Asserting the "Contacts" navbar title alone would not, because the detail screen's back button also carries the label "Contacts".) - nodeSelector: - iosMaestro: - accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-back-navigation/trail.yaml b/trails/ios-contacts/test-back-navigation/trail.yaml index 76db4370a..6639aaf22 100644 --- a/trails/ios-contacts/test-back-navigation/trail.yaml +++ b/trails/ios-contacts/test-back-navigation/trail.yaml @@ -1,5 +1,10 @@ config: target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - nav + title: 'Contacts (iOS): Back from contact detail returns to list' trail: - step: "Open the \"John Appleseed\" contact in Contacts." @@ -7,11 +12,6 @@ trail: ios-iphone: - contacts_ios_openApp: dismissKeyboard: true - - tapOnElementBySelector: - reason: Contacts app is visible on the home screen. Tapping its icon will open it so I can proceed to find the 'John Appleseed' contact. - nodeSelector: - iosMaestro: - resourceIdRegex: Contacts - tapOnElementBySelector: reason: '''John Appleseed'' is visible in the contacts list. Tapping on this contact will open the contact details, fulfilling the objective.' nodeSelector: @@ -23,214 +23,12 @@ trail: recording: ios-iphone: - tapOnElementBySelector: - reason: The back-navigation control is the "<" button in the navbar, which is located on the top left and shares the same ref as the navbar title 'Contacts' on detail screens. Tapping this will return to the contacts list root, where the 'Contacts' navbar title should be visible again. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The "<" back-navigation button is visible on the top-left corner. Tapping it will attempt to return to the root 'Contacts' list, as required by the objective. + reason: Tap the navbar back button to pop back to the contacts list root. On the iOS 18.6 simulators CI uses, the detail-screen back control exposes the accessibility label "Back" (verified live), and there is no element with a "BackButton" resourceId — so match on the label. The post-tap assertion below targets the list-only "Add" button rather than the navbar title, since the detail screen's back button also carries the label "Contacts". nodeSelector: iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The actual back-navigation control is the left arrow icon visible on the top left of the screen in the navbar, which is not the same as the 'Contacts' title element (w72). This element (u997) should correspond to the back button and tapping it should return to the contacts list root. The previous tap attempts targeted the 'Contacts' text rather than the arrow icon. + accessibilityTextRegex: Back + - assertVisibleBySelector: + reason: Verify we returned to the contacts list root by asserting the list-only "Add" button (the "+" in the list navbar) is visible. The contact detail screen exposes an "Edit" button here instead of "Add", so this distinguishes the list root from the detail screen even if the back tap were to no-op. (Asserting the "Contacts" navbar title alone would not, because the detail screen's back button also carries the label "Contacts".) nodeSelector: iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The view hierarchy and screenshot clearly show that the back-navigation control is the left arrow inside the [u997] Toolbar region at the top left of the screen. Tapping this should navigate back to the contacts list root where the 'Contacts' navbar title will be visible, fulfilling the objective. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation control is represented by the left arrow in the Toolbar at the top left. Previous attempts at tapping may not have registered correctly. I will try again to ensure we navigate back to the contacts list root so 'Contacts' becomes the navbar title. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation control (left arrow) is visually present inside the 'Toolbar' region [u997] at the top left. Tapping this area is necessary to return to the contacts list root, as multiple previous tap attempts were not specific to the left arrow itself. This control is distinct from the 'Contacts' navbar title. After tapping, I will verify the 'Contacts' navbar title is again visible to confirm success. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation control (left arrow) is visually present at the top left inside the Toolbar [u997]. Previous taps may not have been precise or registered, so I will tap again closer to the region that contains the arrow. This should navigate back to the contacts list root, fulfilling the objective. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - pressKey: - keyCode: BACK - - tapOnElementBySelector: - reason: The back arrow ('<') control is not getting activated via Toolbar taps. Trying to tap directly on the area containing the back arrow itself, which is left of the 'Contacts' title [w72] at the top. This should navigate back. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The back-navigation control (the left arrow) is visually present in the upper left of the screen, inside the 'Toolbar' region [u997]. Tapping the Toolbar region should activate the back action and return to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation control ('<' button) is inside the Toolbar [u997] region at the top left. Tapping this region should navigate back to the contacts list root. Since the contact detail is still visible, I am retrying to ensure the expected navigation happens. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The '<' back-navigation icon is distinct and resides just left of the 'Contacts' title (w72) at the top. Since tapping the Toolbar region itself hasn't worked and w72 corresponds to the navbar/title, tapping on w72 could also trigger the navigation back to the contacts root. This is a direct attempt to cover the remaining actionable target in the navbar area. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The back arrow button is visually in the top left within the Toolbar region [u997]. Previous taps on Toolbar and the Contacts title haven't navigated back. I will target another tap on the Toolbar [u997], specifically covering the back arrow, to ensure the tap lands on the area expected to trigger back-navigation to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation arrow is clearly visible within the top-left 'Toolbar' region [u997]. Tapping this Toolbar is the expected user action for iOS apps to trigger the back-navigation. This should return us to the contacts list root, where the 'Contacts' navbar title should appear at the top. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation control ('<' button) is part of the Toolbar region [u997] at the top left of the screen. The previous taps may have not been registered, so I will tap on Toolbar [u997] again to trigger the back navigation to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - pressKey: - keyCode: BACK - - tapOnElementBySelector: - reason: The back-navigation '<' button is located inside the Toolbar [u997] at the top-left of the screen. Previous taps on Toolbar may not have registered clearly on the actual back button target. I will tap again on the Toolbar [u997] area, explicitly aiming to hit the back-arrow to return to the contacts list root view. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The actual tappable region for the back-navigation may be the area at the far left where the '<' arrow is located, which is above and to the left of the contact details. The left-most area near the 'Contacts' navbar title [w72] is the most likely interactive element for back-navigation in iOS UI. I will tap on [w72] to attempt to trigger back-navigation. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The actual back-navigation '<' control is visually present as a button within the Toolbar [u997] at the top left. Previous taps may have missed the interactive region. I will tap again, carefully targeting the Toolbar [u997] to trigger the back navigation to the 'Contacts' list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation '<' button is still visible at the far top left of the Toolbar [u997]. Previous attempts may have missed the exact location or the UI is not responding. I will try tapping the Toolbar [u997] again to ensure the tap lands on the back-arrow control. This is the correct method in iOS Contacts to trigger the navigation back to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The left arrow for back-navigation is directly next to the 'Contacts' title [w72], so tapping on [w72] may trigger the back navigation to the contacts list root. Previous attempts at tapping other areas did not succeed in navigating away from the contact details view. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The back-navigation chevron '<' is visually present at the far left inside the toolbar [u997]. Although previous taps on [u997] may not have triggered the navigation, it is the only likely actionable container for the back control. I will try again, tapping the [u997] Toolbar, as this is where iOS typically registers taps for back navigation within the Contacts app. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The current interface still shows the contact details for John Appleseed, and the back-navigation control (“<”) is visually nested within or overlaid by the Toolbar [u997]. Previous taps may have missed the target tap area for triggering the navigation. I will tap the Toolbar [u997] again to attempt the back-navigation to the Contacts list root as this is the most likely actionable area. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: None of the previous taps have caused navigation, but the back ('<') icon is visually within the top-left of the Toolbar [u997]. It is possible this needs a tap very soon after previous attempts, or requires repetition due to UI lag. Tapping [u997] again (as the chevron sits within this container) remains the best action. Precise targeting at the top left should eventually trigger the back-navigation to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The element [w72] appears at the very top, associated with the Contacts title (which is where the back button is located). The left-facing '<' chevron icon is not given a unique ref, but it is visually aligned on the top-left beside [w72]. I will attempt to tap at [w72] in case this area includes the touch target for the back navigation. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The back navigation chevron '<' icon is clearly visible in the screenshot at the far left inside the [u997] Toolbar area, which overlays the top of the screen. Tapping on [u997] attempts to trigger the back navigation. Since previous taps may not have registered, I will tap there again to ensure the navigation is triggered. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The current screen is still showing the contact details, and the visible chevron for back-navigation is within the [u997] Toolbar area. Previous taps on this Toolbar have not transitioned the UI. I will try one more tap to ensure we are targeting the correct back navigation area at the top left of the Toolbar. If this does not work, an alternative action will be needed. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back navigation chevron is visually located to the left of the 'Contacts' title, but in the hierarchy, 'Contacts' [w72] is at the bottom, which might indicate it also acts as a navigation target (e.g., a tappable area or heading, depending on how the UI is layered). Since tapping Toolbar [u997] has not navigated back, tapping directly on [w72] might trigger back-navigation or update the navigation state. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The chevron '<' used for back-navigation is visually inside the [u997] Toolbar at the top left. Since tapping the Toolbar as a whole did not work previously, I will now tap at the 'u997' precisely—which matches the visual box enclosing the chevron—ensuring the interaction is directly within the back area and not any adjacent widgets. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back navigation chevron '<' is clearly inside area [u997] (Toolbar). Tapping that area should trigger a navigation back to the contacts list. Since the view hierarchy and screenshot confirm we are still on the contact detail page, I will ensure a targeted tap in this area. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation chevron is visually within [u997] ('Toolbar'), and previous taps on this exact area are likely to activate back navigation. The view hierarchy and bounding box confirm this is the tappable toolbar. Tapping it again directly should achieve the required effect. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation chevron should lead to the contacts list root, which is indicated by the "Contacts" navbar title (w72). However, since tapping the toolbar area didn't trigger a view change, this suggests either the tap target must be more precise or the navigation event needs to expressly focus on the chevron or left area. Tapping 'w72', which is associated with 'Contacts', may represent the top area and trigger the intended navigation. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The back-navigation chevron '<' is visually part of the Toolbar [u997] at the top left. Tapping on the toolbar region is the correct way to trigger the navigation back to the contacts list root when a dedicated chevron element is not exposed in the hierarchy. Previous taps may not have registered due to tap targeting or timing; I will tap [u997] again for reliability. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The visible back chevron ('<') is part of the toolbar [u997] at the very top-left of the screen. All other elements are content; the Toolbar area is the only likely back-navigation control. Previous attempts may not have registered due to time or tap granularity, so retrying a direct tap on [u997] is the correct action to trigger navigation back to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - pressKey: - keyCode: BACK - - tapOnElementBySelector: - reason: The chevron for back-navigation is visually present inside the Toolbar [u997], at the top left corner. As the list view did not change after the pressKey or previous taps, I will directly tap on the Toolbar [u997] again, targeting the left area where the '<' chevron appears, to trigger navigation to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The '<' chevron for back-navigation is still visible in the top left, and all detail elements for 'John Appleseed' are still present, indicating I have not yet navigated back to the contacts list root. The reference [w72] is associated both with the 'Contacts' label and the typical area where the navbar title appears. Tapping on this area may trigger navigation to the contacts root. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOnElementBySelector: - reason: The user is still on the John Appleseed contact detail screen. The visible '<' in the top left is the back-navigation chevron and is within the Toolbar [u997], which spans the top region. Tapping the Toolbar in the top left where the chevron resides should navigate back to the contacts list root. No other back or navigation control is present. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back navigation control is visually a chevron '<' on the left side of the Toolbar [u997]. This is standard for iOS navigation, and tapping this element is expected to navigate from the contact detail ('John Appleseed') to the contacts list root. I will tap [u997] again to ensure the action is properly triggered. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The chevron for back-navigation ('<') is present on the left side of the Toolbar [u997]. Previous actions haven't changed the screen state, so I will aim for a more precise tap on the Toolbar [u997] (where the chevron is clearly visible in the screenshot) to trigger navigation back to the contacts list root. - nodeSelector: - iosMaestro: - resourceIdRegex: Toolbar - - tapOnElementBySelector: - reason: The back-navigation chevron ('<') appears in the top left, just inside node [w72] labeled 'Contacts'. The user is still on the contact detail view and hasn't yet navigated back. Tapping [w72] is expected to trigger the back-navigation to return to the contacts list root. - nodeSelector: - iosMaestro: - textRegex: Contacts - index: 0 - - tapOn: - selector: - iosMaestro: - resourceIdRegex: BackButton - relativePoint: 61.4%,63.6% + accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-create-contact-basic/blaze.yaml b/trails/ios-contacts/test-create-contact-basic/blaze.yaml deleted file mode 100644 index 7bfe67d78..000000000 --- a/trails/ios-contacts/test-create-contact-basic/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Create a contact with first + last name" - platform: ios - driver: IOS_HOST - target: contacts - tags: [crud] -- prompts: - - step: Open the iOS Contacts app and create a new contact with first name "Trailblaze" and last name "Demo". Save and verify the new contact appears in the list. diff --git a/trails/ios-contacts/test-create-contact-basic/ios-iphone.trail.yaml b/trails/ios-contacts/test-create-contact-basic/ios-iphone.trail.yaml deleted file mode 100644 index 75beee86f..000000000 --- a/trails/ios-contacts/test-create-contact-basic/ios-iphone.trail.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- config: - title: 'Contacts (iOS): Create a contact with first + last name' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the iOS Contacts app and create a new contact with first name "Trailblaze" and last name "Demo". Save and verify the new contact appears in the list. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - contacts_ios_createContact: - firstName: Trailblaze - lastName: Demo diff --git a/trails/ios-contacts/test-create-contact-basic/trail.yaml b/trails/ios-contacts/test-create-contact-basic/trail.yaml new file mode 100644 index 000000000..740290460 --- /dev/null +++ b/trails/ios-contacts/test-create-contact-basic/trail.yaml @@ -0,0 +1,17 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - crud + title: 'Contacts (iOS): Create a contact with first + last name' + +trail: + - step: "Open the iOS Contacts app and create a new contact with first name \"Trailblaze\" and last name \"Demo\". Save and verify the new contact appears in the list." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - contacts_ios_createContact: + firstName: Trailblaze + lastName: Demo diff --git a/trails/ios-contacts/test-create-then-delete/blaze.yaml b/trails/ios-contacts/test-create-then-delete/blaze.yaml deleted file mode 100644 index dd995d07c..000000000 --- a/trails/ios-contacts/test-create-then-delete/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# End-to-end CRUD showcase, written as natural-language INTENT — not tool calls. -# Each step says *what* we want; the target system prompt + scripted-tool -# descriptions let the agent pick the right tool (contacts_ios_createContact / -# contacts_ios_deleteContact / …). A PM/QA can read these steps, and the recorded -# `*.trail.yaml` replays them deterministically with no LLM. Keep steps to intent -# — clear, not verbose; don't name tools or pass args here. -- config: - title: "Contacts (iOS): Create a contact then delete it" - platform: ios - driver: IOS_HOST - target: contacts - tags: [crud, slow] -- prompts: - - step: Remove any leftover "Trailblaze Demo" contact so we start from a clean slate. - - step: Create a new contact named Trailblaze Demo with the phone number 555-123-4567. - - step: Verify the new "Trailblaze Demo" contact was saved. - - step: Delete the "Trailblaze Demo" contact. diff --git a/trails/ios-contacts/test-create-then-delete/ios-iphone.trail.yaml b/trails/ios-contacts/test-create-then-delete/ios-iphone.trail.yaml deleted file mode 100644 index 8adcb7234..000000000 --- a/trails/ios-contacts/test-create-then-delete/ios-iphone.trail.yaml +++ /dev/null @@ -1,34 +0,0 @@ -- config: - title: 'Contacts (iOS): Create a contact then delete it' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Remove any leftover "Trailblaze Demo" contact so we start from a clean slate. - recording: - tools: - - contacts_ios_deleteContact: - name: Trailblaze Demo - - step: Create a new contact named Trailblaze Demo with the phone number 555-123-4567. - recording: - tools: - - contacts_ios_createContact: - firstName: Trailblaze - lastName: Demo - phoneNumber: "5551234567" - - step: Verify the new "Trailblaze Demo" contact was saved. - recording: - tools: - - assertVisibleBySelector: - reason: >- - Verify the contacts list now shows a 'Trailblaze Demo' row by asserting its - visible text — the same shape sibling passing trails (e.g. - test-open-known-contact) use to assert a contact name is visible. - nodeSelector: - iosMaestro: - textRegex: Trailblaze Demo - - step: Delete the "Trailblaze Demo" contact. - recording: - tools: - - contacts_ios_deleteContact: - name: Trailblaze Demo diff --git a/trails/ios-contacts/test-create-then-delete/trail.yaml b/trails/ios-contacts/test-create-then-delete/trail.yaml new file mode 100644 index 000000000..46ddfea5e --- /dev/null +++ b/trails/ios-contacts/test-create-then-delete/trail.yaml @@ -0,0 +1,38 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - crud + - slow + title: 'Contacts (iOS): Create a contact then delete it' + +trail: + - step: "Remove any leftover \"Trailblaze Demo\" contact so we start from a clean slate." + recording: + ios-iphone: + - contacts_ios_deleteContact: + name: Trailblaze Demo + + - step: "Create a new contact named Trailblaze Demo with the phone number 555-123-4567." + recording: + ios-iphone: + - contacts_ios_createContact: + firstName: Trailblaze + lastName: Demo + phoneNumber: "5551234567" + + - step: "Verify the new \"Trailblaze Demo\" contact was saved." + recording: + ios-iphone: + - assertVisibleBySelector: + reason: Verify the contacts list now shows a 'Trailblaze Demo' row by asserting its visible text — the same shape sibling passing trails (e.g. test-open-known-contact) use to assert a contact name is visible. + nodeSelector: + iosMaestro: + textRegex: Trailblaze Demo + + - step: "Delete the \"Trailblaze Demo\" contact." + recording: + ios-iphone: + - contacts_ios_deleteContact: + name: Trailblaze Demo diff --git a/trails/ios-contacts/test-custom-open-contact/blaze.yaml b/trails/ios-contacts/test-custom-open-contact/blaze.yaml deleted file mode 100644 index 27ff78c7c..000000000 --- a/trails/ios-contacts/test-custom-open-contact/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Demonstrates direct typed dispatch of `contacts_ios_openContact` from a -# trail YAML — maximum determinism short of a recording. -- config: - title: "Contacts (iOS) (scripted): direct open by name" - platform: ios - driver: IOS_HOST - target: contacts - tags: [contact] -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - - step: | - Call the `contacts_ios_openContact` tool with name="John Appleseed", - expectedHeading="John Appleseed". diff --git a/trails/ios-contacts/test-custom-open-contact/ios-iphone.trail.yaml b/trails/ios-contacts/test-custom-open-contact/ios-iphone.trail.yaml deleted file mode 100644 index 7c6f95a50..000000000 --- a/trails/ios-contacts/test-custom-open-contact/ios-iphone.trail.yaml +++ /dev/null @@ -1,20 +0,0 @@ -- config: - title: 'Contacts (iOS) (scripted): direct open by name' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - step: | - Call the `contacts_ios_openContact` tool with name="John Appleseed", - expectedHeading="John Appleseed". - recording: - tools: - - contacts_ios_openContact: - name: John Appleseed - expectedHeading: John Appleseed diff --git a/trails/ios-contacts/test-custom-open-contact/trail.yaml b/trails/ios-contacts/test-custom-open-contact/trail.yaml new file mode 100644 index 000000000..9eb976c0d --- /dev/null +++ b/trails/ios-contacts/test-custom-open-contact/trail.yaml @@ -0,0 +1,24 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - contact + title: 'Contacts (iOS) (scripted): direct open by name' + +trail: + - step: | + Call the `contacts_ios_openApp` tool with dismissKeyboard=true. + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + + - step: | + Call the `contacts_ios_openContact` tool with name="John Appleseed", + expectedHeading="John Appleseed". + recording: + ios-iphone: + - contacts_ios_openContact: + name: John Appleseed + expectedHeading: John Appleseed diff --git a/trails/ios-contacts/test-custom-search/blaze.yaml b/trails/ios-contacts/test-custom-search/blaze.yaml deleted file mode 100644 index 85ce80037..000000000 --- a/trails/ios-contacts/test-custom-search/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Demonstrates `contacts_ios_searchContacts` composed on top of the -# `contacts_ios_openApp` trailhead. -- config: - title: "Contacts (iOS) (scripted): trailhead + search the contacts list" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search] -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - - step: | - Call the `contacts_ios_searchContacts` tool with query="John", - rowText="John Appleseed", openFirstResult=true. diff --git a/trails/ios-contacts/test-custom-search/ios-iphone.trail.yaml b/trails/ios-contacts/test-custom-search/ios-iphone.trail.yaml deleted file mode 100644 index 297a7faee..000000000 --- a/trails/ios-contacts/test-custom-search/ios-iphone.trail.yaml +++ /dev/null @@ -1,21 +0,0 @@ -- config: - title: 'Contacts (iOS) (scripted): trailhead + search the contacts list' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - step: | - Call the `contacts_ios_searchContacts` tool with query="John", - rowText="John Appleseed", openFirstResult=true. - recording: - tools: - - contacts_ios_searchContacts: - query: John - rowText: John Appleseed - openFirstResult: true diff --git a/trails/ios-contacts/test-custom-search/trail.yaml b/trails/ios-contacts/test-custom-search/trail.yaml new file mode 100644 index 000000000..558389131 --- /dev/null +++ b/trails/ios-contacts/test-custom-search/trail.yaml @@ -0,0 +1,25 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + title: 'Contacts (iOS) (scripted): trailhead + search the contacts list' + +trail: + - step: | + Call the `contacts_ios_openApp` tool with dismissKeyboard=true. + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + + - step: | + Call the `contacts_ios_searchContacts` tool with query="John", + rowText="John Appleseed", openFirstResult=true. + recording: + ios-iphone: + - contacts_ios_searchContacts: + query: John + rowText: John Appleseed + openFirstResult: true diff --git a/trails/ios-contacts/test-dismiss-keyboard/blaze.yaml b/trails/ios-contacts/test-dismiss-keyboard/blaze.yaml deleted file mode 100644 index 658a355a2..000000000 --- a/trails/ios-contacts/test-dismiss-keyboard/blaze.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Conditional UI handling: the `contacts_ios_dismissKeyboardIfPresent` tool -# no-ops cleanly when no keyboard is showing, so this trail exercises both -# branches in one run (no-op on cold start, then real dismiss after typing). -- config: - title: "Contacts (iOS): Dismiss keyboard no-ops then dismisses" - platform: ios - driver: IOS_HOST - target: contacts - tags: [nav, flaky] - # iOS 26.4's Contacts dismisses the soft keyboard automatically after typing - # into the search field (verified live 2026-05-22). The trail's step 2 - # assumption ("This leaves the keyboard up with text in the field") doesn't - # hold there, so step 3 finds no keyboard to dismiss and the trail's stated - # outcome can't be reached. The tool itself (`contacts_ios_dismissKeyboardIfPresent`) - # is exercised correctly in both branches by other trails — this composition - # exists for documentation. Skipped until either (a) iOS keyboard behavior - # stabilizes across the runtimes this trailmap targets, or (b) the trail's - # second step is replaced with something that demonstrably leaves the - # keyboard up on iOS 26.4+. - skip: "iOS 26.4 auto-dismisses the keyboard after search input — step 3 has no keyboard to dismiss. Remove `skip:` once we have a reliable keyboard-up state on the target runtime." -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - Expected: returns "no keyboard shown" suffix because the app - just cold-launched. - - step: | - Call the `contacts_ios_searchContacts` tool with query="John", - rowText="John Appleseed", openFirstResult=false. - This leaves the keyboard up with text in the field. - - step: | - Call the `contacts_ios_dismissKeyboardIfPresent` tool. - Expected: returns the "Dismissed iOS keyboard" string because - the keyboard is now visible. diff --git a/trails/ios-contacts/test-dismiss-keyboard/trail.yaml b/trails/ios-contacts/test-dismiss-keyboard/trail.yaml new file mode 100644 index 000000000..6924a2f9f --- /dev/null +++ b/trails/ios-contacts/test-dismiss-keyboard/trail.yaml @@ -0,0 +1,37 @@ +# Conditional UI handling: the `contacts_ios_dismissKeyboardIfPresent` tool +# no-ops cleanly when no keyboard is showing, so this trail exercises both +# branches in one run (no-op on cold start, then real dismiss after typing). +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - nav + - flaky + # iOS 26.4's Contacts dismisses the soft keyboard automatically after typing + # into the search field (verified live 2026-05-22). The trail's step 2 + # assumption ("This leaves the keyboard up with text in the field") doesn't + # hold there, so step 3 finds no keyboard to dismiss and the trail's stated + # outcome can't be reached. The tool itself (`contacts_ios_dismissKeyboardIfPresent`) + # is exercised correctly in both branches by other trails — this composition + # exists for documentation. Skipped until either (a) iOS keyboard behavior + # stabilizes across the runtimes this trailmap targets, or (b) the trail's + # second step is replaced with something that demonstrably leaves the + # keyboard up on iOS 26.4+. + skip: + ios-iphone: "iOS 26.4 auto-dismisses the keyboard after search input — step 3 has no keyboard to dismiss. Remove `skip:` once we have a reliable keyboard-up state on the target runtime." + title: 'Contacts (iOS): Dismiss keyboard no-ops then dismisses' + +trail: + - step: | + Call the `contacts_ios_openApp` tool with dismissKeyboard=true. + Expected: returns "no keyboard shown" suffix because the app + just cold-launched. + - step: | + Call the `contacts_ios_searchContacts` tool with query="John", + rowText="John Appleseed", openFirstResult=false. + This leaves the keyboard up with text in the field. + - step: | + Call the `contacts_ios_dismissKeyboardIfPresent` tool. + Expected: returns the "Dismissed iOS keyboard" string because + the keyboard is now visible. diff --git a/trails/ios-contacts/test-edit-contact-add-phone/blaze.yaml b/trails/ios-contacts/test-edit-contact-add-phone/blaze.yaml deleted file mode 100644 index 96c9f4caf..000000000 --- a/trails/ios-contacts/test-edit-contact-add-phone/blaze.yaml +++ /dev/null @@ -1,21 +0,0 @@ -- config: - title: "Contacts (iOS): Edit a contact to add a phone number" - platform: ios - driver: IOS_HOST - target: contacts - tags: [crud, slow] -- prompts: - - step: | - Call the `contacts_ios_deleteContact` tool with name="Trailblaze Demo" - as a defensive teardown of any prior-run state. - - step: | - Call the `contacts_ios_createContact` tool with firstName="Trailblaze", - lastName="Demo", phoneNumber="". - - step: | - Call the `contacts_ios_addPhoneNumber` tool with name="Trailblaze Demo", - phoneNumber="5557654321". - - step: | - Call the `contacts_ios_verifyContactStructure` tool with - name="Trailblaze Demo", requireFields=["phone"]. - - step: | - Call the `contacts_ios_deleteContact` tool with name="Trailblaze Demo". diff --git a/trails/ios-contacts/test-edit-contact-add-phone/trail.yaml b/trails/ios-contacts/test-edit-contact-add-phone/trail.yaml new file mode 100644 index 000000000..9bd61e602 --- /dev/null +++ b/trails/ios-contacts/test-edit-contact-add-phone/trail.yaml @@ -0,0 +1,24 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - crud + - slow + title: 'Contacts (iOS): Edit a contact to add a phone number' + +trail: + - step: | + Call the `contacts_ios_deleteContact` tool with name="Trailblaze Demo" + as a defensive teardown of any prior-run state. + - step: | + Call the `contacts_ios_createContact` tool with firstName="Trailblaze", + lastName="Demo", phoneNumber="". + - step: | + Call the `contacts_ios_addPhoneNumber` tool with name="Trailblaze Demo", + phoneNumber="5557654321". + - step: | + Call the `contacts_ios_verifyContactStructure` tool with + name="Trailblaze Demo", requireFields=["phone"]. + - step: | + Call the `contacts_ios_deleteContact` tool with name="Trailblaze Demo". diff --git a/trails/ios-contacts/test-empty-state/blaze.yaml b/trails/ios-contacts/test-empty-state/blaze.yaml deleted file mode 100644 index 0b254ac0f..000000000 --- a/trails/ios-contacts/test-empty-state/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Contacts navbar visible on cold start" - platform: ios - driver: IOS_HOST - target: contacts - tags: [smoke] -- prompts: - - step: Launch the iOS Contacts app from a cold start. Verify the navbar shows "Contacts" and the list root surface is visible. Do not tap anything. diff --git a/trails/ios-contacts/test-empty-state/ios-iphone.trail.yaml b/trails/ios-contacts/test-empty-state/ios-iphone.trail.yaml deleted file mode 100644 index ff36dcf46..000000000 --- a/trails/ios-contacts/test-empty-state/ios-iphone.trail.yaml +++ /dev/null @@ -1,23 +0,0 @@ -- config: - title: 'Contacts (iOS): Contacts navbar visible on cold start' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Launch the iOS Contacts app from a cold start. Verify the navbar shows "Contacts" and the list root surface is visible. Do not tap anything. - recording: - tools: - - launchApp: - appId: com.apple.MobileAddressBook - launchMode: FORCE_RESTART - reasoning: Launch Contacts from a cold start by its bundle identifier so the trail begins from the contacts list root regardless of prior state. Uses only framework primitives (no scripted tool) so this trail can join the pure-replay smoke set. - - assertVisibleBySelector: - reason: Verify the contacts list root rendered after the cold start by asserting the "Contacts" navbar title is visible. No row is tapped, matching the objective. - nodeSelector: - iosMaestro: - textRegex: Contacts - - assertVisibleBySelector: - reason: Also assert the list-only "Add" button is visible to confirm this is the contacts list root specifically (the text "Contacts" alone is not list-root-unique — a contact detail screen's back button also carries the label "Contacts"). The list root shows "Add"; a detail screen shows "Edit" there instead. assertVisibleBySelector is a framework primitive, so this keeps the trail scripted-tool-free for the pure-replay smoke set. - nodeSelector: - iosMaestro: - accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-empty-state/trail.yaml b/trails/ios-contacts/test-empty-state/trail.yaml new file mode 100644 index 000000000..91a624e8a --- /dev/null +++ b/trails/ios-contacts/test-empty-state/trail.yaml @@ -0,0 +1,26 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - smoke + title: 'Contacts (iOS): Contacts navbar visible on cold start' + +trail: + - step: "Launch the iOS Contacts app from a cold start. Verify the navbar shows \"Contacts\" and the list root surface is visible. Do not tap anything." + recording: + ios-iphone: + - launchApp: + appId: com.apple.MobileAddressBook + launchMode: FORCE_RESTART + reasoning: Launch Contacts from a cold start by its bundle identifier so the trail begins from the contacts list root regardless of prior state. Uses only framework primitives (no scripted tool) so this trail can join the pure-replay smoke set. + - assertVisibleBySelector: + reason: Verify the contacts list root rendered after the cold start by asserting the "Contacts" navbar title is visible. No row is tapped, matching the objective. + nodeSelector: + iosMaestro: + textRegex: Contacts + - assertVisibleBySelector: + reason: Also assert the list-only "Add" button is visible to confirm this is the contacts list root specifically (the text "Contacts" alone is not list-root-unique — a contact detail screen's back button also carries the label "Contacts"). The list root shows "Add"; a detail screen shows "Edit" there instead. assertVisibleBySelector is a framework primitive, so this keeps the trail scripted-tool-free for the pure-replay smoke set. + nodeSelector: + iosMaestro: + accessibilityTextRegex: Add diff --git a/trails/ios-contacts/test-open-known-contact/blaze.yaml b/trails/ios-contacts/test-open-known-contact/blaze.yaml deleted file mode 100644 index c3eaf681e..000000000 --- a/trails/ios-contacts/test-open-known-contact/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Open a known contact by name" - platform: ios - driver: IOS_HOST - target: contacts - tags: [smoke, contact] -- prompts: - - step: Open the "John Appleseed" contact in Contacts and verify its detail screen renders the name "John Appleseed". diff --git a/trails/ios-contacts/test-open-known-contact/ios-iphone.trail.yaml b/trails/ios-contacts/test-open-known-contact/ios-iphone.trail.yaml deleted file mode 100644 index 16c1a1f06..000000000 --- a/trails/ios-contacts/test-open-known-contact/ios-iphone.trail.yaml +++ /dev/null @@ -1,27 +0,0 @@ -- config: - title: 'Contacts (iOS): Open a known contact by name' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the "John Appleseed" contact in Contacts and verify its detail screen renders the name "John Appleseed". - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - tapOnElementBySelector: - reason: '''John Appleseed'' is visible in the contacts list. Tapping this entry will open its detail screen as required by the objective.' - nodeSelector: - iosMaestro: - textRegex: John Appleseed - index: 1 - - assertVisibleBySelector: - reason: Verify the contact detail screen actually opened by asserting its detail-only "Edit" button (top-right of the contact card) is visible. The contacts list root shows an "Add" button here instead of "Edit", so this proves the tap navigated to the detail screen rather than staying on the list — which a bare "John Appleseed" name check could not, since the name is also visible as a list row before the tap. - nodeSelector: - iosMaestro: - accessibilityTextRegex: Edit - - assertVisibleBySelector: - reason: Verify the detail screen rendered the correct contact name by asserting "John Appleseed" is visible. No index is pinned because the name renders as a single title node on the detail screen, and no version-specific identifier is used (the old ContactCardHeaderView id is iOS-26-only). - nodeSelector: - iosMaestro: - textRegex: John Appleseed diff --git a/trails/ios-contacts/test-open-known-contact/trail.yaml b/trails/ios-contacts/test-open-known-contact/trail.yaml new file mode 100644 index 000000000..231c47bc1 --- /dev/null +++ b/trails/ios-contacts/test-open-known-contact/trail.yaml @@ -0,0 +1,31 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - smoke + - contact + title: 'Contacts (iOS): Open a known contact by name' + +trail: + - step: "Open the \"John Appleseed\" contact in Contacts and verify its detail screen renders the name \"John Appleseed\"." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - tapOnElementBySelector: + reason: '''John Appleseed'' is visible in the contacts list. Tapping this entry will open its detail screen as required by the objective.' + nodeSelector: + iosMaestro: + textRegex: John Appleseed + index: 1 + - assertVisibleBySelector: + reason: Verify the contact detail screen actually opened by asserting its detail-only "Edit" button (top-right of the contact card) is visible. The contacts list root shows an "Add" button here instead of "Edit", so this proves the tap navigated to the detail screen rather than staying on the list — which a bare "John Appleseed" name check could not, since the name is also visible as a list row before the tap. + nodeSelector: + iosMaestro: + accessibilityTextRegex: Edit + - assertVisibleBySelector: + reason: Verify the detail screen rendered the correct contact name by asserting "John Appleseed" is visible. No index is pinned because the name renders as a single title node on the detail screen, and no version-specific identifier is used (the old ContactCardHeaderView id is iOS-26-only). + nodeSelector: + iosMaestro: + textRegex: John Appleseed diff --git a/trails/ios-contacts/test-search-autocomplete/blaze.yaml b/trails/ios-contacts/test-search-autocomplete/blaze.yaml deleted file mode 100644 index d60ab2059..000000000 --- a/trails/ios-contacts/test-search-autocomplete/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- config: - title: "Contacts (iOS): Autocomplete suggestions visible while typing" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search, flaky] - # The inline suggestion list races simulator boot and per-keystroke debounce. - # Without a deterministic "suggestions are now visible" signal we get - # intermittent false negatives on slower simulators. Skipped until we either - # (a) extend `contacts_ios_searchContacts` to expose an inline-suggestions - # probe, or (b) settle on a structural anchor we trust. - skip: "Suggestion popup races simulator boot — remove `skip:` once the inline-suggestions probe lands." -- prompts: - - step: Open Contacts and start typing "Joh" into the search field without tapping a row. Verify an inline suggestion containing "John Appleseed" appears. diff --git a/trails/ios-contacts/test-search-autocomplete/trail.yaml b/trails/ios-contacts/test-search-autocomplete/trail.yaml new file mode 100644 index 000000000..b4f346fc0 --- /dev/null +++ b/trails/ios-contacts/test-search-autocomplete/trail.yaml @@ -0,0 +1,18 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + - flaky + # The inline suggestion list races simulator boot and per-keystroke debounce. + # Without a deterministic "suggestions are now visible" signal we get + # intermittent false negatives on slower simulators. Skipped until we either + # (a) extend `contacts_ios_searchContacts` to expose an inline-suggestions + # probe, or (b) settle on a structural anchor we trust. + skip: + ios-iphone: "Suggestion popup races simulator boot — remove `skip:` once the inline-suggestions probe lands." + title: 'Contacts (iOS): Autocomplete suggestions visible while typing' + +trail: + - step: Open Contacts and start typing "Joh" into the search field without tapping a row. Verify an inline suggestion containing "John Appleseed" appears. diff --git a/trails/ios-contacts/test-search-by-first-name/blaze.yaml b/trails/ios-contacts/test-search-by-first-name/blaze.yaml deleted file mode 100644 index b2a218fa9..000000000 --- a/trails/ios-contacts/test-search-by-first-name/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Search by first name" - platform: ios - driver: IOS_HOST - target: contacts - tags: [smoke, search] -- prompts: - - step: Open the iOS Contacts app, search Contacts for "John", and open the resulting "John Appleseed" contact. Verify its detail screen renders the name "John Appleseed". diff --git a/trails/ios-contacts/test-search-by-first-name/ios-iphone.trail.yaml b/trails/ios-contacts/test-search-by-first-name/ios-iphone.trail.yaml deleted file mode 100644 index 5dd31f4be..000000000 --- a/trails/ios-contacts/test-search-by-first-name/ios-iphone.trail.yaml +++ /dev/null @@ -1,27 +0,0 @@ -- config: - title: 'Contacts (iOS): Search by first name' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the iOS Contacts app, search Contacts for "John", and open the resulting "John Appleseed" contact. Verify its detail screen renders the name "John Appleseed". - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - tapOnElementBySelector: - reason: The contact 'John Appleseed' is already visible in the contacts list. Tapping on this contact will open its detail screen, which is the next step in the objective. - nodeSelector: - iosMaestro: - textRegex: John Appleseed - index: 1 - - assertVisibleBySelector: - reason: Guard that the tap actually navigated to the contact detail card rather than staying on the list (where the same "John Appleseed" row is still visible). "Contact Photo & Poster" is a row unique to a contact's detail screen and is present across the iOS runtimes these trails target, unlike the iOS-26-only ContactCardHeaderView identifier. - nodeSelector: - iosMaestro: - accessibilityTextRegex: Contact Photo & Poster - - assertVisibleBySelector: - reason: Verify the detail screen renders the contact name. The "John Appleseed" title is visible at the top of the card; no index is pinned because the name renders as a single title node here. - nodeSelector: - iosMaestro: - textRegex: John Appleseed diff --git a/trails/ios-contacts/test-search-by-first-name/trail.yaml b/trails/ios-contacts/test-search-by-first-name/trail.yaml new file mode 100644 index 000000000..cc038c16c --- /dev/null +++ b/trails/ios-contacts/test-search-by-first-name/trail.yaml @@ -0,0 +1,31 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - smoke + - search + title: 'Contacts (iOS): Search by first name' + +trail: + - step: "Open the iOS Contacts app, search Contacts for \"John\", and open the resulting \"John Appleseed\" contact. Verify its detail screen renders the name \"John Appleseed\"." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - tapOnElementBySelector: + reason: The contact 'John Appleseed' is already visible in the contacts list. Tapping on this contact will open its detail screen, which is the next step in the objective. + nodeSelector: + iosMaestro: + textRegex: John Appleseed + index: 1 + - assertVisibleBySelector: + reason: Guard that the tap actually navigated to the contact detail card rather than staying on the list (where the same "John Appleseed" row is still visible). "Contact Photo & Poster" is a row unique to a contact's detail screen and is present across the iOS runtimes these trails target, unlike the iOS-26-only ContactCardHeaderView identifier. + nodeSelector: + iosMaestro: + accessibilityTextRegex: Contact Photo & Poster + - assertVisibleBySelector: + reason: Verify the detail screen renders the contact name. The "John Appleseed" title is visible at the top of the card; no index is pinned because the name renders as a single title node here. + nodeSelector: + iosMaestro: + textRegex: John Appleseed diff --git a/trails/ios-contacts/test-search-by-last-name/blaze.yaml b/trails/ios-contacts/test-search-by-last-name/blaze.yaml deleted file mode 100644 index ae8aaad7f..000000000 --- a/trails/ios-contacts/test-search-by-last-name/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Search by last name" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search] -- prompts: - - step: Open Contacts and search for "Appleseed" — the last word of the seeded "John Appleseed" contact. Open the resulting contact and verify the detail screen shows "John Appleseed". diff --git a/trails/ios-contacts/test-search-by-last-name/ios-iphone.trail.yaml b/trails/ios-contacts/test-search-by-last-name/ios-iphone.trail.yaml deleted file mode 100644 index 6424d4d1a..000000000 --- a/trails/ios-contacts/test-search-by-last-name/ios-iphone.trail.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- config: - title: 'Contacts (iOS): Search by last name' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open Contacts and search for "Appleseed" — the last word of the seeded "John Appleseed" contact. Open the resulting contact and verify the detail screen shows "John Appleseed". - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - contacts_ios_searchAndVerify: - query: Appleseed - expectedName: John Appleseed diff --git a/trails/ios-contacts/test-search-by-last-name/trail.yaml b/trails/ios-contacts/test-search-by-last-name/trail.yaml new file mode 100644 index 000000000..7eb6ebc5d --- /dev/null +++ b/trails/ios-contacts/test-search-by-last-name/trail.yaml @@ -0,0 +1,17 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + title: 'Contacts (iOS): Search by last name' + +trail: + - step: "Open Contacts and search for \"Appleseed\" — the last word of the seeded \"John Appleseed\" contact. Open the resulting contact and verify the detail screen shows \"John Appleseed\"." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - contacts_ios_searchAndVerify: + query: Appleseed + expectedName: John Appleseed diff --git a/trails/ios-contacts/test-search-multi-contact/blaze.yaml b/trails/ios-contacts/test-search-multi-contact/trail.yaml similarity index 56% rename from trails/ios-contacts/test-search-multi-contact/blaze.yaml rename to trails/ios-contacts/test-search-multi-contact/trail.yaml index a92ed146f..a5e734614 100644 --- a/trails/ios-contacts/test-search-multi-contact/blaze.yaml +++ b/trails/ios-contacts/test-search-multi-contact/trail.yaml @@ -13,21 +13,25 @@ # keeps each case independently retriable and easy to triage when it # fails. # * Adding a new contact is a 3-line diff. Removing one is a 3-line diff. -- config: - title: "Contacts (iOS) (data-driven): search + verify across contacts" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search, contact, slow] -- prompts: - - step: | - Call the `contacts_ios_openApp` tool with dismissKeyboard=true. - - step: | - Call the `contacts_ios_searchAndVerify` tool with query="John", - expectedName="John Appleseed", requireFields=[]. - - step: | - Call the `contacts_ios_searchAndVerify` tool with query="Kate", - expectedName="Kate Bell", requireFields=[]. - - step: | - Call the `contacts_ios_searchAndVerify` tool with query="Anna", - expectedName="Anna Haro", requireFields=[]. +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + - contact + - slow + title: 'Contacts (iOS) (data-driven): search + verify across contacts' + +trail: + - step: | + Call the `contacts_ios_openApp` tool with dismissKeyboard=true. + - step: | + Call the `contacts_ios_searchAndVerify` tool with query="John", + expectedName="John Appleseed", requireFields=[]. + - step: | + Call the `contacts_ios_searchAndVerify` tool with query="Kate", + expectedName="Kate Bell", requireFields=[]. + - step: | + Call the `contacts_ios_searchAndVerify` tool with query="Anna", + expectedName="Anna Haro", requireFields=[]. diff --git a/trails/ios-contacts/test-search-no-results/blaze.yaml b/trails/ios-contacts/test-search-no-results/blaze.yaml deleted file mode 100644 index 965430450..000000000 --- a/trails/ios-contacts/test-search-no-results/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Contacts (iOS): Search query that returns no results" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search] -- prompts: - - step: Open Contacts and search for "ZzZzNoSuchContact". Verify "No Results" is visible. Do not tap any rows. diff --git a/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml b/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml deleted file mode 100644 index e3eeafc3c..000000000 --- a/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -- config: - title: 'Contacts (iOS): Search query that returns no results' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open Contacts and search for "ZzZzNoSuchContact". Verify "No Results" is visible. Do not tap any rows. - recording: - tools: - - launchApp: - appId: com.apple.MobileAddressBook - launchMode: FORCE_RESTART - reasoning: The objective is to open Contacts. Launch it from a cold start by its bundle identifier so the trail begins from the contacts list root regardless of what state a prior run (or a pre-launch) left the app in. - - tapOnElementBySelector: - reason: The next step is to search for the contact 'ZzZzNoSuchContact'. The search field is visible at the bottom of the screen and should be tapped to focus it before entering text. - nodeSelector: - iosMaestro: - hintTextRegex: Search - - inputText: - text: ZzZzNoSuchContact - reasoning: The search field is now focused, so the next step is to enter 'ZzZzNoSuchContact' to search for this contact. This will allow us to proceed to verifying that 'No Results' is visible. - - assertVisibleBySelector: - reason: The text 'No Results for “ZzZzNoSuchContact”' is visible as expected after searching for the nonexistent contact. Verifying its visibility confirms the objective is met. - nodeSelector: - iosMaestro: - accessibilityTextRegex: No Results for “ZzZzNoSuchContact” diff --git a/trails/ios-contacts/test-search-no-results/trail.yaml b/trails/ios-contacts/test-search-no-results/trail.yaml new file mode 100644 index 000000000..922297495 --- /dev/null +++ b/trails/ios-contacts/test-search-no-results/trail.yaml @@ -0,0 +1,29 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + title: 'Contacts (iOS): Search query that returns no results' + +trail: + - step: "Open Contacts and search for \"ZzZzNoSuchContact\". Verify \"No Results\" is visible. Do not tap any rows." + recording: + ios-iphone: + - launchApp: + appId: com.apple.MobileAddressBook + launchMode: FORCE_RESTART + reasoning: The objective is to open Contacts. Launch it from a cold start by its bundle identifier so the trail begins from the contacts list root regardless of what state a prior run (or a pre-launch) left the app in. + - tapOnElementBySelector: + reason: The next step is to search for the contact 'ZzZzNoSuchContact'. The search field is visible at the bottom of the screen and should be tapped to focus it before entering text. + nodeSelector: + iosMaestro: + hintTextRegex: Search + - inputText: + text: ZzZzNoSuchContact + reasoning: The search field is now focused, so the next step is to enter 'ZzZzNoSuchContact' to search for this contact. This will allow us to proceed to verifying that 'No Results' is visible. + - assertVisibleBySelector: + reason: The text 'No Results for “ZzZzNoSuchContact”' is visible as expected after searching for the nonexistent contact. Verifying its visibility confirms the objective is met. + nodeSelector: + iosMaestro: + accessibilityTextRegex: No Results for “ZzZzNoSuchContact” diff --git a/trails/ios-contacts/test-search-type-only/blaze.yaml b/trails/ios-contacts/test-search-type-only/blaze.yaml deleted file mode 100644 index 71d0e5f22..000000000 --- a/trails/ios-contacts/test-search-type-only/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Exercises the `openFirstResult: false` branch of `contacts_ios_searchContacts`. -# The tool stops after typing, leaving the search active so the agent can verify -# the inline suggestion list without committing to a tap. -- config: - title: "Contacts (iOS): Search field active after typing, no result tapped" - platform: ios - driver: IOS_HOST - target: contacts - tags: [search] -- prompts: - - step: | - Call the `contacts_ios_searchContacts` tool with query="John", - rowText="John Appleseed", openFirstResult=false. - - step: Verify "John Appleseed" appears as a search suggestion row without opening it. diff --git a/trails/ios-contacts/test-search-type-only/ios-iphone.trail.yaml b/trails/ios-contacts/test-search-type-only/ios-iphone.trail.yaml deleted file mode 100644 index ce4d9fe20..000000000 --- a/trails/ios-contacts/test-search-type-only/ios-iphone.trail.yaml +++ /dev/null @@ -1,23 +0,0 @@ -- config: - title: 'Contacts (iOS): Search field active after typing, no result tapped' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: | - Call the `contacts_ios_searchContacts` tool with query="John", - rowText="John Appleseed", openFirstResult=false. - recording: - tools: - - contacts_ios_searchContacts: - query: John - rowText: John Appleseed - openFirstResult: false - - step: Verify "John Appleseed" appears as a search suggestion row without opening it. - recording: - tools: - - assertVisibleBySelector: - reason: After typing "John" the search suggestion list shows the "John Appleseed" row. Assert its name is visible without tapping it, fulfilling the objective. - nodeSelector: - iosMaestro: - accessibilityTextRegex: John Appleseed diff --git a/trails/ios-contacts/test-search-type-only/trail.yaml b/trails/ios-contacts/test-search-type-only/trail.yaml new file mode 100644 index 000000000..8b19b5db0 --- /dev/null +++ b/trails/ios-contacts/test-search-type-only/trail.yaml @@ -0,0 +1,27 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - search + title: 'Contacts (iOS): Search field active after typing, no result tapped' + +trail: + - step: | + Call the `contacts_ios_searchContacts` tool with query="John", + rowText="John Appleseed", openFirstResult=false. + recording: + ios-iphone: + - contacts_ios_searchContacts: + query: John + rowText: John Appleseed + openFirstResult: false + + - step: "Verify \"John Appleseed\" appears as a search suggestion row without opening it." + recording: + ios-iphone: + - assertVisibleBySelector: + reason: After typing "John" the search suggestion list shows the "John Appleseed" row. Assert its name is visible without tapping it, fulfilling the objective. + nodeSelector: + iosMaestro: + accessibilityTextRegex: John Appleseed diff --git a/trails/ios-contacts/test-verify-contact-fields/blaze.yaml b/trails/ios-contacts/test-verify-contact-fields/blaze.yaml deleted file mode 100644 index e57ac8585..000000000 --- a/trails/ios-contacts/test-verify-contact-fields/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Branch coverage: exercises `contacts_ios_verifyContactStructure` with a -# non-empty `requireFields` list. Pair with `test-verify-contact-no-extras` -# (empty `requireFields`) for full branch coverage of that tool. -- config: - title: "Contacts (iOS): Verify a contact has required fields" - platform: ios - driver: IOS_HOST - target: contacts - tags: [contact, slow] -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - - step: | - Call the `contacts_ios_verifyContactStructure` tool with - name="John Appleseed", requireFields=["phone"]. diff --git a/trails/ios-contacts/test-verify-contact-fields/ios-iphone.trail.yaml b/trails/ios-contacts/test-verify-contact-fields/ios-iphone.trail.yaml deleted file mode 100644 index fb354c105..000000000 --- a/trails/ios-contacts/test-verify-contact-fields/ios-iphone.trail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -- config: - title: 'Contacts (iOS): Verify a contact has required fields' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - tapOnElementBySelector: - reason: The objective is to open the 'John Appleseed' contact, which is visible in the list. Tapping on it will display its details. - nodeSelector: - iosMaestro: - textRegex: John Appleseed - index: 1 - - step: | - Call the `contacts_ios_verifyContactStructure` tool with - name="John Appleseed", requireFields=["mobile"]. - recording: - tools: - - contacts_ios_verifyContactStructure: - name: John Appleseed - requireFields: - - mobile diff --git a/trails/ios-contacts/test-verify-contact-fields/trail.yaml b/trails/ios-contacts/test-verify-contact-fields/trail.yaml new file mode 100644 index 000000000..248b0bf62 --- /dev/null +++ b/trails/ios-contacts/test-verify-contact-fields/trail.yaml @@ -0,0 +1,34 @@ +# Branch coverage: exercises `contacts_ios_verifyContactStructure` with a +# non-empty `requireFields` list. Pair with `test-verify-contact-no-extras` +# (empty `requireFields`) for full branch coverage of that tool. +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - contact + - slow + title: 'Contacts (iOS): Verify a contact has required fields' + +trail: + - step: "Open the \"John Appleseed\" contact in Contacts." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - tapOnElementBySelector: + reason: The objective is to open the 'John Appleseed' contact, which is visible in the list. Tapping on it will display its details. + nodeSelector: + iosMaestro: + textRegex: John Appleseed + index: 1 + + - step: | + Call the `contacts_ios_verifyContactStructure` tool with + name="John Appleseed", requireFields=["mobile"]. + recording: + ios-iphone: + - contacts_ios_verifyContactStructure: + name: John Appleseed + requireFields: + - mobile diff --git a/trails/ios-contacts/test-verify-contact-no-extras/blaze.yaml b/trails/ios-contacts/test-verify-contact-no-extras/blaze.yaml deleted file mode 100644 index 45e31ac45..000000000 --- a/trails/ios-contacts/test-verify-contact-no-extras/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Branch coverage: exercises `contacts_ios_verifyContactStructure` with an -# empty `requireFields` list — the lightweight "did the detail screen render?" -# path. Pair with `test-verify-contact-fields` (non-empty list) for full -# branch coverage of that tool. -- config: - title: "Contacts (iOS): Verify a contact renders with no field requirements" - platform: ios - driver: IOS_HOST - target: contacts - tags: [contact] -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - - step: | - Call the `contacts_ios_verifyContactStructure` tool with - name="John Appleseed", requireFields=[]. diff --git a/trails/ios-contacts/test-verify-contact-no-extras/ios-iphone.trail.yaml b/trails/ios-contacts/test-verify-contact-no-extras/ios-iphone.trail.yaml deleted file mode 100644 index 29dff9d3a..000000000 --- a/trails/ios-contacts/test-verify-contact-no-extras/ios-iphone.trail.yaml +++ /dev/null @@ -1,25 +0,0 @@ -- config: - title: 'Contacts (iOS): Verify a contact renders with no field requirements' - target: contacts - platform: ios - driver: IOS_HOST -- prompts: - - step: Open the "John Appleseed" contact in Contacts. - recording: - tools: - - contacts_ios_openApp: - dismissKeyboard: true - - tapOnElementBySelector: - reason: The 'John Appleseed' contact is now visible in the contact list. Tapping it will open the desired contact as required by the objective. - nodeSelector: - iosMaestro: - textRegex: John Appleseed - index: 1 - - step: | - Call the `contacts_ios_verifyContactStructure` tool with - name="John Appleseed", requireFields=[]. - recording: - tools: - - contacts_ios_verifyContactStructure: - name: John Appleseed - requireFields: [] diff --git a/trails/ios-contacts/test-verify-contact-no-extras/trail.yaml b/trails/ios-contacts/test-verify-contact-no-extras/trail.yaml new file mode 100644 index 000000000..74bb2487d --- /dev/null +++ b/trails/ios-contacts/test-verify-contact-no-extras/trail.yaml @@ -0,0 +1,29 @@ +config: + target: contacts + devices: + ios-iphone: IOS_HOST + tags: + - contact + title: 'Contacts (iOS): Verify a contact renders with no field requirements' + +trail: + - step: "Open the \"John Appleseed\" contact in Contacts." + recording: + ios-iphone: + - contacts_ios_openApp: + dismissKeyboard: true + - tapOnElementBySelector: + reason: The 'John Appleseed' contact is now visible in the contact list. Tapping it will open the desired contact as required by the objective. + nodeSelector: + iosMaestro: + textRegex: John Appleseed + index: 1 + + - step: | + Call the `contacts_ios_verifyContactStructure` tool with + name="John Appleseed", requireFields=[]. + recording: + ios-iphone: + - contacts_ios_verifyContactStructure: + name: John Appleseed + requireFields: [] diff --git a/trails/playwright-electron/test-counter/blaze.yaml b/trails/playwright-electron/test-counter/blaze.yaml deleted file mode 100644 index ad3f23781..000000000 --- a/trails/playwright-electron/test-counter/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Counter test for the Electron driver. -# Unlike the playwright-native version, there is no initial navigate step — -# the Electron app pre-loads index.html via main.js. -- config: - title: "Electron Counter Test" - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Click on the "Counter" navigation link - - step: Verify the counter value shows "0" - - step: Click the "Increment" button (the + button) three times - - step: Verify the counter value shows "3" - - step: Click the "Decrement" button (the - button) once - - step: Verify the counter value shows "2" - - step: Click the "Reset" button - - step: Verify the counter value shows "0" diff --git a/trails/playwright-electron/test-counter/trail.yaml b/trails/playwright-electron/test-counter/trail.yaml new file mode 100644 index 000000000..80abc9577 --- /dev/null +++ b/trails/playwright-electron/test-counter/trail.yaml @@ -0,0 +1,85 @@ +config: + devices: + web: PLAYWRIGHT_ELECTRON + title: Electron Counter Test + +trail: + - step: "Click on the \"Counter\" navigation link" + recording: + web: + - web_click: + ref: e5 + reasoning: The objective is to click on the "Counter" navigation link. The view hierarchy shows the link with element ID e5, so I will click it to complete the task. + nodeSelector: + web: + cssSelector: "#nav-counter" + + - step: "Verify the counter value shows \"0\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 0' + reasoning: 'The objective requires verifying that the counter value displays ''0''. The paragraph element [e9] clearly shows ''Current value: 0'', which matches the expected format. This verification directly addresses the objective.' + + - step: "Click the \"Increment\" button (the + button) three times" + recording: + web: + - web_click: + ref: e11 + reasoning: I need to click the 'Increment' (+) button three times. I will start by clicking it once and then repeat the action two more times. + nodeSelector: + web: + cssSelector: "#increment-btn" + - web_click: + ref: e11 + reasoning: The objective requires clicking the Increment (+) button three times. One click has already been made, and the counter increased to 1. I will click the button a second time to continue toward the required three total clicks. + nodeSelector: + web: + cssSelector: "#increment-btn" + - web_click: + ref: e11 + reasoning: 'The objective is to click the Increment (+) button three times. I have clicked it twice; the counter shows ''Current value: 2''. I need to click it once more to complete the objective.' + nodeSelector: + web: + cssSelector: "#increment-btn" + + - step: "Verify the counter value shows \"3\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 3' + reasoning: 'The objective requires verifying that the counter value shows ''3''. The paragraph element [e9] contains the text "Current value: 3" as indicated in the view hierarchy. Verifying that this text is visible will confirm that the objective has been met.' + + - step: "Click the \"Decrement\" button (the - button) once" + recording: + web: + - web_click: + ref: e10 + reasoning: The objective is to click the 'Decrement' button (the - button) once. The element with ID e10 corresponds to the 'Decrement' button. + nodeSelector: + web: + cssSelector: "#decrement-btn" + + - step: "Verify the counter value shows \"2\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 2' + reasoning: 'The objective is to verify that the counter value shows ''2''. The screen shows a paragraph with the text ''Current value: 2'', so verifying this text will directly satisfy the objective.' + + - step: "Click the \"Reset\" button" + recording: + web: + - web_click: + ref: e12 + reasoning: The objective is to click the 'Reset' button. The button is present as [e12]. This action should fulfill the current objective. + nodeSelector: + web: + cssSelector: "#reset-btn" + + - step: "Verify the counter value shows \"0\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 0' + reasoning: 'The objective requires verifying that the counter value is ''0''. The paragraph element [e9] displays the text ''Current value: 0'', so I will verify this text is visible on the screen.' diff --git a/trails/playwright-electron/test-counter/web.trail.yaml b/trails/playwright-electron/test-counter/web.trail.yaml deleted file mode 100644 index af0d94e2a..000000000 --- a/trails/playwright-electron/test-counter/web.trail.yaml +++ /dev/null @@ -1,83 +0,0 @@ -- config: - title: Electron Counter Test - platform: web - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Click on the "Counter" navigation link - recording: - tools: - - web_click: - ref: e5 - element: link "Counter" - reasoning: The objective is to click on the "Counter" navigation link. The view hierarchy shows the link with element ID e5, so I will click it to complete the task. - nodeSelector: - web: - cssSelector: "#nav-counter" - - step: Verify the counter value shows "0" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 0' - reasoning: 'The objective requires verifying that the counter value displays ''0''. The paragraph element [e9] clearly shows ''Current value: 0'', which matches the expected format. This verification directly addresses the objective.' - - step: Click the "Increment" button (the + button) three times - recording: - tools: - - web_click: - ref: e11 - element: button Increment - reasoning: I need to click the 'Increment' (+) button three times. I will start by clicking it once and then repeat the action two more times. - nodeSelector: - web: - cssSelector: "#increment-btn" - - web_click: - ref: e11 - element: button Increment - reasoning: The objective requires clicking the Increment (+) button three times. One click has already been made, and the counter increased to 1. I will click the button a second time to continue toward the required three total clicks. - nodeSelector: - web: - cssSelector: "#increment-btn" - - web_click: - ref: e11 - element: button Increment - reasoning: 'The objective is to click the Increment (+) button three times. I have clicked it twice; the counter shows ''Current value: 2''. I need to click it once more to complete the objective.' - nodeSelector: - web: - cssSelector: "#increment-btn" - - step: Verify the counter value shows "3" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 3' - reasoning: 'The objective requires verifying that the counter value shows ''3''. The paragraph element [e9] contains the text "Current value: 3" as indicated in the view hierarchy. Verifying that this text is visible will confirm that the objective has been met.' - - step: Click the "Decrement" button (the - button) once - recording: - tools: - - web_click: - ref: e10 - element: button "Decrement" - reasoning: The objective is to click the 'Decrement' button (the - button) once. The element with ID e10 corresponds to the 'Decrement' button. - nodeSelector: - web: - cssSelector: "#decrement-btn" - - step: Verify the counter value shows "2" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 2' - reasoning: 'The objective is to verify that the counter value shows ''2''. The screen shows a paragraph with the text ''Current value: 2'', so verifying this text will directly satisfy the objective.' - - step: Click the "Reset" button - recording: - tools: - - web_click: - ref: e12 - element: button "Reset" - reasoning: The objective is to click the 'Reset' button. The button is present as [e12]. This action should fulfill the current objective. - nodeSelector: - web: - cssSelector: "#reset-btn" - - step: Verify the counter value shows "0" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 0' - reasoning: 'The objective requires verifying that the counter value is ''0''. The paragraph element [e9] displays the text ''Current value: 0'', so I will verify this text is visible on the screen.' diff --git a/trails/playwright-electron/test-form-interaction/blaze.yaml b/trails/playwright-electron/test-form-interaction/blaze.yaml deleted file mode 100644 index bcbb92842..000000000 --- a/trails/playwright-electron/test-form-interaction/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Form interaction test for the Electron driver. -# No initial navigate step — the Electron app pre-loads index.html via main.js. -- config: - title: "Electron Form Interaction Test" - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Click on the "Form" navigation link - - step: Type "Trail Blaze" into the Name input field - - step: Type "trail@example.com" into the Email input field - - step: Select "Feedback" from the Category dropdown - - step: Type "Trailblaze is great" into the Message textarea - - step: Click the "Submit" button - - step: Verify the text "Form submitted!" is visible - - step: Verify the text "Trail Blaze" is visible in the form summary diff --git a/trails/playwright-electron/test-form-interaction/trail.yaml b/trails/playwright-electron/test-form-interaction/trail.yaml new file mode 100644 index 000000000..d215669fa --- /dev/null +++ b/trails/playwright-electron/test-form-interaction/trail.yaml @@ -0,0 +1,101 @@ +config: + devices: + web: PLAYWRIGHT_ELECTRON + title: Electron Form Interaction Test + +trail: + - step: "Click on the \"Form\" navigation link" + recording: + web: + - web_click: + ref: e4 + reasoning: The current objective is to click the 'Form' navigation link. The element [e4] is labeled as 'Form', which matches the objective. + nodeSelector: + web: + cssSelector: "#nav-form" + + - step: "Type \"Trail Blaze\" into the Name input field" + recording: + web: + - web_type: + text: Trail Blaze + ref: e9 + reasoning: The objective is to type 'Trail Blaze' into the Name input field. The element [e9] is labeled 'Name', which matches the required input field. + nodeSelector: + web: + cssSelector: "#name-input" + - web_verifyValue: + ref: e9 + type: VALUE + expected: Trail Blaze + reasoning: The objective requires confirming that 'Trail Blaze' has been entered in the Name input. Verifying textbox 'Name' has the correct value ensures the task was fully completed. + nodeSelector: + web: + cssSelector: "#name-input" + + - step: "Type \"trail@example.com\" into the Email input field" + recording: + web: + - web_type: + text: trail@example.com + ref: e10 + reasoning: The objective is to type 'trail@example.com' into the Email input field. The element [e10] represents the Email textbox, making it the correct target for this action. + nodeSelector: + web: + cssSelector: "#email-input" + + - step: "Select \"Feedback\" from the Category dropdown" + recording: + web: + - web_selectOption: + ref: e11 + values: + - Feedback + reasoning: To fulfill the objective, I need to select the 'Feedback' option from the 'Category' dropdown (combobox). This action will select the correct item as required. + nodeSelector: + web: + cssSelector: "#category-select" + - web_verifyValue: + ref: e11 + type: VALUE + expected: feedback + reasoning: The objective requires that 'Feedback' is selected from the Category dropdown. Verifying that the value of the Category combobox matches the option value 'feedback' ensures successful completion. + nodeSelector: + web: + cssSelector: "#category-select" + + - step: "Type \"Trailblaze is great\" into the Message textarea" + recording: + web: + - web_type: + text: Trailblaze is great + ref: e16 + reasoning: The objective requires entering the text 'Trailblaze is great' into the Message textarea. The element [e16] is labeled as the 'Message' textbox, making it the correct target for this action. + nodeSelector: + web: + cssSelector: "#message-input" + + - step: "Click the \"Submit\" button" + recording: + web: + - web_click: + ref: e17 + reasoning: The current objective is to click the "Submit" button. The button is available as e17, so I'll click it to accomplish the objective. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: Submit + + - step: "Verify the text \"Form submitted!\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Form submitted! + reasoning: The current objective is to verify that the text 'Form submitted!' is visible. The view hierarchy confirms that this text appears in a strong tag after the form is submitted. Verifying its visibility ensures the objective is met. + + - step: "Verify the text \"Trail Blaze\" is visible in the form summary" + recording: + web: + - web_verifyTextVisible: + text: Trail Blaze + reasoning: 'The objective requires verifying that the text ''Trail Blaze'' is visible in the form summary. The paragraph element [e19] includes ''Name: Trail Blaze'', so verifying the presence of ''Trail Blaze'' fulfills the objective.' diff --git a/trails/playwright-electron/test-form-interaction/web.trail.yaml b/trails/playwright-electron/test-form-interaction/web.trail.yaml deleted file mode 100644 index b9ee5da83..000000000 --- a/trails/playwright-electron/test-form-interaction/web.trail.yaml +++ /dev/null @@ -1,101 +0,0 @@ -- config: - title: Electron Form Interaction Test - platform: web - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Click on the "Form" navigation link - recording: - tools: - - web_click: - ref: e4 - element: link "Form" - reasoning: The current objective is to click the 'Form' navigation link. The element [e4] is labeled as 'Form', which matches the objective. - nodeSelector: - web: - cssSelector: "#nav-form" - - step: Type "Trail Blaze" into the Name input field - recording: - tools: - - web_type: - text: Trail Blaze - ref: e9 - element: textbox Name - reasoning: The objective is to type 'Trail Blaze' into the Name input field. The element [e9] is labeled 'Name', which matches the required input field. - nodeSelector: - web: - cssSelector: "#name-input" - - web_verifyValue: - ref: e9 - type: VALUE - expected: Trail Blaze - element: textbox Name - reasoning: The objective requires confirming that 'Trail Blaze' has been entered in the Name input. Verifying textbox 'Name' has the correct value ensures the task was fully completed. - nodeSelector: - web: - cssSelector: "#name-input" - - step: Type "trail@example.com" into the Email input field - recording: - tools: - - web_type: - text: trail@example.com - ref: e10 - element: textbox "Email" - reasoning: The objective is to type 'trail@example.com' into the Email input field. The element [e10] represents the Email textbox, making it the correct target for this action. - nodeSelector: - web: - cssSelector: "#email-input" - - step: Select "Feedback" from the Category dropdown - recording: - tools: - - web_selectOption: - ref: e11 - element: combobox Category - values: - - Feedback - reasoning: To fulfill the objective, I need to select the 'Feedback' option from the 'Category' dropdown (combobox). This action will select the correct item as required. - nodeSelector: - web: - cssSelector: "#category-select" - - web_verifyValue: - ref: e11 - type: VALUE - expected: feedback - element: Category combobox - reasoning: The objective requires that 'Feedback' is selected from the Category dropdown. Verifying that the value of the Category combobox matches the option value 'feedback' ensures successful completion. - nodeSelector: - web: - cssSelector: "#category-select" - - step: Type "Trailblaze is great" into the Message textarea - recording: - tools: - - web_type: - text: Trailblaze is great - ref: e16 - element: textbox Message - reasoning: The objective requires entering the text 'Trailblaze is great' into the Message textarea. The element [e16] is labeled as the 'Message' textbox, making it the correct target for this action. - nodeSelector: - web: - cssSelector: "#message-input" - - step: Click the "Submit" button - recording: - tools: - - web_click: - ref: e17 - element: button "Submit" - reasoning: The current objective is to click the "Submit" button. The button is available as e17, so I'll click it to accomplish the objective. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: Submit - - step: Verify the text "Form submitted!" is visible - recording: - tools: - - web_verifyTextVisible: - text: Form submitted! - reasoning: The current objective is to verify that the text 'Form submitted!' is visible. The view hierarchy confirms that this text appears in a strong tag after the form is submitted. Verifying its visibility ensures the objective is met. - - step: Verify the text "Trail Blaze" is visible in the form summary - recording: - tools: - - web_verifyTextVisible: - text: Trail Blaze - reasoning: 'The objective requires verifying that the text ''Trail Blaze'' is visible in the form summary. The paragraph element [e19] includes ''Name: Trail Blaze'', so verifying the presence of ''Trail Blaze'' fulfills the objective.' diff --git a/trails/playwright-electron/test-navigation/blaze.yaml b/trails/playwright-electron/test-navigation/blaze.yaml deleted file mode 100644 index 61b8cce6f..000000000 --- a/trails/playwright-electron/test-navigation/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Navigation test for the Electron driver. -# No initial navigate step — the Electron app pre-loads index.html via main.js. -- config: - title: "Electron Navigation Test" - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Verify the heading "Trailblaze Test Fixture" is visible - - step: Click on the "Form" navigation link - - step: Verify the heading "Contact Form" is visible - - step: Click on the "Counter" navigation link - - step: Verify the heading "Counter" is visible - - step: Click on the "About" navigation link - - step: Verify the text "Trailblaze is an AI-powered UI testing framework" is visible - - step: Navigate back to the previous page state by clicking the "Home" navigation link - - step: Verify the heading "Welcome" is visible diff --git a/trails/playwright-electron/test-navigation/trail.yaml b/trails/playwright-electron/test-navigation/trail.yaml new file mode 100644 index 000000000..e0deac9fa --- /dev/null +++ b/trails/playwright-electron/test-navigation/trail.yaml @@ -0,0 +1,96 @@ +config: + devices: + web: PLAYWRIGHT_ELECTRON + title: Electron Navigation Test + +trail: + - step: "Verify the heading \"Trailblaze Test Fixture\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e1 + reasoning: To complete the objective, I need to verify that the heading 'Trailblaze Test Fixture' is visible. The element with ID e1 matches exactly this heading, so verifying its visibility confirms the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Trailblaze Test Fixture + + - step: "Click on the \"Form\" navigation link" + recording: + web: + - web_click: + ref: e4 + reasoning: The objective is to click on the 'Form' navigation link. The element [e4] corresponds to the 'Form' link and should be clicked to fulfill the task. + nodeSelector: + web: + cssSelector: "#nav-form" + + - step: "Verify the heading \"Contact Form\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The objective is to verify that the heading 'Contact Form' is visible. The view hierarchy confirms there is a heading element matching that label (e8), so verifying its visibility will accomplish the task. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Contact Form + + - step: "Click on the \"Counter\" navigation link" + recording: + web: + - web_click: + ref: e5 + reasoning: The objective is to click on the 'Counter' navigation link. The view hierarchy shows a link with the label 'Counter' identified as e5. Clicking this will fulfill the objective. + nodeSelector: + web: + cssSelector: "#nav-counter" + + - step: "Verify the heading \"Counter\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The objective requires verifying that the heading 'Counter' is visible. This element is present as [e8] and should be confirmed visible to satisfy the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Counter + + - step: "Click on the \"About\" navigation link" + recording: + web: + - web_click: + ref: e6 + reasoning: The current objective is to click on the "About" navigation link. The element with ID e6 corresponds to this link, so clicking it fulfills the requirement. + nodeSelector: + web: + cssSelector: "#nav-about" + + - step: "Verify the text \"Trailblaze is an AI-powered UI testing framework\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Trailblaze is an AI-powered UI testing framework + reasoning: The objective requires verifying the visibility of the text 'Trailblaze is an AI-powered UI testing framework'. This verification will check if that exact text is currently displayed on the screen. + + - step: "Navigate back to the previous page state by clicking the \"Home\" navigation link" + recording: + web: + - web_click: + ref: e3 + reasoning: The objective is to navigate back to the previous page state by clicking the 'Home' navigation link. The element for the 'Home' link is present (e3), so I will click it to accomplish the objective. + nodeSelector: + web: + cssSelector: "#nav-home" + + - step: "Verify the heading \"Welcome\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The current objective requires verifying that the heading 'Welcome' is visible. The view hierarchy clearly lists [e8] as heading 'Welcome', so I will verify its visibility directly to fulfill the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Welcome diff --git a/trails/playwright-electron/test-navigation/web.trail.yaml b/trails/playwright-electron/test-navigation/web.trail.yaml deleted file mode 100644 index 4481eb471..000000000 --- a/trails/playwright-electron/test-navigation/web.trail.yaml +++ /dev/null @@ -1,95 +0,0 @@ -- config: - title: Electron Navigation Test - platform: web - driver: PLAYWRIGHT_ELECTRON -- prompts: - - step: Verify the heading "Trailblaze Test Fixture" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e1 - element: heading 'Trailblaze Test Fixture' - reasoning: To complete the objective, I need to verify that the heading 'Trailblaze Test Fixture' is visible. The element with ID e1 matches exactly this heading, so verifying its visibility confirms the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Trailblaze Test Fixture - - step: Click on the "Form" navigation link - recording: - tools: - - web_click: - ref: e4 - element: link "Form" - reasoning: The objective is to click on the 'Form' navigation link. The element [e4] corresponds to the 'Form' link and should be clicked to fulfill the task. - nodeSelector: - web: - cssSelector: "#nav-form" - - step: Verify the heading "Contact Form" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading "Contact Form" - reasoning: The objective is to verify that the heading 'Contact Form' is visible. The view hierarchy confirms there is a heading element matching that label (e8), so verifying its visibility will accomplish the task. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Contact Form - - step: Click on the "Counter" navigation link - recording: - tools: - - web_click: - ref: e5 - element: link "Counter" - reasoning: The objective is to click on the 'Counter' navigation link. The view hierarchy shows a link with the label 'Counter' identified as e5. Clicking this will fulfill the objective. - nodeSelector: - web: - cssSelector: "#nav-counter" - - step: Verify the heading "Counter" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading "Counter" - reasoning: The objective requires verifying that the heading 'Counter' is visible. This element is present as [e8] and should be confirmed visible to satisfy the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Counter - - step: Click on the "About" navigation link - recording: - tools: - - web_click: - ref: e6 - element: link "About" - reasoning: The current objective is to click on the "About" navigation link. The element with ID e6 corresponds to this link, so clicking it fulfills the requirement. - nodeSelector: - web: - cssSelector: "#nav-about" - - step: Verify the text "Trailblaze is an AI-powered UI testing framework" is visible - recording: - tools: - - web_verifyTextVisible: - text: Trailblaze is an AI-powered UI testing framework - reasoning: The objective requires verifying the visibility of the text 'Trailblaze is an AI-powered UI testing framework'. This verification will check if that exact text is currently displayed on the screen. - - step: Navigate back to the previous page state by clicking the "Home" navigation link - recording: - tools: - - web_click: - ref: e3 - element: link "Home" - reasoning: The objective is to navigate back to the previous page state by clicking the 'Home' navigation link. The element for the 'Home' link is present (e3), so I will click it to accomplish the objective. - nodeSelector: - web: - cssSelector: "#nav-home" - - step: Verify the heading "Welcome" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading "Welcome" - reasoning: The current objective requires verifying that the heading 'Welcome' is visible. The view hierarchy clearly lists [e8] as heading 'Welcome', so I will verify its visibility directly to fulfill the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Welcome diff --git a/trails/playwright-native/benchmark.sh b/trails/playwright-native/benchmark.sh index c6282b740..9fe01d16b 100755 --- a/trails/playwright-native/benchmark.sh +++ b/trails/playwright-native/benchmark.sh @@ -93,12 +93,18 @@ AI_DURATIONS=() REC_DURATIONS=() for trail in "${TRAILS[@]}"; do + # Unified format: prompts + recordings live in one trail.yaml. The AI vs + # recording comparison is driven by the run mode flag, not by two files: + # --no-use-recorded-steps forces AI mode (the default auto-detects to + # replay whenever recordings are present, which would make both legs + # replay), --use-recorded-steps forces verbatim replay of the recorded + # tools. trail_file="$SCRIPT_DIR/${trail}/trail.yaml" - recording_file="$SCRIPT_DIR/${trail}/web.trail.yaml" + recording_file="$SCRIPT_DIR/${trail}/trail.yaml" # --- AI run --- echo "Running AI: $trail ..." - "$ROOT_DIR/trailblaze" run "$trail_file" 2>&1 | tail -1 + "$ROOT_DIR/trailblaze" run "$trail_file" --no-use-recorded-steps 2>&1 | tail -1 ai_session=$(find_latest_session) AI_DURATIONS+=("$(extract_duration_ms "$ai_session")") diff --git a/trails/playwright-native/search-yahoo/blaze.yaml b/trails/playwright-native/search-yahoo/blaze.yaml deleted file mode 100644 index 97ba9767d..000000000 --- a/trails/playwright-native/search-yahoo/blaze.yaml +++ /dev/null @@ -1,11 +0,0 @@ -- config: - title: "Yahoo Search for Trailblaze Block" - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Open Yahoo.com - recording: - tools: - - web_navigate: - url: https://yahoo.com - reasoning: The objective is to navigate to the provided file URL. There are currently no interactive elements, so navigation is the required step to begin this task. - - step: Input the search query of "Trailblaze Github Block" but don't click Search. diff --git a/trails/playwright-native/search-yahoo/trail.yaml b/trails/playwright-native/search-yahoo/trail.yaml new file mode 100644 index 000000000..f17674925 --- /dev/null +++ b/trails/playwright-native/search-yahoo/trail.yaml @@ -0,0 +1,13 @@ +config: + title: "Yahoo Search for Trailblaze Block" + devices: + web: PLAYWRIGHT_NATIVE + +trail: + - step: Open Yahoo.com + recording: + web: + - web_navigate: + url: https://yahoo.com + reasoning: The objective is to navigate to the provided file URL. There are currently no interactive elements, so navigation is the required step to begin this task. + - step: Input the search query of "Trailblaze GitHub Block" but don't click Search. diff --git a/trails/playwright-native/test-counter/blaze.yaml b/trails/playwright-native/test-counter/blaze.yaml deleted file mode 100644 index 100add33c..000000000 --- a/trails/playwright-native/test-counter/blaze.yaml +++ /dev/null @@ -1,13 +0,0 @@ -- config: - title: "Test: Counter interaction" - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - - step: Click on the "Counter" navigation link - - step: Verify the counter value shows "0" - - step: Click the "Increment" button (the + button) three times - - step: Verify the counter value shows "3" - - step: Click the "Decrement" button (the - button) once - - step: Verify the counter value shows "2" - - step: Click the "Reset" button - - step: Verify the counter value shows "0" diff --git a/trails/playwright-native/test-counter/trail.yaml b/trails/playwright-native/test-counter/trail.yaml new file mode 100644 index 000000000..d9364c291 --- /dev/null +++ b/trails/playwright-native/test-counter/trail.yaml @@ -0,0 +1,92 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Counter interaction' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/index.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/index.html + reasoning: The current task is to navigate to the specified URL. There are no interactive elements present, so direct navigation is required. + + - step: "Click on the \"Counter\" navigation link" + recording: + web: + - web_click: + ref: e5 + reasoning: The objective requires clicking on the 'Counter' navigation link. The view hierarchy shows 'Counter' as [e5], so clicking this element should satisfy the requirement. + nodeSelector: + web: + cssSelector: "#nav-counter" + + - step: "Verify the counter value shows \"0\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 0' + reasoning: 'The current objective is to verify that the counter value shows ''0''. The text ''Current value: 0'' appears in the paragraph [e9], which satisfies the requirement. Verifying this text ensures the objective is met.' + + - step: "Click the \"Increment\" button (the + button) three times" + recording: + web: + - web_click: + ref: e11 + reasoning: To increment the counter as requested, I will click the 'Increment' (+) button three times. This is the first click. + nodeSelector: + web: + cssSelector: "#increment-btn" + - web_click: + ref: e11 + reasoning: The objective requires clicking the 'Increment' button three times. The button has been clicked once already, so I need to click it two more times to fulfill the objective. + nodeSelector: + web: + cssSelector: "#increment-btn" + - web_click: + ref: e11 + reasoning: The objective is to click the Increment (+) button three times. I have already clicked it twice. The current value displayed is 2, indicating that two increments have been performed. I will click the Increment button one more time to complete the objective. + nodeSelector: + web: + cssSelector: "#increment-btn" + + - step: "Verify the counter value shows \"3\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 3' + reasoning: 'The current objective is to verify that the counter value shows ''3''. The paragraph [e9] displays ''Current value: 3'', so I will verify this text is visible to confirm the objective is satisfied.' + + - step: "Click the \"Decrement\" button (the - button) once" + recording: + web: + - web_click: + ref: e10 + reasoning: The objective requires clicking the 'Decrement' button (the - button). e10 corresponds exactly to that button. Clicking it will fulfill the given task. + nodeSelector: + web: + cssSelector: "#decrement-btn" + + - step: "Verify the counter value shows \"2\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 2' + reasoning: 'The objective is to verify that the counter value shows ''2''. The paragraph element on the screen displays ''Current value: 2'', directly matching the requirement. Verifying this text ensures the objective is met.' + + - step: "Click the \"Reset\" button" + recording: + web: + - web_click: + ref: e12 + reasoning: The objective requires clicking the 'Reset' button. The button is clearly identified as [e12] in the view hierarchy, so I will click it to complete the task. + nodeSelector: + web: + cssSelector: "#reset-btn" + + - step: "Verify the counter value shows \"0\"" + recording: + web: + - web_verifyTextVisible: + text: 'Current value: 0' + reasoning: 'The objective requires verification that the counter value shows ''0''. The paragraph with text ''Current value: 0'' indicates this state. Verifying this text fulfills the objective.' diff --git a/trails/playwright-native/test-counter/web.trail.yaml b/trails/playwright-native/test-counter/web.trail.yaml deleted file mode 100644 index 6b807f7fa..000000000 --- a/trails/playwright-native/test-counter/web.trail.yaml +++ /dev/null @@ -1,89 +0,0 @@ -- config: - title: 'Test: Counter interaction' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/index.html - reasoning: The current task is to navigate to the specified URL. There are no interactive elements present, so direct navigation is required. - - step: Click on the "Counter" navigation link - recording: - tools: - - web_click: - ref: e5 - element: link "Counter" - reasoning: The objective requires clicking on the 'Counter' navigation link. The view hierarchy shows 'Counter' as [e5], so clicking this element should satisfy the requirement. - nodeSelector: - web: - cssSelector: "#nav-counter" - - step: Verify the counter value shows "0" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 0' - reasoning: 'The current objective is to verify that the counter value shows ''0''. The text ''Current value: 0'' appears in the paragraph [e9], which satisfies the requirement. Verifying this text ensures the objective is met.' - - step: Click the "Increment" button (the + button) three times - recording: - tools: - - web_click: - ref: e11 - element: button "Increment" - reasoning: To increment the counter as requested, I will click the 'Increment' (+) button three times. This is the first click. - nodeSelector: - web: - cssSelector: "#increment-btn" - - web_click: - ref: e11 - element: button "Increment" - reasoning: The objective requires clicking the 'Increment' button three times. The button has been clicked once already, so I need to click it two more times to fulfill the objective. - nodeSelector: - web: - cssSelector: "#increment-btn" - - web_click: - ref: e11 - element: button "Increment" - reasoning: The objective is to click the Increment (+) button three times. I have already clicked it twice. The current value displayed is 2, indicating that two increments have been performed. I will click the Increment button one more time to complete the objective. - nodeSelector: - web: - cssSelector: "#increment-btn" - - step: Verify the counter value shows "3" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 3' - reasoning: 'The current objective is to verify that the counter value shows ''3''. The paragraph [e9] displays ''Current value: 3'', so I will verify this text is visible to confirm the objective is satisfied.' - - step: Click the "Decrement" button (the - button) once - recording: - tools: - - web_click: - ref: e10 - element: button "Decrement" - reasoning: The objective requires clicking the 'Decrement' button (the - button). e10 corresponds exactly to that button. Clicking it will fulfill the given task. - nodeSelector: - web: - cssSelector: "#decrement-btn" - - step: Verify the counter value shows "2" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 2' - reasoning: 'The objective is to verify that the counter value shows ''2''. The paragraph element on the screen displays ''Current value: 2'', directly matching the requirement. Verifying this text ensures the objective is met.' - - step: Click the "Reset" button - recording: - tools: - - web_click: - ref: e12 - element: button "Reset" - reasoning: The objective requires clicking the 'Reset' button. The button is clearly identified as [e12] in the view hierarchy, so I will click it to complete the task. - nodeSelector: - web: - cssSelector: "#reset-btn" - - step: Verify the counter value shows "0" - recording: - tools: - - web_verifyTextVisible: - text: 'Current value: 0' - reasoning: 'The objective requires verification that the counter value shows ''0''. The paragraph with text ''Current value: 0'' indicates this state. Verifying this text fulfills the objective.' diff --git a/trails/playwright-native/test-duplicate-list/blaze.yaml b/trails/playwright-native/test-duplicate-list/blaze.yaml deleted file mode 100644 index 815051199..000000000 --- a/trails/playwright-native/test-duplicate-list/blaze.yaml +++ /dev/null @@ -1,41 +0,0 @@ -- config: - title: "Test: Click specific items in a list with duplicate text" - driver: PLAYWRIGHT_NATIVE -- prompts: - # Navigate to the duplicate-list page - - step: Navigate to ../../../examples/playwright-native/sample-app/duplicate-list.html - - # Verify the page loaded - - step: Verify the heading "Product List" is visible - - # --- Click the first "Premium Cable" View button (in Electronics, first row) --- - - step: Click the "View" button on the first "Premium Cable" item in the Electronics section - - step: Verify the text "elec-1" is visible in the detail panel - - # --- Click the second "Premium Cable" View button (in Electronics, second row) --- - - step: Click the "View" button on the second "Premium Cable" item in the Electronics section - - step: Verify the text "elec-2" is visible in the detail panel - - # --- Click "Standard Adapter" View button in Electronics --- - - step: Click the "View" button on the "Standard Adapter" item in the Electronics section - - step: Verify the text "elec-3" is visible in the detail panel - - # --- Click the "Premium Cable" View button in Office Supplies --- - - step: Click the "View" button on the "Premium Cable" item in the Office Supplies section - - step: Verify the text "office-1" is visible in the detail panel - - # --- Click the first "Standard Adapter" View button in Office Supplies --- - - step: Click the "View" button on the first "Standard Adapter" item in the Office Supplies section - - step: Verify the text "office-2" is visible in the detail panel - - # --- Click the second "Standard Adapter" View button in Office Supplies --- - - step: Click the "View" button on the second "Standard Adapter" item in the Office Supplies section - - step: Verify the text "office-3" is visible in the detail panel - - # --- Click the first "Premium Cable" View button in Accessories --- - - step: Click the "View" button on the first "Premium Cable" item in the Accessories section - - step: Verify the text "acc-1" is visible in the detail panel - - # --- Click the second "Premium Cable" View button in Accessories --- - - step: Click the "View" button on the second "Premium Cable" item in the Accessories section - - step: Verify the text "acc-2" is visible in the detail panel diff --git a/trails/playwright-native/test-duplicate-list/trail.yaml b/trails/playwright-native/test-duplicate-list/trail.yaml new file mode 100644 index 000000000..328650eea --- /dev/null +++ b/trails/playwright-native/test-duplicate-list/trail.yaml @@ -0,0 +1,175 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Click specific items in a list with duplicate text' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/duplicate-list.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/duplicate-list.html + reasoning: The objective is to navigate to the specified relative URL. Since there are no interactive elements or navigation options visible on the current page, I will directly navigate to the provided URL to begin or complete the task. + + - step: "Verify the heading \"Product List\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e1 + reasoning: The objective is to verify that the heading 'Product List' is visible, and element [e1] corresponds to that heading. This check confirms its presence on the screen. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Product List + + - step: "Click the \"View\" button on the first \"Premium Cable\" item in the Electronics section" + recording: + web: + - web_click: + ref: e4 + reasoning: This is the first 'View' button in the Electronics section, which corresponds to the first 'Premium Cable' item as per the objective. + nodeSelector: + index: 2 + + - step: "Verify the text \"elec-1\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: elec-1 + reasoning: The objective requires verifying that the text 'elec-1' is visible in the detail panel. This verification directly checks if the required text is present anywhere on the page, including within a detail panel if visible. + + - step: "Click the \"View\" button on the second \"Premium Cable\" item in the Electronics section" + recording: + web: + - web_click: + ref: e5 + reasoning: This is the second 'View' button under the Electronics section, which matches the request to click the 'View' button for the second 'Premium Cable' item in that section. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 1 + + - step: "Verify the text \"elec-2\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: elec-2 + reasoning: The objective is to verify that the text 'elec-2' is visible in the detail panel. Since the prior objective was to view the details for the second 'Premium Cable' in Electronics, I expect 'elec-2' to be present as an identifier in the detail section. I will verify its visibility on the current page. + + - step: "Click the \"View\" button on the \"Standard Adapter\" item in the Electronics section" + recording: + web: + - web_click: + ref: e6 + reasoning: This is the third 'View' button under the Electronics section, corresponding to the 'Standard Adapter' item, as per the layout and visual structure. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 2 + + - step: "Verify the text \"elec-3\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: elec-3 + reasoning: The objective is to verify that the text 'elec-3' is visible in the detail panel. Since the prior step was clicking 'View' on an item in the Electronics section, the detail panel for that item should now display 'elec-3' if the implementation is correct. This action will check for the presence of that text. + + - step: "Click the \"View\" button on the \"Premium Cable\" item in the Office Supplies section" + recording: + web: + - web_click: + ref: e8 + reasoning: This is the first 'View' button under the 'Office Supplies' heading, matching the expected position for 'Premium Cable' in this section. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 3 + + - step: "Verify the text \"office-1\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: office-1 + reasoning: The objective is to verify that the text 'office-1' is visible in the detail panel. Since the prior step was clicking 'View' on 'Premium Cable' under Office Supplies, the detail panel for that item should show 'office-1' if functioning correctly. I will verify the visibility of the text 'office-1' to meet the objective. + + - step: "Click the \"View\" button on the first \"Standard Adapter\" item in the Office Supplies section" + recording: + web: + - web_click: + ref: e9 + reasoning: Based on the hierarchy, [e9] is the second View button after the Office Supplies heading and represents the first Standard Adapter in that section. Clicking it fulfills the objective. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 4 + + - step: "Verify the text \"office-2\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: office-2 + reasoning: The objective requires verification that the text 'office-2' is visible in the detail panel. I will check for its presence on the current screen to satisfy the requirement. + + - step: "Click the \"View\" button on the second \"Standard Adapter\" item in the Office Supplies section" + recording: + web: + - web_click: + ref: e10 + reasoning: Based on the hierarchy, e10 is the 'View' button corresponding to the second 'Standard Adapter' item in the 'Office Supplies' section. Clicking this fulfills the objective. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 5 + + - step: "Verify the text \"office-3\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: office-3 + reasoning: The objective is to verify if the text 'office-3' is visible in the detail panel after clicking the 'View' button on the second 'Standard Adapter' in the Office Supplies section. This check will confirm if the required text is present on the screen. + + - step: "Click the \"View\" button on the first \"Premium Cable\" item in the Accessories section" + recording: + web: + - web_scroll: + amount: 400 + reasoning: The Accessories section 'View' buttons are currently offscreen. Scrolling down will bring them into view so I can interact with the first 'View' button under Accessories. + - web_click: + ref: e12 + reasoning: The Accessories section's first Premium Cable corresponds to the first "View" button after the Accessories heading ([e12]). Clicking this will fulfill the objective as specified. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 6 + + - step: "Verify the text \"acc-1\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: acc-1 + reasoning: 'The objective requires verifying that the text ''acc-1'' is visible in the detail panel. From the screenshot and the UI hierarchy, the detail panel at the bottom of the page contains the text ''Selected: Premium Cable — Accessories (ID: acc-1)'', so it should be present. This step will verify the visibility of ''acc-1''.' + + - step: "Click the \"View\" button on the second \"Premium Cable\" item in the Accessories section" + recording: + web: + - web_click: + ref: e13 + reasoning: Objective requires clicking the View button for the second Premium Cable in Accessories; based on element order, [e13] is the correct target. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: View + nthIndex: 7 + + - step: "Verify the text \"acc-2\" is visible in the detail panel" + recording: + web: + - web_verifyTextVisible: + text: acc-2 + reasoning: 'The objective requires verifying that the text ''acc-2'' is visible. There is a detail panel at the bottom of the screen showing ''Selected: Premium Cable — Accessories (ID: acc-2)''. I will verify the presence of ''acc-2'' in the visible details.' diff --git a/trails/playwright-native/test-duplicate-list/web.trail.yaml b/trails/playwright-native/test-duplicate-list/web.trail.yaml deleted file mode 100644 index fcb7271d2..000000000 --- a/trails/playwright-native/test-duplicate-list/web.trail.yaml +++ /dev/null @@ -1,166 +0,0 @@ -- config: - title: 'Test: Click specific items in a list with duplicate text' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/duplicate-list.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/duplicate-list.html - reasoning: The objective is to navigate to the specified relative URL. Since there are no interactive elements or navigation options visible on the current page, I will directly navigate to the provided URL to begin or complete the task. - - step: Verify the heading "Product List" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e1 - element: heading 'Product List' - reasoning: The objective is to verify that the heading 'Product List' is visible, and element [e1] corresponds to that heading. This check confirms its presence on the screen. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Product List - - step: Click the "View" button on the first "Premium Cable" item in the Electronics section - recording: - tools: - - web_click: - ref: e4 - element: first View button in Electronics section - reasoning: This is the first 'View' button in the Electronics section, which corresponds to the first 'Premium Cable' item as per the objective. - nodeSelector: - index: 2 - - step: Verify the text "elec-1" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: elec-1 - reasoning: The objective requires verifying that the text 'elec-1' is visible in the detail panel. This verification directly checks if the required text is present anywhere on the page, including within a detail panel if visible. - - step: Click the "View" button on the second "Premium Cable" item in the Electronics section - recording: - tools: - - web_click: - ref: e5 - element: View button for second Premium Cable in Electronics section - reasoning: This is the second 'View' button under the Electronics section, which matches the request to click the 'View' button for the second 'Premium Cable' item in that section. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 1 - - step: Verify the text "elec-2" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: elec-2 - reasoning: The objective is to verify that the text 'elec-2' is visible in the detail panel. Since the prior objective was to view the details for the second 'Premium Cable' in Electronics, I expect 'elec-2' to be present as an identifier in the detail section. I will verify its visibility on the current page. - - step: Click the "View" button on the "Standard Adapter" item in the Electronics section - recording: - tools: - - web_click: - ref: e6 - element: View button for Standard Adapter in Electronics section - reasoning: This is the third 'View' button under the Electronics section, corresponding to the 'Standard Adapter' item, as per the layout and visual structure. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 2 - - step: Verify the text "elec-3" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: elec-3 - reasoning: The objective is to verify that the text 'elec-3' is visible in the detail panel. Since the prior step was clicking 'View' on an item in the Electronics section, the detail panel for that item should now display 'elec-3' if the implementation is correct. This action will check for the presence of that text. - - step: Click the "View" button on the "Premium Cable" item in the Office Supplies section - recording: - tools: - - web_click: - ref: e8 - element: View button for Premium Cable (Office Supplies section) - reasoning: This is the first 'View' button under the 'Office Supplies' heading, matching the expected position for 'Premium Cable' in this section. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 3 - - step: Verify the text "office-1" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: office-1 - reasoning: The objective is to verify that the text 'office-1' is visible in the detail panel. Since the prior step was clicking 'View' on 'Premium Cable' under Office Supplies, the detail panel for that item should show 'office-1' if functioning correctly. I will verify the visibility of the text 'office-1' to meet the objective. - - step: Click the "View" button on the first "Standard Adapter" item in the Office Supplies section - recording: - tools: - - web_click: - ref: e9 - element: View button for first Standard Adapter in Office Supplies - reasoning: Based on the hierarchy, [e9] is the second View button after the Office Supplies heading and represents the first Standard Adapter in that section. Clicking it fulfills the objective. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 4 - - step: Verify the text "office-2" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: office-2 - reasoning: The objective requires verification that the text 'office-2' is visible in the detail panel. I will check for its presence on the current screen to satisfy the requirement. - - step: Click the "View" button on the second "Standard Adapter" item in the Office Supplies section - recording: - tools: - - web_click: - ref: e10 - element: View button for second Standard Adapter in Office Supplies - reasoning: Based on the hierarchy, e10 is the 'View' button corresponding to the second 'Standard Adapter' item in the 'Office Supplies' section. Clicking this fulfills the objective. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 5 - - step: Verify the text "office-3" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: office-3 - reasoning: The objective is to verify if the text 'office-3' is visible in the detail panel after clicking the 'View' button on the second 'Standard Adapter' in the Office Supplies section. This check will confirm if the required text is present on the screen. - - step: Click the "View" button on the first "Premium Cable" item in the Accessories section - recording: - tools: - - web_scroll: - amount: 400 - reasoning: The Accessories section 'View' buttons are currently offscreen. Scrolling down will bring them into view so I can interact with the first 'View' button under Accessories. - - web_click: - ref: e12 - element: First "View" button in Accessories section (for Premium Cable) - reasoning: The Accessories section's first Premium Cable corresponds to the first "View" button after the Accessories heading ([e12]). Clicking this will fulfill the objective as specified. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 6 - - step: Verify the text "acc-1" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: acc-1 - reasoning: 'The objective requires verifying that the text ''acc-1'' is visible in the detail panel. From the screenshot and the UI hierarchy, the detail panel at the bottom of the page contains the text ''Selected: Premium Cable — Accessories (ID: acc-1)'', so it should be present. This step will verify the visibility of ''acc-1''.' - - step: Click the "View" button on the second "Premium Cable" item in the Accessories section - recording: - tools: - - web_click: - ref: e13 - element: View button (second Premium Cable in Accessories) - reasoning: Objective requires clicking the View button for the second Premium Cable in Accessories; based on element order, [e13] is the correct target. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: View - nthIndex: 7 - - step: Verify the text "acc-2" is visible in the detail panel - recording: - tools: - - web_verifyTextVisible: - text: acc-2 - reasoning: 'The objective requires verifying that the text ''acc-2'' is visible. There is a detail panel at the bottom of the screen showing ''Selected: Premium Cable — Accessories (ID: acc-2)''. I will verify the presence of ''acc-2'' in the visible details.' diff --git a/trails/playwright-native/test-form-interaction/blaze.yaml b/trails/playwright-native/test-form-interaction/blaze.yaml deleted file mode 100644 index 9565f392e..000000000 --- a/trails/playwright-native/test-form-interaction/blaze.yaml +++ /dev/null @@ -1,13 +0,0 @@ -- config: - title: "Test: Form fill and submit" - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - - step: Click on the "Form" navigation link - - step: Type "Trail Blaze" into the Name input field - - step: Type "trail@example.com" into the Email input field - - step: Select "Feedback" from the Category dropdown - - step: Type "Trailblaze is great" into the Message textarea - - step: Click the "Submit" button - - step: Verify the text "Form submitted!" is visible - - step: Verify the text "Trail Blaze" is visible in the form summary diff --git a/trails/playwright-native/test-form-interaction/trail.yaml b/trails/playwright-native/test-form-interaction/trail.yaml new file mode 100644 index 000000000..6728cec78 --- /dev/null +++ b/trails/playwright-native/test-form-interaction/trail.yaml @@ -0,0 +1,92 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Form fill and submit' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/index.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/index.html + reasoning: The objective is to navigate to the specific URL '../../../examples/playwright-native/sample-app/index.html', so I will perform a direct navigation to that URL. + + - step: "Click on the \"Form\" navigation link" + recording: + web: + - web_click: + ref: e4 + reasoning: The objective is to click on the "Form" navigation link. The element with ID e4 corresponds to the "Form" link in the navigation, making it the correct target for this action. + nodeSelector: + web: + cssSelector: "#nav-form" + + - step: "Type \"Trail Blaze\" into the Name input field" + recording: + web: + - web_type: + text: Trail Blaze + ref: e9 + reasoning: The objective is to type 'Trail Blaze' into the Name input. e9 is the 'Name' textbox in the Contact Form. + nodeSelector: + web: + cssSelector: "#name-input" + + - step: "Type \"trail@example.com\" into the Email input field" + recording: + web: + - web_type: + text: trail@example.com + ref: e10 + reasoning: The current objective is to type 'trail@example.com' into the Email input field. The element with ID [e10] is labeled 'Email', making it the correct target for this action. + nodeSelector: + web: + cssSelector: "#email-input" + + - step: "Select \"Feedback\" from the Category dropdown" + recording: + web: + - web_selectOption: + ref: e11 + values: + - Feedback + reasoning: The objective is to select 'Feedback' from the Category dropdown. Identified the correct combobox and option based on view hierarchy. This action should fulfill the requirement. + nodeSelector: + web: + cssSelector: "#category-select" + + - step: "Type \"Trailblaze is great\" into the Message textarea" + recording: + web: + - web_type: + text: Trailblaze is great + ref: e16 + reasoning: The objective is to type 'Trailblaze is great' into the Message textarea, which corresponds to element [e16]. This action directly fulfills the objective. + nodeSelector: + web: + cssSelector: "#message-input" + + - step: "Click the \"Submit\" button" + recording: + web: + - web_click: + ref: e17 + reasoning: The current objective is to click the 'Submit' button. The view hierarchy lists 'e17' as the Submit button, so I will click it to complete the objective. + nodeSelector: + web: + ariaRole: button + ariaNameRegex: Submit + + - step: "Verify the text \"Form submitted!\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Form submitted! + reasoning: The current objective requires verifying that the text 'Form submitted!' is visible on the screen. The view hierarchy confirms the presence of this text in a 'strong' element. This action will assert its visibility. + + - step: "Verify the text \"Trail Blaze\" is visible in the form summary" + recording: + web: + - web_verifyTextVisible: + text: Trail Blaze + reasoning: 'The objective requires verifying that ''Trail Blaze'' is visible in the form summary. The current screen contains a paragraph element that includes ''Name: Trail Blaze,'' which indicates the required text is present. Verifying this meets the objective.' diff --git a/trails/playwright-native/test-form-interaction/web.trail.yaml b/trails/playwright-native/test-form-interaction/web.trail.yaml deleted file mode 100644 index 697b7a645..000000000 --- a/trails/playwright-native/test-form-interaction/web.trail.yaml +++ /dev/null @@ -1,89 +0,0 @@ -- config: - title: 'Test: Form fill and submit' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/index.html - reasoning: The objective is to navigate to the specific URL '../../../examples/playwright-native/sample-app/index.html', so I will perform a direct navigation to that URL. - - step: Click on the "Form" navigation link - recording: - tools: - - web_click: - ref: e4 - element: link "Form" - reasoning: The objective is to click on the "Form" navigation link. The element with ID e4 corresponds to the "Form" link in the navigation, making it the correct target for this action. - nodeSelector: - web: - cssSelector: "#nav-form" - - step: Type "Trail Blaze" into the Name input field - recording: - tools: - - web_type: - text: Trail Blaze - ref: e9 - element: Name input field - reasoning: The objective is to type 'Trail Blaze' into the Name input. e9 is the 'Name' textbox in the Contact Form. - nodeSelector: - web: - cssSelector: "#name-input" - - step: Type "trail@example.com" into the Email input field - recording: - tools: - - web_type: - text: trail@example.com - ref: e10 - element: Email input field - reasoning: The current objective is to type 'trail@example.com' into the Email input field. The element with ID [e10] is labeled 'Email', making it the correct target for this action. - nodeSelector: - web: - cssSelector: "#email-input" - - step: Select "Feedback" from the Category dropdown - recording: - tools: - - web_selectOption: - ref: e11 - element: Category dropdown - values: - - Feedback - reasoning: The objective is to select 'Feedback' from the Category dropdown. Identified the correct combobox and option based on view hierarchy. This action should fulfill the requirement. - nodeSelector: - web: - cssSelector: "#category-select" - - step: Type "Trailblaze is great" into the Message textarea - recording: - tools: - - web_type: - text: Trailblaze is great - ref: e16 - element: textbox Message - reasoning: The objective is to type 'Trailblaze is great' into the Message textarea, which corresponds to element [e16]. This action directly fulfills the objective. - nodeSelector: - web: - cssSelector: "#message-input" - - step: Click the "Submit" button - recording: - tools: - - web_click: - ref: e17 - element: button "Submit" - reasoning: The current objective is to click the 'Submit' button. The view hierarchy lists 'e17' as the Submit button, so I will click it to complete the objective. - nodeSelector: - web: - ariaRole: button - ariaNameRegex: Submit - - step: Verify the text "Form submitted!" is visible - recording: - tools: - - web_verifyTextVisible: - text: Form submitted! - reasoning: The current objective requires verifying that the text 'Form submitted!' is visible on the screen. The view hierarchy confirms the presence of this text in a 'strong' element. This action will assert its visibility. - - step: Verify the text "Trail Blaze" is visible in the form summary - recording: - tools: - - web_verifyTextVisible: - text: Trail Blaze - reasoning: 'The objective requires verifying that ''Trail Blaze'' is visible in the form summary. The current screen contains a paragraph element that includes ''Name: Trail Blaze,'' which indicates the required text is present. Verifying this meets the objective.' diff --git a/trails/playwright-native/test-navigation/blaze.yaml b/trails/playwright-native/test-navigation/blaze.yaml deleted file mode 100644 index f075e5910..000000000 --- a/trails/playwright-native/test-navigation/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- config: - title: "Test: Navigation and page structure" - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - - step: Verify the heading "Trailblaze Test Fixture" is visible - - step: Click on the "Form" navigation link - - step: Verify the heading "Contact Form" is visible - - step: Click on the "Counter" navigation link - - step: Verify the heading "Counter" is visible - - step: Click on the "About" navigation link - - step: Verify the text "Trailblaze is an AI-powered UI testing framework" is visible - - step: Navigate back to the previous page state by clicking the "Home" navigation link - - step: Verify the heading "Welcome" is visible diff --git a/trails/playwright-native/test-navigation/trail.yaml b/trails/playwright-native/test-navigation/trail.yaml new file mode 100644 index 000000000..41f37d3ff --- /dev/null +++ b/trails/playwright-native/test-navigation/trail.yaml @@ -0,0 +1,103 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Navigation and page structure' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/index.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/index.html + reasoning: The current objective is to navigate to the specified URL. There are no interactive elements present now, so navigation is required to advance towards the objective. + + - step: "Verify the heading \"Trailblaze Test Fixture\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e1 + reasoning: The objective requires verifying that the heading 'Trailblaze Test Fixture' is visible, and the element [e1] matches this heading. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Trailblaze Test Fixture + + - step: "Click on the \"Form\" navigation link" + recording: + web: + - web_click: + ref: e4 + reasoning: The objective is to click the 'Form' navigation link. The element with ID e4 is labeled 'Form', so clicking it will fulfill the current objective. + nodeSelector: + web: + cssSelector: "#nav-form" + + - step: "Verify the heading \"Contact Form\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The current objective is to verify that the heading 'Contact Form' is visible. In the view hierarchy, [e8] corresponds to this heading, so I will verify its visibility to fulfill the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Contact Form + + - step: "Click on the \"Counter\" navigation link" + recording: + web: + - web_click: + ref: e5 + reasoning: The current state shows a navigation bar with a "Counter" link (element e5). To fulfill the objective, I will click this link. Upon a successful click, the objective can be marked complete even if the screen changes, as per instructions. + nodeSelector: + web: + cssSelector: "#nav-counter" + + - step: "Verify the heading \"Counter\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The objective asks to verify the heading 'Counter' is visible. According to the view hierarchy, the element [e8] is the heading 'Counter', so verifying its visibility will fulfill the requirement. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Counter + + - step: "Click on the \"About\" navigation link" + recording: + web: + - web_click: + ref: e6 + reasoning: The objective is to click the 'About' navigation link, which is present as [e6]. Clicking it will likely navigate to the About page, fulfilling the objective. + nodeSelector: + web: + cssSelector: "#nav-about" + + - step: "Verify the text \"Trailblaze is an AI-powered UI testing framework\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Trailblaze is an AI-powered UI testing framework + reasoning: The objective requires verifying the exact text 'Trailblaze is an AI-powered UI testing framework' is visible. I will check if this specific text appears on the page. + + - step: "Navigate back to the previous page state by clicking the \"Home\" navigation link" + recording: + web: + - web_click: + ref: e3 + reasoning: The objective is to return to the previous page state by clicking the 'Home' navigation link. The element for 'Home' is present and clickable. + nodeSelector: + web: + cssSelector: "#nav-home" + + - step: "Verify the heading \"Welcome\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e8 + reasoning: The objective is to verify that the heading "Welcome" is visible. The element with ID e8 matches this heading, so I will verify its visibility to complete the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Welcome diff --git a/trails/playwright-native/test-navigation/web.trail.yaml b/trails/playwright-native/test-navigation/web.trail.yaml deleted file mode 100644 index 73735b0fe..000000000 --- a/trails/playwright-native/test-navigation/web.trail.yaml +++ /dev/null @@ -1,101 +0,0 @@ -- config: - title: 'Test: Navigation and page structure' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/index.html - reasoning: The current objective is to navigate to the specified URL. There are no interactive elements present now, so navigation is required to advance towards the objective. - - step: Verify the heading "Trailblaze Test Fixture" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e1 - element: heading 'Trailblaze Test Fixture' - reasoning: The objective requires verifying that the heading 'Trailblaze Test Fixture' is visible, and the element [e1] matches this heading. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Trailblaze Test Fixture - - step: Click on the "Form" navigation link - recording: - tools: - - web_click: - ref: e4 - element: link "Form" - reasoning: The objective is to click the 'Form' navigation link. The element with ID e4 is labeled 'Form', so clicking it will fulfill the current objective. - nodeSelector: - web: - cssSelector: "#nav-form" - - step: Verify the heading "Contact Form" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading 'Contact Form' - reasoning: The current objective is to verify that the heading 'Contact Form' is visible. In the view hierarchy, [e8] corresponds to this heading, so I will verify its visibility to fulfill the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Contact Form - - step: Click on the "Counter" navigation link - recording: - tools: - - web_click: - ref: e5 - element: link "Counter" - reasoning: The current state shows a navigation bar with a "Counter" link (element e5). To fulfill the objective, I will click this link. Upon a successful click, the objective can be marked complete even if the screen changes, as per instructions. - nodeSelector: - web: - cssSelector: "#nav-counter" - - step: Verify the heading "Counter" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading "Counter" - reasoning: The objective asks to verify the heading 'Counter' is visible. According to the view hierarchy, the element [e8] is the heading 'Counter', so verifying its visibility will fulfill the requirement. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Counter - - step: Click on the "About" navigation link - recording: - tools: - - web_click: - ref: e6 - element: link "About" - reasoning: The objective is to click the 'About' navigation link, which is present as [e6]. Clicking it will likely navigate to the About page, fulfilling the objective. - nodeSelector: - web: - cssSelector: "#nav-about" - - step: Verify the text "Trailblaze is an AI-powered UI testing framework" is visible - recording: - tools: - - web_verifyTextVisible: - text: Trailblaze is an AI-powered UI testing framework - reasoning: The objective requires verifying the exact text 'Trailblaze is an AI-powered UI testing framework' is visible. I will check if this specific text appears on the page. - - step: Navigate back to the previous page state by clicking the "Home" navigation link - recording: - tools: - - web_click: - ref: e3 - element: link "Home" - reasoning: The objective is to return to the previous page state by clicking the 'Home' navigation link. The element for 'Home' is present and clickable. - nodeSelector: - web: - cssSelector: "#nav-home" - - step: Verify the heading "Welcome" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e8 - element: heading "Welcome" - reasoning: The objective is to verify that the heading "Welcome" is visible. The element with ID e8 matches this heading, so I will verify its visibility to complete the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Welcome diff --git a/trails/playwright-native/test-scroll-containers/blaze.yaml b/trails/playwright-native/test-scroll-containers/blaze.yaml deleted file mode 100644 index 6b2e484d9..000000000 --- a/trails/playwright-native/test-scroll-containers/blaze.yaml +++ /dev/null @@ -1,30 +0,0 @@ -- config: - title: "Test: Scroll within specific containers" - driver: PLAYWRIGHT_NATIVE -- prompts: - # Navigate to the test fixture - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - - # Go to the Scroll section - - step: Click on the "Scroll" navigation link - - # Take a snapshot to see the page - - step: Take a snapshot of the current page - - # Verify sidebar top item is visible - - step: Use the web_verifyTextVisible tool to verify "Category 1" is visible - - # Scroll down within the sidebar navigation - - step: Scroll down within the sidebar navigation container - - # Verify a category that was previously hidden at the bottom is now visible - - step: Use the web_verifyTextVisible tool to verify "Category 15" is visible - - # Scroll down within the right content panel - - step: Scroll down within the content panel - - # Verify a content item that was previously hidden is now visible - - step: Use the web_verifyTextVisible tool to verify "Item 20" is visible - - # Scroll back up within the content panel - - step: Scroll up within the content panel diff --git a/trails/playwright-native/test-scroll-containers/trail.yaml b/trails/playwright-native/test-scroll-containers/trail.yaml new file mode 100644 index 000000000..81afc3f6a --- /dev/null +++ b/trails/playwright-native/test-scroll-containers/trail.yaml @@ -0,0 +1,84 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Scroll within specific containers' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/index.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/index.html + reasoning: The objective is to navigate to the specified URL. No interactive elements are present, so navigation must be done directly. + + - step: "Click on the \"Scroll\" navigation link" + recording: + web: + - web_click: + ref: e7 + reasoning: The objective is to click the "Scroll" navigation link. The element with ID e7 is the correct target for this action. + nodeSelector: + web: + cssSelector: "#nav-scroll" + + - step: "Take a snapshot of the current page" + recording: + web: + - web_snapshot: + screenName: current_page_snapshot + reasoning: The objective is to take a snapshot of the current page. This action will capture the current screen state and fulfill the task requirements. + + - step: "Use the web_verifyTextVisible tool to verify \"Category 1\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Category 1 + reasoning: The objective specifically asks to verify that the text 'Category 1' is visible. This text appears as a link in the Sidebar navigation. I expect the verification to succeed since it is clearly listed in the current view hierarchy and screenshot. + + - step: "Scroll down within the sidebar navigation container" + recording: + web: + - web_scroll: + ref: navigation "Sidebar navigation" + reasoning: The sidebar contains more categories than currently visible, so scrolling down within 'Sidebar navigation' will reveal further items and accomplish the objective. + nodeSelector: + web: + ariaRole: navigation + ariaNameRegex: Sidebar navigation + + - step: "Use the web_verifyTextVisible tool to verify \"Category 15\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Category 15 + reasoning: The visible hierarchy shows 'Category 15' as a link in the sidebar navigation. Verifying that this text is visible fulfills the objective. + + - step: "Scroll down within the content panel" + recording: + web: + - web_scroll: + ref: e26 + reasoning: The objective is to scroll down within the content panel. The content panel corresponds to region 'e26'. I will scroll down within this region to achieve the objective. + nodeSelector: + web: + ariaRole: region + ariaNameRegex: Content panel + + - step: "Use the web_verifyTextVisible tool to verify \"Item 20\" is visible" + recording: + web: + - web_verifyTextVisible: + text: Item 20 + reasoning: The current objective requests verification that 'Item 20' is visible. According to the view hierarchy and screenshot, 'Item 20' is visible within the content panel. Using the web_verifyTextVisible tool to confirm its presence as required by the objective. + + - step: "Scroll up within the content panel" + recording: + web: + - web_scroll: + direction: UP + ref: e26 + reasoning: The objective is to scroll up within the content panel. The element e26 represents the content panel region, so scrolling up within this region will satisfy the objective. + nodeSelector: + web: + ariaRole: region + ariaNameRegex: Content panel diff --git a/trails/playwright-native/test-scroll-containers/web.trail.yaml b/trails/playwright-native/test-scroll-containers/web.trail.yaml deleted file mode 100644 index 0e73e972b..000000000 --- a/trails/playwright-native/test-scroll-containers/web.trail.yaml +++ /dev/null @@ -1,78 +0,0 @@ -- config: - title: 'Test: Scroll within specific containers' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/index.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/index.html - reasoning: The objective is to navigate to the specified URL. No interactive elements are present, so navigation must be done directly. - - step: Click on the "Scroll" navigation link - recording: - tools: - - web_click: - ref: e7 - element: link "Scroll" - reasoning: The objective is to click the "Scroll" navigation link. The element with ID e7 is the correct target for this action. - nodeSelector: - web: - cssSelector: "#nav-scroll" - - step: Take a snapshot of the current page - recording: - tools: - - web_snapshot: - screenName: current_page_snapshot - reasoning: The objective is to take a snapshot of the current page. This action will capture the current screen state and fulfill the task requirements. - - step: Use the web_verifyTextVisible tool to verify "Category 1" is visible - recording: - tools: - - web_verifyTextVisible: - text: Category 1 - reasoning: The objective specifically asks to verify that the text 'Category 1' is visible. This text appears as a link in the Sidebar navigation. I expect the verification to succeed since it is clearly listed in the current view hierarchy and screenshot. - - step: Scroll down within the sidebar navigation container - recording: - tools: - - web_scroll: - ref: navigation "Sidebar navigation" - reasoning: The sidebar contains more categories than currently visible, so scrolling down within 'Sidebar navigation' will reveal further items and accomplish the objective. - nodeSelector: - web: - ariaRole: navigation - ariaNameRegex: Sidebar navigation - - step: Use the web_verifyTextVisible tool to verify "Category 15" is visible - recording: - tools: - - web_verifyTextVisible: - text: Category 15 - reasoning: The visible hierarchy shows 'Category 15' as a link in the sidebar navigation. Verifying that this text is visible fulfills the objective. - - step: Scroll down within the content panel - recording: - tools: - - web_scroll: - ref: e26 - element: Content panel region - reasoning: The objective is to scroll down within the content panel. The content panel corresponds to region 'e26'. I will scroll down within this region to achieve the objective. - nodeSelector: - web: - ariaRole: region - ariaNameRegex: Content panel - - step: Use the web_verifyTextVisible tool to verify "Item 20" is visible - recording: - tools: - - web_verifyTextVisible: - text: Item 20 - reasoning: The current objective requests verification that 'Item 20' is visible. According to the view hierarchy and screenshot, 'Item 20' is visible within the content panel. Using the web_verifyTextVisible tool to confirm its presence as required by the objective. - - step: Scroll up within the content panel - recording: - tools: - - web_scroll: - direction: UP - ref: e26 - element: Content panel region - reasoning: The objective is to scroll up within the content panel. The element e26 represents the content panel region, so scrolling up within this region will satisfy the objective. - nodeSelector: - web: - ariaRole: region - ariaNameRegex: Content panel diff --git a/trails/playwright-native/test-search-duplicates/blaze.yaml b/trails/playwright-native/test-search-duplicates/blaze.yaml deleted file mode 100644 index 4a2e37f43..000000000 --- a/trails/playwright-native/test-search-duplicates/blaze.yaml +++ /dev/null @@ -1,60 +0,0 @@ -- config: - title: "Test: Search and click specific results with duplicate text" - driver: PLAYWRIGHT_NATIVE -- prompts: - # Navigate to the search page - - step: Navigate to ../../../examples/playwright-native/sample-app/search-duplicates.html - - # Verify the page loaded - - step: Verify the heading "Product Search" is visible - - # --- Search for "Wireless Mouse" and interact with duplicate results --- - - step: Type "Wireless Mouse" into the search input field - - step: Click the "Search" button - - step: Verify the text "4 results" is visible - - # Click the Bluetooth, Black result — its aria-label is "Wireless Mouse - Bluetooth, Black - $29.99" - - step: Click the button labeled "Wireless Mouse - Bluetooth, Black - $29.99" - - step: Verify the text "Bluetooth, Black" is visible in the detail section at the bottom - - # Click the Bluetooth, White result - - step: Click the button labeled "Wireless Mouse - Bluetooth, White - $29.99" - - step: Verify the text "Bluetooth, White" is visible in the detail section at the bottom - - # Click the USB-A, Black result - - step: Click the button labeled "Wireless Mouse - USB-A, Black - $19.99" - - step: Verify the text "USB-A, Black" is visible in the detail section at the bottom - - # Click the USB-C, Gray result - - step: Click the button labeled "Wireless Mouse - USB-C, Gray - $34.99" - - step: Verify the text "USB-C, Gray" is visible in the detail section at the bottom - - # --- Search for "Keyboard" and interact with duplicate results --- - - step: Clear the search input field and type "Keyboard" - - step: Click the "Search" button - - step: Verify the text "3 results" is visible - - # Click each keyboard result - - step: Click the button labeled "Keyboard - Mechanical, Full-size - $89.99" - - step: Verify the text "Mechanical, Full-size" is visible in the detail section at the bottom - - - step: Click the button labeled "Keyboard - Membrane, Compact - $39.99" - - step: Verify the text "Membrane, Compact" is visible in the detail section at the bottom - - - step: Click the button labeled "Keyboard - Mechanical, Compact - $79.99" - - step: Verify the text "Mechanical, Compact" is visible in the detail section at the bottom - - # --- Search for "Monitor Stand" and interact with duplicate results --- - - step: Clear the search input field and type "Monitor Stand" - - step: Click the "Search" button - - step: Verify the text "3 results" is visible - - # Click each Monitor Stand result - - step: Click the button labeled "Monitor Stand - Aluminum, Single - $49.99" - - step: Verify the text "Aluminum, Single" is visible in the detail section at the bottom - - - step: Click the button labeled "Monitor Stand - Aluminum, Dual - $79.99" - - step: Verify the text "Aluminum, Dual" is visible in the detail section at the bottom - - - step: Click the button labeled "Monitor Stand - Wood, Single - $59.99" - - step: Verify the text "Wood, Single" is visible in the detail section at the bottom diff --git a/trails/playwright-native/test-search-duplicates/trail.yaml b/trails/playwright-native/test-search-duplicates/trail.yaml new file mode 100644 index 000000000..c071e155d --- /dev/null +++ b/trails/playwright-native/test-search-duplicates/trail.yaml @@ -0,0 +1,300 @@ +config: + devices: + web: PLAYWRIGHT_NATIVE + title: 'Test: Search and click specific results with duplicate text' + +trail: + - step: "Navigate to ../../../examples/playwright-native/sample-app/search-duplicates.html" + recording: + web: + - web_navigate: + url: ../../../examples/playwright-native/sample-app/search-duplicates.html + reasoning: The objective is to navigate to the specified URL. Since there are no interactive elements on the current screen, direct navigation using the target URL is the correct next step. + + - step: "Verify the heading \"Product Search\" is visible" + recording: + web: + - web_verifyElementVisible: + ref: e1 + reasoning: The current objective is to verify that the heading 'Product Search' is visible. The view hierarchy confirms that this heading is present as element [e1]. I will verify its visibility to satisfy the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Product Search + + - step: "Type \"Wireless Mouse\" into the search input field" + recording: + web: + - web_type: + text: Wireless Mouse + ref: e2 + reasoning: The objective is to type 'Wireless Mouse' into the search input field. The element with ID e2 is labeled 'Search products' and is the appropriate textbox for this action. + nodeSelector: + web: + cssSelector: "#search-input" + + - step: "Click the \"Search\" button" + recording: + web: + - web_click: + ref: e3 + reasoning: The objective is to click the 'Search' button. The view hierarchy shows a button labeled 'Search' with element ID e3, which is the clear target for this action. + nodeSelector: + web: + cssSelector: "#search-btn" + + - step: "Verify the text \"4 results\" is visible" + recording: + web: + - web_verifyTextVisible: + text: 4 results + reasoning: The objective is to verify that the text '4 results' is visible on the page. This text appears just above the search results on the current screen. + + - step: "Click the button labeled \"Wireless Mouse - Bluetooth, Black - $29.99\"" + recording: + web: + - web_click: + ref: e5 + reasoning: The objective is to click the button labeled 'Wireless Mouse - Bluetooth, Black - $29.99'. In the view hierarchy, e5 is the list item with the matching label. Clicking this should fulfill the objective. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QWireless Mouse - Bluetooth, Black - $29.99\E + + - step: "Verify the text \"Bluetooth, Black\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Bluetooth, Black + reasoning: 'The objective is to verify that the text "Bluetooth, Black" is visible in the detail section at the bottom of the screen. I can see "Viewing: Wireless Mouse — Bluetooth, Black, $29.99 (ID: 1)" at the bottom, which contains the required text. Directly verifying the presence of "Bluetooth, Black" fulfills the objective.' + + - step: "Click the button labeled \"Wireless Mouse - Bluetooth, White - $29.99\"" + recording: + web: + - web_click: + ref: e6 + reasoning: The objective requires clicking the button labeled 'Wireless Mouse - Bluetooth, White - $29.99'. The element with ID e6 matches this description, so clicking it will fulfill the objective. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QWireless Mouse - Bluetooth, White - $29.99\E + + - step: "Verify the text \"Bluetooth, White\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Bluetooth, White + reasoning: The objective requires verifying that 'Bluetooth, White' is visible in the detail section at the bottom. The detail section is shown with the 'Viewing:' label, and the visible text 'Bluetooth, White' should be present in this section to fulfill the objective. + - web_verifyTextVisible: + text: Bluetooth, White + reasoning: The objective is to verify that the text 'Bluetooth, White' appears in the detail section at the bottom. The view hierarchy shows the detail section at the bottom ('Viewing:'), and the screenshot shows 'Bluetooth, White' is included in that section. + + - step: "Click the button labeled \"Wireless Mouse - USB-A, Black - $19.99\"" + recording: + web: + - web_click: + ref: e7 + reasoning: The objective is to click the button/list item labeled 'Wireless Mouse - USB-A, Black - $19.99'. The element [e7] matches this label exactly, so clicking it will complete the task. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QWireless Mouse - USB-A, Black - $19.99\E + + - step: "Verify the text \"USB-A, Black\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: USB-A, Black + reasoning: The objective is to verify that the text 'USB-A, Black' is visible in the detail section at the bottom. The visible detail section appears to be below the product list, confirming product selection. I am verifying the presence of this text as required. + + - step: "Click the button labeled \"Wireless Mouse - USB-C, Gray - $34.99\"" + recording: + web: + - web_click: + ref: e8 + reasoning: Objective directs to click the button labeled 'Wireless Mouse - USB-C, Gray - $34.99'; 'e8' is the corresponding list item, which is likely clickable. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QWireless Mouse - USB-C, Gray - $34.99\E + + - step: "Verify the text \"USB-C, Gray\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: USB-C, Gray + reasoning: 'The objective requires verifying that the text ''USB-C, Gray'' is visible in the detail section at the bottom. I observe the relevant detail (''Viewing: Wireless Mouse — USB-C, Gray, $34.99 (ID: 4)'') at the bottom. Verifying visibility of the text ''USB-C, Gray'' ensures the detail section is displaying the required information.' + + - step: "Clear the search input field and type \"Keyboard\"" + recording: + web: + - web_type: + text: Keyboard + ref: e2 + reasoning: The current objective is to clear the search input field and type 'Keyboard'. I will clear the textbox 'Search products' and enter the required text. + nodeSelector: + web: + cssSelector: "#search-input" + - web_type: + text: Keyboard + ref: e2 + reasoning: To fulfill the objective, I need to clear the search input field and type 'Keyboard'. I will clear the input first and then enter the required text. + nodeSelector: + web: + cssSelector: "#search-input" + + - step: "Click the \"Search\" button" + recording: + web: + - web_click: + ref: e3 + reasoning: The current objective is to click the "Search" button. The button with element ID e3 matches this label. Clicking it should trigger the search action. + nodeSelector: + web: + cssSelector: "#search-btn" + + - step: "Verify the text \"3 results\" is visible" + recording: + web: + - web_verifyTextVisible: + text: 3 results + reasoning: The objective is to verify that the text '3 results' is visible. This text indicates the number of product search results and verifying its presence ensures the correct UI feedback for the search query. + + - step: "Click the button labeled \"Keyboard - Mechanical, Full-size - $89.99\"" + recording: + web: + - web_click: + ref: e5 + reasoning: The list item [e5] matches the label 'Keyboard - Mechanical, Full-size - $89.99' from the objective. Clicking it will fulfill the objective to click this button or interactive target. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QKeyboard - Mechanical, Full-size - $89.99\E + + - step: "Verify the text \"Mechanical, Full-size\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Mechanical, Full-size + reasoning: The objective requires verifying that the text "Mechanical, Full-size" is visible in the detail section at the bottom. The UI screenshot confirms this text is visible in the detail view. Verifying the presence of this specific text on the page will meet the objective. + + - step: "Click the button labeled \"Keyboard - Membrane, Compact - $39.99\"" + recording: + web: + - web_click: + ref: e6 + reasoning: The objective is to click the button labeled 'Keyboard - Membrane, Compact - $39.99'. In this UI, product options are rendered as selectable list items. e6 matches the target label, so I will click it to fulfill the objective. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QKeyboard - Membrane, Compact - $39.99\E + + - step: "Verify the text \"Membrane, Compact\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Membrane, Compact + reasoning: The objective is to verify that the text 'Membrane, Compact' is visible in the detail section at the bottom. From the screenshot, the detail section at the bottom indeed includes this text as part of the 'Viewing' label. + + - step: "Click the button labeled \"Keyboard - Mechanical, Compact - $79.99\"" + recording: + web: + - web_click: + ref: e7 + reasoning: The objective is to click the button labeled 'Keyboard - Mechanical, Compact - $79.99'. The corresponding listitem with this exact label is [e7], so I will click it to complete the action. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QKeyboard - Mechanical, Compact - $79.99\E + + - step: "Verify the text \"Mechanical, Compact\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Mechanical, Compact + reasoning: The objective asks to verify that the text 'Mechanical, Compact' is visible in the detail section at the bottom. This text appears within the detail line starting with 'Viewing:' at the bottom of the screen. Verifying the visibility of the exact text fulfills the objective. + - web_verifyTextVisible: + text: Mechanical, Compact + reasoning: The objective requires confirming the text 'Mechanical, Compact' is visible in the detail section at the bottom. The page structure and screenshot show this text appears in the green-highlighted 'Viewing:' area at the bottom, so verifying its visibility will complete the objective. + + - step: "Clear the search input field and type \"Monitor Stand\"" + recording: + web: + - web_type: + text: Monitor Stand + ref: e2 + reasoning: To complete the objective, I need to clear the current text in the search input and type 'Monitor Stand' in its place. + nodeSelector: + web: + cssSelector: "#search-input" + + - step: "Click the \"Search\" button" + recording: + web: + - web_click: + ref: e3 + reasoning: The objective is to click the 'Search' button. The element [e3] is labeled as the 'Search' button, making it the correct target for this task. I expect this action to trigger the product search. + nodeSelector: + web: + cssSelector: "#search-btn" + + - step: "Verify the text \"3 results\" is visible" + recording: + web: + - web_verifyTextVisible: + text: 3 results + reasoning: The objective is to verify that the text '3 results' is visible on the current screen. This matches the requirement and confirms correct UI feedback for the search results. + + - step: "Click the button labeled \"Monitor Stand - Aluminum, Single - $49.99\"" + recording: + web: + - web_click: + ref: e5 + reasoning: The objective is to click the button labeled 'Monitor Stand - Aluminum, Single - $49.99'. The list item e5 matches this label, and I expect that clicking it will execute the desired action or possibly reveal more product details. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QMonitor Stand - Aluminum, Single - $49.99\E + + - step: "Verify the text \"Aluminum, Single\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Aluminum, Single + reasoning: The objective is to verify that the detail section at the bottom displays the text 'Aluminum, Single.' The screenshot shows a bottom section with summary text containing this phrase, so verifying its presence will complete the objective. + + - step: "Click the button labeled \"Monitor Stand - Aluminum, Dual - $79.99\"" + recording: + web: + - web_click: + ref: e6 + reasoning: The objective clearly requests clicking the button labeled 'Monitor Stand - Aluminum, Dual - $79.99'. In the current view, this matches the list item with ID e6. I expect this action to select or show details for this product. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QMonitor Stand - Aluminum, Dual - $79.99\E + + - step: "Verify the text \"Aluminum, Dual\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Aluminum, Dual + reasoning: The objective requires verifying that the text 'Aluminum, Dual' appears in the detail section at the bottom of the page. Observing the UI, the 'Viewing:' area contains this text, so verifying its visibility will satisfy the requirement. + + - step: "Click the button labeled \"Monitor Stand - Wood, Single - $59.99\"" + recording: + web: + - web_click: + ref: e7 + reasoning: The current objective is to click the button labeled 'Monitor Stand - Wood, Single - $59.99'. The element [e7] matches this label, so clicking it should fulfill the objective. + nodeSelector: + web: + ariaRole: listitem + ariaNameRegex: \QMonitor Stand - Wood, Single - $59.99\E + + - step: "Verify the text \"Wood, Single\" is visible in the detail section at the bottom" + recording: + web: + - web_verifyTextVisible: + text: Wood, Single + reasoning: The objective asks to verify that the text 'Wood, Single' is visible in the detail section at the bottom. The detail area appears to contain this text, so verifying its visibility fulfills the objective. diff --git a/trails/playwright-native/test-search-duplicates/web.trail.yaml b/trails/playwright-native/test-search-duplicates/web.trail.yaml deleted file mode 100644 index e79475715..000000000 --- a/trails/playwright-native/test-search-duplicates/web.trail.yaml +++ /dev/null @@ -1,287 +0,0 @@ -- config: - title: 'Test: Search and click specific results with duplicate text' - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to ../../../examples/playwright-native/sample-app/search-duplicates.html - recording: - tools: - - web_navigate: - url: ../../../examples/playwright-native/sample-app/search-duplicates.html - reasoning: The objective is to navigate to the specified URL. Since there are no interactive elements on the current screen, direct navigation using the target URL is the correct next step. - - step: Verify the heading "Product Search" is visible - recording: - tools: - - web_verifyElementVisible: - ref: e1 - element: heading 'Product Search' - reasoning: The current objective is to verify that the heading 'Product Search' is visible. The view hierarchy confirms that this heading is present as element [e1]. I will verify its visibility to satisfy the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Product Search - - step: Type "Wireless Mouse" into the search input field - recording: - tools: - - web_type: - text: Wireless Mouse - ref: e2 - element: textbox 'Search products' - reasoning: The objective is to type 'Wireless Mouse' into the search input field. The element with ID e2 is labeled 'Search products' and is the appropriate textbox for this action. - nodeSelector: - web: - cssSelector: "#search-input" - - step: Click the "Search" button - recording: - tools: - - web_click: - ref: e3 - element: button "Search" - reasoning: The objective is to click the 'Search' button. The view hierarchy shows a button labeled 'Search' with element ID e3, which is the clear target for this action. - nodeSelector: - web: - cssSelector: "#search-btn" - - step: Verify the text "4 results" is visible - recording: - tools: - - web_verifyTextVisible: - text: 4 results - reasoning: The objective is to verify that the text '4 results' is visible on the page. This text appears just above the search results on the current screen. - - step: Click the button labeled "Wireless Mouse - Bluetooth, Black - $29.99" - recording: - tools: - - web_click: - ref: e5 - element: listitem 'Wireless Mouse - Bluetooth, Black - $29.99' - reasoning: The objective is to click the button labeled 'Wireless Mouse - Bluetooth, Black - $29.99'. In the view hierarchy, e5 is the list item with the matching label. Clicking this should fulfill the objective. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QWireless Mouse - Bluetooth, Black - $29.99\E - - step: Verify the text "Bluetooth, Black" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Bluetooth, Black - reasoning: 'The objective is to verify that the text "Bluetooth, Black" is visible in the detail section at the bottom of the screen. I can see "Viewing: Wireless Mouse — Bluetooth, Black, $29.99 (ID: 1)" at the bottom, which contains the required text. Directly verifying the presence of "Bluetooth, Black" fulfills the objective.' - - step: Click the button labeled "Wireless Mouse - Bluetooth, White - $29.99" - recording: - tools: - - web_click: - ref: e6 - element: listitem 'Wireless Mouse - Bluetooth, White - $29.99' - reasoning: The objective requires clicking the button labeled 'Wireless Mouse - Bluetooth, White - $29.99'. The element with ID e6 matches this description, so clicking it will fulfill the objective. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QWireless Mouse - Bluetooth, White - $29.99\E - - step: Verify the text "Bluetooth, White" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Bluetooth, White - reasoning: The objective requires verifying that 'Bluetooth, White' is visible in the detail section at the bottom. The detail section is shown with the 'Viewing:' label, and the visible text 'Bluetooth, White' should be present in this section to fulfill the objective. - - web_verifyTextVisible: - text: Bluetooth, White - reasoning: The objective is to verify that the text 'Bluetooth, White' appears in the detail section at the bottom. The view hierarchy shows the detail section at the bottom ('Viewing:'), and the screenshot shows 'Bluetooth, White' is included in that section. - - step: Click the button labeled "Wireless Mouse - USB-A, Black - $19.99" - recording: - tools: - - web_click: - ref: e7 - element: listitem 'Wireless Mouse - USB-A, Black - $19.99' - reasoning: The objective is to click the button/list item labeled 'Wireless Mouse - USB-A, Black - $19.99'. The element [e7] matches this label exactly, so clicking it will complete the task. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QWireless Mouse - USB-A, Black - $19.99\E - - step: Verify the text "USB-A, Black" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: USB-A, Black - reasoning: The objective is to verify that the text 'USB-A, Black' is visible in the detail section at the bottom. The visible detail section appears to be below the product list, confirming product selection. I am verifying the presence of this text as required. - - step: Click the button labeled "Wireless Mouse - USB-C, Gray - $34.99" - recording: - tools: - - web_click: - ref: e8 - element: listitem "Wireless Mouse - USB-C, Gray - $34.99" - reasoning: Objective directs to click the button labeled 'Wireless Mouse - USB-C, Gray - $34.99'; 'e8' is the corresponding list item, which is likely clickable. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QWireless Mouse - USB-C, Gray - $34.99\E - - step: Verify the text "USB-C, Gray" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: USB-C, Gray - reasoning: 'The objective requires verifying that the text ''USB-C, Gray'' is visible in the detail section at the bottom. I observe the relevant detail (''Viewing: Wireless Mouse — USB-C, Gray, $34.99 (ID: 4)'') at the bottom. Verifying visibility of the text ''USB-C, Gray'' ensures the detail section is displaying the required information.' - - step: Clear the search input field and type "Keyboard" - recording: - tools: - - web_type: - text: Keyboard - ref: e2 - element: textbox 'Search products' - reasoning: The current objective is to clear the search input field and type 'Keyboard'. I will clear the textbox 'Search products' and enter the required text. - nodeSelector: - web: - cssSelector: "#search-input" - - web_type: - text: Keyboard - ref: e2 - element: textbox 'Search products' - reasoning: To fulfill the objective, I need to clear the search input field and type 'Keyboard'. I will clear the input first and then enter the required text. - nodeSelector: - web: - cssSelector: "#search-input" - - step: Click the "Search" button - recording: - tools: - - web_click: - ref: e3 - element: button "Search" - reasoning: The current objective is to click the "Search" button. The button with element ID e3 matches this label. Clicking it should trigger the search action. - nodeSelector: - web: - cssSelector: "#search-btn" - - step: Verify the text "3 results" is visible - recording: - tools: - - web_verifyTextVisible: - text: 3 results - reasoning: The objective is to verify that the text '3 results' is visible. This text indicates the number of product search results and verifying its presence ensures the correct UI feedback for the search query. - - step: Click the button labeled "Keyboard - Mechanical, Full-size - $89.99" - recording: - tools: - - web_click: - ref: e5 - element: Keyboard - Mechanical, Full-size - $89.99 list item - reasoning: The list item [e5] matches the label 'Keyboard - Mechanical, Full-size - $89.99' from the objective. Clicking it will fulfill the objective to click this button or interactive target. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QKeyboard - Mechanical, Full-size - $89.99\E - - step: Verify the text "Mechanical, Full-size" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Mechanical, Full-size - reasoning: The objective requires verifying that the text "Mechanical, Full-size" is visible in the detail section at the bottom. The UI screenshot confirms this text is visible in the detail view. Verifying the presence of this specific text on the page will meet the objective. - - step: Click the button labeled "Keyboard - Membrane, Compact - $39.99" - recording: - tools: - - web_click: - ref: e6 - element: Keyboard - Membrane, Compact - $39.99 list item - reasoning: The objective is to click the button labeled 'Keyboard - Membrane, Compact - $39.99'. In this UI, product options are rendered as selectable list items. e6 matches the target label, so I will click it to fulfill the objective. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QKeyboard - Membrane, Compact - $39.99\E - - step: Verify the text "Membrane, Compact" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Membrane, Compact - reasoning: The objective is to verify that the text 'Membrane, Compact' is visible in the detail section at the bottom. From the screenshot, the detail section at the bottom indeed includes this text as part of the 'Viewing' label. - - step: Click the button labeled "Keyboard - Mechanical, Compact - $79.99" - recording: - tools: - - web_click: - ref: e7 - element: listitem "Keyboard - Mechanical, Compact - $79.99" - reasoning: The objective is to click the button labeled 'Keyboard - Mechanical, Compact - $79.99'. The corresponding listitem with this exact label is [e7], so I will click it to complete the action. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QKeyboard - Mechanical, Compact - $79.99\E - - step: Verify the text "Mechanical, Compact" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Mechanical, Compact - reasoning: The objective asks to verify that the text 'Mechanical, Compact' is visible in the detail section at the bottom. This text appears within the detail line starting with 'Viewing:' at the bottom of the screen. Verifying the visibility of the exact text fulfills the objective. - - web_verifyTextVisible: - text: Mechanical, Compact - reasoning: The objective requires confirming the text 'Mechanical, Compact' is visible in the detail section at the bottom. The page structure and screenshot show this text appears in the green-highlighted 'Viewing:' area at the bottom, so verifying its visibility will complete the objective. - - step: Clear the search input field and type "Monitor Stand" - recording: - tools: - - web_type: - text: Monitor Stand - ref: e2 - element: textbox "Search products" - reasoning: To complete the objective, I need to clear the current text in the search input and type 'Monitor Stand' in its place. - nodeSelector: - web: - cssSelector: "#search-input" - - step: Click the "Search" button - recording: - tools: - - web_click: - ref: e3 - element: button "Search" - reasoning: The objective is to click the 'Search' button. The element [e3] is labeled as the 'Search' button, making it the correct target for this task. I expect this action to trigger the product search. - nodeSelector: - web: - cssSelector: "#search-btn" - - step: Verify the text "3 results" is visible - recording: - tools: - - web_verifyTextVisible: - text: 3 results - reasoning: The objective is to verify that the text '3 results' is visible on the current screen. This matches the requirement and confirms correct UI feedback for the search results. - - step: Click the button labeled "Monitor Stand - Aluminum, Single - $49.99" - recording: - tools: - - web_click: - ref: e5 - element: listitem "Monitor Stand - Aluminum, Single - $49.99" - reasoning: The objective is to click the button labeled 'Monitor Stand - Aluminum, Single - $49.99'. The list item e5 matches this label, and I expect that clicking it will execute the desired action or possibly reveal more product details. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QMonitor Stand - Aluminum, Single - $49.99\E - - step: Verify the text "Aluminum, Single" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Aluminum, Single - reasoning: The objective is to verify that the detail section at the bottom displays the text 'Aluminum, Single.' The screenshot shows a bottom section with summary text containing this phrase, so verifying its presence will complete the objective. - - step: Click the button labeled "Monitor Stand - Aluminum, Dual - $79.99" - recording: - tools: - - web_click: - ref: e6 - element: listitem "Monitor Stand - Aluminum, Dual - $79.99" - reasoning: The objective clearly requests clicking the button labeled 'Monitor Stand - Aluminum, Dual - $79.99'. In the current view, this matches the list item with ID e6. I expect this action to select or show details for this product. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QMonitor Stand - Aluminum, Dual - $79.99\E - - step: Verify the text "Aluminum, Dual" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Aluminum, Dual - reasoning: The objective requires verifying that the text 'Aluminum, Dual' appears in the detail section at the bottom of the page. Observing the UI, the 'Viewing:' area contains this text, so verifying its visibility will satisfy the requirement. - - step: Click the button labeled "Monitor Stand - Wood, Single - $59.99" - recording: - tools: - - web_click: - ref: e7 - element: Monitor Stand - Wood, Single - $59.99 - reasoning: The current objective is to click the button labeled 'Monitor Stand - Wood, Single - $59.99'. The element [e7] matches this label, so clicking it should fulfill the objective. - nodeSelector: - web: - ariaRole: listitem - ariaNameRegex: \QMonitor Stand - Wood, Single - $59.99\E - - step: Verify the text "Wood, Single" is visible in the detail section at the bottom - recording: - tools: - - web_verifyTextVisible: - text: Wood, Single - reasoning: The objective asks to verify that the text 'Wood, Single' is visible in the detail section at the bottom. The detail area appears to contain this text, so verifying its visibility fulfills the objective. diff --git a/trails/wikipedia/test-about-page/blaze.yaml b/trails/wikipedia/test-about-page/blaze.yaml deleted file mode 100644 index a35776a84..000000000 --- a/trails/wikipedia/test-about-page/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: About page opens and shows the project description" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Wikipedia:About - - step: Verify the heading "Wikipedia:About" or "About Wikipedia" is visible at the top of the page. - - step: Verify that body text containing the phrase "free encyclopedia" or "anyone can edit" is visible. diff --git a/trails/wikipedia/test-about-page/trail.yaml b/trails/wikipedia/test-about-page/trail.yaml new file mode 100644 index 000000000..1f7f4b5a8 --- /dev/null +++ b/trails/wikipedia/test-about-page/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: About page opens and shows the project description' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Wikipedia:About + - step: Verify the heading "Wikipedia:About" or "About Wikipedia" is visible at the top of the page. + - step: Verify that body text containing the phrase "free encyclopedia" or "anyone can edit" is visible. diff --git a/trails/wikipedia/test-article-first-paragraph/blaze.yaml b/trails/wikipedia/test-article-first-paragraph/blaze.yaml deleted file mode 100644 index a8af26d03..000000000 --- a/trails/wikipedia/test-article-first-paragraph/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: First paragraph of an article is readable" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Wikipedia - - step: Verify the heading "Wikipedia" is visible. - - step: Verify the article body text contains the phrase "free content" somewhere near the top of the page. diff --git a/trails/wikipedia/test-article-first-paragraph/trail.yaml b/trails/wikipedia/test-article-first-paragraph/trail.yaml new file mode 100644 index 000000000..164ad6cbc --- /dev/null +++ b/trails/wikipedia/test-article-first-paragraph/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: First paragraph of an article is readable' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Wikipedia + - step: Verify the heading "Wikipedia" is visible. + - step: Verify the article body text contains the phrase "free content" somewhere near the top of the page. diff --git a/trails/wikipedia/test-article-image-present/blaze.yaml b/trails/wikipedia/test-article-image-present/blaze.yaml deleted file mode 100644 index c0db7b3e3..000000000 --- a/trails/wikipedia/test-article-image-present/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Article contains at least one image" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Mount_Everest - - step: Verify the heading "Mount Everest" is visible. - - step: Verify that at least one image element is visible in the article body (Wikipedia articles about geographic features always include thumbnails in the infobox). diff --git a/trails/wikipedia/test-article-image-present/trail.yaml b/trails/wikipedia/test-article-image-present/trail.yaml new file mode 100644 index 000000000..0b78518ac --- /dev/null +++ b/trails/wikipedia/test-article-image-present/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Article contains at least one image' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Mount_Everest + - step: Verify the heading "Mount Everest" is visible. + - step: Verify that at least one image element is visible in the article body (Wikipedia articles about geographic features always include thumbnails in the infobox). diff --git a/trails/wikipedia/test-article-references-section/blaze.yaml b/trails/wikipedia/test-article-references-section/blaze.yaml deleted file mode 100644 index a00b949df..000000000 --- a/trails/wikipedia/test-article-references-section/blaze.yaml +++ /dev/null @@ -1,16 +0,0 @@ -- config: - title: "Wikipedia: Article structure verification (heading, body, References)" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [article, slow] -- prompts: - # Exercises the full path of `wikipedia_web_verifyArticleStructure`: - # - non-empty `expectedHeading` (text-match branch) - # - `requireReferences: true` (scroll-and-find branch — what makes this - # trail "slow") - - step: | - Open the Wikipedia article for "Albert Einstein" and verify the - article is well-formed: first heading is "Albert Einstein", body - content is present, and a References section is reachable by - scrolling. diff --git a/trails/wikipedia/test-article-references-section/trail.yaml b/trails/wikipedia/test-article-references-section/trail.yaml new file mode 100644 index 000000000..2b69ebdfa --- /dev/null +++ b/trails/wikipedia/test-article-references-section/trail.yaml @@ -0,0 +1,19 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - article + - slow + title: 'Wikipedia: Article structure verification (heading, body, References)' + +trail: + # Exercises the full path of `wikipedia_web_verifyArticleStructure`: + # - non-empty `expectedHeading` (text-match branch) + # - `requireReferences: true` (scroll-and-find branch — what makes this + # trail "slow") + - step: | + Open the Wikipedia article for "Albert Einstein" and verify the + article is well-formed: first heading is "Albert Einstein", body + content is present, and a References section is reachable by + scrolling. diff --git a/trails/wikipedia/test-article-shakespeare/blaze.yaml b/trails/wikipedia/test-article-shakespeare/blaze.yaml deleted file mode 100644 index 3e8d8573d..000000000 --- a/trails/wikipedia/test-article-shakespeare/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Open the William Shakespeare article" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, article] -- prompts: - - step: Open the Wikipedia article for "William Shakespeare" and verify the article's first heading is "William Shakespeare". diff --git a/trails/wikipedia/test-article-shakespeare/trail.yaml b/trails/wikipedia/test-article-shakespeare/trail.yaml new file mode 100644 index 000000000..dbdb9da26 --- /dev/null +++ b/trails/wikipedia/test-article-shakespeare/trail.yaml @@ -0,0 +1,23 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - article + title: 'Wikipedia: Open the William Shakespeare article' + +trail: + - step: "Open the Wikipedia article for \"William Shakespeare\" and verify the article's first heading is \"William Shakespeare\"." + recording: + web: + - web_navigate: + url: https://en.wikipedia.org/wiki/William_Shakespeare + reasoning: The screen is currently blank with no interactive elements, so I will directly navigate to the Wikipedia article for William Shakespeare to begin the task. + - web_verifyValue: + expected: William Shakespeare + reasoning: The first heading identified in the main content area is attached to element [e48]. I will verify that its text matches 'William Shakespeare' as required by the objective. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: William Shakespeare diff --git a/trails/wikipedia/test-article-shakespeare/web.trail.yaml b/trails/wikipedia/test-article-shakespeare/web.trail.yaml deleted file mode 100644 index 076bb15a1..000000000 --- a/trails/wikipedia/test-article-shakespeare/web.trail.yaml +++ /dev/null @@ -1,19 +0,0 @@ -- config: - title: 'Wikipedia: Open the William Shakespeare article' - target: wikipedia - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Open the Wikipedia article for "William Shakespeare" and verify the article's first heading is "William Shakespeare". - recording: - tools: - - web_navigate: - url: https://en.wikipedia.org/wiki/William_Shakespeare - reasoning: The screen is currently blank with no interactive elements, so I will directly navigate to the Wikipedia article for William Shakespeare to begin the task. - - web_verifyValue: - expected: William Shakespeare - reasoning: The first heading identified in the main content area is attached to element [e48]. I will verify that its text matches 'William Shakespeare' as required by the objective. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: William Shakespeare diff --git a/trails/wikipedia/test-article-short-no-refs/blaze.yaml b/trails/wikipedia/test-article-short-no-refs/blaze.yaml deleted file mode 100644 index b80b432c6..000000000 --- a/trails/wikipedia/test-article-short-no-refs/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -- config: - title: "Wikipedia: Article structure verification (heading + body only)" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [article] -- prompts: - # Exercises the `requireReferences: false` early-return branch of - # `wikipedia_web_verifyArticleStructure`. Pair with - # `test-article-references-section`, which covers `requireReferences: true`. - - step: | - Open the Wikipedia article for "Wikipedia" and verify it's a - well-formed article (first heading and body content visible). Do - NOT scroll down or check for a References section — only confirm - the article rendered. diff --git a/trails/wikipedia/test-article-short-no-refs/trail.yaml b/trails/wikipedia/test-article-short-no-refs/trail.yaml new file mode 100644 index 000000000..722338443 --- /dev/null +++ b/trails/wikipedia/test-article-short-no-refs/trail.yaml @@ -0,0 +1,17 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - article + title: 'Wikipedia: Article structure verification (heading + body only)' + +trail: + # Exercises the `requireReferences: false` early-return branch of + # `wikipedia_web_verifyArticleStructure`. Pair with + # `test-article-references-section`, which covers `requireReferences: true`. + - step: | + Open the Wikipedia article for "Wikipedia" and verify it's a + well-formed article (first heading and body content visible). Do + NOT scroll down or check for a References section — only confirm + the article rendered. diff --git a/trails/wikipedia/test-article-toc-visible/blaze.yaml b/trails/wikipedia/test-article-toc-visible/blaze.yaml deleted file mode 100644 index e82c568b1..000000000 --- a/trails/wikipedia/test-article-toc-visible/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Article has a table of contents" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein - - step: Verify the heading "Albert Einstein" is visible. - - step: Verify a "Contents" element (the article's table of contents) is visible somewhere on the page. diff --git a/trails/wikipedia/test-article-toc-visible/trail.yaml b/trails/wikipedia/test-article-toc-visible/trail.yaml new file mode 100644 index 000000000..840b58588 --- /dev/null +++ b/trails/wikipedia/test-article-toc-visible/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Article has a table of contents' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein + - step: Verify the heading "Albert Einstein" is visible. + - step: Verify a "Contents" element (the article's table of contents) is visible somewhere on the page. diff --git a/trails/wikipedia/test-back-navigation/blaze.yaml b/trails/wikipedia/test-back-navigation/blaze.yaml deleted file mode 100644 index 0dea75c55..000000000 --- a/trails/wikipedia/test-back-navigation/blaze.yaml +++ /dev/null @@ -1,11 +0,0 @@ -- config: - title: "Wikipedia: Browser back button returns to the previous article" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein - - step: Verify the heading "Albert Einstein" is visible. - - step: Click any hyperlink in the article body that leads to another Wikipedia article (for example a link like "physicist", "Germany", or "Theory of relativity"). - - step: Verify the page has changed — the new page has a different first heading from "Albert Einstein". - - step: Navigate back to the previous page using the browser back action. - - step: Verify the heading "Albert Einstein" is visible again. diff --git a/trails/wikipedia/test-back-navigation/trail.yaml b/trails/wikipedia/test-back-navigation/trail.yaml new file mode 100644 index 000000000..c3f890262 --- /dev/null +++ b/trails/wikipedia/test-back-navigation/trail.yaml @@ -0,0 +1,13 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Browser back button returns to the previous article' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein + - step: Verify the heading "Albert Einstein" is visible. + - step: Click any hyperlink in the article body that leads to another Wikipedia article (for example a link like "physicist", "Germany", or "Theory of relativity"). + - step: Verify the page has changed — the new page has a different first heading from "Albert Einstein". + - step: Navigate back to the previous page using the browser back action. + - step: Verify the heading "Albert Einstein" is visible again. diff --git a/trails/wikipedia/test-custom-main-page-section/blaze.yaml b/trails/wikipedia/test-custom-main-page-section/blaze.yaml deleted file mode 100644 index d0cacf752..000000000 --- a/trails/wikipedia/test-custom-main-page-section/blaze.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Demonstrates `wikipedia_web_openMainPageSection`. One scripted tool covers all -# four main-page section verifications by branching on the `section` arg. -- config: - id: "wikipedia/test-custom-main-page-section" - title: "Wikipedia (scripted): verify all four main-page sections via one tool" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia -- prompts: - - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. - - step: Call the `wikipedia_web_openMainPageSection` tool with section="tfa", ensureOnMainPage=false. - - step: Call the `wikipedia_web_openMainPageSection` tool with section="itn", ensureOnMainPage=false. - - step: Call the `wikipedia_web_openMainPageSection` tool with section="dyk", ensureOnMainPage=false. - - step: Call the `wikipedia_web_openMainPageSection` tool with section="otd", ensureOnMainPage=false. diff --git a/trails/wikipedia/test-custom-main-page-section/trail.yaml b/trails/wikipedia/test-custom-main-page-section/trail.yaml new file mode 100644 index 000000000..3177d6499 --- /dev/null +++ b/trails/wikipedia/test-custom-main-page-section/trail.yaml @@ -0,0 +1,15 @@ +# Demonstrates `wikipedia_web_openMainPageSection`. One scripted tool covers all +# four main-page section verifications by branching on the `section` arg. +config: + id: 'wikipedia/test-custom-main-page-section' + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia (scripted): verify all four main-page sections via one tool' + +trail: + - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. + - step: Call the `wikipedia_web_openMainPageSection` tool with section="tfa", ensureOnMainPage=false. + - step: Call the `wikipedia_web_openMainPageSection` tool with section="itn", ensureOnMainPage=false. + - step: Call the `wikipedia_web_openMainPageSection` tool with section="dyk", ensureOnMainPage=false. + - step: Call the `wikipedia_web_openMainPageSection` tool with section="otd", ensureOnMainPage=false. diff --git a/trails/wikipedia/test-custom-open-article/blaze.yaml b/trails/wikipedia/test-custom-open-article/blaze.yaml deleted file mode 100644 index 9f31b945b..000000000 --- a/trails/wikipedia/test-custom-open-article/blaze.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Demonstrates the `wikipedia_web_openArticle` scripted trailhead. Requires the -# Trailblaze daemon to be running with this trailmap's config dir set: -# -# export TRAILBLAZE_CONFIG_DIR=$PWD/examples/wikipedia/trails/config -# ./trailblaze app --stop && ./trailblaze app --headless & disown -# ./trailblaze run trails/wikipedia/test-custom-open-article --device web -- config: - id: "wikipedia/test-custom-open-article" - title: "Wikipedia (scripted): open Albert Einstein via wikipedia_web_openArticle" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia -- prompts: - - step: | - Call the `wikipedia_web_openArticle` tool with title="Albert Einstein", - expectedHeading="Albert Einstein", dismissBanner=true to navigate to the article. - - step: | - Call the `wikipedia_web_verifyArticleStructure` tool with - expectedHeading="Albert Einstein", requireReferences=true to confirm the - article shape. diff --git a/trails/wikipedia/test-custom-open-article/trail.yaml b/trails/wikipedia/test-custom-open-article/trail.yaml new file mode 100644 index 000000000..66aaf2d0c --- /dev/null +++ b/trails/wikipedia/test-custom-open-article/trail.yaml @@ -0,0 +1,21 @@ +# Demonstrates the `wikipedia_web_openArticle` scripted trailhead. Requires the +# Trailblaze daemon to be running with this trailmap's config dir set: +# +# export TRAILBLAZE_CONFIG_DIR=$PWD/examples/wikipedia/trails/config +# ./trailblaze app --stop && ./trailblaze app --headless & disown +# ./trailblaze run trails/wikipedia/test-custom-open-article --device web +config: + id: 'wikipedia/test-custom-open-article' + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia (scripted): open Albert Einstein via wikipedia_web_openArticle' + +trail: + - step: | + Call the `wikipedia_web_openArticle` tool with title="Albert Einstein", + expectedHeading="Albert Einstein", dismissBanner=true to navigate to the article. + - step: | + Call the `wikipedia_web_verifyArticleStructure` tool with + expectedHeading="Albert Einstein", requireReferences=true to confirm the + article shape. diff --git a/trails/wikipedia/test-custom-random-article/blaze.yaml b/trails/wikipedia/test-custom-random-article/blaze.yaml deleted file mode 100644 index 51941660c..000000000 --- a/trails/wikipedia/test-custom-random-article/blaze.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Demonstrates `wikipedia_web_openRandomArticle`. Destination article is -# non-deterministic, so the tool asserts only structural presence (#firstHeading). -- config: - id: "wikipedia/test-custom-random-article" - title: "Wikipedia (scripted): jump to a random article and verify it loaded" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia -- prompts: - - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. - - step: Call the `wikipedia_web_openRandomArticle` tool with ensureOnWikipedia=false. diff --git a/trails/wikipedia/test-custom-random-article/trail.yaml b/trails/wikipedia/test-custom-random-article/trail.yaml new file mode 100644 index 000000000..923ed9b60 --- /dev/null +++ b/trails/wikipedia/test-custom-random-article/trail.yaml @@ -0,0 +1,12 @@ +# Demonstrates `wikipedia_web_openRandomArticle`. Destination article is +# non-deterministic, so the tool asserts only structural presence (#firstHeading). +config: + id: 'wikipedia/test-custom-random-article' + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia (scripted): jump to a random article and verify it loaded' + +trail: + - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. + - step: Call the `wikipedia_web_openRandomArticle` tool with ensureOnWikipedia=false. diff --git a/trails/wikipedia/test-custom-search/blaze.yaml b/trails/wikipedia/test-custom-search/blaze.yaml deleted file mode 100644 index 3b33e5bdf..000000000 --- a/trails/wikipedia/test-custom-search/blaze.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Demonstrates `wikipedia_web_searchAndOpenFirstResult` composed on top of the -# `wikipedia_web_openMainPage` trailhead. -- config: - title: "Wikipedia (scripted): trailhead + search the header search box" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia -- prompts: - - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. - - step: | - Call the `wikipedia_web_searchAndOpenFirstResult` tool with - query="Python (programming language)", expectedHeading="Python (programming language)", - openFirstResult=true. diff --git a/trails/wikipedia/test-custom-search/trail.yaml b/trails/wikipedia/test-custom-search/trail.yaml new file mode 100644 index 000000000..666c6ffcd --- /dev/null +++ b/trails/wikipedia/test-custom-search/trail.yaml @@ -0,0 +1,14 @@ +# Demonstrates `wikipedia_web_searchAndOpenFirstResult` composed on top of the +# `wikipedia_web_openMainPage` trailhead. +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia (scripted): trailhead + search the header search box' + +trail: + - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. + - step: | + Call the `wikipedia_web_searchAndOpenFirstResult` tool with + query="Python (programming language)", expectedHeading="Python (programming language)", + openFirstResult=true. diff --git a/trails/wikipedia/test-footer-privacy-link/blaze.yaml b/trails/wikipedia/test-footer-privacy-link/blaze.yaml deleted file mode 100644 index 407551611..000000000 --- a/trails/wikipedia/test-footer-privacy-link/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Footer Privacy policy link is reachable" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Scroll all the way to the bottom of the page. - - step: Verify the link text "Privacy policy" is visible in the page footer. diff --git a/trails/wikipedia/test-footer-privacy-link/trail.yaml b/trails/wikipedia/test-footer-privacy-link/trail.yaml new file mode 100644 index 000000000..e4932f2d2 --- /dev/null +++ b/trails/wikipedia/test-footer-privacy-link/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Footer Privacy policy link is reachable' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: Scroll all the way to the bottom of the page. + - step: Verify the link text "Privacy policy" is visible in the page footer. diff --git a/trails/wikipedia/test-language-list-on-article/blaze.yaml b/trails/wikipedia/test-language-list-on-article/blaze.yaml deleted file mode 100644 index 78e1d727e..000000000 --- a/trails/wikipedia/test-language-list-on-article/blaze.yaml +++ /dev/null @@ -1,13 +0,0 @@ -- config: - title: "Wikipedia: Article exposes a list of available languages" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein - - step: | - Locate and (if needed) open the article's language menu. On Vector-2022 - this is the button labeled with a count followed by "languages" near the - top of the page; on smaller viewports it may already be expanded as a list - in the sidebar. - - step: Verify that the language list contains the entry "Español". - - step: Verify that the language list contains the entry "Deutsch". diff --git a/trails/wikipedia/test-language-list-on-article/trail.yaml b/trails/wikipedia/test-language-list-on-article/trail.yaml new file mode 100644 index 000000000..dba483ea9 --- /dev/null +++ b/trails/wikipedia/test-language-list-on-article/trail.yaml @@ -0,0 +1,15 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Article exposes a list of available languages' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein + - step: | + Locate and (if needed) open the article's language menu. On Vector-2022 + this is the button labeled with a count followed by "languages" near the + top of the page; on smaller viewports it may already be expanded as a list + in the sidebar. + - step: Verify that the language list contains the entry "Español". + - step: Verify that the language list contains the entry "Deutsch". diff --git a/trails/wikipedia/test-language-menu-visible/blaze.yaml b/trails/wikipedia/test-language-menu-visible/blaze.yaml deleted file mode 100644 index 7eb72926d..000000000 --- a/trails/wikipedia/test-language-menu-visible/blaze.yaml +++ /dev/null @@ -1,11 +0,0 @@ -- config: - title: "Wikipedia: Language menu trigger is present on an article" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein - - step: | - Verify that some form of language switcher is visible — either a button - labeled with a number followed by "languages" (e.g. "192 languages"), or - a sidebar / panel listing language codes such as "Español", "Français", - or "Deutsch". diff --git a/trails/wikipedia/test-language-menu-visible/trail.yaml b/trails/wikipedia/test-language-menu-visible/trail.yaml new file mode 100644 index 000000000..c5e582c3e --- /dev/null +++ b/trails/wikipedia/test-language-menu-visible/trail.yaml @@ -0,0 +1,13 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Language menu trigger is present on an article' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Albert_Einstein + - step: | + Verify that some form of language switcher is visible — either a button + labeled with a number followed by "languages" (e.g. "192 languages"), or + a sidebar / panel listing language codes such as "Español", "Français", + or "Deutsch". diff --git a/trails/wikipedia/test-language-switch-spanish/blaze.yaml b/trails/wikipedia/test-language-switch-spanish/blaze.yaml deleted file mode 100644 index 6b5aa40a9..000000000 --- a/trails/wikipedia/test-language-switch-spanish/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Switch the Albert Einstein article to Spanish" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [i18n, article] -- prompts: - - step: Open the Wikipedia article for "Albert Einstein" in Spanish and verify the article page rendered with the heading "Albert Einstein". diff --git a/trails/wikipedia/test-language-switch-spanish/trail.yaml b/trails/wikipedia/test-language-switch-spanish/trail.yaml new file mode 100644 index 000000000..8bbcd7c2f --- /dev/null +++ b/trails/wikipedia/test-language-switch-spanish/trail.yaml @@ -0,0 +1,22 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - i18n + - article + title: 'Wikipedia: Switch the Albert Einstein article to Spanish' + +trail: + - step: "Open the Wikipedia article for \"Albert Einstein\" in Spanish and verify the article page rendered with the heading \"Albert Einstein\"." + recording: + web: + - web_navigate: + url: https://es.wikipedia.org/wiki/Albert_Einstein + reasoning: The current screen is blank with no interactive elements. To open the Wikipedia article for Albert Einstein in Spanish, I will directly navigate to the correct URL for the Spanish Wikipedia article. + - web_verifyElementVisible: + reasoning: The objective is to verify that the article page rendered with the heading 'Albert Einstein.' The view hierarchy shows that element [e32] is a heading with the text 'Albert Einstein,' which matches the requirement. I will verify its visibility to confirm the page loaded correctly. + nodeSelector: + web: + ariaRole: heading + ariaNameRegex: Albert Einstein diff --git a/trails/wikipedia/test-language-switch-spanish/web.trail.yaml b/trails/wikipedia/test-language-switch-spanish/web.trail.yaml deleted file mode 100644 index df271cfd3..000000000 --- a/trails/wikipedia/test-language-switch-spanish/web.trail.yaml +++ /dev/null @@ -1,18 +0,0 @@ -- config: - title: 'Wikipedia: Switch the Albert Einstein article to Spanish' - target: wikipedia - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Open the Wikipedia article for "Albert Einstein" in Spanish and verify the article page rendered with the heading "Albert Einstein". - recording: - tools: - - web_navigate: - url: https://es.wikipedia.org/wiki/Albert_Einstein - reasoning: The current screen is blank with no interactive elements. To open the Wikipedia article for Albert Einstein in Spanish, I will directly navigate to the correct URL for the Spanish Wikipedia article. - - web_verifyElementVisible: - reasoning: The objective is to verify that the article page rendered with the heading 'Albert Einstein.' The view hierarchy shows that element [e32] is a heading with the text 'Albert Einstein,' which matches the requirement. I will verify its visibility to confirm the page loaded correctly. - nodeSelector: - web: - ariaRole: heading - ariaNameRegex: Albert Einstein diff --git a/trails/wikipedia/test-main-menu-toggle/blaze.yaml b/trails/wikipedia/test-main-menu-toggle/blaze.yaml deleted file mode 100644 index b293fea8c..000000000 --- a/trails/wikipedia/test-main-menu-toggle/blaze.yaml +++ /dev/null @@ -1,12 +0,0 @@ -- config: - title: "Wikipedia: Open the main menu and verify a sidebar link is visible" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: | - If the "Main menu" / hamburger button is visible in the page header, click it to expand the - sidebar navigation. (On a wide viewport the sidebar may already be open — in that case - no click is needed.) - - step: Verify the link text "Main page" is visible in the sidebar / navigation area. - - step: Verify the link text "Random article" is visible in the sidebar / navigation area. diff --git a/trails/wikipedia/test-main-menu-toggle/trail.yaml b/trails/wikipedia/test-main-menu-toggle/trail.yaml new file mode 100644 index 000000000..aee97fc03 --- /dev/null +++ b/trails/wikipedia/test-main-menu-toggle/trail.yaml @@ -0,0 +1,14 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Open the main menu and verify a sidebar link is visible' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: | + If the "Main menu" / hamburger button is visible in the page header, click it to expand the + sidebar navigation. (On a wide viewport the sidebar may already be open — in that case + no click is needed.) + - step: Verify the link text "Main page" is visible in the sidebar / navigation area. + - step: Verify the link text "Random article" is visible in the sidebar / navigation area. diff --git a/trails/wikipedia/test-main-page-did-you-know/blaze.yaml b/trails/wikipedia/test-main-page-did-you-know/blaze.yaml deleted file mode 100644 index 23b6aa31a..000000000 --- a/trails/wikipedia/test-main-page-did-you-know/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: Did you know section visible on main page" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, main-page] -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Verify the "Did you know" section is visible on the page. diff --git a/trails/wikipedia/test-main-page-did-you-know/trail.yaml b/trails/wikipedia/test-main-page-did-you-know/trail.yaml new file mode 100644 index 000000000..e91692469 --- /dev/null +++ b/trails/wikipedia/test-main-page-did-you-know/trail.yaml @@ -0,0 +1,12 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - main-page + title: 'Wikipedia: Did you know section visible on main page' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: Verify the "Did you know" section is visible on the page. diff --git a/trails/wikipedia/test-main-page-featured-article/blaze.yaml b/trails/wikipedia/test-main-page-featured-article/blaze.yaml deleted file mode 100644 index 01c7d0822..000000000 --- a/trails/wikipedia/test-main-page-featured-article/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: Today's featured article section visible on main page" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, main-page] -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Verify the "Today's featured article" section is visible on the page. diff --git a/trails/wikipedia/test-main-page-featured-article/trail.yaml b/trails/wikipedia/test-main-page-featured-article/trail.yaml new file mode 100644 index 000000000..14c413c5f --- /dev/null +++ b/trails/wikipedia/test-main-page-featured-article/trail.yaml @@ -0,0 +1,25 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - main-page + title: 'Wikipedia: Today''s featured article section visible on main page' + +trail: + - step: "Navigate to https://en.wikipedia.org/wiki/Main_Page" + recording: + web: + - web_navigate: + url: https://en.wikipedia.org/wiki/Main_Page + reasoning: The objective is to navigate to the English Wikipedia Main Page. Since there are no interactive elements on the current screen, direct navigation to the specified URL is required. + + - step: "Verify the \"Today's featured article\" section is visible on the page." + recording: + web: + - web_verifyElementVisible: + reasoning: The section titled "From today's featured article" is represented by the heading element [e40]. Verifying its visibility will confirm that the "Today's featured article" section is present on the page. + nodeSelector: + web: + cssSelector: "#mp-tfa-h2" diff --git a/trails/wikipedia/test-main-page-featured-article/web.trail.yaml b/trails/wikipedia/test-main-page-featured-article/web.trail.yaml deleted file mode 100644 index 2e183b77c..000000000 --- a/trails/wikipedia/test-main-page-featured-article/web.trail.yaml +++ /dev/null @@ -1,20 +0,0 @@ -- config: - title: 'Wikipedia: Today''s featured article section visible on main page' - target: wikipedia - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - recording: - tools: - - web_navigate: - url: https://en.wikipedia.org/wiki/Main_Page - reasoning: The objective is to navigate to the English Wikipedia Main Page. Since there are no interactive elements on the current screen, direct navigation to the specified URL is required. - - step: Verify the "Today's featured article" section is visible on the page. - recording: - tools: - - web_verifyElementVisible: - reasoning: The section titled "From today's featured article" is represented by the heading element [e40]. Verifying its visibility will confirm that the "Today's featured article" section is present on the page. - nodeSelector: - web: - cssSelector: "#mp-tfa-h2" diff --git a/trails/wikipedia/test-main-page-in-the-news/blaze.yaml b/trails/wikipedia/test-main-page-in-the-news/blaze.yaml deleted file mode 100644 index 5f20a2735..000000000 --- a/trails/wikipedia/test-main-page-in-the-news/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: In the news section visible on main page" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, main-page] -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Verify the "In the news" section is visible on the page. diff --git a/trails/wikipedia/test-main-page-in-the-news/trail.yaml b/trails/wikipedia/test-main-page-in-the-news/trail.yaml new file mode 100644 index 000000000..f68d1b336 --- /dev/null +++ b/trails/wikipedia/test-main-page-in-the-news/trail.yaml @@ -0,0 +1,25 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - main-page + title: 'Wikipedia: In the news section visible on main page' + +trail: + - step: "Navigate to https://en.wikipedia.org/wiki/Main_Page" + recording: + web: + - web_navigate: + url: https://en.wikipedia.org/wiki/Main_Page + reasoning: The objective is to navigate to the Wikipedia main page, so I will use the browser navigation tool to go directly to the specified URL. + + - step: "Verify the \"In the news\" section is visible on the page." + recording: + web: + - web_verifyElementVisible: + reasoning: The 'In the news' section is represented by the heading element with ID e89 in the main content area. Verifying the visibility of this heading will confirm that the 'In the news' section is currently visible on the Wikipedia Main Page. + nodeSelector: + web: + cssSelector: "#mp-itn-h2" diff --git a/trails/wikipedia/test-main-page-in-the-news/web.trail.yaml b/trails/wikipedia/test-main-page-in-the-news/web.trail.yaml deleted file mode 100644 index b182cac51..000000000 --- a/trails/wikipedia/test-main-page-in-the-news/web.trail.yaml +++ /dev/null @@ -1,20 +0,0 @@ -- config: - title: 'Wikipedia: In the news section visible on main page' - target: wikipedia - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - recording: - tools: - - web_navigate: - url: https://en.wikipedia.org/wiki/Main_Page - reasoning: The objective is to navigate to the Wikipedia main page, so I will use the browser navigation tool to go directly to the specified URL. - - step: Verify the "In the news" section is visible on the page. - recording: - tools: - - web_verifyElementVisible: - reasoning: The 'In the news' section is represented by the heading element with ID e89 in the main content area. Verifying the visibility of this heading will confirm that the 'In the news' section is currently visible on the Wikipedia Main Page. - nodeSelector: - web: - cssSelector: "#mp-itn-h2" diff --git a/trails/wikipedia/test-main-page-loads/blaze.yaml b/trails/wikipedia/test-main-page-loads/blaze.yaml deleted file mode 100644 index 6da659a39..000000000 --- a/trails/wikipedia/test-main-page-loads/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Main page loads" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, main-page] -- prompts: - - step: Open Wikipedia and verify the main page loaded ("Welcome to Wikipedia" is visible somewhere on the page). diff --git a/trails/wikipedia/test-main-page-loads/trail.yaml b/trails/wikipedia/test-main-page-loads/trail.yaml new file mode 100644 index 000000000..cbddb38e5 --- /dev/null +++ b/trails/wikipedia/test-main-page-loads/trail.yaml @@ -0,0 +1,11 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - main-page + title: 'Wikipedia: Main page loads' + +trail: + - step: Open Wikipedia and verify the main page loaded ("Welcome to Wikipedia" is visible somewhere on the page). diff --git a/trails/wikipedia/test-main-page-on-this-day/blaze.yaml b/trails/wikipedia/test-main-page-on-this-day/blaze.yaml deleted file mode 100644 index 5fc2ddb26..000000000 --- a/trails/wikipedia/test-main-page-on-this-day/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: On this day section visible on main page" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, main-page] -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Verify the "On this day" section is visible on the page. diff --git a/trails/wikipedia/test-main-page-on-this-day/trail.yaml b/trails/wikipedia/test-main-page-on-this-day/trail.yaml new file mode 100644 index 000000000..68ebf72fd --- /dev/null +++ b/trails/wikipedia/test-main-page-on-this-day/trail.yaml @@ -0,0 +1,27 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - main-page + title: 'Wikipedia: On this day section visible on main page' + +trail: + - step: "Navigate to https://en.wikipedia.org/wiki/Main_Page" + recording: + web: + - web_navigate: + url: https://en.wikipedia.org/wiki/Main_Page + reasoning: The objective is to navigate to the Wikipedia main page. The current screen is blank, so a direct navigation to the target URL is the correct next step. + + - step: "Verify the \"On this day\" section is visible on the page." + recording: + web: + - web_scroll: + reasoning: The visible portion of the page does not show an 'On this day' section. Scrolling down should reveal more of the main page content, which likely contains this section based on Wikipedia's standard layout. + - web_verifyElementVisible: + reasoning: The objective requires verification that the 'On this day' section is visible. In the view hierarchy, [e121] is the heading for this section. Confirming its visibility will fulfill the objective. + nodeSelector: + web: + cssSelector: "#mp-otd-h2" diff --git a/trails/wikipedia/test-main-page-on-this-day/web.trail.yaml b/trails/wikipedia/test-main-page-on-this-day/web.trail.yaml deleted file mode 100644 index 6d5abe041..000000000 --- a/trails/wikipedia/test-main-page-on-this-day/web.trail.yaml +++ /dev/null @@ -1,22 +0,0 @@ -- config: - title: 'Wikipedia: On this day section visible on main page' - target: wikipedia - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - recording: - tools: - - web_navigate: - url: https://en.wikipedia.org/wiki/Main_Page - reasoning: The objective is to navigate to the Wikipedia main page. The current screen is blank, so a direct navigation to the target URL is the correct next step. - - step: Verify the "On this day" section is visible on the page. - recording: - tools: - - web_scroll: - reasoning: The visible portion of the page does not show an 'On this day' section. Scrolling down should reveal more of the main page content, which likely contains this section based on Wikipedia's standard layout. - - web_verifyElementVisible: - reasoning: The objective requires verification that the 'On this day' section is visible. In the view hierarchy, [e121] is the heading for this section. Confirming its visibility will fulfill the objective. - nodeSelector: - web: - cssSelector: "#mp-otd-h2" diff --git a/trails/wikipedia/test-random-article/blaze.yaml b/trails/wikipedia/test-random-article/blaze.yaml deleted file mode 100644 index 2e7eca542..000000000 --- a/trails/wikipedia/test-random-article/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Open a random article from Wikipedia" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, nav] -- prompts: - - step: Open a random Wikipedia article and verify a new article page loaded (any article-style first heading is visible). diff --git a/trails/wikipedia/test-random-article/trail.yaml b/trails/wikipedia/test-random-article/trail.yaml new file mode 100644 index 000000000..0b9848b13 --- /dev/null +++ b/trails/wikipedia/test-random-article/trail.yaml @@ -0,0 +1,11 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - nav + title: 'Wikipedia: Open a random article from Wikipedia' + +trail: + - step: Open a random Wikipedia article and verify a new article page loaded (any article-style first heading is visible). diff --git a/trails/wikipedia/test-search-autocomplete/blaze.yaml b/trails/wikipedia/test-search-autocomplete/blaze.yaml deleted file mode 100644 index 77d12a247..000000000 --- a/trails/wikipedia/test-search-autocomplete/blaze.yaml +++ /dev/null @@ -1,15 +0,0 @@ -- config: - title: "Wikipedia: Autocomplete suggestions visible while typing search" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [search, flaky] - # The header search autocomplete popup is timing-sensitive — the suggestion - # list mounts after the input fires a `keyup` event, then races with debounced - # API responses. We don't have a deterministic "suggestions are now visible" - # signal yet, so this trail flakes on slow agents. Skipped until we either - # (a) extend `wikipedia_web_searchAndOpenFirstResult` to expose a probe for - # the suggestion list, or (b) settle on a structural anchor we trust. - skip: "Autocomplete popup is timing-sensitive; remove `skip:` once the suggestion-list probe lands." -- prompts: - - step: Open Wikipedia and start typing "Albert" into the header search box without submitting. Verify an autocomplete suggestion containing "Albert Einstein" appears. diff --git a/trails/wikipedia/test-search-autocomplete/trail.yaml b/trails/wikipedia/test-search-autocomplete/trail.yaml new file mode 100644 index 000000000..f4767c4b8 --- /dev/null +++ b/trails/wikipedia/test-search-autocomplete/trail.yaml @@ -0,0 +1,13 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - search + - flaky + skip: + web: 'Autocomplete popup is timing-sensitive; remove `skip:` once the suggestion-list probe lands.' + title: 'Wikipedia: Autocomplete suggestions visible while typing search' + +trail: + - step: Open Wikipedia and start typing "Albert" into the header search box without submitting. Verify an autocomplete suggestion containing "Albert Einstein" appears. diff --git a/trails/wikipedia/test-search-einstein/blaze.yaml b/trails/wikipedia/test-search-einstein/blaze.yaml deleted file mode 100644 index df1fd0057..000000000 --- a/trails/wikipedia/test-search-einstein/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Search for Albert Einstein" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [smoke, search] -- prompts: - - step: Search Wikipedia for "Albert Einstein" and verify the resulting article page shows the heading "Albert Einstein". diff --git a/trails/wikipedia/test-search-einstein/trail.yaml b/trails/wikipedia/test-search-einstein/trail.yaml new file mode 100644 index 000000000..c4cc820d3 --- /dev/null +++ b/trails/wikipedia/test-search-einstein/trail.yaml @@ -0,0 +1,11 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - smoke + - search + title: 'Wikipedia: Search for Albert Einstein' + +trail: + - step: Search Wikipedia for "Albert Einstein" and verify the resulting article page shows the heading "Albert Einstein". diff --git a/trails/wikipedia/test-search-multi-topic/blaze.yaml b/trails/wikipedia/test-search-multi-topic/trail.yaml similarity index 51% rename from trails/wikipedia/test-search-multi-topic/blaze.yaml rename to trails/wikipedia/test-search-multi-topic/trail.yaml index af48e294e..3af926ac8 100644 --- a/trails/wikipedia/test-search-multi-topic/blaze.yaml +++ b/trails/wikipedia/test-search-multi-topic/trail.yaml @@ -13,26 +13,30 @@ # keeps each case independently retriable and easy to triage when it # fails. # * Adding a new topic is a 3-line diff. Removing one is a 3-line diff. -- config: - title: "Wikipedia (data-driven): search + verify across topics" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [search, article, slow] -- prompts: - - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. - - step: | - Call the `wikipedia_web_searchAndVerify` tool with - query="Python (programming language)", - expectedHeading="Python (programming language)", - requireReferences=true. - - step: | - Call the `wikipedia_web_searchAndVerify` tool with - query="Albert Einstein", - expectedHeading="Albert Einstein", - requireReferences=true. - - step: | - Call the `wikipedia_web_searchAndVerify` tool with - query="Mount Everest", - expectedHeading="Mount Everest", - requireReferences=true. +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - search + - article + - slow + title: 'Wikipedia (data-driven): search + verify across topics' + +trail: + - step: Call the `wikipedia_web_openMainPage` tool with dismissBanner=true. + - step: | + Call the `wikipedia_web_searchAndVerify` tool with + query="Python (programming language)", + expectedHeading="Python (programming language)", + requireReferences=true. + - step: | + Call the `wikipedia_web_searchAndVerify` tool with + query="Albert Einstein", + expectedHeading="Albert Einstein", + requireReferences=true. + - step: | + Call the `wikipedia_web_searchAndVerify` tool with + query="Mount Everest", + expectedHeading="Mount Everest", + requireReferences=true. diff --git a/trails/wikipedia/test-search-python/blaze.yaml b/trails/wikipedia/test-search-python/blaze.yaml deleted file mode 100644 index 13cd08c4b..000000000 --- a/trails/wikipedia/test-search-python/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: Search for Python (programming language)" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Type "Python (programming language)" into the Wikipedia search input in the header. - - step: Press Enter to submit the search. - - step: Verify the heading "Python (programming language)" is visible on the resulting article page. diff --git a/trails/wikipedia/test-search-python/trail.yaml b/trails/wikipedia/test-search-python/trail.yaml new file mode 100644 index 000000000..80ae682ac --- /dev/null +++ b/trails/wikipedia/test-search-python/trail.yaml @@ -0,0 +1,11 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Search for Python (programming language)' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: Type "Python (programming language)" into the Wikipedia search input in the header. + - step: Press Enter to submit the search. + - step: Verify the heading "Python (programming language)" is visible on the resulting article page. diff --git a/trails/wikipedia/test-search-submit-button/blaze.yaml b/trails/wikipedia/test-search-submit-button/blaze.yaml deleted file mode 100644 index 88c84270c..000000000 --- a/trails/wikipedia/test-search-submit-button/blaze.yaml +++ /dev/null @@ -1,9 +0,0 @@ -- config: - title: "Wikipedia: Search via the search submit button" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Type "Trailblazer" into the Wikipedia search input in the header. - - step: Click the search submit button (the magnifying glass / "Search" button next to the input). - - step: Verify that an article or search results page has loaded by checking that the page heading area shows either "Trailblazer" or "Search results". diff --git a/trails/wikipedia/test-search-submit-button/trail.yaml b/trails/wikipedia/test-search-submit-button/trail.yaml new file mode 100644 index 000000000..5c36e52eb --- /dev/null +++ b/trails/wikipedia/test-search-submit-button/trail.yaml @@ -0,0 +1,11 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Search via the search submit button' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: Type "Trailblazer" into the Wikipedia search input in the header. + - step: Click the search submit button (the magnifying glass / "Search" button next to the input). + - step: Verify that an article or search results page has loaded by checking that the page heading area shows either "Trailblazer" or "Search results". diff --git a/trails/wikipedia/test-search-type-only/blaze.yaml b/trails/wikipedia/test-search-type-only/blaze.yaml deleted file mode 100644 index 0a4f06f35..000000000 --- a/trails/wikipedia/test-search-type-only/blaze.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- config: - title: "Wikipedia: Type a search query without submitting" - platform: web - driver: PLAYWRIGHT_NATIVE - target: wikipedia - tags: [search] -- prompts: - # This trail exercises the `openFirstResult: false` branch of - # `wikipedia_web_searchAndOpenFirstResult`. Distinct from - # `test-search-autocomplete` (which asserts the popup is visible — flaky) - # — here we just verify the search input ends up with the query in it. - # Deterministic, no popup timing involved. - - step: | - Open Wikipedia and type "Albert Einstein" into the header search box, - but do NOT submit the form (don't press Enter, don't click the search - button). After typing, verify that the search input contains the - text "Albert Einstein". diff --git a/trails/wikipedia/test-search-type-only/trail.yaml b/trails/wikipedia/test-search-type-only/trail.yaml new file mode 100644 index 000000000..563355816 --- /dev/null +++ b/trails/wikipedia/test-search-type-only/trail.yaml @@ -0,0 +1,19 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + tags: + - search + title: 'Wikipedia: Type a search query without submitting' + +trail: + # This trail exercises the `openFirstResult: false` branch of + # `wikipedia_web_searchAndOpenFirstResult`. Distinct from + # `test-search-autocomplete` (which asserts the popup is visible — flaky) + # — here we just verify the search input ends up with the query in it. + # Deterministic, no popup timing involved. + - step: | + Open Wikipedia and type "Albert Einstein" into the header search box, + but do NOT submit the form (don't press Enter, don't click the search + button). After typing, verify that the search input contains the + text "Albert Einstein". diff --git a/trails/wikipedia/test-wiki-logo-visible/blaze.yaml b/trails/wikipedia/test-wiki-logo-visible/blaze.yaml deleted file mode 100644 index 420edcba8..000000000 --- a/trails/wikipedia/test-wiki-logo-visible/blaze.yaml +++ /dev/null @@ -1,8 +0,0 @@ -- config: - title: "Wikipedia: Globe logo / wordmark visible on every page" - platform: web - driver: PLAYWRIGHT_NATIVE -- prompts: - - step: Navigate to https://en.wikipedia.org/wiki/Main_Page - - step: Verify the Wikipedia wordmark text "Wikipedia" is visible in the top-left area of the page (the site name shown next to the puzzle-globe logo). - - step: Verify the tagline text "The Free Encyclopedia" is visible near the wordmark. diff --git a/trails/wikipedia/test-wiki-logo-visible/trail.yaml b/trails/wikipedia/test-wiki-logo-visible/trail.yaml new file mode 100644 index 000000000..d912a3820 --- /dev/null +++ b/trails/wikipedia/test-wiki-logo-visible/trail.yaml @@ -0,0 +1,10 @@ +config: + target: wikipedia + devices: + web: PLAYWRIGHT_NATIVE + title: 'Wikipedia: Globe logo / wordmark visible on every page' + +trail: + - step: Navigate to https://en.wikipedia.org/wiki/Main_Page + - step: Verify the Wikipedia wordmark text "Wikipedia" is visible in the top-left area of the page (the site name shown next to the puzzle-globe logo). + - step: Verify the tagline text "The Free Encyclopedia" is visible near the wordmark.