From 606ea53ee99450fdb359f1023eb852c14a70019c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:13:09 +0000 Subject: [PATCH 01/44] journeys: the action fuzzer as a mode of the smoke-gate driver (ext_fuzz.py, fuzz.py) Brings scripts/journeys (the journeys runner and the dogfood smoke gate, from cp/pa-d-dogfood-smoke-gate at 1222849c09f) to feat-cmux-next, and adds the action fuzz on top of it (cmux-ad 2026-10-31, fuzz-catalog rows a and m): - ext_fuzz.py: step fuzz_run plays a seeded plan of user actions through debug.key, ext_window's drags and the driver's relaunch, at mixed timings, with a model of workspaces/tabs/terminals/panes. Oracles after each burst: create (or a visible refusal), R3a and R3b through ext_smoke's own smoke_r3 / column readings, focus, daemon == app topology, stall > 100 ms in cmux code, crash, leak after close-all. A failure is shrunk in the same app with ddmin. Speed marks per create: shown_ms, typeable_ms. - fuzz.py: rounds through run.py (exclusive, pool perf-lead, never cmux17), --base A/B on the same mini (ABBA), confirms each shrunk repro in a fresh lease and writes it as a regression journey; verdict CLEAN/RED/NOT JUDGED/DIAGNOSTIC, .clean file only for a judged clean run. - Sets: layout (row m: New Column, Cmd-D, Split Down, Ctrl-`, close pane, divider drag, window resize, relaunch) and full. - journeys/fuzz/: the fuzz journey and Lawrence's reported bugs as fixed sequences (lawrence-*.json). - test_fuzz.py (plan, ddmin, speed table, verdicts with a stand-in run.py) and test_fuzz_sim.py (the oracles against a simulated app with planted bugs): python only, no fleet. Tests: python3 scripts/journeys/test_fuzz.py (16 OK), test_fuzz_sim.py (8 OK), test_smoke_gate.py (OK), run.py --check (59 valid). --- scripts/journeys/README.md | 418 +++++ scripts/journeys/dogfood-fixes.list | 10 + scripts/journeys/dogfood-gate.py | 199 ++ scripts/journeys/driver.py | 1619 +++++++++++++++++ scripts/journeys/ext_fuzz.py | 942 ++++++++++ scripts/journeys/ext_multiwindow.py | 550 ++++++ scripts/journeys/ext_restore.py | 315 ++++ scripts/journeys/ext_smoke.py | 667 +++++++ scripts/journeys/ext_spam.py | 297 +++ scripts/journeys/ext_window.py | 800 ++++++++ scripts/journeys/fuzz.py | 355 ++++ .../journeys/dogfood/smoke-dogfood.json | 145 ++ .../journeys/journeys/fuzz/action-fuzz.json | 48 + .../journeys/fuzz/lawrence-cmd-d-twice.json | 70 + .../journeys/fuzz/lawrence-cmd-t-slow.json | 70 + .../journeys/fuzz/lawrence-column-widths.json | 70 + .../fuzz/lawrence-ctrl-backtick-twice.json | 70 + .../fuzz/lawrence-no-new-workspace.json | 70 + .../journeys/fuzz/lawrence-resize.json | 87 + .../journeys/p0/app-life-crash-recovers.json | 52 + .../journeys/p0/app-life-first-launch.json | 41 + .../journeys/p0/app-life-many-tabs.json | 56 + .../journeys/p0/app-life-move-tab.json | 54 + .../journeys/p0/app-life-new-close-tab.json | 51 + .../journeys/p0/app-life-quit-restore.json | 83 + .../journeys/p0/app-life-settings-live.json | 40 + .../journeys/journeys/p0/bracketed-paste.json | 39 + scripts/journeys/journeys/p0/clear.json | 71 + .../journeys/p0/close-pane-fills.json | 42 + scripts/journeys/journeys/p0/ctrl-c.json | 45 + .../journeys/journeys/p0/mw-close-window.json | 262 +++ .../p0/mw-home-click-each-window.json | 438 +++++ .../p0/mw-move-workspace-between-windows.json | 244 +++ .../p0/mw-new-window-entrypoints.json | 336 ++++ .../journeys/p0/mw-restart-three-windows.json | 265 +++ .../journeys/p0/mw-shortcuts-new-window.json | 446 +++++ .../journeys/p0/mw-workspace-row-click.json | 609 +++++++ .../journeys/journeys/p0/nested-splits.json | 67 + .../p0/new-tab-at-launch-scripted.json | 33 + .../journeys/p0/new-tab-at-launch.json | 34 + .../journeys/p0/pane-divider-drag.json | 49 + scripts/journeys/journeys/p0/paste-10k.json | 80 + scripts/journeys/journeys/p0/paste-small.json | 32 + .../p0/restart-focus-after-settings.json | 452 +++++ .../p0/restart-focus-browser-keys.json | 80 + .../journeys/p0/restart-focus-browser.json | 71 + .../journeys/journeys/p0/restart-restore.json | 70 + .../p0/scripted-newtab-keeps-person-keys.json | 39 + .../journeys/journeys/p0/scrollback-100k.json | 107 ++ .../journeys/p0/second-window-resize.json | 71 + scripts/journeys/journeys/p0/select-copy.json | 46 + scripts/journeys/journeys/p0/spam-cmd-d.json | 144 ++ .../journeys/journeys/p0/spam-cmd-digit.json | 127 ++ scripts/journeys/journeys/p0/spam-cmd-n.json | 144 ++ .../p0/spam-cmd-shift-close-bracket.json | 161 ++ .../p0/spam-cmd-shift-open-bracket.json | 161 ++ .../journeys/p0/spam-cmd-shift-t.json | 149 ++ scripts/journeys/journeys/p0/spam-cmd-t.json | 144 ++ scripts/journeys/journeys/p0/spam-cmd-w.json | 206 +++ .../journeys/p0/spam-ctrl-backtick.json | 144 ++ .../journeys/journeys/p0/spam-ctrl-tab.json | 161 ++ .../journeys/p0/split-right-down.json | 62 + scripts/journeys/journeys/p0/type.json | 28 + .../journeys/p0/window-fullscreen.json | 55 + .../journeys/journeys/p0/window-maximize.json | 54 + .../journeys/p0/window-resize-grow.json | 52 + .../journeys/p0/window-resize-shrink.json | 53 + scripts/journeys/journeys/p0/zoom-pane.json | 59 + .../journeys/p1/paste-confirm-keys.json | 53 + .../journeys/p1/quit-leaves-helpers.json | 44 + .../journeys/quit-leaves-no-helpers/README.md | 11 + .../quit-leaves-no-helpers/journey.py | 254 +++ .../journeys/quit-leaves-no-helpers/run.py | 139 ++ scripts/journeys/run.py | 602 ++++++ scripts/journeys/smoke-gate.py | 315 ++++ scripts/journeys/smoke_hog.py | 106 ++ scripts/journeys/test_fuzz.py | 196 ++ scripts/journeys/test_fuzz_sim.py | 339 ++++ scripts/journeys/test_process_scope.py | 156 ++ scripts/journeys/test_smoke_gate.py | 624 +++++++ 80 files changed, 15950 insertions(+) create mode 100644 scripts/journeys/README.md create mode 100644 scripts/journeys/dogfood-fixes.list create mode 100755 scripts/journeys/dogfood-gate.py create mode 100755 scripts/journeys/driver.py create mode 100644 scripts/journeys/ext_fuzz.py create mode 100644 scripts/journeys/ext_multiwindow.py create mode 100644 scripts/journeys/ext_restore.py create mode 100644 scripts/journeys/ext_smoke.py create mode 100644 scripts/journeys/ext_spam.py create mode 100644 scripts/journeys/ext_window.py create mode 100644 scripts/journeys/fuzz.py create mode 100644 scripts/journeys/journeys/dogfood/smoke-dogfood.json create mode 100644 scripts/journeys/journeys/fuzz/action-fuzz.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-cmd-d-twice.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-cmd-t-slow.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-column-widths.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-ctrl-backtick-twice.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-no-new-workspace.json create mode 100644 scripts/journeys/journeys/fuzz/lawrence-resize.json create mode 100644 scripts/journeys/journeys/p0/app-life-crash-recovers.json create mode 100644 scripts/journeys/journeys/p0/app-life-first-launch.json create mode 100644 scripts/journeys/journeys/p0/app-life-many-tabs.json create mode 100644 scripts/journeys/journeys/p0/app-life-move-tab.json create mode 100644 scripts/journeys/journeys/p0/app-life-new-close-tab.json create mode 100644 scripts/journeys/journeys/p0/app-life-quit-restore.json create mode 100644 scripts/journeys/journeys/p0/app-life-settings-live.json create mode 100644 scripts/journeys/journeys/p0/bracketed-paste.json create mode 100644 scripts/journeys/journeys/p0/clear.json create mode 100644 scripts/journeys/journeys/p0/close-pane-fills.json create mode 100644 scripts/journeys/journeys/p0/ctrl-c.json create mode 100644 scripts/journeys/journeys/p0/mw-close-window.json create mode 100644 scripts/journeys/journeys/p0/mw-home-click-each-window.json create mode 100644 scripts/journeys/journeys/p0/mw-move-workspace-between-windows.json create mode 100644 scripts/journeys/journeys/p0/mw-new-window-entrypoints.json create mode 100644 scripts/journeys/journeys/p0/mw-restart-three-windows.json create mode 100644 scripts/journeys/journeys/p0/mw-shortcuts-new-window.json create mode 100644 scripts/journeys/journeys/p0/mw-workspace-row-click.json create mode 100644 scripts/journeys/journeys/p0/nested-splits.json create mode 100644 scripts/journeys/journeys/p0/new-tab-at-launch-scripted.json create mode 100644 scripts/journeys/journeys/p0/new-tab-at-launch.json create mode 100644 scripts/journeys/journeys/p0/pane-divider-drag.json create mode 100644 scripts/journeys/journeys/p0/paste-10k.json create mode 100644 scripts/journeys/journeys/p0/paste-small.json create mode 100644 scripts/journeys/journeys/p0/restart-focus-after-settings.json create mode 100644 scripts/journeys/journeys/p0/restart-focus-browser-keys.json create mode 100644 scripts/journeys/journeys/p0/restart-focus-browser.json create mode 100644 scripts/journeys/journeys/p0/restart-restore.json create mode 100644 scripts/journeys/journeys/p0/scripted-newtab-keeps-person-keys.json create mode 100644 scripts/journeys/journeys/p0/scrollback-100k.json create mode 100644 scripts/journeys/journeys/p0/second-window-resize.json create mode 100644 scripts/journeys/journeys/p0/select-copy.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-d.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-digit.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-n.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-shift-close-bracket.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-shift-open-bracket.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-shift-t.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-t.json create mode 100644 scripts/journeys/journeys/p0/spam-cmd-w.json create mode 100644 scripts/journeys/journeys/p0/spam-ctrl-backtick.json create mode 100644 scripts/journeys/journeys/p0/spam-ctrl-tab.json create mode 100644 scripts/journeys/journeys/p0/split-right-down.json create mode 100644 scripts/journeys/journeys/p0/type.json create mode 100644 scripts/journeys/journeys/p0/window-fullscreen.json create mode 100644 scripts/journeys/journeys/p0/window-maximize.json create mode 100644 scripts/journeys/journeys/p0/window-resize-grow.json create mode 100644 scripts/journeys/journeys/p0/window-resize-shrink.json create mode 100644 scripts/journeys/journeys/p0/zoom-pane.json create mode 100644 scripts/journeys/journeys/p1/paste-confirm-keys.json create mode 100644 scripts/journeys/journeys/p1/quit-leaves-helpers.json create mode 100644 scripts/journeys/quit-leaves-no-helpers/README.md create mode 100644 scripts/journeys/quit-leaves-no-helpers/journey.py create mode 100644 scripts/journeys/quit-leaves-no-helpers/run.py create mode 100755 scripts/journeys/run.py create mode 100644 scripts/journeys/smoke-gate.py create mode 100644 scripts/journeys/smoke_hog.py create mode 100644 scripts/journeys/test_fuzz.py create mode 100644 scripts/journeys/test_fuzz_sim.py create mode 100644 scripts/journeys/test_process_scope.py create mode 100644 scripts/journeys/test_smoke_gate.py diff --git a/scripts/journeys/README.md b/scripts/journeys/README.md new file mode 100644 index 000000000000..ab248085baf7 --- /dev/null +++ b/scripts/journeys/README.md @@ -0,0 +1,418 @@ +# User journeys + +A journey is one thing a real person does with cmux, played against one exact build on a +fleet Mac, recorded as a clip, and judged by what the person would see. The journey suite is +the definition of done for a dogfood build ([holistic-quality](#why)): no dogfood build goes +to Lawrence until every P0 journey passes on that exact build, the earlier dogfood fixes are +all in it, and an independent QA agent says ship (`dogfood-gate.py`). + +The lead runs journeys at the moments that matter (a dogfood build, a fix for a journey's bug, +before a handoff). They are not a CI job, step or gate, and must not become one. + +## Run + +```bash +# Every P0 journey against the app of fleet build job JOB (waits for the build if it still runs): +python3 scripts/journeys/run.py --build JOB + +# One journey, a priority, or all of them: +python3 scripts/journeys/run.py --build JOB --journey ctrl-c +python3 scripts/journeys/run.py --build JOB --priority P1 +python3 scripts/journeys/run.py --build JOB --all + +# Against a commit: builds that exact pushed SHA on the fleet once (receipt in +# artifacts/journeys/builds/), then runs on its artifact: +python3 scripts/journeys/run.py --commit SHA + +python3 scripts/journeys/run.py --list # id, priority, file, title +python3 scripts/journeys/run.py --check # validate the journey files (no fleet) +``` + +Capture hosts come from the fleet registry (`~/.ssh/config.d/fleet-registry.json`, written by +leo-workspace `tools/reach --install`). On a machine without it, name them: +`--hosts "cmux17 cmux18 cmux19 cmux21 cmux22 cmux23 cmux24"` (sets `CAPTURE_HOST_SLOT_HOSTS`). +`JOURNEYS_EXCLUDE_HOSTS` (default `cmux16`) are never leased. + +Output, in `artifacts/journeys/-/` (or `--out`): + +- `results.jsonl`: one line per journey: `id`, `priority`, `status` (`pass`, `fail` or + `not run`), `reason`, `evidence` (each check with its result, its time from the mark and + what it saw), `clip`, `checks` (the frames the checks read), `build_job`, `commit`, `tag`, + `host`, `path`, `nonce`, `run_at`; +- `/result.json`: the full record (every step and its reply, every check's detail); +- `/check-NN.png`: each frame a `frame_text` check read (full size); +- `/clip.mp4` (with ffmpeg) or `/clip.png` (an animated PNG, about a frame a second; + any browser plays it) and `/clip/`, the frames it is made of. + +Exit 0 when every selected journey passed, 1 when one failed, 75 when none failed but one did +not run. + +## How it runs (what it reuses) + +No new harness: `run.py` takes the route `cmux-ci capture` (hq `build-fleet/pr-capture`) and +`scripts/cmux-next/restore-journey-slot.sh` already take. + +1. The build: `cmux-ci status JOB` (its exact `ref` is the commit under test); a running build + is waited on with `cmux-ci wait` (a wait timeout leaves it running: wait again, never + resubmit). +2. `capture-host acquire --slot` leases one slot on a GUI capture mini (hq; the installed copy + under `~/.local/share/cmux/hq-tools/current/build-fleet/` when `capture-host` is not on + PATH). A lease lasts at most 10 minutes and is released in every case; journeys that do not + fit one lease (`budget_seconds`) run in further leases. +3. The slot downloads the build's artifact (`cmux-ci artifact JOB`) into the lease root, and + compiles `scripts/ci/pr_media_ocr.swift` once per mini (`~/.cache/cmux-journeys/`). +4. `capture-host launch` starts the app in the slot: its own clone, bundle id, HOME, TMPDIR, + control socket and empty settings file, never activated, never another app's state. +5. `driver.py` runs there (stock `/usr/bin/python3`, standard library) against the slot's + socket. It warms text recognition up first (about a minute on a cold mini, recorded in + `host.json`), then plays the journeys in one app, each in its own new workspace. `run.py` + then fetches only the expected files and builds the clips. + +The driver drives and reads the app only through interfaces the app already has: + +| What | Interface | +| --- | --- | +| keys a person presses | `debug.key`: a key-down dispatched as `NSApplication.sendEvent` does (key router, key equivalents, menu, responder chain) | +| app state | the control socket: `debug.surfaces {text: true}` (each pane's focus, link, viewport text, `terminal_id`, `host_pid`), `action.run` | +| the daemon's screen | the bundled `cmux` CLI on the app's own daemon: `cmux terminal ID screen read` | +| processes | `ps`, scoped to the terminal's `__terminal-host` (`host_pid`), else to every process run from the app bundle | +| the frame | `debug.window_snapshot` (the app's own drawing of its main window) read with macOS text recognition | + +## Write a journey + +One JSON file per journey under `journeys//`. `run.py --check` validates it. + +```json +{ + "id": "ctrl-c", + "priority": "P0", + "title": "Ctrl+C interrupts the foreground job; the prompt returns within 1 s", + "why": "what breaks for the person, and the bug it guards (commit) if any", + "source": "where it comes from: a dogfood bug, the action catalog, a shortcut, everyday use", + "budget_seconds": 60, + "steps": [ ... ] +} +``` + +`id`: lowercase, digits and dashes. `priority`: P0 (a person hits it in the first five +minutes: type, Ctrl+C, resize, scroll, paste, split, restart), P1, P2, P3. `budget_seconds`: +how long it may take (it plans the leases). Copy `journeys/p0/type.json` (the smallest) or +`journeys/p0/ctrl-c.json` (marks, timing, processes). + +### Steps + +| `do` | Fields | What it does | +| --- | --- | --- | +| `new_terminal` | `via`, `name` | `via: "key"`: the person's New Terminal Tab key (Ctrl-\`, newSurface) through `debug.key` (origin user: it claims the window's keyboard), then as `focused_terminal`; use it in every journey that plays what a person does. `via: "socket"` (the default, kept for older journeys): `action.run newTab` named `name` (focused); waits for the new focused terminal pane and its first prompt; later steps use that terminal | +| `script_new_terminal` | `name`, `focus` | automation on purpose: a script's `action.run newTab` through the socket; waits for the topology to list workspace `name` with a terminal and its first prompt, without waiting for it to show (a script's newTab does not take a text input the person has: cp/fix-chief-composer-focus) | +| `neutral_prompt` | | writes `PS1='J> '` (zsh and bash), `cd /tmp` and a clear into the PTY through the daemon (set-up, not under test; no host, user or path in any frame) | +| `type` | `text`, `enter`, `mark` | presses each character with `debug.key`, then Return when `enter` | +| `key` | `key`, `modifiers`, `mark` | one `debug.key` (`modifiers`: `control`, `shift`, `option`, `cmd`; named keys: `return`, `escape`, `tab`, `space`, `up`, `down`, `left`, `right`, `delete`, `pageup`, ...) | +| `rpc` | `method`, `params` | one control-socket request (set-up the person does through UI the driver cannot click yet) | +| `wait` | `seconds` | only when nothing observable can be waited on | +| `expect` | `all`, `since`, `within`, `timeout`, `settle`, `title` | polls every condition in `all` (about every 50 ms plus the reads) until all hold or `timeout` seconds pass; `settle`: the negative conditions must keep holding that many more seconds | +| `mouse` | `action`, `dx`, `dy`, `phase`, `modifiers`, `button`, `count`, `interval`, `mark` | `debug.mouse` at the center of the journey's pane, `count` times: `scroll` (default; `dy` > 0 is a wheel turned up, toward older rows), `click`, `double_click` | +| `select_text` | `text`, `how`, `occurrence`, `mark` | selects TEXT where the pane shows it, with the pointer: `drag` (default: press on its first cell, drag to its last) or `double_click` (a word); the cells come from the pane's viewport text and grid (`debug.surfaces`) | +| `clipboard_set` | `text` or `lines` (`format`, `count`, `start`) | set-up: puts the text on the Mac's general pasteboard (what Cmd+V reads; `pbcopy`) and reads it back; binds `${clip_sha}` (sha256) and `${clip_lines}` | +| `file` | `path`, `text` or `lines` | set-up: writes a helper program to `/tmp/jr-*` (only there; not substituted, so it takes the run's values as arguments); removed when the journey ends | +| `focused_terminal` | `timeout` | after a key that makes a terminal (Ctrl-\` New Terminal Tab, Cmd-D split, Home's Open a terminal): waits for a focused terminal pane showing a terminal not seen before, makes it current and waits for its first prompt | +| `use` | `name` | makes the terminal named `name` current again; its pane is read from the topology now (after a move or a relaunch) | +| `relaunch` | `how` (`quit`, `crash`), `mark` | ends the app and opens it again as a person does: `quit` presses Cmd-Q (a quit sheet gets its default, as Return); `crash` is `debug.crash.app` (SIGSEGV in the app). Then `open -g -n -a` the same bundle with its launch variables (the slot's HOME, socket, settings file), waits for the new process and the loaded topology, and finds every named terminal again. `mark` is the moment the old process ended. `leftovers_after: S`: S seconds after the old process ended (before the reopen), lists every process of the bundle (its path, or `--owner BUNDLE_ID`) for `quit_leftovers` | +| `settings_file` | `set`, `unset`, `mark` | edits the app's own settings file (`CMUX_NEXT_CONFIG_FILE`, the slot's) as an editor does: `set` merges nested keys, `unset` removes dotted keys | +| `remember_value` | `source`, `name` | keeps `daemon_cols`, `daemon_rows` or `app_cell_height` of the current terminal as `${name}` | +| `fresh_app` | | the app must be the one capture-host launched from empty state with no journey before it (`not run` otherwise); the journey file says `"first_in_app": true`, so `run.py` starts a lease with it | +| `outwait_hold` | | the next key waits out the creation hold after the last key (below); use it after a tab switch onto a terminal whose view may come later | + +Any step may say `"as": "NAME"`: the current terminal after it is named NAME, for `use` and for +conditions that take `name`. `key` also takes `repeat` and `gap` (seconds between presses). +`key`, `type` and `mouse` take `window`: a window a step named (`name_window`, `await_window`) or a +window id; the key is typed in (the click lands in) that window. Without it, keys go to the journey's +key window and `debug.mouse` to the first window. + +A capture slot's window is never the key window (the app runs with `CMUX_NEXT_NO_ACTIVATE`). +Keys typed while the app holds them for a terminal still being made (`CreationInputCoordinator`: +after Cmd-D, Ctrl-\`, a restore; at most its 2 s `holdLimit`) are replayed through +`NSApp.sendEvent`, which only reaches the key window, so in a slot they are lost. A person's +window is key, so this is the slot's limit, not the app's. After `focused_terminal`, `relaunch` +and `outwait_hold`, the next `type` or `key` therefore waits until 2.5 s after the key that made +the terminal (checks do not wait). Typing ahead into a terminal still being made is not covered +by journeys in a slot. + +`mark` names the moment of a step (for `type` with `enter`, the moment Return is pressed); +`expect`'s `since` measures from it. Strings may use `${nonce}` (six digits, new each run, so +no fixture or earlier run can show the expected text) and `${prompt}` (`J> `). + +### Conditions (inside `expect.all`) + +| Condition | Evidence | Holds when | +| --- | --- | --- | +| `{"daemon_line": "TEXT"}` | daemon | a line of the daemon's screen is TEXT (spaces at the ends ignored) | +| `{"daemon_contains": "TEXT"}` | daemon | the daemon's screen contains TEXT | +| `{"daemon_prompt": true}` | daemon | the screen's last non-empty line is the bare prompt | +| `{"app_text_line": "TEXT"}` | app | a line of the pane's viewport text in the app is TEXT | +| `{"app_text_contains": "TEXT"}` | app | the pane's viewport text contains TEXT | +| `{"app_prompt": true}` | app | the pane's last non-empty line is the bare prompt | +| `{"app_pane": {"field": value}}` | app | the pane's `debug.surfaces` fields equal these (`focused`, `link`, ...) | +| `{"process_present": {"match": "RE", "bind": "NAME", "foreground": true}}` | process | a process under the terminal matches RE (its full command line); `foreground`: it has a controlling tty and is in the tty's foreground group (`ps` stat `+`); `bind` keeps it as NAME | +| `{"process_gone": "NAME"}` | process | the bound process is gone (or a zombie) | +| `{"frame_text": "TEXT"}` | frame | a fresh snapshot of the window shows TEXT to text recognition (spaces dropped, look-alike letters folded) | +| `{"daemon_lacks": "TEXT"}` | daemon | negative: the daemon's screen does not contain TEXT | +| `{"app_text_lacks": "TEXT"}` | app | negative: the pane's viewport text does not contain TEXT | +| `{"daemon_history": {...}}` | daemon | the daemon's scrollback (`terminal ID history read`): `rows` `oldest`/`newest`, `count`; `contains`, `lacks` (negative) TEXT; `pattern` (one group: a row number) with `contiguous` and `last` N | +| `{"app_rows": {...}}` | app | the numbered rows the viewport shows (after any scroll): `pattern`, `min_rows`, `contiguous`; `top`/`last`: `{eq\|ne\|gt\|lt\|ge\|le: N, a bound NAME, or "daemon_oldest"}`; `bind` NAME (sets `${NAME_top}`, `${NAME_mid}`); `hold` makes it negative-style (read every poll and through `settle`) | +| `{"frame_screen": {"shows": [...], "lacks": [...]}}` | frame | one fresh frame shows every `shows` text and none of `lacks` (a negative read only on a frame that shows the run's own text) | +| `{"clipboard": "TEXT"}` | clipboard | the general pasteboard is exactly TEXT (`pbpaste`) | +| `{"quit_leftovers": {"allow": [{"match": RE, "why": TEXT}]}}` | process | every process a `relaunch` with `leftovers_after` found after the quit matches an `allow` entry; the others are listed with their RSS | + +A negative condition (`*_lacks`, `daemon_history` with `lacks`, `app_rows` with `hold`) is read again +every poll and holds only when it holds together with the positive ones at the end (and through +`settle`); a positive one holds once it has held. The capture minis share one pasteboard per login: +`clipboard_set` reads its text back before the paste, and the `clipboard` check runs within a second. + +| `{"daemon_cwd": "PATH"}` or `{"daemon_cwd": {"name": N, "cwd": PATH}}` | daemon | the daemon's terminal list gives the terminal (current, or named N) that working directory (`/private/tmp` is `/tmp`) | +| `{"daemon_grid": {"cols_lt": V, "rows_equals": V}}` | daemon | the terminal's grid in the daemon (`cols`, `rows` with `equals`, `lt`, `gt`, `lte`, `gte`) | +| `{"daemon_pane_ready": {"tabs": N}}` | daemon | the current terminal's pane has at least N terminal tabs and each one's daemon screen shows text (a prompt) | +| `{"app_cwd": ...}` | app | the topology's tab of the terminal shows that working directory (same forms as `daemon_cwd`) | +| `{"app_layout": {"workspace": W, "windows": N, "panes": N, "tabs": N, "pane_tabs": N, "selected": true}}` | app | the current terminal's workspace in the topology has these counts; `selected`: its tab is its pane's selected tab; `bind_workspace: NAME` keeps the workspace's name as `${NAME}` | +| `{"app_tab_pane": {"same_as": N}}` / `{"not_same_as": N}` | app | the current terminal's tab is (is not) in the same pane as terminal N | +| `{"app_tab_gone": "N"}` | app | terminal N is in no tab of the topology | +| `{"app_rpc": {"method": M, "params": P, "path": "a.b.0", "equals"\|"contains"\|"len"\|"len_gte"\|"gt"\|...: V}}` | app | one control-socket read; the value at `path` meets every rule (also `not_equals`, `in`, `not_in`, `lacks`: a missing value fails them; `lacks` TEXT: the value, as JSON text unless a string, does not contain TEXT); `bind: NAME` keeps the value as `${NAME}` | +| `{"app_grid": {"cell_height_gt": V}}` | app | the pane's drawn grid (`cell_height`, `cell_width` with `equals`, `lt`, `gt`, ...) | + +`within` (seconds) makes a timing claim: each condition must first hold within that long of +`since`, or it fails with the measured time. Frame checks are read after the other conditions +hold (text recognition takes about a second) and do not count against `within`. + +### Step groups (`ext_*.py`) + +A group's own steps and conditions live in `ext_.py` beside `driver.py`; the driver loads +each one (`load_extensions`) and `run.py` sends them to the mini with it. A group file has +`install(driver)`, `STEPS` and `CONDITIONS` (name to evidence kind and function), must not import +`driver.py`, and may not redefine a step or condition. A journey may have `finally` steps (put the +app back for the next journey: leave full screen, close a second window, restore the window +frame); they run whatever happened and never change the status. + +A group may list its negative conditions in `NEGATIVE` (read every poll and through `settle`, as +`*_lacks` are). + +`ext_multiwindow.py` (group multi-window, `journeys/p0/mw-*.json`) judges which window an input acted +in: the target window changed as asked and every other window did not change. Steps: `name_window`, +`await_window` (the window Cmd-Shift-N, the menu, the CLI or a move just opened), `use_window`, +`remember_windows`, `click_sidebar` (the Home item or a workspace row, clicked with `debug.mouse` at +its `debug.sidebar_rows` frame), `move_workspace` (Move Workspace to Window, an explicit window), +`run_cli` (the bundled `cmux` on the app socket) and the clean-up `close_extra_windows`. Conditions: +`window_state` (what one window shows: Home, a workspace by name, its rows, its first sidebar entry, +pane and tab changes since a mark), `window_unchanged` (negative: every other window still shows the +same page, workspace, rows, panes, tabs, focused pane and frame), `windows_open`, `windows_show`. +The file's docstring has every field. + +`ext_spam.py` (group shortcut-spam, `journeys/p0/spam-*.json`) holds a shortcut down and hammers it (handoff +input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md). Keys go out on the system's schedule, one thread +each, stamped with their scheduled time, so a main thread that falls behind sees a queue of old repeats as with a +real held key; `debug.key {repeat: true}` sends an auto-repeat (the app's repeat gate reads that key as down). +Steps: `spam_remember`, `hold_key` (one press, repeats every 33 ms from 250 ms, then the key is up; keeps the counts +one frame after the key-up as `AS_release`), `hammer_key` (N physical presses within T seconds). Conditions: +`spam_counts` (tabs, panes, the focused pane's tabs, workspaces against a remembered count, by a delta or by the +runs a hold reported), `spam_unchanged` (negative: nothing changed after the key-up, with `settle`), +`spam_selected` (the selection moved N places, for Ctrl-Tab and Cmd-Shift-[ ]), `spam_runs`. The docstring has +every field. The 10x slow-motion clips need the Motion Inspector (cp/motion-inspector-N) in the build. + +`ext_window.py` (group window-pane-resize) adds pointer and window steps. Drags go through +`debug.mouse`: a press, one drag event per 60 Hz frame with the button held, a release, so +AppKit's live-resize tracking or the layout's divider drag reads them as it reads a person's drag. +Steps: `name_pane`, `adopt_pane` (the pane a split or a new window just focused), `use_pane` +(`click: true` clicks into it first), `remember`, `window_place` (set-up), `window_drag` +(`press: false` / `release: false` keep the button held, so checks run inside the live resize), +`divider_drag`, `action`, and the clean-ups `leave_full_screen`, `restore_window`, `close_window`. +Conditions: `window_frame`, `full_screen`, `grid_fits` (the pane shows; the app's grid equals its +fitted grid, Ghostty's drawn grid and the daemon screen's size, and fits the view's frame at +Ghostty's cell size), `pane_frame`, `pane_state`, `at_bottom` (the app's viewport ends with the +daemon screen's last lines: the scrollback did not jump), `output_advances`, and `pty_size` +(daemon evidence: the shell's `echo TAG $(stty size)` equals the daemon screen's and the app's +grid). The file's docstring has every field. + +## Judge + +The driver decides, not the author: + +- `pass`: every step ran, every condition held (in time), and the passing checks include app + state, the daemon's screen and a frame. A journey whose file lacks any of the three does not + validate. "Pixels changed" is never a check: a frame passes only when text recognition reads + the run's own expected text in it, so a blank frame, a fixture or an earlier run cannot pass. +- `fail`: a step failed (the error says which and why) or a condition did not hold. +- `not run`: the journey could not be played (no capture slot, the build failed or still + builds, the app never answered, the lease ran out). Never counted as a pass. + +Check the clip anyway: a pass says the checked text showed; the clip shows what else did. + +## Gate a dogfood build + +```bash +python3 scripts/journeys/dogfood-gate.py --build JOB +``` + +It refuses (exit 1) and prints the build report unless: + +1. every commit in `dogfood-fixes.list` is an ancestor of the build's commit (add each dogfood + fix there in the commit that lands it); +2. every P0 journey's newest result for JOB (any `results.jsonl` under `artifacts/journeys`, + `--results`) is `pass` on the build's commit, with passing app, daemon and frame evidence and + its check frames on disk; +3. the independent QA verdict file `artifacts/journeys/qa/.json` (`--qa`) says ship. + +The QA agent is not the author: it gets the build (tag and job) and `run.py --list`, never the +diff; it uses the build as a new user and tries to break it, files each failure as a new +journey and a bug, and writes: + +```json +{ + "build_job": "JOB", + "commit": "FULL SHA", + "verdict": "ship", + "qa_agent": "who ran QA", + "author_agent": "who wrote the build", + "tried": ["typed in a new terminal", "Ctrl+C on sleep and cat", "..."], + "failures": [{"what": "...", "journey": "journeys/p0/NEW.json", "bug": "issue or bead id"}] +} +``` + +Any other verdict, an empty `tried`, a failure without a filed journey and bug, or a QA agent +that is the author refuses the build. + +## Dogfood smoke gate (10 of 10 under memory pressure) + +```bash +python3 scripts/journeys/smoke-gate.py --build JOB [--rounds 10] [--swap-gb 15] +``` + +cmux-ad 2026-10-30 (stop the line, item 2): a build goes to Lawrence only with +`/Users/lawrence/fun/wt/handoff/dogfood-smoke/.pass`, which only this gate writes, and only +when `journeys/dogfood/smoke-dogfood.json` passed every round (10 of 10). Each round is one run of +`run.py --journey smoke-dogfood --exclusive --hog-swap-gb 15`: + +- `--exclusive`: the lease owns the whole mini (`capture-host acquire --slot --exclusive`), so no + fleet step runs there during the round (the fleet rule for evidence). + The perf minis are owner-only for pool `perf-lead`, so the gate sets `CAPTURE_HOST_POOL=perf-lead` + (capture-host passes it as `--owner-pool`; hq #5153: an older hq-tools install gets HTTP 409). +- `--hog-swap-gb`: the Austin's-day memory hog (`smoke_hog.py`, a copy of feat's + `scripts/cmux-next/austins-day/hog.py` until perf has it) holds incompressible memory until swap + used is that much above the start, gives memory back near the edge, and ends by its own + deadline; run.py starts it before the download and launch (at least 45 s before the journey) + and stops it by its pid. A 16 GB mini may reach its free-memory edge before +15 GB of swap; the + gate counts a round only when its hog log shows pressure (at least 4 GB held and swap +15 GB or + `kern.memorystatus_level` down to 20), and reports what each round reached. + +The journey (group `ext_smoke.py`): Cmd-N then Ctrl-\` three times (three workspaces, each with a +terminal: a person's Cmd-N shows the New Tab page; Ctrl-\` comes as soon as the workspace shows, +as a person presses it, before the daemon may have confirmed it), a line typed into each new terminal at once; +Cmd-D twice fast, a line typed into each new pane at once; Ctrl-\` twice fast, a line typed into +each new tab at once; Cmd-Shift-L, example.com, Return; Cmd-Q and reopen, the same three +workspaces (ids, as recorded once the daemon confirmed them) with their output back; close all. Each step is checked by the app's state +(topology, `debug.surfaces`, `debug.focus`, `debug.windows`, `debug.window_list`, `debug.crashes`), +the daemon's screens and a window frame (`check-NN.png`). `smoke_new_terminals` proves the keys +went to the new terminals: each line is on exactly one terminal, that terminal is new, it is where +it should be (its own new workspace, its own new pane, or a new tab of the focused pane, which +gained exactly that many tabs), and the last line is on the focused terminal. `smoke_panes` counts the panes of +the workspace whose terminal shows a line (the driver's `app_layout` needs a terminal it adopted itself). + +**Resize (R3a) and new columns (R3b)** (cmux-ad orders 3 and 4, 2026-10-31; Lawrence: "resizing +terminals is broken", "creating new columns must never resize existing columns"). After Cmd-D twice, +the journey drags the divider between the two new panes and then the window's right edge. After each, +in the shown terminal: grid == fitted grid == drawn grid == fit(the view's bounds at the cell size) == +the PTY's size (`stty size`) == the daemon's screen size == the topology tab's size, and a line longer +than the old width wraps at the new width (`smoke_r3`; every size is in the result; the window check +also needs the window 160 points narrower and the pane narrower than before). Both are re-read through +a 1.5 s settle. Then New Column twice (the action, then its key Ctrl-Cmd-D): every existing column +(matched by its panes, width from the float view frames) keeps exactly its width, and there is one +more column each time (`smoke_same_widths`; each old and new width in the result). Cmd-D (Split +Right) is not used here: it never opens a column (`SplitRoomCheck.toolSplit`), it splits the pane. + +**Typed at once** (`smoke_type_now`): no wait for the creation hold, the case Lawrence reports +(keys into a terminal while it is created). A capture slot's window is never key +(`CMUX_NEXT_NO_ACTIVATE`), and held keys are replayed through `NSApplication.sendEvent`, which +reaches only the key window. So the journey first makes its window key without activating the +app (`smoke_make_key`: `debug.window.focus`, which calls `makeKey()` and reports the key window), +and each check and each type-at-once step reads that a main window is still key +(`smoke_key_window`, `debug.window_list`; not key before typing: known gap). +If the window did not become key, the round is "not run: known gap", which never passes the gate. +`smoke-gate.py --whole-host HOST` takes that mini whole (exclusive) and launches the app active +(`capture-host launch --activate`; the driver's relaunch then opens it in front too), so its window +is key and every line is typed at once. It needs CuaDriver's grants, given at the mini's console. + +Verdict: PASS (every round passed, 10 rounds or more at +15 GB; the `.pass` file holds the +summary, the rounds' leases, pressure and results, and the gate's commit; a gate with uncommitted +changes never writes it), TRIAL PASS (all passed with fewer rounds or less swap: no `.pass`), FAIL +(a round failed: the summary names its step), NOT JUDGED (no failure, but a round did not run or +showed no memory pressure), DIAGNOSTIC (`--diagnostic`: where the slot has no key window the lines are +typed after the creation hold, every other check runs and is counted per round; never a `.pass`). A round counts only with its own fresh results for this build; `--out` +must be a new folder. Writes `summary.json` and `summary.txt`. Test: +`python3 scripts/journeys/test_smoke_gate.py`. + +## Action fuzz (correctness and speed, one harness with the smoke gate) + +```bash +python3 scripts/journeys/fuzz.py --build JOB --base BASE_JOB [--minutes 10] [--set layout|full] [--corpus] [--diagnostic] +python3 scripts/journeys/fuzz.py --build JOB --replay artifacts/fuzz//repros/found--.json +python3 scripts/journeys/fuzz.py --plan --seed 7 --set layout # a seed's plan, no fleet +python3 scripts/journeys/test_fuzz.py; python3 scripts/journeys/test_fuzz_sim.py # no fleet, no app +``` + +cmux-ad 2026-10-31 (Lawrence: "we need to fuzz greater for both correctness and speed"). The fuzz is a mode of this +driver: `ext_fuzz.py` adds the step `fuzz_run` (journey `journeys/fuzz/action-fuzz.json`), and `fuzz.py` runs it in +rounds through `run.py` (`--exclusive` leases in pool perf-lead, never cmux17 or cmux16). It plays a seeded plan of user +actions through `debug.key` (the key router's real path, as the gate's keys), ext_window's drags and the driver's +relaunch, at mixed timings (`now`, `pair` within 60 ms, `slow` 1.5 s), and keeps a model of the expected workspaces, +tabs, terminals and panes. After every burst it runs the oracles, which reuse the gate's own conditions: + +| Oracle | Holds when | +| --- | --- | +| `create` | a create reached the model's prediction, or the app showed a refusal (`debug.toast` refusals); a probe line typed into the new terminal runs there and nowhere else | +| `r3a` | every shown terminal passes ext_smoke's `smoke_r3` (grid == fit == drawn == fit(bounds) == daemon == topology, and == the PTY where a probe printed `stty size`) | +| `r3b` | after a burst that only adds columns (New Column, Cmd-D), every old column keeps its exact width (ext_smoke's `smoke_same_widths` reading) | +| `focus` | `debug.focus` has no model/first-responder mismatch (weak in a slot: no key window) | +| `topology` | every terminal the app shows is in the daemon's list; `debug.desync` has no violation | +| `stall` | no `debug.hangs` record over 100 ms with a cmux frame | +| `crash` | the app lives, its daemon is connected, a quit's relaunch is clean | +| `leak` | after close-all, PTYs and non-helper processes at the first close-all's count; fds and browser helpers within a margin | + +Sets: `layout` (fuzz-catalog row m, the first mode: New Column, Cmd-D, Split Down, Ctrl-`, close pane, divider drag, +window resize, relaunch) and `full` (also Cmd-T, Cmd-N, closes, workspace switches, typing, paste, a browser URL, +close all). A failure is shrunk in the same app (ddmin; each try starts from the baseline workspaces), replayed in a +fresh lease, and written as a regression journey (`OUT/repros/found--.json`, red on that build). The +corpus `journeys/fuzz/lawrence-*.json` (`--corpus`) plays Lawrence's reported bugs as fixed sequences. + +Speed: each create records `shown_ms` (key to the new terminal's pane drawn, not blank) and `typeable_ms` (key to its +prompt in the drawn viewport; Cmd-T: the New Tab field focused). No build has a present (photon) timestamp yet, so +these are the app's drawn state, read the same way on both builds. `--base` plays the same seeds on the base build on +the same mini, ABBA over rounds; a mark is red when the build's p50 is 25 % and 40 ms over the base's and its p90 is +worse, with at least 4 samples each. + +Verdict: CLEAN (writes `handoff/dogfood-fuzz/.clean`), RED, NOT JUDGED, or DIAGNOSTIC (`--diagnostic`: a slot +has no key window, so every key after a create waits out the 2.5 s creation hold; typing at once, quick pairs of +creates and the focus oracle are weak there; never a clean file). + +## Not supported yet + +- Generated journeys: one per action catalog row, settings schema entry and keyboard shortcut + (planned as a generator writing `journeys/generated/`; P0 is hand-written now). +- Pointer journeys beyond window and divider drags, pane clicks (`ext_window.py`) and sidebar Home + and workspace-row clicks (`ext_multiwindow.py`): tab drags, the green button. +- The Dock's reopen (`applicationShouldHandleReopen`, only with no window visible): no debug method + drives it, so `mw-new-window-entrypoints` does not cover it. +- Display and scale changes during a resize (`debug.simulate.display` exists; no step yet). +- Pointer in the pane: the `mouse` and `select_text` steps cover wheel, clicks and text selection + (scroll-paste group). +- Relaunch through `capture-host relaunch`: the driver runs on the mini, so its `relaunch` step opens + the bundle itself (as `restore-journey.py` does); `capture-host release` still ends every process + of the lease root. The full restore matrix (browser pages, agents, update and reboot variants) + stays in `restore-journey.py`. +- Sleep and wake. +- macOS 14 and 15: capture minis run the fleet's macOS; use `scripts/cmux-next/os-matrix.sh`. + +## Why + +From the cmux-ad handoff "Holistic quality" (Lawrence, 2026-10-27): v1 shipped with Ctrl+C and +live resize broken because no agent typed in a terminal or resized a window before handing it +over; v2 dropped both fixes; many PASS results were pixels-changed, blank frames or fixture text; +and the author judged its own work. Each P0 journey and the gate exist to make those mistakes +impossible to repeat. diff --git a/scripts/journeys/dogfood-fixes.list b/scripts/journeys/dogfood-fixes.list new file mode 100644 index 000000000000..b544f6e61723 --- /dev/null +++ b/scripts/journeys/dogfood-fixes.list @@ -0,0 +1,10 @@ +# Every dogfood fix commit: every later dogfood build must contain each one (cmux-ad 2026-10-27). +# One abbreviated or full sha per line, then why. dogfood-gate.py refuses a build whose commit lacks any. +# Ported from the orchestrator's chrome-parity-design/dogfood/fixes.list (check-fixes.sh); add each new +# dogfood fix here in the commit that lands it. +d00c05b3f61 v1: event-cause-v1 is an optional daemon capability (compile) +a6af36e87b8 v1: the server helper phase compiles CmuxSafeConcurrency before CmuxNextCompat (compile) +6a4a6a0f242 v1.1: test, Ctrl+C reaches the foreground job (controlling tty) +33cd577dcd8 v1.1: test, live resize does not jump the scrollback +66d9960c10f v1.1: fix, PTY child gets a controlling tty (setsid, open, TIOCSCTTY) +673587c97dc v1.1: fix, TerminalScroller ignores AppKit's clip clamp during tile() diff --git a/scripts/journeys/dogfood-gate.py b/scripts/journeys/dogfood-gate.py new file mode 100755 index 000000000000..bca323935cb4 --- /dev/null +++ b/scripts/journeys/dogfood-gate.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Refuse a dogfood build unless it keeps every earlier fix, passed every P0 journey, and QA says ship. + + scripts/journeys/dogfood-gate.py --build JOB [--results DIR] [--qa FILE] [--fixes FILE] [--repo DIR] + +Run by the lead before a dogfood build goes to Lawrence (holistic-quality.md 2 and 3); it is not a +CI gate. Refuses (exit 1) unless all three hold for the exact build of fleet job JOB: + + (a) fixes: every commit in dogfood-fixes.list is an ancestor of the build's commit + (`git merge-base --is-ancestor`, after fetching the commit; an unknown sha refuses too); + (b) journeys: every P0 journey in scripts/journeys/journeys has a `pass` line for JOB and its + commit in some results.jsonl under DIR (run.py's output; default artifacts/journeys), the + newest line per journey counting, with passing app, daemon and frame evidence and its check + frames on disk; + (c) QA: the independent QA agent's verdict file (default artifacts/journeys/qa/.json) names + JOB, says "verdict": "ship", lists what it tried, and has no open failure (README.md). + +Prints the build report (fixes, P0 results with clip links, the QA verdict) either way. +Exit 0: ship; 1: refused; 2: usage or the build could not be read. +""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(ROOT / "scripts" / "cmux-next")) +import run as journeys # noqa: E402 + +REQUIRED = ("app", "daemon", "frame") + + +def build_record(job): + ci = shutil.which("cmux-ci") or str(Path.home() / ".local/bin/cmux-ci") + done = subprocess.run([ci, "status", job], capture_output=True, text=True, timeout=120) + if done.returncode != 0: + raise SystemExit(f"dogfood-gate: cmux-ci status {job} failed: {(done.stderr or done.stdout).strip()[-300:]}") + return json.loads(done.stdout) + + +def git(repo, *args): + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) + + +def check_fixes(repo, sha, fixes): + rows, refusals = [], [] + if git(repo, "cat-file", "-e", f"{sha}^{{commit}}").returncode != 0: + git(repo, "fetch", "-q", "origin", sha) + if git(repo, "cat-file", "-e", f"{sha}^{{commit}}").returncode != 0: + return [], [f"fixes: the build commit {sha} is not in {repo} even after a fetch"] + for raw in Path(fixes).read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + commit, _, why = line.partition(" ") + known = git(repo, "rev-parse", "--verify", "-q", f"{commit}^{{commit}}") + if known.returncode != 0: + rows.append(("UNKNOWN", commit, why)) + refusals.append(f"fixes: {commit} ({why}) is not a commit in {repo}") + continue + ancestor = git(repo, "merge-base", "--is-ancestor", commit, sha).returncode + if ancestor == 0: + rows.append(("ok", commit, why)) + else: + rows.append(("MISSING", commit, why)) + refusals.append(f"fixes: {sha[:11]} lacks {commit} ({why})") + return rows, refusals + + +def newest_results(results_dir, job): + newest = {} + for path in sorted(Path(results_dir).rglob("results.jsonl")): + for raw in path.read_text().splitlines(): + try: + line = json.loads(raw) + except ValueError: + continue + if line.get("build_job") != job: + continue + line["_file"] = str(path) + if line["id"] not in newest or str(line.get("run_at")) >= str(newest[line["id"]].get("run_at")): + newest[line["id"]] = line + return newest + + +def check_journeys(results_dir, job, sha): + specs, problems = journeys.load_journeys() + if problems: + return [], [f"journeys: the journey files do not validate: {p}" for p in problems] + p0 = [s for s in specs if s["priority"] == "P0"] + newest = newest_results(results_dir, job) + rows, refusals = [], [] + for spec in p0: + line = newest.get(spec["id"]) + if line is None: + rows.append((spec["id"], "not run", "no result for this build", None)) + refusals.append(f"journeys: P0 {spec['id']} has no result for build {job}") + continue + status, reason = line.get("status"), line.get("reason") or "" + kinds = {e.get("kind") for e in line.get("evidence") or [] if e.get("pass")} + failing = [e for e in line.get("evidence") or [] if not e.get("pass")] + checks_on_disk = [c for c in line.get("checks") or [] if Path(c).is_file()] + if status == "pass": + if line.get("commit") != sha: + status, reason = "fail", f"its result names commit {line.get('commit')}, not {sha}" + elif any(k not in kinds for k in REQUIRED) or failing: + status, reason = "fail", "its result lacks passing app, daemon and frame evidence" + elif not checks_on_disk: + status, reason = "fail", "its check frames are not on disk" + rows.append((spec["id"], status, reason, line.get("clip"))) + if status != "pass": + refusals.append(f"journeys: P0 {spec['id']} is {status}: {reason}") + return rows, refusals + + +def check_qa(path, job, sha): + path = Path(path) + if not path.is_file(): + return None, [f"qa: no verdict file at {path} (the independent QA agent writes it; README.md)"] + try: + verdict = json.loads(path.read_text()) + except ValueError as error: + return None, [f"qa: {path} is not JSON: {error}"] + refusals = [] + if verdict.get("build_job") != job: + refusals.append(f"qa: the verdict names build {verdict.get('build_job')!r}, not {job}") + if verdict.get("commit") and verdict["commit"] != sha: + refusals.append(f"qa: the verdict names commit {verdict['commit']}, not {sha}") + if verdict.get("verdict") != "ship": + refusals.append(f"qa: the verdict is {verdict.get('verdict')!r}, not \"ship\"") + if not verdict.get("tried"): + refusals.append("qa: the verdict lists nothing it tried") + open_failures = [f for f in verdict.get("failures") or [] if not (isinstance(f, dict) and f.get("journey") and f.get("bug"))] + if open_failures: + refusals.append(f"qa: {len(open_failures)} failure(s) without a filed journey and bug") + if verdict.get("qa_agent") and verdict.get("qa_agent") == verdict.get("author_agent"): + refusals.append("qa: the QA agent is the author; the author cannot judge its own build") + return verdict, refusals + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("\n\n", 1)[1]) + ap.add_argument("--build", required=True, help="the dogfood build's fleet job id") + ap.add_argument("--results", default=str(ROOT / "artifacts" / "journeys"), help="where run.py wrote results.jsonl files") + ap.add_argument("--qa", help="the QA verdict file (default /qa/.json)") + ap.add_argument("--fixes", default=str(HERE / "dogfood-fixes.list")) + ap.add_argument("--repo", default=str(ROOT), help="a clone that has (or can fetch) the build commit") + opts = ap.parse_args(argv) + + record = build_record(opts.build) + sha = record.get("ref") or record.get("commit") or "" + if not re.fullmatch(r"[0-9a-f]{40}", sha): + print(f"dogfood-gate: build {opts.build} names no exact commit ({sha!r})", file=sys.stderr) + return 2 + refusals = [] + if record.get("state") != "done": + refusals.append(f"build: job {opts.build} is {record.get('state')}, not done") + fix_rows, r = check_fixes(opts.repo, sha, opts.fixes) + refusals += r + journey_rows, r = check_journeys(opts.results, opts.build, sha) + refusals += r + verdict, r = check_qa(opts.qa or Path(opts.results) / "qa" / f"{opts.build}.json", opts.build, sha) + refusals += r + + print(f"# Dogfood build report: {record.get('tag')} (job {opts.build})\n") + print(f"commit {sha}, state {record.get('state')}, worker {record.get('worker')}\n") + print("## Earlier dogfood fixes (every one must be an ancestor)\n") + for state, commit, why in fix_rows: + print(f"- {state:<7} {commit} {why}") + print("\n## P0 journeys (on this exact build)\n") + print("| journey | result | clip | note |\n| --- | --- | --- | --- |") + for jid, status, reason, clip in journey_rows: + print(f"| {jid} | {status} | {clip or '-'} | {reason or ''} |") + print("\n## Independent QA\n") + if verdict: + print(f"verdict {verdict.get('verdict')!r} by {verdict.get('qa_agent', '?')}; tried {len(verdict.get('tried') or [])} things; " + f"failures {len(verdict.get('failures') or [])}") + else: + print("no verdict") + print() + if refusals: + print("REFUSED: this build does not go to Lawrence:") + for line in refusals: + print(f"- {line}") + return 1 + print("SHIP: every earlier fix is in, every P0 journey passed on this build, and QA says ship.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/journeys/driver.py b/scripts/journeys/driver.py new file mode 100755 index 000000000000..bd217c7d05da --- /dev/null +++ b/scripts/journeys/driver.py @@ -0,0 +1,1619 @@ +#!/usr/bin/env python3 +"""Run user journeys against one launched cmux NEXT DEV app, on the Mac where it runs. + +run.py sends this file to a leased capture slot (with build_names.py and the journey specs) and +starts it there with the stock /usr/bin/python3 (3.9: standard library only): + + driver.py --socket S --app APP --home HOME --journeys specs.json --out DIR --ocr OCR + [--deadline-seconds N] [--frame-interval S] + +Each journey is a list of steps (README.md, "Steps and conditions"). The driver plays them +through the same interfaces a person's input and the app's own state go through: + + - keys: `debug.key`, a key-down dispatched the way NSApplication.sendEvent does (the key + router, window key equivalents, the menu, then the responder chain): the real key path; + - app state: the control socket (`debug.surfaces` with the pane's viewport text); + - daemon state: the bundled `cmux` CLI on the app's own daemon (`terminal ID screen read`); + - processes: the Mac's process table (`ps`), scoped to the terminal's `__terminal-host`; + - frames: the app's own drawing of its main window (`debug.window_snapshot`), read with + macOS text recognition (OCR, scripts/ci/pr_media_ocr.swift compiled by run.py). + +It writes DIR//result.json (status pass, fail or not run, every step and every +piece of evidence), DIR//frames/ (the window about every --frame-interval seconds, for the +clip) and DIR//check-NN.png (each frame a check read). A journey passes only when every +step ran, every condition held, and the passing evidence covers app state, the daemon screen +and a frame. Exit 0 when every journey passed, 1 otherwise. +""" +import argparse +import hashlib +import json +import os +import random +import re +import shlex +import shutil +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # build_names.py beside it, also under -I +import build_names # noqa: E402 + +PROMPT = "J> " +# The prompt every journey's shell gets (neutral: no host, user or path in any frame). +NEUTRAL_PROMPT = "PS1='J> '; PROMPT='J> '; RPROMPT=''; unset PROMPT_COMMAND; cd /tmp; printf '\\033[H\\033[2J\\033[3J'\r" +# Before it, the shell writes its tty to {tty_file} (its own short line, which the neutral prompt's clear then +# removes): one terminal host runs the shells of every terminal (one tty each), and process checks look only +# at this terminal's tty. +TTY_PROBE = "tty >| {tty_file}\r" +RPC_TIMEOUT = 30.0 +SNAPSHOT_TIMEOUT = 30.0 +POLL_SECONDS = 0.05 +FRAME_INTERVAL = 1.0 +MAX_FRAMES = 240 +CLIP_WIDTH = 1280 +# Seconds of fresh snapshots a frame check gets once the state it should show holds. +FRAME_GRACE = 10.0 +# Evidence a passing journey must have: the user-visible result seen three independent ways. +REQUIRED_KINDS = ("app", "daemon", "frame") +KIND = {"daemon_line": "daemon", "daemon_contains": "daemon", "daemon_prompt": "daemon", + "app_text_line": "app", "app_text_contains": "app", "app_prompt": "app", "app_pane": "app", + "process_present": "process", "process_gone": "process", "frame_text": "frame", + # Negative reads, scrollback rows and the clipboard ("Scroll, paste, select" below). + "daemon_lacks": "daemon", "daemon_history": "daemon", "app_text_lacks": "app", "app_rows": "app", + "frame_screen": "frame", "clipboard": "clipboard", "quit_leftovers": "process", + # App life (journeys/p0/app-life-*.json): layout, working directories, restore and settings. + "daemon_cwd": "daemon", "daemon_grid": "daemon", "daemon_pane_ready": "daemon", + "app_cwd": "app", "app_layout": "app", "app_tab_pane": "app", "app_tab_gone": "app", "app_rpc": "app", + "app_grid": "app"} +# Conditions an extension module adds (ext_*.py beside this file, load_extensions): name -> fn(journey, want, cache). +EXT_CONDITIONS = {} +# The extension conditions that are negative (an extension's NEGATIVE): read again every poll, never latched. +EXT_NEGATIVE = set() +# Seconds a quit (Cmd-Q or a crash) may take to end the app's process, and a relaunch to answer. +QUIT_SECONDS = 60 +RELAUNCH_SECONDS = 90 +# A capture slot's window is never the key window (the app runs with CMUX_NEXT_NO_ACTIVATE). Keys typed while +# the app holds them for a terminal still being made (CreationInputCoordinator: after Cmd-D, Ctrl-` or a restore, +# at most its holdLimit of 2 s) are replayed through NSApp.sendEvent, which only reaches the key window, so in a +# slot they are lost (a person's window is key). Steps that make a terminal wait this long before the next key. +CREATION_HOLD_SECONDS = 2.5 +# The app's New Terminal Tab key (newSurface, TabActionCatalog's default Ctrl-`): what a person presses for a +# new terminal. On Home it opens a workspace with a terminal; in a workspace, a terminal tab in the focused pane. +NEW_TERMINAL_KEY = {"key": "`", "modifiers": ["control"]} +# Launch variables a relaunch passes on (restore-journey.py's rule): what a capture slot sets. +LAUNCH_ENV = re.compile(r"^(CMUX_[A-Z0-9_]*|HOME|TMPDIR)$") + + +def now(): + return time.monotonic() + + +class StepError(Exception): + pass + + +class Deadline(Exception): + pass + + +class NotRun(Exception): + """The journey cannot be played in this app (for example: it needs a fresh app and another journey ran first).""" + + +# Journeys already played in this app process (first launch needs none before it). +PLAYED = [] + + +# ---- the app: control socket, daemon CLI, process table, frames ----------------------------- + +class App: + def __init__(self, sock, bundle, home, ocr): + self.sock, self.home, self.ocr = sock, home, ocr + info = self.rpc("system.identify") + if not isinstance(info, dict) or not info.get("pid"): + raise SystemExit(f"driver: no app answers on {sock}: {info!r}") + self.identity = info + self.pid = int(info["pid"]) + self.bundle = info.get("app_bundle_path") or bundle + self.cli = info.get("app_cli_path") or os.path.join(self.bundle, "Contents/Resources/bin/cmux") + self.tag = info.get("tag") + bundle_id = info.get("bundle_id") or "" + if ".debug" not in bundle_id or self.bundle.startswith("/Applications/"): + raise SystemExit(f"driver: refusing {self.bundle} ({bundle_id}): not a tagged DEV build") + try: + named = build_names.of_app(self.bundle).get("session") + except (ValueError, OSError, KeyError): + named = None + self.sessions = [s for s in (named, f"cmux-app-{self.tag}" if self.tag else None) if s] + + def rpc(self, method, params=None, timeout=RPC_TIMEOUT): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn: + conn.settimeout(timeout) + conn.connect(self.sock) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + reply = json.loads(buf or b"{}") + if not reply.get("ok", "result" in reply): + raise StepError(f"{method}: {reply.get('error') or 'no answer'}") + result = reply.get("result") + if isinstance(result, dict) and result.get("error"): + raise StepError(f"{method}: {result['error']}") + return result + + def reidentify(self, info): + """After a relaunch: the new process of the same bundle (refused if it is another app).""" + if not isinstance(info, dict) or not info.get("pid"): + raise StepError(f"system.identify after relaunch: {info!r}") + bundle = info.get("app_bundle_path") or self.bundle + if bundle != self.bundle: + raise StepError(f"the socket now answers for another app: {bundle}") + self.identity, self.pid = info, int(info["pid"]) + self._daemon_sock = None + + def launch_env(self): + """The app process's own launch variables (`ps eww`, same user): CMUX_*, HOME and TMPDIR.""" + out = subprocess.run(["ps", "eww", "-o", "command=", "-p", str(self.pid)], capture_output=True, text=True).stdout + env = {} + for word in out.split(): + key, sep, value = word.partition("=") + if sep and LAUNCH_ENV.match(key): + env[key] = value + return env + + @staticmethod + def alive(pid): + """Whether `pid` runs (a zombie counts as gone).""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + stat = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True).stdout.strip() + return bool(stat) and not stat.startswith("Z") + + def daemon_socket(self): + """The app daemon's socket, from its own `--socket` argument: the daemon the app says it is + connected to (`debug.daemon` pid), else the `--headless` process of this bundle and session.""" + try: + state = self.rpc("debug.daemon", timeout=5) or {} + except (StepError, OSError, ValueError): + state = {} + self.daemon_state = state + lines = [] + if state.get("pid"): + lines = subprocess.run(["ps", "-o", "args=", "-p", str(int(state["pid"]))], capture_output=True, text=True).stdout.splitlines() + if not any("--socket " in line for line in lines): + lines = [line for line in subprocess.run(["ps", "axo", "args="], capture_output=True, text=True).stdout.splitlines() + if line.startswith(self.bundle + "/") and "--headless" in line + and any(f"--session {s} " in line + " " for s in self.sessions)] + for line in lines: + found = re.search(r"--socket[ =](\S+)", line) + if found: + return found.group(1) + self.daemon_args = lines + return None + + def cmux(self, *args, timeout=30): + """The bundled `cmux` CLI on the app's daemon, `--json`: the parsed answer.""" + sock = self._daemon_sock if getattr(self, "_daemon_sock", None) and os.path.exists(self._daemon_sock) else None + sock = sock or self.daemon_socket() + self._daemon_sock = sock + if not sock: + raise StepError(f"no daemon socket: debug.daemon {json.dumps(getattr(self, 'daemon_state', None))[:300]}, " + f"daemon args {getattr(self, 'daemon_args', [])[:2]}") + env = {"HOME": self.home, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "CMUX_QUIET": "1"} + done = subprocess.run([self.cli, "--socket", sock, "--json", *args], capture_output=True, text=True, + timeout=timeout, env=env) + try: + answer = json.loads(done.stdout) + except ValueError: + raise StepError(f"cmux {' '.join(args)[:80]}: exit {done.returncode}: {(done.stdout + done.stderr).strip()[-300:]}") + if isinstance(answer, dict) and answer.get("error"): + raise StepError(f"cmux {' '.join(args)[:80]}: {answer['error']}") + return answer.get("value", answer) if isinstance(answer, dict) and "text" not in answer else answer + + def screen(self, term): + answer = self.cmux("terminal", term, "screen", "read") + for _ in range(3): # the answer, or its value / result envelope + if isinstance(answer, dict) and "text" not in answer: + answer = next((answer[k] for k in ("value", "result", "data") if isinstance(answer.get(k), dict)), answer) + if not isinstance(answer, dict) or "text" not in answer: + raise StepError(f"terminal {term} screen read gave no text: {json.dumps(answer)[:200]}") + return answer + + def panes(self): + report = self.rpc("debug.surfaces", {"text": True}) or {} + return [p for w in report.get("windows", []) for p in w.get("panes", []) if isinstance(p, dict)] + + @staticmethod + def processes(): + """{pid: {ppid, pgid, tpgid, tty, stat, command}} of every process of this account.""" + out = subprocess.run(["ps", "-axo", "pid=,ppid=,pgid=,tpgid=,tty=,stat=,command="], + capture_output=True, text=True).stdout + table = {} + for line in out.splitlines(): + parts = line.split(None, 6) + if len(parts) < 6: + continue + try: + pid, ppid, pgid, tpgid = (int(p) for p in parts[:4]) + except ValueError: + continue + table[pid] = {"pid": pid, "ppid": ppid, "pgid": pgid, "tpgid": tpgid, "tty": parts[4], "stat": parts[5], + "command": parts[6] if len(parts) > 6 else ""} + return table + + def bundle_processes(self, app_pid=None): + """[{pid, ppid, rss_kb, command}] of every process this app's bundle started or owns: its command is + in the bundle (helpers, daemons, Chromium) or names the bundle id as its `--owner` (terminal hosts).""" + out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss=,command="], capture_output=True, text=True).stdout + bundle_id = str(self.identity.get("bundle_id") or "") + rows = [] + for line in out.splitlines(): + parts = line.split(None, 3) + if len(parts) < 4 or not parts[0].isdigit(): + continue + command = parts[3] + owned = bool(bundle_id) and re.search(r"--owner " + re.escape(bundle_id) + r"(?::|\s|$)", command) + if (command.startswith(self.bundle + "/") or owned) and int(parts[0]) != app_pid: + rows.append({"pid": int(parts[0]), "ppid": int(parts[1]), "rss_kb": int(parts[2]) if parts[2].isdigit() else None, + "command": command[:400]}) + return rows + + def roots(self, table, host_pid): + """The terminal's host when the app named it, else every process run from the app's bundle.""" + if host_pid and host_pid in table: + return {host_pid} + return {pid for pid, row in table.items() if row["command"].startswith(self.bundle + "/")} + + def snapshot(self, path): + path = Path(path) + if path.exists(): + path.unlink() + # The journey's window (a main window id) when a step chose one, else the main window. + target = {"window": self.snapshot_window} if getattr(self, "snapshot_window", None) else {"kind": "main"} + self.rpc("debug.window_snapshot", dict(target, path=str(path)), timeout=SNAPSHOT_TIMEOUT) + if not path.is_file() or path.stat().st_size == 0: + raise StepError("debug.window_snapshot wrote no image") + return path + + def read_text(self, image): + done = subprocess.run([self.ocr, str(image)], capture_output=True, text=True, timeout=120) + if done.returncode != 0: + raise StepError(f"text recognition failed on {Path(image).name}: {(done.stderr or done.stdout).strip()[-200:]}") + return [line for line in done.stdout.splitlines() if line.strip()] + + +def descendants(table, roots): + children = {} + for row in table.values(): + children.setdefault(row["ppid"], []).append(row["pid"]) + seen, todo = set(), list(roots) + while todo: + pid = todo.pop() + for child in children.get(pid, []): + if child not in seen: + seen.add(child) + todo.append(child) + return seen + + +class Frames(threading.Thread): + """The main window every `interval` seconds while a journey runs: the clip.""" + + def __init__(self, app, directory, interval): + super().__init__(daemon=True) + self.app, self.directory, self.interval = app, Path(directory), max(0.25, interval) + self.count, self.stopped = 0, threading.Event() + self.directory.mkdir(parents=True, exist_ok=True) + + def run(self): + while not self.stopped.is_set() and self.count < MAX_FRAMES: + try: + self.app.snapshot(self.directory / f"frame-{self.count:05d}.png") + self.count += 1 + except (OSError, ValueError, StepError): + pass + self.stopped.wait(self.interval) + + +# ---- text matching ------------------------------------------------------------------------- + +def lines_of(text): + return [line.rstrip() for line in (text or "").split("\n")] + + +def last_line(text): + filled = [line for line in lines_of(text) if line.strip()] + return filled[-1] if filled else "" + + +# Letters text recognition confuses in a monospace terminal font, folded to one form. +OCR_FOLD = str.maketrans({"O": "0", "o": "0", "D": "0", "I": "1", "l": "1", "L": "1", "|": "1", "i": "1", "S": "5", "s": "5", + "B": "8", "Z": "2", "z": "2", "—": "-", "–": "-", "_": "-"}) + + +def ocr_contains(lines, wanted): + """Whether OCR'd `lines` show `wanted` (spaces dropped, look-alike letters folded).""" + fold = lambda s: re.sub(r"\s+", "", s).translate(OCR_FOLD).lower() + target = fold(wanted) + return any(target in fold(line) for line in lines) or target in fold("".join(lines)) + + +def negative(name, want): + """Whether a condition claims something is NOT there: read again every poll, never latched.""" + if name in ("daemon_lacks", "app_text_lacks") or name in EXT_NEGATIVE: + return True + # daemon_history with `lacks`; app_rows with `hold` (the view must stay as it is, e.g. while output arrives). + return isinstance(want, dict) and ((name == "daemon_history" and "lacks" in want) + or (name == "app_rows" and bool(want.get("hold")))) +# The app-life conditions (Journey.evaluate_life). +LIFE_CONDITIONS = {"daemon_cwd", "daemon_grid", "daemon_pane_ready", "app_cwd", "app_layout", "app_tab_pane", + "app_tab_gone", "app_rpc", "app_grid"} + + +def same_dir(got, wanted): + """Whether two directory paths are the same place: macOS shows /tmp and /var as /private/tmp and + /private/var, and a shell may print either.""" + if not got or not wanted: + return False + norm = lambda p: re.sub(r"^/private(?=/)", "", str(p).rstrip("/") or "/") + return norm(got) == norm(wanted) + + +def dig(value, path): + """`value` at a dotted path ("result.launch.recovery"; list items by index).""" + for part in [p for p in str(path).split(".") if p]: + if isinstance(value, list) and part.isdigit(): + value = value[int(part)] if int(part) < len(value) else None + elif isinstance(value, dict): + value = value.get(part) + else: + return None + return value + + +# ---- one journey --------------------------------------------------------------------------- + +class Journey: + def __init__(self, spec, app, out, deadline, frame_interval): + self.spec, self.app, self.deadline = spec, app, deadline + self.id = spec["id"] + self.out = Path(out) / self.id + if self.out.exists(): + shutil.rmtree(self.out) + self.out.mkdir(parents=True) + self.frame_interval = frame_interval + # Digits a text reader cannot confuse with letters: the run's own text, never a fixture's. + self.nonce = "".join(random.choice("23456789") for _ in range(6)) + self.vars = {"nonce": self.nonce, "prompt": PROMPT} + self.marks, self.binds = {}, {} + # {terminal id: its tty as ps names it (ttys004)}, written by the shell at neutral_prompt. + self.ttys, self.tty_probes = {}, 0 + self.term = self.pane = self.host_pid = None + self.steps, self.evidence, self.checks = [], [], 0 + # The window keys go to (a main window id; None: debug.key's default, the first window), and + # the window frames are read from (None: the main window); each journey starts with both defaults. + self.key_window = None + app.snapshot_window = None + self.files = [] + # Named terminals ({"term", "pane", "host_pid"}): `as` on the steps that adopt one, `use` switches. + self.aliases = {} + # The app's own terminal ids already on screen: `focused_terminal` waits for one not in it. + self.known_views = set() + + def sub(self, value): + if isinstance(value, str): + return re.sub(r"\$\{(\w+)\}", lambda m: str(self.vars.get(m.group(1), m.group(0))), value) + if isinstance(value, list): + return [self.sub(v) for v in value] + if isinstance(value, dict): + return {k: self.sub(v) for k, v in value.items()} + return value + + # -- steps -- + + def step_new_terminal(self, step): + """`via`: `key` (the person's New Terminal Tab key, NEW_TERMINAL_KEY, through debug.key: an action of + origin user that claims the window's keyboard) or `socket` (default: a script's `action.run newTab` + with focus, which since cp/fix-chief-composer-focus shows the workspace only when no text input has + the person's keyboard). A journey that plays what a person does says `via: key`.""" + via = step.get("via", "socket") + if via == "key": + return self.new_terminal_by_key(step) + if via != "socket": + raise StepError(f"new_terminal: via is key or socket, not {via!r}") + before = {p.get("terminal_id") for p in self.app.panes() if p.get("terminal_id")} + # A second new_terminal in one journey (a second window) finds its own terminal, not the first's. + self.term = self.pane = self.host_pid = None + reply = self.app.rpc("action.run", {"action": "newTab", "args": {"name": self.sub(step.get("name", self.id)), "focus": True}}) + end = now() + float(step.get("timeout", 20)) + while now() < end: + for pane in self.app.panes(): + if pane.get("focused") and pane.get("terminal_id") and pane["terminal_id"] not in before: + # The daemon's id of the terminal: the tab's `terminal` in the app's topology + # (debug.surfaces' terminal_id is the app's own id, not a daemon selector). + self.pane = pane.get("pane") + self.host_pid = int(pane["host_pid"]) if pane.get("host_pid") else None + # The shell drew its first prompt (any text) in the daemon's screen. + last = "empty screen" + while now() < end: + try: + if not self.term: + self.term = self.daemon_terminal(self.sub(step.get("name", self.id)), self.pane) + if self.app.screen(self.term)["text"].strip(): + return {"pane": self.pane, "terminal": self.term, "host_pid": self.host_pid, + "workspace": pane.get("workspace"), "daemon_socket": self.app._daemon_sock, + "action_reply": reply} + except StepError as error: + last = str(error) + time.sleep(0.2) + raise StepError(f"terminal {self.term}: the daemon's screen never showed a prompt: {last[-400:]}") + time.sleep(0.2) + raise StepError(f"newTab showed no new focused terminal pane within {step.get('timeout', 20)} s: {json.dumps(reply)[:200]}") + + def new_terminal_by_key(self, step): + """The person's new terminal: presses NEW_TERMINAL_KEY (the app's own binding, never changed here) and + adopts the new focused terminal as `focused_terminal` does. Right after launch the window may not exist + yet (debug.key answers "no window"); the key is pressed again until it does, as a person presses it once + they see the window. Checks the key router ran newSurface for it (debug.key `last`).""" + held = self.before_keys() + end = now() + float(step.get("timeout", 20)) + press = {"key": step.get("key", NEW_TERMINAL_KEY["key"]), "modifiers": step.get("modifiers", NEW_TERMINAL_KEY["modifiers"])} + tries = 0 + while True: + tries += 1 + self.known_views |= {p.get("terminal_id") for p in self.app.panes() if p.get("terminal_id")} + before = self.app.rpc("debug.key", {"last": True}) or {} + reply = self.key(press["key"], press["modifiers"]) + if not (isinstance(reply, dict) and reply.get("error") == "no window") or now() > end: + break + time.sleep(0.1) + if isinstance(reply, dict) and reply.get("error"): + raise StepError(f"the new terminal key {press} was refused: {reply['error']}") + last = self.app.rpc("debug.key", {"last": True}) or {} + adopted = self.step_focused_terminal({"timeout": max(1.0, end - now())}) + return dict(adopted, via="key", key=press, presses=tries, waited_out_hold_s=held, + handled_by=reply.get("handled_by") if isinstance(reply, dict) else None, + router_last={"action": last.get("action"), "ran": last.get("ran"), + "new": last.get("count") != before.get("count")}) + + def step_script_new_terminal(self, step): + """Automation on purpose: a script's `action.run newTab` (`name`, `focus`, default true) through the + socket, as `cmux workspace new --focus` sends it. Does not wait for the workspace to show (a script's + new workspace shows only when no text input has the person's keyboard): waits for the topology to list + a workspace named `name` with a terminal, makes that terminal current (no pane: the app may not show + it) and waits for its shell's first prompt in the daemon's screen.""" + name = self.sub(step.get("name", self.id)) + reply = self.app.rpc("action.run", {"action": "newTab", "args": {"name": name, "focus": bool(step.get("focus", True))}}) + end = now() + float(step.get("timeout", 20)) + last = "no reply" + while now() < end: + try: + self.term, self.pane, self.host_pid = self.daemon_terminal(name, None), None, None + if self.app.screen(self.term)["text"].strip(): + return {"terminal": self.term, "workspace": name, "action_reply": reply} + except StepError as error: + last = str(error) + time.sleep(0.2) + raise StepError(f"the script's newTab {name!r} listed no terminal with a prompt within {step.get('timeout', 20)} s: {last[-300:]}") + + def daemon_terminal(self, workspace_name, pane_key): + """The daemon terminal id of the selected tab in `pane_key` of the workspace named `workspace_name`.""" + snap = self.app.rpc("snapshot.get") or {} + topo = snap.get("topology") if isinstance(snap, dict) else None + found = [] + for ws in (topo or {}).get("workspaces", []): + if ws.get("name") != workspace_name: + continue + for screen in ws.get("screens", []): + for pane in screen.get("panes", []): + for tab in pane.get("tabs", []): + term = tab.get("terminal") + term = term.get("id") if isinstance(term, dict) else term + if term: + found.append((pane.get("id") == pane_key, bool(tab.get("selected")), str(term))) + if not found: + names = [w.get("name") for w in (topo or {}).get("workspaces", [])] + sample = next((t for w in (topo or {}).get("workspaces", []) if w.get("name") == workspace_name + for s in w.get("screens", []) for p in s.get("panes", []) for t in p.get("tabs", [])), None) + raise StepError(f"the topology has no terminal tab in a workspace named {workspace_name!r} " + f"(workspaces {names[:8]}, a tab {json.dumps(sample)[:300]})") + return sorted(found, reverse=True)[0][2] + + def step_neutral_prompt(self, step): + # Set-up, not under test: written into the PTY by the daemon, as a paste would be. + term = self.need_term() + # The tty first, on its own line; the neutral prompt is written only after the shell ran it. + tty_file = self.tty_probe_path() + if tty_file.exists(): + tty_file.unlink() + self.app.cmux("terminal", term, "write", "--text", TTY_PROBE.format(tty_file=shlex.quote(str(tty_file)))) + tty = self.read_tty(tty_file, float(step.get("tty_timeout", 3))) + self.app.cmux("terminal", term, "write", "--text", NEUTRAL_PROMPT) + end = now() + float(step.get("timeout", 15)) + while now() < end: + if last_line(self.app.screen(self.term)["text"]) == PROMPT.rstrip(): + # A slow shell ran the probe late: it is ahead of the neutral prompt line, so it ran by now. + tty = tty or self.read_tty(tty_file, 0) + if tty_file.exists(): + tty_file.unlink() + if tty: + self.ttys[term] = tty + else: + self.ttys.pop(term, None) + return {"prompt": PROMPT, "tty": tty} + time.sleep(0.1) + raise StepError(f"the neutral prompt never showed; screen ends {last_line(self.app.screen(self.term)['text'])!r}") + + def tty_probe_path(self): + """The next probe file: in the driver's out dir (the capture slot's, removed with the slot; never /tmp), + a name of fixed length (no journey id), so the probe line never wraps the 110-column terminal.""" + self.tty_probes += 1 + return self.out.parent / f".tty-{self.nonce}-{self.tty_probes}" + + @staticmethod + def read_tty(path, timeout): + """The tty the shell wrote (/dev/ttys004 -> ttys004, as ps names it), or None.""" + end = now() + timeout + while True: + try: + text = path.read_text().strip() + except OSError: + text = "" + if text.startswith("/dev/"): + return text[len("/dev/"):] + if now() >= end: + return None + time.sleep(0.05) + + def step_type(self, step): + # The mark is the moment the command is entered (Return), else the first key. + text = self.sub(step["text"]) + held = self.before_keys() + window = self.window_for(step.get("window")) + replies = [] + if step.get("mark") and not step.get("enter"): + self.marks[step["mark"]] = now() + for char in text: + replies.append(self.key(char, [], window)) + time.sleep(0.01) + if step.get("enter"): + if step.get("mark"): + self.marks[step["mark"]] = now() + replies.append(self.key("return", [], window)) + routes = sorted({str(r.get("handled_by")) for r in replies if isinstance(r, dict)}) + # One full reply per route (its trace says who took the key): evidence when typing went astray. + samples = {} + for char, reply in zip(text + ("\r" if step.get("enter") else ""), replies): + if isinstance(reply, dict) and str(reply.get("handled_by")) not in samples: + samples[str(reply.get("handled_by"))] = {"char": char, "reply": reply} + return {"typed": text, "keys": len(replies), "handled_by": routes, "samples": samples, "waited_out_hold_s": held} + + def step_key(self, step): + held = self.before_keys() + # `window`: the window the key is typed in (a name a step gave it, e.g. ext_multiwindow's + # `name_window`, or a window id); else the journey's key window. + window = self.window_for(step.get("window")) + if step.get("mark"): + self.marks[step["mark"]] = now() + # `repeat`: the same key N times, `gap` seconds apart (a person pressing it again and again). + repeat, replies, took = int(step.get("repeat", 1)), [], [] + for index in range(repeat): + if index: + time.sleep(float(step.get("gap", 0.1))) + t = now() + replies.append(self.key(step["key"], step.get("modifiers", []), window)) + took.append(round((now() - t) * 1000, 1)) + if repeat == 1: + return {"key": step["key"], "modifiers": step.get("modifiers", []), "window": window or self.key_window, + "reply": replies[0], "waited_out_hold_s": held} + return {"key": step["key"], "modifiers": step.get("modifiers", []), "presses": repeat, + "reply_ms_max": max(took), "reply_ms": took, "handled_by": sorted({str(r.get("handled_by")) for r in replies if isinstance(r, dict)})} + + def step_outwait_hold(self, step): + """After a key that can show a terminal whose view comes later (a tab switch onto an evicted terminal): + the next key waits until CREATION_HOLD_SECONDS after the last key (see CREATION_HOLD_SECONDS).""" + self.hold_from = getattr(self, "last_key_at", now()) + return {"next_key_waits_from": "the last key"} + + def step_wait(self, step): + time.sleep(float(step.get("seconds", 1))) + return {} + + def step_rpc(self, step): + return {"reply": self.app.rpc(step["method"], self.sub(step.get("params") or {}))} + + # -- scroll, paste, select: the pointer, the clipboard, helper files -- + + def step_mouse(self, step): + """`debug.mouse` over the journey's pane (its center): `action` scroll (default; `dy` > 0 moves + toward older rows, as a wheel turned up does), click, double_click; `count` times, `interval` + seconds apart.""" + params = {k: self.sub(v) for k, v in step.items() if k in ("action", "dx", "dy", "phase", "modifiers", "button")} + params.setdefault("action", "scroll") + params["pane"] = self.need_pane() + # `window`: the window that shows the pane (a named window or an id); debug.mouse's default is the first window. + if step.get("window"): + params["window"] = self.window_for(step["window"]) + if step.get("mark"): + self.marks[step["mark"]] = now() + replies = [] + for index in range(int(step.get("count", 1))): + if index: + time.sleep(float(step.get("interval", 0.05))) + replies.append(self.app.rpc("debug.mouse", params)) + return {"params": params, "count": len(replies), "replies": replies[:2]} + + def step_select_text(self, step): + """Selects TEXT where the pane shows it, as a person does with the pointer: `how` drag (press on + its first cell, drag to its last, release) or double_click (on its middle: a word). The cells come + from the pane's viewport text and grid (debug.surfaces); `occurrence` last (default) or first.""" + text = self.sub(step["text"]) + pane = self.pane_report() + grid, viewport = pane.get("grid"), pane.get("text") or "" + if not isinstance(grid, dict) or not grid.get("cell_width"): + raise StepError(f"pane {self.pane} reports no grid: {json.dumps(grid)[:200]}") + lines = viewport.split("\n") + hits = [i for i, line in enumerate(lines) if text in line] + if not hits: + raise StepError(f"the pane does not show {text!r}; it ends {[l for l in lines if l.strip()][-4:]}") + index = hits[-1] if step.get("occurrence", "last") == "last" else hits[0] + # The viewport text is logical lines; the grid wraps each at its column count. + cw, ch = float(grid["cell_width"]), float(grid["cell_height"]) + cols = max(1, int((grid["frame_width"] - 2 * (grid["origin_x"] - grid["frame_x"])) // cw)) + col = lines[index].index(text) + row = sum(max(1, -(-len(line) // cols)) for line in lines[:index]) + col // cols + col %= cols + y = grid["origin_y"] + (row + 0.5) * ch + first_x = grid["origin_x"] + (col + 0.25) * cw + last_x = grid["origin_x"] + (col + len(text) - 0.25) * cw + if step.get("mark"): + self.marks[step["mark"]] = now() + if step.get("how", "drag") == "double_click": + params = {"window": grid["window"], "action": "double_click", "x": (first_x + last_x) / 2, "y": y} + else: + params = {"window": grid["window"], "action": "drag", "x": first_x, "y": y, "to_x": last_x, "to_y": y, + "steps": int(step.get("steps", 12))} + return {"text": text, "row": row, "col": col, "cols": cols, "params": params, "reply": self.app.rpc("debug.mouse", params)} + + def step_clipboard_set(self, step): + """Set-up: puts `text` (or `lines`: {"format": "L%05d-${nonce}", "count": N}, joined by LF) on the + Mac's general pasteboard, the one Cmd+V reads, and reads it back; binds ${clip_sha} and ${clip_lines}.""" + if "lines" in step: + spec = self.sub(step["lines"]) + fmt, start = spec["format"], int(spec.get("start", 1)) + text = "\n".join(fmt % n for n in range(start, start + int(spec["count"]))) + else: + text = self.sub(step["text"]) + data = text.encode() + env = {"LANG": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8", "PATH": "/usr/bin:/bin"} + subprocess.run(["/usr/bin/pbcopy"], input=data, env=env, check=True, timeout=30) + back = subprocess.run(["/usr/bin/pbpaste"], env=env, capture_output=True, timeout=30).stdout + if back != data: + raise StepError(f"the pasteboard did not take the text ({len(data)} bytes set, {len(back)} read back)") + self.vars["clip_sha"] = hashlib.sha256(data).hexdigest() + self.vars["clip_lines"] = str(text.count("\n") + 1) + return {"bytes": len(data), "lines": text.count("\n") + 1, "sha256": self.vars["clip_sha"], "head": text[:60]} + + def step_file(self, step): + """Set-up: writes a helper file the journey runs (a program under test's stand-in), only at + /tmp/jr-*; removed when the journey ends. `text`, or `lines` (joined by LF). The content is not + substituted: a helper takes the run's values as arguments.""" + path = self.sub(step["path"]) + if not re.fullmatch(r"/tmp/jr-[A-Za-z0-9._-]+", path): + raise StepError(f"a helper file goes to /tmp/jr-*, not {path!r}") + text = "\n".join(step["lines"]) + "\n" if "lines" in step else step["text"] + Path(path).write_text(text) + self.files.append(path) + return {"path": path, "bytes": len(text.encode())} + + def need_pane(self): + if not self.pane: + raise StepError("no pane: the journey needs a new_terminal step first") + return self.pane + + def pane_report(self): + pane = next((p for p in self.app.panes() if p.get("pane") == self.need_pane()), None) + if pane is None: + raise StepError(f"pane {self.pane} is not in debug.surfaces") + return pane + + def key(self, key, modifiers, window=None): + self.last_key_at = now() + params = {"key": key, "modifiers": modifiers} + if window or self.key_window: + params["window"] = window or self.key_window + return self.app.rpc("debug.key", params) + + def window_for(self, ref): + """A step's `window`: a name a step gave a window (`named_windows`, ext_multiwindow's `name_window`), + else a window id as is; None when the step names none.""" + if not ref: + return None + ref = self.sub(ref) + return getattr(self, "named_windows", {}).get(ref, ref) + + def before_keys(self): + """Before a step's first key: waits out a creation hold a step before may have opened; seconds waited.""" + since, self.hold_from = getattr(self, "hold_from", None), None + return None if since is None else self.outwait_creation_hold(since) + + def outwait_creation_hold(self, since): + """Sleeps until CREATION_HOLD_SECONDS after `since` (see CREATION_HOLD_SECONDS); returns the seconds slept.""" + left = since + CREATION_HOLD_SECONDS - now() + if left > 0: + time.sleep(left) + return round(max(0.0, left), 2) + + def need_term(self): + if not self.term: + raise StepError("no terminal: the journey needs a new_terminal step first") + return self.term + + # -- conditions for scroll, paste, select (README "Conditions") -- + + def cached_pane(self, cache): + if "pane" not in cache: + cache["pane"] = next((p for p in self.app.panes() if p.get("pane") == self.pane), None) + if cache["pane"] is None: + raise StepError(f"pane {self.pane} is not in debug.surfaces") + return cache["pane"] + + def history(self, which, count, cache): + """The daemon's scrollback rows (`terminal ID history read`): the `count` oldest or newest, as + text, and the number of rows it holds.""" + key = ("history", which, count) + if key not in cache: + args = ["terminal", self.need_term(), "history", "read", "--limit", str(count)] + if which == "oldest": + args += ["--before", str(count)] + answer = self.app.cmux(*args) + for _ in range(3): + if isinstance(answer, dict) and "rows" not in answer: + answer = next((answer[k] for k in ("value", "result", "data") if isinstance(answer.get(k), dict)), answer) + if not isinstance(answer, dict) or not isinstance(answer.get("rows"), list): + raise StepError(f"terminal {self.term} history read gave no rows: {json.dumps(answer)[:200]}") + texts = ["".join(str(run.get("text", "")) for run in row.get("runs", []) if isinstance(run, dict)) + for row in answer["rows"] if isinstance(row, dict)] + start = int(answer.get("start") or 0) + total = start + len(texts) if which == "newest" else None + cache[key] = (texts, total) + return cache[key] + + @staticmethod + def numbered(lines, pattern): + """(number, line) of each line `pattern` (one group: the number) finds, in order.""" + found = [] + for line in lines: + match = re.search(pattern, line) + if match: + found.append((int(match.group(1)), line.strip())) + return found + + @staticmethod + def gaps(numbers): + """Where `numbers` is not each one after the last: [(index, previous, next)].""" + return [(i, a, b) for i, (a, b) in enumerate(zip(numbers, numbers[1:]), 1) if b != a + 1][:5] + + def cond_app_rows(self, want, cache): + """The numbered rows the pane's viewport shows (what the person sees, after any scroll): + `pattern` (one group: the row's number), `min_rows` (default 3), `contiguous` (default true: + each row the one after the last, nothing dropped, repeated or out of order), `top` / `last`: + {eq|ne|gt|lt|ge|le: N, a NAME an earlier app_rows bound, or "daemon_oldest" (the first numbered + row the daemon's scrollback still holds)}; `bind` NAME keeps the top and last numbers and sets + ${NAME_top} and ${NAME_mid} to the top and middle rows' text.""" + pane = self.cached_pane(cache) + lines = lines_of(pane.get("text") or "") + rows = self.numbered(lines, want["pattern"]) + numbers = [n for n, _ in rows] + detail = {"rows": len(rows), "top": numbers[0] if numbers else None, "last": numbers[-1] if numbers else None, + "viewport_lines": len(lines), "head": [l for l in lines if l.strip()][:2], + "tail": [l for l in lines if l.strip()][-2:]} + if len(rows) < int(want.get("min_rows", 3)): + return False, dict(detail, why="too few numbered rows in the viewport") + if want.get("contiguous", True) and self.gaps(numbers): + return False, dict(detail, why="rows out of order or missing", gaps=self.gaps(numbers)) + ops = {"eq": lambda a, b: a == b, "ne": lambda a, b: a != b, "gt": lambda a, b: a > b, + "lt": lambda a, b: a < b, "ge": lambda a, b: a >= b, "le": lambda a, b: a <= b} + for field in ("top", "last"): + for op, ref in (want.get(field) or {}).items(): + if ref == "daemon_oldest": + oldest = self.numbered(self.history("oldest", 200, cache)[0], want["pattern"]) + if not oldest: + return False, dict(detail, why="the daemon's oldest 200 scrollback rows hold no numbered row") + value = oldest[0][0] + elif isinstance(ref, str): + if ref not in self.binds: + return False, dict(detail, why=f"no rows bound as {ref!r}") + value = self.binds[ref][field] + else: + value = int(ref) + detail[f"{field}_{op}"] = value + if not ops[op](detail[field], value): + return False, dict(detail, why=f"{field} {detail[field]} is not {op} {value}") + if want.get("bind"): + self.binds[want["bind"]] = {"top": numbers[0], "last": numbers[-1]} + self.vars[want["bind"] + "_top"] = rows[0][1] + self.vars[want["bind"] + "_mid"] = rows[len(rows) // 2][1] + return True, detail + + def cond_daemon_history(self, want, cache): + """The daemon's scrollback (`history read`): `rows` oldest|newest, `count` (default 200, at most + 10000) rows; `contains` / `lacks` TEXT in any of them; `pattern` (one group: a row's number) with + `contiguous` (default true) and `last` N (the last numbered row's number).""" + which, count = want.get("rows", "newest"), int(want.get("count", 200)) + texts, total = self.history(which, count, cache) + detail = {"rows_read": len(texts), "history_rows": total, "head": texts[:2], "tail": texts[-2:]} + if "contains" in want and not any(want["contains"] in t for t in texts): + return False, dict(detail, why=f"no row has {want['contains']!r}") + if "lacks" in want: + hits = [t for t in texts if want["lacks"] in t] + if hits: + return False, dict(detail, why=f"{len(hits)} rows have {want['lacks']!r}", hit=hits[0]) + if "pattern" in want: + numbers = [n for n, _ in self.numbered(texts, want["pattern"])] + detail.update(numbered=len(numbers), first=numbers[0] if numbers else None, last=numbers[-1] if numbers else None) + if not numbers: + return False, dict(detail, why="no numbered rows") + if want.get("contiguous", True) and self.gaps(numbers): + return False, dict(detail, why="rows out of order or missing", gaps=self.gaps(numbers)) + if "last" in want and numbers[-1] != int(want["last"]): + return False, dict(detail, why=f"the last numbered row is {numbers[-1]}, not {want['last']}") + return True, detail + + def cond_frame_screen(self, want, cache): + """One fresh frame (the app's own drawing of the window) that shows every text of `shows` and + none of `lacks` to text recognition: what is gone is checked on a frame that is not blank.""" + self.checks += 1 + image = self.app.snapshot(self.out / f"check-{self.checks:02d}.png") + lines = self.app.read_text(image) + missing = [t for t in want.get("shows", []) if not ocr_contains(lines, t)] + present = [t for t in want.get("lacks", []) if ocr_contains(lines, t)] + detail = {"image": image.name, "text": lines[-12:], "lines": len(lines), "missing": missing, "present": present} + return bool(want.get("shows")) and not missing and not present, detail + + def cond_quit_leftovers(self, want, cache): + """The processes a `relaunch` with `leftovers_after` found after the quit: each must match one of + `allow` ({"match": RE on the command, "why": the reason it may stay}); the rest are offenders.""" + kept = getattr(self, "leftovers", None) + if kept is None: + return False, {"error": "no relaunch step with leftovers_after ran before this check"} + allow = want.get("allow", []) + offenders = [r for r in kept["rows"] if not any(re.search(a["match"], r["command"]) for a in allow)] + allowed = [dict(r, why=next(a.get("why") for a in allow if re.search(a["match"], r["command"]))) + for r in kept["rows"] if r not in offenders] + rss = sum(r["rss_kb"] or 0 for r in offenders) + return not offenders, {"after_s": kept["after_s"], "offenders": offenders, "offender_rss_mb": round(rss / 1024, 1), + "allowed": allowed[:6], "allowed_count": len(allowed)} + + def cond_clipboard(self, want, cache): + """The Mac's general pasteboard (what Cmd+V pastes, `pbpaste`) is exactly `want`.""" + env = {"LANG": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8", "PATH": "/usr/bin:/bin"} + got = subprocess.run(["/usr/bin/pbpaste"], env=env, capture_output=True, timeout=30).stdout.decode(errors="replace") + return got == want, {"got": got[:200], "bytes": len(got.encode())} + # -- app life steps (journeys/p0/app-life-*.json) -- + + def topology(self): + snap = self.app.rpc("snapshot.get") or {} + topo = snap.get("topology") if isinstance(snap, dict) else None + if not isinstance(topo, dict): + raise StepError(f"snapshot.get has no topology: {json.dumps(snap)[:200]}") + return topo + + @staticmethod + def places(topo): + """(workspace, pane, tab, daemon terminal id) of every tab in the topology.""" + for ws in topo.get("workspaces", []): + for screen in ws.get("screens", []): + for pane in screen.get("panes", []): + for tab in pane.get("tabs", []): + term = tab.get("terminal") + term = term.get("id") if isinstance(term, dict) else term + yield ws, pane, tab, (str(term) if term else None) + + def place_of(self, term, topo=None): + """(workspace, pane, tab) that holds daemon terminal `term`, or None.""" + for ws, pane, tab, t in self.places(topo or self.topology()): + if t == term: + return ws, pane, tab + return None + + def view_of_pane(self, pane_id): + return next((p for p in self.app.panes() if p.get("pane") == pane_id), None) + + def adopt(self, term, pane_id, host_pid=None): + """Makes daemon terminal `term` in pane `pane_id` the journey's current terminal.""" + self.term, self.pane = term, pane_id + view = self.view_of_pane(pane_id) if host_pid is None else None + if view and view.get("host_pid"): + host_pid = int(view["host_pid"]) + if host_pid: + self.host_pid = host_pid + return {"terminal": self.term, "pane": self.pane, "host_pid": self.host_pid} + + def wait_screen(self, end): + """Until the daemon's screen of the current terminal shows any text (the shell's first prompt).""" + last = "empty screen" + while now() < end: + try: + if self.app.screen(self.term)["text"].strip(): + return + except StepError as error: + last = str(error) + time.sleep(0.2) + raise StepError(f"terminal {self.term}: the daemon's screen never showed a prompt: {last[-300:]}") + + def step_focused_terminal(self, step): + """After a key that makes a terminal (Ctrl-` New Terminal Tab, Cmd-D split, Home's Open a terminal): + waits for a focused terminal pane showing a terminal the journey has not seen, adopts it (`as` names + it) and waits for its shell's first prompt in the daemon's screen.""" + end = now() + float(step.get("timeout", 20)) + last = None + while now() < end: + for view in self.app.panes(): + if not (view.get("focused") and view.get("kind", "terminal") == "terminal" and view.get("terminal_id")): + continue + if view["terminal_id"] in self.known_views: + last = f"the focused pane {view.get('pane')} still shows a terminal seen before" + continue + place = None + topo = self.topology() + for ws, pane, tab, term in self.places(topo): + if pane.get("id") == view.get("pane") and tab.get("id") == pane.get("selected_tab") and term: + place = (ws, pane, term) + if not place: + last = f"pane {view.get('pane')} has no selected terminal tab in the topology yet" + continue + self.known_views.add(view["terminal_id"]) + adopted = self.adopt(place[2], view.get("pane"), int(view["host_pid"]) if view.get("host_pid") else None) + self.wait_screen(end) + # The next key waits out the creation hold (CREATION_HOLD_SECONDS); checks need not. + self.hold_from = getattr(self, "last_key_at", now()) + return dict(adopted, workspace=place[0].get("name")) + time.sleep(0.2) + raise StepError(f"no new focused terminal pane within {step.get('timeout', 20)} s ({last or 'no focused terminal pane'})") + + def step_fresh_app(self, step): + """First launch: the app must be the one capture-host launched from empty state, with no journey + played in it before (run.py puts a `first_in_app` journey first in its lease).""" + if PLAYED: + raise NotRun(f"first launch needs a fresh app; this app already played {', '.join(PLAYED)}") + return {"pid": self.app.pid, "played_before": []} + + def step_use(self, step): + """Makes the terminal named `name` current again; its pane is read from the topology now (a moved + tab or a relaunch can change it).""" + name = step["name"] + if name not in self.aliases: + raise StepError(f"use: no terminal named {name!r} (name one with `as`)") + term = self.aliases[name]["term"] + place = self.place_of(term) + if not place: + raise StepError(f"use {name}: terminal {term} is in no tab of the topology") + self.host_pid = self.aliases[name].get("host_pid") + return self.adopt(term, place[1].get("id")) + + def step_remember_value(self, step): + """Keeps a measured value as ${name} for later conditions: `daemon_cols`, `daemon_rows` (the + current terminal's grid in the daemon) or `app_cell_height` (the pane's cell height in points).""" + source = step["source"] + if source in ("daemon_cols", "daemon_rows"): + row = self.daemon_terminal_row(self.need_term()) + value = row.get("cols" if source == "daemon_cols" else "rows") + elif source == "app_cell_height": + view = self.view_of_pane(self.pane) or {} + value = (view.get("grid") or {}).get("cell_height") + else: + raise StepError(f"remember_value: unknown source {source!r}") + if value is None: + raise StepError(f"remember_value {source}: no value") + self.vars[step["name"]] = value + return {step["name"]: value} + + def config_file(self): + env = self.app.launch_env() + return env.get("CMUX_NEXT_CONFIG_FILE") or os.path.join(self.app.home, ".config", "cmux", "cmux-next.json") + + def step_settings_file(self, step): + """Edits the app's settings file (cmux-next.json, the slot's own) as a person does in an editor: + `set` merges nested keys, `unset` removes dotted keys. Nothing else touches the app.""" + path = Path(self.config_file()) + try: + data = json.loads(path.read_text()) if path.is_file() and path.read_text().strip() else {} + except ValueError as error: + raise StepError(f"settings file {path} is not JSON: {error}") + + def merge(into, more): + for k, v in more.items(): + if isinstance(v, dict) and isinstance(into.get(k), dict): + merge(into[k], v) + else: + into[k] = v + merge(data, self.sub(step.get("set") or {})) + for dotted in step.get("unset") or []: + node, parts = data, dotted.split(".") + for part in parts[:-1]: + node = node.get(part) if isinstance(node, dict) else None + if isinstance(node, dict): + node.pop(parts[-1], None) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".journey") + tmp.write_text(json.dumps(data, indent=2) + "\n") + os.replace(tmp, path) + if step.get("mark"): + self.marks[step["mark"]] = now() + return {"file": str(path), "now": data} + + def step_relaunch(self, step): + """Ends the app and opens it again, as a person does: `how` "quit" presses Cmd-Q (answering a quit + sheet with its default, as Return does) and "crash" crashes it (`debug.crash.app`, SIGSEGV in the + app's own process, so none of its quit path runs). The same bundle opens again through LaunchServices + with its launch variables (the slot's HOME, socket and settings file). Then every named terminal is + found again in the restored topology; the journey's checks say what came back.""" + how = step.get("how", "quit") + app, old = self.app, self.app.pid + env = app.launch_env() + record = {"how": how, "old_pid": old} + started = now() + if how == "quit": + record["waited_out_hold_s"] = self.before_keys() + try: + record["key"] = self.key("q", ["cmd"]) + except (StepError, OSError, ValueError) as error: + record["key_error"] = str(error)[-200:] + end = now() + QUIT_SECONDS + while now() < end and app.alive(old): + try: + report = app.rpc("debug.quit", timeout=5) or {} + except (StepError, OSError, ValueError): + report = {} + if isinstance(report, dict) and report.get("asking"): + default = str((report.get("prompt") or {}).get("default")) + record["sheet"] = {"default": default, "lines": report.get("lines"), + "buttons": [b.get("id") for b in report.get("buttons") or []]} + app.rpc("debug.quit", {"press": default}, timeout=10) + time.sleep(0.25) + elif how == "crash": + try: + record["reply"] = app.rpc("debug.crash.app", {"signal": "segv"}, timeout=5) + except (StepError, OSError, ValueError) as error: + record["reply"] = f"no answer (expected): {str(error)[-120:]}" + end = now() + QUIT_SECONDS + else: + raise StepError(f"relaunch: how is quit or crash, not {how!r}") + while now() < end and app.alive(old): + time.sleep(0.25) + if app.alive(old): + raise StepError(f"relaunch ({how}): app pid {old} still runs {QUIT_SECONDS} s later: {json.dumps(record)[:300]}") + record["ended_after_s"] = round(now() - started, 2) + if step.get("leftovers_after") is not None: + # What a person's Mac still runs after the quit: every process run from this bundle (its + # path) or owned by it (`--owner BUNDLE_ID`, the terminal hosts), `leftovers_after` seconds on. + time.sleep(float(step["leftovers_after"])) + self.leftovers = {"after_s": float(step["leftovers_after"]), "rows": app.bundle_processes(old)} + record["leftovers"] = self.leftovers + if step.get("mark"): + self.marks[step["mark"]] = now() + # In the background (-g), as capture-host's own launch, unless that launch was --activate (a whole-host + # lease): then the reopened app comes to the front and becomes the active app again, as a person sees it. + argv = ["/usr/bin/open", *([] if ACTIVATE else ["-g"]), "-n", "-a", app.bundle] + for key, value in env.items(): + argv += ["--env", f"{key}={value}"] + opened = subprocess.run(argv, capture_output=True, text=True, timeout=60) + if opened.returncode != 0: + raise StepError(f"relaunch: open exited {opened.returncode}: {(opened.stderr or opened.stdout)[-300:]}") + t, end = now(), now() + RELAUNCH_SECONDS + while now() < end: + try: + info = app.rpc("system.identify", timeout=3) + if isinstance(info, dict) and info.get("pid") and int(info["pid"]) != old: + break + except (StepError, OSError, ValueError): + pass + time.sleep(0.25) + else: + raise StepError(f"relaunch: the app never answered on {app.sock} within {RELAUNCH_SECONDS} s") + app.reidentify(info) + record.update(new_pid=app.pid, answered_after_s=round(now() - t, 2)) + # Restore has loaded once the topology says so; then each named terminal is looked up again. + loaded = False + while now() < end and not loaded: + try: + loaded = bool(self.topology().get("loaded")) + except (StepError, OSError, ValueError): + pass + if not loaded: + time.sleep(0.25) + record["topology_loaded"] = loaded + topo = self.topology() if loaded else None + found = {} + for name, alias in self.aliases.items(): + place = self.place_of(alias["term"], topo) if topo else None + found[name] = place[1].get("id") if place else None + if place: + alias["pane"] = place[1].get("id") + record["named_terminals"] = found + # Restore settles when every shown terminal view is attached live (restore-journey.py's rule); then the + # creation hold a restored selection opens is waited out (CREATION_HOLD_SECONDS). + settled = False + while now() < end and not settled: + try: + shown = [p for p in app.panes() if p.get("kind") == "terminal" and p.get("presence") == "visible"] + settled = bool(shown) and all(p.get("attach") == "live" for p in shown) + except (StepError, OSError, ValueError): + pass + if not settled: + time.sleep(0.25) + record["settled_after_s"] = round(now() - t, 2) if settled else None + self.hold_from = now() + if self.term: + place = self.place_of(self.term, topo) if topo else None + if place: + self.pane = place[1].get("id") + self.known_views = {p.get("terminal_id") for p in app.panes() if p.get("terminal_id")} + return record + + def daemon_terminal_row(self, term): + """The daemon's record of terminal `term` (`cmux terminal list`: cols, rows, cwd, lifecycle).""" + rows = self.app.cmux("terminal", "list") + if isinstance(rows, dict): + rows = next((rows[k] for k in ("terminals", "value", "result") if isinstance(rows.get(k), list)), []) + row = next((r for r in rows or [] if isinstance(r, dict) and r.get("id") == term), None) + if row is None: + raise StepError(f"terminal {term} is not in the daemon's terminal list ({len(rows or [])} terminals)") + return row + + # -- conditions -- + + def step_expect(self, step): + conds = [self.sub(c) for c in step["all"]] + since = step.get("since") + if since and since not in self.marks: + raise StepError(f"expect since {since!r}: no step set that mark") + t0 = self.marks[since] if since else now() + within = step.get("within") + timeout_at = t0 + float(step.get("timeout", 10)) + state = [{"cond": c, "name": next(iter(c)), "ok": False, "at": None, "detail": None} for c in conds] + for s in state: + s["negative"] = negative(s["name"], s["cond"][s["name"]]) + live = [s for s in state if KIND.get(s["name"]) != "frame"] + frames = [s for s in state if KIND.get(s["name"]) == "frame"] + ticks = 0 + while True: + ticks += 1 + cache = {} + for s in live: + # A positive condition holds once it has held; a negative one (something must NOT + # show) is read again every poll, so it holds only together with the positive ones. + if not s["ok"] or s["negative"]: + ok, detail = self.evaluate(s["name"], s["cond"][s["name"]], cache) + s["detail"] = detail + if ok and not s["ok"]: + s["at"] = now() + s["ok"] = ok + if all(s["ok"] for s in live) or now() >= timeout_at: + break + time.sleep(POLL_SECONDS) + # `settle` (seconds): the negative conditions must keep holding that much longer, so what the + # step must not cause cannot show up late (a scrolled view that jumps, a paste that runs). + settle_until = now() + float(step.get("settle", 0)) if all(s["ok"] for s in live) else now() + while now() < settle_until: + time.sleep(POLL_SECONDS * 4) + cache = {} + for s in live: + if s["negative"] and s["ok"]: + ok, detail = self.evaluate(s["name"], s["cond"][s["name"]], cache) + if not ok: + s["ok"] = False + s["detail"] = dict(detail, broke_during_settle=True) if isinstance(detail, dict) else detail + # A frame is read after the state it should show holds: the window draws it on its next frame, + # so it gets FRAME_GRACE more seconds of fresh snapshots (text recognition time not counted + # against the step's timeout), and at least two tries. + frame_until = max(timeout_at, now() + FRAME_GRACE) + for s in frames: + tries = 0 + while not s["ok"]: + time.sleep(0.25) + tries += 1 + ok, detail = self.evaluate(s["name"], s["cond"][s["name"]], {}) + s["detail"] = dict(detail, tries=tries) if isinstance(detail, dict) else detail + if ok: + s["ok"], s["at"] = True, now() + elif now() >= frame_until and tries >= 2: + break + failed = [] + for s in state: + elapsed = None if s["at"] is None else round((s["at"] - t0) * 1000) + limit = None if within is None or KIND.get(s["name"]) == "frame" else round(float(within) * 1000) + passed = s["ok"] and (limit is None or elapsed <= limit) + row = {"kind": KIND.get(s["name"], "other"), "check": s["name"], "want": s["cond"][s["name"]], + "pass": passed, "elapsed_ms": elapsed, "within_ms": limit, "detail": s["detail"], + "step": step.get("title", "")} + self.evidence.append(row) + if not passed: + failed.append(f"{s['name']} {json.dumps(s['cond'][s['name']])}" + + (f" took {elapsed} ms > {limit} ms" if s["ok"] + else " did not hold at the end" if s["negative"] else " never held")) + result = {"polls": ticks, "since": since, "conditions": len(state)} + if failed: + raise StepError("; ".join(failed)) + return result + + def evaluate(self, name, want, cache): + """(holds, detail) of one condition; `cache` keeps one read of each source per poll.""" + try: + if name in EXT_CONDITIONS: + return EXT_CONDITIONS[name](self, want, cache) + special = getattr(self, "cond_" + name, None) + if special is not None: + return special(want, cache) + if name in LIFE_CONDITIONS: + return self.evaluate_life(name, want, cache) + if name.startswith("daemon_"): + if "screen" not in cache: + cache["screen"] = self.app.screen(self.need_term()) + text = cache["screen"]["text"] + tail = [l for l in lines_of(text) if l.strip()][-4:] + if name == "daemon_line": + return any(l.strip() == want.strip() for l in lines_of(text)), {"tail": tail} + if name == "daemon_contains": + return want in text, {"tail": tail} + if name == "daemon_lacks": + return want not in text, {"tail": tail} + if name == "daemon_prompt": + return last_line(text) == PROMPT.rstrip(), {"last_line": last_line(text)} + if name.startswith("app_"): + if "pane" not in cache: + cache["pane"] = next((p for p in self.app.panes() if p.get("pane") == self.pane), None) + pane = cache["pane"] + if pane is None: + return False, {"error": f"pane {self.pane} is not in debug.surfaces"} + text = pane.get("text") or "" + tail = [l for l in lines_of(text) if l.strip()][-4:] + if name == "app_text_line": + return any(l.strip() == want.strip() for l in lines_of(text)), {"tail": tail, "link": pane.get("link")} + if name == "app_text_contains": + return want in text, {"tail": tail} + if name == "app_text_lacks": + return want not in text, {"tail": tail} + if name == "app_prompt": + return last_line(text) == PROMPT.rstrip(), {"last_line": last_line(text), "link": pane.get("link")} + if name == "app_pane": + got = {k: pane.get(k) for k in want} + return got == want, {"got": got} + if name.startswith("process_"): + if "ps" not in cache: + cache["ps"] = self.app.processes() + table = cache["ps"] + if name == "process_present": + scope = descendants(table, self.app.roots(table, self.host_pid)) + hits = [table[p] for p in sorted(scope) if re.search(want["match"], table[p]["command"])] + tty = self.ttys.get(self.term) if self.term else None + detail = {"matches": hits[:3], "count": len(hits), "tty": tty, "scope_root": self.host_pid or "bundle"} + if want.get("foreground"): + # A controlling tty, and the process in its foreground group (ps `+`): Ctrl+C can reach it. + good = [h for h in hits if h["tty"] not in ("??", "-", "") and "+" in h["stat"]] + # This terminal's tty: the host also runs the other terminals' shells and jobs. + if tty: + good = [h for h in good if h["tty"] == tty] + elif want.get("bind") and len({h["tty"] for h in good}) > 1: + # Which one is this terminal's is unknown: a guess would bind another terminal's. + detail["error"] = f"matches on ttys {sorted({h['tty'] for h in good})}; this terminal's tty is unknown" + return False, detail + else: + good = hits + if good and want.get("bind"): + self.binds[want["bind"]] = good[0] + detail["bound"] = good[0] + return bool(good), detail + if name == "process_gone": + bound = self.binds.get(want) + if not bound: + return False, {"error": f"no process bound as {want!r}"} + row = table.get(bound["pid"]) + alive = row is not None and not row["stat"].startswith("Z") and row["command"] == bound["command"] + return not alive, {"pid": bound["pid"], "tty": bound["tty"], "now": row} + if name == "frame_text": + self.checks += 1 + image = self.app.snapshot(self.out / f"check-{self.checks:02d}.png") + lines = self.app.read_text(image) + return ocr_contains(lines, want), {"image": image.name, "text": lines[-12:], "lines": len(lines)} + except (StepError, OSError, ValueError, subprocess.TimeoutExpired) as error: + return False, {"error": str(error)[-300:]} + return False, {"error": f"unknown condition {name}"} + + def evaluate_life(self, name, want, cache): + """The app-life conditions: layout and working directories from the app's topology and the + daemon's terminal list, app reports over the control socket, grids in both.""" + def topo(): + if "topology" not in cache: + cache["topology"] = self.topology() + return cache["topology"] + + def term_of(spec, key): + """The terminal a condition names (`name`: an alias), else the current one; and its wanted value.""" + if isinstance(spec, dict): + alias = spec.get("name") + if alias and alias not in self.aliases: + raise StepError(f"no terminal named {alias!r}") + return (self.aliases[alias]["term"] if alias else self.need_term()), spec.get(key) + return self.need_term(), spec + + def compare(got, rules): + """`rules`: {"equals"|"not_equals"|"in"|"not_in"|"contains"|"lacks"|"gte"|"lte"|"gt"|"lt"|"len"|"len_gte": value}; + all must hold. `not_equals`, `not_in`: a missing value (None) does not hold either, so a wrong + path cannot pass a negative rule.""" + for rule, value in rules.items(): + if rule == "not_equals" and (got is None or got == value): + return False + if rule == "in" and got not in value: + return False + if rule == "not_in" and (got is None or got in value): + return False + if rule == "equals" and got != value: + # A remembered ${number} arrives as text: numbers compare as numbers. + try: + if isinstance(got, bool) or float(got) != float(value): + return False + except (TypeError, ValueError): + return False + if rule == "contains" and (got is None or str(value) not in (got if isinstance(got, (list, str)) else json.dumps(got))): + return False + # `lacks`: the value (as JSON text when not a string) does not contain the text; a missing value fails. + if rule == "lacks" and (got is None or str(value) in (got if isinstance(got, str) else json.dumps(got))): + return False + if rule in ("gte", "lte", "gt", "lt"): + try: + g, v = float(got), float(value) + except (TypeError, ValueError): + return False + if not {"gte": g >= v, "lte": g <= v, "gt": g > v, "lt": g < v}[rule]: + return False + if rule in ("len", "len_gte"): + if not isinstance(got, (list, dict, str)): + return False + if (rule == "len" and len(got) != int(value)) or (rule == "len_gte" and len(got) < int(value)): + return False + return True + + if name == "daemon_cwd": + term, path = term_of(want, "cwd") + row = self.daemon_terminal_row(term) + return same_dir(row.get("cwd"), path), {"cwd": row.get("cwd"), "terminal": term} + if name == "daemon_grid": + row = self.daemon_terminal_row(self.need_term()) + got = {"cols": row.get("cols"), "rows": row.get("rows")} + ok = all(compare(got.get(rule.split("_")[0]), {rule.split("_", 1)[1] if "_" in rule else "equals": value}) + for rule, value in want.items()) + return ok, got + if name == "daemon_pane_ready": + place = self.place_of(self.need_term(), topo()) + if not place: + return False, {"error": f"terminal {self.term} is in no tab"} + terms = [t for _, pane, _, t in self.places(topo()) if pane.get("id") == place[1].get("id") and t] + empty = [t for t in terms if not self.app.screen(t)["text"].strip()] + return len(terms) >= int(want.get("tabs", 1)) and not empty, {"terminals": len(terms), "empty": empty[:5]} + if name == "app_cwd": + term, path = term_of(want, "cwd") + place = self.place_of(term, topo()) + got = place[2].get("cwd") if place else None + return place is not None and same_dir(got, path), {"cwd": got, "terminal": term} + if name == "app_layout": + t = topo() + place = self.place_of(self.need_term(), t) + if not place: + return False, {"error": f"terminal {self.term} is in no tab of the topology"} + ws, pane, _ = place + panes = [p for s in ws.get("screens", []) for p in s.get("panes", [])] + got = {"windows": len(t.get("windows", [])), "workspace": ws.get("name"), "panes": len(panes), + "tabs": sum(len(p.get("tabs", [])) for p in panes), "pane_tabs": len(pane.get("tabs", [])), + "selected": pane.get("selected_tab") == place[2].get("id")} + ok = all(got.get(k) == v for k, v in want.items() if k != "bind_workspace") + if ok and want.get("bind_workspace"): + # `bind_workspace`: the workspace's name as ${NAME} (an automatic name, checked after a quit). + self.vars[want["bind_workspace"]] = got["workspace"] + return ok, got + if name == "app_tab_pane": + place = self.place_of(self.need_term(), topo()) + other_name = want.get("same_as") or want.get("not_same_as") + if other_name not in self.aliases: + return False, {"error": f"no terminal named {other_name!r}"} + other = self.place_of(self.aliases[other_name]["term"], topo()) + if not place or not other: + return False, {"error": "a terminal is in no tab", "here": bool(place), "other": bool(other)} + same = place[1].get("id") == other[1].get("id") + return (same if want.get("same_as") else not same), {"pane": place[1].get("id"), "other_pane": other[1].get("id")} + if name == "app_tab_gone": + if want not in self.aliases: + return False, {"error": f"no terminal named {want!r}"} + place = self.place_of(self.aliases[want]["term"], topo()) + return place is None, {"tab": place[2].get("id") if place else None} + if name == "app_rpc": + reply = self.app.rpc(want["method"], want.get("params") or {}) + got = dig(reply, want.get("path", "")) + rules = {k: v for k, v in want.items() if k not in ("method", "params", "path", "bind")} + ok = compare(got, rules) + if ok and want.get("bind"): + # `bind`: the value as ${NAME} for later steps (a pane id read before a quit, checked after it). + self.vars[want["bind"]] = got + return ok, {"got": got if len(json.dumps(got, default=str)) < 600 else json.dumps(got, default=str)[:600]} + if name == "app_grid": + view = self.view_of_pane(self.pane) or {} + grid = view.get("grid") or {} + got = {"cell_height": grid.get("cell_height"), "cell_width": grid.get("cell_width"), "grid": (view.get("surface") or {}).get("grid")} + ok = all(compare(got.get(rule.rsplit("_", 1)[0]), {rule.rsplit("_", 1)[1]: value}) for rule, value in want.items()) + return ok, got + return False, {"error": f"unknown condition {name}"} + + # -- the run -- + + def run(self): + started, wall = now(), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + frames = Frames(self.app, self.out / "frames", self.frame_interval) + frames.start() + status, reason = "pass", "" + try: + for index, step in enumerate(self.spec["steps"]): + if now() > self.deadline: + raise Deadline(f"the lease deadline came before step {index + 1} ({step['do']})") + record = {"index": index + 1, "do": step["do"], "title": step.get("title", "")} + handler = getattr(self, "step_" + step["do"], None) + if handler is None: + raise StepError(f"unknown step {step['do']!r}") + if step["do"] in ("key", "type", "rpc", "new_terminal"): + # The terminals on screen before an input: `focused_terminal` waits for one not among them. + self.known_views |= {p.get("terminal_id") for p in self.app.panes() if p.get("terminal_id")} + t = now() + try: + record["result"] = handler(step) + record["ok"] = True + if step.get("as"): + # Names the current terminal for later `use` steps and conditions. + self.aliases[step["as"]] = {"term": self.need_term(), "pane": self.pane, "host_pid": self.host_pid} + view = self.view_of_pane(self.pane) + if view and view.get("terminal_id"): + self.known_views.add(view["terminal_id"]) + except StepError as error: + record["ok"], record["error"] = False, str(error)[-600:] + raise + finally: + record["seconds"] = round(now() - t, 3) + self.steps.append(record) + except (Deadline, NotRun) as error: + status, reason = "not run", str(error) + except (StepError, OSError, ValueError, subprocess.TimeoutExpired) as error: + status, reason = "fail", f"step {len(self.steps)} ({self.steps[-1]['do'] if self.steps else '?'}): {error}" + finally: + # `finally` steps put the app back for the next journey (leave full screen, close a second + # window), whatever happened; their errors are recorded and never change the status. + for step in self.spec.get("finally") or []: + record = {"do": step["do"], "title": step.get("title", ""), "finally": True} + try: + record["result"] = getattr(self, "step_" + step["do"])(step) + record["ok"] = True + except Exception as error: # noqa: BLE001 (clean-up: record any error, keep going) + record["ok"], record["error"] = False, str(error)[-300:] + self.steps.append(record) + frames.stopped.set() + frames.join(timeout=SNAPSHOT_TIMEOUT) + for path in self.files: + try: + os.unlink(path) + except OSError: + pass + if status == "pass": + kinds = {e["kind"] for e in self.evidence if e["pass"]} + missing = [k for k in REQUIRED_KINDS if k not in kinds] + if missing: + status, reason = "fail", "not enough evidence: no passing " + ", ".join(missing) + " check" + elif not all(e["pass"] for e in self.evidence): + status, reason = "fail", "a check failed" + clip = self.clip_frames() + result = {"id": self.id, "priority": self.spec.get("priority"), "title": self.spec.get("title"), + "status": status, "reason": reason, "started_at": wall, "seconds": round(now() - started, 2), + "nonce": self.nonce, "terminal": self.term, "pane": self.pane, "host_pid": self.host_pid, + "frames": frames.count, "clip_frames": clip, "steps": self.steps, "evidence": self.evidence} + (self.out / "result.json").write_text(json.dumps(result, indent=1, default=str) + "\n") + return result + + def clip_frames(self): + """The frames scaled to CLIP_WIDTH (sips, stock macOS) in clip/: what run.py fetches for the clip.""" + frames = sorted((self.out / "frames").glob("frame-*.png")) + if not frames: + return 0 + clip = self.out / "clip" + clip.mkdir(exist_ok=True) + done = subprocess.run(["/usr/bin/sips", "--resampleWidth", str(CLIP_WIDTH), *map(str, frames), "--out", str(clip)], + capture_output=True, text=True, timeout=300) + if done.returncode == 0: + shutil.rmtree(self.out / "frames") + return len(list(clip.glob("frame-*.png"))) + + +def load_extensions(module): + """Step and condition groups beside this file (`ext_*.py`): each has `install(driver)`, `STEPS` + ({name: fn(journey, step)}, played as `do: name`) and `CONDITIONS` ({name: (evidence kind, fn(journey, + want, cache) -> (holds, detail))}). They must not import this file (it runs as __main__ on the mini).""" + import importlib + for path in sorted(Path(os.path.dirname(os.path.abspath(__file__))).glob("ext_*.py")): + ext = importlib.import_module(path.stem) + ext.install(module) + for name, fn in getattr(ext, "STEPS", {}).items(): + if hasattr(Journey, "step_" + name): + raise SystemExit(f"driver: {path.name} redefines step {name!r}") + setattr(Journey, "step_" + name, fn) + for name, (kind, fn) in getattr(ext, "CONDITIONS", {}).items(): + if name in KIND: + raise SystemExit(f"driver: {path.name} redefines condition {name!r}") + KIND[name], EXT_CONDITIONS[name] = kind, fn + # `NEGATIVE`: the group's conditions that claim something did NOT happen (read every poll and through `settle`). + EXT_NEGATIVE.update(n for n in getattr(ext, "NEGATIVE", ()) if n in EXT_CONDITIONS) + + +load_extensions(sys.modules[__name__]) + + +# Whether the app was launched active (capture-host launch --activate, a whole-host lease); main sets it. +ACTIVATE = False + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--socket", required=True) + ap.add_argument("--app", required=True, help="the slot's app bundle (capture-host launch's app=)") + ap.add_argument("--home", required=True, help="the slot's HOME, for the bundled CLI") + ap.add_argument("--journeys", required=True, help="a JSON list of journey specs") + ap.add_argument("--out", required=True) + ap.add_argument("--ocr", required=True, help="the compiled scripts/ci/pr_media_ocr.swift") + ap.add_argument("--deadline-seconds", type=float, default=480) + ap.add_argument("--frame-interval", type=float, default=FRAME_INTERVAL) + ap.add_argument("--activate", action="store_true", + help="the app was launched active (capture-host launch --activate): a relaunch opens it in front") + a = ap.parse_args(argv) + global ACTIVATE + ACTIVATE = a.activate + deadline = now() + a.deadline_seconds + out = Path(a.out) + out.mkdir(parents=True, exist_ok=True) + app = App(a.socket, a.app, a.home, a.ocr) + host = {"host": socket.gethostname().split(".")[0], "macos": subprocess.run(["sw_vers", "-productVersion"], + capture_output=True, text=True).stdout.strip(), "identify": app.identity, "sessions": app.sessions} + (out / "host.json").write_text(json.dumps(host, indent=1, default=str) + "\n") + # Text recognition loads its models on first use (tens of seconds on a cold mini): warm it up + # before any journey, so no frame check pays for it. + try: + warm = app.snapshot(out / "warmup.png") + t = now() + app.read_text(warm) + host["ocr_warmup_seconds"] = round(now() - t, 2) + warm.unlink() + except (StepError, OSError, subprocess.TimeoutExpired) as error: + host["ocr_warmup_error"] = str(error)[-300:] + (out / "host.json").write_text(json.dumps(host, indent=1, default=str) + "\n") + results = [] + for spec in json.loads(Path(a.journeys).read_text()): + if now() > deadline: + results.append({"id": spec["id"], "status": "not run", "reason": "the lease deadline came first"}) + continue + result = Journey(spec, app, out, deadline, a.frame_interval).run() + PLAYED.append(result["id"]) + results.append(result) + print(f"journey {result['id']}: {result['status']} {result['reason']}", flush=True) + (out / "summary.json").write_text(json.dumps([{k: r.get(k) for k in ("id", "status", "reason")} for r in results], + indent=1) + "\n") + return 0 if all(r["status"] == "pass" for r in results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/journeys/ext_fuzz.py b/scripts/journeys/ext_fuzz.py new file mode 100644 index 000000000000..f394aa84c0c2 --- /dev/null +++ b/scripts/journeys/ext_fuzz.py @@ -0,0 +1,942 @@ +"""Model-based action fuzzing for the journeys (group action-fuzz, journeys/fuzz/*.json; orchestrator: fuzz.py). + +driver.py loads this file (load_extensions) on the mini, under the stock /usr/bin/python3 (3.9, standard library +only). It must not import driver.py: install() gets the driver module instead. fuzz.py (on the orchestrating +machine) imports it too, for plan(), ddmin() and the corpus; nothing here runs at import. + +cmux-ad 2026-10-31 (Lawrence: "we need to fuzz greater for both correctness and speed"): one harness with the smoke +gate. The fuzz shares the driver's action layer (debug.key, the key router's real path, the same as a person's key; +debug.mouse drags as ext_window plays them; the driver's relaunch) and its oracles (ext_window's grid fit, the +smoke gate's topology reads, ext_focus's focus reading). + +A seed gives a plan: random user actions (ACTIONS) with a timing each (`now`: the next action follows at once, +`pair`: within 60 ms, a quick double press; `slow`: 1.5 s later). Actions that follow at once or as a pair form a +burst; the oracles run after every burst ends (checking between the actions of a burst would change the timing +under test), so after every action of a slow plan. A model of the expected state (workspaces, tabs, terminals, +panes of the shown workspace) is kept beside the app: each action predicts its change; the app must reach it. + +Oracles (each a short name in the failure, `oracle:detail`): + create a create (Cmd-T, Cmd-N, Cmd-D, Cmd-Shift-D, Ctrl-`, Cmd-Shift-L) reached the model's prediction (a new + workspace, tab, terminal or pane), or the app showed a refusal (debug.toast `refusals` grew); never + silence. A new terminal takes keys: a probe line typed into the focused terminal after the burst runs + on exactly one terminal, a new one (its daemon screen), and prints the PTY's size (stty) for R3a. + Cmd-T: the New Tab field has focus (debug.new_tab field). Cmd-Shift-L: the address bar has the keys. + r3a every shown terminal: grid == fitted grid == drawn grid == the daemon's screen size, and the grid + fits the view's frame at the cell size (ext_window's grid_fits rule); the probe's `stty size` == the + daemon's size (the PTY) + r3b a new column (Cmd-D, split right) never changes the width of a column that was there (exact, 0.5 pt + for rounding): on a tree screen every pane except the one split keeps its width; on a column-strip + screen (the topology's `column`) every old column keeps its width + focus debug.focus: the model's target == AppKit's first responder (`consistent`, `mismatches`), read again + 0.5 s later before it counts + topology the daemon's terminals == the app topology's terminals (`cmux terminal list`); debug.desync check + stall no main-thread stall over 100 ms with a cmux frame (debug.hangs records since the last burst); + stalls with no cmux frame (or no stack) are kept as notes + crash the app process lives and its daemon is connected (debug.crashes after a quit relaunch: clean) + leak after close-all: the app's fd count, the bundle's process count and its PTY count are back at the + baseline (taken at the first close-all, so browser helpers that stay warm are counted once); a later + close-all above it is a leak + +Speed: each create records, from its key, `shown_ms` (the first poll, every 25 ms, where the new terminal's pane is +visible and not blank with a drawn grid, or the new tab or workspace is in the topology) and `typeable_ms` (a +terminal: its prompt is in the app's viewport text, what the renderer draws; Cmd-T: the New Tab field has focus; +Cmd-Shift-L: the address bar has the keys). No build has a photon (present) timestamp yet, so these are the app's +own drawn state, read the same way on both builds of an A/B; fuzz.py compares them build against build. + +A failure is shrunk at once in the same app (ddmin over the actions; each try starts from the baseline workspaces, +closed through the app's own closeWorkspace), until the step's budget ends; fuzz.py replays the smallest sequence in +a fresh lease and writes it as a regression journey (red first). + +Diagnostic (journey option fuzz_mode=diagnostic, the smoke gate's --diagnostic): a capture slot has no key window, so +keys the app holds for a terminal still being made are lost there. Every key after a create then waits out the +creation hold (2.5 s, as the driver's typing does): the quick pairs of creates, typing at once into a new terminal and +the focus oracle (no key window) are weak in this mode, and fuzz.py never writes a clean file from it. + +Steps (`do`): + fuzz_run {seed?, count?, sequence?, budget_s?, shrink?, weights?} + plays the plan of SEED (or SEQUENCE: a list of {"a": action, "t": timing, ...}, a corpus entry or a + shrunk repro), the oracles after each burst; writes fuzz.json beside result.json; fails with the + first failure's oracle, its seed and the shrunk sequence +Conditions (inside `expect.all`): + fuzz_clean {} app: the last fuzz_run found no failure + fuzz_daemon_agrees {} daemon: the daemon's terminals == the app topology's terminals now +""" +import json +import random +import re +import subprocess +import time + +D = None # the driver module (install) + +# action: what a person presses. `key`/`mods` go through debug.key (the key router's real path); `action` goes +# through action.run (a menu item's route, for an action with no default key); `special` is played below. +# `creates` names the model's change; `column` marks an action that makes a column (R3b). +ACTIONS = { + "new_tab": {"key": "t", "mods": ["cmd"], "creates": "tab_page"}, # Cmd-T: the New Tab page + "new_workspace": {"key": "n", "mods": ["cmd"], "creates": "workspace"}, # Cmd-N + "split_right": {"key": "d", "mods": ["cmd"], "creates": "pane", "column": True}, # Cmd-D (also adds a column) + "new_column": {"key": "d", "mods": ["control", "cmd"], "creates": "pane", "column": True}, # Ctrl-Cmd-D + "split_down": {"key": "d", "mods": ["cmd", "shift"], "creates": "pane"}, # Cmd-Shift-D + "new_terminal": {"key": "`", "mods": ["control"], "creates": "terminal"}, # Ctrl-` + "open_url": {"special": True, "creates": "browser"}, # Cmd-Shift-L, a URL, Return + "close_tab": {"key": "w", "mods": ["cmd"], "closes": "tab"}, # Cmd-W + "close_pane": {"action": "closePane", "closes": "pane"}, # no default key: the menu's route + "close_workspace": {"key": "w", "mods": ["cmd", "shift"], "closes": "workspace"}, # Cmd-Shift-W + "switch_workspace": {"special": True}, # Cmd-1 .. Cmd-4 + "resize_window": {"special": True}, # drag the window's corner + "drag_divider": {"special": True}, # drag a split's divider + "type_line": {"special": True}, # a line typed into the terminal + "paste": {"special": True}, # Cmd-V of a short text + "relaunch": {"special": True, "alone": True}, # Cmd-Q, then open again + "close_all": {"special": True, "alone": True}, # every workspace closed; leak check +} +# How often a plan picks each action: creates more often than closes, so the app keeps something to act on. +WEIGHTS = {"new_column": 2, "new_tab": 3, "new_workspace": 2, "split_right": 3, "split_down": 2, "new_terminal": 4, "open_url": 1, + "close_tab": 2, "close_pane": 1, "close_workspace": 1, "switch_workspace": 2, "resize_window": 2, + "drag_divider": 2, "type_line": 3, "paste": 1, "relaunch": 0.3, "close_all": 1} +# The action sets (journey option fuzz_set): `layout` (cmux-ad 06:42Z, fuzz-catalog row m, the first mode: the layout +# invariants R3a and R3b under the actions that change the layout) and `full` (every action above). +SETS = {"layout": {"new_column": 3, "split_right": 3, "split_down": 2, "new_terminal": 3, "close_pane": 1.5, + "drag_divider": 2, "resize_window": 2, "relaunch": 0.3}, + "full": WEIGHTS} +TIMINGS = {"now": 0.0, "pair": 0.06, "slow": 1.5} +TIMING_WEIGHTS = {"now": 3, "pair": 2, "slow": 4} +# A pair of creates is most often the same key twice (Cmd-D twice, Ctrl-` twice: what Lawrence hit). +SAME_PAIR = 0.6 +MAX_BURST = 3 +URL = "example.com" +PROBE = "fz{nonce}-{n}" +POLL = 0.025 +CREATE_TIMEOUT = 20.0 +SETTLE = 3.0 +STALL_MS = 100.0 +HOLD_SECONDS = 2.5 # driver.CREATION_HOLD_SECONDS; repeated so fuzz.py can import this file without the driver +# Actions after which the model cannot predict counts (exact create checks and R3b are off for that burst). +DISTURB = {"close_tab", "close_pane", "close_workspace", "switch_workspace", "relaunch", "close_all"} +# Actions that change pane sizes on purpose (R3b is off for that burst). +RESIZE = {"resize_window", "drag_divider"} +# Leak tolerances after close-all (see Fuzz.leak). +LEAK_FDS = 48 +LEAK_HELPERS = 4 +LEAK_SETTLE = 15.0 + +# Lawrence's reported bugs as fixed sequences (cmux-lead-status-3 items 897-914): played first by fuzz.py --corpus, +# and as journeys of their own (journeys/fuzz/lawrence-*.json, the same sequences). +CORPUS = { + "lawrence-cmd-d-twice": [{"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "split_right", "t": "pair"}, {"a": "split_right", "t": "slow"}, + {"a": "type_line", "t": "slow"}], + "lawrence-ctrl-backtick-twice": [{"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "new_terminal", "t": "pair"}, {"a": "new_terminal", "t": "slow"}, + {"a": "type_line", "t": "slow"}], + "lawrence-no-new-workspace": [{"a": "new_workspace", "t": "now"}, {"a": "new_workspace", "t": "now"}, + {"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "type_line", "t": "slow"}], + "lawrence-resize": [{"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "split_right", "t": "slow"}, {"a": "resize_window", "t": "slow", "by": [-320, -160]}, + {"a": "resize_window", "t": "slow", "by": [300, 140]}, {"a": "drag_divider", "t": "slow", "by": -120}, + {"a": "type_line", "t": "slow"}], + "lawrence-column-widths": [{"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "split_right", "t": "slow"}, {"a": "split_right", "t": "slow"}, + {"a": "split_right", "t": "slow"}], + "lawrence-cmd-t-slow": [{"a": "new_workspace", "t": "slow"}, {"a": "new_terminal", "t": "slow"}, + {"a": "new_tab", "t": "slow"}, {"a": "new_tab", "t": "pair"}, {"a": "new_tab", "t": "slow"}], +} + + +def install(driver): + global D + D = driver + + +# ---- the plan (pure: fuzz.py and the tests use it too) ---------------------------------------- + +def plan(seed, count, weights=None, actions="full"): + """COUNT actions from SEED out of the set ACTIONS (SETS): [{"a": name, "t": timing, ...args}]; the same seed and + set always give the same plan.""" + rng = random.Random(int(seed)) + table = dict(SETS[actions], **(weights or {})) + names = sorted(n for n in ACTIONS if table.get(n, 0) > 0) + w = [table[n] for n in names] + timings = sorted(TIMINGS) + tw = [TIMING_WEIGHTS[t] for t in timings] + out = [] + while len(out) < count: + name = rng.choices(names, w)[0] + if out and out[-1]["t"] == "pair" and ACTIONS[out[-1]["a"]].get("creates") and rng.random() < SAME_PAIR: + name = out[-1]["a"] + item = {"a": name, "t": rng.choices(timings, tw)[0]} + if ACTIONS[name].get("alone"): + item["t"] = "slow" + if out and out[-1]["t"] != "slow": + out[-1]["t"] = "slow" + if name == "resize_window": + item["by"] = [rng.randint(-320, 320), rng.randint(-200, 200)] + elif name == "drag_divider": + item["by"] = rng.choice((-1, 1)) * rng.randint(24, 200) + elif name == "switch_workspace": + item["n"] = rng.randint(1, 4) + out.append(item) + return out + + +def bursts(seq): + """The plan cut into bursts: actions that follow at once or as a pair, at most MAX_BURST, then a slow one.""" + out, cur = [], [] + for item in seq: + cur.append(item) + if item.get("t", "slow") == "slow" or len(cur) >= MAX_BURST or ACTIONS.get(item["a"], {}).get("alone"): + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + + +def ddmin(seq, fails, budget_left=lambda: True): + """Zeller's ddmin: the smallest subsequence (1-minimal within the budget) for which FAILS(subseq) is True. + FAILS returns True (the same failure), False, or None (no verdict: out of budget; the search stops there).""" + seq, n, tries = list(seq), 2, 0 + while len(seq) >= 2 and budget_left(): + size = -(-len(seq) // n) + chunks = [seq[i:i + size] for i in range(0, len(seq), size)] + reduced = False + for i, chunk in enumerate(chunks): + for cand in ([chunk] if len(chunks) > 1 else []) + [sum(chunks[:i] + chunks[i + 1:], [])]: + if not cand or cand == seq: + continue + tries += 1 + verdict = fails(cand) + if verdict is None: + return seq, tries + if verdict: + seq, n, reduced = cand, (2 if len(cand) == len(chunk) else max(n - 1, 2)), True + break + if reduced: + break + if not reduced: + if n >= len(seq): + break + n = min(len(seq), n * 2) + return seq, tries + + +# ---- reads ------------------------------------------------------------------------------- + +def _rpc(j, method, params=None, timeout=10): + try: + return j.app.rpc(method, params or {}, timeout=timeout) + except (D.StepError, OSError, ValueError) as error: + return {"error": str(error)[-300:]} + + +def state(j): + """The app's topology now, reduced to what the model predicts.""" + topo = j.topology() + out = {"workspaces": [], "tabs": [], "terminals": {}, "panes": {}, "browsers": 0, "focus": topo.get("focus") or {}, + "daemon_state": topo.get("daemon_state"), "columns": {}} + for ws in topo.get("workspaces") or []: + out["workspaces"].append(ws.get("id")) + for screen in ws.get("screens") or []: + for pane in screen.get("panes") or []: + out["panes"].setdefault(ws.get("id"), []).append(pane.get("id")) + if pane.get("column") is not None: + out["columns"][pane.get("id")] = pane.get("column") + for tab in pane.get("tabs") or []: + out["tabs"].append(tab.get("id")) + term = tab.get("terminal") + term = term.get("id") if isinstance(term, dict) else term + if term: + out["terminals"][str(tab.get("terminal_key") or term)] = {"term": str(term), "ws": ws.get("id"), + "pane": pane.get("id"), "tab": tab.get("id")} + if tab.get("kind") == "browser" or (tab.get("url") and not term): + out["browsers"] += 1 + return out + + +def shown_ws(st): + return st["focus"].get("workspace") + + +def surfaces(j, text=False): + report = _rpc(j, "debug.surfaces", {"text": text}) or {} + panes = [] + for w in report.get("windows") or [] if isinstance(report, dict) else []: + for p in w.get("panes") or []: + if isinstance(p, dict): + panes.append(dict(p, window_number=w.get("window"))) + return panes + + +def _grid(text): + m = re.fullmatch(r"(\d+)x(\d+)", str(text or "")) + return (int(m.group(1)), int(m.group(2))) if m else None + + +def drawn(p): + """A terminal pane the window shows, not blank, with a grid Ghostty drew.""" + return bool(p.get("visible")) and not p.get("blank") and bool(_grid((p.get("surface") or {}).get("drawn_grid"))) + + +def refusals(j): + report = _rpc(j, "debug.toast") or {} + return list(report.get("refusals") or []) if isinstance(report, dict) else [] + + +def hang_records(j, after): + report = _rpc(j, "debug.hangs", {"after": after, "limit": 200}) or {} + rows = [r for r in report.get("records") or [] if isinstance(r, dict)] if isinstance(report, dict) else [] + return [r for r in rows if int(r.get("sequence") or 0) > after] + + +def cmux_frame(record): + return any("cmux" in str(f.get("image") or "").lower() or "cmux" in str(f.get("symbol") or "").lower() + for f in record.get("frame_details") or [] if isinstance(f, dict)) + + +def daemon_terminals(j): + rows = j.app.cmux("terminal", "list") + if isinstance(rows, dict): + rows = next((rows[k] for k in ("terminals", "value", "result") if isinstance(rows.get(k), list)), []) + return {str(r.get("id")): r for r in rows or [] if isinstance(r, dict) and r.get("id")} + + +def counts(j): + """{fds, processes, helpers, ptys}: the app's open file descriptors (numeric FDs), the bundle's processes + (helpers, daemon, terminal hosts; `helpers`: the Chromium ones, which may stay warm) and the PTY devices those + hold (each /dev/ttysNNN once; lsof, stock macOS).""" + procs = j.app.bundle_processes(j.app.pid) + pids = [j.app.pid] + [p["pid"] for p in procs] + out = subprocess.run(["/usr/sbin/lsof", "-n", "-P", "-p", ",".join(map(str, pids))], capture_output=True, + text=True, timeout=90).stdout.splitlines() + fds = 0 + for line in out[1:]: + parts = line.split() + if len(parts) > 3 and parts[1] == str(j.app.pid) and re.fullmatch(r"\d+[rwu]?", parts[3]): + fds += 1 + ptys = len(set(re.findall(r"/dev/ttys\d+", "\n".join(out)))) + return {"fds": fds, "processes": len(procs), "ptys": ptys, + "helpers": sum(1 for p in procs if "Helper" in p["command"])} + + +# ---- the run ------------------------------------------------------------------------------- + +class Fuzz: + def __init__(self, j, step): + self.j = j + opts = j.spec.get("options") or {} + self.diagnostic = opts.get("fuzz_mode") == "diagnostic" or opts.get("smoke_mode") == "diagnostic" + self.seed = int(opts.get("fuzz_seed") or step.get("seed") or random.randrange(1 << 30)) + self.count = int(opts.get("fuzz_count") or step.get("count", 300)) + budget = float(opts.get("fuzz_budget_s") or step.get("budget_s", 200)) + self.end = min(D.now() + budget, j.deadline - 25) + self.shrink = str(opts.get("fuzz_shrink", step.get("shrink", True))).lower() not in ("false", "0", "no") + seq = step.get("sequence") + if opts.get("fuzz_sequence"): + seq = json.loads(opts["fuzz_sequence"]) + self.set = str(opts.get("fuzz_set") or step.get("set", "full")) + if self.set not in SETS: + raise D.StepError(f"fuzz_run: set is one of {sorted(SETS)}, not {self.set!r}") + self.sequence = list(seq) if seq else plan(self.seed, self.count, step.get("weights"), self.set) + if not seq and self.set == "full": + # The leak oracle compares each close-all with the first one: a seeded plan starts with one (the baseline, + # after which warm singletons exist) only once something was made, so it ends with one too. + self.sequence = self.sequence[:max(0, self.count - 1)] + [{"a": "close_all", "t": "slow"}] + self.source = "sequence" if seq else "seed" + self.baseline = set(getattr(j, "smoke_baseline", None) or state(j)["workspaces"]) + self.n = 0 + self.probes = 0 + self.pty_tags = {} + self.last_create = None + self.hang_seq = max([int(r.get("sequence") or 0) for r in hang_records(j, 0)] or [0]) + self.leak_base = None + self.speed, self.played, self.notes = [], [], [] + + def left(self): + return D.now() < self.end + + # -- input -- + + def wait_hold(self): + """Diagnostic: every key after a create waits out the creation hold (a slot has no key window).""" + if self.diagnostic and self.last_create is not None: + self.j.outwait_creation_hold(self.last_create) + + def press(self, key, mods=()): + self.wait_hold() + t = D.now() + reply = self.j.key(key, list(mods)) + return t, reply + + def type_text(self, text): + self.wait_hold() + for ch in text: + self.j.key(ch, []) + self.j.key("return", []) + + def answer_dialog(self): + """A close that asks (a running process): its default, as Return does.""" + report = _rpc(self.j, "debug.dialog") or {} + shown = [d for d in report.get("dialogs") or [] if isinstance(d, dict) and d.get("visible")] if isinstance(report, dict) else [] + if shown: + _rpc(self.j, "debug.dialog", {"key": "return"}) + return shown[-1].get("title") + return None + + def play(self, item, before): + """Plays one action; returns its record (key time, reply). `before` is the state before the burst.""" + name, j = item["a"], self.j + spec = ACTIONS[name] + rec = {"a": name, "t": item.get("t", "slow")} + if "key" in spec: + rec["at"], reply = self.press(spec["key"], spec["mods"]) + rec["reply"] = {k: reply.get(k) for k in ("handled_by", "error")} if isinstance(reply, dict) else reply + elif "action" in spec: + self.wait_hold() + rec["at"] = D.now() + rec["reply"] = _rpc(j, "action.run", {"action": spec["action"], "args": {}, "focus": True}) + elif name == "open_url": + rec["at"], _ = self.press("l", ["cmd", "shift"]) + rec["typeable_ms"] = self.wait_focus("addressBar", rec["at"]) + for ch in URL: + j.key(ch, []) + j.key("return", []) + elif name == "switch_workspace": + rec["at"], _ = self.press(str(item.get("n", 1)), ["cmd"]) + elif name == "resize_window": + self.wait_hold() + rec["at"] = D.now() + j.ext_window = None + rec["drag"] = _short(j.step_window_drag({"edge": "bottom-right", "by": item.get("by", [-200, -100]), "events": 20})) + elif name == "drag_divider": + self.wait_hold() + rec["at"] = D.now() + rec["drag"] = self.drag_divider(item.get("by", 80)) + elif name == "type_line": + rec["at"] = D.now() + rec["probe"] = self.probe(before, require_new=False) + elif name == "paste": + text = f"fzp{j.nonce}{self.n}" + subprocess.run(["/usr/bin/pbcopy"], input=text.encode(), timeout=10, check=False) + rec["at"], _ = self.press("v", ["cmd"]) + rec["pasted"] = text + elif name == "relaunch": + self.wait_hold() + rec["at"] = D.now() + rec["relaunch"] = _short(j.step_relaunch({"how": "quit"})) + self.last_create = None + elif name == "close_all": + self.wait_hold() + rec["at"] = D.now() + rec["closed"] = self.close_all() + if spec.get("creates"): + self.last_create = rec.get("at", D.now()) + if spec.get("closes"): + rec["dialog"] = self.answer_dialog() + return rec + + def wait_focus(self, kind, since, timeout=CREATE_TIMEOUT): + end = D.now() + timeout + while D.now() < end: + focus = _rpc(self.j, "debug.focus") or {} + if any((w.get("model") or {}).get("resolved") == kind for w in focus.get("windows") or [] if isinstance(w, dict)): + return round((D.now() - since) * 1000) + time.sleep(POLL) + return None + + def drag_divider(self, by): + """Drags the divider between two side-by-side panes of the shown workspace (ext_window's divider_drag).""" + j = self.j + panes = [p for p in surfaces(j) if p.get("visible") and (p.get("grid") or {}).get("frame_width")] + frames = [((p["grid"]["frame_x"], p["grid"]["frame_y"], p["grid"]["frame_width"], p["grid"]["frame_height"]), p) for p in panes] + for fa, a in frames: + for fb, b in frames: + if a is b or a["grid"].get("window") != b["grid"].get("window"): + continue + if abs(fb[0] - (fa[0] + fa[2])) < 12 or abs(fb[1] - (fa[1] + fa[3])) < 12: + j.ext_panes = {"fa": {"pane": a.get("pane"), "window": a["grid"].get("window")}, + "fb": {"pane": b.get("pane"), "window": b["grid"].get("window")}} + try: + return _short(j.step_divider_drag({"between": ["fa", "fb"], "by": by, "events": 20})) + except D.StepError as error: + return {"error": str(error)[-200:]} + return {"skipped": "no two panes side by side"} + + def close_all(self): + j, closed = self.j, [] + for wid in state(j)["workspaces"]: + if wid not in self.baseline: + _rpc(j, "action.run", {"action": "closeWorkspace", "target": f"workspace:{wid}", "args": {"confirm": True}}) + closed.append(wid) + end = D.now() + 15 + while D.now() < end and any(w not in self.baseline for w in state(j)["workspaces"]): + self.answer_dialog() + time.sleep(0.2) + return closed + + # -- oracles -- + + def predict(self, burst, before): + """The model's change for the burst: the least increase of workspaces, tabs, terminals, browsers, and + panes of the workspace shown at the end, and whether it is `exact`. A close or a switch (DISTURB) makes the + burst inexact: the model does not know what shows after it, so it asks only that something new appear + (a tab or workspace id not there before) when a create that always makes something (Cmd-N; Ctrl-`, which + opens a workspace on Home) follows it. A create on Home that needs a workspace (Cmd-D, Cmd-T, Cmd-Shift-L) + is not predicted. A close or switch after the last create may undo it: nothing is predicted then.""" + want = {"workspaces": 0, "tabs": 0, "terminals": 0, "browsers": 0, "panes": 0} + none = ({k: 0 for k in want}, False) + creates = [k for k, i in enumerate(burst) if ACTIONS[i["a"]].get("creates")] + if not creates or any(i["a"] in DISTURB for i in burst[creates[-1] + 1:]): + return none + on_ws = True if shown_ws(before) else False # True, False (Home) or None (not known) + exact, sure = True, False + for item in burst: + kind = ACTIONS[item["a"]].get("creates") + if item["a"] in DISTURB: + # It may undo what came before it in the burst: only the creates after it count. + exact, on_ws, sure = False, None, False + want = {k: 0 for k in want} + elif kind == "workspace": + want["workspaces"] += 1 + on_ws, sure = True, True + elif kind == "terminal": + want["terminals"] += 1 + want["tabs"] += 1 + if on_ws is False: + want["workspaces"] += 1 + elif on_ws is None: + exact = False + on_ws, sure = True, True + elif kind in ("pane", "tab_page", "browser"): + if on_ws is not True: + exact = False + continue + want["tabs"] += 1 + if kind == "pane": + want["panes"] += 1 + want["terminals"] += 1 + sure = True + if not exact and not sure: + return none + return want, exact + + def delta(self, before, after): + ws = shown_ws(after) + return {"workspaces": len(after["workspaces"]) - len(before["workspaces"]), + "tabs": len(after["tabs"]) - len(before["tabs"]), + "terminals": len(after["terminals"]) - len(before["terminals"]), + "browsers": after["browsers"] - before["browsers"], + "panes": len(after["panes"].get(ws, [])) - len(before["panes"].get(ws, []))} + + def creates(self, burst, before, recs, refused_before): + """The `create` oracle and the speed marks: polls until the model's prediction holds or CREATE_TIMEOUT.""" + want, exact = self.predict(burst, before) + if not any(want.values()): + return [], None, exact + made = [r for r in recs if ACTIONS[r["a"]].get("creates")] + t_end = D.now() + CREATE_TIMEOUT + after, got = before, {} + old = set(before["terminals"]) + shown_at, prompt_at = {}, {} + # Inexact (a close or a switch in the burst): something new must appear (a tab or workspace id not there before). + old_ids = set(before["tabs"]) | set(before["workspaces"]) + fresh = lambda: bool((set(after["tabs"]) | set(after["workspaces"])) - old_ids) + held = lambda g: all(g[k] >= v for k, v in want.items() if v > 0) if exact else fresh() + while D.now() < t_end: + after = state(self.j) + got = self.delta(before, after) + new = [k for k in after["terminals"] if k not in old] + for p in surfaces(self.j, text=bool(new)): + key = str(p.get("terminal_id") or "") + if key in new and drawn(p): + shown_at.setdefault(key, D.now()) + if (p.get("text") or "").strip(): + prompt_at.setdefault(key, D.now()) + if held(got) and all(k in prompt_at for k in new[:want["terminals"]]): + break + time.sleep(POLL) + done_at = D.now() + # Speed: the k-th terminal-making action gets the k-th new terminal (by when it showed), from its own key. + order = sorted(shown_at, key=shown_at.get) + terms = [r for r in made if ACTIONS[r["a"]]["creates"] in ("terminal", "pane")] + for r, key in zip(terms, order): + r["shown_ms"] = round((shown_at[key] - r["at"]) * 1000) + if key in prompt_at: + r["typeable_ms"] = round((prompt_at[key] - r["at"]) * 1000) + for r in made: + if ACTIONS[r["a"]]["creates"] in ("workspace", "tab_page") and "shown_ms" not in r and held(got): + r["shown_ms"] = round((done_at - r["at"]) * 1000) + if ACTIONS[r["a"]]["creates"] == "tab_page" and r is made[-1]: + r["typeable_ms"] = self.new_tab_field(r["at"]) + problems = [] + if not held(got): + refused = refusals(self.j)[len(refused_before):] + if refused: + self.notes.append({"n": self.n, "refused": refused[-3:], "want": want, "got": got}) + else: + problems.append(("create", f"{'+'.join(i['a'] for i in burst)}: wanted {want}{'' if exact else ' (any)'}, " + f"got {got}, and no refusal shown within {CREATE_TIMEOUT:g} s")) + return problems, after, exact + + def new_tab_field(self, since, timeout=10.0): + end = D.now() + timeout + while D.now() < end: + field = _rpc(self.j, "debug.new_tab", {"action": "field"}) or {} + if isinstance(field, dict) and field.get("focused") is True: + return round((D.now() - since) * 1000) + time.sleep(POLL * 2) + return None + + def probe(self, before, require_new=True): + """Types a probe line into the focused terminal: it must run on exactly one terminal (a new one when the + burst made one) and print the PTY's size (R3a).""" + j = self.j + st = state(j) + tab = st["focus"].get("tab") + if not any(t["tab"] == tab for t in st["terminals"].values()): + return {"skipped": "the focused tab is not a terminal"} + self.probes += 1 + line = PROBE.format(nonce=j.nonce, n=self.probes) + self.type_text(f"echo {line} $(stty size)") + end = D.now() + 15 + holders = {} + while D.now() < end: + holders = {} + for key, t in state(j)["terminals"].items(): + try: + text = j.app.screen(t["term"]) + except D.StepError: + continue + for row in (text.get("text") or "").split("\n"): + m = re.fullmatch(re.escape(line) + r" (\d+) (\d+)", row.strip()) + if m: + holders[key] = {"term": t["term"], "rows": int(m.group(1)), "cols": int(m.group(2)), + "daemon": [text.get("cols"), text.get("rows")]} + if holders: + break + time.sleep(0.1) + new = [k for k in st["terminals"] if k not in before["terminals"]] + out = {"line": line, "on": sorted(holders)} + problems = [] + if len(holders) != 1: + problems.append(("create" if require_new else "keys", f"the probe {line} ran on {len(holders)} terminals, not 1")) + elif require_new and new and next(iter(holders)) not in new: + problems.append(("create", f"the probe {line} ran on the old terminal {next(iter(holders))}, not a new one {new}")) + out["problems"] = problems + if len(holders) == 1: + self.pty_tags = {next(iter(holders.values()))["term"]: line} + return out + + def r3a(self, st, pty_tags=None): + """R3a for every terminal the window shows, judged by the smoke gate's own condition (ext_smoke.cond_smoke_r3): + grid == fitted grid == drawn grid == fit(the view's frame at the cell size) == the daemon's screen size == + the topology tab's size, and, where a probe printed `stty size` (PTY_TAGS: {daemon terminal: tag}), == the + PTY's size.""" + import ext_smoke # beside this file (the driver loads every ext_*.py from here) + problems = [] + if not hasattr(self.j, "smoke_named"): + self.j.smoke_named = {} + for p in surfaces(self.j): + if not p.get("visible") or p.get("blank") or not _grid((p.get("surface") or {}).get("grid")): + continue + term = st["terminals"].get(str(p.get("terminal_id") or "")) + if not term: + continue + name = "fz-r3a" + self.j.smoke_named[name] = {"pane": p.get("pane"), "term": term["term"], "workspace": term["ws"]} + want = {"pane": name} + if pty_tags and pty_tags.get(term["term"]): + want["pty_tag"] = pty_tags[term["term"]] + try: + ok, detail = ext_smoke.cond_smoke_r3(self.j, want, {}) + except D.StepError as error: + self.notes.append({"n": self.n, "r3a_read": str(error)[-160:]}) + continue + if not ok: + problems.append(("r3a", f"pane {p.get('pane')} terminal {term['term']}: {'; '.join(detail.get('problems') or [])} " + f"(sizes {json.dumps(detail.get('sizes'))})")) + return problems + + def widths(self): + """The smoke gate's column snapshot (ext_smoke._widths/_columns: the topology's `column` of each pane and + the union of its panes' view frames) of the shown workspace: the same R3b reading as the gate's step.""" + import ext_smoke # beside this file (the driver loads every ext_*.py from here) + ws = shown_ws(state(self.j)) + if not ws: + return None + return {"workspace": ws, "widths": ext_smoke._widths(self.j, ws), "columns": ext_smoke._columns(self.j, ws)} + + def r3b(self, snap, before, after): + """Every column the snapshot had (matched by its panes, as ext_smoke.cond_smoke_same_widths does) keeps its + exact width (0.01 pt) after a burst whose only layout change is new columns.""" + if not snap or not after or shown_ws(after) != snap["workspace"]: + return [] + import ext_smoke + now_cols = ext_smoke._columns(self.j, snap["workspace"]) + now_w = ext_smoke._widths(self.j, snap["workspace"]) + problems = [] + for index, members in snap["columns"].items(): + match = next((k for k, m in now_cols.items() if set(members) <= set(m)), None) + old, new = snap["widths"].get(index), now_w.get(match) if match is not None else None + if match is None or old is None or new is None: + continue # closed, or scrolled out of view: no width to compare + if abs(old - new) > 0.01: + problems.append(("r3b", f"column {index} (panes {members}): width {old:g} -> {new:g} after a new column")) + return problems + + def focus(self): + def read(): + report = _rpc(self.j, "debug.focus") or {} + out = [] + for w in report.get("windows") or [] if isinstance(report, dict) else []: + if not isinstance(w, dict): + continue + if w.get("consistent") is False or w.get("mismatches"): + out.append(f"window {w.get('id')}: model {(w.get('model') or {}).get('resolved')} vs first responder " + f"{(w.get('appkit') or {}).get('classified')}: {json.dumps(w.get('mismatches'))[:200]}") + return out + found = read() + if found: + time.sleep(0.5) + again = read() + found = [f for f in found if f.split(":")[0] in {g.split(":")[0] for g in again}] + if self.diagnostic and found: + # A slot has no key window: AppKit's first responder is not the person's; kept, never a red. + self.notes.append({"n": self.n, "focus_weak": found[:3]}) + return [] + return [("focus", f) for f in found] + + def topology(self, st, closed_all=False): + problems = [] + try: + daemon = daemon_terminals(self.j) + except D.StepError as error: + return [("topology", f"cmux terminal list failed: {str(error)[-200:]}")] + app_terms = {t["term"] for t in st["terminals"].values()} + missing = sorted(app_terms - set(daemon)) + if missing: + problems.append(("topology", f"the app shows terminals the daemon does not have: {missing[:6]}")) + live = {k for k, r in daemon.items() if str(r.get("lifecycle") or "live") not in ("exited", "dead", "closed")} + extra = sorted(live - app_terms) + if extra: + # Closed tabs may stay in the daemon for undo (the closed history); the leak oracle counts their PTYs. + self.notes.append({"n": self.n, "daemon_only_terminals": extra[:6], "after_close_all": closed_all}) + check = (_rpc(self.j, "debug.desync", {"check": True}) or {}).get("check") or {} + for v in check.get("violations") or [] if isinstance(check, dict) else []: + problems.append(("topology", f"desync {v.get('invariant')}: {str(v.get('detail'))[:200]}")) + return problems + + def crash(self, st, relaunched): + problems = [] + if not self.j.app.alive(self.j.app.pid): + return [("crash", f"the app (pid {self.j.app.pid}) is gone")] + if st and st.get("daemon_state") != "connected": + problems.append(("crash", f"daemon_state {st.get('daemon_state')}")) + if relaunched: + rec = (_rpc(self.j, "debug.crashes") or {}).get("launch") or {} + if isinstance(rec, dict) and rec.get("recovery") not in (None, "clean"): + problems.append(("crash", f"after the quit, the launch says recovery {rec.get('recovery')}")) + return problems + + def stalls(self): + rows = hang_records(self.j, self.hang_seq) + if rows: + self.hang_seq = max(int(r.get("sequence") or 0) for r in rows) + problems = [] + for r in rows: + ms = float(r.get("duration_ms") or 0) + if ms <= STALL_MS: + continue + if cmux_frame(r): + top = next((f.get("symbol") for f in r.get("frame_details") or [] if "cmux" in str(f.get("image") or "").lower()), None) + problems.append(("stall", f"{r.get('kind')} {ms:.0f} ms on the main thread in {top}")) + else: + self.notes.append({"n": self.n, "stall_ms": round(ms), "kind": r.get("kind"), "attributed": False}) + return problems + + def leak_settled(self): + """leak(), read again for up to LEAK_SETTLE seconds while it finds something (processes end, PTYs close).""" + end = D.now() + LEAK_SETTLE + while True: + found, now = self.leak() + if not found or D.now() >= end: + return found, now + time.sleep(1.0) + + def leak(self): + """After close-all, against the counts of the first close-all (set then): PTYs and non-helper processes + exactly, the app's fds within LEAK_FDS, browser helpers within LEAK_HELPERS (Chromium keeps its GPU, + network and a spare renderer warm). A relaunch starts a new baseline.""" + now = counts(self.j) + if self.leak_base is None: + self.leak_base = now + return [], now + base, problems = self.leak_base, [] + if now["ptys"] > base["ptys"]: + problems.append(("leak", f"PTYs {base['ptys']} -> {now['ptys']} after close-all")) + if now["processes"] - now["helpers"] > base["processes"] - base["helpers"]: + problems.append(("leak", f"processes other than browser helpers {base['processes'] - base['helpers']} -> " + f"{now['processes'] - now['helpers']} after close-all")) + if now["helpers"] > base["helpers"] + LEAK_HELPERS: + problems.append(("leak", f"browser helpers {base['helpers']} -> {now['helpers']} after close-all")) + if now["fds"] > base["fds"] + LEAK_FDS: + problems.append(("leak", f"app fds {base['fds']} -> {now['fds']} after close-all")) + return problems, now + + def settle_check(self, fn): + """An oracle must hold within SETTLE seconds (a layout in flight is not a failure); its last reading.""" + end = D.now() + SETTLE + while True: + found = fn() + if not found or D.now() >= end: + return found + time.sleep(0.25) + + # -- one burst -- + + def run_burst(self, burst): + j = self.j + names = [i["a"] for i in burst] + before = state(j) + # R3b: only a burst whose sole layout change is its creates (no resize, drag, close or switch in it). + column = any(ACTIONS[a].get("column") for a in names) and not any(a in DISTURB or a in RESIZE for a in names) + before_w = self.widths() if column else None + refused_before = refusals(j) if any(ACTIONS[a].get("creates") for a in names) else [] + recs = [] + for k, item in enumerate(burst): + self.n += 1 + recs.append(self.play(item, before)) + if k + 1 < len(burst): + gap = TIMINGS.get(item.get("t"), 0.0) + if gap: + time.sleep(gap) + problems, after, exact = self.creates(burst, before, recs, refused_before) + for r in recs: + if r["a"] == "type_line": + problems += (r.get("probe") or {}).get("problems", []) + made_terminal = any(ACTIONS[a].get("creates") in ("terminal", "pane") for a in names) + if made_terminal and not any(p[0] == "create" for p in problems): + pr = self.probe(before, require_new=exact) + recs[-1]["new_probe"] = pr + problems += pr.get("problems", []) + closed_all, relaunched = "close_all" in names, "relaunch" in names + if relaunched: + self.hang_seq, self.leak_base = 0, None # a new process: its own hang sequence and resources + after = state(j) + problems += self.crash(after, relaunched) + if not any(p[0] == "crash" for p in problems): + tags, self.pty_tags = self.pty_tags, {} + problems += self.settle_check(lambda: self.r3a(state(j), tags)) + if before_w is not None: + problems += self.r3b(before_w, before, after) + problems += self.focus() + problems += self.settle_check(lambda: self.topology(state(j), closed_all)) + problems += self.stalls() + if closed_all: + found, now = self.leak_settled() + problems += found + recs[-1]["counts"] = now + for r in recs: + if r.get("shown_ms") is not None or r.get("typeable_ms") is not None: + self.speed.append({"a": r["a"], "t": r["t"], "shown_ms": r.get("shown_ms"), "typeable_ms": r.get("typeable_ms")}) + self.played.append({"burst": [dict(i) for i in burst], "recs": [_short(r) for r in recs], + "problems": [f"{o}:{d}" for o, d in problems]}) + return problems + + def play_sequence(self, seq, stop_at_first=True): + """Plays SEQ (the oracles after each burst); the first failure: {oracle, detail, index, sequence: the actions + played up to it, setup ones included}, or None; "budget" when the time ran out.""" + self.effective = [] + for burst in bursts(seq): + if not self.left(): + return "budget" + setup = self.ensure_target(burst) + if setup: + self.effective += setup + problems = self.run_burst(setup) + if problems and stop_at_first: + return self.failed(problems) + self.effective += burst + problems = self.run_burst(burst) + if problems and stop_at_first: + return self.failed(problems) + return None + + def failed(self, problems): + oracle, detail = problems[0] + return {"oracle": oracle, "detail": detail, "index": len(self.effective), "sequence": list(self.effective), + "all": [f"{o}:{d}" for o, d in problems]} + + def ensure_target(self, burst): + """The layout set acts on a workspace: when none shows (launch, or a close took the last pane), a Ctrl-` first + (it opens a workspace with a terminal on Home), played and kept in the sequence like any action.""" + if self.set != "layout" or burst[0]["a"] in ("new_terminal", "relaunch", "close_all"): + return None + if shown_ws(state(self.j)): + return None + return [{"a": "new_terminal", "t": "slow", "setup": True}] + + def reset(self): + """Back to the baseline workspaces for a shrink try (the app's own closeWorkspace).""" + self.close_all() + self.last_create = None + self.n += 1 + + +def _short(value, limit=600): + text = json.dumps(value, default=str) + return value if len(text) <= limit else {"truncated": text[:limit]} + + +def step_fuzz_run(j, step): + fz = Fuzz(j, step) + j.fuzz = fz + started = D.now() + first = fz.play_sequence(fz.sequence) + budget_out = first == "budget" + failure = None if budget_out else first + played = sum(len(b["burst"]) for b in fz.played) + shrunk, tries = None, 0 + if failure and fz.shrink: + prefix = failure["sequence"] + + def fails(cand): + if not fz.left(): + return None + fz.reset() + got = fz.play_sequence(cand) + if got == "budget": + return None + return bool(got) and got["oracle"] == failure["oracle"] + shrunk, tries = ddmin(prefix, fails, fz.left) + report = {"seed": fz.seed, "source": fz.source, "diagnostic": fz.diagnostic, "planned": len(fz.sequence), + "played": played, "seconds": round(D.now() - started, 1), "failure": failure, + "shrunk": shrunk, "shrink_tries": tries, "budget_out": budget_out, "speed": fz.speed, + "notes": fz.notes[-200:], "leak_baseline": fz.leak_base, "log": fz.played[-400:], + "sequence": getattr(fz, "effective", []) if failure is None else failure["sequence"]} + (j.out / "fuzz.json").write_text(json.dumps(report, indent=1, default=str) + "\n") + j.fuzz_report = report + if failure: + raise D.StepError(f"FUZZ {failure['oracle']} after action {failure['index']} (seed {fz.seed}): {failure['detail'][:300]}" + f" | shrunk to {len(shrunk or failure['sequence'])} actions: {json.dumps(shrunk or failure['sequence'])[:600]}") + return {"seed": fz.seed, "played": played, "speed_samples": len(fz.speed), "seconds": report["seconds"]} + + +def cond_fuzz_clean(j, want, cache): + report = getattr(j, "fuzz_report", None) + if report is None: + return False, {"error": "no fuzz_run before this check"} + return report["failure"] is None, {"seed": report["seed"], "played": report["played"], "failure": report["failure"]} + + +def cond_fuzz_daemon_agrees(j, want, cache): + fz = getattr(j, "fuzz", None) + if fz is None: + return False, {"error": "no fuzz_run before this check"} + found = fz.topology(state(j)) + return not found, {"problems": [d for _, d in found]} + + +STEPS = {"fuzz_run": step_fuzz_run} +CONDITIONS = {"fuzz_clean": ("app", cond_fuzz_clean), "fuzz_daemon_agrees": ("daemon", cond_fuzz_daemon_agrees)} diff --git a/scripts/journeys/ext_multiwindow.py b/scripts/journeys/ext_multiwindow.py new file mode 100644 index 000000000000..091688de4363 --- /dev/null +++ b/scripts/journeys/ext_multiwindow.py @@ -0,0 +1,550 @@ +"""Multi-window steps and conditions for the journeys (group multi-window, journeys/p0/mw-*.json). + +driver.py loads this file (load_extensions) on the mini, under the stock /usr/bin/python3 (3.9, standard library +only). It must not import driver.py (that runs as __main__ there): install() gets the driver module instead. + +Lawrence 2026-10-28 (handoff/multiwindow.md): "keyboard shortcuts dont work, and clicking on home/workspaces might +bring me to the wrong window. we need to completely eliminate this error class". Each mw-* journey does one thing in +one window (the TARGET) and judges two things: the target changed as the person asked, and every OTHER window did +not change at all (what it shows, its sidebar rows, its panes and tabs, its focused pane, its frame). + +What a window shows is read from the app's own state, three reads per poll: + + - `debug.windows`: each window's id (the window state id that `debug.key`, `debug.mouse` and `debug.layers` take), + `open`, `visible`, `selected` (the workspace it shows), `shown_page` (`home` for classic Home's top page); + - `snapshot.get` topology: each workspace's name and its panes and tabs (with each pane's selected tab), each + window's public id (`win_...`) and focused pane; + - `debug.sidebar_rows`: each window's sidebar rows (the workspaces it lists) and items (Home, with `active` while + the window shows it: docked Home is the home workspace, which the rows leave out while the Home item shows it), + with their frames in window points from the top-left, which `click_sidebar` clicks with `debug.mouse`; + - `debug.layers`: each window's frame. + +The oracle's `windows` facet (`debug.oracle.snapshot {facets: ["windows"]}`) has frames and the key window but not +what a window shows, so these reads are used instead. + +Steps (`do`): + name_window {as, shows?} names the journey's current window (the window of the current pane, or the + one a step adopted), or the window that shows workspace SHOWS; `key`, + `type` and `mouse` steps take the name as `window`. The first step of this + group in a journey records the windows open then (the baseline) + await_window {as, timeout?} waits for an open, visible window none of this group's steps knew (after + Cmd-Shift-N, the menu's New Window, `cmux app new-window`, a move to a new + window), makes it key (`debug.window.focus`, without activating the app) and + the window keys and frames go to, and names it + use_window {window | shows, as?, focus?} + makes a named window (or the window that shows workspace SHOWS, e.g. after a + relaunch) the one keys and frames go to; `focus` (default true): it comes to + the front and takes key, as a person's click on it does + remember_windows {as} keeps every open window's state as AS (for `window_unchanged`, `since`) and + sets the mark AS (for `expect`'s `since`) + click_sidebar {window, home: true | workspace: NAME, mark?, focus_first?} + a click (debug.mouse down and up) at the middle of the Home item or the + workspace row NAME in that window's own sidebar. The window is NOT made key + first: the key record stays whatever it was (a stale key window is the bug + class these journeys guard), so the click alone must name its window. + `focus_first: true` (opt-in, off in the mw- journeys) makes it key first + move_workspace {workspace: NAME, to: WINDOW | "new", as?, mark?} + `action.run moveWorkspaceToWindow` (target the workspace, argument `window` + the named window), the action the sidebar row's Move to Window menu and the + palette run; `to: new` runs moveWorkspaceToNewWindow and, with `as`, waits + for the new window and names it (as await_window) + run_cli {args: [...], mark?} the app's bundled `cmux` CLI on the app's own control socket + (`--app-socket`, `--json`), as a script runs it + close_extra_windows {keep_listing?: [NAME]} + clean-up: Ctrl-Cmd-W (closeWindow) in every window that was not open at the + baseline and shows no KEEP_LISTING row; when no baseline window id is open any + more (a relaunch gave new ids), also every window that shows a row a baseline + window showed (less the workspaces move_workspace moved) stays; never the + last window + +Conditions (inside `expect.all`; evidence kind app): + window_state {window, ...} the named window now: `open` (bool), `home` (true: it shows Home, the + classic top page or the docked Home's workspace with the Home item active; + false: it does not), `workspace` NAME (it shows that workspace and not Home), + `lists` / `lacks` [NAME] (its sidebar lists / does not list them), + `first_entry` (it shows its sidebar's first entry: the first top item, or the + first workspace row, whichever numbering Cmd-1 uses), and against `since` + MARK: `dpanes`, `dtabs` (change of the shown workspace's pane and tab counts), + `same_workspace` (shows the workspace it showed), `selected_changed` (a pane's + selected tab changed) + window_unchanged {since, window? | windows? | except?, ignore?} + NEGATIVE (read every poll and through `settle`): each window remembered at + SINCE (all of them, or `window` / `windows`, less `except`) is still open and + shows the same page and workspace, the same Home state, the same sidebar rows, + the same panes with the same tabs and selected tabs, the same focused pane and + the same frame (2 points); `ignore` drops fields (`rows`, `frame`, ...) + windows_open {count? | baseline_plus? | since?, delta?} + the number of open, visible windows: COUNT, the baseline's plus N, or the + number at SINCE plus DELTA (default 0) + windows_show {names: [NAME]} each workspace NAME is shown (not Home) in an open window, each in its own +""" +import json +import time + +D = None # the driver module (install) + +# Built-in sidebar items that open no page (launchers): never the "first entry" a Cmd-1 shows. +LAUNCHERS = {"new_terminal", "new_browser", "new_agent_chat", "customize", "search_chats"} + + +def install(driver): + global D + D = driver + + +# ---- reads ----------------------------------------------------------------------------------- + +def _rect_center(frame): + if isinstance(frame, dict) and frame.get("width"): + return frame["x"] + frame["width"] / 2.0, frame["y"] + frame["height"] / 2.0 + if isinstance(frame, list) and len(frame) == 4 and frame[2]: + return frame[0] + frame[2] / 2.0, frame[1] + frame[3] / 2.0 + return None + + +def _y_of(frame): + if isinstance(frame, dict): + return (frame.get("y", 0), frame.get("x", 0)) + if isinstance(frame, list) and len(frame) == 4: + return (frame[1], frame[0]) + return (1e9, 1e9) + + +def read_state(j, cache=None): + """{window id: state} of every open window with a controller (see the module docstring).""" + if cache is not None and "mw_state" in cache: + return cache["mw_state"] + report = j.app.rpc("debug.windows") or {} + snap = j.app.rpc("snapshot.get") or {} + topo = snap.get("topology") if isinstance(snap, dict) else None + topo = topo or {} + sidebar = j.app.rpc("debug.sidebar_rows") or {} + layers = j.app.rpc("debug.layers") or {} + workspaces = {} + for ws in topo.get("workspaces") or []: + panes = [] + for screen in ws.get("screens") or []: + for pane in screen.get("panes") or []: + tabs = [t.get("id") for t in pane.get("tabs") or []] + selected = pane.get("selected_tab") or next((t.get("id") for t in pane.get("tabs") or [] if t.get("selected")), None) + panes.append({"id": pane.get("id"), "tabs": tabs, "selected": selected}) + rec = {"key": ws.get("key") or ws.get("id"), "id": ws.get("id"), "name": ws.get("name"), "panes": panes} + for k in (ws.get("key"), ws.get("id")): + if k: + workspaces[k] = rec + topo_windows = {w.get("key"): w for w in topo.get("windows") or [] if w.get("key")} + sides = {w.get("window"): w for w in sidebar.get("windows") or [] if isinstance(w, dict)} + frames = {w.get("id"): w.get("frame") for w in layers.get("windows") or [] if isinstance(w, dict)} + out = {} + for w in report.get("windows") or []: + wid = w.get("id") + if not wid or not w.get("open") or not w.get("has_controller"): + continue + side = sides.get(wid) or {} + items = [i for i in side.get("items") or [] if isinstance(i, dict)] + rows = [r for r in side.get("rows") or [] if isinstance(r, dict) and r.get("in_list", True) and not r.get("suppressed")] + home_item = next((i for i in items if i.get("ref_kind") == "built_in" and i.get("ref") == "home"), None) + ws = workspaces.get(w.get("selected") or "") + page = w.get("shown_page") + tw = topo_windows.get(wid) or {} + listed = [workspaces[k]["name"] if k in workspaces else k for k in w.get("workspaces") or []] + out[wid] = { + "id": wid, "public": tw.get("id"), "visible": bool(w.get("visible")), "page": page, + "selected": w.get("selected"), "workspace": ws["name"] if ws else None, + "home": page == "home" or bool(home_item and home_item.get("active")), + "home_item": home_item, "items": items, + "rows": [r.get("key") for r in rows], "row_titles": [r.get("title") for r in rows], "row_records": rows, + "listed": listed, "panes": ws["panes"] if ws else [], "focused_pane": tw.get("focused_pane"), + "frame": frames.get(wid), + } + if cache is not None: + cache["mw_state"] = out + return out + + +def open_visible(state): + return [wid for wid, s in state.items() if s["visible"]] + + +def compact(s): + """A window's state for evidence (no frames of every row).""" + if not s: + return None + return {"id": s["id"], "page": s["page"], "workspace": s["workspace"], "home": s["home"], "rows": s["row_titles"][:8], + "panes": [[len(p["tabs"]), p["selected"]] for p in s["panes"]], "focused_pane": s["focused_pane"], "frame": s["frame"]} + + +def _init(j): + """The baseline: the windows open at this group's first step in the journey.""" + if getattr(j, "mw_baseline", None) is not None: + return + state = read_state(j) + j.mw_baseline = set(state) + # The rows the baseline windows show (docked Home's workspace is no row: it moves to the window Home shows in). + j.mw_baseline_rows = {t for s in state.values() for t in s["row_titles"] if t} + j.mw_moved = set() + j.mw_known = set(state) + j.mw_marks = {} + if not hasattr(j, "named_windows"): + j.named_windows = {} + + +def window_id(j, ref): + _init(j) + ref = j.sub(ref) + if ref in j.named_windows: + return j.named_windows[ref] + if ref in read_state(j): + return ref + raise D.StepError(f"no window named {ref!r} (named: {sorted(j.named_windows)})") + + +def current_window(j): + """The journey's current window: the one a step adopted, else the window that shows the current pane.""" + for wid in (getattr(j, "ext_window", None), j.key_window): + if wid: + return wid + if j.pane: + report = j.app.rpc("debug.surfaces") or {} + layers = j.app.rpc("debug.layers") or {} + numbers = {str(w.get("window_number")): w.get("id") for w in layers.get("windows") or []} + for w in report.get("windows") or []: + for p in w.get("panes") or []: + if isinstance(p, dict) and p.get("pane") == j.pane: + return (p.get("grid") or {}).get("window") or numbers.get(str(w.get("window"))) + state = read_state(j) + return next(iter(state), None) + + +def shows_window(j, name, state=None): + state = state or read_state(j) + hits = [wid for wid, s in state.items() if s["workspace"] == name and not s["home"]] + if len(hits) != 1: + raise D.StepError(f"{len(hits)} windows show workspace {name!r}: {[compact(s) for s in state.values()]}") + return hits[0] + + +def make_current(j, wid, focus=True): + """Keys (`debug.key`), frames (`debug.window_snapshot`) and ext_window's pane reads go to window `wid`.""" + j.key_window = j.app.snapshot_window = wid + j.ext_window = wid + if focus: + return j.app.rpc("debug.window.focus", {"window": wid}) + return None + + +# ---- steps ----------------------------------------------------------------------------------- + +def step_name_window(j, step): + _init(j) + wid = shows_window(j, j.sub(step["shows"])) if step.get("shows") else current_window(j) + if not wid: + raise D.StepError("name_window: no current window") + j.named_windows[step["as"]] = wid + j.mw_known.add(wid) + return {"window": wid, "state": compact(read_state(j).get(wid))} + + +def await_new_window(j, name, timeout): + end = D.now() + float(timeout) + state = {} + while D.now() < end: + state = read_state(j) + new = [wid for wid in open_visible(state) if wid not in j.mw_known] + if new: + wid = new[0] + j.mw_known.add(wid) + focus = make_current(j, wid) + if name: + j.named_windows[name] = wid + return {"window": wid, "focus": focus, "state": compact(state[wid]), "open": len(open_visible(state))} + time.sleep(0.2) + raise D.StepError(f"no new window opened within {timeout} s (open: {[compact(s) for s in state.values()]})") + + +def step_await_window(j, step): + _init(j) + return await_new_window(j, step.get("as"), step.get("timeout", 20)) + + +def step_use_window(j, step): + _init(j) + wid = shows_window(j, j.sub(step["shows"])) if step.get("shows") else window_id(j, step["window"]) + if step.get("as"): + j.named_windows[step["as"]] = wid + j.mw_known.add(wid) + focus = make_current(j, wid, step.get("focus", True)) + return {"window": wid, "focus": focus} + + +def step_remember_windows(j, step): + _init(j) + state = read_state(j) + j.mw_marks[step["as"]] = state + # Also a mark of the same name, for `expect`'s `since` (a later step's `mark` of that name moves it). + j.marks[step["as"]] = D.now() + return {"windows": [compact(s) for s in state.values()]} + + +def sidebar_target(j, wid, step): + report = j.app.rpc("debug.sidebar_rows") or {} + side = next((w for w in report.get("windows") or [] if isinstance(w, dict) and w.get("window") == wid), None) + if side is None: + raise D.StepError(f"debug.sidebar_rows has no sidebar for window {wid}") + if step.get("home"): + item = next((i for i in side.get("items") or [] if i.get("ref_kind") == "built_in" and i.get("ref") == "home"), None) + if not item or not item.get("window_frame"): + raise D.StepError(f"window {wid}'s sidebar shows no Home item: " + f"{[(i.get('ref_kind'), i.get('ref'), bool(i.get('window_frame'))) for i in side.get('items') or []]}") + return item, "home" + name = j.sub(step["workspace"]) + rows = [r for r in side.get("rows") or [] if r.get("in_list", True) and not r.get("suppressed")] + row = next((r for r in rows if r.get("title") == name), None) + if row is None: + raise D.StepError(f"window {wid}'s sidebar lists no row {name!r}: {[r.get('title') for r in rows][:12]}") + if not row.get("window_frame") or row.get("view_frame") is None: + raise D.StepError(f"the row {name!r} has no view in window {wid} (scrolled off?): {json.dumps(row)[:300]}") + return row, name + + +def step_click_sidebar(j, step): + _init(j) + wid = window_id(j, step["window"]) + # Never key first unless asked: the click must act in its own window whatever window the app last had as key. + focus = make_current(j, wid, bool(step.get("focus_first", False))) + target, what = sidebar_target(j, wid, step) + center = _rect_center(target["window_frame"]) + if not center: + raise D.StepError(f"no frame to click for {what}: {target.get('window_frame')}") + if step.get("mark"): + j.marks[step["mark"]] = D.now() + reply = j.app.rpc("debug.mouse", {"window": wid, "action": "click", "x": center[0], "y": center[1]}) + if isinstance(reply, dict) and reply.get("error"): + raise D.StepError(f"debug.mouse: {reply['error']}") + return {"window": wid, "clicked": what, "at": center, "focus": focus, "reply": reply} + + +def public_window(j, wid): + pub = (read_state(j).get(wid) or {}).get("public") + if pub: + return pub + plain = wid.replace("-", "").lower() + return "win_" + plain if len(plain) == 32 else wid + + +def workspace_public(j, name): + snap = j.app.rpc("snapshot.get") or {} + topo = (snap.get("topology") if isinstance(snap, dict) else None) or {} + hits = [ws for ws in topo.get("workspaces") or [] if ws.get("name") == name] + if len(hits) != 1: + raise D.StepError(f"{len(hits)} workspaces are named {name!r}") + return hits[0].get("id") + + +def step_move_workspace(j, step): + _init(j) + name = j.sub(step["workspace"]) + j.mw_moved.add(name) + target = "workspace:" + workspace_public(j, name) + if step["to"] == "new": + params = {"action": "moveWorkspaceToNewWindow", "target": target, "focus": True} + else: + params = {"action": "moveWorkspaceToWindow", "target": target, "focus": True, + "args": {"window": "window:" + public_window(j, window_id(j, step["to"]))}} + if step.get("mark"): + j.marks[step["mark"]] = D.now() + reply = j.app.rpc("action.run", params) + out = {"params": params, "reply": reply} + if step["to"] == "new" and step.get("as"): + out["window"] = await_new_window(j, step["as"], step.get("timeout", 20)) + return out + + +def step_run_cli(j, step): + args = [j.sub(a) for a in step["args"]] + env = {"HOME": j.app.home, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "CMUX_QUIET": "1"} + if step.get("mark"): + j.marks[step["mark"]] = D.now() + done = D.subprocess.run([j.app.cli, "--app-socket", j.app.sock, "--json", *args], capture_output=True, text=True, + timeout=float(step.get("timeout", 30)), env=env) + if done.returncode != 0: + raise D.StepError(f"cmux {' '.join(args)}: exit {done.returncode}: {(done.stdout + done.stderr).strip()[-300:]}") + return {"argv": ["cmux", "--app-socket", "SOCKET", "--json", *args], "stdout": done.stdout.strip()[-400:]} + + +def step_close_extra_windows(j, step): + if getattr(j, "mw_baseline", None) is None: + return {"closed": [], "why": "no window was recorded"} + # A window that shows a row a baseline window showed (less the workspaces the journey moved) stays: after a + # relaunch the baseline window may have a new id. + state = read_state(j) + keep = {j.sub(n) for n in step.get("keep_listing") or []} + if not j.mw_baseline & set(state): + keep |= j.mw_baseline_rows - j.mw_moved + closing = [wid for wid, s in state.items() if wid not in j.mw_baseline and not (set(s["row_titles"]) & keep)] + closed, left = [], [] + for wid in closing: + if len(read_state(j)) <= 1: + left.append({"window": wid, "why": "the last open window"}) + continue + j.app.rpc("debug.key", {"key": "w", "modifiers": ["control", "cmd"], "window": wid}) + end = D.now() + float(step.get("timeout", 10)) + while D.now() < end and wid in read_state(j): + time.sleep(0.2) + (left if wid in read_state(j) else closed).append(wid) + # The window keys go to after: a baseline window still open. + rest = [wid for wid in read_state(j) if wid in j.mw_baseline] or list(read_state(j)) + if rest: + make_current(j, rest[0], focus=False) + return {"closed": closed, "left_open": left} + + +STEPS = {"name_window": step_name_window, "await_window": step_await_window, "use_window": step_use_window, + "remember_windows": step_remember_windows, "click_sidebar": step_click_sidebar, + "move_workspace": step_move_workspace, "run_cli": step_run_cli, "close_extra_windows": step_close_extra_windows} + + +# ---- conditions -------------------------------------------------------------------------------- + +def remembered(j, mark): + marks = getattr(j, "mw_marks", {}) + if mark not in marks: + raise D.StepError(f"no remember_windows step kept {mark!r}") + return marks[mark] + + +def tab_count(s): + return sum(len(p["tabs"]) for p in s["panes"]) + + +def first_entry_shown(s): + """Whether the window shows its sidebar's first entry: the first top item that opens a page (by its place), or + the first workspace row; which one Cmd-1 picks depends on the sidebar's numbering setting.""" + items = [i for i in s["items"] if i.get("window_frame") and i.get("region", "top") == "top" + and not (i.get("ref_kind") == "built_in" and i.get("ref") in LAUNCHERS)] + items.sort(key=lambda i: _y_of(i.get("window_frame"))) + rows = sorted(s["row_records"], key=lambda r: _y_of(r.get("window_frame"))) + first_item = items[0] if items else None + first_row = next((r for r in rows if str(r.get("key", "")).startswith("workspace")), rows[0] if rows else None) + item_shown = bool(first_item and first_item.get("active")) + row_shown = bool(first_row and first_row.get("title") == s["workspace"] and not s["home"]) + detail = {"first_item": first_item and [first_item.get("ref_kind"), first_item.get("ref"), first_item.get("active")], + "first_row": first_row and first_row.get("title")} + return item_shown or row_shown, detail + + +def cond_window_state(j, want, cache): + wid = window_id(j, want["window"]) + state = read_state(j, cache) + s = state.get(wid) + detail = {"window": wid, "now": compact(s)} + problems = [] + if "open" in want: + if bool(s) != bool(want["open"]): + problems.append(f"open is {bool(s)}") + elif s is None: + return False, dict(detail, problems=["the window is not open"]) + if s is not None: + if "home" in want and s["home"] != bool(want["home"]): + problems.append(f"home is {s['home']}") + if "workspace" in want and (s["workspace"] != want["workspace"] or s["home"]): + problems.append(f"shows {s['workspace']!r} (home {s['home']}), not {want['workspace']!r}") + for name in want.get("lists") or []: + if name not in s["row_titles"] and name not in s["listed"]: + problems.append(f"does not list {name!r}") + for name in want.get("lacks") or []: + if name in s["row_titles"] or name in s["listed"]: + problems.append(f"lists {name!r}") + if want.get("first_entry"): + ok, first = first_entry_shown(s) + detail["first_entry"] = first + if not ok: + problems.append("does not show its sidebar's first entry") + if want.get("since"): + before = remembered(j, want["since"]).get(wid) + if before is None: + problems.append(f"window {wid} was not open at {want['since']}") + else: + detail["before"] = compact(before) + if "dpanes" in want and len(s["panes"]) - len(before["panes"]) != int(want["dpanes"]): + problems.append(f"panes {len(before['panes'])} -> {len(s['panes'])}, not {int(want['dpanes']):+d}") + if "dtabs" in want and tab_count(s) - tab_count(before) != int(want["dtabs"]): + problems.append(f"tabs {tab_count(before)} -> {tab_count(s)}, not {int(want['dtabs']):+d}") + if "same_workspace" in want and (s["selected"] == before["selected"]) != bool(want["same_workspace"]): + problems.append(f"workspace {before['workspace']!r} -> {s['workspace']!r}") + if want.get("selected_changed"): + old = {p["id"]: p["selected"] for p in before["panes"]} + if not any(p["id"] in old and p["selected"] != old[p["id"]] for p in s["panes"]): + problems.append("no pane's selected tab changed") + detail["problems"] = problems + return not problems, detail + + +FIELDS = ("page", "selected", "home", "rows", "panes", "focused_pane", "frame") + + +def same_frame(a, b, tol=2.0): + if a is None or b is None: + return a == b + return all(abs(float(x) - float(y)) <= tol for x, y in zip(a, b)) + + +def cond_window_unchanged(j, want, cache): + before = remembered(j, want["since"]) + if want.get("window"): + ids = [window_id(j, want["window"])] + elif want.get("windows"): + ids = [window_id(j, w) for w in want["windows"]] + else: + ids = list(before) + ids = [w for w in ids if w not in {window_id(j, e) for e in want.get("except") or []}] + fields = [f for f in FIELDS if f not in set(want.get("ignore") or [])] + now = read_state(j, cache) + changed = {} + for wid in ids: + old, new = before.get(wid), now.get(wid) + if old is None: + changed[wid] = "not open at the mark" + continue + if new is None: + changed[wid] = "closed" + continue + diff = {} + for f in fields: + same = same_frame(old[f], new[f]) if f == "frame" else old[f] == new[f] + if not same: + diff[f] = {"was": old[f] if f != "rows" else old["row_titles"], "now": new[f] if f != "rows" else new["row_titles"]} + if diff: + changed[wid] = diff + names = {v: k for k, v in getattr(j, "named_windows", {}).items()} + return not changed, {"windows": [names.get(w, w) for w in ids], "changed": {names.get(k, k): v for k, v in changed.items()}} + + +def cond_windows_open(j, want, cache): + count = len(open_visible(read_state(j, cache))) + if "count" in want: + target = int(want["count"]) + elif "baseline_plus" in want: + target = len(getattr(j, "mw_baseline", set())) + int(want["baseline_plus"]) + else: + target = len(open_visible(remembered(j, want["since"]))) + int(want.get("delta", 0)) + return count == target, {"open": count, "want": target} + + +def cond_windows_show(j, want, cache): + state = read_state(j, cache) + where, problems = {}, [] + for name in want["names"]: + hits = [wid for wid, s in state.items() if s["visible"] and s["workspace"] == name and not s["home"]] + if len(hits) != 1: + problems.append(f"{name!r} shows in {len(hits)} windows") + else: + where[name] = hits[0] + if len(set(where.values())) != len(where): + problems.append("two workspaces show in one window") + return not problems, {"where": where, "problems": problems, "windows": [compact(s) for s in state.values()]} + + +CONDITIONS = {"window_state": ("app", cond_window_state), "window_unchanged": ("app", cond_window_unchanged), + "windows_open": ("app", cond_windows_open), "windows_show": ("app", cond_windows_show)} +NEGATIVE = {"window_unchanged"} diff --git a/scripts/journeys/ext_restore.py b/scripts/journeys/ext_restore.py new file mode 100644 index 000000000000..9f14f67e0e6a --- /dev/null +++ b/scripts/journeys/ext_restore.py @@ -0,0 +1,315 @@ +"""Restart-restore steps and conditions for the journeys (group restart-restore). + +driver.py loads this file (load_extensions) on the mini, under the stock /usr/bin/python3 (standard library +only). It must not import driver.py: install() gets the driver module instead. + +Lawrence 2026-10-27 (handoff/restore-perf.md): cmux NIGHTLY relaunched with ~60 workspaces showed three visible +terminals as empty panes with a cursor box and "Restoring..." for seconds. The binding targets, Chrome's session +restore: the visible terminals show their last content in the first frame, never an empty pane; no "Restoring..." +pill unless the attach is missing after 1 s, and then over the last content; live within 300 ms p95 warm and 1 s +cold; hidden workspaces attach on first show. + +Steps (`do`): + seed_workspaces {count, prefix?} set-up, not under test: COUNT more workspaces of one terminal each, + made through the daemon (`cmux workspace create`), each printing + one line; the current terminal's workspace is shown again after + close_seeded {prefix} clean-up: closes the workspaces seed_workspaces made + require_drawable {timeout?} `not run` (with the window server's order) while the app's window is + occluded on the shared capture host: frame checks cannot judge it + restore_diagnose {} evidence (a `finally` step): debug.surfaces/input/timings/windows, + a window snapshot and a 3 s sample of the app, as diag-* files + restore_relaunch {how?, mark?} the driver's `relaunch` (Cmd-Q, reopen through LaunchServices), with + the visible panes sampled from the new process's first socket answer + (`debug.surfaces`, about every 25 ms) until every visible terminal + is live or 20 s pass; the launch marks are read at the end + (`debug.timings`). The conditions below judge that record. + +Conditions (inside `expect.all`; evidence kind app): + restore_no_blank {pill_over_blank?: 0} every sample showed each visible terminal pane with content (its + held last frame or its replayed screen), never an empty surface; + and the last frame was up by the first window frame + (`terminal.launch_frame_shown` <= `first_window_frame_committed`, + or the first frame already drew live content); a "Restoring..." + pill was never over an empty pane + restore_live {within_ms} every visible terminal pane was live (attach live, link connected, + its screen replayed) within WITHIN_MS of the app's `main_start` (the + process start is in the detail: before main_start a capture slot's + clone can wait 2-3 s on amfid, a cost of the slot's ad-hoc signature) + restore_hidden_lazy {max} the app attached at most MAX terminal views when restore settled + (`debug.surfaces` live_terminals): hidden workspaces do not attach +""" +import ctypes +import json +import threading +import time + +D = None # the driver module (install) + + +def install(driver): + global D + D = driver + + +def step_seed_workspaces(journey, step): + count = int(step["count"]) + prefix = journey.sub(step.get("prefix", "jr-bg")) + made = 0 + for index in range(1, count + 1): + ws = journey.app.cmux("workspace", "create", "--name", f"{prefix}-{index}") + term = ws.get("terminal_id") if isinstance(ws, dict) else None + if term: + journey.app.cmux("terminal", term, "write", "--text", f"echo {prefix}-{index}-{journey.nonce}\n") + made += 1 + # The journey's own workspace is the shown one again (as a person left it before the quit). + place = journey.place_of(journey.need_term()) + if not place: + raise D.StepError("seed_workspaces: the current terminal is in no workspace") + journey.app.cmux("workspace", place[0].get("id"), "focus") + journey.app.cmux("pane", place[1].get("id"), "focus") + return {"made": made, "of": count, "workspace": place[0].get("name")} + + +class _Sampler(threading.Thread): + """Samples the visible panes of the relaunched app (any pid but `old`) until each visible terminal is live.""" + + def __init__(self, app, old, seconds=20.0): + super().__init__(daemon=True) + self.app, self.old, self.seconds = app, old, seconds + self.stop = threading.Event() + self.samples, self.pid, self.first_answer = [], None, None + + def run(self): + end = D.now() + 90 + while not self.stop.is_set() and D.now() < end: + try: + info = self.app.rpc("system.identify", timeout=1) + if isinstance(info, dict) and info.get("pid") and int(info["pid"]) != self.old: + self.pid, self.first_answer = int(info["pid"]), time.time() + break + except (D.StepError, OSError, ValueError): + pass + time.sleep(0.005) + if not self.pid: + return + end = D.now() + self.seconds + while not self.stop.is_set() and D.now() < end: + t = time.time() # wall: the kernel's process start is on it + try: + report = self.app.rpc("debug.surfaces", {"text": True}, timeout=5) or {} + except (D.StepError, OSError, ValueError): + time.sleep(0.02) + continue + panes = [p for w in report.get("windows", []) for p in w.get("panes", []) if isinstance(p, dict) and p.get("visible")] + rows = [] + for p in panes: + surface = p.get("surface") or {} + replayed = bool(surface.get("replay_applied")) + held = bool(p.get("frame_held")) + rows.append({"pane": p.get("pane"), "kind": p.get("kind"), "held": held, "replayed": replayed, + "text": bool((p.get("text") or "").strip()), "link": p.get("link"), "attach": p.get("attach"), + "status": p.get("status_text")}) + self.samples.append({"t": t, "panes": rows}) + terms = [r for r in rows if r["kind"] == "terminal"] + if terms and all(r["attach"] == "live" and r["link"] == "connected" and r["replayed"] for r in terms): + break + time.sleep(0.02) + + +class _ProcBSDInfo(ctypes.Structure): + _fields_ = [("pbi_flags", ctypes.c_uint32), ("pbi_status", ctypes.c_uint32), ("pbi_xstatus", ctypes.c_uint32), + ("pbi_pid", ctypes.c_uint32), ("pbi_ppid", ctypes.c_uint32), ("pbi_uid", ctypes.c_uint32), + ("pbi_gid", ctypes.c_uint32), ("pbi_ruid", ctypes.c_uint32), ("pbi_rgid", ctypes.c_uint32), + ("pbi_svuid", ctypes.c_uint32), ("pbi_svgid", ctypes.c_uint32), ("rfu_1", ctypes.c_uint32), + ("pbi_comm", ctypes.c_char * 16), ("pbi_name", ctypes.c_char * 32), ("pbi_nfiles", ctypes.c_uint32), + ("pbi_pgid", ctypes.c_uint32), ("pbi_pjobc", ctypes.c_uint32), ("e_tdev", ctypes.c_uint32), + ("e_tpgid", ctypes.c_uint32), ("pbi_nice", ctypes.c_int32), ("pbi_start_tvsec", ctypes.c_uint64), + ("pbi_start_tvusec", ctypes.c_uint64)] + + +def _process_start(pid): + """Wall-clock seconds when the kernel started `pid` (PROC_PIDTBSDINFO, as the app's own launch clock), or None.""" + try: + libproc = ctypes.CDLL("/usr/lib/libproc.dylib") + except OSError: + return None + info = _ProcBSDInfo() + if libproc.proc_pidinfo(pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info)) != ctypes.sizeof(info): + return None + return info.pbi_start_tvsec + info.pbi_start_tvusec / 1e6 + + +def step_restore_relaunch(journey, step): + sampler = _Sampler(journey.app, journey.app.pid) + sampler.start() + try: + record = journey.step_relaunch(step) + finally: + sampler.join(timeout=30) + sampler.stop.set() + try: + timings = journey.app.rpc("debug.timings", timeout=10) or {} + except (D.StepError, OSError, ValueError): + timings = {} + marks = timings.get("launch_ms_since_process_start") or {} + try: + settled = journey.app.rpc("debug.surfaces", timeout=10) or {} + except (D.StepError, OSError, ValueError): + settled = {} + # Every time is ms since the process start (the launch marks' zero); the samples carry wall time, the clock + # the kernel's start time is on. + zero = sampler.first_answer + start = _process_start(sampler.pid) if sampler.pid else None + first_live = {} + blank, pill_over_blank = 0, 0 + for sample in sampler.samples: + for row in sample["panes"]: + if row["kind"] != "terminal": + continue + content = row["held"] or (row["replayed"] and row["text"]) + if not content: + blank += 1 + if "Restoring" in (row["status"] or ""): + pill_over_blank += 1 + if row["attach"] == "live" and row["link"] == "connected" and row["replayed"] and row["pane"] not in first_live: + first_live[row["pane"]] = sample["t"] + live_ms = {pane: round((t - start) * 1000, 1) for pane, t in first_live.items()} if start else {} + # The time before `main_start` is the system's (dyld, and in a capture slot amfid checking the clone's ad-hoc + # signed frameworks: 2-3 s in about half the launches, 2026-10-27, cmux17 log `amfid ... Sparkle.framework not + # valid`), not the app's: `live` is judged from the app's own start, and the process start is kept beside it. + main_start = marks.get("main_start") + live_app_ms = {pane: round(ms - main_start, 1) for pane, ms in live_ms.items()} if main_start is not None else {} + journey.restore = { + "samples": len(sampler.samples), "blank_pane_samples": blank, "pill_over_blank": pill_over_blank, + "first_window_frame_committed": marks.get("first_window_frame_committed"), + "launch_frame_shown": marks.get("terminal.launch_frame_shown"), + "first_terminal_frame": marks.get("first_terminal_frame"), + "sidebar_rows_shown": marks.get("sidebar_rows_shown"), + "live_ms": live_ms, "main_start": main_start, "live_from_main_ms": live_app_ms, "visible_terminals": len({r["pane"] for s in sampler.samples for r in s["panes"] if r["kind"] == "terminal"}), + "live_terminals": settled.get("live_terminals"), + "socket_ms": round((zero - start) * 1000, 1) if zero and start else None, + "trace": [[round((s["t"] - start) * 1000, 1) if start else None, + [[r["pane"], int(r["held"]), int(r["replayed"]), r["link"], r["attach"], r["status"]] for r in s["panes"]]] + for s in sampler.samples[:400]], + } + record["restore"] = {k: v for k, v in journey.restore.items() if k != "trace"} + (journey.out / "restore-trace.json").write_text(json.dumps(journey.restore, indent=1) + "\n") + return record + + +def step_close_seeded(journey, step): + """Clean-up: closes every workspace whose name starts with PREFIX (the seeded ones), so the next journey's + app restores no extra workspaces.""" + prefix = journey.sub(step.get("prefix", "jr-bg")) + closed = 0 + for ws in journey.topology().get("workspaces", []): + if (ws.get("name") or "").startswith(prefix): + journey.app.cmux("workspace", ws.get("id"), "close") + closed += 1 + return {"closed": closed} + + +# Front to back: one line per on-screen window at layer 0 (owner, pid, x, y, w, h). +WINDOW_ORDER_JXA = """ +ObjC.import('CoreGraphics'); +const list = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, 0))); +list.filter(w => w.kCGWindowLayer === 0).map(w => [w.kCGWindowOwnerName, w.kCGWindowOwnerPID, w.kCGWindowBounds.X, + w.kCGWindowBounds.Y, w.kCGWindowBounds.Width, w.kCGWindowBounds.Height].join(' ')).join('\\n'); +""" + + +def step_restore_diagnose(journey, step): + """Evidence of what the window draws now, whatever the journey's result (a `finally` step): the full + `debug.surfaces` (each pane's frame hold, surface, drawing, layers and render counters), `debug.input`, a + window snapshot, the launch marks, and a 3 s `sample` of the app process. For the case seen once on the + nightly (2026-10-28, run 1): the app's text and link were live but the window showed all 3 panes empty.""" + out = journey.out + saved = {} + for name, method, params in (("surfaces", "debug.surfaces", {"text": True}), ("input", "debug.input", {}), + ("timings", "debug.timings", {}), ("windows", "debug.windows", {})): + try: + value = journey.app.rpc(method, params, timeout=30) + except (D.StepError, OSError, ValueError) as error: + value = {"error": str(error)[-300:]} + (out / f"diag-{name}.json").write_text(json.dumps(value, indent=1, default=str) + "\n") + saved[name] = True + try: + journey.app.snapshot(out / "diag-window.png") + saved["window"] = True + except (D.StepError, OSError, ValueError) as error: + saved["window"] = str(error)[-200:] + # The window server's on-screen windows, front to back (owner, pid, layer, bounds): whether another app's + # window covers ours, which AppKit then reports occluded and the terminals stop drawing (by design). + order = D.subprocess.run(["/usr/bin/osascript", "-l", "JavaScript", "-e", WINDOW_ORDER_JXA], + capture_output=True, text=True, timeout=60) + (out / "diag-order.txt").write_text(order.stdout + order.stderr) + done = D.subprocess.run(["/usr/bin/sample", str(journey.app.pid), "3", "-mayDie", "-file", str(out / "diag-app-sample.txt")], + capture_output=True, text=True, timeout=60) + saved["sample"] = done.returncode == 0 + return saved + + +def step_require_drawable(journey, step): + """Frame checks judge what a person sees only in a window on screen. A capture mini's GUI login is shared by + its slots and keeps other apps' windows (another slot's cmux, Safari, Terminal); `open -g` can reopen our + window fully behind them. AppKit then reports it occluded and the terminals stop drawing by design, so the + window's own snapshot shows its panes empty though the text and the link are live (proven 6 of 6 on cmux17, + 2026-10-28). Such a run is `not run`, with the window server's order as evidence, never a pass or a fail.""" + end = D.now() + float(step.get("timeout", 5)) + while True: + panes = [p for p in journey.app.panes() if p.get("visible") and p.get("kind", "terminal") == "terminal"] + covered = [p.get("pane") for p in panes if p.get("window_occluded")] + if not covered: + return {"panes": len(panes), "occluded": 0} + if D.now() >= end: + break + time.sleep(0.25) + order = D.subprocess.run(["/usr/bin/osascript", "-l", "JavaScript", "-e", WINDOW_ORDER_JXA], + capture_output=True, text=True, timeout=60) + (journey.out / "diag-order.txt").write_text(order.stdout + order.stderr) + front = [line for line in order.stdout.splitlines() if f" {journey.app.pid} " not in line][:6] + raise D.NotRun(f"the app's window is covered on the shared capture host (AppKit occluded, {len(covered)} visible " + f"terminal pane(s) paused); in front of it: {front}") + + +def _restore(journey): + record = getattr(journey, "restore", None) + if record is None: + raise D.StepError("no restore_relaunch before this check") + return record + + +def cond_restore_no_blank(journey, want, cache): + r = _restore(journey) + first_frame, shown, live = r["first_window_frame_committed"], r["launch_frame_shown"], r["first_terminal_frame"] + in_first_frame = first_frame is not None and ( + (shown is not None and shown <= first_frame + 0.5) or (live is not None and live <= first_frame + 0.5)) + limit = int((want or {}).get("pill_over_blank", 0)) if isinstance(want, dict) else 0 + ok = r["samples"] > 0 and r["blank_pane_samples"] == 0 and r["pill_over_blank"] <= limit and in_first_frame + return ok, {"samples": r["samples"], "blank_pane_samples": r["blank_pane_samples"], + "pill_over_blank": r["pill_over_blank"], "first_window_frame_committed": first_frame, + "launch_frame_shown": shown, "first_terminal_frame": live, "last_content_in_first_frame": in_first_frame} + + +def cond_restore_live(journey, want, cache): + r = _restore(journey) + within = float(want["within_ms"] if isinstance(want, dict) else want) + live = r["live_from_main_ms"] + worst = max(live.values()) if live else None + ok = bool(live) and len(live) >= r["visible_terminals"] and worst <= within + return ok, {"live_from_main_ms": live, "worst_ms": worst, "within_ms": within, "main_start": r["main_start"], + "live_from_process_start_ms": r["live_ms"], "visible_terminals": r["visible_terminals"]} + + +def cond_restore_hidden_lazy(journey, want, cache): + r = _restore(journey) + most = int(want["max"] if isinstance(want, dict) else want) + got = r["live_terminals"] + return got is not None and got <= most, {"live_terminals": got, "max": most} + + +STEPS = {"seed_workspaces": step_seed_workspaces, "restore_relaunch": step_restore_relaunch, + "close_seeded": step_close_seeded, "restore_diagnose": step_restore_diagnose, + "require_drawable": step_require_drawable} +CONDITIONS = {"restore_no_blank": ("app", cond_restore_no_blank), "restore_live": ("app", cond_restore_live), + "restore_hidden_lazy": ("app", cond_restore_hidden_lazy)} diff --git a/scripts/journeys/ext_smoke.py b/scripts/journeys/ext_smoke.py new file mode 100644 index 000000000000..404bc841e2a0 --- /dev/null +++ b/scripts/journeys/ext_smoke.py @@ -0,0 +1,667 @@ +"""Dogfood smoke-gate steps and conditions for the journeys (group dogfood-smoke, journeys/dogfood/smoke-dogfood.json). + +driver.py loads this file (load_extensions) on the mini, under the stock /usr/bin/python3 (3.9, standard library +only). It must not import driver.py: install() gets the driver module instead. + +cmux-ad 2026-10-30 (stop the line, item 2): a build goes to Lawrence only when the smoke journey passes 10 of 10 +rounds under memory pressure. Most of it uses the driver's own steps; these add what it lacks: + +Steps (`do`): + smoke_make_key {} makes the journey's window the app's key window without activating the app + (`debug.window.focus`: orderFrontRegardless + makeKey) and records whether it + took (`key` == the window). A capture slot's window is never key otherwise + (CMUX_NEXT_NO_ACTIVATE), and keys the app holds for a terminal still being made + are replayed through NSApplication.sendEvent, which reaches only the key window. + When the window did not become key, every later `smoke_type_now` is "not run: + known gap" (the slot's limit), never a pass. + smoke_type_now {text, bind?} types TEXT and Return into the terminal that was just made, at once: no wait for + the creation hold (the driver's typing waits 2.5 s after a new terminal; this is + the case Lawrence reports, keys into a terminal while it is created). Binds + ${BIND} to the text. Needs `smoke_make_key` first in the journey; reads again + that a main window is key (debug.window_list): another app window key is a + fail (the app took the keys elsewhere), no app window key is "not run: known + gap" -- or, with the journey option smoke_mode=diagnostic, the keys are typed + after the creation hold and the round goes on (never a gate pass). + smoke_ready {timeout?} waits (default 60 s) for a main window, a loaded topology and a connected daemon + smoke_remember_workspaces {} keeps the workspace ids the app has now (the baseline) + smoke_snapshot {name} keeps, before the keys that make terminals, the daemon terminals of the + topology, each pane's tabs and the focused pane (smoke_new_terminals reads it) + smoke_close_all {mark?} closes every workspace not in the baseline through the app's own + closeWorkspace action, as the sidebar's close does + +Conditions (inside `expect.all`; evidence kind app): + smoke_workspaces {new, same_as?} the topology has exactly NEW workspaces beyond the baseline; SAME_AS (a + smoke_snapshot name): and they are the ones that snapshot had (the restore + keeps their ids) + smoke_frame {min_lines?, lacks?} + (evidence frame) one fresh window frame, kept as check-NN.png, that is not blank + (text recognition reads at least MIN_LINES, default 3, lines) and shows none of + LACKS. The screenshot of a step whose state the app's reads prove + smoke_panes {line, count} the workspace whose terminal shows LINE has COUNT panes + smoke_key_window {} a main window of the app is its key window (debug.window_list; a read only) + smoke_new_terminals {since, lines, focused, placement} + the keys went to the new terminals, not to an old one: exactly len(LINES) new + daemon terminals since the snapshot SINCE; each line is on exactly one terminal + of the whole topology, that terminal is new, and no two lines share one; the + line FOCUSED is on the focused terminal. PLACEMENT: `new_workspaces` (each in + its own workspace not in the snapshot), `new_panes` (each in its own pane not in + the snapshot) or `focused_pane` (all tabs of the pane focused at the snapshot, + which now has len(LINES) more tabs) + smoke_app_lines {lines} every line is a line of some pane's viewport text in the app (any pane) + smoke_daemon_lines {lines} (evidence daemon) every line is a line of some terminal's daemon screen + +R3a (terminal resize) and R3b (new columns never resize old ones), cmux-ad orders 3 and 4 (2026-10-31): + smoke_adopt_line {line, as} makes the terminal whose daemon screen shows LINE the current one and names + its pane AS (ext_window's name_pane), so divider_drag, window_drag, remember + and typing can use it + smoke_long_line {tag, longer_than: [NAME...]} + waits for the prompt, then prints one line of TAG and x's longer than the widest + grid of the current pane, the named panes and the remembered ones (+15 cells), so + it must wrap; fails when the focused pane is not the current one + smoke_widths {name} keeps the width and panes of every column of the current pane's workspace +Conditions: + smoke_r3 {pane, pty_tag, long_tag?, vs?, cols?: wider|narrower} + R3: the pane's grid == fitted grid == drawn grid == fit(the view's frame at the + cell size) == the PTY's size (`echo PTY_TAG $(stty size)`) == the daemon's size + (screen read) == the topology tab's columns x rows; all sizes in the result. With + LONG_TAG the long line wraps at the NEW width: its first screen row is exactly + `cols` long and the rest follows. VS/COLS: the grid is wider/narrower than the + remembered one. + smoke_same_widths {since, new} every column of the snapshot SINCE (matched by its panes) has EXACTLY its old width + (0.01 point; float view frames), and the workspace has NEW more columns; each old + and new width in the result +""" +import json +import time + +D = None # the driver module (install) +GAP = "known gap: the capture lease could not make the window key (debug.window.focus), so keys typed into a new " \ + "terminal while it is made cannot be checked here; not a pass" + + +def install(driver): + global D + D = driver + + +def _windows(j): + report = j.app.rpc("debug.windows") or {} + return [w for w in report.get("windows", []) if isinstance(w, dict)] + + +def _journey_window(j): + """The window the journey types in: its key window if a step named one, else the first window.""" + if j.key_window: + return j.key_window + windows = _windows(j) + if not windows: + raise D.StepError("debug.windows lists no window") + return windows[0].get("id") + + +def step_smoke_make_key(j, step): + window = _journey_window(j) + reply = j.app.rpc("debug.window.focus", {"window": window}) or {} + took = isinstance(reply, dict) and reply.get("key") == window + j.smoke_key_ok = took + j.smoke_key_reply = reply + return {"window": window, "reply": reply, "key_window_now": took} + + +def _diagnostic(j): + """`smoke_mode=diagnostic` (run.py --journey-option, the gate's --diagnostic): when no window of the app can be + key (a capture slot), type after the creation hold instead of at once, so the other steps are still checked. + Never a gate pass (smoke-gate.py writes no .pass file in this mode).""" + return (j.spec.get("options") or {}).get("smoke_mode") == "diagnostic" + + +def step_smoke_type_now(j, step): + key_now, windows = _main_key(j) + other = [w for w in windows if w["key"] is True and w["kind"] != "main"] + if not key_now and other: + # Another window of the app took the keys (a New Tab page's child window, a panel): the app's bug. + raise D.StepError(f"the keys would go to a window that is not a main window: {json.dumps(other)[:300]}") + waited = None + if not (getattr(j, "smoke_key_ok", False) and key_now): + if not any(w["kind"] == "main" for w in windows): + raise D.StepError(f"the app has no main window to type in: {json.dumps(windows)[:200]}") + if not _diagnostic(j): + # The slot's limit, not the build's: report it as not run (the gate counts that as no pass). + how = ("the window lost key status after smoke_make_key made it key" if getattr(j, "smoke_key_ok", False) + else f"debug.window.focus said {json.dumps(getattr(j, 'smoke_key_reply', None))[:200]}") + raise D.NotRun(GAP + f" ({how}; windows now {json.dumps(windows)[:200]})") + # Diagnostic: no key window, so held keys could not be replayed; type after the creation hold instead. + waited = j.outwait_creation_hold(getattr(j, "last_key_at", D.now())) + text = j.sub(step["text"]) + j.hold_from = None # at once (or, diagnostic, the hold already waited out above) + sent, replies = D.now(), [] + for char in text: + replies.append(j.key(char, [], j.key_window)) + replies.append(j.key("return", [], j.key_window)) + if step.get("bind"): # never `as`: the driver names the current terminal after any step with `as` + j.vars[step["bind"]] = text + return {"typed": text, "keys": len(replies), "at": round(sent, 3), "at_once": waited is None, "waited_hold_s": waited, + "handled_by": sorted({str(r.get("handled_by")) for r in replies if isinstance(r, dict)})} + + +def _workspace_ids(j): + return [ws.get("id") for ws in j.topology().get("workspaces", []) if ws.get("id")] + + +def step_smoke_ready(j, step): + """Waits (at most TIMEOUT s, default 60) until the app shows a main window and its topology is loaded with the + daemon connected: the moment a person would start to use it after the launch. Under memory pressure this can + take long; the wait is recorded, and an app that is not ready within TIMEOUT fails the round.""" + t0, last = D.now(), {} + end = t0 + float(step.get("timeout", 60)) + while D.now() < end: + windows = _windows(j) + snap = j.app.rpc("snapshot.get") or {} + topo = snap.get("topology") if isinstance(snap, dict) else None + topo = topo if isinstance(topo, dict) else {} + last = {"windows": len(windows), "loaded": topo.get("loaded"), "daemon_state": topo.get("daemon_state")} + if windows and topo.get("loaded") is True and topo.get("daemon_state") == "connected": + return dict(last, waited_s=round(D.now() - t0, 2)) + time.sleep(0.5) + raise D.StepError(f"the app was not ready {step.get('timeout', 60)} s into the journey " + f"(a main window, topology loaded, daemon connected): {json.dumps(last)}") + + +def step_smoke_remember_workspaces(j, step): + """Keeps the workspace ids the app has now, so later steps can count and close the ones the journey made + (workspaces made with the person's Cmd-N get the app's default names, so names cannot tell them apart).""" + j.smoke_baseline = set(_workspace_ids(j)) + return {"workspaces": sorted(j.smoke_baseline)} + + +def step_smoke_close_all(j, step): + """Closes every workspace the journey made (not in the remembered baseline) through the app's own + closeWorkspace action, as the sidebar's close does.""" + baseline = getattr(j, "smoke_baseline", None) + if baseline is None: + raise D.StepError("smoke_close_all needs smoke_remember_workspaces first") + if step.get("mark"): + j.marks[step["mark"]] = D.now() + closed, replies = [], [] + for wid in _workspace_ids(j): + if wid not in baseline: + replies.append(j.app.rpc("action.run", {"action": "closeWorkspace", "target": f"workspace:{wid}", + "args": {"confirm": True}})) + closed.append(wid) + return {"closed": closed, "replies": replies} + + +def cond_smoke_workspaces(j, want, cache): + """The app has exactly `new` workspaces beyond the remembered baseline; with `same_as` (a smoke_snapshot + name), they are the very workspaces (ids) that snapshot saw beyond the baseline: after a relaunch, the + restore kept them. Take that snapshot once the workspaces are confirmed by the daemon (a person's Cmd-N + first shows a stand-in whose id changes to the daemon's `ws_...` id), e.g. after their terminals ran.""" + baseline = getattr(j, "smoke_baseline", set()) + mine = sorted(w for w in _workspace_ids(j) if w not in baseline) + holds = len(mine) == int(want["new"]) + detail = {"new_workspaces": mine, "want": int(want["new"])} + if want.get("same_as"): + snap = getattr(j, "smoke_snapshots", {}).get(want["same_as"]) + before = None if snap is None else sorted(w for w in snap["workspaces"] if w not in baseline) + holds = holds and before is not None and mine == before + detail["before"] = before + return holds, detail + + +def _focused_pane(j): + """The pane id of the app's focused pane (debug.surfaces), or None.""" + return next((p.get("pane") for p in j.app.panes() if p.get("focused")), None) + + +def _layout(j): + """{terminal key: (workspace id, pane id, selected, daemon selector)} and {pane id: [tab ids]} of the topology + now. Terminals are keyed on the tab's `terminal_key` (the app's own terminal id, which stays the same when the + daemon's tab replaces a stand-in); the daemon screen is read through its `terminal` (the public id).""" + terms, tabs = {}, {} + for ws, pane, tab, term in j.places(j.topology()): + tabs.setdefault(pane.get("id"), []).append(tab.get("id")) + if term: + key = str(tab.get("terminal_key") or term) + terms[key] = (ws.get("id"), pane.get("id"), pane.get("selected_tab") == tab.get("id"), term) + return terms, tabs + + +def step_smoke_snapshot(j, step): + terms, tabs = _layout(j) + snap = {"terms": sorted(terms), "workspaces": sorted({t[0] for t in terms.values()} | set(_workspace_ids(j))), + "panes": sorted(tabs), "tabs": tabs, "focused_pane": _focused_pane(j)} + if not hasattr(j, "smoke_snapshots"): + j.smoke_snapshots = {} + j.smoke_snapshots[step["name"]] = snap + return {"terminals": len(snap["terms"]), "panes": len(snap["panes"]), "focused_pane": snap["focused_pane"]} + + +def cond_smoke_new_terminals(j, want, cache): + """The typed lines landed one each on the terminals the keys made (see the module text).""" + snap = getattr(j, "smoke_snapshots", {}).get(want["since"]) + if snap is None: + return False, {"error": f"no smoke_snapshot named {want['since']!r} ran before this check"} + terms, tabs = _layout(j) + focused_pane = _focused_pane(j) + screens = {} + for term, place in terms.items(): + try: + screens[term] = set(_lines(j.app.screen(place[3])["text"])) + except D.StepError: + screens[term] = set() + old = set(snap["terms"]) + new = sorted(t for t in terms if t not in old) + wanted = [j.sub(x) for x in want["lines"]] + holders = {line: [t for t in terms if line in screens[t]] for line in wanted} + problems = [] + if len(new) != len(wanted): + problems.append(f"{len(new)} new terminals, not {len(wanted)}") + for line, on in holders.items(): + if len(on) != 1: + problems.append(f"{line} is on {len(on)} terminals ({', '.join(on) or 'none'}), not exactly one") + elif on[0] in old: + problems.append(f"{line} is on the old terminal {on[0]} (the keys missed the new one)") + owners = [on[0] for on in holders.values() if len(on) == 1] + if len(set(owners)) != len(owners): + problems.append("two lines are on one terminal") + focus_line = j.sub(want["focused"]) if want.get("focused") else None + if focus_line and len(holders.get(focus_line, [])) == 1: + ws, pane, selected, _sel = terms[holders[focus_line][0]] + if not (pane == focused_pane and selected): + problems.append(f"{focus_line} is not on the focused terminal (focused pane {focused_pane}, line in " + f"pane {pane}, its selected tab: {selected})") + placement = want.get("placement") + places = [terms[t] for t in owners] + if placement == "new_workspaces": + ids = [t[0] for t in places] + if any(w in snap["workspaces"] for w in ids) or len(set(ids)) != len(ids): + problems.append(f"the lines are not each in its own new workspace: {ids}") + elif placement == "new_panes": + ids = [t[1] for t in places] + if any(p in snap["panes"] for p in ids) or len(set(ids)) != len(ids): + problems.append(f"the lines are not each in its own new pane: {ids}") + elif placement == "focused_pane": + ids = [t[1] for t in places] + target = snap["focused_pane"] + if any(p != target for p in ids): + problems.append(f"the lines are not all tabs of the pane focused before the keys ({target}): {ids}") + had, has = len(snap["tabs"].get(target, [])), len(tabs.get(target, [])) + if has != had + len(wanted): + problems.append(f"pane {target} has {has} tabs, not {had} + {len(wanted)}") + elif placement is not None: + problems.append(f"unknown placement {placement!r}") + return not problems, {"problems": problems, "new_terminals": new, "holders": holders, + "focused_pane": focused_pane, "placement": placement} + + +def _lines(text): + return [line.strip() for line in (text or "").split("\n")] + + +def cond_smoke_app_lines(j, want, cache): + """Every line in `lines` is a line of some pane's viewport text in the app (debug.surfaces): the output a + person sees, in whichever pane shows it.""" + panes = cache.setdefault("smoke_panes", j.app.panes()) + shown = {line for p in panes for line in _lines(p.get("text"))} + wanted = [j.sub(x) for x in want["lines"]] + missing = [x for x in wanted if x not in shown] + return not missing, {"missing": missing, "panes": len(panes)} + + +def cond_smoke_daemon_lines(j, want, cache): + """Every line in `lines` is a line of some terminal's screen in the daemon (each terminal of the topology): + the command ran, also in a tab that is not shown.""" + if "smoke_screens" not in cache: + screens = [] + for _ws, _pane, _tab, term in j.places(j.topology()): + if term: + try: + screens.append(j.app.screen(term)["text"]) + except D.StepError: + pass + cache["smoke_screens"] = screens + shown = {line for text in cache["smoke_screens"] for line in _lines(text)} + wanted = [j.sub(x) for x in want["lines"]] + missing = [x for x in wanted if x not in shown] + return not missing, {"missing": missing, "terminals": len(cache["smoke_screens"])} + + +def cond_smoke_frame(j, want, cache): + """A fresh frame of the window that is not blank (the step's screenshot; the app state proves the rest).""" + j.checks += 1 + image = j.app.snapshot(j.out / f"check-{j.checks:02d}.png") + lines = j.app.read_text(image) + present = [t for t in j.sub(want.get("lacks", [])) if D.ocr_contains(lines, t)] + return len(lines) >= int(want.get("min_lines", 3)) and not present, \ + {"image": image.name, "lines": len(lines), "present": present, "text": lines[-8:]} + + +def _main_key(j): + """(a main window is the app's key window, the app's windows): a read (debug.window_list), never a focus.""" + report = j.app.rpc("debug.window_list") or {} + windows = [{"id": w.get("id"), "kind": w.get("kind"), "key": w.get("key"), "visible": w.get("visible")} + for w in report.get("windows", []) if isinstance(w, dict)] + return any(w["key"] is True and w["kind"] == "main" for w in windows), windows + + +def cond_smoke_panes(j, want, cache): + """The workspace whose terminal shows LINE (a daemon screen line) has COUNT panes: the journey's own pane count + (the driver's app_layout needs a terminal the driver adopted, which a key-made terminal here is not).""" + line = j.sub(want["line"]) + topo = j.topology() + for ws, _pane, _tab, term in j.places(topo): + if not term: + continue + try: + shown = set(_lines(j.app.screen(term)["text"])) + except D.StepError: + continue + if line in shown: + panes = [p for sc in ws.get("screens", []) for p in sc.get("panes", [])] + return len(panes) == int(want["count"]), {"workspace": ws.get("id"), "panes": len(panes), "want": int(want["count"])} + return False, {"error": f"no terminal shows {line}"} + + +def cond_smoke_key_window(j, want, cache): + """A main window is the app's key window: a read (debug.window_list), so the check does not make it key.""" + key_now, windows = _main_key(j) + if not key_now and _diagnostic(j) and not getattr(j, "smoke_key_ok", False) \ + and not any(w["key"] is True for w in windows) and any(w["kind"] == "main" for w in windows): + # Diagnostic on a slot: no window was ever key; the step's other checks still count. + return True, {"windows": windows, "diagnostic": "no key window on this slot (not checked)"} + return key_now, {"windows": windows} + + + +# ---- R3a: resize (cmux-ad order 3) ------------------------------------------------------------ + +def _grid(text): + parts = str(text or "").split("x") + return (int(parts[0]), int(parts[1])) if len(parts) == 2 and all(x.isdigit() for x in parts) else None + + +def _surfaces(j, cache): + if "smoke_surfaces" not in cache: + cache["smoke_surfaces"] = j.app.panes() + return cache["smoke_surfaces"] + + +def _terminal_of_line(j, line): + """(topology pane id, daemon terminal, tab record) of the one terminal whose daemon screen shows LINE.""" + found = [] + for ws, pane, tab, term in j.places(j.topology()): + if not term: + continue + try: + if line in set(_lines(j.app.screen(term)["text"])): + found.append((pane.get("id"), term, tab, ws)) + except D.StepError: + continue + if len(found) != 1: + raise D.StepError(f"{len(found)} terminals show {line!r}, not exactly one") + return found[0] + + +def step_smoke_adopt_line(j, step): + pane, term, _tab, ws = _terminal_of_line(j, j.sub(step["line"])) + j.term, j.pane, j.host_pid = term, pane, None + rec = j.step_name_pane({"as": step["as"]}) + if not hasattr(j, "smoke_named"): + j.smoke_named = {} + j.smoke_named[step["as"]] = {"pane": pane, "term": term, "workspace": ws.get("id")} + return dict(rec, workspace=ws.get("id")) + + +def _named(j, name): + named = getattr(j, "smoke_named", {}) + if name not in named: + raise D.StepError(f"no pane named {name!r} (smoke_adopt_line first)") + return named[name] + + +def _pane_grid(j, pane_key, cache): + row = next((p for p in _surfaces(j, cache) if p.get("pane") == pane_key), None) + return row, _grid(((row or {}).get("surface") or {}).get("grid")) + + +# The bare prompts a key-made terminal shows: the capture lease's neutral shell home ("$ "), zsh's "%", root's "#", +# and the driver's neutral prompt ("J> "). +PROMPTS = ("$", "%", "#", "J>") + + +def _wait_prompt(j, timeout): + """Seconds until the current terminal's last non-empty daemon screen line is a bare prompt (PROMPTS).""" + t0 = D.now() + last = None + while D.now() - t0 < timeout: + lines = [l for l in _lines(j.app.screen(j.need_term())["text"]) if l] + last = lines[-1] if lines else None + if last in PROMPTS: + return round(D.now() - t0, 2) + time.sleep(0.1) + raise D.StepError(f"the prompt did not come back within {timeout} s (last screen line {last!r})") + + +def step_smoke_long_line(j, step): + focused = _focused_pane(j) + if focused != j.pane: + raise D.StepError(f"smoke_long_line: the focused pane is {focused}, not the current pane {j.pane}: the keys " + "would go elsewhere") + _row, current = _pane_grid(j, j.pane, {}) + widest = current[0] if current else 0 + for name in step.get("longer_than", []): + _row, grid = _pane_grid(j, _named(j, name)["pane"], {}) + widest = max(widest, grid[0] if grid else 0) + for kept in getattr(j, "ext_remembered", {}).values(): + if kept.get("grid"): + widest = max(widest, kept["grid"][0]) + if widest <= 0 or not current: + raise D.StepError(f"smoke_long_line: no grid width to exceed (current pane grid {current})") + tag = j.sub(step["tag"]) + length = widest + 15 + # As a person does: wait until the command before has finished and the prompt is back (the last screen line + # is the bare prompt), so these keys do not echo into that command's output. + waited = _wait_prompt(j, float(step.get("timeout", 10))) + # The shell prints TAG then x's to LENGTH characters; the typed command itself holds no such run. + text = f"printf '%s' {tag}; printf 'x%.0s' $(seq 1 {length - len(tag)}); echo" + replies = [j.key(char, [], j.key_window) for char in text] + [j.key("return", [], j.key_window)] + if not hasattr(j, "smoke_long"): + j.smoke_long = {} + j.smoke_long[tag] = length + return {"tag": tag, "length": length, "widest_before": widest, "keys": len(replies), "prompt_wait_s": waited} + + +def _pty_size(text, tag): + for line in _lines(text): + parts = line.split() + if len(parts) == 3 and parts[0] == tag and parts[1].isdigit() and parts[2].isdigit(): + return int(parts[2]), int(parts[1]) # stty size prints rows then cols + return None + + +def _wrap_rows(text, tag, length): + """The screen rows the long line takes: the row that starts with TAG, then the rows of x's after it.""" + rows = (text or "").split("\n") + for index, row in enumerate(rows): + row = row.rstrip() + if row.startswith(tag) and set(row[len(tag):]) <= {"x"}: + got = [row] + for more in rows[index + 1:]: + more = more.rstrip() + if more and set(more) <= {"x"} and sum(len(r) for r in got) < length: + got.append(more) + else: + break + return got + return None + + +def cond_smoke_r3(j, want, cache): + rec = _named(j, want["pane"]) + row, grid = _pane_grid(j, rec["pane"], cache) + surface, info = (row or {}).get("surface") or {}, (row or {}).get("grid") or {} + fit, drawn = _grid(surface.get("fit_grid")), _grid(surface.get("drawn_grid")) + bounds = None + cw, ch = info.get("cell_width"), info.get("cell_height") + if cw and ch and "frame_width" in info: + pad_x = max(0.0, info.get("origin_x", 0) - info.get("frame_x", 0)) + pad_y = max(0.0, info.get("origin_y", 0) - info.get("frame_y", 0)) + bounds = (int((info["frame_width"] - 2 * pad_x + 1e-6) // cw), int((info["frame_height"] - 2 * pad_y + 1e-6) // ch)) + screen = j.app.screen(rec["term"]) + daemon = (screen.get("cols"), screen.get("rows")) + topo_size = None + for _ws, pane, tab, term in j.places(j.topology()): + if term == rec["term"]: + topo_size = (tab.get("columns"), tab.get("rows")) + pty = _pty_size(screen.get("text"), j.sub(want["pty_tag"])) if want.get("pty_tag") else None + sizes = {"grid": grid, "fit_grid": fit, "drawn_grid": drawn, "fit_bounds": bounds, "pty": pty, "daemon": daemon, + "topology": topo_size} + detail = {"pane": rec["pane"], "terminal": rec["term"], "sizes": sizes, "frame": [info.get("frame_width"), info.get("frame_height")], + "cell": [cw, ch], "content_size": (row or {}).get("content_size")} + problems = [] + if row is None: + problems.append("the pane is not in debug.surfaces") + if not grid: + problems.append("no grid") + for name in ("fit_grid", "drawn_grid", "daemon", "topology"): + if sizes[name] != grid: + problems.append(f"{name} {sizes[name]} != grid {grid}") + if want.get("pty_tag") and pty != grid: + problems.append(f"pty {pty} != grid {grid}") + # fit(bounds): the view's frame at the cell size holds the grid (Ghostty may keep one cell of slack). + # fit(bounds): the cells the view's frame holds at the cell size, padding on both sides (floor, as Ghostty fits). + if bounds is None or not grid or tuple(bounds) != tuple(grid): + problems.append(f"grid {grid} != fit(bounds) {bounds}") + if want.get("vs") and grid: + base = (getattr(j, "ext_remembered", {}).get(want["vs"]) or {}).get("grid") + detail["vs"] = base + if not base: + problems.append(f"nothing remembered as {want['vs']}") + elif want.get("cols") == "wider" and not grid[0] > base[0]: + problems.append(f"cols {grid[0]} not wider than {base[0]}") + elif want.get("cols") == "narrower" and not grid[0] < base[0]: + problems.append(f"cols {grid[0]} not narrower than {base[0]}") + if want.get("long_tag"): + tag = j.sub(want["long_tag"]) + length = getattr(j, "smoke_long", {}).get(tag) + rows = _wrap_rows(screen.get("text"), tag, length or 0) + detail["long_line"] = {"length": length, "rows": [len(r) for r in rows] if rows else None} + if not length or not rows: + problems.append("the long line is not on the screen") + elif not grid or len(rows[0]) != min(length, grid[0]) or sum(len(r) for r in rows) != length: + problems.append(f"the long line ({length} cells) wraps at {len(rows[0])}, not at the new width {grid and grid[0]} " + f"(rows {[len(r) for r in rows]})") + detail["problems"] = problems + return not problems, detail + + +# ---- R3b: new columns never resize old ones (cmux-ad, Lawrence's P0 rule) ------------------------ + +def _widths(j, workspace, cache=None): + """{column index: width in points} of every column of WORKSPACE: the topology's `column` of each pane, and the + union of its panes' horizontal extents in the window (debug.surfaces: the view frame, floats; content_size, + whole points, only when a pane has no frame). Panes stacked in one column share its width.""" + panes = {p.get("pane"): p for p in (_surfaces(j, cache) if cache is not None else j.app.panes())} + extents = {} + for ws in j.topology().get("workspaces", []): + if ws.get("id") != workspace: + continue + for screen in ws.get("screens", []): + for pane in screen.get("panes", []): + column = pane.get("column") + row = panes.get(pane.get("id")) or {} + grid = row.get("grid") if isinstance(row.get("grid"), dict) else {} + if "frame_width" in grid: + x0, w = float(grid.get("frame_x", 0.0)), float(grid["frame_width"]) + else: + size = str(row.get("content_size") or "") + head = size.split("x")[0] if "x" in size else "" + if not head.replace(".", "", 1).isdigit(): + extents.setdefault(column, None) + continue + x0, w = None, float(head) + if extents.get(column, 0) is None: + continue + spans = extents.setdefault(column, []) + spans.append((x0, w)) + out = {} + for column, spans in extents.items(): + if not spans: + out[str(column)] = None + elif all(x0 is not None for x0, _w in spans): + out[str(column)] = round(max(x0 + w for x0, w in spans) - min(x0 for x0, _w in spans), 2) + else: + out[str(column)] = round(max(w for _x0, w in spans), 2) + return out + + +def _columns(j, workspace, cache=None): + """{column index: [pane ids]} of WORKSPACE (the topology's `column` of each pane).""" + out = {} + for ws in j.topology().get("workspaces", []): + if ws.get("id") == workspace: + for screen in ws.get("screens", []): + for pane in screen.get("panes", []): + out.setdefault(str(pane.get("column")), []).append(pane.get("id")) + return out + + +def _current_workspace(j): + """The topology workspace id of the current pane (the topology's ids, which _columns and _widths read).""" + for ws, pane, _tab, _term in j.places(j.topology()): + if pane.get("id") == j.pane: + return ws.get("id") + raise D.StepError(f"the current pane {j.pane} is in no workspace of the topology") + + +def step_smoke_widths(j, step): + workspace = _current_workspace(j) + widths, columns = _widths(j, workspace), _columns(j, workspace) + if not hasattr(j, "smoke_width_snaps"): + j.smoke_width_snaps = {} + j.smoke_width_snaps[step["name"]] = {"workspace": workspace, "widths": widths, "columns": columns} + return {"workspace": workspace, "widths": widths, "columns": columns} + + +def cond_smoke_same_widths(j, want, cache): + snap = getattr(j, "smoke_width_snaps", {}).get(want["since"]) + if snap is None: + return False, {"error": f"no smoke_widths named {want['since']!r}"} + # Columns are matched by their content, not their index: a new column after the focused one shifts the + # indexes of the columns right of it. Each old column is its set of panes. + old_cols, new_cols = snap["columns"], _columns(j, snap["workspace"], cache) + widths_now = _widths(j, snap["workspace"], cache) + changed, gone = {}, [] + for index, members in old_cols.items(): + match = next((k for k, m in new_cols.items() if set(members) <= set(m)), None) + before = snap["widths"].get(index) + if match is None: + gone.append(index) + continue + after = widths_now.get(match) + if before is None or after is None or abs(before - after) > 0.01: + changed[index] = {"old": before, "new": after, "panes": members} + matched = {next((k for k, m in new_cols.items() if set(members) <= set(m)), None) for members in old_cols.values()} + added = sorted(k for k in new_cols if k not in matched) + problems = [] + if changed: + problems.append(f"{len(changed)} existing columns changed width") + if gone: + problems.append(f"{len(gone)} existing columns are gone") + if len(added) != int(want.get("new", 1)): + problems.append(f"{len(added)} new columns, not {want.get('new', 1)}") + return not problems, {"problems": problems, "changed": changed, "gone": gone, "added": added, + "old_widths": snap["widths"], "new_widths": widths_now, "old_columns": old_cols, "new_columns": new_cols} + +STEPS = {"smoke_ready": step_smoke_ready, "smoke_make_key": step_smoke_make_key, "smoke_type_now": step_smoke_type_now, + "smoke_remember_workspaces": step_smoke_remember_workspaces, "smoke_close_all": step_smoke_close_all, + "smoke_snapshot": step_smoke_snapshot, "smoke_adopt_line": step_smoke_adopt_line, + "smoke_long_line": step_smoke_long_line, "smoke_widths": step_smoke_widths} +CONDITIONS = {"smoke_workspaces": ("app", cond_smoke_workspaces), "smoke_key_window": ("app", cond_smoke_key_window), + "smoke_app_lines": ("app", cond_smoke_app_lines), "smoke_daemon_lines": ("daemon", cond_smoke_daemon_lines), + "smoke_new_terminals": ("daemon", cond_smoke_new_terminals), "smoke_frame": ("frame", cond_smoke_frame), + "smoke_panes": ("app", cond_smoke_panes), "smoke_r3": ("daemon", cond_smoke_r3), + "smoke_same_widths": ("app", cond_smoke_same_widths)} +# Re-read every poll and through an expect's `settle`: an old column that changes width a moment after the new one +# shows (an animation, a late layout pass), or a terminal size that snaps back after it agreed, must fail. +NEGATIVE = ("smoke_same_widths", "smoke_r3") diff --git a/scripts/journeys/ext_spam.py b/scripts/journeys/ext_spam.py new file mode 100644 index 000000000000..d56673517758 --- /dev/null +++ b/scripts/journeys/ext_spam.py @@ -0,0 +1,297 @@ +"""Shortcut-spam steps and conditions for the journeys (group shortcut-spam, journeys/p0/spam-*.json). + +driver.py loads this file (load_extensions) on the mini, under the stock /usr/bin/python3 (3.9, standard library +only). It must not import driver.py: install() gets the driver module instead. + +Lawrence 2026-10-28 (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md): "spamming cmd w needs to +work, same thing for all other shortcuts. spamming it very fast must work perfectly, very fast." Each spam journey +holds one shortcut down (key repeat) and hammers it (20 presses in 1 s), and judges what the person sees: + + - held: one action per repeat delivered while the key is down, none after it goes up (no backlog), and Cmd-W + stops before the pane's last tab (key repeat never crosses a boundary); + - hammered: every physical press does exactly one action, none dropped, none doubled. + +Keys are sent as the system makes them: each on its own schedule (a thread per key, the event's `timestamp` its +scheduled time on this Mac's uptime clock), so a main thread that falls behind sees a queue of old repeats, as +with a real held key. `debug.key {repeat: true}` sends an auto-repeat; the app's repeat gate reads that key as down +(a slot has no physical key) and still drops repeats that waited longer than one repeat interval. + +Each hold and hammer reply has a `bench` row (the shortcut-spam bench): presses handled per second and each run's +main-thread time to its commit (debug.action_budget, p50 / p99 / max). Target on an optimized build: p99 < 2 ms. + +Steps (`do`): + spam_remember {as} keeps the journey workspace's counts (tabs, panes, the focused pane's tabs, + its selected tab, the workspaces of the window, the workspace shown) as AS + hold_key {key, modifiers?, seconds?, delay?, interval?, as, mark?} + one press, then auto-repeats every INTERVAL (default 0.033 s, the fastest + System Settings rate) from DELAY (default 0.25 s) until SECONDS (default 2); + then the key is up. Keeps AS_release (the counts one frame after the key-up) + and AS (the reply counts: `ran`, the actions that ran; `sent`). MARK (default + AS) is the key-up moment + hammer_key {key, modifiers?, presses?, within?, as, mark?} + PRESSES (default 20) physical presses spread over WITHIN seconds (default 1) + +Conditions (inside `expect.all`; evidence kind app): + spam_counts {from: NAME, field, delta | equals | delta_runs: HOLD | delta_presses: HAMMER, sign?} + the field now (tabs, panes, pane_tabs, workspaces) against NAME's: `delta` a + number, `delta_runs` SIGN times the runs a hold or hammer reported, `equals` + spam_unchanged {since: NAME, fields?} negative: the counts still equal NAME's (with `settle`: nothing happened + after the key-up) + spam_selected {from: NAME, by: N | by_runs: HOLD, sign?} + the focused pane's selected tab moved that many places in NAME's tab order + spam_runs {of: NAME, ran | ran_le | ran_ge: N | "sent" | "sent-1"} + the actions a hold or hammer ran +""" +import threading +import time + +D = None # the driver module (install) +FRAME = 1 / 60.0 + + +def install(driver): + global D + D = driver + + +def uptime(): + return time.clock_gettime(time.CLOCK_UPTIME_RAW) + + +def _init(j): + if not hasattr(j, "spam"): + j.spam = {} + j.spam_workspace = None + + +def counts(j, cache=None): + """The journey workspace's counts from the app's topology (snapshot.get).""" + topo = j.topology() if cache is None or "spam_topo" not in cache else cache["spam_topo"] + if cache is not None: + cache["spam_topo"] = topo + wid = getattr(j, "spam_workspace", None) + if wid is None: + place = j.place_of(j.need_term(), topo) + if not place: + raise D.StepError("the journey's terminal is in no tab of the topology") + wid = place[0].get("id") + j.spam_workspace = wid + ws = next((w for w in topo.get("workspaces", []) if w.get("id") == wid), None) + windows = topo.get("windows", []) + out = {"workspaces": len(topo.get("workspaces", [])), "workspace_open": ws is not None} + if ws is None: + return dict(out, tabs=0, panes=0, pane_tabs=0, selected=None, pane_tab_ids=[]) + panes = [p for s in ws.get("screens", []) for p in s.get("panes", [])] + focused = None + for w in windows: + focused = focused or w.get("focused_pane") + pane = next((p for p in panes if p.get("id") == focused), panes[0] if panes else None) + out.update(tabs=sum(len(p.get("tabs", [])) for p in panes), panes=len(panes), + pane_tabs=len(pane.get("tabs", [])) if pane else 0, selected=pane.get("selected_tab") if pane else None, + pane_tab_ids=[t.get("id") for t in pane.get("tabs", [])] if pane else []) + return out + + +def step_spam_remember(j, step): + _init(j) + got = counts(j) + j.spam[step["as"]] = got + j.marks[step["as"]] = D.now() + return got + + +def _send(j, key, modifiers, at, is_repeat, window): + """One key at its scheduled uptime `at` (sleeps until then); returns the reply.""" + wait = at - uptime() + if wait > 0: + time.sleep(wait) + params = {"key": key, "modifiers": modifiers, "timestamp": at} + if is_repeat: + params["repeat"] = True + if window or j.key_window: + params["window"] = window or j.key_window + try: + return j.app.rpc("debug.key", params) + except Exception as error: # a reply that timed out still ran or queued its key + return {"error": str(error)} + + +def _play(j, key, modifiers, schedule, window): + """Sends each (time, is_repeat) of `schedule` on its own thread, so a slow reply never delays the next key.""" + replies = [None] * len(schedule) + + def run(index, at, is_repeat): + replies[index] = _send(j, key, modifiers, at, is_repeat, window) + + threads = [threading.Thread(target=run, args=(i, at, rep), daemon=True) for i, (at, rep) in enumerate(schedule)] + for t in threads: + t.start() + return threads, replies + + +def _budget_mark(j): + """The newest action-budget sample's sequence now (debug.action_budget), or None when the build has none.""" + try: + report = j.app.rpc("debug.action_budget", {"limit": 1}) or {} + except Exception: + return None + recent = report.get("recent") or [] + return max((r.get("sequence", 0) for r in recent), default=0) + + +def _bench(j, since, seconds, ran): + """The bench row of one hold or hammer: presses handled per second and each run's main-thread time to its + commit (debug.action_budget `commit_ms`: ActionBudgetMonitor), p50 / p99 / max, for runs after `since`.""" + if since is None: + return None + try: + report = j.app.rpc("debug.action_budget", {"limit": 512}) or {} + except Exception as error: + return {"error": str(error)} + ms = sorted(r.get("commit_ms", 0.0) for r in report.get("recent") or [] if r.get("sequence", 0) > since) + if not ms: + return {"runs_sampled": 0} + + def pct(p): + return round(ms[min(len(ms) - 1, int(p * len(ms)))], 3) + return {"presses_per_s": round(ran / seconds, 1) if seconds else None, "runs_sampled": len(ms), + "main_ms_p50": pct(0.5), "main_ms_p99": pct(0.99), "main_ms_max": round(ms[-1], 3)} + + +def _summary(replies): + ran = sum(1 for r in replies if isinstance(r, dict) and r.get("action")) + refused = sum(1 for r in replies if isinstance(r, dict) and r.get("refused_action")) + errors = [r.get("error") for r in replies if isinstance(r, dict) and r.get("error")][:3] + dropped = next((r.get("repeats_dropped") for r in reversed(replies) if isinstance(r, dict) and r.get("repeats_dropped")), None) + return {"sent": len(replies), "ran": ran, "refused": refused, "errors": errors, "repeats_dropped": dropped} + + +def step_hold_key(j, step): + _init(j) + j.before_keys() + window = j.window_for(step.get("window")) + seconds, delay, interval = float(step.get("seconds", 2)), float(step.get("delay", 0.25)), float(step.get("interval", 0.033)) + start = uptime() + 0.05 + schedule = [(start, False)] + t = start + delay + while t < start + seconds: + schedule.append((t, True)) + t += interval + key_up = start + seconds + mark = _budget_mark(j) + threads, replies = _play(j, step["key"], step.get("modifiers", []), schedule, window) + # The key goes up at key_up: debug.key stops reading it as held (repeats still queued then are a backlog). + time.sleep(max(0.0, key_up - uptime())) + try: + up = {"key": step["key"], "modifiers": step.get("modifiers", []), "up": True} + if window or j.key_window: + up["window"] = window or j.key_window + j.app.rpc("debug.key", up) + except Exception: + pass + # One frame after the key-up: what the person sees when they let go. + time.sleep(max(0.0, key_up + FRAME - uptime())) + name = step["as"] + j.marks[step.get("mark", name)] = D.now() + j.spam[name + "_release"] = counts(j) + for thread in threads: + thread.join(30) + summary = _summary(replies) + summary["bench"] = _bench(j, mark, seconds, summary["ran"]) + j.spam[name] = summary + j.last_key_at = D.now() + return dict(summary, at_release=j.spam[name + "_release"]) + + +def step_hammer_key(j, step): + _init(j) + j.before_keys() + window = j.window_for(step.get("window")) + presses, within = int(step.get("presses", 20)), float(step.get("within", 1)) + start = uptime() + 0.05 + schedule = [(start + i * within / max(presses, 1), False) for i in range(presses)] + mark = _budget_mark(j) + threads, replies = _play(j, step["key"], step.get("modifiers", []), schedule, window) + for thread in threads: + thread.join(30) + name = step["as"] + j.marks[step.get("mark", name)] = D.now() + summary = _summary(replies) + summary["bench"] = _bench(j, mark, within, summary["ran"]) + j.spam[name] = summary + j.last_key_at = D.now() + return summary + + +def _remembered(j, name): + _init(j) + if name not in j.spam: + raise D.StepError(f"no spam_remember / hold_key / hammer_key named {name!r}") + return j.spam[name] + + +def _target(j, want, base): + if "equals" in want: + return want["equals"] + sign = int(want.get("sign", 1)) + if "delta_runs" in want: + return base + sign * int(_remembered(j, want["delta_runs"])["ran"]) + int(want.get("plus", 0)) + return base + int(want.get("delta", 0)) + + +def cond_spam_counts(j, want, cache): + before = _remembered(j, want["from"]) + now = counts(j, cache) + field = want.get("field", "tabs") + wanted = _target(j, want, before.get(field) or 0) + return now.get(field) == wanted, {"field": field, "now": now.get(field), "wanted": wanted, "before": before.get(field)} + + +def cond_spam_unchanged(j, want, cache): + before = _remembered(j, want["since"]) + now = counts(j, cache) + fields = want.get("fields") or ["tabs", "panes", "pane_tabs", "workspaces"] + changed = {f: [before.get(f), now.get(f)] for f in fields if before.get(f) != now.get(f)} + return not changed, {"changed": changed} + + +def cond_spam_selected(j, want, cache): + """The focused pane's selected tab is NAME's selected tab moved by `by` (or SIGN times a hold's or hammer's + runs, `by_runs`) places in NAME's tab order, wrapping (Ctrl-Tab, Cmd-Shift-[ and ]).""" + before = _remembered(j, want["from"]) + ids = before.get("pane_tab_ids") or [] + if before.get("selected") not in ids: + return False, {"error": "no selected tab at the mark", "before": before.get("selected")} + by = int(want.get("by", 0)) + if "by_runs" in want: + by = int(want.get("sign", 1)) * int(_remembered(j, want["by_runs"])["ran"]) + wanted = ids[(ids.index(before["selected"]) + by) % len(ids)] + now = counts(j, cache) + return now.get("selected") == wanted, {"now": now.get("selected"), "wanted": wanted, "by": by, "order": ids} + + +def cond_spam_runs(j, want, cache): + got = _remembered(j, want["of"]) + ran, sent = got["ran"], got["sent"] + + def value(v): + if v == "sent": + return sent + if v == "sent-1": + return sent - 1 + return int(v) + + ok = True + if "ran" in want: + ok = ok and ran == value(want["ran"]) + if "ran_le" in want: + ok = ok and ran <= value(want["ran_le"]) + if "ran_ge" in want: + ok = ok and ran >= value(want["ran_ge"]) + return ok, {"ran": ran, "sent": sent, "refused": got.get("refused"), "dropped": got.get("repeats_dropped")} + + +STEPS = {"spam_remember": step_spam_remember, "hold_key": step_hold_key, "hammer_key": step_hammer_key} +CONDITIONS = {"spam_counts": ("app", cond_spam_counts), "spam_unchanged": ("app", cond_spam_unchanged), + "spam_runs": ("app", cond_spam_runs), "spam_selected": ("app", cond_spam_selected)} +NEGATIVE = {"spam_unchanged"} diff --git a/scripts/journeys/ext_window.py b/scripts/journeys/ext_window.py new file mode 100644 index 000000000000..be7c4655208e --- /dev/null +++ b/scripts/journeys/ext_window.py @@ -0,0 +1,800 @@ +"""Window and pane steps and conditions for the journeys (group window-pane-resize). + +driver.py loads this file (load_extensions) on the mini, where it runs under the stock /usr/bin/python3 +(3.9, standard library only). It must not import driver.py (that runs as __main__ there): install() +gets the driver module instead. + +What a person does is played through the app's own input paths: + + - a drag of the window's edge or corner, or of a divider between panes: `debug.mouse` down, then one + drag event per display frame (60 Hz) with the button held, then up. The events go through the app's + event queue, so AppKit's own live-resize tracking (or the layout's divider drag) reads them, as it + reads a real drag; the person's pointer never moves; + - keys (Cmd-D, Cmd-Shift-D, Cmd-W, Cmd-Shift-Return, Ctrl-Cmd-F, Cmd-Shift-N): the driver's `key` step + (`debug.key`, the key router's real path); + - menu-only actions with no default key (Window > Zoom): `action.run`, the route the menu item takes. + +What the person sees is read three ways, as the driver requires: + + - app state: the window's frame and screen (`debug.layers`), full screen (`debug.windows`), each + pane's frame, grid, fitted grid and drawn grid (`debug.surfaces`: the terminal view's frame in the + window, Ghostty's cell size, the grid the view fits, the grid Ghostty drew and the grid the PTY owner + has), its viewport text; + - the daemon: its screen's size (cols x rows) and text (`cmux terminal ID screen read`), and the PTY's + own size as the shell reads it (`stty size`, typed by the journey); + - the frame: the window's snapshot read with text recognition (the driver's `frame_text`). + +Steps (`do`): + name_pane {as, window?: new} names the current pane (after new_terminal); window: new + fails unless it is in a window no named pane is in + adopt_window {} waits for a newly opened main window (after Cmd-Shift-N) and + makes it key (without activating the app); keys and frames + go to it + adopt_pane {as, window: same|new} waits for the newly focused terminal pane (after a split or a + new window) and makes it the current pane + use_pane {pane, click?} makes a named pane current (its window gets keys and snapshots); + click: true clicks into it first (`debug.mouse`), as a person + does before typing there, and waits for its focus + remember {as, pane?} keeps the window frame, the pane's frame and grid as NAME + window_place {fraction: [x, y, w, h]} set-up, not under test: puts the window at these fractions of + its screen's visible frame (top-left origin) and waits for it + window_drag {edge, by: [dx, dy], events?, hz?, press?, release?, mark?} + drags the window's `bottom-right` (default), `right` or `bottom` + edge by dx, dy points; press: false continues a drag left open + (release: false), so checks can run while the window is in its + live resize + divider_drag {between: [A, B], by, events?, hz?, mark?} + drags the divider between named panes A (left or top) and B by + `by` points along the split's axis + action {action, args?, mark?} action.run as a menu item runs it (`origin: user`) + leave_full_screen {} clean-up: Ctrl-Cmd-F until the window is out of full screen + restore_window {from} clean-up: the window back to a remembered frame + close_window {pane} | {window: new} clean-up: Ctrl-Cmd-W in the named pane's window (or the + adopted new window) + +Conditions (inside `expect.all`): + window_frame {from, dw?, dh?, tol?} | {fills: visible|screen, tol?} | {same_as, tol?} app + full_screen true | false app + grid_fits {pane?, vs?, cols?: wider|narrower|same, rows?: taller|shorter|same} app + the pane shows (not blank), its grid equals the grid the view fits and the grid Ghostty + drew, the daemon's screen has that size, and the grid fits the view's frame at Ghostty's + cell size (within one cell); `vs` compares with a remembered grid + pane_frame {pane?, vs, w?, h?, tol?} | {pane?, right_of} | {pane?, below} app + w / h: the pane's width / height as a fraction of the remembered pane's + pane_state {pane, fields: {...}} app + at_bottom {pane?, lines?} the app's viewport ends with the daemon screen's last lines app + output_advances {pattern, by, pane?} the viewport's highest counter rose by `by` app + pty_size TAG | {tag, pane?} the shell's `echo TAG $(stty size)` line: the PTY's rows and + cols equal the daemon screen's and the app's grid daemon + pane_frame_text TEXT | {text, pane?} a fresh snapshot of the pane's window (kept as check-NN.png) + shows TEXT inside the pane to text recognition: the rows + around it, else the pane, else the whole window frame +""" +import json +import re +import time + +D = None # the driver module (install) + + +def install(driver): + global D + D = driver + + +# ---- reads ----------------------------------------------------------------------------------- + +def windows(j): + """{state id: {id, number, frame, visible, screen}} of the app's main windows (debug.layers).""" + report = j.app.rpc("debug.layers") or {} + out = {} + for w in report.get("windows") or []: + out[w.get("id")] = {"id": w.get("id"), "number": w.get("window_number"), "frame": w.get("frame"), + "visible": w.get("screen_visible_frame"), "screen": w.get("screen_frame")} + return out + + +def all_panes(j, cache=None): + if cache is not None and "ext_panes" in cache: + return cache["ext_panes"] + report = j.app.rpc("debug.surfaces", {"text": True}) or {} + panes = [] + for w in report.get("windows") or []: + for p in w.get("panes") or []: + if isinstance(p, dict): + p = dict(p, window_number=w.get("window")) + panes.append(p) + if cache is not None: + cache["ext_panes"] = panes + return panes + + +def named(j, name): + """A named pane's record, or the current pane's when name is None.""" + if not name: + return {"pane": j.pane, "term": j.term, "host_pid": j.host_pid, "window": current_window(j)} + panes = getattr(j, "ext_panes", {}) + if name not in panes: + raise D.StepError(f"no pane named {name!r} (name_pane or adopt_pane first)") + return panes[name] + + +def pane_row(j, key, cache=None): + return next((p for p in all_panes(j, cache) if p.get("pane") == key), None) + + +def window_of_pane(j, key, cache=None): + row = pane_row(j, key, cache) + if row is None: + return None + grid = row.get("grid") or {} + if grid.get("window"): + return grid["window"] + number = row.get("window_number") + for w in windows(j).values(): + if str(w.get("number")) == str(number): + return w["id"] + return None + + +def current_window(j): + if getattr(j, "ext_window", None): + return j.ext_window + if j.pane: + j.ext_window = window_of_pane(j, j.pane) + return j.ext_window + first = next(iter(windows(j)), None) + return first + + +def window_rec(j, wid): + found = windows(j) + if wid not in found: + raise D.StepError(f"window {wid} is not in debug.layers (windows {list(found)[:4]})") + return found[wid] + + +def parse_grid(text): + m = re.fullmatch(r"(\d+)x(\d+)", str(text or "")) + return (int(m.group(1)), int(m.group(2))) if m else None + + +def frame_of(row): + """A pane's terminal view frame in window points from the top-left: (x, y, w, h).""" + grid = (row or {}).get("grid") or {} + if "frame_width" not in grid: + return None + return (grid["frame_x"], grid["frame_y"], grid["frame_width"], grid["frame_height"]) + + +def daemon_terminal_of(j, pane_key, workspace_id=None, avoid=()): + """The daemon terminal id of the selected tab of `pane_key` (topology from snapshot.get).""" + snap = j.app.rpc("snapshot.get") or {} + topo = snap.get("topology") if isinstance(snap, dict) else None + exact, in_workspace = [], [] + for ws in (topo or {}).get("workspaces", []): + for screen in ws.get("screens", []): + for pane in screen.get("panes", []): + for tab in pane.get("tabs", []): + term = tab.get("terminal") + term = term.get("id") if isinstance(term, dict) else term + if not term: + continue + if pane.get("id") == pane_key: + exact.append((bool(tab.get("selected")), str(term))) + elif workspace_id and ws.get("id") == workspace_id and str(term) not in avoid: + in_workspace.append((bool(tab.get("selected")), str(term))) + if exact: + return sorted(exact, reverse=True)[0][1] + if len(in_workspace) == 1: + return in_workspace[0][1] + raise D.StepError(f"no daemon terminal for pane {pane_key} (workspace {workspace_id}: candidates {in_workspace[:4]})") + + +# ---- steps ----------------------------------------------------------------------------------- + +def _known(j): + if not hasattr(j, "ext_panes"): + j.ext_panes = {} + if not hasattr(j, "ext_seen"): + j.ext_seen = set() + return j.ext_panes + + +def _record_current(j, name): + panes = _known(j) + j.ext_window = window_of_pane(j, j.pane) + panes[name] = {"pane": j.pane, "term": j.term, "host_pid": j.host_pid, "window": j.ext_window} + j.ext_seen |= {p.get("pane") for p in all_panes(j)} + if not hasattr(j, "ext_windows_seen"): + j.ext_windows_seen = set(windows(j)) + return panes[name] + + +def step_name_pane(j, step): + if not j.pane or not j.term: + raise D.StepError("name_pane: no current pane (new_terminal first)") + others = {p["window"] for p in _known(j).values()} + rec = _record_current(j, step["as"]) + if step.get("window") == "new" and rec["window"] in others: + raise D.StepError(f"name_pane {step['as']!r}: pane {rec['pane']} is in window {rec['window']}, " + f"not a new window (windows of the named panes: {sorted(others)})") + j.key_window = j.app.snapshot_window = rec["window"] + return rec + + +def step_adopt_window(j, step): + """Waits for a main window that was not open at the journey's first named pane (after Cmd-Shift-N), + shown and on screen, and makes it the window keys and snapshots go to (debug.window.focus: key, + without activating the app).""" + _known(j) + old = getattr(j, "ext_windows_seen", set()) + end = D.now() + float(step.get("timeout", 20)) + found = {} + while D.now() < end: + found = windows(j) + new = [w for w in found if w not in old] + if new: + wid = new[0] + focus = j.app.rpc("debug.window.focus", {"window": wid}) + j.ext_window = j.key_window = j.app.snapshot_window = wid + j.ext_windows_seen = set(found) + j.ext_new_window = wid + return {"window": wid, "frame": found[wid]["frame"], "focus": focus} + time.sleep(0.2) + raise D.StepError(f"no new window opened within {step.get('timeout', 20)} s (windows {sorted(found)})") + + +def step_adopt_pane(j, step): + _known(j) + seen = set(j.ext_seen) + terms = {p["term"] for p in j.ext_panes.values()} + want_window = step.get("window", "same") + old_windows = getattr(j, "ext_windows_seen", set(windows(j))) + end = D.now() + float(step.get("timeout", 20)) + last = "no new focused terminal pane" + while D.now() < end: + for row in all_panes(j): + key = row.get("pane") + if key in seen or not row.get("focused") or row.get("kind") != "terminal": + continue + wid = window_of_pane(j, key) + if want_window == "new" and wid in old_windows: + continue + if want_window == "same" and wid != current_window(j): + continue + try: + term = daemon_terminal_of(j, key, row.get("workspace"), avoid=terms) + if j.app.screen(term)["text"].strip(): + j.pane, j.term = key, term + j.host_pid = int(row["host_pid"]) if row.get("host_pid") else None + j.ext_window = wid + j.key_window = wid + j.app.snapshot_window = wid + rec = _record_current(j, step["as"]) + j.ext_windows_seen = set(windows(j)) + return dict(rec, workspace=row.get("workspace"), frame=frame_of(row), grid=row.get("surface", {}).get("grid")) + last = f"pane {key}: terminal {term} has an empty screen" + except D.StepError as error: + last = str(error) + time.sleep(0.2) + raise D.StepError(f"adopt_pane {step['as']!r} ({want_window} window): {last[-400:]}") + + +def step_use_pane(j, step): + release_open_drag(j) + rec = named(j, step["pane"]) + j.pane, j.term, j.host_pid, j.ext_window = rec["pane"], rec["term"], rec["host_pid"], rec["window"] + j.key_window = rec["window"] + j.app.snapshot_window = rec["window"] + if not step.get("click"): + return rec + # A person clicks into the pane to type there: a click at the center of its content. + reply = j.app.rpc("debug.mouse", {"window": rec["window"], "pane": rec["pane"], "action": "click"}) + end = D.now() + float(step.get("timeout", 5)) + while D.now() < end: + row = pane_row(j, rec["pane"]) + if row and row.get("focused"): + return dict(rec, click=reply) + time.sleep(0.1) + raise D.StepError(f"a click in pane {step['pane']} did not focus it: {json.dumps(reply)[:200]}") + + +def step_remember(j, step): + rec = named(j, step.get("pane")) + row = pane_row(j, rec["pane"]) + if row is None: + raise D.StepError(f"remember: pane {rec['pane']} is not in debug.surfaces") + win = window_rec(j, rec["window"]) + surface = row.get("surface") or {} + kept = {"window": win["frame"], "visible": win["visible"], "screen": win["screen"], "pane_frame": frame_of(row), + "grid": parse_grid(surface.get("grid")), "fit_grid": surface.get("fit_grid")} + if not hasattr(j, "ext_remembered"): + j.ext_remembered = {} + j.ext_remembered[step["as"]] = kept + return kept + + +def remembered(j, name): + kept = getattr(j, "ext_remembered", {}) + if name not in kept: + raise D.StepError(f"nothing remembered as {name!r}") + return kept[name] + + +def wait_frame(j, wid, target, timeout=10, tol=2): + end = D.now() + timeout + frame = None + while D.now() < end: + frame = window_rec(j, wid)["frame"] + if frame and all(abs(a - b) <= tol for a, b in zip(frame, target)): + return frame + time.sleep(0.1) + raise D.StepError(f"the window frame is {frame}, not {target}") + + +def step_window_place(j, step): + wid = current_window(j) + win = window_rec(j, wid) + vx, vy, vw, vh = win["visible"] + fx, fy, fw, fh = step["fraction"] + # The frame before (`restore_window {"from": "before-place"}` in `finally` puts it back). + if not hasattr(j, "ext_remembered"): + j.ext_remembered = {} + j.ext_remembered.setdefault("before-place", {"window": win["frame"]}) + w, h = round(vw * fw), round(vh * fh) + x = round(vx + vw * fx) + y = round(vy + vh - vh * fy - h) + reply = j.app.rpc("debug.window_frame", {"window": wid, "frame": [x, y, w, h]}) + frame = wait_frame(j, wid, [x, y, w, h]) + # The panes follow the window on their next layout: wait for every pane to fit its view again. + return {"frame": frame, "visible": win["visible"], "reply": reply} + + +def _pace(t0, index, hz): + due = t0 + index / float(hz) + left = due - time.monotonic() + if left > 0: + time.sleep(left) + + +def step_window_drag(j, step): + wid = current_window(j) + edge = step.get("edge", "bottom-right") + dx, dy = step.get("by", [0, 0]) + events, hz = int(step.get("events", 30)), float(step.get("hz", 60)) + win = window_rec(j, wid) + x0, y0, w, h = win["frame"] + vx, vy, vw, vh = win["visible"] + drag = getattr(j, "ext_drag", None) + if step.get("press", True): + # Window-local points from the top-left: the edge the person grabs. + start = {"bottom-right": (w - 1, h - 1), "right": (w - 1, h / 2), "bottom": (w / 2, h - 1)}[edge] + j.ext_drag = drag = {"x": start[0], "y": start[1], "window": wid, "edge": edge, "events": 0} + j.app.rpc("debug.mouse", {"window": wid, "action": "down", "x": start[0], "y": start[1]}) + elif not drag: + raise D.StepError("window_drag press: false, but no drag is open") + # Keep the window on its screen's visible frame (the top-left corner stays put in a resize from the + # bottom or right): a drag past it only presses the window against the screen edge. + room_right = (vx + vw) - (x0 + w) + room_down = y0 - vy + clamp = {"dx": dx, "dy": dy} + dx = min(dx, max(0, room_right - 4)) if dx > 0 else dx + dy = min(dy, max(0, room_down - 4)) if dy > 0 else dy + if step.get("mark"): + j.marks[step["mark"]] = D.now() + fx, fy = drag["x"], drag["y"] + t0, rtt = time.monotonic(), [] + for i in range(1, events + 1): + x, y = fx + dx * i / events, fy + dy * i / events + t = time.monotonic() + j.app.rpc("debug.mouse", {"window": wid, "action": "drag", "x": x, "y": y, "to_x": x, "to_y": y, "steps": 1, + "press": False, "release": False}) + rtt.append(round((time.monotonic() - t) * 1000, 1)) + _pace(t0, i, hz) + drag["x"], drag["y"], drag["events"] = fx + dx, fy + dy, drag["events"] + events + result = {"window": wid, "edge": edge, "asked": clamp, "dragged": [dx, dy], "events": events, + "event_rpc_ms_max": max(rtt) if rtt else None, "frame_before": win["frame"]} + if step.get("release", True): + j.app.rpc("debug.mouse", {"window": wid, "action": "up", "x": drag["x"], "y": drag["y"]}) + j.ext_drag = None + result["released"] = True + result["settle"] = settle_queue(j) + result["frame_after"] = window_rec(j, wid)["frame"] + return result + + +def settle_queue(j, timeout=5.0): + """Waits until the app's event queue has drained past the posted mouse-up. + + `debug.mouse` posts its events to the app's queue, where AppKit's resize tracking (or the + layout's divider drag) reads them in a loop; `debug.key` dispatches at once. A key sent right + after the release would reach the app before the queued mouse-up ends the tracking loop, which + no person can do: their key is queued behind the mouse-up. So a right-button release outside the + window's content (no view gets it; nothing reacts) is queued behind it (`debug.mouse`, the same + queue), and the input journal, which records what `sendEvent` dispatches, shows it once the + tracking loop ended. (`debug.key` `sequence` would be the natural marker, but a capture slot's + app cannot make the CGEvents it needs: "no event timestamp".)""" + before = j.app.rpc("debug.journal", {"last": 1}) or {} + if not (before.get("stats") or {}).get("enabled"): + time.sleep(0.5) # no journal on this build: nothing observable; the drag's events drain meanwhile + return {"settled_by": "wait 0.5 s (input journal off)"} + last = max([e.get("seq", -1) for e in before.get("entries") or []] or [-1]) + t0 = D.now() + wid = current_window(j) + j.app.rpc("debug.mouse", {"window": wid, "action": "up", "button": "right", "x": -40, "y": -40}) + while D.now() - t0 < timeout: + entries = (j.app.rpc("debug.journal", {"last": 40}) or {}).get("entries") or [] + if any(e.get("seq", -1) > last and '"up"' in json.dumps(e) for e in entries): + return {"settled_by": "queued marker release dispatched", "ms": round((D.now() - t0) * 1000)} + time.sleep(0.05) + raise D.StepError(f"the app's event queue did not drain past the drag's mouse-up within {timeout} s " + f"(a queued marker never reached sendEvent: the drag's tracking loop may still run)") + + +def step_divider_drag(j, step): + a, b = (named(j, n) for n in step["between"]) + ra, rb = pane_row(j, a["pane"]), pane_row(j, b["pane"]) + fa, fb = frame_of(ra), frame_of(rb) + if not fa or not fb: + raise D.StepError(f"divider_drag: no frame for {step['between']}: {fa} {fb}") + wid = a["window"] + by = float(step["by"]) + if fb[0] >= fa[0] + fa[2] - 1: # B is right of A: a vertical divider + x = (fa[0] + fa[2] + fb[0]) / 2 + top, bottom = max(fa[1], fb[1]), min(fa[1] + fa[3], fb[1] + fb[3]) + start, delta = (x, (top + bottom) / 2), (by, 0) + elif fb[1] >= fa[1] + fa[3] - 1: # B is below A: a horizontal divider + y = (fa[1] + fa[3] + fb[1]) / 2 + left, right = max(fa[0], fb[0]), min(fa[0] + fa[2], fb[0] + fb[2]) + start, delta = ((left + right) / 2, y), (0, by) + else: + raise D.StepError(f"divider_drag: {step['between']} are not side by side: {fa} {fb}") + events, hz = int(step.get("events", 30)), float(step.get("hz", 60)) + if step.get("mark"): + j.marks[step["mark"]] = D.now() + # The pointer rests over the divider first (its hover), then presses and drags as a person does. + j.app.rpc("debug.mouse", {"window": wid, "action": "move", "x": start[0], "y": start[1]}) + j.app.rpc("debug.mouse", {"window": wid, "action": "down", "x": start[0], "y": start[1]}) + t0 = time.monotonic() + for i in range(1, events + 1): + x, y = start[0] + delta[0] * i / events, start[1] + delta[1] * i / events + j.app.rpc("debug.mouse", {"window": wid, "action": "drag", "x": x, "y": y, "to_x": x, "to_y": y, "steps": 1, + "press": False, "release": False}) + _pace(t0, i, hz) + end = (start[0] + delta[0], start[1] + delta[1]) + j.app.rpc("debug.mouse", {"window": wid, "action": "up", "x": end[0], "y": end[1]}) + settle = settle_queue(j) + j.app.rpc("debug.mouse", {"window": wid, "action": "leave", "x": end[0], "y": end[1]}) + return {"from": start, "to": end, "frames_before": [fa, fb], "settle": settle} + + +def step_action(j, step): + if step.get("mark"): + j.marks[step["mark"]] = D.now() + # The control socket takes origin cli, mcp, script or remote (never user); the default is the + # script's. The action runs through the same registry handler as its menu item. + params = {"action": step["action"], "args": j.sub(step.get("args") or {}), "focus": True} + return {"reply": j.app.rpc("action.run", params)} + + +def full_screen_of(j, wid): + report = j.app.rpc("debug.windows") or {} + for w in report.get("windows") or []: + if w.get("id") == wid: + return bool(w.get("full_screen")) + return None + + +def step_leave_full_screen(j, step): + release_open_drag(j) + wid = current_window(j) + if not full_screen_of(j, wid): + return {"full_screen": False} + j.app.rpc("debug.key", {"key": "f", "modifiers": ["control", "cmd"], "window": wid}) + end = D.now() + float(step.get("timeout", 15)) + while D.now() < end: + if not full_screen_of(j, wid): + time.sleep(1.0) # clean-up: the exit animation ends before the next journey's window reads + return {"full_screen": False, "left": True} + time.sleep(0.2) + raise D.StepError("the window stayed in full screen") + + +def release_open_drag(j): + """Clean-up: a journey that failed while a drag held the button (`release: false`) leaves AppKit's + resize tracking loop running, which would take the next journey's input. Release it.""" + drag = getattr(j, "ext_drag", None) + if not drag: + return None + j.app.rpc("debug.mouse", {"window": drag["window"], "action": "up", "x": drag["x"], "y": drag["y"]}) + j.ext_drag = None + return {"released_open_drag": True, "settle": settle_queue(j)} + + +def step_restore_window(j, step): + released = release_open_drag(j) + kept = remembered(j, step["from"]) + wid = current_window(j) + target = [round(v) for v in kept["window"]] + j.app.rpc("debug.window_frame", {"window": wid, "frame": target}) + return {"frame": wait_frame(j, wid, target, timeout=float(step.get("timeout", 10)), tol=4), "drag": released} + + +def step_close_window(j, step): + release_open_drag(j) + if step.get("window") == "new": + wid = getattr(j, "ext_new_window", None) + if not wid: + return {"closed": None, "why": "no new window was adopted"} + else: + wid = named(j, step["pane"])["window"] + reply = j.app.rpc("debug.key", {"key": "w", "modifiers": ["control", "cmd"], "window": wid}) + end = D.now() + float(step.get("timeout", 10)) + while D.now() < end: + if wid not in windows(j): + return {"closed": wid, "reply": reply} + time.sleep(0.2) + raise D.StepError(f"window {wid} is still open after Ctrl-Cmd-W: {json.dumps(reply)[:200]}") + + +STEPS = {"name_pane": step_name_pane, "adopt_window": step_adopt_window, "adopt_pane": step_adopt_pane, "use_pane": step_use_pane, "remember": step_remember, + "window_place": step_window_place, "window_drag": step_window_drag, "divider_drag": step_divider_drag, + "action": step_action, "leave_full_screen": step_leave_full_screen, "restore_window": step_restore_window, + "close_window": step_close_window} + + +# ---- conditions -------------------------------------------------------------------------------- + +def near(a, b, tol): + return abs(float(a) - float(b)) <= tol + + +def cond_window_frame(j, want, cache): + wid = current_window(j) + win = window_rec(j, wid) + frame = win["frame"] + tol = float(want.get("tol", 12)) + detail = {"window": wid, "frame": frame} + if "fills" in want: + target = win["visible"] if want["fills"] == "visible" else win["screen"] + detail["target"] = target + return all(near(a, b, tol) for a, b in zip(frame, target)), detail + if "same_as" in want: + target = remembered(j, want["same_as"])["window"] + detail["target"] = target + return all(near(a, b, tol) for a, b in zip(frame, target)), detail + base = remembered(j, want["from"])["window"] + dw, dh = frame[2] - base[2], frame[3] - base[3] + detail.update(dw=dw, dh=dh, from_frame=base) + ok = True + for key, got in (("dw", dw), ("dh", dh)): + if key in want: + wanted = float(want[key]) + # A positive or negative change of at least the asked size less tol; 0 means unchanged. + ok = ok and (near(got, 0, tol) if wanted == 0 else (got * wanted > 0 and abs(got) >= abs(wanted) - tol)) + return ok, detail + + +def cond_full_screen(j, want, cache): + wid = current_window(j) + state = full_screen_of(j, wid) + win = window_rec(j, wid) + fills = all(near(a, b, 2) for a, b in zip(win["frame"], win["screen"] or [])) + if want: + return bool(state) and fills, {"full_screen": state, "frame": win["frame"], "screen": win["screen"]} + return state is False and not fills, {"full_screen": state, "frame": win["frame"], "screen": win["screen"]} + + +def daemon_size(j, term): + screen = j.app.screen(term) + return screen.get("cols"), screen.get("rows"), screen + + +def cond_grid_fits(j, want, cache): + want = want if isinstance(want, dict) else {} + rec = named(j, want.get("pane")) + row = pane_row(j, rec["pane"], cache) + if row is None: + return False, {"error": f"pane {rec['pane']} is not in debug.surfaces"} + surface, grid_info = row.get("surface") or {}, row.get("grid") or {} + grid, fit, drawn = parse_grid(surface.get("grid")), parse_grid(surface.get("fit_grid")), parse_grid(surface.get("drawn_grid")) + cols, rows, _ = daemon_size(j, rec["term"]) + detail = {"pane": rec["pane"], "grid": surface.get("grid"), "fit_grid": surface.get("fit_grid"), + "drawn_grid": surface.get("drawn_grid"), "daemon": f"{cols}x{rows}", "blank": row.get("blank"), + "visible": row.get("visible"), "view_size": surface.get("view_size")} + problems = [] + if not row.get("visible") or row.get("blank") or not row.get("content_visible", True): + problems.append("the pane does not show its terminal") + if not grid or grid != fit or grid != drawn: + problems.append("grid, fitted grid and drawn grid differ") + if grid and (cols, rows) != grid: + problems.append("the daemon's screen is not the app's grid") + cw, ch = grid_info.get("cell_width"), grid_info.get("cell_height") + if grid and cw and ch: + pad_x = max(0.0, grid_info.get("origin_x", 0) - grid_info.get("frame_x", 0)) + pad_y = max(0.0, grid_info.get("origin_y", 0) - grid_info.get("frame_y", 0)) + # The columns and rows the view's frame holds at Ghostty's cell size (padding on both sides). + est_c = (grid_info["frame_width"] - 2 * pad_x) / cw + est_r = (grid_info["frame_height"] - 2 * pad_y) / ch + detail.update(cell=[cw, ch], padding=[pad_x, pad_y], frame=frame_of(row), fits=[round(est_c, 2), round(est_r, 2)]) + if not (est_c - 2 <= grid[0] <= est_c + 1 and est_r - 2 <= grid[1] <= est_r + 1): + problems.append("the grid does not fit the view's frame") + else: + problems.append("no cell metrics") + if want.get("vs") and grid: + base = remembered(j, want["vs"])["grid"] + detail["vs"] = base + rel = {"wider": grid[0] > base[0], "narrower": grid[0] < base[0], "same": grid[0] == base[0]} + if want.get("cols") and not rel[want["cols"]]: + problems.append(f"cols not {want['cols']} than {base}") + rel = {"taller": grid[1] > base[1], "shorter": grid[1] < base[1], "same": grid[1] == base[1]} + if want.get("rows") and not rel[want["rows"]]: + problems.append(f"rows not {want['rows']} than {base}") + detail["problems"] = problems + return not problems, detail + + +def cond_pane_frame(j, want, cache): + rec = named(j, want.get("pane")) + row = pane_row(j, rec["pane"], cache) + frame = frame_of(row) + if not frame: + return False, {"error": f"pane {rec['pane']} has no frame"} + detail = {"pane": rec["pane"], "frame": frame} + if "right_of" in want or "below" in want: + other = frame_of(pane_row(j, named(j, want.get("right_of") or want.get("below"))["pane"], cache)) + detail["other"] = other + if not other: + return False, detail + if "right_of" in want: + return frame[0] >= other[0] + other[2] - 1 and near(frame[1], other[1], 4), detail + return frame[1] >= other[1] + other[3] - 1 and near(frame[0], other[0], 4), detail + base = remembered(j, want["vs"])["pane_frame"] + tol = float(want.get("tol", 0.06)) + detail["vs"] = base + ok = True + if "w" in want: + ratio = frame[2] / base[2] + detail["w"] = round(ratio, 3) + ok = ok and near(ratio, want["w"], tol) + if "h" in want: + ratio = frame[3] / base[3] + detail["h"] = round(ratio, 3) + ok = ok and near(ratio, want["h"], tol) + if "dw" in want: + detail["dw"] = frame[2] - base[2] + ok = ok and near(frame[2] - base[2], want["dw"], float(want.get("tol_points", 24))) + return ok, detail + + +def cond_pane_state(j, want, cache): + rec = named(j, want["pane"]) + row = pane_row(j, rec["pane"], cache) + if row is None: + gone = want.get("gone", False) + return gone, {"pane": rec["pane"], "present": False} + if want.get("gone"): + return False, {"pane": rec["pane"], "present": True} + got = {k: row.get(k) for k in want.get("fields", {})} + return got == want.get("fields", {}), {"pane": rec["pane"], "got": got} + + +def tail(text, n): + return [line.rstrip() for line in (text or "").split("\n") if line.strip()][-n:] + + +def cond_at_bottom(j, want, cache): + want = want if isinstance(want, dict) else {} + rec = named(j, want.get("pane")) + n = int(want.get("lines", 3)) + row = pane_row(j, rec["pane"], cache) + app = tail((row or {}).get("text"), n) + daemon = tail(j.app.screen(rec["term"])["text"], n) + return bool(app) and app == daemon, {"app_tail": app, "daemon_tail": daemon} + + +def cond_output_advances(j, want, cache): + rec = named(j, want.get("pane")) + row = pane_row(j, rec["pane"], cache) + numbers = [int(m) for m in re.findall(want["pattern"], (row or {}).get("text") or "")] + top = max(numbers) if numbers else None + key = ("ext_advance", rec["pane"], want["pattern"]) + base = j.binds.setdefault(key, top) + if base is None and top is not None: + j.binds[key] = base = top + ok = top is not None and base is not None and top >= base + int(want.get("by", 10)) + return ok, {"first": base, "now": top} + + +def cond_pty_size(j, want, cache): + want = want if isinstance(want, dict) else {"tag": want} + rec = named(j, want.get("pane")) + cols, rows, screen = daemon_size(j, rec["term"]) + found = None + for line in screen["text"].split("\n"): + m = re.fullmatch(re.escape(want["tag"]) + r" (\d+) (\d+)", line.strip()) + if m: + found = (int(m.group(2)), int(m.group(1))) # stty size prints rows then cols + row = pane_row(j, rec["pane"], cache) + grid = parse_grid(((row or {}).get("surface") or {}).get("grid")) + detail = {"stty_cols_rows": found, "daemon": [cols, rows], "app_grid": grid} + return found is not None and found == (cols, rows) == grid, detail + + +def png_size(path): + with open(path, "rb") as f: + head = f.read(24) + if head[:8] != b"\x89PNG\r\n\x1a\n": + raise D.StepError(f"{path} is not a PNG") + return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big") + + +def crop_read(j, image, box, upscale=2): + """Text recognition of `box` ([x, y, w, h] pixels) of `image`, scaled by `upscale` (stock sips).""" + x, y, w, h = box + crop = image.with_name(image.stem + "-crop.png") + done = D.subprocess.run(["/usr/bin/sips", "--cropOffset", str(y), str(x), "-c", str(h), str(w), str(image), + "--out", str(crop)], capture_output=True, text=True, timeout=60) + if done.returncode == 0 and upscale != 1: + D.subprocess.run(["/usr/bin/sips", "--resampleWidth", str(int(w * upscale)), str(crop)], + capture_output=True, text=True, timeout=60) + if done.returncode != 0 or not crop.is_file(): + raise D.StepError(f"sips crop failed: {(done.stderr or done.stdout)[-200:]}") + try: + return j.app.read_text(crop) + finally: + crop.unlink() + + +def cond_pane_frame_text(j, want, cache): + """A fresh snapshot of the pane's window (kept whole as check-NN.png, as frame_text keeps it) + shows TEXT inside the pane, to text recognition (the driver's folding). + + Vision drops lines of a whole window (1920 points of mostly empty terminal: the last lines, or + every terminal line next to the sidebar's text; seen on this build's captures, where the frame + showed the text). So it reads, in order, until one shows TEXT: (1) the band of the pane's rows + around the row whose viewport text holds TEXT (two rows above and below, as wide as the longest + of those lines), scaled 2x; (2) the pane's whole terminal view, scaled 2x; (3) the whole window. + The row only says where to look: the pixels must still show the text.""" + want = want if isinstance(want, dict) else {"text": want} + text = want["text"] + rec = named(j, want.get("pane")) + j.checks += 1 + image = j.app.snapshot(j.out / f"check-{j.checks:02d}.png") + row = pane_row(j, rec["pane"]) + rect, grid = frame_of(row), (row or {}).get("grid") or {} + win = window_rec(j, rec["window"]) + if not rect or not win.get("frame"): + return False, {"image": image.name, "error": f"no frame for pane {rec['pane']}"} + width, height = png_size(image) + scale = width / float(win["frame"][2]) + reads, boxes = [], [] + lines_app = ((row or {}).get("text") or "").split("\n") + hits = [i for i, line in enumerate(lines_app) if text in line] + cw, ch = grid.get("cell_width"), grid.get("cell_height") + if hits and cw and ch and "origin_x" in grid: + first, last = max(0, hits[-1] - 2), hits[-1] + 2 + longest = max(len(l.rstrip()) for l in lines_app[first:last + 1]) + bx, by = grid["origin_x"] - 2, grid["origin_y"] + first * ch - 2 + bw = min((longest + 4) * cw, rect[0] + rect[2] - bx) + bh = min((last - first + 1) * ch + 4, rect[1] + rect[3] - by) + boxes.append(("rows", [round(bx * scale), round(by * scale), round(bw * scale), round(bh * scale)], 2)) + boxes.append(("pane", [round(v * scale) for v in rect], 2)) + for name, box, up in boxes: + x, y, w, h = (max(0, v) for v in box) + box = [x, y, min(w, width - x), min(h, height - y)] + lines = crop_read(j, image, box, up) + reads.append({"read": name, "crop_px": box, "text": lines[-8:], "lines": len(lines)}) + if D.ocr_contains(lines, text): + return True, {"image": image.name, "pane": rec["pane"], "scale": round(scale, 3), "reads": reads} + lines = j.app.read_text(image) + reads.append({"read": "window", "text": lines[-8:], "lines": len(lines)}) + return D.ocr_contains(lines, text), {"image": image.name, "pane": rec["pane"], "scale": round(scale, 3), + "app_row": hits[-1] if hits else None, "reads": reads} + + +CONDITIONS = {"pane_frame_text": ("frame", cond_pane_frame_text), "window_frame": ("app", cond_window_frame), "full_screen": ("app", cond_full_screen), + "grid_fits": ("app", cond_grid_fits), "pane_frame": ("app", cond_pane_frame), + "pane_state": ("app", cond_pane_state), "at_bottom": ("app", cond_at_bottom), + "output_advances": ("app", cond_output_advances), "pty_size": ("daemon", cond_pty_size)} diff --git a/scripts/journeys/fuzz.py b/scripts/journeys/fuzz.py new file mode 100644 index 000000000000..3eb3cc5b6244 --- /dev/null +++ b/scripts/journeys/fuzz.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""The action fuzzer: seeded, model-based runs of journeys/fuzz/action-fuzz.json (ext_fuzz.py), with an A/B of speed. + + scripts/journeys/fuzz.py --build JOB [--base JOB] [--minutes 10] [--seed S] [--diagnostic] [--corpus] + [--hog-swap-gb 14] [--hosts "cmux18 ..."] [--out DIR] + scripts/journeys/fuzz.py --build JOB --replay REPRO.json # one shrunk sequence, in a fresh lease + scripts/journeys/fuzz.py --plan --seed S [--count N] # print a seed's plan (no fleet) + +cmux-ad 2026-10-31 (Lawrence: "we need to fuzz greater for both correctness and speed"). One harness with the smoke +gate: each round is one run.py lease (`--exclusive`, pool perf-lead: no fleet step runs on the mini) that plays the +fuzz journey with a seed; the driver's action layer and the oracles are ext_fuzz.py's (its docstring lists them). + +- A/B: with --base, every round plays the SAME seed on the build and on the base, back to back on the same mini + (ABBA order over rounds), so each action's speed marks (key -> the new terminal drawn, key -> its prompt drawn, + Cmd-T -> the field has focus) are compared build against build on one host in one run, never against a fixed + number. A speed regression is red with the action, its timing kind and the seeds (see speed_table). +- Correctness: a failure of the build names the oracle, the seed and the shrunk sequence (ext_fuzz shrinks in the + app with ddmin). Each shrunk sequence is replayed in a fresh lease (confirmed: fails again with the same oracle) + and written as a regression journey (journeys/fuzz/found-*.json shape, priority P1) under OUT/repros/; the same + failure on the base is reported as a base finding, not a regression. +- --corpus plays the sequences of Lawrence's reported bugs first (ext_fuzz.CORPUS). +- --diagnostic: a capture slot has no key window (until the Cua grant on cmux17), so keys after a create wait out the + creation hold; typing at once into a new terminal, quick pairs of creates, and the focus oracle are weak. The + verdict is DIAGNOSTIC and no clean file is written. + +Verdict: CLEAN (no failure on the build, no speed regression, every round ran, at least --minutes of fuzz, not +diagnostic: writes HANDOFF/.clean, default /Users/lawrence/fun/wt/handoff/dogfood-fuzz), RED (a failure or a +speed regression), NOT JUDGED (a round did not run), DIAGNOSTIC. Writes OUT/summary.json and OUT/summary.txt. +Exit 0 CLEAN, 1 RED, 75 NOT JUDGED or DIAGNOSTIC without a red, 2 usage. Fleet only: never on a person's Mac. +""" +import argparse +import importlib.util +import json +import os +import statistics +import subprocess +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(HERE)) +import ext_fuzz # noqa: E402 (plan, CORPUS; nothing of it runs at import) + +JOURNEY = "action-fuzz" +HANDOFF = Path("/Users/lawrence/fun/wt/handoff/dogfood-fuzz") +# cmux17 is reserved for the Cua grant (STATUS "CUA GRANTED cmux17"); cmux16 is Leo's builder. +DEFAULT_HOSTS = "cmux18 cmux19 cmux20 cmux21 cmux22 cmux23 cmux24" +# A speed regression: at least MIN_SAMPLES on each side, the build's median above the base's by RATIO and by +# FLOOR_MS, and its p90 worse too (one slow sample does not make a red). +MIN_SAMPLES = 4 +RATIO = 1.25 +FLOOR_MS = 40 + + +def load_gate(): + """smoke-gate.py's helpers (idle_first, exclusive_line, pressure): the same lease rules as the gate.""" + spec = importlib.util.spec_from_file_location("smoke_gate", HERE / "smoke-gate.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def pct(values, q): + values = sorted(values) + if not values: + return None + k = (len(values) - 1) * q + lo, hi = int(k), min(int(k) + 1, len(values) - 1) + return round(values[lo] + (values[hi] - values[lo]) * (k - lo), 1) + + +def speed_table(rows): + """rows: [{build: "test"|"base", seed, a, t, shown_ms, typeable_ms}] -> per (action, timing, mark): each side's + n, p50, p90, max, and `red` when the build is slower by the rule above.""" + groups = {} + for r in rows: + for mark in ("shown_ms", "typeable_ms"): + if r.get(mark) is None: + continue + key = (r["a"], r["t"] if r["t"] == "slow" else "quick", mark) + side = groups.setdefault(key, {"test": [], "base": [], "seeds": set()}) + side[r["build"]].append(float(r[mark])) + side["seeds"].add(r["seed"]) + table = [] + for (action, timing, mark), g in sorted(groups.items()): + row = {"action": action, "timing": timing, "mark": mark, "seeds": sorted(g["seeds"])[:12]} + for side in ("test", "base"): + v = g[side] + row[side] = {"n": len(v), "p50": pct(v, 0.5), "p90": pct(v, 0.9), "max": max(v) if v else None} + t, b = row["test"], row["base"] + row["red"] = bool(t["n"] >= MIN_SAMPLES and b["n"] >= MIN_SAMPLES and t["p50"] > b["p50"] * RATIO + and t["p50"] - b["p50"] >= FLOOR_MS and t["p90"] >= b["p90"]) + row["delta_p50_ms"] = round(t["p50"] - b["p50"], 1) if t["p50"] is not None and b["p50"] is not None else None + table.append(row) + return table + + +def fuzz_report(out): + path = out / JOURNEY / "fuzz.json" + if not path.is_file(): + return None + try: + return json.loads(path.read_text()) + except ValueError: + return None + + +def journey_row(out): + path = out / "results.jsonl" + if not path.is_file(): + return {"status": "not run", "reason": "run.py wrote no results.jsonl"} + for line in path.read_text().splitlines(): + try: + row = json.loads(line) + except ValueError: + continue + if row.get("id") == JOURNEY: + return row + return {"status": "not run", "reason": "no action-fuzz line"} + + +def repro_journey(failure, sequence, seed, build): + """A regression journey that plays SEQUENCE once (no shrink) and must stay clean: red on the build it was found on.""" + ident = f"found-{failure['oracle']}-{seed}".lower() + spec = json.loads((HERE / "journeys" / "fuzz" / "action-fuzz.json").read_text()) + spec.update(id=ident, priority="P1", + title=f"fuzz repro ({failure['oracle']}): {' '.join(i['a'] for i in sequence)[:160]}", + why=f"action fuzz, seed {seed}, build {build}: {failure['detail'][:300]}", + source="scripts/journeys/fuzz.py (shrunk by ext_fuzz.ddmin)") + for step in spec["steps"]: + if step["do"] == "fuzz_run": + step.clear() + step.update({"do": "fuzz_run", "sequence": sequence, "shrink": False, "budget_s": 150}) + return ident, spec + + +class Runner: + def __init__(self, opts, gate): + self.opts, self.gate = opts, gate + + def lease(self, build, out, options, host=None): + """One run.py round: the fuzz journey on BUILD in an exclusive lease (on HOST when given); (exit code, + journey row, fuzz report, the mini it ran on).""" + opts = self.opts + env = {**os.environ, + "CAPTURE_HOST_ACQUIRE_TIMEOUT_SECONDS": os.environ.get("CAPTURE_HOST_ACQUIRE_TIMEOUT_SECONDS", "600"), + "CAPTURE_HOST_EXCLUSIVE_DRAIN_SECONDS": os.environ.get("CAPTURE_HOST_EXCLUSIVE_DRAIN_SECONDS", "900"), + "CAPTURE_HOST_POOL": os.environ.get("CAPTURE_HOST_POOL", self.gate.OWNER_POOL), + "JOURNEYS_EXCLUDE_HOSTS": " ".join(x for x in (os.environ.get("JOURNEYS_EXCLUDE_HOSTS", "cmux16"), + "" if opts.allow_cmux17 else "cmux17") if x)} + hosts = host or opts.host or self.gate.idle_first(opts.hosts) + env["CAPTURE_HOST_HOSTS"] = env["CAPTURE_HOST_SLOT_HOSTS"] = hosts + argv = [sys.executable, opts.runner, "--build", build, "--journey", JOURNEY, "--exclusive", "--hosts", hosts, + "--out", str(out)] + if opts.hog_swap_gb: + argv += ["--hog-swap-gb", str(opts.hog_swap_gb)] + for key, value in options.items(): + argv += ["--journey-option", f"{key}={value}"] + if opts.diagnostic: + argv += ["--journey-option", "fuzz_mode=diagnostic"] + out.mkdir(parents=True, exist_ok=True) + with open(out / "run.log", "w") as log: # live, so a long round can be followed + done = subprocess.run(argv, stdout=log, stderr=subprocess.STDOUT, text=True, env=env) + row = journey_row(out) + return done.returncode, row, fuzz_report(out), (row.get("host") or None) + + +def summarize(rounds, opts, started): + test = [r for r in rounds if r["build"] == "test"] + fails = [r for r in test if r.get("failure")] + base_fails = [r for r in rounds if r["build"] == "base" and r.get("failure")] + not_run = [r for r in rounds if r["status"] == "not run" and not r.get("failure")] + speed = speed_table([dict(s, build=r["build"], seed=r["seed"]) for r in rounds for s in r.get("speed") or []]) + reds = [s for s in speed if s["red"]] + fuzz_s = sum(r.get("seconds") or 0 for r in test) + actions = sum(r.get("played") or 0 for r in test) + if fails or reds: + verdict = "RED" + elif opts.diagnostic: + verdict = "DIAGNOSTIC (NOT JUDGED)" + elif not_run or fuzz_s < opts.minutes * 60 * 0.6: + verdict = "NOT JUDGED" + else: + verdict = "CLEAN" + return {"build": opts.build, "base": opts.base, "set": opts.set, "verdict": verdict, "diagnostic": opts.diagnostic, + "rounds": len(rounds), "test_rounds": len(test), "not_run": len(not_run), "actions": actions, + "fuzz_seconds": round(fuzz_s), "wall_seconds": round(time.time() - started), + "seeds": sorted({r["seed"] for r in test if r.get("seed") is not None}), + "failures": [{k: r.get(k) for k in ("round", "seed", "host", "failure", "shrunk", "confirmed", "repro", "corpus")} for r in fails], + "base_failures": [{k: r.get(k) for k in ("round", "seed", "host", "failure", "shrunk", "corpus")} for r in base_fails], + "speed": speed, "speed_reds": reds, + "weak_oracles": (["typeable at once (keys wait out the 2.5 s creation hold after a create)", + "quick pairs of creates (the second key waits too)", + "focus (a slot has no key window; AppKit has no first responder to compare)"] + if opts.diagnostic else []), + "rounds_detail": rounds} + + +def text_of(summary): + s = summary + lines = [f"action fuzz ({s['set']}) of build {s['build']}" + (f" vs base {s['base']}" if s["base"] else "") + + f": {s['verdict']} ({s['test_rounds']} rounds, {s['actions']} actions, {s['fuzz_seconds']} s of fuzz, " + f"{s['not_run']} rounds not run; seeds {s['seeds'][:10]})"] + if s["weak_oracles"]: + lines.append("DIAGNOSTIC (no key window on a slot): weak oracles: " + "; ".join(s["weak_oracles"])) + for f in s["failures"]: + fl = f["failure"] or {} + lines.append(f" FAIL {fl.get('oracle')} seed {f['seed']} round {f['round']} on {f['host']}: {str(fl.get('detail'))[:220]}") + lines.append(f" shrunk to {len(f['shrunk'] or [])} actions: {json.dumps(f['shrunk'])[:400]}" + f" (confirmed in a fresh lease: {f.get('confirmed')}; repro {f.get('repro')})") + for f in s["base_failures"]: + fl = f["failure"] or {} + lines.append(f" base finding {fl.get('oracle')} seed {f['seed']}: {str(fl.get('detail'))[:200]}") + for row in s["speed"]: + if row["test"]["n"] or row["base"]["n"]: + lines.append(f" speed {row['action']:<15} {row['timing']:<5} {row['mark']:<12} build p50 {row['test']['p50']} " + f"p90 {row['test']['p90']} (n {row['test']['n']}) | base p50 {row['base']['p50']} p90 " + f"{row['base']['p90']} (n {row['base']['n']}) | delta p50 {row['delta_p50_ms']} ms" + + (" RED" if row["red"] else "")) + return "\n".join(lines) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("\n\n", 1)[1]) + ap.add_argument("--build", help="the fleet build job id under test") + ap.add_argument("--base", help="the base build job id for the speed A/B (same seeds, same mini, interleaved)") + ap.add_argument("--minutes", type=float, default=10.0, help="fuzz time on the build (the base gets as much)") + ap.add_argument("--seed", type=int, help="the first seed (default: from the clock); round N plays seed + N - 1") + ap.add_argument("--count", type=int, default=300, help="actions planned per round (a round stops at its budget)") + ap.add_argument("--set", default="layout", choices=sorted(ext_fuzz.SETS), + help="the action set: layout (fuzz-catalog row m, the first mode: R3a/R3b under New Column, Cmd-D, " + "Split Down, Ctrl-`, close pane, divider drag, window resize, relaunch) or full (every action)") + ap.add_argument("--corpus", action="store_true", help="play Lawrence's reported-bug sequences first") + ap.add_argument("--diagnostic", action="store_true", help="a slot with no key window: keys wait out the creation hold") + ap.add_argument("--hog-swap-gb", type=float, default=0.0, help="the memory hog (smoke_hog.py) for each round") + ap.add_argument("--hosts", default=DEFAULT_HOSTS) + ap.add_argument("--host", help="pin every round to this mini (the A/B is then on one host for the whole run)") + ap.add_argument("--allow-cmux17", action="store_true", help="only after STATUS says CUA GRANTED cmux17") + ap.add_argument("--replay", help="a repro JSON (a sequence list, or a file with `shrunk`/`sequence`): one round") + ap.add_argument("--plan", action="store_true", help="print the plan of --seed and exit (no fleet)") + ap.add_argument("--out") + ap.add_argument("--handoff", default=str(HANDOFF)) + ap.add_argument("--runner", default=str(HERE / "run.py"), help=argparse.SUPPRESS) # tests: a stand-in for run.py + opts = ap.parse_args(argv) + seed0 = opts.seed if opts.seed is not None else int(time.time()) % 1000000 + if opts.plan: + for item in ext_fuzz.plan(seed0, opts.count, actions=opts.set): + print(json.dumps(item)) + return 0 + if not opts.build: + ap.error("--build JOB is required") + if opts.host and opts.host == "cmux17" and not opts.allow_cmux17: + ap.error("cmux17 is reserved for the Cua grant: --allow-cmux17 only after STATUS says CUA GRANTED cmux17") + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + out = Path(opts.out) if opts.out else ROOT / "artifacts" / "fuzz" / f"{opts.build[:12]}-{stamp}" + if out.exists() and any(out.iterdir()): + ap.error(f"--out {out} is not empty") + out.mkdir(parents=True, exist_ok=True) + gate = load_gate() + runner = Runner(opts, gate) + started = time.time() + rounds = [] + + # The work: (label, options) per round. Corpus entries first, then seeds until the time is spent. + work = [] + if opts.replay: + data = json.loads(Path(opts.replay).read_text()) + seq = data if isinstance(data, list) else (data.get("shrunk") or data.get("sequence")) + work.append(("replay", {"fuzz_sequence": json.dumps(seq, separators=(",", ":")), "fuzz_shrink": "false"}, None)) + else: + if opts.corpus: + for name, seq in ext_fuzz.CORPUS.items(): + work.append((name, {"fuzz_sequence": json.dumps(seq, separators=(",", ":"))}, None)) + + def play(label, options, seed, n): + # ABBA over rounds; the second side plays on the mini the first one got. + order = ["test", "base"] if not opts.base or n % 2 == 1 else ["base", "test"] + host = opts.host + for side in order: + if side == "base" and not opts.base: + continue + job = opts.build if side == "test" else opts.base + r_out = out / f"round-{n:03d}-{side}" + code, row, report, used = runner.lease(job, r_out, options, host) + host = host or used + rec = {"round": n, "build": side, "job": job, "label": label, "seed": seed if seed is not None else (report or {}).get("seed"), + "host": used, "status": row.get("status"), "reason": (row.get("reason") or "")[:300], "exit": code, + "played": (report or {}).get("played"), "seconds": (report or {}).get("seconds"), + "failure": (report or {}).get("failure"), "shrunk": (report or {}).get("shrunk"), + "speed": (report or {}).get("speed") or [], "corpus": label if label not in ("seed", "replay") else None, + "exclusive": gate.exclusive_line(r_out), "pressure": gate.pressure(r_out, opts.hog_swap_gb) if opts.hog_swap_gb else None} + rounds.append(rec) + print(f"round {n} {side} ({label}, seed {rec['seed']}) on {used}: {rec['status']}" + + (f" FAIL {rec['failure']['oracle']}: {rec['failure']['detail'][:160]}" if rec["failure"] else + f" ({rec['reason'][:160]})" if rec["status"] != "pass" else f" {rec['played']} actions"), flush=True) + + n = 0 + for label, options, seed in work: + n += 1 + play(label, options, seed, n) + if not opts.replay: + budget = opts.minutes * 60 + while sum(r.get("seconds") or 0 for r in rounds if r["build"] == "test") < budget: + n += 1 + seed = seed0 + n - 1 + play("seed", {"fuzz_seed": str(seed), "fuzz_count": str(opts.count), "fuzz_set": opts.set}, seed, n) + test_rounds = [r for r in rounds if r["build"] == "test"] + if len(test_rounds) >= 3 and not any(r.get("seconds") for r in test_rounds[-3:]): + print("fuzz: three rounds in a row played nothing; stopping", flush=True) + break + if time.time() - started > budget * 6 + 1800: + print("fuzz: the wall clock is six times the fuzz budget; stopping", flush=True) + break + + # Confirm each failure of the build in a fresh lease with its shrunk sequence, and write it as a journey. + repros = out / "repros" + for r in [r for r in rounds if r["build"] == "test" and r.get("failure")]: + seq = r.get("shrunk") or None + if not seq: + report = fuzz_report(out / f"round-{r['round']:03d}-test") or {} + seq = report.get("sequence") + if not seq: + continue + repros.mkdir(exist_ok=True) + ident, spec = repro_journey(r["failure"], seq, r["seed"], opts.build) + (repros / f"{ident}.json").write_text(json.dumps(spec, indent=1) + "\n") + r["repro"] = str(repros / f"{ident}.json") + c_out = out / f"confirm-{r['round']:03d}" + code, row, report, used = runner.lease(opts.build, c_out, {"fuzz_sequence": json.dumps(seq, separators=(",", ":")), + "fuzz_shrink": "false"}) + again = (report or {}).get("failure") + r["confirmed"] = bool(again) and again.get("oracle") == r["failure"]["oracle"] + r["confirm_detail"] = (again or {}).get("detail") or row.get("reason") + + summary = summarize(rounds, opts, started) + summary["out"] = str(out) + (out / "summary.json").write_text(json.dumps(summary, indent=1, default=str) + "\n") + text = text_of(summary) + (out / "summary.txt").write_text(text + "\n") + print(text) + if summary["verdict"] == "CLEAN": + handoff = Path(opts.handoff) + handoff.mkdir(parents=True, exist_ok=True) + (handoff / f"{opts.build}.clean").write_text(json.dumps({k: v for k, v in summary.items() if k != "rounds_detail"}, + indent=1, default=str) + "\n") + print(f"wrote {handoff / (opts.build + '.clean')}") + return 0 + return 1 if summary["verdict"] == "RED" else 75 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/journeys/journeys/dogfood/smoke-dogfood.json b/scripts/journeys/journeys/dogfood/smoke-dogfood.json new file mode 100644 index 000000000000..a45fb33d7794 --- /dev/null +++ b/scripts/journeys/journeys/dogfood/smoke-dogfood.json @@ -0,0 +1,145 @@ +{ + "id": "smoke-dogfood", + "priority": "P1", + "title": "Dogfood smoke: 3 workspaces (Cmd-N, Ctrl-`), Cmd-D twice and Ctrl-` twice fast with a line typed into each new terminal at once, a browser page, quit and reopen (the same workspaces come back), close all", + "why": "cmux-ad 2026-10-30 (stop the line, item 2): a build goes to Lawrence only when this passes 10 of 10 rounds under memory pressure (scripts/journeys/smoke-gate.py). Keys typed into a terminal while it is created are the bug class Lawrence reports, so the lines are typed at once, with no wait for the creation hold.", + "source": "the dogfood smoke gate (scripts/journeys/smoke-gate.py; README)", + "budget_seconds": 250, + "steps": [ + {"do": "smoke_ready", "timeout": 60}, + {"do": "smoke_remember_workspaces"}, + {"do": "smoke_make_key"}, + {"do": "smoke_snapshot", "name": "before_workspaces"}, + {"do": "key", "key": "n", "modifiers": ["cmd"], "mark": "ws1"}, + {"do": "expect", "title": "Cmd-N (1 of 3) makes a workspace; Ctrl-` follows as soon as it shows, at a person's speed (the frame kept: not blank)", "since": "ws1", "timeout": 20, + "all": [ + {"smoke_workspaces": {"new": 1}}, + {"smoke_frame": {}} + ]}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "wsterm1"}, + {"do": "smoke_type_now", "text": "echo w1-${nonce}-$((6*7))"}, + {"do": "key", "key": "n", "modifiers": ["cmd"], "mark": "ws2"}, + {"do": "expect", "title": "Cmd-N (2 of 3) makes a workspace; Ctrl-` follows as soon as it shows, at a person's speed (the frame kept: not blank)", "since": "ws2", "timeout": 20, + "all": [ + {"smoke_workspaces": {"new": 2}}, + {"smoke_frame": {}} + ]}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "wsterm2"}, + {"do": "smoke_type_now", "text": "echo w2-${nonce}-$((6*7))"}, + {"do": "key", "key": "n", "modifiers": ["cmd"], "mark": "ws3"}, + {"do": "expect", "title": "Cmd-N (3 of 3) makes a workspace; Ctrl-` follows as soon as it shows, at a person's speed (the frame kept: not blank)", "since": "ws3", "timeout": 20, + "all": [ + {"smoke_workspaces": {"new": 3}}, + {"smoke_frame": {}} + ]}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "wsterm3"}, + {"do": "smoke_type_now", "text": "echo w3-${nonce}-$((6*7))"}, + {"do": "expect", "title": "each new workspace's Ctrl-` terminal ran the line typed into it at once, and no other terminal got it", "since": "wsterm1", "timeout": 30, + "all": [ + {"smoke_new_terminals": {"since": "before_workspaces", "lines": ["w1-${nonce}-42", "w2-${nonce}-42", "w3-${nonce}-42"], "focused": "w3-${nonce}-42", "placement": "new_workspaces"}}, + {"smoke_key_window": {}}, + {"frame_screen": {"shows": ["w3-${nonce}-42"]}} + ]}, + {"do": "smoke_snapshot", "name": "before_splits"}, + {"do": "key", "key": "d", "modifiers": ["cmd"], "mark": "split1"}, + {"do": "smoke_type_now", "text": "echo d1-${nonce}-$((6*7))"}, + {"do": "key", "key": "d", "modifiers": ["cmd"], "mark": "split2"}, + {"do": "smoke_type_now", "text": "echo d2-${nonce}-$((6*7))"}, + {"do": "expect", "title": "Cmd-D twice fast: two new panes, and the line typed into each at once ran there and nowhere else", "since": "split1", "timeout": 30, + "all": [ + {"smoke_panes": {"line": "d2-${nonce}-42", "count": 3}}, + {"smoke_new_terminals": {"since": "before_splits", "lines": ["d1-${nonce}-42", "d2-${nonce}-42"], "focused": "d2-${nonce}-42", "placement": "new_panes"}}, + {"smoke_app_lines": {"lines": ["d1-${nonce}-42", "d2-${nonce}-42"]}}, + {"smoke_key_window": {}}, + {"frame_screen": {"shows": ["d1-${nonce}-42", "d2-${nonce}-42"]}} + ]}, + {"do": "smoke_adopt_line", "line": "d1-${nonce}-42", "as": "left"}, + {"do": "smoke_adopt_line", "line": "d2-${nonce}-42", "as": "right"}, + {"do": "remember", "as": "left0", "pane": "left"}, + {"do": "remember", "as": "right0", "pane": "right"}, + {"do": "divider_drag", "title": "R3a: drag the divider between the two new panes 120 points left", "between": ["left", "right"], "by": -120, "mark": "r3a-split"}, + {"do": "type", "text": "echo r3as-${nonce} $(stty size)", "enter": true}, + {"do": "smoke_long_line", "tag": "r3asw${nonce}"}, + {"do": "expect", "title": "R3a split resize: the right pane is wider, the left narrower; in each, grid == fit(bounds) == PTY == daemon size, and a long line wraps at the new width", "since": "r3a-split", "timeout": 20, "settle": 1.5, + "all": [ + {"smoke_r3": {"pane": "right", "pty_tag": "r3as-${nonce}", "long_tag": "r3asw${nonce}", "vs": "right0", "cols": "wider"}}, + {"smoke_r3": {"pane": "left", "vs": "left0", "cols": "narrower"}}, + {"smoke_frame": {}} + ]}, + {"do": "remember", "as": "left1", "pane": "left"}, + {"do": "remember", "as": "right1", "pane": "right"}, + {"do": "window_drag", "title": "R3a: drag the window's right edge 160 points left", "edge": "right", "by": [-160, 0], "mark": "r3a-window"}, + {"do": "type", "text": "echo r3aw-${nonce} $(stty size)", "enter": true}, + {"do": "smoke_long_line", "tag": "r3aww${nonce}"}, + {"do": "expect", "title": "R3a window resize: the window is 160 points narrower and the right pane narrower; grid == fit(bounds) == PTY == daemon size in the shown terminal, and a long line wraps at the new width", "since": "r3a-window", "timeout": 20, "settle": 1.5, + "all": [ + {"window_frame": {"from": "right1", "dw": -160, "tol": 4}}, + {"smoke_r3": {"pane": "right", "pty_tag": "r3aw-${nonce}", "long_tag": "r3aww${nonce}", "vs": "right1", "cols": "narrower"}}, + {"smoke_r3": {"pane": "left"}}, + {"smoke_frame": {}} + ]}, + {"do": "smoke_widths", "name": "cols0"}, + {"do": "action", "action": "newColumn", "mark": "r3b-new"}, + {"do": "expect", "title": "R3b: New Column adds a column and every existing column keeps exactly its width", "since": "r3b-new", "timeout": 15, "settle": 1.5, + "all": [ + {"smoke_same_widths": {"since": "cols0", "new": 1}}, + {"smoke_frame": {}} + ]}, + {"do": "smoke_widths", "name": "cols1"}, + {"do": "key", "key": "d", "modifiers": ["control", "cmd"], "mark": "r3b-split"}, + {"do": "expect", "title": "R3b: New Column's key (Ctrl-Cmd-D) adds a column and every existing column keeps exactly its width", "since": "r3b-split", "timeout": 15, "settle": 1.5, + "all": [ + {"smoke_same_widths": {"since": "cols1", "new": 1}}, + {"smoke_frame": {}} + ]}, + {"do": "smoke_snapshot", "name": "before_tabs"}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "tab1"}, + {"do": "smoke_type_now", "text": "echo t1-${nonce}-$((6*7))"}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "tab2"}, + {"do": "smoke_type_now", "text": "echo t2-${nonce}-$((6*7))"}, + {"do": "expect", "title": "Ctrl-` twice fast: two new tabs in the focused pane, and the line typed into each at once ran there and nowhere else", "since": "tab1", "timeout": 30, + "all": [ + {"smoke_new_terminals": {"since": "before_tabs", "lines": ["t1-${nonce}-42", "t2-${nonce}-42"], "focused": "t2-${nonce}-42", "placement": "focused_pane"}}, + {"smoke_app_lines": {"lines": ["t2-${nonce}-42"]}}, + {"smoke_key_window": {}}, + {"frame_screen": {"shows": ["t2-${nonce}-42"]}} + ]}, + {"do": "key", "key": "l", "modifiers": ["cmd", "shift"], "mark": "browser"}, + {"do": "expect", "title": "Cmd-Shift-L opens a browser tab whose address bar takes the keys", "since": "browser", "timeout": 15, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "addressBar"}} + ]}, + {"do": "type", "text": "example.com", "mark": "url"}, + {"do": "expect", "title": "the typed address shows in the address bar", "since": "url", "timeout": 15, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "addressBar"}}, + {"frame_text": "example.com"} + ]}, + {"do": "key", "key": "return", "modifiers": [], "mark": "loaded"}, + {"do": "expect", "title": "the browser tab loads the page", "since": "loaded", "timeout": 45, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "browserPage"}}, + {"frame_text": "documentation examples"} + ]}, + {"do": "relaunch", "how": "quit", "mark": "reopened"}, + {"do": "expect", "title": "after the reopen: one window, a clean launch, no blank pane, the same three workspaces with their output, and the browser page", "since": "reopened", "timeout": 60, + "all": [ + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "clean"}}, + {"app_rpc": {"method": "debug.surfaces", "path": "blank_panes", "equals": 0}}, + {"smoke_workspaces": {"new": 3, "same_as": "before_splits"}}, + {"smoke_panes": {"line": "d2-${nonce}-42", "count": 5}}, + {"smoke_daemon_lines": {"lines": ["w1-${nonce}-42", "w2-${nonce}-42", "w3-${nonce}-42", "d1-${nonce}-42", "d2-${nonce}-42", "t1-${nonce}-42", "t2-${nonce}-42"]}}, + {"frame_text": "documentation examples"} + ]}, + {"do": "smoke_close_all", "mark": "closed"}, + {"do": "expect", "title": "close all: the journey's workspaces are gone from the app and the window", "since": "closed", "timeout": 20, + "all": [ + {"smoke_workspaces": {"new": 0}}, + {"smoke_frame": {"lacks": ["documentation examples", "w3-${nonce}-42", "d2-${nonce}-42", "t2-${nonce}-42"]}} + ]} + ], + "finally": [ + {"do": "smoke_close_all"} + ] +} diff --git a/scripts/journeys/journeys/fuzz/action-fuzz.json b/scripts/journeys/journeys/fuzz/action-fuzz.json new file mode 100644 index 000000000000..23292629fce6 --- /dev/null +++ b/scripts/journeys/journeys/fuzz/action-fuzz.json @@ -0,0 +1,48 @@ +{ + "id": "action-fuzz", + "priority": "P2", + "title": "Model-based action fuzz: seeded random user actions at mixed timings (set `layout`: New Column, Cmd-D, Split Down, Ctrl-`, close pane, divider drag, window resize, relaunch; set `full`: also Cmd-T, Cmd-N, closes, switches, typing, paste, a browser URL, close all), with every oracle after each burst", + "why": "cmux-ad 2026-10-31 (Lawrence: \"we need to fuzz greater for both correctness and speed\"): one fuzzer with the smoke gate's action layer and oracles. A create that gives no terminal and no refusal, R3a (grid == fit == PTY == daemon), R3b (a new column never resizes an old one), focus != first responder, daemon != app topology, a main-thread stall over 100 ms in cmux code, a crash or a leak after close-all fails it; fuzz.py compares the speed marks against the base build.", + "source": "scripts/journeys/fuzz.py (ext_fuzz.py); the seed and the sequence come from the journey options fuzz_seed, fuzz_count, fuzz_sequence, fuzz_budget_s, fuzz_mode", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "count": 300, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-cmd-d-twice.json b/scripts/journeys/journeys/fuzz/lawrence-cmd-d-twice.json new file mode 100644 index 000000000000..a88bfd42a7be --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-cmd-d-twice.json @@ -0,0 +1,70 @@ +{ + "id": "lawrence-cmd-d-twice", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_terminal split_right split_right type_line), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-30/31: the second Cmd-D of a quick pair made no terminal (smoke gate run 2, round 4; STATUS 901).", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "split_right", + "t": "pair" + }, + { + "a": "split_right", + "t": "slow" + }, + { + "a": "type_line", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-cmd-t-slow.json b/scripts/journeys/journeys/fuzz/lawrence-cmd-t-slow.json new file mode 100644 index 000000000000..20d216733e83 --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-cmd-t-slow.json @@ -0,0 +1,70 @@ +{ + "id": "lawrence-cmd-t-slow", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_terminal new_tab new_tab new_tab), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-31 (STATUS 914 A): Cmd-T is slow on feat-tip-fixes-v1; its typeable mark is compared to the base build.", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "new_tab", + "t": "slow" + }, + { + "a": "new_tab", + "t": "pair" + }, + { + "a": "new_tab", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-column-widths.json b/scripts/journeys/journeys/fuzz/lawrence-column-widths.json new file mode 100644 index 000000000000..adfbf7a4059a --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-column-widths.json @@ -0,0 +1,70 @@ +{ + "id": "lawrence-column-widths", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_terminal split_right split_right split_right), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-31 05:3xZ (STATUS 913): \"creating new columns must never resize existing columns\" (R3b).", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "split_right", + "t": "slow" + }, + { + "a": "split_right", + "t": "slow" + }, + { + "a": "split_right", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-ctrl-backtick-twice.json b/scripts/journeys/journeys/fuzz/lawrence-ctrl-backtick-twice.json new file mode 100644 index 000000000000..422a95f0da83 --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-ctrl-backtick-twice.json @@ -0,0 +1,70 @@ +{ + "id": "lawrence-ctrl-backtick-twice", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_terminal new_terminal new_terminal type_line), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-30/31: the second Ctrl-` of a quick pair made no tab, or its typing was cut (STATUS 901 rounds 1 and 9).", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "pair" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "type_line", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-no-new-workspace.json b/scripts/journeys/journeys/fuzz/lawrence-no-new-workspace.json new file mode 100644 index 000000000000..189c3dfda031 --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-no-new-workspace.json @@ -0,0 +1,70 @@ +{ + "id": "lawrence-no-new-workspace", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_workspace new_workspace new_terminal type_line), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-30 (handoff v2210-lawrence-noworkspace-2026-10-30): Cmd-N made no new workspace (\"closed by client\").", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "now" + }, + { + "a": "new_workspace", + "t": "now" + }, + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "type_line", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/fuzz/lawrence-resize.json b/scripts/journeys/journeys/fuzz/lawrence-resize.json new file mode 100644 index 000000000000..11f08a5e6678 --- /dev/null +++ b/scripts/journeys/journeys/fuzz/lawrence-resize.json @@ -0,0 +1,87 @@ +{ + "id": "lawrence-resize", + "priority": "P1", + "title": "fuzz corpus: Lawrence's report as a fixed sequence (new_workspace new_terminal split_right resize_window resize_window drag_divider type_line), every fuzz oracle after each burst", + "why": "Lawrence 2026-10-31 (handoff v2211-lawrence-resize-2026-10-31; STATUS 912): after a window resize and a split drag the grid, PTY and daemon disagree (R3a).", + "source": "ext_fuzz.CORPUS (scripts/journeys/fuzz.py --corpus plays the same sequences)", + "budget_seconds": 255, + "steps": [ + { + "do": "smoke_ready", + "timeout": 60 + }, + { + "do": "smoke_remember_workspaces" + }, + { + "do": "smoke_make_key" + }, + { + "do": "fuzz_run", + "sequence": [ + { + "a": "new_workspace", + "t": "slow" + }, + { + "a": "new_terminal", + "t": "slow" + }, + { + "a": "split_right", + "t": "slow" + }, + { + "a": "resize_window", + "t": "slow", + "by": [ + -320, + -160 + ] + }, + { + "a": "resize_window", + "t": "slow", + "by": [ + 300, + 140 + ] + }, + { + "a": "drag_divider", + "t": "slow", + "by": -120 + }, + { + "a": "type_line", + "t": "slow" + } + ], + "shrink": true, + "budget_s": 190 + }, + { + "do": "expect", + "title": "the fuzz found no failure, the daemon agrees with the app, and the window is not blank", + "timeout": 20, + "all": [ + { + "fuzz_clean": {} + }, + { + "fuzz_daemon_agrees": {} + }, + { + "smoke_frame": { + "min_lines": 1 + } + } + ] + } + ], + "finally": [ + { + "do": "smoke_close_all" + } + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-crash-recovers.json b/scripts/journeys/journeys/p0/app-life-crash-recovers.json new file mode 100644 index 000000000000..5abd80bbaef0 --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-crash-recovers.json @@ -0,0 +1,52 @@ +{ + "id": "app-life-crash-recovers", + "priority": "P0", + "title": "After a crash, the next launch says so and brings the terminals back, still running", + "why": "A crash must never cost the person their terminals: the daemon keeps them, and the next launch must reattach them, show the restart notice (with its cause) and take input. The crash is a real SIGSEGV in the app process (debug.crash.app), so none of the quit path runs.", + "source": "app life (holistic-quality.md 1): crash recovery", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-crash", "as": "shell"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "mkdir -p /tmp/jr-${nonce}-crash && cd /tmp/jr-${nonce}-crash && echo crash-${nonce}-$((6*7))", "enter": true, "mark": "seeded"}, + {"do": "expect", "title": "the shell answers before the crash", "since": "seeded", "timeout": 10, + "all": [{"daemon_line": "crash-${nonce}-42"}, {"daemon_cwd": "/tmp/jr-${nonce}-crash"}]}, + {"do": "type", "text": "sleep 300", "enter": true}, + {"do": "expect", "title": "a long job runs in the terminal when the app crashes", "timeout": 10, + "all": [{"process_present": {"match": "^sleep 300$", "bind": "job", "foreground": true}}]}, + + {"do": "relaunch", "how": "crash", "mark": "reopened"}, + + {"do": "use", "name": "shell"}, + {"do": "expect", "title": "the launch knows the last run crashed and says so", "since": "reopened", "timeout": 45, + "all": [ + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "restarted"}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.previous.signal", "equals": 11}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.notice", "contains": "restarted after a problem"}}, + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"frame_text": "cmux restarted after a problem"} + ]}, + {"do": "expect", "title": "the terminal came back attached, in its folder, its job still running", "since": "reopened", "timeout": 45, + "all": [ + {"app_layout": {"workspace": "jr-crash", "panes": 1, "tabs": 1, "selected": true}}, + {"app_cwd": "/tmp/jr-${nonce}-crash"}, + {"daemon_cwd": "/tmp/jr-${nonce}-crash"}, + {"app_pane": {"link": "connected"}}, + {"process_present": {"match": "^sleep 300$", "bind": "job-after", "foreground": true}}, + {"daemon_line": "crash-${nonce}-42"}, + {"app_text_line": "crash-${nonce}-42"}, + {"frame_text": "crash-${nonce}-42"} + ]}, + {"do": "key", "key": "c", "modifiers": ["control"], "mark": "interrupt"}, + {"do": "expect", "title": "the job takes Ctrl+C after the crash and the prompt returns", "since": "interrupt", "within": 2, "timeout": 8, + "all": [{"process_gone": "job-after"}, {"daemon_prompt": true}, {"app_prompt": true}]}, + {"do": "type", "text": "echo alive-${nonce}-$(basename $PWD)", "enter": true, "mark": "alive"}, + {"do": "expect", "title": "the shell takes input after the crash", "since": "alive", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "alive-${nonce}-jr-${nonce}-crash"}, + {"app_text_line": "alive-${nonce}-jr-${nonce}-crash"}, + {"frame_text": "alive-${nonce}-jr-${nonce}-crash"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-first-launch.json b/scripts/journeys/journeys/p0/app-life-first-launch.json new file mode 100644 index 000000000000..0e170473b4e1 --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-first-launch.json @@ -0,0 +1,41 @@ +{ + "id": "app-life-first-launch", + "priority": "P0", + "title": "First launch of a fresh profile: one window, Home's Open a terminal gives a working shell", + "why": "The very first thing a new user sees. A blank or second window, a launch window that never hands over, or a terminal that never gets a shell loses the user in the first minute.", + "source": "app life (holistic-quality.md 1): first launch", + "first_in_app": true, + "budget_seconds": 60, + "steps": [ + {"do": "fresh_app"}, + {"do": "expect", "title": "one window, launched and handed over, the daemon connected, only Home", "timeout": 30, + "all": [ + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"app_rpc": {"method": "debug.windows", "path": "consistent", "equals": true}}, + {"app_rpc": {"method": "debug.windows", "path": "windows.0.visible", "equals": true}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.loaded", "equals": true}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.daemon_state", "equals": "connected"}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.workspaces", "len": 1}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "clean"}}, + {"frame_text": "Open a terminal"} + ]}, + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "open-terminal"}, + {"do": "focused_terminal", "as": "first", "timeout": 20}, + {"do": "expect", "title": "the terminal shows the shell's first prompt in the app", "since": "open-terminal", "timeout": 20, + "all": [ + {"app_pane": {"focused": true, "link": "connected"}}, + {"app_rpc": {"method": "debug.surfaces", "path": "blank_panes", "equals": 0}} + ]}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo first-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + {"do": "expect", "title": "the shell answers in the daemon, the app and the frame", "since": "typed", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "first-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "first-${nonce}-42"}, + {"app_prompt": true}, + {"frame_text": "first-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-many-tabs.json b/scripts/journeys/journeys/p0/app-life-many-tabs.json new file mode 100644 index 000000000000..018a8d5e8a8d --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-many-tabs.json @@ -0,0 +1,56 @@ +{ + "id": "app-life-many-tabs", + "priority": "P0", + "title": "20 terminal tabs in one pane: each gets a shell, switching and typing stay instant", + "why": "People keep many tabs open. Opening 20 quickly must give 20 working shells, and switching tabs (Ctrl-Tab) and typing must stay within a second, with no blank pane.", + "source": "window life (holistic-quality.md 1): many tabs", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-many", "as": "first"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo first-${nonce}-$((6*7))", "enter": true, "mark": "first"}, + {"do": "expect", "title": "the first tab answers", "since": "first", "timeout": 10, + "all": [{"daemon_line": "first-${nonce}-42"}]}, + + {"do": "key", "key": "`", "modifiers": ["control"], "repeat": 19, "gap": 0.15, "mark": "open-19"}, + {"do": "expect", "title": "20 tabs in the pane, every one with a shell prompt in the daemon", "since": "open-19", "timeout": 30, + "all": [ + {"app_layout": {"workspace": "jr-many", "panes": 1, "tabs": 20, "pane_tabs": 20}}, + {"daemon_pane_ready": {"tabs": 20}}, + {"app_rpc": {"method": "debug.surfaces", "path": "blank_panes", "equals": 0}} + ]}, + {"do": "focused_terminal", "as": "last", "timeout": 10}, + {"do": "expect", "title": "the last opened tab is the selected and focused one", "timeout": 10, + "all": [ + {"app_layout": {"pane_tabs": 20, "selected": true}}, + {"app_pane": {"focused": true, "link": "connected"}} + ]}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo last-${nonce}-$((6*7))", "enter": true, "mark": "last"}, + {"do": "expect", "title": "the 20th tab echoes within a second", "since": "last", "within": 1.0, "timeout": 10, + "all": [ + {"daemon_line": "last-${nonce}-42"}, + {"app_text_line": "last-${nonce}-42"}, + {"frame_text": "last-${nonce}-42"} + ]}, + + {"do": "key", "key": "tab", "modifiers": ["control"], "mark": "wrap"}, + {"do": "use", "name": "first"}, + {"do": "expect", "title": "Ctrl-Tab from the 20th tab wraps to the first within a second, its text shown", "since": "wrap", "within": 1.0, "timeout": 10, + "all": [ + {"app_layout": {"pane_tabs": 20, "selected": true}}, + {"app_text_line": "first-${nonce}-42"}, + {"app_pane": {"focused": true}}, + {"daemon_line": "first-${nonce}-42"}, + {"frame_text": "first-${nonce}-42"} + ]}, + {"do": "outwait_hold"}, + {"do": "type", "text": "echo again-${nonce}-$((6*7))", "enter": true, "mark": "again"}, + {"do": "expect", "title": "the first tab takes input with 20 tabs open", "since": "again", "within": 1.0, "timeout": 10, + "all": [ + {"daemon_line": "again-${nonce}-42"}, + {"app_text_line": "again-${nonce}-42"}, + {"frame_text": "again-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-move-tab.json b/scripts/journeys/journeys/p0/app-life-move-tab.json new file mode 100644 index 000000000000..fbb808142def --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-move-tab.json @@ -0,0 +1,54 @@ +{ + "id": "app-life-move-tab", + "priority": "P0", + "title": "Move a terminal tab to the pane on its left (Cmd-Opt-Shift-Left): it arrives selected, keeps its text and shell", + "why": "Rearranging tabs between split panes is everyday window work. The moved tab must leave its pane, show in the other with its scrollback (no blank view, no new shell) and take input there.", + "source": "window life (holistic-quality.md 1): move a tab; shortcut moveSurfaceToPaneLeft", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-move", "as": "left"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo left-${nonce}-$((6*7))", "enter": true, "mark": "left"}, + {"do": "expect", "title": "the left pane answers", "since": "left", "timeout": 10, + "all": [{"daemon_line": "left-${nonce}-42"}]}, + + {"do": "key", "key": "d", "modifiers": ["cmd"]}, + {"do": "focused_terminal", "as": "right"}, + {"do": "key", "key": "`", "modifiers": ["control"]}, + {"do": "focused_terminal", "as": "moving"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo moving-${nonce}-$((6*7))", "enter": true, "mark": "seeded"}, + {"do": "expect", "title": "before: the moving tab is the second tab of the right pane", "since": "seeded", "timeout": 10, + "all": [ + {"daemon_line": "moving-${nonce}-42"}, + {"app_layout": {"workspace": "jr-move", "panes": 2, "tabs": 3, "pane_tabs": 2, "selected": true}}, + {"app_tab_pane": {"same_as": "right"}}, + {"app_text_line": "moving-${nonce}-42"}, + {"frame_text": "moving-${nonce}-42"} + ]}, + + {"do": "key", "key": "left", "modifiers": ["shift", "option", "cmd"], "mark": "move"}, + {"do": "expect", "title": "the tab left the right pane and joined the left one", "since": "move", "within": 2, "timeout": 10, + "all": [ + {"app_tab_pane": {"same_as": "left"}}, + {"app_tab_pane": {"not_same_as": "right"}}, + {"daemon_line": "moving-${nonce}-42"} + ]}, + {"do": "use", "name": "moving"}, + {"do": "expect", "title": "in the left pane it is selected, focused, attached and shows its text", "since": "move", "timeout": 10, + "all": [ + {"app_layout": {"workspace": "jr-move", "panes": 2, "tabs": 3, "pane_tabs": 2, "selected": true}}, + {"app_pane": {"focused": true, "link": "connected"}}, + {"app_text_line": "moving-${nonce}-42"}, + {"frame_text": "moving-${nonce}-42"} + ]}, + {"do": "type", "text": "echo moved-${nonce}-$((6*7))", "enter": true, "mark": "moved"}, + {"do": "expect", "title": "the moved tab's shell takes input in its new pane", "since": "moved", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "moved-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "moved-${nonce}-42"}, + {"frame_text": "moved-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-new-close-tab.json b/scripts/journeys/journeys/p0/app-life-new-close-tab.json new file mode 100644 index 000000000000..e7efaaeedd90 --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-new-close-tab.json @@ -0,0 +1,51 @@ +{ + "id": "app-life-new-close-tab", + "priority": "P0", + "title": "New Terminal Tab (Ctrl-`) opens a working shell beside the first; Cmd-W closes it and the first tab is back, focused and working", + "why": "Opening and closing tabs is constant. A new tab must get its own shell in the same pane; closing it must leave the other tab shown, focused and typing, not a blank pane or a focus that went nowhere.", + "source": "window life (holistic-quality.md 1): new tab and close tab; shortcuts newSurface (Ctrl-`) and closeTab (Cmd-W)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-tabs", "as": "first"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo one-${nonce}-$((6*7))", "enter": true, "mark": "one"}, + {"do": "expect", "title": "the first tab answers", "since": "one", "timeout": 10, + "all": [{"daemon_line": "one-${nonce}-42"}]}, + + {"do": "key", "key": "`", "modifiers": ["control"], "mark": "new-tab"}, + {"do": "focused_terminal", "as": "second"}, + {"do": "expect", "title": "a second tab in the same pane, selected and focused", "since": "new-tab", "within": 2, "timeout": 10, + "all": [ + {"app_layout": {"workspace": "jr-tabs", "panes": 1, "tabs": 2, "pane_tabs": 2, "selected": true}}, + {"app_tab_pane": {"same_as": "first"}}, + {"app_pane": {"focused": true}} + ]}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo two-${nonce}-$((6*7))", "enter": true, "mark": "two"}, + {"do": "expect", "title": "the new tab's shell answers in the daemon, the view and the frame", "since": "two", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "two-${nonce}-42"}, + {"app_text_line": "two-${nonce}-42"}, + {"frame_text": "two-${nonce}-42"} + ]}, + + {"do": "key", "key": "w", "modifiers": ["cmd"], "mark": "close"}, + {"do": "use", "name": "first"}, + {"do": "expect", "title": "Cmd-W closed the second tab; the first is shown, focused and keeps its text", "since": "close", "within": 2, "timeout": 10, + "all": [ + {"app_tab_gone": "second"}, + {"app_layout": {"workspace": "jr-tabs", "panes": 1, "tabs": 1, "selected": true}}, + {"app_pane": {"focused": true, "link": "connected"}}, + {"app_text_line": "one-${nonce}-42"}, + {"daemon_line": "one-${nonce}-42"}, + {"frame_text": "one-${nonce}-42"} + ]}, + {"do": "type", "text": "echo three-${nonce}-$((6*7))", "enter": true, "mark": "three"}, + {"do": "expect", "title": "typing goes to the remaining tab", "since": "three", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "three-${nonce}-42"}, + {"app_text_line": "three-${nonce}-42"}, + {"frame_text": "three-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-quit-restore.json b/scripts/journeys/journeys/p0/app-life-quit-restore.json new file mode 100644 index 000000000000..5044355e154a --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-quit-restore.json @@ -0,0 +1,83 @@ +{ + "id": "app-life-quit-restore", + "priority": "P0", + "title": "Cmd-Q and reopen: the window, its split panes, tabs, working directories and terminal text come back", + "why": "A person quits cmux every day (and every update relaunches it). If a split, a tab, a working directory or the scrollback is lost, their work setup is gone. Cmd-Q goes through the real key path; the reopen goes through LaunchServices like a Dock click.", + "source": "app life (holistic-quality.md 1): quit and restart (session restore); P0 'restart'", + "budget_seconds": 100, + "steps": [ + {"do": "new_terminal", "name": "jr-restore", "as": "left"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "mkdir -p /tmp/jr-${nonce}-left && cd /tmp/jr-${nonce}-left && echo left-${nonce}-$((6*7))", "enter": true, "mark": "left"}, + {"do": "expect", "title": "the left shell is in its own folder", "since": "left", "timeout": 10, + "all": [{"daemon_line": "left-${nonce}-42"}, {"daemon_cwd": "/tmp/jr-${nonce}-left"}]}, + + {"do": "key", "key": "d", "modifiers": ["cmd"]}, + {"do": "focused_terminal", "as": "right"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "mkdir -p /tmp/jr-${nonce}-right && cd /tmp/jr-${nonce}-right && echo right-${nonce}-$((6*7))", "enter": true, "mark": "right"}, + {"do": "expect", "title": "the right shell is in its own folder", "since": "right", "timeout": 10, + "all": [{"daemon_line": "right-${nonce}-42"}, {"daemon_cwd": "/tmp/jr-${nonce}-right"}]}, + + {"do": "key", "key": "`", "modifiers": ["control"]}, + {"do": "focused_terminal", "as": "second"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "mkdir -p /tmp/jr-${nonce}-second && cd /tmp/jr-${nonce}-second && echo second-${nonce}-$((6*7))", "enter": true, "mark": "second"}, + {"do": "expect", "title": "before the quit: two panes, three tabs, three folders", "since": "second", "timeout": 10, + "all": [ + {"daemon_line": "second-${nonce}-42"}, + {"daemon_cwd": "/tmp/jr-${nonce}-second"}, + {"app_layout": {"workspace": "jr-restore", "panes": 2, "tabs": 3, "pane_tabs": 2, "selected": true}}, + {"app_tab_pane": {"same_as": "right"}}, + {"app_cwd": {"name": "left", "cwd": "/tmp/jr-${nonce}-left"}}, + {"app_cwd": {"name": "right", "cwd": "/tmp/jr-${nonce}-right"}}, + {"app_cwd": "/tmp/jr-${nonce}-second"}, + {"frame_text": "second-${nonce}-42"} + ]}, + + {"do": "relaunch", "how": "quit", "mark": "reopened"}, + + {"do": "use", "name": "second"}, + {"do": "expect", "title": "after the reopen: one window, launched and handed over, no crash notice", "since": "reopened", "timeout": 45, + "all": [ + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"app_rpc": {"method": "debug.windows", "path": "consistent", "equals": true}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "clean"}}, + {"app_rpc": {"method": "debug.surfaces", "path": "blank_panes", "equals": 0}} + ]}, + {"do": "expect", "title": "the same layout, tabs and working directories came back", "since": "reopened", "timeout": 45, + "all": [ + {"app_layout": {"workspace": "jr-restore", "panes": 2, "tabs": 3, "pane_tabs": 2, "selected": true}}, + {"app_tab_pane": {"same_as": "right"}}, + {"app_tab_pane": {"not_same_as": "left"}}, + {"app_cwd": {"name": "left", "cwd": "/tmp/jr-${nonce}-left"}}, + {"app_cwd": {"name": "right", "cwd": "/tmp/jr-${nonce}-right"}}, + {"app_cwd": "/tmp/jr-${nonce}-second"}, + {"daemon_cwd": {"name": "left", "cwd": "/tmp/jr-${nonce}-left"}}, + {"daemon_cwd": {"name": "right", "cwd": "/tmp/jr-${nonce}-right"}}, + {"daemon_cwd": "/tmp/jr-${nonce}-second"} + ]}, + {"do": "expect", "title": "the shown terminal's text came back in the daemon, the view and the frame", "since": "reopened", "timeout": 45, + "all": [ + {"daemon_line": "second-${nonce}-42"}, + {"app_text_line": "second-${nonce}-42"}, + {"app_pane": {"link": "connected"}}, + {"frame_text": "second-${nonce}-42"} + ]}, + {"do": "type", "text": "echo back-${nonce}-$(basename $PWD)", "enter": true, "mark": "back"}, + {"do": "expect", "title": "the restored shell takes input, in its folder", "since": "back", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "back-${nonce}-jr-${nonce}-second"}, + {"app_text_line": "back-${nonce}-jr-${nonce}-second"}, + {"frame_text": "back-${nonce}-jr-${nonce}-second"} + ]}, + {"do": "use", "name": "left"}, + {"do": "expect", "title": "the left pane's text came back too", "timeout": 20, + "all": [ + {"daemon_line": "left-${nonce}-42"}, + {"app_text_line": "left-${nonce}-42"}, + {"frame_text": "left-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/app-life-settings-live.json b/scripts/journeys/journeys/p0/app-life-settings-live.json new file mode 100644 index 000000000000..d774978410ef --- /dev/null +++ b/scripts/journeys/journeys/p0/app-life-settings-live.json @@ -0,0 +1,40 @@ +{ + "id": "app-life-settings-live", + "priority": "P0", + "title": "A settings change applies live: terminal.fontSize in cmux-next.json resizes the open terminal's text, and back", + "why": "People change the font size in the settings file and expect the open terminals to follow without a restart. The text must grow (bigger cells, fewer columns in the daemon's grid), stay readable and keep its content, and go back when the setting is removed.", + "source": "app life (holistic-quality.md 1): settings change applied live (font size)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-settings", "as": "shell"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo font-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + {"do": "expect", "title": "the shell answers at the default size", "since": "typed", "timeout": 10, + "all": [{"daemon_line": "font-${nonce}-42"}, {"app_text_line": "font-${nonce}-42"}]}, + {"do": "remember_value", "source": "app_cell_height", "name": "cell"}, + {"do": "remember_value", "source": "daemon_cols", "name": "cols"}, + + {"do": "settings_file", "set": {"terminal": {"fontSize": 26}}, "mark": "bigger"}, + {"do": "expect", "title": "the open terminal's text grows within 3 s, its grid shrinks in the daemon, its text stays", "since": "bigger", "within": 3, "timeout": 10, + "all": [ + {"app_rpc": {"method": "settings.get", "params": {"key": "terminal.fontSize"}, "path": "value", "equals": 26}}, + {"app_grid": {"cell_height_gt": "${cell}"}}, + {"daemon_grid": {"cols_lt": "${cols}"}}, + {"app_text_line": "font-${nonce}-42"}, + {"daemon_line": "font-${nonce}-42"}, + {"frame_text": "font-${nonce}-42"} + ]}, + {"do": "type", "text": "echo big-${nonce}-$((6*7))", "enter": true, "mark": "big"}, + {"do": "expect", "title": "the shell still takes input at the new size", "since": "big", "within": 3, "timeout": 10, + "all": [{"daemon_line": "big-${nonce}-42"}, {"app_text_line": "big-${nonce}-42"}, {"frame_text": "big-${nonce}-42"}]}, + + {"do": "settings_file", "unset": ["terminal.fontSize"], "mark": "reset"}, + {"do": "expect", "title": "removing the setting brings the default size back", "since": "reset", "within": 3, "timeout": 10, + "all": [ + {"app_grid": {"cell_height_equals": "${cell}"}}, + {"daemon_grid": {"cols_equals": "${cols}"}}, + {"app_text_line": "big-${nonce}-42"}, + {"frame_text": "big-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/bracketed-paste.json b/scripts/journeys/journeys/p0/bracketed-paste.json new file mode 100644 index 000000000000..f25ad83fa80c --- /dev/null +++ b/scripts/journeys/journeys/p0/bracketed-paste.json @@ -0,0 +1,39 @@ +{ + "id": "bracketed-paste", + "priority": "P0", + "title": "A two-line paste into zsh (bracketed paste on) lands in the line editor without a dialog and runs only on Return", + "why": "zsh turns bracketed paste on, so a multi-line paste must reach its line editor as one paste: no line runs before the person presses Return, no paste markers show as text, and the app asks no 'Paste into the terminal?' question (it asks only when the program did not turn bracketed paste on). The app's mirror must track the mode the daemon's shell set.", + "source": "everyday terminal use (holistic-quality.md 1)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-bracketed-paste"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo shell-${ZSH_VERSION:+zsh}-${nonce}", "enter": true, "mark": "shell"}, + {"do": "expect", "title": "the shell is zsh (it turns bracketed paste on)", "since": "shell", "timeout": 10, + "all": [{"daemon_line": "shell-zsh-${nonce}"}, {"daemon_prompt": true}]}, + {"do": "clipboard_set", "text": "echo bp1-${nonce}-$((6*7))\necho bp2-${nonce}-$((6*7))"}, + {"do": "key", "key": "v", "modifiers": ["cmd"], "mark": "paste"}, + {"do": "expect", "title": "both lines land in the line editor, nothing runs, no markers, no dialog", "since": "paste", "within": 1.5, "timeout": 5, "settle": 1.5, + "all": [ + {"daemon_contains": "echo bp1-${nonce}-$((6*7))"}, + {"daemon_contains": "echo bp2-${nonce}-$((6*7))"}, + {"daemon_lacks": "bp1-${nonce}-42"}, + {"daemon_lacks": "bp2-${nonce}-42"}, + {"daemon_lacks": "200~"}, + {"daemon_lacks": "201~"}, + {"app_text_contains": "echo bp2-${nonce}-$((6*7))"}, + {"app_text_lacks": "bp1-${nonce}-42"} + ]}, + {"do": "key", "key": "return", "modifiers": [], "mark": "run"}, + {"do": "expect", "title": "Return runs both lines, in order", "since": "run", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "bp1-${nonce}-42"}, + {"daemon_line": "bp2-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "bp1-${nonce}-42"}, + {"app_text_line": "bp2-${nonce}-42"}, + {"app_prompt": true}, + {"frame_screen": {"shows": ["bp1-${nonce}-42", "bp2-${nonce}-42"], "lacks": ["200~"]}} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/clear.json b/scripts/journeys/journeys/p0/clear.json new file mode 100644 index 000000000000..cd3d01a27257 --- /dev/null +++ b/scripts/journeys/journeys/p0/clear.json @@ -0,0 +1,71 @@ +{ + "id": "clear", + "priority": "P0", + "title": "clear and Cmd+K empty the terminal; Cmd+K also empties the scrollback; the shell takes input after", + "why": "Clearing is everyday terminal use. In cmux the daemon owns the terminal and the app draws a mirror: a clear that empties only one of them leaves old text in the other (it comes back on reattach, or the view and the daemon disagree).", + "source": "everyday terminal use (holistic-quality.md 1); Cmd+K is Clear Screen and Scrollback in a terminal (decision K1)", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-clear"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "seq -f 'C%04g-${nonce}' 1 300", "enter": true, "mark": "fill"}, + {"do": "expect", "title": "300 rows show", "since": "fill", "timeout": 15, + "all": [ + {"daemon_line": "C0300-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_line": "C0300-${nonce}"} + ]}, + + {"do": "type", "text": "clear", "enter": true, "mark": "clear"}, + {"do": "expect", "title": "the clear command empties the screen; the prompt is all that shows", "since": "clear", "within": 2, "timeout": 5, "settle": 0.5, + "all": [ + {"daemon_lacks": "C0300-${nonce}"}, + {"daemon_lacks": "C0299-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_lacks": "C0300-${nonce}"}, + {"app_text_lacks": "C0299-${nonce}"}, + {"app_prompt": true} + ]}, + {"do": "type", "text": "echo cleared-${nonce}-$((6*7))", "enter": true, "mark": "after-clear"}, + {"do": "expect", "title": "the shell answers after clear", "since": "after-clear", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "cleared-${nonce}-42"}, + {"app_text_line": "cleared-${nonce}-42"}, + {"app_text_lacks": "C0300-${nonce}"}, + {"frame_screen": {"shows": ["cleared-${nonce}-42"], "lacks": ["C0300-${nonce}", "C0299-${nonce}"]}} + ]}, + + {"do": "type", "text": "seq -f 'K%04g-${nonce}' 1 300", "enter": true, "mark": "fill2"}, + {"do": "expect", "title": "300 more rows show", "since": "fill2", "timeout": 15, + "all": [ + {"daemon_line": "K0300-${nonce}"}, + {"daemon_prompt": true}, + {"daemon_history": {"rows": "newest", "count": 1000, "contains": "K0001-${nonce}"}}, + {"app_text_line": "K0300-${nonce}"} + ]}, + {"do": "key", "key": "k", "modifiers": ["cmd"], "mark": "cmd-k"}, + {"do": "expect", "title": "Cmd+K empties the screen and the scrollback, in the daemon and in the view", "since": "cmd-k", "within": 1.5, "timeout": 5, "settle": 0.5, + "all": [ + {"daemon_lacks": "K0300-${nonce}"}, + {"daemon_lacks": "K0200-${nonce}"}, + {"daemon_history": {"rows": "newest", "count": 1000, "lacks": "K0"}}, + {"app_text_lacks": "K0300-${nonce}"}, + {"app_text_lacks": "K0200-${nonce}"} + ]}, + {"do": "mouse", "action": "scroll", "dy": 40, "count": 30, "interval": 0.03, "mark": "scroll-back"}, + {"do": "expect", "title": "scrolling back after Cmd+K finds none of the cleared rows", "since": "scroll-back", "timeout": 5, "settle": 1, + "all": [ + {"app_text_lacks": "K0"}, + {"daemon_lacks": "K0"} + ]}, + {"do": "type", "text": "echo after-k-${nonce}-$((6*7))", "enter": true, "mark": "after-k"}, + {"do": "expect", "title": "the shell takes input after Cmd+K", "since": "after-k", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "after-k-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "after-k-${nonce}-42"}, + {"app_prompt": true}, + {"frame_screen": {"shows": ["after-k-${nonce}-42"], "lacks": ["K0300-${nonce}", "K0299-${nonce}"]}} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/close-pane-fills.json b/scripts/journeys/journeys/p0/close-pane-fills.json new file mode 100644 index 000000000000..a848f920e083 --- /dev/null +++ b/scripts/journeys/journeys/p0/close-pane-fills.json @@ -0,0 +1,42 @@ +{ + "id": "close-pane-fills", + "priority": "P0", + "title": "Close a split pane with Cmd-W: the other pane fills the space and its PTY grows back", + "why": "After closing one of two panes the person keeps working in the other: it must take the whole space again (no blank half, no stale strip) and its PTY must grow back to the full grid, or programs keep wrapping at half width.", + "source": "window life: close a pane (holistic-quality.md 1)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-close-pane"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "left"}, + {"do": "window_place", "title": "set-up: the window at 70% x 70% of the screen", "fraction": [0.05, 0.05, 0.7, 0.7]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "full"}, + {"do": "type", "text": "echo keep-${nonce}", "enter": true}, + {"do": "key", "title": "Cmd-D", "key": "d", "modifiers": ["cmd"]}, + {"do": "adopt_pane", "as": "right"}, + {"do": "neutral_prompt"}, + {"do": "expect", "title": "two panes, the left one at half width", "timeout": 10, + "all": [{"pane_frame": {"pane": "left", "vs": "full", "w": 0.5, "tol": 0.06}}, {"grid_fits": {"pane": "left", "vs": "full", "cols": "narrower"}}]}, + + {"do": "key", "title": "Cmd-W in the right pane", "key": "w", "modifiers": ["cmd"], "mark": "close"}, + {"do": "expect", "title": "the right pane is gone; the left pane fills the window again, fitted, with the focus", "since": "close", "timeout": 10, + "all": [ + {"pane_state": {"pane": "right", "gone": true}}, + {"pane_frame": {"pane": "left", "vs": "full", "w": 1.0, "h": 1.0, "tol": 0.02}}, + {"grid_fits": {"pane": "left", "vs": "full", "cols": "same", "rows": "same"}}, + {"pane_state": {"pane": "left", "fields": {"focused": true}}} + ]}, + {"do": "use_pane", "pane": "left"}, + {"do": "type", "text": "echo after-${nonce} $(stty size)", "enter": true, "mark": "typed"}, + {"do": "expect", "title": "keys go to the remaining pane; its PTY has the full grid again", "since": "typed", "timeout": 10, + "all": [ + {"pty_size": {"tag": "after-${nonce}", "pane": "left"}}, + {"daemon_line": "keep-${nonce}"}, + {"app_text_line": "keep-${nonce}"}, + {"app_prompt": true}, + {"pane_frame_text": "after-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/ctrl-c.json b/scripts/journeys/journeys/p0/ctrl-c.json new file mode 100644 index 000000000000..2b1203dbe3a1 --- /dev/null +++ b/scripts/journeys/journeys/p0/ctrl-c.json @@ -0,0 +1,45 @@ +{ + "id": "ctrl-c", + "priority": "P0", + "title": "Ctrl+C interrupts the foreground job; the prompt returns within 1 s", + "why": "Dogfood v1 shipped with Ctrl+C broken: the PTY child had no controlling tty, so the line discipline never sent SIGINT (fix 66d9960c10f, test 6a4a6a0f242). Permanent journey for that bug.", + "source": "dogfood bug v1 (Lawrence), everyday terminal use", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "via": "key"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo before-${nonce}-$((6*7))", "enter": true, "mark": "before"}, + {"do": "expect", "title": "the shell answers", "since": "before", "timeout": 10, + "all": [{"daemon_line": "before-${nonce}-42"}]}, + + {"do": "type", "text": "sleep 100", "enter": true}, + {"do": "expect", "title": "sleep 100 runs in the foreground of the terminal's tty", "timeout": 10, + "all": [{"process_present": {"match": "^sleep 100$", "bind": "sleep", "foreground": true}}]}, + {"do": "key", "key": "c", "modifiers": ["control"], "mark": "ctrl-c-sleep"}, + {"do": "expect", "title": "Ctrl+C ends sleep and the prompt returns", "since": "ctrl-c-sleep", "within": 1.0, "timeout": 5, + "all": [ + {"process_gone": "sleep"}, + {"daemon_prompt": true}, + {"app_prompt": true} + ]}, + + {"do": "type", "text": "cat", "enter": true}, + {"do": "expect", "title": "cat with no input waits in the foreground", "timeout": 10, + "all": [{"process_present": {"match": "^cat$", "bind": "cat", "foreground": true}}]}, + {"do": "key", "key": "c", "modifiers": ["control"], "mark": "ctrl-c-cat"}, + {"do": "expect", "title": "Ctrl+C ends cat and the prompt returns", "since": "ctrl-c-cat", "within": 1.0, "timeout": 5, + "all": [ + {"process_gone": "cat"}, + {"daemon_prompt": true}, + {"app_prompt": true} + ]}, + + {"do": "type", "text": "echo after-${nonce}-$((6*7))", "enter": true, "mark": "after"}, + {"do": "expect", "title": "the shell takes input again", "since": "after", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "after-${nonce}-42"}, + {"app_text_line": "after-${nonce}-42"}, + {"frame_text": "after-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/mw-close-window.json b/scripts/journeys/journeys/p0/mw-close-window.json new file mode 100644 index 000000000000..e5fb355ec9be --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-close-window.json @@ -0,0 +1,262 @@ +{ + "id": "mw-close-window", + "priority": "P0", + "title": "Three windows: close window 2, move to window 3; its shortcut and a script's split act there, never in window 1", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): after a window closes, the next shortcut must act in the window the person moved to; an implicit lookup (`controllers.first`, `windows.first`, a stale key window) sends it to window 1, the app's first controller. Window 2 closes with its Close Window key (Ctrl-Cmd-W) and windows 1 and 3 must not change; the person brings window 3 forward and clicks into it; Cmd-D there must split window 3 only, and a script's split with no window or target (resolved to the window the person used last, cp/multiwindow-2's WindowTarget) must also land in window 3, never window 1.", + "source": "handoff/multiwindow.md item 5 (close a window); WindowActionCatalog (closeWindow: Ctrl-Cmd-W)", + "budget_seconds": 120, + "steps": [ + { + "do": "new_terminal", + "name": "mw-cw-b-${nonce}", + "as": "b", + "title": "set-up: workspace mw-cw-b with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-cw-c-${nonce}", + "as": "c", + "title": "set-up: workspace mw-cw-c with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-cw-a-${nonce}", + "as": "a", + "title": "set-up: workspace mw-cw-a with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w1", + "title": "window 1: the first window (the app's first controller), showing workspace a" + }, + { + "do": "move_workspace", + "workspace": "mw-cw-b-${nonce}", + "to": "new", + "as": "w2", + "title": "set-up: workspace b into window 2" + }, + { + "do": "move_workspace", + "workspace": "mw-cw-c-${nonce}", + "to": "new", + "as": "w3", + "title": "set-up: workspace c into window 3" + }, + { + "do": "expect", + "title": "three windows, each showing its own workspace", + "timeout": 15, + "all": [ + { + "windows_open": { + "baseline_plus": 2 + } + }, + { + "windows_show": { + "names": [ + "mw-cw-a-${nonce}", + "mw-cw-b-${nonce}", + "mw-cw-c-${nonce}" + ] + } + } + ] + }, + { + "do": "use_window", + "window": "w2", + "title": "the person is in window 2" + }, + { + "do": "remember_windows", + "as": "before-close" + }, + { + "do": "key", + "title": "Ctrl-Cmd-W in window 2 (Close Window)", + "key": "w", + "modifiers": [ + "control", + "cmd" + ], + "window": "w2", + "mark": "close" + }, + { + "do": "expect", + "title": "window 2 closed; windows 1 and 3 did not change", + "since": "close", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "open": false + } + }, + { + "windows_open": { + "since": "before-close", + "delta": -1 + } + }, + { + "window_unchanged": { + "since": "before-close", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "use", + "name": "c" + }, + { + "do": "use_window", + "window": "w3", + "title": "the person moves to window 3 (it comes forward and takes key)" + }, + { + "do": "mouse", + "action": "click", + "window": "w3", + "title": "and clicks into its terminal" + }, + { + "do": "remember_windows", + "as": "before-split" + }, + { + "do": "key", + "title": "Cmd-D in window 3 (Split Right)", + "key": "d", + "modifiers": [ + "cmd" + ], + "window": "w3", + "mark": "split" + }, + { + "do": "focused_terminal", + "as": "c2" + }, + { + "do": "neutral_prompt" + }, + { + "do": "expect", + "title": "the split is in window 3; window 1 did not change", + "since": "split", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "since": "before-split", + "dpanes": 1, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "before-split", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "type", + "text": "echo mw-cw-${nonce}-typed", + "enter": true, + "window": "w3", + "mark": "typed" + }, + { + "do": "expect", + "title": "typing reaches window 3's new terminal", + "since": "typed", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-cw-${nonce}-typed" + }, + { + "app_text_line": "mw-cw-${nonce}-typed" + }, + { + "pane_frame_text": "mw-cw-${nonce}-typed" + }, + { + "window_unchanged": { + "since": "before-split", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "before-script" + }, + { + "do": "rpc", + "title": "a script's split with no window and no target (action.run splitDown): it acts in the window the person used last, window 3", + "method": "action.run", + "params": { + "action": "splitDown", + "focus": true + } + }, + { + "do": "expect", + "title": "the script's split is in window 3, never window 1 by default", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "since": "before-script", + "dpanes": 1, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "before-script", + "except": [ + "w3" + ] + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close window 3 (and window 2 if it stayed open)" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-home-click-each-window.json b/scripts/journeys/journeys/p0/mw-home-click-each-window.json new file mode 100644 index 000000000000..2fb750df731c --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-home-click-each-window.json @@ -0,0 +1,438 @@ +{ + "id": "mw-home-click-each-window", + "priority": "P0", + "title": "Three windows: Home clicked in each window's sidebar shows in that window, and no other window changes", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): \"clicking on home/workspaces might bring me to the wrong window\". Home asked for in window N must show in window N (cp/multiwindow-2: the sidebar item names its own window; docked Home moves the home workspace into the window that asked unless its window lists only Home), never bring another window forward or change it. Each window's Home item is clicked with the pointer (`debug.mouse` at the item's frame from `debug.sidebar_rows`; no action.run), then its workspace row, and every check reads every window: the clicked one shows Home (the classic top page, or the docked Home's workspace with the Home item active) and the others keep what they show, their rows, panes, focused pane and frame. Three new windows hold the journey's workspaces; the first window, with the earlier journeys' workspaces (its rows may be scrolled), is a fourth window that must not change either.", + "source": "handoff/multiwindow.md item 5 (click Home in each window and assert that window, and only it, changes)", + "budget_seconds": 150, + "steps": [ + { + "do": "new_terminal", + "name": "mw-hm-a-${nonce}", + "as": "a", + "title": "set-up: workspace mw-hm-a with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-hm-b-${nonce}", + "as": "b", + "title": "set-up: workspace mw-hm-b with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-hm-c-${nonce}", + "as": "c", + "title": "set-up: workspace mw-hm-c with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w0", + "title": "the first window (it keeps the earlier journeys' workspaces; a bystander here)" + }, + { + "do": "move_workspace", + "workspace": "mw-hm-a-${nonce}", + "to": "new", + "as": "w1", + "title": "set-up: workspace a into its own new window" + }, + { + "do": "move_workspace", + "workspace": "mw-hm-b-${nonce}", + "to": "new", + "as": "w2", + "title": "set-up: workspace b into its own new window" + }, + { + "do": "move_workspace", + "workspace": "mw-hm-c-${nonce}", + "to": "new", + "as": "w3", + "title": "set-up: workspace c into its own new window" + }, + { + "do": "expect", + "title": "three new windows, each showing its own workspace", + "timeout": 15, + "all": [ + { + "windows_open": { + "baseline_plus": 3 + } + }, + { + "windows_show": { + "names": [ + "mw-hm-a-${nonce}", + "mw-hm-b-${nonce}", + "mw-hm-c-${nonce}" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "h1" + }, + { + "do": "click_sidebar", + "window": "w1", + "home": true, + "mark": "home-1", + "title": "click Home in window 1's sidebar" + }, + { + "do": "expect", + "title": "window 1 shows Home; every other window did not change", + "since": "home-1", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "home": true + } + }, + { + "window_unchanged": { + "since": "h1", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "r1" + }, + { + "do": "click_sidebar", + "window": "w1", + "workspace": "mw-hm-a-${nonce}", + "mark": "row-1", + "title": "click workspace a's row in window 1's sidebar (back from Home)" + }, + { + "do": "expect", + "title": "window 1 shows workspace a again; every other window did not change", + "since": "row-1", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-hm-a-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "r1", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "use", + "name": "a" + }, + { + "do": "use_window", + "window": "w1", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w1", + "title": "a click into a's terminal in w1, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-hm-a-${nonce}", + "enter": true, + "window": "w1", + "mark": "typed-1" + }, + { + "do": "expect", + "title": "typing in window 1 reaches workspace a's terminal", + "since": "typed-1", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-hm-a-${nonce}" + }, + { + "app_text_line": "mw-hm-a-${nonce}" + }, + { + "pane_frame_text": "mw-hm-a-${nonce}" + }, + { + "window_unchanged": { + "since": "r1", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "h2" + }, + { + "do": "click_sidebar", + "window": "w2", + "home": true, + "mark": "home-2", + "title": "click Home in window 2's sidebar" + }, + { + "do": "expect", + "title": "window 2 shows Home; every other window did not change", + "since": "home-2", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "home": true + } + }, + { + "window_unchanged": { + "since": "h2", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "r2" + }, + { + "do": "click_sidebar", + "window": "w2", + "workspace": "mw-hm-b-${nonce}", + "mark": "row-2", + "title": "click workspace b's row in window 2's sidebar (back from Home)" + }, + { + "do": "expect", + "title": "window 2 shows workspace b again; every other window did not change", + "since": "row-2", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "workspace": "mw-hm-b-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "r2", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "use", + "name": "b" + }, + { + "do": "use_window", + "window": "w2", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w2", + "title": "a click into b's terminal in w2, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-hm-b-${nonce}", + "enter": true, + "window": "w2", + "mark": "typed-2" + }, + { + "do": "expect", + "title": "typing in window 2 reaches workspace b's terminal", + "since": "typed-2", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-hm-b-${nonce}" + }, + { + "app_text_line": "mw-hm-b-${nonce}" + }, + { + "pane_frame_text": "mw-hm-b-${nonce}" + }, + { + "window_unchanged": { + "since": "r2", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "h3" + }, + { + "do": "click_sidebar", + "window": "w3", + "home": true, + "mark": "home-3", + "title": "click Home in window 3's sidebar" + }, + { + "do": "expect", + "title": "window 3 shows Home; every other window did not change", + "since": "home-3", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "home": true + } + }, + { + "window_unchanged": { + "since": "h3", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "r3" + }, + { + "do": "click_sidebar", + "window": "w3", + "workspace": "mw-hm-c-${nonce}", + "mark": "row-3", + "title": "click workspace c's row in window 3's sidebar (back from Home)" + }, + { + "do": "expect", + "title": "window 3 shows workspace c again; every other window did not change", + "since": "row-3", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "workspace": "mw-hm-c-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "r3", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "use", + "name": "c" + }, + { + "do": "use_window", + "window": "w3", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w3", + "title": "a click into c's terminal in w3, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-hm-c-${nonce}", + "enter": true, + "window": "w3", + "mark": "typed-3" + }, + { + "do": "expect", + "title": "typing in window 3 reaches workspace c's terminal", + "since": "typed-3", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-hm-c-${nonce}" + }, + { + "app_text_line": "mw-hm-c-${nonce}" + }, + { + "pane_frame_text": "mw-hm-c-${nonce}" + }, + { + "window_unchanged": { + "since": "r3", + "except": [ + "w3" + ] + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close the three new windows" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-move-workspace-between-windows.json b/scripts/journeys/journeys/p0/mw-move-workspace-between-windows.json new file mode 100644 index 000000000000..da7e67dfa647 --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-move-workspace-between-windows.json @@ -0,0 +1,244 @@ +{ + "id": "mw-move-workspace-between-windows", + "priority": "P0", + "title": "Move a workspace from window 1 to window 2 and back: it shows in the window named, the window it left keeps its others", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): moving a workspace between windows must act on the two windows named and no other: the workspace shows in the target window, the window it left keeps showing what it showed (its panes, focused pane and frame) and lists it no more, and a third window does not change. The move runs the Move Workspace to Window action (moveWorkspaceToWindow, the sidebar row's Move to Window menu item and the palette entry) through action.run with the target workspace and an explicit `window` argument; typing in the target window then reaches the moved workspace's terminal.", + "source": "handoff/multiwindow.md item 5 (move a workspace between windows); WorkspaceActionCatalog (moveWorkspaceToWindow, cliName workspace move-to-window)", + "budget_seconds": 120, + "steps": [ + { + "do": "new_terminal", + "name": "mw-mv-a-${nonce}", + "as": "a", + "title": "set-up: workspace mw-mv-a with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-mv-b-${nonce}", + "as": "b", + "title": "set-up: workspace mw-mv-b with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-mv-c-${nonce}", + "as": "c", + "title": "set-up: workspace mw-mv-c with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w0", + "title": "the first window (it keeps the earlier journeys' workspaces; a bystander here)" + }, + { + "do": "move_workspace", + "workspace": "mw-mv-a-${nonce}", + "to": "new", + "as": "w1", + "title": "set-up: workspace a into a new window 1" + }, + { + "do": "move_workspace", + "workspace": "mw-mv-b-${nonce}", + "to": "w1", + "title": "set-up: workspace b into window 1 (it shows b)" + }, + { + "do": "move_workspace", + "workspace": "mw-mv-c-${nonce}", + "to": "new", + "as": "w2", + "title": "set-up: workspace c into a new window 2" + }, + { + "do": "expect", + "title": "window 1 lists a and b and shows b; window 2 shows c", + "timeout": 15, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-mv-b-${nonce}", + "lists": [ + "mw-mv-a-${nonce}", + "mw-mv-b-${nonce}" + ] + } + }, + { + "window_state": { + "window": "w2", + "workspace": "mw-mv-c-${nonce}", + "lists": [ + "mw-mv-c-${nonce}" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "before-move" + }, + { + "do": "move_workspace", + "workspace": "mw-mv-a-${nonce}", + "to": "w2", + "mark": "move", + "title": "Move Workspace to Window: a (listed, not shown, in window 1) to window 2, the window named explicitly" + }, + { + "do": "expect", + "title": "a shows in window 2; window 1 keeps b (shown, same panes and frame) and lists a no more; the first window did not change", + "since": "move", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "workspace": "mw-mv-a-${nonce}", + "lists": [ + "mw-mv-a-${nonce}", + "mw-mv-c-${nonce}" + ] + } + }, + { + "window_state": { + "window": "w1", + "workspace": "mw-mv-b-${nonce}", + "lists": [ + "mw-mv-b-${nonce}" + ], + "lacks": [ + "mw-mv-a-${nonce}" + ] + } + }, + { + "window_unchanged": { + "since": "before-move", + "window": "w1", + "ignore": [ + "rows" + ] + } + }, + { + "window_unchanged": { + "since": "before-move", + "window": "w0" + } + } + ] + }, + { + "do": "use", + "name": "a" + }, + { + "do": "use_window", + "window": "w2", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w2", + "title": "a click into a's terminal in w2, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-mv-a-${nonce}-moved", + "enter": true, + "window": "w2", + "mark": "typed" + }, + { + "do": "expect", + "title": "typing in window 2 reaches the moved workspace's terminal", + "since": "typed", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-mv-a-${nonce}-moved" + }, + { + "app_text_line": "mw-mv-a-${nonce}-moved" + }, + { + "pane_frame_text": "mw-mv-a-${nonce}-moved" + }, + { + "window_unchanged": { + "since": "before-move", + "except": [ + "w1", + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "before-back" + }, + { + "do": "move_workspace", + "workspace": "mw-mv-a-${nonce}", + "to": "w1", + "mark": "back", + "title": "and back: a to window 1" + }, + { + "do": "expect", + "title": "a shows in window 1 again; window 2 shows c and lists a no more; the first window did not change", + "since": "back", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-mv-a-${nonce}", + "lists": [ + "mw-mv-a-${nonce}", + "mw-mv-b-${nonce}" + ] + } + }, + { + "window_state": { + "window": "w2", + "workspace": "mw-mv-c-${nonce}", + "lacks": [ + "mw-mv-a-${nonce}" + ] + } + }, + { + "window_unchanged": { + "since": "before-back", + "window": "w0" + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close the two new windows" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-new-window-entrypoints.json b/scripts/journeys/journeys/p0/mw-new-window-entrypoints.json new file mode 100644 index 000000000000..0e17179101cf --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-new-window-entrypoints.json @@ -0,0 +1,336 @@ +{ + "id": "mw-new-window-entrypoints", + "priority": "P0", + "title": "A new window from the key, the menu and the CLI: each opens one window, leaves the others as they were, and takes typing", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): a new window must get the same behavior as the first window from its first frame, whatever opened it. Each entrypoint opens one window and no window open before changes; then Ctrl-` and typing in the new window reach its own terminal (daemon screen, the pane's text and the frame). Entrypoints: Cmd-Shift-N (newWindow; Cmd-N is New Workspace, WindowActionCatalog / WorkspaceActionCatalog), the main menu's New Window (`debug.menu_item`, the click path), and the CLI (`cmux app new-window` on the app socket). NOT RUN: the Dock. Its path is applicationShouldHandleReopen -> WindowManager.reopenOrCreateWindow, which runs only when no window is visible, and the app has no debug socket method that drives it; a Dock case needs a new driver path (close every window, then a kAEReopenApplication event, e.g. `open -g -a` of the running bundle), not invented here.", + "source": "handoff/multiwindow.md item 5 (new window from Cmd-N, menu, CLI, dock); KeyBindingDefaults / WindowActionCatalog (newWindow: Cmd-Shift-N, cliName app new-window)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "name": "mw-nw-${nonce}", + "as": "first", + "title": "set-up: workspace mw-nw with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w1", + "title": "the first window" + }, + { + "do": "remember_windows", + "as": "before-key" + }, + { + "do": "key", + "title": "Cmd-Shift-N in the first window (New Window; Cmd-N is New Workspace)", + "key": "n", + "modifiers": [ + "cmd", + "shift" + ], + "window": "w1", + "mark": "key" + }, + { + "do": "await_window", + "as": "wk", + "title": "the window Cmd-Shift-N opened; keys and frames go to it" + }, + { + "do": "expect", + "title": "Cmd-Shift-N: one more window; no window open before it changed", + "since": "before-key", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "windows_open": { + "since": "before-key", + "delta": 1 + } + }, + { + "window_unchanged": { + "since": "before-key" + } + }, + { + "window_state": { + "window": "wk", + "home": false + } + } + ] + }, + { + "do": "wait", + "seconds": 4.5, + "title": "the New Tab page's key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end" + }, + { + "do": "key", + "title": "Ctrl-` in the window Cmd-Shift-N opened (New Terminal Tab)", + "key": "`", + "modifiers": [ + "control" + ], + "window": "wk" + }, + { + "do": "focused_terminal", + "as": "t-key" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo mw-nw-key-${nonce}", + "enter": true, + "window": "wk", + "mark": "typed-key" + }, + { + "do": "expect", + "title": "typing in the window Cmd-Shift-N opened reaches its terminal; the windows open before did not change", + "since": "typed-key", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-nw-key-${nonce}" + }, + { + "app_text_line": "mw-nw-key-${nonce}" + }, + { + "pane_frame_text": "mw-nw-key-${nonce}" + }, + { + "window_state": { + "window": "wk", + "home": false + } + }, + { + "window_unchanged": { + "since": "before-key" + } + } + ] + }, + { + "do": "use_window", + "window": "w1", + "title": "the person goes back to the first window (it takes key: the menu acts in the key window)" + }, + { + "do": "remember_windows", + "as": "before-menu" + }, + { + "do": "rpc", + "title": "New Window from the main menu (debug.menu_item: performActionForItem, the click path, after menu validation)", + "method": "debug.menu_item", + "params": { + "title": "New Window" + } + }, + { + "do": "await_window", + "as": "wm", + "title": "the window the menu's File > New Window opened; keys and frames go to it" + }, + { + "do": "expect", + "title": "the menu's File > New Window: one more window; no window open before it changed", + "since": "before-menu", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "windows_open": { + "since": "before-menu", + "delta": 1 + } + }, + { + "window_unchanged": { + "since": "before-menu" + } + }, + { + "window_state": { + "window": "wm", + "home": false + } + } + ] + }, + { + "do": "wait", + "seconds": 4.5, + "title": "the New Tab page's key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end" + }, + { + "do": "key", + "title": "Ctrl-` in the window the menu's File > New Window opened (New Terminal Tab)", + "key": "`", + "modifiers": [ + "control" + ], + "window": "wm" + }, + { + "do": "focused_terminal", + "as": "t-menu" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo mw-nw-menu-${nonce}", + "enter": true, + "window": "wm", + "mark": "typed-menu" + }, + { + "do": "expect", + "title": "typing in the window the menu's File > New Window opened reaches its terminal; the windows open before did not change", + "since": "typed-menu", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-nw-menu-${nonce}" + }, + { + "app_text_line": "mw-nw-menu-${nonce}" + }, + { + "pane_frame_text": "mw-nw-menu-${nonce}" + }, + { + "window_state": { + "window": "wm", + "home": false + } + }, + { + "window_unchanged": { + "since": "before-menu" + } + } + ] + }, + { + "do": "remember_windows", + "as": "before-cli" + }, + { + "do": "run_cli", + "title": "the CLI: cmux app new-window, on the app's control socket", + "args": [ + "app", + "new-window" + ], + "mark": "cli" + }, + { + "do": "await_window", + "as": "wc", + "title": "the window `cmux app new-window` opened; keys and frames go to it" + }, + { + "do": "expect", + "title": "`cmux app new-window`: one more window; no window open before it changed", + "since": "before-cli", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "windows_open": { + "since": "before-cli", + "delta": 1 + } + }, + { + "window_unchanged": { + "since": "before-cli" + } + }, + { + "window_state": { + "window": "wc", + "home": false + } + } + ] + }, + { + "do": "wait", + "seconds": 4.5, + "title": "the New Tab page's key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end" + }, + { + "do": "key", + "title": "Ctrl-` in the window `cmux app new-window` opened (New Terminal Tab)", + "key": "`", + "modifiers": [ + "control" + ], + "window": "wc" + }, + { + "do": "focused_terminal", + "as": "t-cli" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo mw-nw-cli-${nonce}", + "enter": true, + "window": "wc", + "mark": "typed-cli" + }, + { + "do": "expect", + "title": "typing in the window `cmux app new-window` opened reaches its terminal; the windows open before did not change", + "since": "typed-cli", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-nw-cli-${nonce}" + }, + { + "app_text_line": "mw-nw-cli-${nonce}" + }, + { + "pane_frame_text": "mw-nw-cli-${nonce}" + }, + { + "window_state": { + "window": "wc", + "home": false + } + }, + { + "window_unchanged": { + "since": "before-cli" + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close the three new windows" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-restart-three-windows.json b/scripts/journeys/journeys/p0/mw-restart-three-windows.json new file mode 100644 index 000000000000..e5f0d7bc03de --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-restart-three-windows.json @@ -0,0 +1,265 @@ +{ + "id": "mw-restart-three-windows", + "priority": "P0", + "title": "Three windows, each on its own workspace: after Cmd-Q and reopen each window comes back with its own workspace", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): restore must give each window back its own workspace, never fold them into the first window, swap them, or drop a window. Before the quit the first window shows workspace a and two more windows show b and c; after Cmd-Q and reopen there are as many windows as before (three when the app had one window before the journey) and a, b and c each show in a window of their own, by name (window ids may change, so windows are found by what they show); typing in each restored window reaches that workspace's terminal.", + "source": "handoff/multiwindow.md item 5 (restart with 3 windows; restore each to its own window)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "name": "mw-rs-b-${nonce}", + "as": "b", + "title": "set-up: workspace mw-rs-b with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rs-c-${nonce}", + "as": "c", + "title": "set-up: workspace mw-rs-c with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rs-a-${nonce}", + "as": "a", + "title": "set-up: workspace mw-rs-a with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w1", + "title": "window 1: the first window, showing workspace a" + }, + { + "do": "move_workspace", + "workspace": "mw-rs-b-${nonce}", + "to": "new", + "as": "w2", + "title": "set-up: workspace b into window 2" + }, + { + "do": "move_workspace", + "workspace": "mw-rs-c-${nonce}", + "to": "new", + "as": "w3", + "title": "set-up: workspace c into window 3" + }, + { + "do": "expect", + "title": "before the quit: three windows, each showing its own workspace", + "timeout": 15, + "all": [ + { + "windows_open": { + "baseline_plus": 2 + } + }, + { + "windows_show": { + "names": [ + "mw-rs-a-${nonce}", + "mw-rs-b-${nonce}", + "mw-rs-c-${nonce}" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "before-quit" + }, + { + "do": "relaunch", + "how": "quit", + "mark": "reopened", + "title": "Cmd-Q, then open the app again" + }, + { + "do": "expect", + "title": "after the relaunch: the same number of windows, each showing its own workspace by name", + "since": "reopened", + "timeout": 30, + "all": [ + { + "windows_open": { + "since": "before-quit" + } + }, + { + "windows_show": { + "names": [ + "mw-rs-a-${nonce}", + "mw-rs-b-${nonce}", + "mw-rs-c-${nonce}" + ] + } + } + ] + }, + { + "do": "use_window", + "shows": "mw-rs-a-${nonce}", + "as": "w1", + "focus": false, + "title": "the restored window that shows workspace a" + }, + { + "do": "use", + "name": "a" + }, + { + "do": "mouse", + "action": "click", + "window": "w1" + }, + { + "do": "type", + "text": "echo mw-rs-a-${nonce}-back", + "enter": true, + "window": "w1", + "mark": "typed-1" + }, + { + "do": "expect", + "title": "typing in the restored window of a reaches a's terminal", + "since": "typed-1", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rs-a-${nonce}-back" + }, + { + "app_text_line": "mw-rs-a-${nonce}-back" + }, + { + "pane_frame_text": "mw-rs-a-${nonce}-back" + }, + { + "windows_show": { + "names": [ + "mw-rs-a-${nonce}", + "mw-rs-b-${nonce}", + "mw-rs-c-${nonce}" + ] + } + } + ] + }, + { + "do": "use_window", + "shows": "mw-rs-b-${nonce}", + "as": "w2", + "focus": false, + "title": "the restored window that shows workspace b" + }, + { + "do": "use", + "name": "b" + }, + { + "do": "mouse", + "action": "click", + "window": "w2" + }, + { + "do": "type", + "text": "echo mw-rs-b-${nonce}-back", + "enter": true, + "window": "w2", + "mark": "typed-2" + }, + { + "do": "expect", + "title": "typing in the restored window of b reaches b's terminal", + "since": "typed-2", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rs-b-${nonce}-back" + }, + { + "app_text_line": "mw-rs-b-${nonce}-back" + }, + { + "pane_frame_text": "mw-rs-b-${nonce}-back" + }, + { + "windows_show": { + "names": [ + "mw-rs-a-${nonce}", + "mw-rs-b-${nonce}", + "mw-rs-c-${nonce}" + ] + } + } + ] + }, + { + "do": "use_window", + "shows": "mw-rs-c-${nonce}", + "as": "w3", + "focus": false, + "title": "the restored window that shows workspace c" + }, + { + "do": "use", + "name": "c" + }, + { + "do": "mouse", + "action": "click", + "window": "w3" + }, + { + "do": "type", + "text": "echo mw-rs-c-${nonce}-back", + "enter": true, + "window": "w3", + "mark": "typed-3" + }, + { + "do": "expect", + "title": "typing in the restored window of c reaches c's terminal", + "since": "typed-3", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rs-c-${nonce}-back" + }, + { + "app_text_line": "mw-rs-c-${nonce}-back" + }, + { + "pane_frame_text": "mw-rs-c-${nonce}-back" + }, + { + "windows_show": { + "names": [ + "mw-rs-a-${nonce}", + "mw-rs-b-${nonce}", + "mw-rs-c-${nonce}" + ] + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "keep_listing": [ + "mw-rs-a-${nonce}" + ], + "title": "clean-up: close windows 2 and 3" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-shortcuts-new-window.json b/scripts/journeys/journeys/p0/mw-shortcuts-new-window.json new file mode 100644 index 000000000000..fb4a4d96b517 --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-shortcuts-new-window.json @@ -0,0 +1,446 @@ +{ + "id": "mw-shortcuts-new-window", + "priority": "P0", + "title": "Two windows: everyday shortcuts typed in the new window act in the new window only", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): \"keyboard shortcuts dont work ... we need to completely eliminate this error class\". In a second window every shortcut must act in the window it was typed in from that window's first frame, never in the first window (the app's first controller, the old `controllers.first` / `windows.active ?? controllers.first` fallback). Each key here goes to the new window explicitly (`debug.key` `window`: a capture slot's app is never active) and each check reads both windows: the new one changed as the key asks (tab and pane counts, the selected tab, the sidebar selection) and the first one did not change at all (shown workspace, rows, panes, tabs, focused pane, frame). Guards cp/multiwindow-2 (WindowTarget resolved once at the entrypoint). No binding is changed.", + "source": "handoff/multiwindow.md item 5 (P0 journeys: every keyboard shortcut in the new window); KeyBindingDefaults and the action catalog: Cmd-Shift-N newWindow, Ctrl-` newSurface, Cmd-T newTab.default, Cmd-W closeTab, Cmd-D splitRight, Cmd-Shift-D splitDown, Cmd-Shift-] nextSurface, Cmd-1 selectWorkspaceByNumber", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "name": "mw-sc-${nonce}", + "as": "first", + "title": "set-up: workspace mw-sc with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w1", + "title": "the first window (the app's first controller: what an implicit 'first window' fallback picks)" + }, + { + "do": "remember_windows", + "as": "start" + }, + { + "do": "key", + "title": "Cmd-Shift-N in the first window (New Window)", + "key": "n", + "modifiers": [ + "cmd", + "shift" + ], + "window": "w1", + "mark": "new-window" + }, + { + "do": "await_window", + "as": "w2", + "title": "the new window opens; keys and frames go to it from here" + }, + { + "do": "expect", + "title": "one more window; the first window did not change", + "since": "new-window", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "windows_open": { + "since": "start", + "delta": 1 + } + }, + { + "window_unchanged": { + "since": "start" + } + } + ] + }, + { + "do": "wait", + "seconds": 4.5, + "title": "the New Tab page's key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end" + }, + { + "do": "remember_windows", + "as": "k-term1" + }, + { + "do": "key", + "title": "Ctrl-` (New Terminal Tab) in the new window", + "key": "`", + "modifiers": [ + "control" + ], + "window": "w2", + "mark": "k-term1" + }, + { + "do": "focused_terminal", + "as": "t1" + }, + { + "do": "neutral_prompt" + }, + { + "do": "expect", + "title": "Ctrl-` (New Terminal Tab): acts in the new window; the first window did not change", + "since": "k-term1", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-term1", + "dtabs": 1, + "dpanes": 0, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "remember_windows", + "as": "k-cmdt" + }, + { + "do": "key", + "title": "Cmd-T (New Tab: the New Tab page) in the new window", + "key": "t", + "modifiers": [ + "cmd" + ], + "window": "w2", + "mark": "k-cmdt" + }, + { + "do": "expect", + "title": "Cmd-T (New Tab: the New Tab page): acts in the new window; the first window did not change", + "since": "k-cmdt", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-cmdt", + "dtabs": 1, + "dpanes": 0, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "wait", + "seconds": 4.5, + "title": "the New Tab page's key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end" + }, + { + "do": "remember_windows", + "as": "k-cmdw" + }, + { + "do": "key", + "title": "Cmd-W (Close Tab: the New Tab page Cmd-T opened) in the new window", + "key": "w", + "modifiers": [ + "cmd" + ], + "window": "w2", + "mark": "k-cmdw" + }, + { + "do": "expect", + "title": "Cmd-W (Close Tab: the New Tab page Cmd-T opened): acts in the new window; the first window did not change", + "since": "k-cmdw", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-cmdw", + "dtabs": -1, + "dpanes": 0, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "remember_windows", + "as": "k-cmdd" + }, + { + "do": "key", + "title": "Cmd-D (Split Right) in the new window", + "key": "d", + "modifiers": [ + "cmd" + ], + "window": "w2", + "mark": "k-cmdd" + }, + { + "do": "focused_terminal", + "as": "t2" + }, + { + "do": "neutral_prompt" + }, + { + "do": "expect", + "title": "Cmd-D (Split Right): acts in the new window; the first window did not change", + "since": "k-cmdd", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-cmdd", + "dpanes": 1, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "remember_windows", + "as": "k-cmdsd" + }, + { + "do": "key", + "title": "Cmd-Shift-D (Split Down) in the new window", + "key": "d", + "modifiers": [ + "cmd", + "shift" + ], + "window": "w2", + "mark": "k-cmdsd" + }, + { + "do": "focused_terminal", + "as": "t3" + }, + { + "do": "neutral_prompt" + }, + { + "do": "expect", + "title": "Cmd-Shift-D (Split Down): acts in the new window; the first window did not change", + "since": "k-cmdsd", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-cmdsd", + "dpanes": 1, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "remember_windows", + "as": "k-term2" + }, + { + "do": "key", + "title": "Ctrl-` again (a second terminal tab in the focused pane) in the new window", + "key": "`", + "modifiers": [ + "control" + ], + "window": "w2", + "mark": "k-term2" + }, + { + "do": "focused_terminal", + "as": "t4" + }, + { + "do": "neutral_prompt" + }, + { + "do": "expect", + "title": "Ctrl-` again (a second terminal tab in the focused pane): acts in the new window; the first window did not change", + "since": "k-term2", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-term2", + "dtabs": 1, + "dpanes": 0, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "type", + "text": "echo mw-sc-${nonce}-typed", + "enter": true, + "window": "w2", + "mark": "typed", + "title": "typing in the new window" + }, + { + "do": "expect", + "title": "the keys reach the new window's terminal; the first window did not change", + "since": "typed", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-sc-${nonce}-typed" + }, + { + "app_text_line": "mw-sc-${nonce}-typed" + }, + { + "pane_frame_text": "mw-sc-${nonce}-typed" + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "remember_windows", + "as": "k-next" + }, + { + "do": "key", + "title": "Cmd-Shift-] (Next Tab: the focused pane's other terminal tab) in the new window", + "key": "]", + "modifiers": [ + "cmd", + "shift" + ], + "window": "w2", + "mark": "k-next" + }, + { + "do": "expect", + "title": "Cmd-Shift-] (Next Tab: the focused pane's other terminal tab): acts in the new window; the first window did not change", + "since": "k-next", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-next", + "selected_changed": true, + "dpanes": 0, + "dtabs": 0, + "same_workspace": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + }, + { + "do": "outwait_hold", + "title": "the tab Cmd-Shift-] selected may make its terminal's view now" + }, + { + "do": "remember_windows", + "as": "k-cmd1" + }, + { + "do": "key", + "title": "Cmd-1 (Select the first sidebar item) in the new window", + "key": "1", + "modifiers": [ + "cmd" + ], + "window": "w2", + "mark": "k-cmd1" + }, + { + "do": "expect", + "title": "Cmd-1 (Select the first sidebar item): acts in the new window; the first window did not change", + "since": "k-cmd1", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "since": "k-cmd1", + "first_entry": true + } + }, + { + "window_unchanged": { + "since": "start", + "window": "w1" + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close the new window" + } + ] +} diff --git a/scripts/journeys/journeys/p0/mw-workspace-row-click.json b/scripts/journeys/journeys/p0/mw-workspace-row-click.json new file mode 100644 index 000000000000..482fe4503016 --- /dev/null +++ b/scripts/journeys/journeys/p0/mw-workspace-row-click.json @@ -0,0 +1,609 @@ +{ + "id": "mw-workspace-row-click", + "priority": "P0", + "title": "Three windows: each workspace row clicked in each window's sidebar shows in that window, and no other window changes", + "why": "Lawrence 2026-10-28 (handoff/multiwindow.md): \"clicking on home/workspaces might bring me to the wrong window\". A row clicked in window N's sidebar must show its workspace in window N (WindowManager.show, origin thisWindow) and leave every other window as it was. Each row is clicked with the pointer (`debug.mouse` at the row's frame from `debug.sidebar_rows`), A, B and A again in each of three windows, and every check reads every window (shown workspace, Home state, rows, panes and tabs, focused pane, frame). The first window, with the earlier journeys' workspaces (its rows may be scrolled), is a fourth window that must not change either.", + "source": "handoff/multiwindow.md item 5 (click each workspace row in each window and assert that window, and only it, changes)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "name": "mw-rc-a1-${nonce}", + "as": "a1", + "title": "set-up: workspace mw-rc-a1 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rc-a2-${nonce}", + "as": "a2", + "title": "set-up: workspace mw-rc-a2 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rc-b1-${nonce}", + "as": "b1", + "title": "set-up: workspace mw-rc-b1 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rc-b2-${nonce}", + "as": "b2", + "title": "set-up: workspace mw-rc-b2 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rc-c1-${nonce}", + "as": "c1", + "title": "set-up: workspace mw-rc-c1 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "new_terminal", + "name": "mw-rc-c2-${nonce}", + "as": "c2", + "title": "set-up: workspace mw-rc-c2 with a terminal (socket newTab, in the first window)" + }, + { + "do": "neutral_prompt" + }, + { + "do": "name_window", + "as": "w0", + "title": "the first window (it keeps the earlier journeys' workspaces; a bystander here)" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-a1-${nonce}", + "to": "new", + "as": "w1", + "title": "set-up: workspace a1 into a new window 1" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-a2-${nonce}", + "to": "w1", + "title": "set-up: workspace a2 into window 1" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-b1-${nonce}", + "to": "new", + "as": "w2", + "title": "set-up: workspace b1 into a new window 2" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-b2-${nonce}", + "to": "w2", + "title": "set-up: workspace b2 into window 2" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-c1-${nonce}", + "to": "new", + "as": "w3", + "title": "set-up: workspace c1 into a new window 3" + }, + { + "do": "move_workspace", + "workspace": "mw-rc-c2-${nonce}", + "to": "w3", + "title": "set-up: workspace c2 into window 3" + }, + { + "do": "expect", + "title": "three new windows, two workspaces each", + "timeout": 15, + "all": [ + { + "windows_open": { + "baseline_plus": 3 + } + }, + { + "window_state": { + "window": "w1", + "lists": [ + "mw-rc-a1-${nonce}", + "mw-rc-a2-${nonce}" + ] + } + }, + { + "window_state": { + "window": "w2", + "lists": [ + "mw-rc-b1-${nonce}", + "mw-rc-b2-${nonce}" + ] + } + }, + { + "window_state": { + "window": "w3", + "lists": [ + "mw-rc-c1-${nonce}", + "mw-rc-c2-${nonce}" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c10" + }, + { + "do": "click_sidebar", + "window": "w1", + "workspace": "mw-rc-a1-${nonce}", + "mark": "c10", + "title": "click workspace a1's row in window 1's sidebar" + }, + { + "do": "expect", + "title": "window 1 shows a1; every other window did not change", + "since": "c10", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-rc-a1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c10", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c11" + }, + { + "do": "click_sidebar", + "window": "w1", + "workspace": "mw-rc-a2-${nonce}", + "mark": "c11", + "title": "click workspace a2's row in window 1's sidebar" + }, + { + "do": "expect", + "title": "window 1 shows a2; every other window did not change", + "since": "c11", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-rc-a2-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c11", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c12" + }, + { + "do": "click_sidebar", + "window": "w1", + "workspace": "mw-rc-a1-${nonce}", + "mark": "c12", + "title": "click workspace a1's row in window 1's sidebar" + }, + { + "do": "expect", + "title": "window 1 shows a1; every other window did not change", + "since": "c12", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w1", + "workspace": "mw-rc-a1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c12", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "use", + "name": "a1" + }, + { + "do": "use_window", + "window": "w1", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w1", + "title": "a click into a1's terminal in w1, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-rc-a1-${nonce}", + "enter": true, + "window": "w1", + "mark": "typed-1" + }, + { + "do": "expect", + "title": "typing in window 1 reaches workspace a1's terminal", + "since": "typed-1", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rc-a1-${nonce}" + }, + { + "app_text_line": "mw-rc-a1-${nonce}" + }, + { + "pane_frame_text": "mw-rc-a1-${nonce}" + }, + { + "window_unchanged": { + "since": "c12", + "except": [ + "w1" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c20" + }, + { + "do": "click_sidebar", + "window": "w2", + "workspace": "mw-rc-b1-${nonce}", + "mark": "c20", + "title": "click workspace b1's row in window 2's sidebar" + }, + { + "do": "expect", + "title": "window 2 shows b1; every other window did not change", + "since": "c20", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "workspace": "mw-rc-b1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c20", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c21" + }, + { + "do": "click_sidebar", + "window": "w2", + "workspace": "mw-rc-b2-${nonce}", + "mark": "c21", + "title": "click workspace b2's row in window 2's sidebar" + }, + { + "do": "expect", + "title": "window 2 shows b2; every other window did not change", + "since": "c21", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "workspace": "mw-rc-b2-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c21", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c22" + }, + { + "do": "click_sidebar", + "window": "w2", + "workspace": "mw-rc-b1-${nonce}", + "mark": "c22", + "title": "click workspace b1's row in window 2's sidebar" + }, + { + "do": "expect", + "title": "window 2 shows b1; every other window did not change", + "since": "c22", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w2", + "workspace": "mw-rc-b1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c22", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "use", + "name": "b1" + }, + { + "do": "use_window", + "window": "w2", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w2", + "title": "a click into b1's terminal in w2, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-rc-b1-${nonce}", + "enter": true, + "window": "w2", + "mark": "typed-2" + }, + { + "do": "expect", + "title": "typing in window 2 reaches workspace b1's terminal", + "since": "typed-2", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rc-b1-${nonce}" + }, + { + "app_text_line": "mw-rc-b1-${nonce}" + }, + { + "pane_frame_text": "mw-rc-b1-${nonce}" + }, + { + "window_unchanged": { + "since": "c22", + "except": [ + "w2" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c30" + }, + { + "do": "click_sidebar", + "window": "w3", + "workspace": "mw-rc-c1-${nonce}", + "mark": "c30", + "title": "click workspace c1's row in window 3's sidebar" + }, + { + "do": "expect", + "title": "window 3 shows c1; every other window did not change", + "since": "c30", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "workspace": "mw-rc-c1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c30", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c31" + }, + { + "do": "click_sidebar", + "window": "w3", + "workspace": "mw-rc-c2-${nonce}", + "mark": "c31", + "title": "click workspace c2's row in window 3's sidebar" + }, + { + "do": "expect", + "title": "window 3 shows c2; every other window did not change", + "since": "c31", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "workspace": "mw-rc-c2-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c31", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "remember_windows", + "as": "c32" + }, + { + "do": "click_sidebar", + "window": "w3", + "workspace": "mw-rc-c1-${nonce}", + "mark": "c32", + "title": "click workspace c1's row in window 3's sidebar" + }, + { + "do": "expect", + "title": "window 3 shows c1; every other window did not change", + "since": "c32", + "timeout": 10, + "settle": 0.5, + "all": [ + { + "window_state": { + "window": "w3", + "workspace": "mw-rc-c1-${nonce}", + "home": false + } + }, + { + "window_unchanged": { + "since": "c32", + "except": [ + "w3" + ] + } + } + ] + }, + { + "do": "use", + "name": "c1" + }, + { + "do": "use_window", + "window": "w3", + "focus": false + }, + { + "do": "mouse", + "action": "click", + "window": "w3", + "title": "a click into c1's terminal in w3, as a person does before typing" + }, + { + "do": "type", + "text": "echo mw-rc-c1-${nonce}", + "enter": true, + "window": "w3", + "mark": "typed-3" + }, + { + "do": "expect", + "title": "typing in window 3 reaches workspace c1's terminal", + "since": "typed-3", + "timeout": 15, + "all": [ + { + "daemon_line": "mw-rc-c1-${nonce}" + }, + { + "app_text_line": "mw-rc-c1-${nonce}" + }, + { + "pane_frame_text": "mw-rc-c1-${nonce}" + }, + { + "window_unchanged": { + "since": "c32", + "except": [ + "w3" + ] + } + } + ] + } + ], + "finally": [ + { + "do": "close_extra_windows", + "title": "clean-up: close the three new windows" + } + ] +} diff --git a/scripts/journeys/journeys/p0/nested-splits.json b/scripts/journeys/journeys/p0/nested-splits.json new file mode 100644 index 000000000000..99a04f32274d --- /dev/null +++ b/scripts/journeys/journeys/p0/nested-splits.json @@ -0,0 +1,67 @@ +{ + "id": "nested-splits", + "priority": "P0", + "title": "Nested splits (right, then down, then right again): every pane is fitted and its PTY has its own size", + "why": "Split trees three levels deep are everyday use (an editor, a server, logs, a shell). Every pane must keep a shell that answers, and every PTY must have its pane's grid, not its parent's.", + "source": "window life: split (holistic-quality.md 1), nested", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-nested"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "a"}, + {"do": "window_place", "title": "set-up: the window at 85% x 85% of the screen", "fraction": [0.02, 0.03, 0.85, 0.85]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "full"}, + {"do": "key", "title": "Cmd-D", "key": "d", "modifiers": ["cmd"]}, + {"do": "adopt_pane", "as": "b"}, + {"do": "neutral_prompt"}, + {"do": "remember", "as": "b-half", "pane": "b"}, + {"do": "key", "title": "Cmd-Shift-D in b", "key": "d", "modifiers": ["cmd", "shift"]}, + {"do": "adopt_pane", "as": "c"}, + {"do": "neutral_prompt"}, + {"do": "remember", "as": "c-quarter", "pane": "c"}, + {"do": "key", "title": "Cmd-D in c", "key": "d", "modifiers": ["cmd"], "mark": "third"}, + {"do": "adopt_pane", "as": "d"}, + {"do": "neutral_prompt"}, + {"do": "expect", "title": "four panes in the right places, each at its share of the window and fitted", "since": "third", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "b", "right_of": "a"}}, + {"pane_frame": {"pane": "c", "below": "b"}}, + {"pane_frame": {"pane": "d", "right_of": "c"}}, + {"pane_frame": {"pane": "a", "vs": "full", "w": 0.5, "h": 1.0, "tol": 0.06}}, + {"pane_frame": {"pane": "b", "vs": "full", "w": 0.5, "h": 0.5, "tol": 0.07}}, + {"pane_frame": {"pane": "c", "vs": "full", "w": 0.25, "h": 0.5, "tol": 0.07}}, + {"pane_frame": {"pane": "d", "vs": "full", "w": 0.25, "h": 0.5, "tol": 0.07}}, + {"grid_fits": {"pane": "a"}}, + {"grid_fits": {"pane": "b"}}, + {"grid_fits": {"pane": "c", "vs": "c-quarter", "cols": "narrower", "rows": "same"}}, + {"grid_fits": {"pane": "d"}}, + {"app_prompt": true} + ]}, + + {"do": "type", "text": "echo d-${nonce} $(stty size)", "enter": true, "mark": "td"}, + {"do": "expect", "title": "d answers with its own PTY size", "since": "td", "timeout": 10, + "all": [{"pty_size": {"tag": "d-${nonce}", "pane": "d"}}, {"app_text_contains": "d-${nonce} "}, {"pane_frame_text": {"text": "d-${nonce}", "pane": "d"}}]}, + {"do": "use_pane", "pane": "c", "click": true}, + {"do": "type", "text": "echo c-${nonce} $(stty size)", "enter": true, "mark": "tc"}, + {"do": "expect", "title": "c answers with its own PTY size", "since": "tc", "timeout": 10, + "all": [{"pty_size": {"tag": "c-${nonce}", "pane": "c"}}, {"app_text_contains": "c-${nonce} "}, {"pane_frame_text": {"text": "c-${nonce}", "pane": "c"}}]}, + {"do": "use_pane", "pane": "b", "click": true}, + {"do": "type", "text": "echo b-${nonce} $(stty size)", "enter": true, "mark": "tb"}, + {"do": "expect", "title": "b answers with its own PTY size", "since": "tb", "timeout": 10, + "all": [{"pty_size": {"tag": "b-${nonce}", "pane": "b"}}, {"app_text_contains": "b-${nonce} "}, {"pane_frame_text": {"text": "b-${nonce}", "pane": "b"}}]}, + {"do": "use_pane", "pane": "a", "click": true}, + {"do": "type", "text": "echo a-${nonce} $(stty size)", "enter": true, "mark": "ta"}, + {"do": "expect", "title": "a answers with its own PTY size; every pane's text shows in one frame", "since": "ta", "timeout": 10, + "all": [ + {"pty_size": {"tag": "a-${nonce}", "pane": "a"}}, + {"daemon_prompt": true}, + {"app_text_contains": "a-${nonce} "}, + {"pane_frame_text": {"text": "a-${nonce}", "pane": "a"}}, + {"pane_frame_text": {"text": "b-${nonce}", "pane": "b"}}, + {"pane_frame_text": {"text": "c-${nonce}", "pane": "c"}}, + {"pane_frame_text": {"text": "d-${nonce}", "pane": "d"}} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/new-tab-at-launch-scripted.json b/scripts/journeys/journeys/p0/new-tab-at-launch-scripted.json new file mode 100644 index 000000000000..dc813afdab6b --- /dev/null +++ b/scripts/journeys/journeys/p0/new-tab-at-launch-scripted.json @@ -0,0 +1,33 @@ +{ + "id": "new-tab-at-launch-scripted", + "priority": "P0", + "title": "A script's newTab sent the moment the app answers: one window, the new workspace in it, and the person's typed command never sent to Chief", + "why": "The window-registry defect of v2.2.2 and v2.2.3 (a second window at launch: the launch window was not registered yet, the launch window stayed on Home and the person's keys went to the Chief composer and were sent as a message) was found through the socket's newTab. new-tab-at-launch now plays the person's Ctrl-` key, which may take another route; this variant keeps the script's route: a socket newTab is the driver's first act, with no focus claim. Since cp/fix-chief-composer-focus a script's newTab shows only when no text input has the person's keyboard; a capture slot's app is never active, so here it shows and the person's keys are the terminal's.", + "source": "dogfood gate v2.2.3 (cp/fix-newtab-at-launch), lead 2026-10-28: keep the scripted route next to the key route", + "first_in_app": true, + "budget_seconds": 60, + "steps": [ + {"do": "fresh_app"}, + {"do": "script_new_terminal", "name": "jr-launch-scripted-${nonce}", "focus": true}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo scripted-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + { + "do": "expect", + "title": "one window holds Home and the script's workspace; the command ran in the script's terminal and reached no Chief conversation", + "since": "typed", + "timeout": 10, + "settle": 1.5, + "all": [ + {"daemon_line": "scripted-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"app_rpc": {"method": "debug.windows", "path": "consistent", "equals": true}}, + {"app_rpc": {"method": "debug.windows", "path": "windows.0.workspaces", "len": 2}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.workspaces", "len": 2}}, + {"app_rpc": {"method": "debug.home", "path": "conversations", "lacks": "scripted-${nonce}"}}, + {"frame_text": "scripted-${nonce}-42"} + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/new-tab-at-launch.json b/scripts/journeys/journeys/p0/new-tab-at-launch.json new file mode 100644 index 000000000000..d898666cd287 --- /dev/null +++ b/scripts/journeys/journeys/p0/new-tab-at-launch.json @@ -0,0 +1,34 @@ +{ + "id": "new-tab-at-launch", + "priority": "P0", + "title": "A new terminal opened the moment the app answers, then typed into at once: it shows, in the one window, and takes the keys", + "why": "A person (or a script) opens a new terminal right after launch, while the app is still connecting to cmux-tui, and types at once. On v2.2.2 and v2.2.3 the workspace was made by the daemon but went to a second window (the launch window was not registered yet), the launch window stayed on Home, and the typed keys went to the Chief composer and were sent to Chief as a message (dogfood gate v2.2.3, journey type on cmux17). new_terminal here is the person's New Terminal Tab key (Ctrl-`, debug.key, origin user), pressed with no wait for the connection: the driver's first act after the app's socket answers (pressed again while the app has no window yet). A script's newTab no longer takes the person's keyboard (cp/fix-chief-composer-focus); scripted-newtab-keeps-person-keys covers that.", + "source": "dogfood gate v2.2.3 (cp/fix-newtab-at-launch): type failed 1 in N on fresh apps", + "first_in_app": true, + "budget_seconds": 60, + "steps": [ + {"do": "fresh_app"}, + {"do": "new_terminal", "via": "key"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo launch-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + { + "do": "expect", + "title": "the command ran in the new terminal, shown and focused in the app's only window", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + {"daemon_line": "${prompt}echo launch-${nonce}-$((6*7))"}, + {"daemon_line": "launch-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "launch-${nonce}-42"}, + {"app_pane": {"focused": true}}, + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"app_rpc": {"method": "debug.windows", "path": "consistent", "equals": true}}, + {"app_rpc": {"method": "debug.windows", "path": "windows.0.page", "equals": null}}, + {"frame_text": "launch-${nonce}-42"} + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/pane-divider-drag.json b/scripts/journeys/journeys/p0/pane-divider-drag.json new file mode 100644 index 000000000000..1e527bc0fee6 --- /dev/null +++ b/scripts/journeys/journeys/p0/pane-divider-drag.json @@ -0,0 +1,49 @@ +{ + "id": "pane-divider-drag", + "priority": "P0", + "title": "Drag the divider between two panes: both panes resize, both PTYs get the new sizes", + "why": "A person resizes panes by dragging the divider. Both terminals must refit (one wider, one narrower) and both PTYs must follow, or one side wraps at the old width or leaves a blank strip.", + "source": "window life: pane divider drag (holistic-quality.md 1)", + "budget_seconds": 75, + "steps": [ + {"do": "new_terminal", "name": "jr-divider"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "left"}, + {"do": "window_place", "title": "set-up: the window at 70% x 70% of the screen", "fraction": [0.05, 0.05, 0.7, 0.7]}, + {"do": "key", "title": "Cmd-D", "key": "d", "modifiers": ["cmd"]}, + {"do": "adopt_pane", "as": "right"}, + {"do": "neutral_prompt"}, + {"do": "expect", "title": "two fitted panes side by side", "timeout": 10, + "all": [{"pane_frame": {"pane": "right", "right_of": "left"}}, {"grid_fits": {"pane": "left"}}, {"grid_fits": {"pane": "right"}}]}, + {"do": "remember", "as": "left0", "pane": "left"}, + {"do": "remember", "as": "right0", "pane": "right"}, + + {"do": "divider_drag", "title": "drag the divider 200 points left", "between": ["left", "right"], "by": -200, "events": 30, "mark": "drag"}, + {"do": "expect", "title": "the left pane is 200 points narrower, the right one 200 wider; both fitted", "since": "drag", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "left", "vs": "left0", "dw": -200, "tol_points": 30}}, + {"pane_frame": {"pane": "right", "vs": "right0", "dw": 200, "tol_points": 30}}, + {"grid_fits": {"pane": "left", "vs": "left0", "cols": "narrower", "rows": "same"}}, + {"grid_fits": {"pane": "right", "vs": "right0", "cols": "wider", "rows": "same"}}, + {"app_prompt": true} + ]}, + {"do": "type", "text": "echo right-${nonce} $(stty size)", "enter": true, "mark": "typed-right"}, + {"do": "expect", "title": "the right PTY has the wider grid", "since": "typed-right", "timeout": 10, + "all": [ + {"pty_size": {"tag": "right-${nonce}", "pane": "right"}}, + {"daemon_prompt": true}, + {"app_text_contains": "right-${nonce} "}, + {"pane_frame_text": "right-${nonce}"} + ]}, + {"do": "use_pane", "pane": "left", "click": true, "title": "click into the left pane"}, + {"do": "type", "text": "echo left-${nonce} $(stty size)", "enter": true, "mark": "typed-left"}, + {"do": "expect", "title": "the left PTY has the narrower grid", "since": "typed-left", "timeout": 10, + "all": [ + {"pty_size": {"tag": "left-${nonce}", "pane": "left"}}, + {"daemon_prompt": true}, + {"app_text_contains": "left-${nonce} "}, + {"pane_frame_text": "left-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/paste-10k.json b/scripts/journeys/journeys/p0/paste-10k.json new file mode 100644 index 000000000000..43c43b7f769a --- /dev/null +++ b/scripts/journeys/journeys/p0/paste-10k.json @@ -0,0 +1,80 @@ +{ + "id": "paste-10k", + "priority": "P0", + "title": "Cmd+V pastes 10,000 lines into a program that takes bracketed paste: every line arrives, in order, once; nothing hangs", + "why": "Pasting a long log or file into an editor or a REPL is everyday use. 10,000 lines (about 140 kB) go from the pasteboard through the app, the daemon and the PTY, which applies back pressure; a lost, repeated or reordered chunk, a confirmation dialog for a program that asked for bracketed paste, or a hung app are each a broken paste.", + "source": "everyday terminal use (holistic-quality.md 1)", + "budget_seconds": 100, + "steps": [ + {"do": "new_terminal", "name": "jr-paste-10k"}, + {"do": "neutral_prompt"}, + {"do": "file", "path": "/tmp/jr-bp-${nonce}.py", "lines": [ + "# A program that takes bracketed paste, as an editor does: it turns the mode on, reads raw input until", + "# the paste's end marker, then reports what arrived (journeys paste-10k).", + "import hashlib, os, select, sys, termios, time, tty", + "nonce, want_sha, want_lines = sys.argv[1], sys.argv[2], int(sys.argv[3])", + "saved = termios.tcgetattr(0)", + "buf, first, last = bytearray(), None, None", + "tty.setraw(0)", + "try:", + " os.write(1, b'\\x1b[?2004h')", + " os.write(1, ('READY-%s\\r\\n' % nonce).encode())", + " deadline = time.time() + 120", + " while b'\\x1b[201~' not in buf and time.time() < deadline:", + " if not select.select([0], [], [], 0.5)[0]:", + " continue", + " chunk = os.read(0, 65536)", + " if not chunk:", + " break", + " first = first or time.time()", + " buf += chunk", + " last = time.time()", + " if buf[:1] == b'\\x03':", + " break", + "finally:", + " os.write(1, b'\\x1b[?2004l')", + " termios.tcsetattr(0, termios.TCSADRAIN, saved)", + "start, end = buf.find(b'\\x1b[200~'), buf.find(b'\\x1b[201~')", + "bracketed = 'yes' if start == 0 and end > start else 'no'", + "body = bytes(buf[start + 6:end]) if 0 <= start < end else bytes(buf)", + "body = body.replace(b'\\r\\n', b'\\n').replace(b'\\r', b'\\n')", + "lines = body.decode('utf-8', 'replace').split('\\n')", + "order = 'ok'", + "for i, line in enumerate(lines, 1):", + " if line != 'L%05d-%s' % (i, nonce):", + " order = 'bad@%d' % i", + " print('MISMATCH-%s line %d is %r' % (nonce, i, line[:40]))", + " break", + "sha = 'match' if hashlib.sha256(body).hexdigest() == want_sha else 'differ'", + "print('TOOK-%s %.2fs bytes=%d start=%d tail=%r' % (nonce, (last - first) if first else -1, len(buf), start, bytes(buf[-12:])))", + "print('GOT-%s lines=%d bracketed=%s order=%s sha=%s' % (nonce, len(lines), bracketed, order, sha))" + ]}, + {"do": "clipboard_set", "lines": {"format": "L%05d-${nonce}", "count": 10000}}, + {"do": "type", "text": "/usr/bin/python3 -I /tmp/jr-bp-${nonce}.py ${nonce} ${clip_sha} ${clip_lines}", "enter": true, "mark": "start"}, + {"do": "expect", "title": "the program is ready and turned bracketed paste on", "since": "start", "timeout": 20, + "all": [ + {"daemon_line": "READY-${nonce}"}, + {"app_text_line": "READY-${nonce}"} + ]}, + {"do": "expect", "title": "the reader waits in the foreground (bound after READY: /usr/bin/python3 re-executes the real interpreter first)", "timeout": 10, + "all": [{"process_present": {"match": "Python.* -I /tmp/jr-bp-${nonce}\\.py |^/usr/bin/python3 -I /tmp/jr-bp-${nonce}\\.py ", "bind": "reader", "foreground": true}}]}, + {"do": "key", "key": "v", "modifiers": ["cmd"], "mark": "paste"}, + {"do": "expect", "title": "all 10,000 lines arrive, bracketed, in order, once, with the pasteboard's exact bytes", "since": "paste", "within": 30, "timeout": 60, + "all": [ + {"daemon_line": "GOT-${nonce} lines=10000 bracketed=yes order=ok sha=match"}, + {"process_gone": "reader"}, + {"daemon_prompt": true}, + {"app_text_line": "GOT-${nonce} lines=10000 bracketed=yes order=ok sha=match"}, + {"app_prompt": true}, + {"pane_frame_text": "lines=10000 bracketed=yes order=ok sha=match"} + ]}, + {"do": "type", "text": "echo alive-${nonce}-$((6*7))", "enter": true, "mark": "after"}, + {"do": "expect", "title": "the app and the shell answer at once after the paste", "since": "after", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "alive-${nonce}-42"}, + {"app_text_line": "alive-${nonce}-42"}, + {"app_pane": {"focused": true}}, + {"pane_frame_text": "alive-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/paste-small.json b/scripts/journeys/journeys/p0/paste-small.json new file mode 100644 index 000000000000..06fb740e9dfc --- /dev/null +++ b/scripts/journeys/journeys/p0/paste-small.json @@ -0,0 +1,32 @@ +{ + "id": "paste-small", + "priority": "P0", + "title": "Cmd+V pastes a short command at the prompt; it runs only on Return", + "why": "Pasting a command is everyday terminal use. The paste goes through the app's real path (Cmd+V, the menu's Paste, the terminal's clipboard read) into the daemon's PTY; it must arrive whole, once, without the paste markers, and must not run by itself.", + "source": "everyday terminal use (holistic-quality.md 1)", + "budget_seconds": 50, + "steps": [ + {"do": "new_terminal", "name": "jr-paste-small"}, + {"do": "neutral_prompt"}, + {"do": "clipboard_set", "text": "echo pasted-${nonce}-$((6*7))"}, + {"do": "key", "key": "v", "modifiers": ["cmd"], "mark": "paste"}, + {"do": "expect", "title": "the pasted text shows at the prompt once, not run", "since": "paste", "within": 1, "timeout": 5, "settle": 1, + "all": [ + {"daemon_line": "${prompt}echo pasted-${nonce}-$((6*7))"}, + {"app_text_line": "${prompt}echo pasted-${nonce}-$((6*7))"}, + {"daemon_lacks": "pasted-${nonce}-42"}, + {"daemon_lacks": "$((6*7))echo"}, + {"daemon_lacks": "200~"}, + {"app_text_lacks": "pasted-${nonce}-42"} + ]}, + {"do": "key", "key": "return", "modifiers": [], "mark": "run"}, + {"do": "expect", "title": "Return runs it", "since": "run", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "pasted-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "pasted-${nonce}-42"}, + {"app_prompt": true}, + {"frame_text": "pasted-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/restart-focus-after-settings.json b/scripts/journeys/journeys/p0/restart-focus-after-settings.json new file mode 100644 index 000000000000..cf5e773bf2fd --- /dev/null +++ b/scripts/journeys/journeys/p0/restart-focus-after-settings.json @@ -0,0 +1,452 @@ +{ + "id": "restart-focus-after-settings", + "priority": "P0", + "title": "QA evidence b12-b14 key for key: the browser pane focused, Settings opened (Cmd+,) and closed (Cmd+W), lines typed into the page, Cmd-Q, reopen: no key reaches the sidebar", + "why": "QA chrome-parity-v2-1 bug 1 (high) was captured in evidence b12-b14: before the quit the QA opened Settings with Cmd+, and closed it with Cmd+W, then typed 'echo after-settings-close' Return and a second line into the browser page. After the reopen debug.focus said first_responder=SidebarListView. restart-focus-browser and restart-focus-browser-keys (the filed repro) do not open Settings; this one replays the evidence's exact sequence.", + "source": "independent QA evidence qa-chrome-parity-v2-1-evidence/b12, b13, b14 log.json", + "budget_seconds": 200, + "steps": [ + { + "do": "key", + "key": "n", + "modifiers": [ + "cmd" + ], + "mark": "ws" + }, + { + "do": "wait", + "seconds": 2 + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ] + }, + { + "do": "focused_terminal", + "as": "left" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo left-${nonce}-$((6*7))", + "enter": true, + "mark": "left" + }, + { + "do": "expect", + "title": "the new workspace's terminal works; its pane and automatic name are kept", + "since": "left", + "timeout": 10, + "all": [ + { + "daemon_line": "left-${nonce}-42" + }, + { + "app_text_line": "left-${nonce}-42" + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "equals": "terminal" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved_pane", + "not_equals": null, + "bind": "left_pane" + } + }, + { + "app_layout": { + "selected": true, + "bind_workspace": "ws_name" + } + } + ] + }, + { + "do": "key", + "key": "d", + "modifiers": [ + "cmd" + ], + "mark": "split" + }, + { + "do": "expect", + "title": "Cmd+D splits: a new pane has the keyboard", + "since": "split", + "timeout": 10, + "all": [ + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved_pane", + "not_in": [ + "${left_pane}" + ] + } + }, + { + "app_layout": { + "panes": 2 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "l", + "modifiers": [ + "cmd", + "shift" + ], + "mark": "browser" + }, + { + "do": "expect", + "title": "Cmd+Shift+L opens a browser tab whose address bar takes the keys", + "since": "browser", + "timeout": 15, + "all": [ + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "equals": "addressBar" + } + } + ] + }, + { + "do": "type", + "text": "example.com", + "enter": true, + "mark": "loaded" + }, + { + "do": "expect", + "title": "before the quit: the browser pane shows example.com and has the keyboard", + "since": "loaded", + "timeout": 40, + "all": [ + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "equals": "browserPage" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved_pane", + "not_in": [ + "${left_pane}" + ], + "bind": "browser_pane" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.appkit.classified", + "not_in": [ + "sidebar", + "sidebarField" + ] + } + }, + { + "frame_text": "Example Domain" + } + ] + }, + { + "do": "key", + "key": ",", + "modifiers": [ + "cmd" + ], + "mark": "settings" + }, + { + "do": "wait", + "seconds": 3 + }, + { + "do": "key", + "key": "w", + "modifiers": [ + "cmd" + ], + "mark": "settings-closed" + }, + { + "do": "expect", + "title": "Settings closed: the browser page has the keyboard again", + "since": "settings-closed", + "timeout": 10, + "all": [ + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "in": [ + "browserPage", + "addressBar" + ] + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved_pane", + "equals": "${browser_pane}" + } + }, + { + "app_layout": { + "workspace": "${ws_name}", + "panes": 2 + } + } + ] + }, + { + "do": "type", + "text": "echo after-settings-close", + "enter": false + }, + { + "do": "key", + "key": "return", + "modifiers": [] + }, + { + "do": "type", + "text": "zzz-typed-after-return", + "enter": true, + "mark": "typed-page" + }, + { + "do": "expect", + "title": "typing into the page before the quit renames nothing", + "since": "typed-page", + "timeout": 10, + "settle": 1, + "all": [ + { + "app_layout": { + "workspace": "${ws_name}" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "not_in": [ + "sidebar", + "sidebarField" + ] + } + } + ] + }, + { + "do": "relaunch", + "how": "quit", + "mark": "reopened" + }, + { + "do": "use", + "name": "left" + }, + { + "do": "wait", + "seconds": 5 + }, + { + "do": "expect", + "title": "5 s after the reopen: the browser pane has the keyboard again, not the sidebar", + "since": "reopened", + "timeout": 45, + "all": [ + { + "app_rpc": { + "method": "debug.crashes", + "path": "launch.recovery", + "equals": "clean" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.appkit.classified", + "not_in": [ + "sidebar", + "sidebarField" + ] + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.appkit.first_responder", + "not_in": [ + "SidebarListView" + ] + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "in": [ + "browserPage", + "addressBar" + ] + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved_pane", + "equals": "${browser_pane}" + } + }, + { + "app_layout": { + "workspace": "${ws_name}", + "panes": 2 + } + }, + { + "daemon_line": "left-${nonce}-42" + }, + { + "frame_text": "Example Domain" + } + ] + }, + { + "do": "type", + "text": "echo hi-${nonce}", + "enter": true, + "mark": "first" + }, + { + "do": "type", + "text": "zzz${nonce}", + "enter": true, + "mark": "second" + }, + { + "do": "expect", + "title": "the two typed lines did not rename the workspace or open the sidebar's rename field", + "since": "second", + "timeout": 10, + "settle": 2, + "all": [ + { + "app_layout": { + "workspace": "${ws_name}" + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "not_in": [ + "sidebar", + "sidebarField" + ] + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.appkit.classified", + "not_in": [ + "sidebar", + "sidebarField" + ] + } + }, + { + "daemon_lacks": "zzz${nonce}" + } + ] + }, + { + "do": "key", + "key": "left", + "modifiers": [ + "cmd", + "option" + ], + "mark": "to-left" + }, + { + "do": "expect", + "title": "Cmd+Opt+Left moves the keyboard to the restored terminal on the left", + "since": "to-left", + "timeout": 10, + "all": [ + { + "app_pane": { + "focused": true + } + }, + { + "app_rpc": { + "method": "debug.focus", + "path": "windows.0.model.resolved", + "equals": "terminal" + } + } + ] + }, + { + "do": "type", + "text": "echo ok-${nonce}-$((6*7))", + "enter": true, + "mark": "ok" + }, + { + "do": "expect", + "title": "the restored terminal runs the typed command; the workspace keeps its name", + "since": "ok", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "ok-${nonce}-42" + }, + { + "app_text_line": "ok-${nonce}-42" + }, + { + "app_layout": { + "workspace": "${ws_name}" + } + }, + { + "frame_text": "ok-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/restart-focus-browser-keys.json b/scripts/journeys/journeys/p0/restart-focus-browser-keys.json new file mode 100644 index 000000000000..1e269b235917 --- /dev/null +++ b/scripts/journeys/journeys/p0/restart-focus-browser-keys.json @@ -0,0 +1,80 @@ +{ + "id": "restart-focus-browser-keys", + "priority": "P0", + "title": "The QA repro key for key: Cmd+N, Ctrl+`, Cmd+D, Cmd+Shift+L example.com, Cmd-Q, reopen, wait 5 s, type: no key reaches the sidebar", + "why": "QA chrome-parity-v2-1 (bug 1, high): after a restart with a browser pane focused the window's first responder was SidebarListView; the first Return opened the rename field of the automatically named workspace and 'zzz' Return renamed it. restart-focus-browser builds the layout through the driver (a named workspace) and types at once; this one presses the QA's keys from an automatically named workspace (Cmd+N) and waits the QA's 5 s after the reopen, so a focus that moves to the sidebar late is caught too.", + "source": "independent QA bug (qa-chrome-parity-v2-1.json bugs[0]; evidence b12, b13, b14 log.json)", + "budget_seconds": 180, + "steps": [ + {"do": "key", "key": "n", "modifiers": ["cmd"], "mark": "ws"}, + {"do": "wait", "seconds": 2}, + {"do": "key", "key": "`", "modifiers": ["control"]}, + {"do": "focused_terminal", "as": "left"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo left-${nonce}-$((6*7))", "enter": true, "mark": "left"}, + {"do": "expect", "title": "the new workspace's terminal works; its pane and automatic name are kept", "since": "left", "timeout": 10, + "all": [ + {"daemon_line": "left-${nonce}-42"}, + {"app_text_line": "left-${nonce}-42"}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "terminal"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "not_equals": null, "bind": "left_pane"}}, + {"app_layout": {"selected": true, "bind_workspace": "ws_name"}} + ]}, + + {"do": "key", "key": "d", "modifiers": ["cmd"], "mark": "split"}, + {"do": "expect", "title": "Cmd+D splits: a new pane has the keyboard", "since": "split", "timeout": 10, + "all": [{"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "not_in": ["${left_pane}"]}}, + {"app_layout": {"panes": 2}}]}, + {"do": "outwait_hold"}, + {"do": "key", "key": "l", "modifiers": ["cmd", "shift"], "mark": "browser"}, + {"do": "expect", "title": "Cmd+Shift+L opens a browser tab whose address bar takes the keys", "since": "browser", "timeout": 15, + "all": [{"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "addressBar"}}]}, + {"do": "type", "text": "example.com", "enter": true, "mark": "loaded"}, + {"do": "expect", "title": "before the quit: the browser pane shows example.com and has the keyboard", "since": "loaded", "timeout": 40, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "browserPage"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "not_in": ["${left_pane}"], "bind": "browser_pane"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"frame_text": "Example Domain"} + ]}, + + {"do": "relaunch", "how": "quit", "mark": "reopened"}, + {"do": "use", "name": "left"}, + {"do": "wait", "seconds": 5}, + {"do": "expect", "title": "5 s after the reopen: the browser pane has the keyboard again, not the sidebar", "since": "reopened", "timeout": 45, + "all": [ + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "clean"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.first_responder", "not_in": ["SidebarListView"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "in": ["browserPage", "addressBar"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "equals": "${browser_pane}"}}, + {"app_layout": {"workspace": "${ws_name}", "panes": 2}}, + {"daemon_line": "left-${nonce}-42"}, + {"frame_text": "Example Domain"} + ]}, + {"do": "type", "text": "echo hi-${nonce}", "enter": true, "mark": "first"}, + {"do": "type", "text": "zzz${nonce}", "enter": true, "mark": "second"}, + {"do": "expect", "title": "the two typed lines did not rename the workspace or open the sidebar's rename field", "since": "second", "timeout": 10, "settle": 2, + "all": [ + {"app_layout": {"workspace": "${ws_name}"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "not_in": ["sidebar", "sidebarField"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"daemon_lacks": "zzz${nonce}"} + ]}, + + {"do": "key", "key": "left", "modifiers": ["cmd", "option"], "mark": "to-left"}, + {"do": "expect", "title": "Cmd+Opt+Left moves the keyboard to the restored terminal on the left", "since": "to-left", "timeout": 10, + "all": [ + {"app_pane": {"focused": true}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "terminal"}} + ]}, + {"do": "type", "text": "echo ok-${nonce}-$((6*7))", "enter": true, "mark": "ok"}, + {"do": "expect", "title": "the restored terminal runs the typed command; the workspace keeps its name", "since": "ok", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "ok-${nonce}-42"}, + {"app_text_line": "ok-${nonce}-42"}, + {"app_layout": {"workspace": "${ws_name}"}}, + {"frame_text": "ok-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/restart-focus-browser.json b/scripts/journeys/journeys/p0/restart-focus-browser.json new file mode 100644 index 000000000000..f35fbaed8745 --- /dev/null +++ b/scripts/journeys/journeys/p0/restart-focus-browser.json @@ -0,0 +1,71 @@ +{ + "id": "restart-focus-browser", + "priority": "P0", + "title": "Quit with a browser pane focused, reopen and type: keys reach the restored pane, never the sidebar; the workspace keeps its name", + "why": "QA chrome-parity-v2-1 (bug 1, high, cmux17/18/24): after a restart with a browser pane focused, the window's first responder was the sidebar list (debug.focus first_responder=SidebarListView), so the person's first Return opened the sidebar row's rename field and the next typed command became the workspace's name. A person quits and reopens cmux every day; the first thing they do after is type.", + "source": "independent QA bug (qa-chrome-parity-v2-1.json bugs[0]); exact repro: Cmd+N, Ctrl+`, Cmd+D, Cmd+Shift+L example.com, Cmd+Q, reopen, type 'echo hi' Return, 'zzz' Return", + "budget_seconds": 170, + "steps": [ + {"do": "new_terminal", "name": "jr-refocus-${nonce}", "as": "left"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo left-${nonce}-$((6*7))", "enter": true, "mark": "left"}, + {"do": "expect", "title": "the left terminal works", "since": "left", "timeout": 10, + "all": [{"daemon_line": "left-${nonce}-42"}, {"app_text_line": "left-${nonce}-42"}]}, + + {"do": "key", "key": "d", "modifiers": ["cmd"]}, + {"do": "focused_terminal", "as": "right"}, + {"do": "key", "key": "l", "modifiers": ["cmd", "shift"], "mark": "browser"}, + {"do": "expect", "title": "Cmd+Shift+L opens a browser tab whose address bar takes the keys", "since": "browser", "timeout": 15, + "all": [{"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "addressBar"}}]}, + {"do": "type", "text": "example.com", "enter": true, "mark": "loaded"}, + {"do": "expect", "title": "before the quit: the browser pane shows example.com and has the keyboard", "since": "loaded", "timeout": 40, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "browserPage"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "not_equals": null, "bind": "browser_pane"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"app_layout": {"workspace": "jr-refocus-${nonce}", "panes": 2}}, + {"frame_text": "Example Domain"} + ]}, + + {"do": "relaunch", "how": "quit", "mark": "reopened"}, + + {"do": "use", "name": "left"}, + {"do": "expect", "title": "after the reopen: the browser pane has the keyboard again, not the sidebar", "since": "reopened", "timeout": 45, + "all": [ + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.crashes", "path": "launch.recovery", "equals": "clean"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.first_responder", "not_equals": "SidebarListView"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "in": ["browserPage", "addressBar"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved_pane", "equals": "${browser_pane}"}}, + {"app_layout": {"workspace": "jr-refocus-${nonce}", "panes": 2}}, + {"daemon_line": "left-${nonce}-42"}, + {"frame_text": "Example Domain"} + ]}, + {"do": "type", "text": "echo hi-${nonce}", "enter": true, "mark": "first"}, + {"do": "type", "text": "zzz${nonce}", "enter": true, "mark": "second"}, + {"do": "expect", "title": "the two typed lines did not rename the workspace or open the sidebar's rename field", "since": "second", "timeout": 10, "settle": 2, + "all": [ + {"app_layout": {"workspace": "jr-refocus-${nonce}"}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "not_in": ["sidebar", "sidebarField"]}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.appkit.classified", "not_in": ["sidebar", "sidebarField"]}}, + {"daemon_lacks": "zzz${nonce}"}, + {"frame_text": "jr-refocus-${nonce}"} + ]}, + + {"do": "key", "key": "left", "modifiers": ["cmd", "option"], "mark": "to-left"}, + {"do": "expect", "title": "Cmd+Opt+Left moves the keyboard to the restored terminal on the left", "since": "to-left", "timeout": 10, + "all": [ + {"app_pane": {"focused": true}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "terminal"}} + ]}, + {"do": "type", "text": "echo ok-${nonce}-$((6*7))", "enter": true, "mark": "ok"}, + {"do": "expect", "title": "the restored terminal runs the typed command", "since": "ok", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "ok-${nonce}-42"}, + {"app_text_line": "ok-${nonce}-42"}, + {"app_layout": {"workspace": "jr-refocus-${nonce}"}}, + {"frame_text": "ok-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/restart-restore.json b/scripts/journeys/journeys/p0/restart-restore.json new file mode 100644 index 000000000000..362c31fdef1d --- /dev/null +++ b/scripts/journeys/journeys/p0/restart-restore.json @@ -0,0 +1,70 @@ +{ + "id": "restart-restore", + "priority": "P0", + "title": "Cmd-Q and reopen with 60 workspaces: the shown terminals show their last screen in the first frame, never an empty pane, and are live within 1 s", + "why": "Lawrence 2026-10-27 19:25 (handoff/restore-perf.md, restore-shot-2026-10-27-1925.png): cmux NIGHTLY relaunched with about 60 workspaces showed its three visible terminals as empty panes with a cursor box and 'Restoring...' for seconds: nothing usable on screen. Chrome's session restore shows the visible tab's last content at once and loads hidden tabs lazily. Guards the frame hold that gave up after 3 s (cp/restore-frame-hold) and every later restore regression.", + "source": "restore-perf.md binding targets: last content in the first frame, no blank pane, no Restoring pill over a blank pane, visible terminals live within 300 ms warm / 1 s cold, hidden workspaces attach on first show", + "budget_seconds": 150, + "steps": [ + {"do": "new_terminal", "name": "jr-restore-${nonce}", "as": "left"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "for i in 1 2 3 4 5 6 7 8; do echo left-${nonce}-$i; done", "enter": true, "mark": "left"}, + {"do": "expect", "title": "the left shell printed its lines", "since": "left", "timeout": 10, + "all": [{"daemon_line": "left-${nonce}-8"}]}, + + {"do": "key", "key": "d", "modifiers": ["cmd"]}, + {"do": "focused_terminal", "as": "right"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "for i in 1 2 3 4 5 6 7 8; do echo right-${nonce}-$i; done", "enter": true, "mark": "right"}, + {"do": "expect", "title": "the right shell printed its lines", "since": "right", "timeout": 10, + "all": [{"daemon_line": "right-${nonce}-8"}]}, + + {"do": "key", "key": "d", "modifiers": ["cmd", "shift"]}, + {"do": "focused_terminal", "as": "down"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "for i in 1 2 3 4 5 6 7 8; do echo down-${nonce}-$i; done", "enter": true, "mark": "down"}, + {"do": "expect", "title": "the third shell printed its lines", "since": "down", "timeout": 10, + "all": [{"daemon_line": "down-${nonce}-8"}]}, + + {"do": "seed_workspaces", "count": 59, "prefix": "jr-bg-${nonce}"}, + {"do": "expect", "title": "before the quit: 3 terminals shown, 59 more workspaces", "timeout": 20, + "all": [ + {"app_layout": {"workspace": "jr-restore-${nonce}", "panes": 3, "tabs": 3, "selected": true}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.workspaces", "len_gte": 60}}, + {"app_text_line": "down-${nonce}-8"}, + {"frame_text": "right-${nonce}-8"} + ]}, + + {"do": "restore_relaunch", "how": "quit", "mark": "reopened"}, + + {"do": "expect", "title": "the shown terminals showed their last screen from the first frame, never an empty pane, and went live within 1 s", "timeout": 5, + "all": [ + {"restore_no_blank": {"pill_over_blank": 0}}, + {"restore_live": {"within_ms": 1000}}, + {"restore_hidden_lazy": {"max": 8}}, + {"app_rpc": {"method": "debug.surfaces", "path": "blank_panes", "equals": 0}} + ]}, + {"do": "require_drawable"}, + {"do": "use", "name": "down"}, + {"do": "expect", "title": "the restored text is in the daemon, the view and the frame", "since": "reopened", "timeout": 30, + "all": [ + {"daemon_line": "down-${nonce}-8"}, + {"app_text_line": "down-${nonce}-8"}, + {"app_pane": {"link": "connected"}}, + {"frame_text": "left-${nonce}-8"}, + {"frame_text": "right-${nonce}-8"}, + {"frame_text": "down-${nonce}-8"} + ]}, + {"do": "type", "text": "echo back-${nonce}-$((6*7))", "enter": true, "mark": "back"}, + {"do": "expect", "title": "the restored shell takes input", "since": "back", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "back-${nonce}-42"}, + {"app_text_line": "back-${nonce}-42"}, + {"frame_text": "back-${nonce}-42"} + ]} + ], + "finally": [ + {"do": "restore_diagnose"}, + {"do": "close_seeded", "prefix": "jr-bg-${nonce}"} + ] +} diff --git a/scripts/journeys/journeys/p0/scripted-newtab-keeps-person-keys.json b/scripts/journeys/journeys/p0/scripted-newtab-keeps-person-keys.json new file mode 100644 index 000000000000..95d6625e5761 --- /dev/null +++ b/scripts/journeys/journeys/p0/scripted-newtab-keeps-person-keys.json @@ -0,0 +1,39 @@ +{ + "id": "scripted-newtab-keeps-person-keys", + "priority": "P0", + "title": "A person types in the Chief composer on Home; a script's newTab makes a terminal but the person's text stays in the composer and no key reaches the new shell", + "why": "Automation never competes with the person (CLAUDE.md, \"Go past Chrome where cmux is scripted\"). Since cp/fix-chief-composer-focus only the person's own newTab (key, menu, click, palette) claims the window's keyboard; a script's newTab (the socket, origin script) makes the workspace without showing it while a text input has the person's keyboard. Before it, a script's newTab took the window from a person typing to Chief and their next keys went to the new shell.", + "source": "cp/fix-chief-composer-focus (80723559628): a script's newTab never takes a field the person focused", + "first_in_app": true, + "budget_seconds": 90, + "steps": [ + {"do": "fresh_app"}, + {"do": "expect", "title": "one window on Home, the daemon connected, no workspace but Home", "timeout": 30, + "all": [ + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.windows", "path": "launch_window", "equals": null}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.loaded", "equals": true}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.daemon_state", "equals": "connected"}}, + {"app_rpc": {"method": "snapshot.get", "path": "topology.workspaces", "len": 1}} + ]}, + {"do": "rpc", "title": "set-up: the person's window is the active app's key window (a capture slot never is; the app's own become-active and become-key notices, NSApp.isActive stays false)", + "method": "debug.app_activation", "params": {"active": true, "window_notices": true}}, + {"do": "rpc", "title": "where the keyboard is before the person types", "method": "debug.focus", "params": {}}, + {"do": "type", "text": "keep-${nonce}", "mark": "typed"}, + {"do": "rpc", "title": "where the keyboard is after the person typed", "method": "debug.focus", "params": {}}, + {"do": "script_new_terminal", "name": "jr-scripted-${nonce}", "focus": true}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "-more-${nonce}", "mark": "more"}, + {"do": "rpc", "title": "where the keyboard is after the script's newTab", "method": "debug.focus", "params": {}}, + {"do": "expect", "title": "the script's workspace exists with a working shell that got none of the person's keys; the window stays on Home and the composer shows all the person typed", "since": "more", "timeout": 10, "settle": 1.5, + "all": [ + {"app_rpc": {"method": "snapshot.get", "path": "topology.workspaces", "len": 2}}, + {"app_rpc": {"method": "debug.windows", "path": "windows", "len": 1}}, + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "not_equals": "terminal"}}, + {"daemon_prompt": true}, + {"daemon_lacks": "keep-"}, + {"daemon_lacks": "more-"}, + {"frame_text": "keep-${nonce}-more-${nonce}"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/scrollback-100k.json b/scripts/journeys/journeys/p0/scrollback-100k.json new file mode 100644 index 000000000000..911b01f919c7 --- /dev/null +++ b/scripts/journeys/journeys/p0/scrollback-100k.json @@ -0,0 +1,107 @@ +{ + "id": "scrollback-100k", + "priority": "P0", + "title": "100k lines of output: scroll up, down, to the top and to the bottom; the view follows new output only at the bottom", + "why": "Long output is everyday terminal use (builds, logs). Every row must arrive in order, the wheel and Cmd+Home / Cmd+End must move the view through the scrollback the daemon holds, and output that arrives while the person reads older rows must not yank the view down (dogfood v1: the scrollback jumped on live resize, fix 673587c97dc).", + "source": "everyday terminal use (holistic-quality.md 1), dogfood bug v1 (scroll position)", + "budget_seconds": 150, + "steps": [ + {"do": "new_terminal", "name": "jr-scrollback"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "seq -f 'S%06g-${nonce}' 1 100000", "enter": true, "mark": "gen"}, + {"do": "expect", "title": "all 100k rows arrive, in order, and the prompt returns", "since": "gen", "timeout": 90, + "all": [ + {"daemon_line": "S100000-${nonce}"}, + {"daemon_prompt": true}, + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "last": {"eq": 100000}}}, + {"app_prompt": true}, + {"frame_text": "S100000-${nonce}"} + ]}, + {"do": "expect", "title": "the daemon's scrollback holds the rows before the screen, in order", "timeout": 10, + "all": [ + {"daemon_history": {"rows": "newest", "count": 2000, "pattern": "S(\\d{6})-${nonce}", "contiguous": true, "contains": "S099900-${nonce}"}}, + {"daemon_history": {"rows": "oldest", "count": 200, "pattern": "S(\\d{6})-${nonce}", "contiguous": true}} + ]}, + + {"do": "mouse", "action": "scroll", "dy": 40, "count": 30, "interval": 0.03, "mark": "up"}, + {"do": "expect", "title": "the wheel scrolls back: older rows show, in order; nothing reaches the shell", "since": "up", "timeout": 5, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "last": {"lt": 100000}, "bind": "up"}}, + {"app_text_lacks": "S100000-${nonce}"}, + {"daemon_line": "S100000-${nonce}"}, + {"daemon_prompt": true} + ]}, + {"do": "expect", "title": "the frame shows the scrolled-back rows", "timeout": 5, + "all": [{"frame_screen": {"shows": ["${up_mid}"], "lacks": ["S100000-${nonce}"]}}]}, + + {"do": "mouse", "action": "scroll", "dy": -40, "count": 8, "interval": 0.03, "mark": "down"}, + {"do": "expect", "title": "the wheel scrolls forward again, not to the bottom", "since": "down", "timeout": 5, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "top": {"gt": "up"}, "last": {"lt": 100000}}}, + {"daemon_prompt": true} + ]}, + + {"do": "key", "key": "home", "modifiers": ["cmd"], "mark": "top"}, + {"do": "expect", "title": "Cmd+Home jumps far back, to older rows than the wheel reached", "since": "top", "within": 1.5, "timeout": 5, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "top": {"lt": "up"}, "last": {"lt": 99000}, "bind": "top"}}, + {"daemon_prompt": true} + ]}, + {"do": "mouse", "action": "scroll", "dy": 40, "count": 20, "interval": 0.03, "mark": "past-top"}, + {"do": "expect", "title": "it is the top: the wheel cannot go further up", "since": "past-top", "timeout": 5, "settle": 1, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "top": {"eq": "top"}, "hold": true}}, + {"daemon_prompt": true} + ]}, + {"do": "expect", "title": "the frame shows the oldest rows", "timeout": 5, + "all": [{"frame_screen": {"shows": ["${top_mid}"], "lacks": ["S100000-${nonce}"]}}]}, + + {"do": "key", "key": "end", "modifiers": ["cmd"], "mark": "bottom"}, + {"do": "expect", "title": "Cmd+End returns to the last row and the prompt", "since": "bottom", "within": 1.5, "timeout": 5, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "last": {"eq": 100000}}}, + {"app_prompt": true}, + {"daemon_prompt": true}, + {"frame_text": "S100000-${nonce}"} + ]}, + + {"do": "type", "text": "for i in $(seq 1 12); do echo T$i-${nonce}; sleep 0.5; done", "enter": true, "mark": "loop"}, + {"do": "mouse", "action": "scroll", "dy": 40, "count": 30, "interval": 0.03}, + {"do": "expect", "title": "scrolled back while the loop prints", "timeout": 5, + "all": [{"app_rows": {"pattern": "S(\\d{6})-${nonce}", "last": {"lt": 100000}, "bind": "held"}}]}, + {"do": "expect", "title": "the loop's output reaches the terminal", "since": "loop", "timeout": 30, + "all": [ + {"daemon_line": "T12-${nonce}"}, + {"daemon_prompt": true} + ]}, + {"do": "expect", "title": "output that arrived while scrolled back did not move the view", "timeout": 5, "settle": 1.5, + "all": [ + {"app_rows": {"pattern": "S(\\d{6})-${nonce}", "top": {"eq": "held"}, "hold": true}}, + {"app_text_lacks": "T12-${nonce}"}, + {"daemon_line": "T12-${nonce}"} + ]}, + {"do": "expect", "title": "the frame still shows the rows the person was reading", "timeout": 5, + "all": [{"frame_screen": {"shows": ["${held_mid}"], "lacks": ["T12-${nonce}"]}}]}, + + {"do": "key", "key": "end", "modifiers": ["cmd"], "mark": "bottom2"}, + {"do": "expect", "title": "back at the bottom: the loop's output and the prompt show", "since": "bottom2", "within": 1.5, "timeout": 5, + "all": [ + {"app_text_line": "T12-${nonce}"}, + {"app_prompt": true} + ]}, + {"do": "type", "text": "for i in $(seq 1 6); do echo U$i-${nonce}; sleep 0.5; done", "enter": true, "mark": "follow"}, + {"do": "expect", "title": "at the bottom, the view follows new output as it arrives", "since": "follow", "timeout": 20, + "all": [ + {"app_text_line": "U3-${nonce}"}, + {"daemon_line": "U3-${nonce}"} + ]}, + {"do": "expect", "title": "the last line and the prompt show", "since": "follow", "timeout": 20, + "all": [ + {"daemon_line": "U6-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_line": "U6-${nonce}"}, + {"app_prompt": true}, + {"frame_text": "U6-${nonce}"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/second-window-resize.json b/scripts/journeys/journeys/p0/second-window-resize.json new file mode 100644 index 000000000000..a8f3e590cf90 --- /dev/null +++ b/scripts/journeys/journeys/p0/second-window-resize.json @@ -0,0 +1,71 @@ +{ + "id": "second-window-resize", + "priority": "P0", + "title": "Resize a second window by drag while the first window's terminal streams output: both stay right", + "why": "With two windows open, a live resize of one must refit that window's terminal and PTY while the other keeps drawing its running output (no stall, no blank frame, no jump), and keys must still reach the first window's job afterwards.", + "source": "window life: two windows, resize by drag (holistic-quality.md 1)", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-two-windows"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "w1"}, + {"do": "type", "text": "for i in $(seq 1 200000); do echo out-${nonce}-$i; sleep 0.02; done", "enter": true, "mark": "stream"}, + {"do": "expect", "title": "the first window's terminal streams output", "since": "stream", "timeout": 10, + "all": [{"daemon_contains": "out-${nonce}-"}, {"output_advances": {"pattern": "out-${nonce}-(\\d+)", "by": 20}}]}, + + {"do": "key", "title": "Cmd-Shift-N (a new window, on its New Tab page)", "key": "n", "modifiers": ["cmd", "shift"], "mark": "new-window"}, + {"do": "adopt_window", "title": "the new window opens; it becomes the key window"}, + {"do": "wait", "seconds": 4.5, "title": "Cmd-Shift-N's New Tab key hold (NewTabInputCoordinator.incomingHoldLimit, 4 s) ends: in a capture slot the app is never active, so held keys would be replayed into the first window (NSApp.keyWindow is nil), which no person sees; no state shows the hold's end"}, + {"do": "key", "title": "Ctrl-` in the new window (New Terminal Tab)", "key": "`", "modifiers": ["control"]}, + {"do": "adopt_pane", "as": "w2", "window": "same", "title": "the new window's terminal tab has the focus and a shell"}, + {"do": "neutral_prompt"}, + {"do": "window_place", "title": "set-up: the second window at 45% x 45% of the screen, right of center", "fraction": [0.4, 0.3, 0.45, 0.45]}, + {"do": "expect", "title": "the second window's terminal fits it", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "w2-start"}, + {"do": "type", "text": "seq 1 2000; echo w2-${nonce}", "enter": true, "mark": "w2-typed"}, + {"do": "expect", "title": "the second window's shell answers", "since": "w2-typed", "timeout": 15, + "all": [{"daemon_line": "w2-${nonce}"}, {"app_prompt": true}, {"pane_frame_text": "w2-${nonce}"}]}, + + {"do": "window_drag", "title": "drag the second window's corner 250 right and 180 down (button held)", "by": [250, 180], "events": 40, "release": false, "mark": "drag"}, + {"do": "expect", "title": "mid-drag: the second window's grid follows, its prompt stays in view; the first window keeps streaming", "since": "drag", "timeout": 10, + "all": [ + {"window_frame": {"from": "w2-start", "dw": 250, "dh": 180}}, + {"grid_fits": {"vs": "w2-start", "cols": "wider", "rows": "taller"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"output_advances": {"pane": "w1", "pattern": "out-${nonce}-(\\d+)", "by": 20}}, + {"grid_fits": {"pane": "w1"}}, + {"pane_frame_text": "w2-${nonce}"} + ]}, + {"do": "window_drag", "title": "release", "press": false, "by": [10, 10], "events": 3, "release": true}, + {"do": "type", "text": "echo pty-${nonce} $(stty size)", "enter": true, "mark": "after"}, + {"do": "expect", "title": "after the drag: the second window's PTY has its new grid", "since": "after", "timeout": 10, + "all": [ + {"pty_size": "pty-${nonce}"}, + {"grid_fits": {"vs": "w2-start", "cols": "wider", "rows": "taller"}}, + {"daemon_prompt": true}, + {"pane_frame_text": "pty-${nonce}"} + ]}, + + {"do": "use_pane", "pane": "w1", "title": "back to the first window (keys and frames)"}, + {"do": "expect", "title": "the first window still streams and draws its output", "timeout": 10, + "all": [{"output_advances": {"pattern": "out-${nonce}-(\\d+)", "by": 20}}, {"grid_fits": {}}, {"pane_frame_text": "out-${nonce}-"}]}, + {"do": "key", "title": "Ctrl+C in the first window", "key": "c", "modifiers": ["control"], "mark": "stop"}, + {"do": "expect", "title": "the stream stops, the prompt returns", "since": "stop", "timeout": 5, + "all": [{"daemon_prompt": true}]}, + {"do": "type", "text": "echo w1-${nonce}-done", "enter": true, "mark": "w1"}, + {"do": "expect", "title": "the first window's shell answers and the frame shows it, at the bottom", "since": "w1", "timeout": 10, + "all": [ + {"daemon_line": "w1-${nonce}-done"}, + {"app_text_line": "w1-${nonce}-done"}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"pane_frame_text": "w1-${nonce}-done"} + ]} + ], + "finally": [ + {"do": "use_pane", "pane": "w1"}, + {"do": "key", "key": "c", "modifiers": ["control"]}, + {"do": "close_window", "window": "new"} + ] +} diff --git a/scripts/journeys/journeys/p0/select-copy.json b/scripts/journeys/journeys/p0/select-copy.json new file mode 100644 index 000000000000..d6e9c2be55a4 --- /dev/null +++ b/scripts/journeys/journeys/p0/select-copy.json @@ -0,0 +1,46 @@ +{ + "id": "select-copy", + "priority": "P0", + "title": "Drag-select and double-click a word, Cmd+C: the clipboard gets exactly that text, and it pastes back", + "why": "Copying output is everyday terminal use. The pointer goes through the app's real mouse path (debug.mouse posts the events to the app's queue); the clipboard must get exactly the selected text, no more, no less, with no trailing space or newline.", + "source": "everyday terminal use (holistic-quality.md 1)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-select-copy"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo copy-me-${nonce}-$((6*7)) tail dbl${nonce}x end", "enter": true, "mark": "out"}, + {"do": "expect", "title": "the line to copy shows", "since": "out", "timeout": 10, + "all": [ + {"daemon_line": "copy-me-${nonce}-42 tail dbl${nonce}x end"}, + {"app_text_line": "copy-me-${nonce}-42 tail dbl${nonce}x end"}, + {"app_prompt": true} + ]}, + {"do": "clipboard_set", "text": "stale-${nonce}"}, + + {"do": "select_text", "text": "copy-me-${nonce}-42", "how": "drag"}, + {"do": "key", "key": "c", "modifiers": ["cmd"], "mark": "copy-drag"}, + {"do": "expect", "title": "Cmd+C puts exactly the dragged text on the clipboard; the shell gets no key", "since": "copy-drag", "within": 1, "timeout": 5, + "all": [ + {"clipboard": "copy-me-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_prompt": true} + ]}, + + {"do": "select_text", "text": "dbl${nonce}x", "how": "double_click"}, + {"do": "key", "key": "c", "modifiers": ["cmd"], "mark": "copy-word"}, + {"do": "expect", "title": "a double-click selects the word; Cmd+C copies exactly it", "since": "copy-word", "within": 1, "timeout": 5, + "all": [{"clipboard": "dbl${nonce}x"}]}, + + {"do": "type", "text": "echo "}, + {"do": "key", "key": "v", "modifiers": ["cmd"]}, + {"do": "type", "text": "-again", "enter": true, "mark": "again"}, + {"do": "expect", "title": "the copied word pastes back as typed text", "since": "again", "within": 3, "timeout": 10, + "all": [ + {"daemon_line": "${prompt}echo dbl${nonce}x-again"}, + {"daemon_line": "dbl${nonce}x-again"}, + {"daemon_prompt": true}, + {"app_text_line": "dbl${nonce}x-again"}, + {"frame_text": "dbl${nonce}x-again"} + ]} + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-d.json b/scripts/journeys/journeys/p0/spam-cmd-d.json new file mode 100644 index 000000000000..be8fe27d99dd --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-d.json @@ -0,0 +1,144 @@ +{ + "id": "spam-cmd-d", + "priority": "P0", + "title": "Cmd-D held makes one item per repeat and nothing after the key-up; 4 presses in 0.2 s make 4", + "why": "Lawrence 2026-10-28: \"if i hold cmd t then stop holding it, it still creates new stuff for a while\". Cmd-D must keep up with the key and stop with it.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-cmd-d", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "d", + "modifiers": [ + "cmd" + ], + "seconds": 0.6, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-D: one new pane per repeat that ran, none after the key-up", + "since": "held", + "timeout": 15, + "all": [ + { + "spam_counts": { + "from": "before-hold", + "field": "panes", + "delta_runs": "held" + } + }, + { + "spam_runs": { + "of": "held", + "ran_ge": 2 + } + } + ] + }, + { + "do": "expect", + "title": "nothing more was made after Cmd-D went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "d", + "modifiers": [ + "cmd" + ], + "presses": 4, + "within": 0.2, + "as": "hammered" + }, + { + "do": "expect", + "title": "4 presses of Cmd-D in 0.2 s made exactly 4", + "since": "hammered", + "timeout": 20, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "panes", + "delta": 4 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 4 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-digit.json b/scripts/journeys/journeys/p0/spam-cmd-digit.json new file mode 100644 index 000000000000..ba96879fa368 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-digit.json @@ -0,0 +1,127 @@ +{ + "id": "spam-cmd-digit", + "priority": "P0", + "title": "Cmd-1..9 hammered: every press runs; held: nothing after the key-up", + "why": "Workspace switching by number under spam must neither drop presses nor run on.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-spam-digits", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "n", + "modifiers": [ + "cmd" + ], + "repeat": 2, + "gap": 0.5, + "mark": "made" + }, + { + "do": "wait", + "seconds": 2 + }, + { + "do": "use", + "name": "first" + }, + { + "do": "hammer_key", + "key": "2", + "modifiers": [ + "cmd" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Cmd-2 in 1 s each ran", + "since": "hammered", + "timeout": 10, + "all": [ + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "1", + "modifiers": [ + "cmd" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "nothing ran after Cmd-1 went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-n.json b/scripts/journeys/journeys/p0/spam-cmd-n.json new file mode 100644 index 000000000000..701c881aaedf --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-n.json @@ -0,0 +1,144 @@ +{ + "id": "spam-cmd-n", + "priority": "P0", + "title": "Cmd-N held makes one item per repeat and nothing after the key-up; 10 presses in 0.5 s make 10", + "why": "Lawrence 2026-10-28: \"if i hold cmd t then stop holding it, it still creates new stuff for a while\". Cmd-N must keep up with the key and stop with it.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-cmd-n", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "n", + "modifiers": [ + "cmd" + ], + "seconds": 1.0, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-N: one new workspace per repeat that ran, none after the key-up", + "since": "held", + "timeout": 15, + "all": [ + { + "spam_counts": { + "from": "before-hold", + "field": "workspaces", + "delta_runs": "held" + } + }, + { + "spam_runs": { + "of": "held", + "ran_ge": 2 + } + } + ] + }, + { + "do": "expect", + "title": "nothing more was made after Cmd-N went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "n", + "modifiers": [ + "cmd" + ], + "presses": 10, + "within": 0.5, + "as": "hammered" + }, + { + "do": "expect", + "title": "10 presses of Cmd-N in 0.5 s made exactly 10", + "since": "hammered", + "timeout": 20, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "workspaces", + "delta": 10 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 10 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-shift-close-bracket.json b/scripts/journeys/journeys/p0/spam-cmd-shift-close-bracket.json new file mode 100644 index 000000000000..49bb7e30fa51 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-shift-close-bracket.json @@ -0,0 +1,161 @@ +{ + "id": "spam-cmd-shift-close-bracket", + "priority": "P0", + "title": "Cmd-Shift-] hammered and held moves the selection exactly once per press or repeat", + "why": "Tab cycling under key repeat must never skip, double or run on after the key-up.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-cmd-shift-close-bracket", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 4, + "gap": 0.2, + "mark": "tabs" + }, + { + "do": "expect", + "title": "4 more terminal tabs in the pane", + "since": "tabs", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 5 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "}", + "modifiers": [ + "cmd" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Cmd-Shift-] in 1 s moved the selection 20 tabs", + "since": "hammered", + "within": 1, + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hammer", + "by": 20 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "}", + "modifiers": [ + "cmd" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-Shift-]: one step per repeat that ran", + "since": "held", + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hold", + "by_runs": "held", + "sign": 1 + } + } + ] + }, + { + "do": "expect", + "title": "the selection stopped when Cmd-Shift-] went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-shift-open-bracket.json b/scripts/journeys/journeys/p0/spam-cmd-shift-open-bracket.json new file mode 100644 index 000000000000..7cf32fd01fe1 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-shift-open-bracket.json @@ -0,0 +1,161 @@ +{ + "id": "spam-cmd-shift-open-bracket", + "priority": "P0", + "title": "Cmd-Shift-[ hammered and held moves the selection exactly once per press or repeat", + "why": "Tab cycling under key repeat must never skip, double or run on after the key-up.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-cmd-shift-open-bracket", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 4, + "gap": 0.2, + "mark": "tabs" + }, + { + "do": "expect", + "title": "4 more terminal tabs in the pane", + "since": "tabs", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 5 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "{", + "modifiers": [ + "cmd" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Cmd-Shift-[ in 1 s moved the selection 20 tabs", + "since": "hammered", + "within": 1, + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hammer", + "by": -20 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "{", + "modifiers": [ + "cmd" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-Shift-[: one step per repeat that ran", + "since": "held", + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hold", + "by_runs": "held", + "sign": -1 + } + } + ] + }, + { + "do": "expect", + "title": "the selection stopped when Cmd-Shift-[ went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-shift-t.json b/scripts/journeys/journeys/p0/spam-cmd-shift-t.json new file mode 100644 index 000000000000..2c7cf81f8586 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-shift-t.json @@ -0,0 +1,149 @@ +{ + "id": "spam-cmd-shift-t", + "priority": "P0", + "title": "Cmd-Shift-T hammered reopens one closed tab per press, newest first, none twice", + "why": "audit A1: press 2 inside one round trip reopened the same item (not found) or an older one.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-spam-reopen", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 6, + "gap": 0.2, + "mark": "tabs" + }, + { + "do": "expect", + "title": "6 more terminal tabs in the pane", + "since": "tabs", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 7 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "w", + "modifiers": [ + "cmd" + ], + "repeat": 6, + "gap": 0.25, + "mark": "closed" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "expect", + "title": "6 tabs closed", + "since": "closed", + "timeout": 10, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "pane_tabs", + "equals": 1 + } + } + ] + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "T", + "modifiers": [ + "cmd" + ], + "presses": 6, + "within": 0.3, + "as": "hammered" + }, + { + "do": "expect", + "title": "6 Cmd-Shift-T presses in 0.3 s reopened 6 tabs, none twice", + "since": "hammered", + "within": 2, + "timeout": 15, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "pane_tabs", + "delta": 6 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 6 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-t.json b/scripts/journeys/journeys/p0/spam-cmd-t.json new file mode 100644 index 000000000000..0bccb5faab39 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-t.json @@ -0,0 +1,144 @@ +{ + "id": "spam-cmd-t", + "priority": "P0", + "title": "Cmd-T held makes one item per repeat and nothing after the key-up; 20 presses in 1.0 s make 20", + "why": "Lawrence 2026-10-28: \"if i hold cmd t then stop holding it, it still creates new stuff for a while\". Cmd-T must keep up with the key and stop with it.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-cmd-t", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "t", + "modifiers": [ + "cmd" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-T: one new tab per repeat that ran, none after the key-up", + "since": "held", + "timeout": 15, + "all": [ + { + "spam_counts": { + "from": "before-hold", + "field": "tabs", + "delta_runs": "held" + } + }, + { + "spam_runs": { + "of": "held", + "ran_ge": 2 + } + } + ] + }, + { + "do": "expect", + "title": "nothing more was made after Cmd-T went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "t", + "modifiers": [ + "cmd" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Cmd-T in 1.0 s made exactly 20", + "since": "hammered", + "timeout": 20, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "tabs", + "delta": 20 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-cmd-w.json b/scripts/journeys/journeys/p0/spam-cmd-w.json new file mode 100644 index 000000000000..1907b72e7110 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-cmd-w.json @@ -0,0 +1,206 @@ +{ + "id": "spam-cmd-w", + "priority": "P0", + "title": "Cmd-W held stops before the pane's last tab; 6 presses in 0.3 s close 6 tabs", + "why": "Spamming Cmd-W must close exactly one tab per press, from what the person sees, and a held key never closes the pane (Lawrence 2026-10-28).", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-spam-w", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 7, + "gap": 0.2, + "mark": "tabs1" + }, + { + "do": "expect", + "title": "7 more terminal tabs in the pane", + "since": "tabs1", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 8 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "w", + "modifiers": [ + "cmd" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "held Cmd-W closed the pane's tabs at the repeat rate and stopped before its last one", + "since": "held", + "timeout": 10, + "all": [ + { + "spam_counts": { + "from": "before-hold", + "field": "pane_tabs", + "equals": 1 + } + }, + { + "spam_counts": { + "from": "before-hold", + "field": "panes", + "delta": 0 + } + }, + { + "spam_counts": { + "from": "before-hold", + "field": "workspaces", + "delta": 0 + } + } + ] + }, + { + "do": "expect", + "title": "nothing more closed after the key went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "use", + "name": "first" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 6, + "gap": 0.2, + "mark": "tabs2" + }, + { + "do": "expect", + "title": "6 more terminal tabs in the pane", + "since": "tabs2", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 7 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "w", + "modifiers": [ + "cmd" + ], + "presses": 6, + "within": 0.3, + "as": "hammered" + }, + { + "do": "expect", + "title": "6 Cmd-W presses in 0.3 s closed exactly 6 tabs", + "since": "hammered", + "within": 1, + "timeout": 10, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "pane_tabs", + "delta": -6 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 6 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-ctrl-backtick.json b/scripts/journeys/journeys/p0/spam-ctrl-backtick.json new file mode 100644 index 000000000000..c3a9b3348086 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-ctrl-backtick.json @@ -0,0 +1,144 @@ +{ + "id": "spam-ctrl-backtick", + "priority": "P0", + "title": "Ctrl-` held makes one item per repeat and nothing after the key-up; 20 presses in 1.0 s make 20", + "why": "Lawrence 2026-10-28: \"if i hold cmd t then stop holding it, it still creates new stuff for a while\". Ctrl-` must keep up with the key and stop with it.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-ctrl-backtick", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "`", + "modifiers": [ + "control" + ], + "seconds": 1.2, + "as": "held" + }, + { + "do": "expect", + "title": "held Ctrl-`: one new tab per repeat that ran, none after the key-up", + "since": "held", + "timeout": 15, + "all": [ + { + "spam_counts": { + "from": "before-hold", + "field": "tabs", + "delta_runs": "held" + } + }, + { + "spam_runs": { + "of": "held", + "ran_ge": 2 + } + } + ] + }, + { + "do": "expect", + "title": "nothing more was made after Ctrl-` went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "`", + "modifiers": [ + "control" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Ctrl-` in 1.0 s made exactly 20", + "since": "hammered", + "timeout": 20, + "all": [ + { + "spam_counts": { + "from": "before-hammer", + "field": "tabs", + "delta": 20 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/spam-ctrl-tab.json b/scripts/journeys/journeys/p0/spam-ctrl-tab.json new file mode 100644 index 000000000000..c2ca4d0a4637 --- /dev/null +++ b/scripts/journeys/journeys/p0/spam-ctrl-tab.json @@ -0,0 +1,161 @@ +{ + "id": "spam-ctrl-tab", + "priority": "P0", + "title": "Ctrl-Tab hammered and held moves the selection exactly once per press or repeat", + "why": "Tab cycling under key repeat must never skip, double or run on after the key-up.", + "source": "shortcut spam (handoff/input-principles.md, hold-shortcuts-ux.md, repeat-runoff.md 1; Lawrence 2026-10-28)", + "budget_seconds": 180, + "steps": [ + { + "do": "new_terminal", + "via": "key", + "name": "jr-ctrl-tab", + "as": "first" + }, + { + "do": "neutral_prompt" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "repeat": 4, + "gap": 0.2, + "mark": "tabs" + }, + { + "do": "expect", + "title": "4 more terminal tabs in the pane", + "since": "tabs", + "timeout": 30, + "all": [ + { + "daemon_pane_ready": { + "tabs": 5 + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "spam_remember", + "as": "before-hammer" + }, + { + "do": "hammer_key", + "key": "tab", + "modifiers": [ + "control" + ], + "presses": 20, + "within": 1.0, + "as": "hammered" + }, + { + "do": "expect", + "title": "20 presses of Ctrl-Tab in 1 s moved the selection 20 tabs", + "since": "hammered", + "within": 1, + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hammer", + "by": 20 + } + }, + { + "spam_runs": { + "of": "hammered", + "ran": 20 + } + } + ] + }, + { + "do": "spam_remember", + "as": "before-hold" + }, + { + "do": "hold_key", + "key": "tab", + "modifiers": [ + "control" + ], + "seconds": 2, + "as": "held" + }, + { + "do": "expect", + "title": "held Ctrl-Tab: one step per repeat that ran", + "since": "held", + "timeout": 10, + "all": [ + { + "spam_selected": { + "from": "before-hold", + "by_runs": "held", + "sign": 1 + } + } + ] + }, + { + "do": "expect", + "title": "the selection stopped when Ctrl-Tab went up", + "since": "held", + "timeout": 5, + "settle": 1.5, + "all": [ + { + "spam_unchanged": { + "since": "held_release" + } + } + ] + }, + { + "do": "outwait_hold" + }, + { + "do": "key", + "key": "`", + "modifiers": [ + "control" + ], + "mark": "after" + }, + { + "do": "focused_terminal", + "as": "after" + }, + { + "do": "neutral_prompt" + }, + { + "do": "type", + "text": "echo after-${nonce}-$((6*7))", + "enter": true, + "mark": "typed" + }, + { + "do": "expect", + "title": "after the spam a new terminal answers, in the daemon and on screen", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + { + "daemon_line": "after-${nonce}-42" + }, + { + "frame_text": "after-${nonce}-42" + } + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/split-right-down.json b/scripts/journeys/journeys/p0/split-right-down.json new file mode 100644 index 000000000000..96fa70982a39 --- /dev/null +++ b/scripts/journeys/journeys/p0/split-right-down.json @@ -0,0 +1,62 @@ +{ + "id": "split-right-down", + "priority": "P0", + "title": "Cmd-D splits right, Cmd-Shift-D splits down: each pane gets its half, its own shell and a PTY of its size", + "why": "Splitting is the first thing a person does after typing. Each new pane must start a shell that takes keys, and every pane (the old one too) must refit: the old pane's PTY shrinks to its half, or programs there wrap at the old width.", + "source": "window life: split (holistic-quality.md 1), Ghostty's split keys (Cmd-D, Cmd-Shift-D)", + "budget_seconds": 75, + "steps": [ + {"do": "new_terminal", "name": "jr-split"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "left"}, + {"do": "window_place", "title": "set-up: the window at 70% x 75% of the screen", "fraction": [0.05, 0.05, 0.7, 0.75]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "full"}, + + {"do": "key", "title": "Cmd-D", "key": "d", "modifiers": ["cmd"], "mark": "split-right"}, + {"do": "adopt_pane", "as": "right", "title": "the new pane on the right has the focus and a shell"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo right-${nonce} $(stty size)", "enter": true, "mark": "typed-right"}, + {"do": "expect", "title": "two panes side by side, each half the width, each fitted; the new shell answers", "since": "typed-right", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "right", "right_of": "left"}}, + {"pane_frame": {"pane": "left", "vs": "full", "w": 0.5, "h": 1.0, "tol": 0.06}}, + {"pane_frame": {"pane": "right", "vs": "full", "w": 0.5, "h": 1.0, "tol": 0.06}}, + {"grid_fits": {"pane": "left", "vs": "full", "cols": "narrower", "rows": "same"}}, + {"grid_fits": {"pane": "right", "vs": "full", "cols": "narrower", "rows": "same"}}, + {"pty_size": {"tag": "right-${nonce}", "pane": "right"}}, + {"app_pane": {"focused": true}}, + {"app_prompt": true}, + {"pane_frame_text": "right-${nonce}"} + ]}, + {"do": "remember", "as": "right-half", "pane": "right"}, + + {"do": "key", "title": "Cmd-Shift-D in the right pane", "key": "d", "modifiers": ["cmd", "shift"], "mark": "split-down"}, + {"do": "adopt_pane", "as": "bottom", "title": "the new pane below has the focus and a shell"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo bottom-${nonce} $(stty size)", "enter": true, "mark": "typed-bottom"}, + {"do": "expect", "title": "the right pane split in two rows, each fitted; the new shell answers", "since": "typed-bottom", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "bottom", "below": "right"}}, + {"pane_frame": {"pane": "right", "vs": "right-half", "w": 1.0, "h": 0.5, "tol": 0.07}}, + {"pane_frame": {"pane": "bottom", "vs": "right-half", "w": 1.0, "h": 0.5, "tol": 0.07}}, + {"grid_fits": {"pane": "right", "vs": "right-half", "cols": "same", "rows": "shorter"}}, + {"grid_fits": {"pane": "bottom", "vs": "right-half", "cols": "same", "rows": "shorter"}}, + {"grid_fits": {"pane": "left", "vs": "full", "cols": "narrower", "rows": "same"}}, + {"pty_size": {"tag": "bottom-${nonce}", "pane": "bottom"}}, + {"app_prompt": true}, + {"pane_frame_text": "bottom-${nonce}"} + ]}, + + {"do": "use_pane", "pane": "left", "click": true, "title": "click into the left pane"}, + {"do": "type", "text": "echo left-${nonce} $(stty size)", "enter": true, "mark": "typed-left"}, + {"do": "expect", "title": "the old pane's PTY has its half-width grid", "since": "typed-left", "timeout": 10, + "all": [ + {"pty_size": {"tag": "left-${nonce}", "pane": "left"}}, + {"daemon_prompt": true}, + {"app_text_contains": "left-${nonce} "}, + {"pane_frame_text": "left-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/type.json b/scripts/journeys/journeys/p0/type.json new file mode 100644 index 000000000000..8ca3cd824743 --- /dev/null +++ b/scripts/journeys/journeys/p0/type.json @@ -0,0 +1,28 @@ +{ + "id": "type", + "priority": "P0", + "title": "Type a command in a new terminal; its output shows", + "why": "The first thing a user does. Every key goes through the app's real key path (debug.key: the key router, then the terminal view), not a daemon write.", + "source": "everyday terminal use (holistic-quality.md 1)", + "budget_seconds": 45, + "steps": [ + {"do": "new_terminal", "via": "key"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo ok-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + { + "do": "expect", + "title": "the command and its output show", + "since": "typed", + "within": 3, + "timeout": 10, + "all": [ + {"daemon_line": "${prompt}echo ok-${nonce}-$((6*7))"}, + {"daemon_line": "ok-${nonce}-42"}, + {"daemon_prompt": true}, + {"app_text_line": "ok-${nonce}-42"}, + {"app_pane": {"focused": true}}, + {"frame_text": "ok-${nonce}-42"} + ] + } + ] +} diff --git a/scripts/journeys/journeys/p0/window-fullscreen.json b/scripts/journeys/journeys/p0/window-fullscreen.json new file mode 100644 index 000000000000..a7be6a8541ea --- /dev/null +++ b/scripts/journeys/journeys/p0/window-fullscreen.json @@ -0,0 +1,55 @@ +{ + "id": "window-fullscreen", + "priority": "P0", + "title": "Ctrl-Cmd-F enters full screen and leaves it: the grid fits the screen and the window again, the PTY follows", + "why": "Full screen is a resize the person does not drag: the window animates to the whole screen in its own Space and back. The terminal must end at the screen's grid and back at the window's, the PTY following, no blank frame, the prompt in view.", + "source": "window life: fullscreen (holistic-quality.md 1), Ctrl-Cmd-F (Ghostty and macOS default)", + "budget_seconds": 75, + "steps": [ + {"do": "new_terminal", "name": "jr-fullscreen"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "main"}, + {"do": "window_place", "title": "set-up: the window at 55% x 50% of the screen", "fraction": [0.1, 0.1, 0.55, 0.5]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}, {"full_screen": false}]}, + {"do": "remember", "as": "start"}, + {"do": "type", "text": "seq 1 500; echo end-${nonce}", "enter": true, "mark": "filled"}, + {"do": "expect", "title": "output, the prompt at the bottom", "since": "filled", "timeout": 15, + "all": [{"daemon_line": "end-${nonce}"}, {"app_prompt": true}]}, + + {"do": "key", "title": "Ctrl-Cmd-F", "key": "f", "modifiers": ["control", "cmd"], "mark": "enter"}, + {"do": "expect", "title": "full screen: the window is the whole screen; the grid fits it", "since": "enter", "timeout": 15, + "all": [ + {"full_screen": true}, + {"grid_fits": {"vs": "start", "cols": "wider", "rows": "taller"}}, + {"app_prompt": true}, + {"at_bottom": {}} + ]}, + {"do": "type", "text": "echo fs-${nonce} $(stty size)", "enter": true, "mark": "fs"}, + {"do": "expect", "title": "the PTY has the full-screen grid", "since": "fs", "timeout": 10, + "all": [ + {"pty_size": "fs-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_contains": "fs-${nonce} "}, + {"pane_frame_text": "fs-${nonce}"} + ]}, + + {"do": "key", "title": "Ctrl-Cmd-F again", "key": "f", "modifiers": ["control", "cmd"], "mark": "leave"}, + {"do": "expect", "title": "out of full screen: the window is back at its frame; the grid is the first grid again", "since": "leave", "timeout": 15, + "all": [ + {"full_screen": false}, + {"window_frame": {"same_as": "start", "tol": 4}}, + {"grid_fits": {"vs": "start", "cols": "same", "rows": "same"}}, + {"app_prompt": true}, + {"at_bottom": {}} + ]}, + {"do": "type", "text": "echo back-${nonce} $(stty size)", "enter": true, "mark": "back"}, + {"do": "expect", "title": "the PTY has the first grid again", "since": "back", "timeout": 10, + "all": [ + {"pty_size": "back-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_contains": "back-${nonce} "}, + {"pane_frame_text": "back-${nonce}"} + ]} + ], + "finally": [{"do": "leave_full_screen"}, {"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/window-maximize.json b/scripts/journeys/journeys/p0/window-maximize.json new file mode 100644 index 000000000000..ac3153825381 --- /dev/null +++ b/scripts/journeys/journeys/p0/window-maximize.json @@ -0,0 +1,54 @@ +{ + "id": "window-maximize", + "priority": "P0", + "title": "Window > Zoom fills the screen and back: the grid fits each size, the PTY follows", + "why": "Maximize (Window > Zoom, or Rectangle) is the window's largest jump in one frame: the terminal must refit to it and back, with the PTY's size following, no blank frame and the prompt in view.", + "source": "window life: maximize (holistic-quality.md 1)", + "budget_seconds": 60, + "steps": [ + {"do": "new_terminal", "name": "jr-maximize"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "main"}, + {"do": "window_place", "title": "set-up: the window at 55% x 50% of the screen", "fraction": [0.1, 0.1, 0.55, 0.5]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "start"}, + {"do": "type", "text": "seq 1 500; echo end-${nonce}", "enter": true, "mark": "filled"}, + {"do": "expect", "title": "output, the prompt at the bottom", "since": "filled", "timeout": 15, + "all": [{"daemon_line": "end-${nonce}"}, {"app_prompt": true}]}, + + {"do": "action", "title": "Window > Zoom", "action": "zoomWindow", "mark": "zoom"}, + {"do": "expect", "title": "the window fills the screen's visible frame; the grid fits it", "since": "zoom", "timeout": 10, + "all": [ + {"window_frame": {"fills": "visible", "tol": 12}}, + {"grid_fits": {"vs": "start", "cols": "wider", "rows": "taller"}}, + {"app_prompt": true}, + {"at_bottom": {}} + ]}, + {"do": "type", "text": "echo max-${nonce} $(stty size)", "enter": true, "mark": "max"}, + {"do": "expect", "title": "the PTY has the zoomed grid", "since": "max", "timeout": 10, + "all": [ + {"pty_size": "max-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_contains": "max-${nonce} "}, + {"pane_frame_text": "max-${nonce}"} + ]}, + + {"do": "action", "title": "Window > Zoom again", "action": "zoomWindow", "mark": "unzoom"}, + {"do": "expect", "title": "the window is back at its size; the grid is the first grid again", "since": "unzoom", "timeout": 10, + "all": [ + {"window_frame": {"same_as": "start", "tol": 4}}, + {"grid_fits": {"vs": "start", "cols": "same", "rows": "same"}}, + {"app_prompt": true}, + {"at_bottom": {}} + ]}, + {"do": "type", "text": "echo back-${nonce} $(stty size)", "enter": true, "mark": "back"}, + {"do": "expect", "title": "the PTY has the first grid again", "since": "back", "timeout": 10, + "all": [ + {"pty_size": "back-${nonce}"}, + {"daemon_prompt": true}, + {"app_text_contains": "back-${nonce} "}, + {"pane_frame_text": "back-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/window-resize-grow.json b/scripts/journeys/journeys/p0/window-resize-grow.json new file mode 100644 index 000000000000..799a39e5a8bb --- /dev/null +++ b/scripts/journeys/journeys/p0/window-resize-grow.json @@ -0,0 +1,52 @@ +{ + "id": "window-resize-grow", + "priority": "P0", + "title": "Drag the window wider, then taller (live): the grid follows the window and the scrollback stays at the prompt", + "why": "Dogfood v1 (Lawrence 2026-10-27, \"resizing window makes terminal looks buggy\"): a window growing while the terminal shows the bottom of a long scrollback scrolled the terminal into its history, further at each grow step (TerminalScroller took AppKit's clip clamp as a knob drag; test 33cd577dcd8, fix 673587c97dc). Checked while the button is still held (in the live resize), not only after.", + "source": "dogfood bug v1 (Lawrence), window life: resize by drag", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-resize-grow"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "main"}, + {"do": "window_place", "title": "set-up: the window at 55% x 50% of the screen, top left", "fraction": [0.02, 0.03, 0.55, 0.5]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "start"}, + {"do": "type", "text": "seq 1 3000; echo end-${nonce}", "enter": true, "mark": "filled"}, + {"do": "expect", "title": "a long scrollback, the prompt at the bottom", "since": "filled", "timeout": 20, + "all": [{"daemon_line": "end-${nonce}"}, {"daemon_prompt": true}, {"app_prompt": true}, {"at_bottom": {}}]}, + + {"do": "window_drag", "title": "press the bottom-right corner and drag 300 points right (button held)", "edge": "bottom-right", "by": [300, 0], "events": 36, "release": false, "mark": "wider"}, + {"do": "expect", "title": "mid-drag, wider: the grid follows, the prompt stays in view", "since": "wider", "timeout": 10, + "all": [ + {"window_frame": {"from": "start", "dw": 300, "dh": 0}}, + {"grid_fits": {"vs": "start", "cols": "wider", "rows": "same"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"daemon_prompt": true}, + {"pane_frame_text": "end-${nonce}"} + ]}, + {"do": "window_drag", "title": "keep the button held and drag 220 points down", "press": false, "by": [0, 220], "events": 30, "release": false, "mark": "taller"}, + {"do": "expect", "title": "mid-drag, taller: more rows, the scrollback does not jump into its history", "since": "taller", "timeout": 10, + "all": [ + {"window_frame": {"from": "start", "dw": 300, "dh": 220}}, + {"grid_fits": {"vs": "start", "cols": "wider", "rows": "taller"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"app_text_line": "end-${nonce}"}, + {"pane_frame_text": "end-${nonce}"} + ]}, + {"do": "window_drag", "title": "a last grow step, then release", "press": false, "by": [40, 40], "events": 8, "release": true}, + + {"do": "type", "text": "echo pty-${nonce} $(stty size)", "enter": true, "mark": "after"}, + {"do": "expect", "title": "after the drag: the PTY has the window's grid, the prompt is at the bottom", "since": "after", "timeout": 10, + "all": [ + {"pty_size": "pty-${nonce}"}, + {"grid_fits": {"vs": "start", "cols": "wider", "rows": "taller"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"pane_frame_text": "pty-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/window-resize-shrink.json b/scripts/journeys/journeys/p0/window-resize-shrink.json new file mode 100644 index 000000000000..22a3d9d57137 --- /dev/null +++ b/scripts/journeys/journeys/p0/window-resize-shrink.json @@ -0,0 +1,53 @@ +{ + "id": "window-resize-shrink", + "priority": "P0", + "title": "Drag the window narrower, then shorter (live): the grid follows the window and the prompt stays in view", + "why": "A person makes a window smaller as often as larger. Each step must refit the grid (the PTY gets the new size, so the shell and programs wrap at the new width), with no blank frame and the prompt still at the bottom (the live-resize bug of dogfood v1, fix 673587c97dc, guarded on the shrink side).", + "source": "window life: resize by drag (holistic-quality.md 1)", + "budget_seconds": 75, + "steps": [ + {"do": "new_terminal", "name": "jr-resize-shrink"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "main"}, + {"do": "window_place", "title": "set-up: the window at 85% x 85% of the screen, top left", "fraction": [0.02, 0.03, 0.85, 0.85]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "start"}, + {"do": "type", "text": "seq 1 3000; echo end-${nonce}", "enter": true, "mark": "filled"}, + {"do": "expect", "title": "a long scrollback, the prompt at the bottom", "since": "filled", "timeout": 20, + "all": [{"daemon_line": "end-${nonce}"}, {"daemon_prompt": true}, {"app_prompt": true}, {"at_bottom": {}}]}, + + {"do": "window_drag", "title": "press the bottom-right corner and drag 420 points left (button held)", "by": [-420, 0], "events": 40, "release": false, "mark": "narrower"}, + {"do": "expect", "title": "mid-drag, narrower: fewer columns, the prompt stays in view", "since": "narrower", "timeout": 10, + "all": [ + {"window_frame": {"from": "start", "dw": -420, "dh": 0}}, + {"grid_fits": {"vs": "start", "cols": "narrower", "rows": "same"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"daemon_prompt": true}, + {"pane_frame_text": "end-${nonce}"} + ]}, + {"do": "window_drag", "title": "keep the button held and drag 300 points up", "press": false, "by": [0, -300], "events": 36, "release": false, "mark": "shorter"}, + {"do": "expect", "title": "mid-drag, shorter: fewer rows, the prompt stays in view", "since": "shorter", "timeout": 10, + "all": [ + {"window_frame": {"from": "start", "dw": -420, "dh": -300}}, + {"grid_fits": {"vs": "start", "cols": "narrower", "rows": "shorter"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"app_text_line": "end-${nonce}"}, + {"pane_frame_text": "end-${nonce}"} + ]}, + {"do": "window_drag", "title": "release", "press": false, "by": [-10, -10], "events": 4, "release": true}, + + {"do": "type", "text": "echo pty-${nonce} $(stty size)", "enter": true, "mark": "after"}, + {"do": "expect", "title": "after the drag: the PTY has the window's smaller grid", "since": "after", "timeout": 10, + "all": [ + {"pty_size": "pty-${nonce}"}, + {"grid_fits": {"vs": "start", "cols": "narrower", "rows": "shorter"}}, + {"app_prompt": true}, + {"at_bottom": {}}, + {"daemon_line": "${prompt}echo pty-${nonce} $(stty size)"}, + {"pane_frame_text": "pty-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p0/zoom-pane.json b/scripts/journeys/journeys/p0/zoom-pane.json new file mode 100644 index 000000000000..f5751b3dacff --- /dev/null +++ b/scripts/journeys/journeys/p0/zoom-pane.json @@ -0,0 +1,59 @@ +{ + "id": "zoom-pane", + "priority": "P0", + "title": "Cmd-Shift-Return zooms a pane to the whole window and back: the grid and the PTY follow both ways", + "why": "Zooming a pane is how a person reads a long log in a split. The zoomed terminal must refit to the whole window (its PTY too), the other pane must hide, and unzooming must give both panes their halves and grids back.", + "source": "window life: zoom a pane (holistic-quality.md 1), Ghostty's Cmd-Shift-Return", + "budget_seconds": 75, + "steps": [ + {"do": "new_terminal", "name": "jr-zoom"}, + {"do": "neutral_prompt"}, + {"do": "name_pane", "as": "left"}, + {"do": "window_place", "title": "set-up: the window at 70% x 70% of the screen", "fraction": [0.05, 0.05, 0.7, 0.7]}, + {"do": "expect", "title": "the terminal fits the placed window", "timeout": 10, "all": [{"grid_fits": {}}]}, + {"do": "remember", "as": "full"}, + {"do": "key", "title": "Cmd-D", "key": "d", "modifiers": ["cmd"]}, + {"do": "adopt_pane", "as": "right"}, + {"do": "neutral_prompt"}, + {"do": "expect", "title": "two fitted panes", "timeout": 10, + "all": [{"pane_frame": {"pane": "right", "vs": "full", "w": 0.5, "tol": 0.06}}, {"grid_fits": {"pane": "right"}}]}, + {"do": "remember", "as": "half", "pane": "right"}, + + {"do": "key", "title": "Cmd-Shift-Return in the right pane", "key": "return", "modifiers": ["cmd", "shift"], "mark": "zoom"}, + {"do": "expect", "title": "zoomed: the right pane has the whole window, the left one is hidden", "since": "zoom", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "right", "vs": "full", "w": 1.0, "h": 1.0, "tol": 0.05}}, + {"grid_fits": {"pane": "right", "vs": "half", "cols": "wider"}}, + {"pane_state": {"pane": "left", "fields": {"visible": false}}}, + {"app_prompt": true} + ]}, + {"do": "type", "text": "echo zoomed-${nonce} $(stty size)", "enter": true, "mark": "tz"}, + {"do": "expect", "title": "the zoomed PTY has the whole window's grid", "since": "tz", "timeout": 10, + "all": [ + {"pty_size": {"tag": "zoomed-${nonce}", "pane": "right"}}, + {"daemon_prompt": true}, + {"app_text_contains": "zoomed-${nonce} "}, + {"pane_frame_text": "zoomed-${nonce}"} + ]}, + + {"do": "key", "title": "Cmd-Shift-Return again", "key": "return", "modifiers": ["cmd", "shift"], "mark": "unzoom"}, + {"do": "expect", "title": "unzoomed: both panes back at their halves, both fitted", "since": "unzoom", "timeout": 10, + "all": [ + {"pane_frame": {"pane": "right", "vs": "half", "w": 1.0, "h": 1.0, "tol": 0.03}}, + {"pane_frame": {"pane": "right", "right_of": "left"}}, + {"pane_state": {"pane": "left", "fields": {"visible": true}}}, + {"grid_fits": {"pane": "right", "vs": "half", "cols": "same", "rows": "same"}}, + {"grid_fits": {"pane": "left"}}, + {"app_prompt": true} + ]}, + {"do": "type", "text": "echo back-${nonce} $(stty size)", "enter": true, "mark": "tb"}, + {"do": "expect", "title": "the PTY has the half grid again", "since": "tb", "timeout": 10, + "all": [ + {"pty_size": {"tag": "back-${nonce}", "pane": "right"}}, + {"daemon_prompt": true}, + {"app_text_contains": "back-${nonce} "}, + {"pane_frame_text": "back-${nonce}"} + ]} + ], + "finally": [{"do": "restore_window", "from": "before-place"}] +} diff --git a/scripts/journeys/journeys/p1/paste-confirm-keys.json b/scripts/journeys/journeys/p1/paste-confirm-keys.json new file mode 100644 index 000000000000..982e8c3c322b --- /dev/null +++ b/scripts/journeys/journeys/p1/paste-confirm-keys.json @@ -0,0 +1,53 @@ +{ + "id": "paste-confirm-keys", + "priority": "P1", + "title": "Multi-line paste into a running program: the confirm dialog's accept key pastes, Esc denies, and no typed character is lost", + "why": "QA chrome-parity-v2-1 (bug 3, low): the 'Paste into the terminal?' dialog opened with focus on Deny; Return did nothing, and typing 'seq 1 10' let the dialog eat 'seq' while the Space pressed Deny, so the paste and part of the next command were lost. cmux dialogs answer keys one way (CmuxDialogKeys): Return presses the default button, Escape the cancel one. A person confirms a dialog with Return.", + "source": "independent QA bug (qa-chrome-parity-v2-1.json bugs[2]); repro: printf 'L1\\nL2\\n' | pbcopy, cat -n, Cmd+V, Return, then type", + "budget_seconds": 90, + "steps": [ + {"do": "new_terminal", "name": "jr-paste-confirm"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "awk '{print \"got-\" $0; fflush()}'", "enter": true, "mark": "awk"}, + {"do": "expect", "title": "awk reads the terminal (bracketed paste off, so a multi-line paste asks first)", "since": "awk", "timeout": 10, + "all": [{"process_present": {"match": "^awk ", "foreground": true}}]}, + + {"do": "clipboard_set", "text": "L1-${nonce}\nL2-${nonce}\n"}, + {"do": "key", "key": "v", "modifiers": ["cmd"], "mark": "paste"}, + {"do": "expect", "title": "Cmd+V asks first: the paste dialog shows", "since": "paste", "timeout": 5, + "all": [ + {"app_rpc": {"method": "debug.dialog", "path": "dialogs.0.identifier", "equals": "cmux.dialog.terminalClipboard"}}, + {"daemon_lacks": "got-L1-${nonce}"}, + {"frame_text": "Paste into the terminal"} + ]}, + {"do": "key", "key": "return", "modifiers": [], "mark": "accept"}, + {"do": "expect", "title": "Return (the dialog keyboard's accept key) pastes both lines once and closes the dialog", "since": "accept", "within": 2, "timeout": 5, + "all": [ + {"app_rpc": {"method": "debug.dialog", "path": "dialogs", "len": 0}}, + {"daemon_line": "got-L1-${nonce}"}, + {"daemon_line": "got-L2-${nonce}"}, + {"app_text_line": "got-L2-${nonce}"}, + {"frame_text": "got-L2-${nonce}"} + ]}, + + {"do": "clipboard_set", "text": "M1-${nonce}\nM2-${nonce}\n"}, + {"do": "key", "key": "v", "modifiers": ["cmd"], "mark": "paste2"}, + {"do": "expect", "title": "a second multi-line paste asks again", "since": "paste2", "timeout": 5, + "all": [{"app_rpc": {"method": "debug.dialog", "path": "dialogs.0.identifier", "equals": "cmux.dialog.terminalClipboard"}}]}, + {"do": "key", "key": "escape", "modifiers": [], "mark": "deny"}, + {"do": "expect", "title": "Esc denies: the dialog closes and nothing is pasted", "since": "deny", "within": 2, "timeout": 5, "settle": 1, + "all": [ + {"app_rpc": {"method": "debug.dialog", "path": "dialogs", "len": 0}}, + {"daemon_lacks": "M1-${nonce}"}, + {"app_text_lacks": "M1-${nonce}"} + ]}, + {"do": "type", "text": "seq 1 10 ${nonce}", "enter": true, "mark": "retype"}, + {"do": "expect", "title": "the line typed after the dialog arrives whole", "since": "retype", "within": 2, "timeout": 5, + "all": [ + {"daemon_line": "got-seq 1 10 ${nonce}"}, + {"app_text_line": "got-seq 1 10 ${nonce}"}, + {"frame_text": "got-seq 1 10 ${nonce}"} + ]}, + {"do": "key", "key": "c", "modifiers": ["control"]} + ] +} diff --git a/scripts/journeys/journeys/p1/quit-leaves-helpers.json b/scripts/journeys/journeys/p1/quit-leaves-helpers.json new file mode 100644 index 000000000000..50c0da2a63c2 --- /dev/null +++ b/scripts/journeys/journeys/p1/quit-leaves-helpers.json @@ -0,0 +1,44 @@ +{ + "id": "quit-leaves-helpers", + "priority": "P1", + "title": "Cmd-Q with default settings: 10 s later only the processes that hold terminal sessions still run", + "why": "QA chrome-parity-v2-1 (bug 2, medium): after Cmd+Q the agent, chief and browser helper daemons (cmux-tui --headless for the cmux-chief session, acpmux daemon and router, optchat-chief, cmux-browser-host, 'cmux acp daemon run'; about 430 MB) kept running with no app process, one of them still alive after the relaunch. A person who quits an app expects it gone from Activity Monitor and from the battery list. The default quit keeps terminal sessions, so the terminal hosts and the app session's daemon that owns them may stay.", + "source": "independent QA bug (qa-chrome-parity-v2-1.json bugs[1]; evidence ps-before-quit.txt, ps-after-quit.txt)", + "budget_seconds": 150, + "steps": [ + {"do": "new_terminal", "name": "jr-quit-helpers", "as": "term"}, + {"do": "neutral_prompt"}, + {"do": "type", "text": "echo quit-${nonce}-$((6*7))", "enter": true, "mark": "typed"}, + {"do": "expect", "title": "the terminal works", "since": "typed", "timeout": 10, + "all": [{"daemon_line": "quit-${nonce}-42"}, {"app_text_line": "quit-${nonce}-42"}]}, + {"do": "key", "key": "d", "modifiers": ["cmd"]}, + {"do": "focused_terminal", "as": "right"}, + {"do": "key", "key": "l", "modifiers": ["cmd", "shift"], "mark": "browser"}, + {"do": "expect", "title": "Cmd+Shift+L opens a browser tab in the right pane", "since": "browser", "timeout": 15, + "all": [{"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "addressBar"}}]}, + {"do": "type", "text": "example.com", "enter": true, "mark": "loaded"}, + {"do": "expect", "title": "before the quit: a terminal on the left, a loaded browser tab on the right", "since": "loaded", "timeout": 40, + "all": [ + {"app_rpc": {"method": "debug.focus", "path": "windows.0.model.resolved", "equals": "browserPage"}}, + {"frame_text": "Example Domain"} + ]}, + + {"do": "relaunch", "how": "quit", "leftovers_after": 10, "mark": "reopened"}, + + {"do": "expect", "title": "10 s after the quit, nothing but the terminal-session holders ran", "timeout": 2, + "all": [ + {"quit_leftovers": {"allow": [ + {"match": "(^|/)cmux-tui __terminal-host ", "why": "holds a terminal's PTY and shell: the default quit keeps sessions"}, + {"match": "/Contents/Resources/bin/cmux-tui --headless --session cmux-app-", "why": "the app session's daemon, the owner of those terminal hosts (their --owner)"} + ]}} + ]}, + + {"do": "use", "name": "term"}, + {"do": "expect", "title": "after the reopen the kept terminal is back with its text", "since": "reopened", "timeout": 45, + "all": [ + {"daemon_line": "quit-${nonce}-42"}, + {"app_text_line": "quit-${nonce}-42"}, + {"frame_text": "quit-${nonce}-42"} + ]} + ] +} diff --git a/scripts/journeys/quit-leaves-no-helpers/README.md b/scripts/journeys/quit-leaves-no-helpers/README.md new file mode 100644 index 000000000000..ea1856c19582 --- /dev/null +++ b/scripts/journeys/quit-leaves-no-helpers/README.md @@ -0,0 +1,11 @@ +# Journey quit-leaves-no-helpers (P0) + +Run it against a fleet build: `python3 scripts/journeys/quit-leaves-no-helpers/run.py --build JOB +[--hosts "cmux17 cmux18 cmux19 cmux21 cmux22 cmux23 cmux24"]`. It leases one capture slot, launches the +build's app, uses a terminal and an agent pane, quits it the way a person does (`quit app id`, the +Apple event of Cmd-Q, answering the quit question's default), waits 5 s, and lists every process of +the app bundle. It passes when only the documented survivors remain (journey.py says which and why). +Exit 0 pass, 1 fail, 75 not run; the record is in `artifacts/journeys/quit-leaves-no-helpers/`. + +It stands alone until `cp/journeys-framework` lands (that branch's `run.py` plays JSON journeys of +keys and screen checks; this one needs process listings across a quit). It is not a CI job or gate. diff --git a/scripts/journeys/quit-leaves-no-helpers/journey.py b/scripts/journeys/quit-leaves-no-helpers/journey.py new file mode 100644 index 000000000000..1c0be20154e7 --- /dev/null +++ b/scripts/journeys/quit-leaves-no-helpers/journey.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Journey quit-leaves-no-helpers (P0): launch, use a terminal and an agent pane, quit normally, and +check that no process of the app bundle runs 5 s later except the documented survivors. + +Runs on the Mac where the app runs (a capture slot; run.py beside this file leases it and starts this +with the stock /usr/bin/python3, standard library only), against the app's control socket: + + journey.py --socket S --app APP --out DIR [--quit applescript|menu] [--settle 5] [--keep-processes] + +A person's quit: `quit app id ` (the Apple event Cmd-Q, the Dock and `osascript` send), else, +when this session may not send Apple events to the app, `debug.quit {open: true}` (the same interactive +quit). When the quit question shows, the journey answers its default (Keep Sessions Running), as a +person pressing Return does. + +The processes of the app are found as scripts/cmux-next/tag_teardown.py finds them (by bundle path, +terminal host `--owner`, holder socket, one level of children), never by name. + +Documented survivors of a quit that keeps sessions (plans/cmux-next/quit-persistence.md): + - the app's cmux-tui daemon (`cmux-tui --headless --session cmux-app-...`) and its terminal hosts, + holders and their shells: the terminals the person chose to keep; they reattach at the next launch. + With it, its `cmux-agent-screen-detection` plugin, which reads those terminals' screens and ends + with the daemon; + - the acpmux daemon and its agent hosts, and the Chief (`optchat-chief host`, its acpmux and router, + its `cmux-chief-*` conversation owner), only while they work (a turn, a queued prompt, a pending + permission, a subagent or a background process an agent started): `keep sessions` keeps agents + and the Chief working. Idle, each exits a few seconds after the app (cp/lag-quit-helpers, + cp/quit-chief-idle). The journey starts no turn, so all of them must be gone. +Anything else (an idle acpmux or Chief, a Chromium or app host, a helper under Resources/bin) is a leak. + +Writes DIR/result.json; exit 0 pass, 1 fail, 2 the journey could not run. +""" +import argparse +import json +import os +import socket +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import tag_teardown # noqa: E402 (shipped beside this file by run.py) + +RPC_TIMEOUT = 30.0 + + +def rpc(sock, method, params=None, timeout=RPC_TIMEOUT): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn: + conn.settimeout(timeout) + conn.connect(sock) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + reply = json.loads(buf or b"{}") + if not reply.get("ok", "result" in reply): + raise RuntimeError(f"{method}: {reply.get('error') or 'no answer'}") + return reply.get("result") + + +def alive(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + # A zombie still answers kill 0; ps says Z. + stat = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True).stdout.strip() + return bool(stat) and not stat.startswith("Z") + + +def executable(teardown, command): + rel = teardown._relative(command) + words = (rel or command).split() + return rel, words, (os.path.basename(words[0]) if words else "") + + +def classify(teardown, pid, rows): + """(role, documented survivor?) of one process of the app after the quit. `rows` maps every surviving + pid to its `command` and `ppid`, so a helper is judged by the process that runs it.""" + command = rows[pid]["command"] + rel, words, exe = executable(teardown, command) + + def exe_of(p): + return executable(teardown, rows[p]["command"])[2] if p in rows else "" + ppid = rows[pid].get("ppid") + parent_exe = exe_of(ppid) + grandparent_exe = exe_of(rows[ppid].get("ppid")) if ppid in rows else "" + if rel is not None and exe == "cmux-tui" and "--headless" in words: + if any(w.startswith("cmux-chief-") for w in words) or "cmux-chief-" in command: + # The Chief's conversation owner: it holds no terminal of the person's and stops after + # the app when nothing needs it (cmux-tui headless/chief_owner_exit.rs). + return "the Chief's conversation owner with nothing live", False + return "cmux-tui daemon (kept terminals)", True + if rel is None: + # A terminal host, holder or a shell under one (tag_teardown matched it by --owner, socket or parent). + if "__terminal-host" in command or teardown._is_holder(command): + return "terminal host or holder (kept terminals)", True + return "terminal shell or its child (kept terminals)", True + if exe == "cmux-agent-screen-detection" and parent_exe == "cmux-tui": + if "cmux-chief-" in rows[ppid]["command"]: + return "screen-detection plugin of the Chief's conversation owner", False + # The kept daemon's agent plugin: it reads the kept terminals' screens and ends with its daemon. + return "screen-detection plugin of a kept cmux-tui daemon", True + if exe == "optchat-chief": + # An idle Chief stops after its app (optchat-chief owner_app.rs); the journey starts no turn. + return "Chief brain host with no live work", False + if exe == "acpmux" and parent_exe == "optchat-chief": + return "the Chief's own acpmux with no live work", False + if exe == "acpmux" and "router" in words and parent_exe == "acpmux" and grandparent_exe == "optchat-chief": + return "the Chief's acpmux router with no live work", False + if exe == "acpmux" and "daemon" in words: + return "the app's acpmux daemon with no live agent", False + return f"{exe} (helper)", False + + +def table_row(pid): + out = subprocess.run(["ps", "-o", "ppid=,pgid=,stat=", "-p", str(pid)], capture_output=True, text=True).stdout.split() + return {"ppid": int(out[0]), "pgid": int(out[1]), "stat": out[2]} if len(out) >= 3 else {} + + +def wait_for(predicate, timeout, interval=0.25): + end = time.monotonic() + timeout + while time.monotonic() < end: + value = predicate() + if value: + return value + time.sleep(interval) + return predicate() + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--socket", required=True) + ap.add_argument("--app", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--quit", choices=("applescript", "menu"), default="applescript") + ap.add_argument("--settle", type=float, default=5.0, help="seconds after the app exits before the check") + ap.add_argument("--keep-processes", action="store_true", help="leave the survivors running (default: end them)") + a = ap.parse_args(argv) + os.makedirs(a.out, exist_ok=True) + result = {"id": "quit-leaves-no-helpers", "priority": "P0", "steps": [], "status": "not run", "reason": ""} + + def step(name, **facts): + facts["step"], facts["t"] = name, round(time.monotonic() - t0, 3) + result["steps"].append(facts) + print(f"journey: {name} {json.dumps({k: v for k, v in facts.items() if k not in ('step', 't')})[:400]}", flush=True) + + t0 = time.monotonic() + teardown = tag_teardown.TagTeardown(a.app, log=lambda line: step("teardown", line=line)) + try: + info = rpc(a.socket, "system.identify") or {} + app_pid, bundle_id = int(info["pid"]), info.get("bundle_id") or teardown.bundle_id() + if ".debug" not in (bundle_id or ""): + raise SystemExit(f"journey: refusing {a.app} ({bundle_id}): not a tagged DEV build") + step("identify", pid=app_pid, bundle_id=bundle_id, tag=info.get("tag")) + + # 1. Use a terminal: a new tab, a command typed key by key. + nonce = str(int(time.time()))[-6:] + rpc(a.socket, "action.run", {"action": "newTab", "args": {"name": "jr-quit", "focus": True}}) + time.sleep(1.0) + for char in f"echo quit-{nonce}": + rpc(a.socket, "debug.key", {"key": char, "modifiers": []}) + rpc(a.socket, "debug.key", {"key": "return", "modifiers": []}) + + def echoed(): + report = rpc(a.socket, "debug.surfaces", {"text": True}) or {} + panes = [p for w in report.get("windows", []) for p in w.get("panes", []) if isinstance(p, dict)] + return any(f"quit-{nonce}" in (p.get("text") or "") for p in panes) + step("terminal", echoed=bool(wait_for(echoed, 20))) + + # 2. Use an agent pane: the New Agent Chat a person opens (it starts the app's acpmux daemon). + try: + reply = rpc(a.socket, "action.run", {"action": "palette.newAgentChat"}) + except RuntimeError as error: + reply = {"error": str(error)} + acpmux = os.path.join(teardown.bin, "acpmux") + started = wait_for(lambda: [p for p, c in teardown.processes() if c.startswith(acpmux + " daemon run")], 60, 0.5) + step("agent pane", reply=reply, acpmux_pids=started) + + before = teardown.remember() + step("before quit", processes=[{"pid": p, "command": teardown.shown(c)} for p, c in before]) + + # 3. Quit normally. + quitter, how = None, a.quit + if a.quit == "applescript": + quitter = subprocess.Popen(["/usr/bin/osascript", "-e", f'tell application id "{bundle_id}" to quit'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + time.sleep(2.0) + if quitter.poll() not in (None, 0): + step("applescript quit refused", stderr=quitter.stderr.read()[-300:]) + quitter, how = None, "menu" + if how == "menu": + rpc(a.socket, "debug.quit", {"open": True}) + pressed = None + + def asked_or_gone(): + nonlocal pressed + if not alive(app_pid): + return True + try: + report = rpc(a.socket, "debug.quit", timeout=5) or {} + except (OSError, RuntimeError, ValueError): + return False + if report.get("asking") and pressed is None: + pressed = report.get("prompt", {}).get("default") or "keep" + rpc(a.socket, "debug.quit", {"press": "keep"}, timeout=5) + step("quit question", prompt=report.get("prompt"), lines=report.get("lines"), pressed="keep") + return False + exited = wait_for(asked_or_gone, 60, 0.25) + step("quit", how=how, app_exited=bool(exited)) + if quitter is not None and quitter.poll() is None: + quitter.kill() + if not exited: + result.update(status="fail", reason="the app did not exit within 60 s of the quit") + return 1 + + # 4. Settle, then every process of the app bundle. + time.sleep(a.settle) + after = teardown.processes() + rows = {pid: {"command": command, **table_row(pid)} for pid, command in after} + survivors = [] + for pid, row in rows.items(): + role, documented = classify(teardown, pid, rows) + survivors.append({"pid": pid, "command": teardown.shown(row["command"]), "role": role, + "documented": documented, **{k: v for k, v in row.items() if k != "command"}}) + leaks = [s for s in survivors if not s["documented"]] + step("after quit", settle_seconds=a.settle, survivors=survivors) + result["survivors"], result["leaks"] = survivors, leaks + if leaks: + result.update(status="fail", reason=f"{len(leaks)} process(es) of the app outlived the quit: " + + ", ".join(f"{s['pid']} {s['role']}" for s in leaks)) + else: + result.update(status="pass", reason="only documented survivors remain" + + (f" ({len(survivors)}: kept terminals)" if survivors else " (none)")) + return 0 if not leaks else 1 + except (OSError, RuntimeError, KeyError, ValueError) as error: + result.update(status="not run", reason=str(error)[-400:]) + return 2 + finally: + if not a.keep_processes: + teardown.end() + result["seconds"] = round(time.monotonic() - t0, 2) + with open(os.path.join(a.out, "result.json"), "w") as f: + json.dump(result, f, indent=1, default=str) + f.write("\n") + print(f"journey quit-leaves-no-helpers: {result['status']} {result['reason']}", flush=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/journeys/quit-leaves-no-helpers/run.py b/scripts/journeys/quit-leaves-no-helpers/run.py new file mode 100644 index 000000000000..4e11d5949863 --- /dev/null +++ b/scripts/journeys/quit-leaves-no-helpers/run.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Run the P0 journey quit-leaves-no-helpers against the app of one fleet build, on a capture slot. + + python3 scripts/journeys/quit-leaves-no-helpers/run.py --build JOB [--quit applescript|menu] [--out DIR] + +The route scripts/journeys/run.py (cp/journeys-framework) and `cmux-ci capture` take: `capture-host +acquire --slot` leases one slot on a GUI capture mini (released in every case; at most 10 minutes), +the slot downloads the build's artifact (`cmux-ci artifact JOB`), `capture-host launch` starts the app +there (its own clone, bundle id, HOME, TMPDIR, socket and empty settings), and journey.py drives it, +quits it and lists what of the bundle still runs. Capture hosts come from the fleet registry, or +`--hosts "cmux17 cmux18 ..."` (sets CAPTURE_HOST_SLOT_HOSTS). cmux16 and cmux20 are never leased. + +Writes DIR/result.json (default artifacts/journeys/quit-leaves-no-helpers/-/). +Exit 0 pass, 1 fail, 75 not run. +""" +import argparse +import io +import json +import os +import shutil +import subprocess +import sys +import tarfile +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent.parent +TEARDOWN = ROOT / "scripts" / "cmux-next" / "tag_teardown.py" +SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"] +NEVER = ("cmux16", "cmux20") + + +def log(message): + print(f"quit-journey: {message}", file=sys.stderr, flush=True) + + +def q(text): + return "'" + str(text).replace("'", "'\"'\"'") + "'" + + +def run(argv, *, input=None, timeout=None, check=True): + done = subprocess.run(argv, input=input, capture_output=True, timeout=timeout) + if check and done.returncode != 0: + raise RuntimeError(f"{' '.join(map(str, argv[:3]))} ... exited {done.returncode}: " + f"{(done.stderr or done.stdout).decode(errors='replace').strip()[-500:]}") + return done + + +def remote(host, script, *, input=None, timeout=600, check=True): + return run(SSH + [host, "zsh -lc " + q(script)], input=input, timeout=timeout, check=check) + + +def capture_host(): + found = os.environ.get("CAPTURE_HOST") or shutil.which("capture-host") + return found or str(Path.home() / ".local/share/cmux/hq-tools/current/build-fleet/capture-host") + + +def kit(): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + tar.add(HERE / "journey.py", arcname="journey.py") + tar.add(TEARDOWN, arcname="tag_teardown.py") + return buf.getvalue() + + +def launch_facts(text): + line = next((l for l in reversed(text.splitlines()) if "socket=" in l), "") + head, _, app = line.partition(" app=") + facts = dict(kv.split("=", 1) for kv in head.split() if "=" in kv) + if app.strip(): + facts["app"] = app.strip() + return facts + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--build", required=True, help="a done fleet build job id (cmux-ci build cmux ...)") + ap.add_argument("--quit", choices=("applescript", "menu"), default="applescript") + ap.add_argument("--hosts", help="capture hosts, spaces between (sets CAPTURE_HOST_SLOT_HOSTS)") + ap.add_argument("--out") + a = ap.parse_args(argv) + if a.hosts: + hosts = [h for h in a.hosts.split() if h not in NEVER] + os.environ["CAPTURE_HOST_SLOT_HOSTS"] = " ".join(hosts) + os.environ["CAPTURE_HOST_EXCLUDE"] = " ".join(filter(None, [os.environ.get("CAPTURE_HOST_EXCLUDE", ""), *NEVER])) + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + out = Path(a.out or ROOT / "artifacts" / "journeys" / "quit-leaves-no-helpers" / f"{a.build[:12]}-{stamp}") + out.mkdir(parents=True, exist_ok=True) + ch = capture_host() + acquired = run([ch, "acquire", "--slot"], timeout=330).stdout.decode().split() + if not acquired: + log("capture-host acquire printed no lease") + return 75 + lease = acquired[-1] + log(f"leased {lease}") + status = 75 + try: + info = dict(kv.split("=", 1) for kv in run([ch, "slot-info", lease]).stdout.decode().split() if "=" in kv) + host, root = info["host"], info["root"] + if host in NEVER: + raise RuntimeError(f"leased {host}, which this journey never uses") + work = f"{root}/quit-journey" + remote(host, f"mkdir -p {q(work + '/kit')} && tar x -C {q(work + '/kit')}", input=kit(), timeout=120) + log(f"downloading build {a.build} on {host}") + app = remote(host, f"set -e; cd {q(work)}; cmux-ci artifact {q(a.build)} app.zip >&2; ditto -x -k app.zip build; " + f"rm -f app.zip; find \"$PWD/build\" -maxdepth 2 -name '*.app' -type d | head -1", + timeout=420).stdout.decode().strip() + if not app.endswith(".app"): + raise RuntimeError(f"build {a.build} has no app bundle") + launched = run([ch, "launch", lease, app, "quit-journey", "--"], timeout=180, check=False) + facts = launch_facts(launched.stdout.decode()) + if facts.get("socket", "none") == "none": + raise RuntimeError("the app did not answer on its control socket: " + f"{(launched.stdout + launched.stderr).decode(errors='replace').strip()[-400:]}") + log(f"launched pid {facts.get('pid')} on {host}; driving") + drove = remote(host, f"/usr/bin/python3 -I {q(work + '/kit/journey.py')} --socket {q(facts['socket'])} " + f"--app {q(facts.get('app', app))} --out {q(work + '/out')} --quit {a.quit}", + timeout=300, check=False) + for line in drove.stdout.decode(errors="replace").splitlines(): + log(f"[{host}] {line}") + if drove.stderr: + log(f"[{host}] stderr: {drove.stderr.decode(errors='replace').strip()[-800:]}") + text = remote(host, f"cat {q(work + '/out/result.json')}", timeout=60, check=False).stdout.decode() + result = json.loads(text) if text.strip().startswith("{") else {"status": "not run", "reason": "no result.json"} + result.update(build_job=a.build, host=host, lease=lease) + (out / "result.json").write_text(json.dumps(result, indent=1) + "\n") + log(f"{result['status']}: {result.get('reason')} ({out / 'result.json'})") + status = {"pass": 0, "fail": 1}.get(result["status"], 75) + except (RuntimeError, subprocess.TimeoutExpired, KeyError, ValueError) as error: + log(f"lease {lease}: {error}") + finally: + released = subprocess.run([ch, "release", lease], capture_output=True, timeout=120) + log(f"released {lease}" if released.returncode == 0 else f"release of {lease} exited {released.returncode}") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/journeys/run.py b/scripts/journeys/run.py new file mode 100755 index 000000000000..907bc9926988 --- /dev/null +++ b/scripts/journeys/run.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +"""Run cmux NEXT user journeys against one exact fleet build, on a fleet capture Mac. + + scripts/journeys/run.py --build JOB [--priority P0 | --journey ID ... | --all] [--out DIR] + scripts/journeys/run.py --commit SHA [...] # builds SHA on the fleet first (or reuses its receipt) + scripts/journeys/run.py --list | --check + +The route is the one cmux-ci capture and restore-journey-slot.sh use (no new harness, no CI job): +capture-host leases one slot on a GUI capture mini (released in every case; a lease lasts at most +10 minutes), the slot downloads the build's artifact (`cmux-ci artifact JOB`), capture-host launches +it in the slot (own HOME, bundle id, socket, settings), and driver.py plays the journeys there over +the control socket, the bundled CLI on the app's daemon, the process table and the app's own +window snapshots. Journeys that do not fit one lease run in further leases. + +Writes OUT (default artifacts/journeys/-/): results.jsonl (one line per journey: +id, priority, status pass|fail|not run, evidence, clip, build), /result.json, /check-NN.png +(each frame a check read), /clip.mp4 (ffmpeg) or /clip.png (an animated PNG; any browser +plays it). A journey that could not run is "not run" with the reason, never a pass. +Exit 0 when every selected journey passed, 1 when one failed, 75 when none failed but one did not run. + +Capture hosts: capture-host reads the fleet registry (~/.ssh/config.d/fleet-registry.json), or +CAPTURE_HOST_SLOT_HOSTS (--hosts sets it). JOURNEYS_EXCLUDE_HOSTS (default cmux16) are never leased. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import re +import shutil +import struct +import subprocess +import sys +import tarfile +import time +import zlib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(ROOT / "scripts" / "cmux-next")) +import driver # noqa: E402 (the step and condition names, for --check) + +JOURNEYS = HERE / "journeys" +BUILD_NAMES = ROOT / "scripts" / "cmux-next" / "build_names.py" +OCR_SOURCE = ROOT / "scripts" / "ci" / "pr_media_ocr.swift" +SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"] +PRIORITIES = ("P0", "P1", "P2", "P3") +# capture-host's hard maximum is 600 s from acquire; keep room to fetch and release. +LEASE_SECONDS = 540 +# Download, launch, the OCR compile (first time per mini) and its warm-up (about 60 s), before the first journey. +SETUP_RESERVE = 240 +JOURNEY_SECONDS = LEASE_SECONDS - SETUP_RESERVE - 40 +RESULT_FILE = re.compile(r"(?:\./)?((?:[a-z0-9][a-z0-9-]*/)?(?:result\.json|check-\d{2,3}\.png|clip/frame-\d{5}\.png" + r"|restore-trace\.json|fuzz\.json|diag-[a-z]+\.(?:json|png|txt)|diag-app-sample\.txt)|host\.json|summary\.json)") +RESULT_MAX_BYTES = 64 << 20 +RESULT_TAR_MAX_BYTES = 768 << 20 +STEP_NAMES = {name[len("step_"):] for name in dir(driver.Journey) if name.startswith("step_")} + + +def log(message): + print(f"journeys: {message}", file=sys.stderr, flush=True) + + +def q(text): + return "'" + str(text).replace("'", "'\"'\"'") + "'" + + +def run(argv, *, input=None, timeout=None, check=True): + done = subprocess.run(argv, input=input, capture_output=True, timeout=timeout) + if check and done.returncode != 0: + raise RuntimeError(f"{' '.join(map(str, argv[:3]))} ... exited {done.returncode}: " + f"{(done.stderr or done.stdout).decode(errors='replace').strip()[-500:]}") + return done + + +def remote(host, script, *, input=None, timeout=600, check=True): + """A login shell on the mini (cmux-ci is on its login PATH only), as pr-capture does.""" + return run(SSH + [host, "zsh -lc " + q(script)], input=input, timeout=timeout, check=check) + + +# ---- journeys ------------------------------------------------------------------------------ + +def load_journeys(directory=JOURNEYS): + specs, problems = [], [] + for path in sorted(Path(directory).rglob("*.json")): + try: + spec = json.loads(path.read_text()) + except ValueError as error: + problems.append(f"{path}: {error}") + continue + spec["_path"] = str(path.relative_to(ROOT)) + problems += [f"{path.relative_to(ROOT)}: {p}" for p in validate(spec)] + specs.append(spec) + ids = [s.get("id") for s in specs] + problems += [f"journey id {i!r} is used twice" for i in sorted({i for i in ids if ids.count(i) > 1})] + specs.sort(key=lambda s: (PRIORITIES.index(s["priority"]) if s.get("priority") in PRIORITIES else 9, s.get("id", ""))) + return specs, problems + + +def validate(spec): + problems = [] + for key in ("id", "priority", "title", "why", "budget_seconds", "steps"): + if key not in spec: + problems.append(f"missing {key!r}") + if spec.get("priority") not in PRIORITIES: + problems.append(f"priority {spec.get('priority')!r} is not one of {', '.join(PRIORITIES)}") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", str(spec.get("id", ""))): + problems.append("id must be lowercase letters, digits and dashes") + if not isinstance(spec.get("budget_seconds"), (int, float)) or not 0 < spec["budget_seconds"] <= JOURNEY_SECONDS: + problems.append(f"budget_seconds must be in (0, {JOURNEY_SECONDS}] (one lease)") + kinds = set() + for index, step in enumerate(spec.get("steps") or [], 1): + if step.get("do") not in STEP_NAMES: + problems.append(f"step {index}: unknown do {step.get('do')!r} (one of {', '.join(sorted(STEP_NAMES))})") + for cond in step.get("all", []) if step.get("do") == "expect" else []: + if not isinstance(cond, dict) or len(cond) != 1 or next(iter(cond)) not in driver.KIND: + problems.append(f"step {index}: a condition is one of {', '.join(sorted(driver.KIND))}: {cond!r}") + else: + kinds.add(driver.KIND[next(iter(cond))]) + missing = [k for k in driver.REQUIRED_KINDS if k not in kinds] + if missing: + problems.append("checks no " + ", ".join(missing) + " evidence (a journey needs app state, daemon screen and a frame)") + return problems + + +def select(specs, opts): + if opts.journey: + wanted = set(opts.journey) + unknown = wanted - {s["id"] for s in specs} + if unknown: + raise SystemExit(f"journeys: unknown journey {', '.join(sorted(unknown))} (--list)") + return [s for s in specs if s["id"] in wanted] + if opts.all: + return specs + return [s for s in specs if s["priority"] == opts.priority] + + +def batches(specs): + """Journeys in leases of at most JOURNEY_SECONDS each. A `first_in_app` journey (first launch) needs the + app capture-host just launched from empty state, so each one starts a lease of its own and goes first.""" + out, current, used = [], [], 0 + firsts = [s for s in specs if s.get("first_in_app")] + for first in firsts: + out.append([first]) + rest = [s for s in specs if not s.get("first_in_app")] + # The rest fill the first-launch leases first, then new ones. + for batch in out: + used = batch[0]["budget_seconds"] + while rest and used + rest[0]["budget_seconds"] <= JOURNEY_SECONDS: + used += rest[0]["budget_seconds"] + batch.append(rest.pop(0)) + used = 0 + for spec in rest: + if current and used + spec["budget_seconds"] > JOURNEY_SECONDS: + out.append(current) + current, used = [], 0 + current.append(spec) + used += spec["budget_seconds"] + return out + ([current] if current else []) + + +# ---- the build --------------------------------------------------------------------------- + +def cmux_ci(): + return shutil.which("cmux-ci") or str(Path.home() / ".local/bin/cmux-ci") + + +def job_status(job): + done = run([cmux_ci(), "status", job], timeout=120) + return json.loads(done.stdout.decode()) + + +def wait_build(job, wait_seconds): + """Waits for JOB (a wait timeout leaves it running: wait again, never resubmit); its record.""" + end = time.monotonic() + wait_seconds + while True: + status = job_status(job) + if status.get("state") in ("done", "failed", "cancelled"): + return status + left = end - time.monotonic() + if left <= 0: + return status + log(f"build {job} is {status.get('state')}; waiting (up to {left / 60:.0f} more min)") + subprocess.run([cmux_ci(), "wait", job, "--timeout", str(int(min(left, 1800)) or 1)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def build_for_commit(sha, out_root): + """The fleet build of SHA: an earlier receipt's job, else one new submission (never a second one).""" + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise SystemExit("journeys: --commit takes an exact 40-character SHA (pushed)") + receipts = out_root / "builds" + receipts.mkdir(parents=True, exist_ok=True) + receipt = receipts / f"{sha}.json" + if receipt.exists(): + return json.loads(receipt.read_text())["id"] + submit = receipts / f"{sha}-submit.json" + if submit.exists(): + job = json.loads(submit.read_text()).get("id") + if job: + receipt.write_text(json.dumps({"id": job, "sha": sha}) + "\n") + return job + raise SystemExit(f"journeys: {submit} has no job id: reconcile it (cmux-ci status), never resubmit blindly") + log(f"building {sha[:12]} on the fleet") + run([cmux_ci(), "build", "cmux", "--ref", sha, "--tag", f"journeys-{sha[:8]}-v1", "--backend-mode", "local", + "--workspace", "user-journeys", "--reason", "user journeys (scripts/journeys/run.py)", + "--receipt", str(submit)], timeout=600, check=False) + job = json.loads(submit.read_text()).get("id") if submit.exists() else None + if not job: + raise SystemExit(f"journeys: the build submission left no receipt at {submit}; check `cmux-ci status` before retrying") + receipt.write_text(json.dumps({"id": job, "sha": sha}) + "\n") + return job + + +# ---- one lease --------------------------------------------------------------------------- + +def capture_host(): + found = os.environ.get("CAPTURE_HOST") or shutil.which("capture-host") + installed = Path.home() / ".local/share/cmux/hq-tools/current/build-fleet/capture-host" + return found or str(installed) + + +def kit_tar(specs): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + tar.add(HERE / "driver.py", arcname="driver.py") + for ext in sorted(HERE.glob("ext_*.py")): # step and condition groups the driver loads + tar.add(ext, arcname=ext.name) + tar.add(BUILD_NAMES, arcname="build_names.py") + tar.add(HERE / "smoke_hog.py", arcname="smoke_hog.py") # run only with --hog-swap-gb + tar.add(OCR_SOURCE, arcname="ocr.swift") + data = json.dumps([{k: v for k, v in s.items() if not k.startswith("_")} for s in specs]).encode() + info = tarfile.TarInfo("journeys.json") + info.size, info.mode = len(data), 0o644 + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def launch_facts(text): + line = next((l for l in reversed(text.splitlines()) if "socket=" in l), "") + head, _, app = line.partition(" app=") + facts = dict(kv.split("=", 1) for kv in head.split() if "=" in kv) + if app.strip(): + facts["app"] = app.strip() + return facts + + +ACQUIRE_SLACK_SECONDS = 60 + + +def acquire_lease(ch, exclusive, log_path=None, whole_host=False): + """`capture-host acquire --slot [--exclusive]` (or, whole_host, `acquire [--exclusive]`: the whole mini); the + lease words it prints. An exclusive acquire waits for the + mini's running fleet steps (the drain), and capture-host moves its own acquire deadline on by the time + drained, so its bound here is its acquire timeout plus its drain budget. On that bound the acquire gets TERM + first: its trap ends the controller reservation the drain made (a KILL would leave it until it expires).""" + env_seconds = lambda name, default: int(os.environ.get(name, default)) + bound = (env_seconds("CAPTURE_HOST_ACQUIRE_TIMEOUT_SECONDS", 120) + env_seconds("CAPTURE_HOST_EXCLUSIVE_DRAIN_SECONDS", 3600) + + ACQUIRE_SLACK_SECONDS) if exclusive else 330 + proc = subprocess.Popen([ch, "acquire", *([] if whole_host else ["--slot"]), *(["--exclusive"] if exclusive else [])], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + out, err = proc.communicate(timeout=bound) + if log_path: # capture-host's own lines (each host tried and why it was skipped), for a round with no lease + Path(log_path).write_bytes(err or b"") + except subprocess.TimeoutExpired: + proc.terminate() # our own child: capture-host's acquire_interrupted unreserves and exits + try: + out, err = proc.communicate(timeout=60) + except subprocess.TimeoutExpired: + proc.kill() + try: # a child of the acquire may still hold the pipes: never wait on them without a bound + out, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + out, err = b"", b"(no output: the acquire's children still hold its pipes)" + raise RuntimeError(f"capture-host acquire gave no lease within {bound}s: " + f"{err.decode(errors='replace').strip()[-400:]}") + if proc.returncode != 0: + raise RuntimeError(f"capture-host acquire exited {proc.returncode}: " + f"{(err or out).decode(errors='replace').strip()[-500:]}") + return out.decode().split() + + +HOG_WARMUP_SECONDS = 45 # at least this long from the hog's start (before the download) to the first journey + + +def run_lease(specs, build, out, frame_interval, exclusive=False, hog_swap_gb=0.0, whole_host=False): + """Plays `specs` in one slot (or, whole_host, on the whole mini with the app active); {id: result} (driver results, or not run with the reason). `exclusive`: the + lease owns the whole mini (`capture-host acquire --slot --exclusive`: no fleet step runs there meanwhile; + the fleet rule for any result that is evidence, such as the dogfood smoke gate).""" + ch = capture_host() + ids = [s["id"] for s in specs] + try: + out.mkdir(parents=True, exist_ok=True) + acquired = acquire_lease(ch, exclusive, out / "acquire.log", whole_host=whole_host) + except (RuntimeError, subprocess.TimeoutExpired) as error: + return {i: {"status": "not run", "reason": f"no capture slot: {str(error)[-300:]}"} for i in ids} + if not acquired: + return {i: {"status": "not run", "reason": "capture-host acquire printed no lease"} for i in ids} + lease, deadline = acquired[-1], time.monotonic() + LEASE_SECONDS + left = lambda cap: max(10.0, min(cap, deadline - time.monotonic())) + log(f"leased {lease}") + facts, host, hog_started, work = {}, None, None, None + try: + info = dict(kv.split("=", 1) for kv in run([ch, "slot-info", lease]).stdout.decode().split() if "=" in kv) + host, root = info["host"], info["root"] + work = f"{root}/journeys" + remote(host, f"mkdir -p {q(work + '/kit')} && tar x -C {q(work + '/kit')}", input=kit_tar(specs), timeout=left(120)) + if hog_swap_gb > 0: + # Memory pressure (the Austin's-day hog), from before the download and launch to the end of the + # journeys: it holds incompressible memory until swap used is hog_swap_gb above the start, gives + # memory back near the edge, and ends by itself at its deadline. Started by this lease, stopped by its + # own pid in `finally` (never a pid this run did not start). Marked started before the call, so a + # call that times out after the hog began still stops it. + hog_end = int(left(LEASE_SECONDS) - 30) + hog_started = time.monotonic() + remote(host, f"mkdir -p {q(work + '/out')} && cd {q(work)} && " + f"nohup /usr/bin/python3 -I kit/smoke_hog.py --swap-gb {float(hog_swap_gb)} " + f"--deadline $(( $(date +%s) + {hog_end} )) --log {q(work + '/hog.jsonl')} /dev/null 2>&1 & " + f"echo $! > {q(work + '/hog.pid')}", timeout=left(30)) + log(f"memory hog on {host}: swap +{hog_swap_gb} GB, {HOG_WARMUP_SECONDS}s at least before the journeys") + # One compiled text reader per mini and source version, outside the slot (release removes the slot). + digest = hashlib.sha256(OCR_SOURCE.read_bytes()).hexdigest()[:16] + ocr = f"$HOME/.cache/cmux-journeys/ocr-{digest}" + log(f"downloading build {build['id']} on {host} (and the text reader, once per mini)") + app = remote(host, f"set -e; cd {q(work)}; mkdir -p $HOME/.cache/cmux-journeys; " + f"( [ -x {ocr} ] || {{ swiftc -O kit/ocr.swift -o {ocr}.$$ && mv -f {ocr}.$$ {ocr}; }} ) >ocr.log 2>&1 & " + f"cmux-ci artifact {q(build['id'])} app.zip >&2; ditto -x -k app.zip build; rm -f app.zip; wait || true; " + f"[ -x {ocr} ] || {{ cat ocr.log >&2; exit 3; }}; " + f"a=$(find \"$PWD/build\" -maxdepth 2 -name '*.app' -type d | head -1); " + # The commit the build job names, beside the bundle unpacked from that job's artifact: + # capture-host launch --commit checks it (and the bundle's own CMUXCommit, if any, must agree). + f"[ -z \"$a\" ] || {{ [ {len(str(build.get('commit') or ''))} -ne 40 ] || " + f"printf '%s\\n' {q(build.get('commit') or '')} > \"$(dirname \"$a\")/capture-commit\"; }}; echo \"$a\"", + timeout=left(420)).stdout.decode().strip() + if not app.endswith(".app"): + raise RuntimeError(f"build {build['id']} has no app bundle") + # capture-host (hq main since 2026-10-30) launches only the build of --commit, checked against the bundle. + commit = str(build.get("commit") or "") + if not re.fullmatch(r"[0-9a-f]{7,40}", commit): + raise RuntimeError(f"build {build['id']} names no commit sha ({commit!r}): capture-host launch needs --commit") + # --whole-host: the app launches active (--activate, which capture-host allows on a whole-host lease + # only): it is the active app with a key window, as on a person's Mac. + launched = run([ch, "launch", "--commit", commit, *(["--activate"] if whole_host else []), lease, app, + build.get("tag") or "journeys", "--"], + timeout=left(180), check=False) + facts = launch_facts(launched.stdout.decode()) + if facts.get("socket", "none") == "none": + raise RuntimeError(f"the app did not answer on its control socket: " + f"{(launched.stdout + launched.stderr).decode(errors='replace').strip()[-400:]}") + if hog_started: + # The rest of the warm-up: the pressure builds before the journeys start. + time.sleep(max(0.0, HOG_WARMUP_SECONDS - (time.monotonic() - hog_started))) + budget = int(left(LEASE_SECONDS) - 45) + if budget < 60: + raise RuntimeError(f"the lease has {budget + 45}s left after setup: too little to run journeys") + log(f"driving {', '.join(ids)} on {host} ({budget}s left on the lease)") + drove = remote(host, f"/usr/bin/python3 -I {q(work + '/kit/driver.py')} --socket {q(facts['socket'])} " + f"--app {q(facts.get('app', app))} --home {q(root + '/home')} " + f"--journeys {q(work + '/kit/journeys.json')} --out {q(work + '/out')} --ocr {ocr} " + f"--deadline-seconds {budget} --frame-interval {frame_interval}" + + (" --activate" if whole_host else ""), + timeout=budget + 90, check=False) + for line in drove.stdout.decode(errors="replace").splitlines(): + log(f"[{host}] {line}") + if drove.returncode not in (0, 1): + log(f"driver exited {drove.returncode}: {drove.stderr.decode(errors='replace').strip()[-600:]}") + files = remote(host, f"cd {q(work + '/out')} && tar c --exclude 'frames' . | head -c {RESULT_TAR_MAX_BYTES}", + timeout=left(150), check=False).stdout + take_results(files, out, set(ids)) + if hog_started: + hog_log = remote(host, f"tail -c 2000000 {q(work + '/hog.jsonl')}", timeout=left(60), check=False).stdout + (out / f"hog-{lease}.jsonl").write_bytes(hog_log or b"") + except (RuntimeError, subprocess.TimeoutExpired, KeyError, ValueError) as error: + log(f"lease {lease}: {error}") + reason = f"{host or lease}: {str(error)[-400:]}" + return collect(out, ids, host, missing_reason=reason) + finally: + if hog_started and host: + # Our own hog only: the pid it wrote, checked to still be smoke_hog.py (its deadline ends it anyway). + # A stop that times out must not skip the release below. + try: + remote(host, f"p=$(cat {q(work + '/hog.pid')} 2>/dev/null) && [ -n \"$p\" ] && " + f"ps -p \"$p\" -o command= | grep -q smoke_hog.py && kill \"$p\"; true", timeout=30, check=False) + except (RuntimeError, subprocess.TimeoutExpired) as error: + log(f"stopping the memory hog on {host} failed ({error}); it ends by itself at its deadline") + released = subprocess.run([ch, "release", lease], capture_output=True, timeout=120) + log(f"released {lease}" if released.returncode == 0 else + f"release of {lease} exited {released.returncode} (it expires within 10 minutes)") + return collect(out, ids, host, missing_reason=f"{host}: the driver wrote no result (driver exit {drove.returncode}: " + f"{drove.stderr.decode(errors='replace').strip()[-300:]})") + + +def take_results(files, out, ids): + """Keeps only the expected regular files of the mini's tarball: the app there could plant links or paths.""" + if not files: + return + with tarfile.open(fileobj=io.BytesIO(files)) as tar: + for member in tar: + match = RESULT_FILE.fullmatch(member.name) + if not match or not member.isreg() or member.size > RESULT_MAX_BYTES: + continue + name = match.group(1) + if "/" in name and name.split("/", 1)[0] not in ids: + continue + target = out / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(tar.extractfile(member).read()) + + +def collect(out, ids, host, missing_reason): + results = {} + for i in ids: + path = out / i / "result.json" + if path.is_file(): + result = json.loads(path.read_text()) + result["host"] = host + results[i] = result + else: + results[i] = {"status": "not run", "reason": missing_reason, "host": host} + return results + + +# ---- clips ------------------------------------------------------------------------------- + +def make_clip(directory, interval): + """clip.mp4 with ffmpeg, else clip.png (APNG, standard library only) from /clip/frame-*.png.""" + frames = sorted((directory / "clip").glob("frame-*.png")) + if not frames: + return None + if shutil.which("ffmpeg"): + listing, video = directory / "clip.txt", directory / "clip.mp4" + listing.write_text("".join(f"file '{f.resolve()}'\nduration {interval:g}\n" for f in frames) + f"file '{frames[-1].resolve()}'\n") + done = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(listing), + "-vf", "scale=1280:-2", "-pix_fmt", "yuv420p", str(video)], capture_output=True, timeout=600) + listing.unlink() + if done.returncode == 0: + return video + return apng(frames, directory / "clip.png", interval) + + +def png_chunks(data): + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + pos, chunks = 8, [] + while pos < len(data): + length, kind = struct.unpack(">I4s", data[pos:pos + 8]) + chunks.append((kind, data[pos + 8:pos + 8 + length])) + pos += 12 + length + return chunks + + +def apng(frames, target, interval): + """An animated PNG of same-format frames (others skipped), `interval` seconds each; identical frames merge.""" + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) + parsed, last = [], None + for path in frames: + data = path.read_bytes() + if data == last and parsed: + parsed[-1][2] += 1 + continue + last = data + chunks = png_chunks(data) + ihdr = dict(chunks).get(b"IHDR") + if ihdr is None or (parsed and ihdr[8:] != parsed[0][0][8:]): + continue + parsed.append([ihdr, b"".join(body for kind, body in chunks if kind == b"IDAT"), 1]) + if not parsed: + return None + width, height = struct.unpack(">II", parsed[0][0][:8]) + parsed = [p for p in parsed if struct.unpack(">II", p[0][:8]) == (width, height)] + out, seq = [b"\x89PNG\r\n\x1a\n", chunk(b"IHDR", parsed[0][0]), chunk(b"acTL", struct.pack(">II", len(parsed), 0))], 0 + for index, (ihdr, idat, repeats) in enumerate(parsed): + delay = max(1, round(interval * repeats * 100)) + out.append(chunk(b"fcTL", struct.pack(">IIIIIHHBB", seq, width, height, 0, 0, delay, 100, 0, 0))) + seq += 1 + if index == 0: + out.append(chunk(b"IDAT", idat)) + else: + out.append(chunk(b"fdAT", struct.pack(">I", seq) + idat)) + seq += 1 + out.append(chunk(b"IEND", b"")) + target.write_bytes(b"".join(out)) + return target + + +# ---- report ------------------------------------------------------------------------------ + +def compact(evidence): + rows = [] + for e in evidence or []: + detail = e.get("detail") + if isinstance(detail, dict): + detail = {k: v for k, v in detail.items() if k in ("last_line", "tail", "image", "text", "pid", "tty", "matches", "got", "error")} + rows.append({"kind": e.get("kind"), "check": e.get("check"), "want": e.get("want"), "pass": e.get("pass"), + "elapsed_ms": e.get("elapsed_ms"), "within_ms": e.get("within_ms"), "detail": detail}) + return rows + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("\n\n", 1)[1]) + target = ap.add_mutually_exclusive_group() + target.add_argument("--build", help="a cmux fleet build job id (its artifact is the app under test)") + target.add_argument("--commit", help="an exact pushed SHA: its fleet build (submitted once, receipt reused)") + pick = ap.add_mutually_exclusive_group() + pick.add_argument("--priority", choices=PRIORITIES, default="P0") + pick.add_argument("--journey", action="append", help="a journey id (repeatable)") + pick.add_argument("--all", action="store_true") + ap.add_argument("--out", help="output folder (default artifacts/journeys/-