Skip to content

Commit 624e434

Browse files
test(e2e): add end-to-end shell test suite
Add a separate e2e Go module that validates the shell integrations against real shells instead of only asserting generated script text: - layer 1: generate the init script per shell and feature-config overlay (base, transient, rprompt, tooltips, full) and validate it with the shell's own parser - layer 2: boot each shell interactively in a pty (ConPTY on Windows, creack/pty elsewhere) with a vt10x screen emulator, assert the prompt renders and the session exits cleanly - layer 3: feature scenarios asserting exit-code propagation, transient prompt replacement, and rprompt right-alignment Covers bash, zsh, fish, pwsh, and nu; tests skip cleanly when a shell binary is absent or the platform cannot drive it faithfully. The harness answers PSReadLine's DSR cursor queries on Unix ptys and isolates sessions from the host's cache and nu vendor autoloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 8a75c4edb40b
1 parent e98db08 commit 624e434

15 files changed

Lines changed: 1712 additions & 0 deletions

File tree

.agents/skills/project-knowledge/references/testing.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
Patterns for functionally driving omp's shell integrations, mostly in WSL (verified on aarch64,
44
zsh 5.9, fish 4.1.2).
55

6+
## The e2e module (`e2e/`)
7+
8+
A separate Go module with a cross-platform pty harness (go-pty + vt10x) that runs three layers
9+
(syntax check, interactive smoke, feature scenarios) for bash/zsh/fish/pwsh/nu. See `e2e/README.md`
10+
for usage. Gotchas baked into it, relevant to any future pty work:
11+
12+
- PSReadLine on a raw Unix pty floods `CSI 6n` (DSR cursor-position) queries and wedges without a
13+
reply; ConPTY answers them internally on Windows. The harness's reader goroutine answers with
14+
the vt10x cursor position (`harness/session.go`).
15+
- nu autoloads every `.nu` under `$nu.vendor-autoload-dirs` AFTER `--config`, so a dev machine's
16+
real oh-my-posh nu integration clobbers the test prompt. Isolate with `XDG_DATA_HOME` pointed
17+
at an empty dir (works on Windows too).
18+
- go-pty's Windows `Cmd` resolves bare executable names relative to `Cmd.Dir` when `Dir` is set.
19+
Always pass an absolute binary path.
20+
- Windows PATH resolves `bash` to System32's WSL launcher, not Git Bash; it fails on Windows-style
21+
paths. `harness.LookupShellBinary` derives Git Bash from `git.exe`'s location.
22+
- bash transient and rprompt are ble.sh-only (`bashBLEsession`, gated on `BLE_SESSION_ID` in
23+
`src/shell/bash.go`). Plain interactive bash gets no code for either feature.
24+
625
## WSL basics
726

827
- WSL `/tmp` is wiped between separate `wsl.exe` invocations (instance auto-shutdown). Either make

.github/workflows/e2e.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
on:
2+
pull_request:
3+
paths:
4+
- 'src/**'
5+
- 'e2e/**'
6+
- '.github/workflows/e2e.yml'
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
10+
cancel-in-progress: true
11+
12+
name: E2E Tests
13+
jobs:
14+
linux:
15+
runs-on: ubuntu-latest
16+
env:
17+
NU_VERSION: "0.113.1"
18+
OMP_E2E_REQUIRE: bash,zsh,fish,pwsh,nu
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
22+
- name: Install Go 🗳
23+
uses: ./.github/workflows/composite/bootstrap-go
24+
- name: Install zsh and fish
25+
run: |
26+
sudo apt-get update
27+
sudo apt-get install -y zsh fish
28+
- name: Install nushell
29+
run: |
30+
curl -sSL -o nu.tar.gz "https://github.com/nushell/nushell/releases/download/${NU_VERSION}/nu-${NU_VERSION}-x86_64-unknown-linux-musl.tar.gz"
31+
tar xzf nu.tar.gz
32+
echo "$PWD/nu-${NU_VERSION}-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
33+
- name: E2E tests
34+
working-directory: e2e
35+
run: go test -count=1 -v ./...
36+
37+
windows:
38+
runs-on: windows-latest
39+
env:
40+
NU_VERSION: "0.113.1"
41+
OMP_E2E_REQUIRE: pwsh,nu
42+
steps:
43+
- name: Checkout code
44+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
45+
- name: Install Go 🗳
46+
uses: ./.github/workflows/composite/bootstrap-go
47+
- name: Install nushell
48+
shell: pwsh
49+
run: |
50+
Invoke-WebRequest -Uri "https://github.com/nushell/nushell/releases/download/$env:NU_VERSION/nu-$env:NU_VERSION-x86_64-pc-windows-msvc.zip" -OutFile nu.zip
51+
Expand-Archive -Path nu.zip -DestinationPath nu
52+
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\nu"
53+
- name: E2E tests
54+
working-directory: e2e
55+
run: go test -count=1 -v ./...

e2e/README.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# E2E Test Suite
2+
3+
End-to-end tests for the oh-my-posh shell integrations themselves (not the Go internals under
4+
`src/`). They generate real init scripts with the `oh-my-posh` binary, feed them to the actual
5+
shells, and drive interactive sessions in a pseudo-terminal.
6+
7+
This is a separate Go module (`github.com/jandedobbeleer/oh-my-posh/e2e`, own `go.mod`/`go.sum`)
8+
so its dependencies never leak into `src/`.
9+
10+
## Layers
11+
12+
1. **Syntax** (`syntax_test.go`) — generate the init script per shell x config overlay and
13+
validate it with the shell's own parser (`bash -n`, `zsh -n`, `fish --no-execute`, a
14+
`System.Management.Automation.Language.Parser` call for pwsh, `nu-check` for nu).
15+
2. **Smoke** (`smoke_test.go`) — boot each shell interactively in a real pty with the generated
16+
init script, assert the prompt renders cleanly, a typed command runs, and the shell exits.
17+
3. **Behavior** (`features_test.go`) — per-feature scenarios: exit-code propagation, transient
18+
prompt, right prompt, styling colors, and FTCS marks.
19+
20+
Both layers 2 and 3 drive the shell through `harness.Session`, a pty wrapper with a vt10x screen
21+
emulator for rendered-screen assertions and a raw-byte buffer for escape-sequence assertions.
22+
23+
The suite targets the "big five" shells: bash, zsh, fish, pwsh, nu.
24+
25+
## Running locally
26+
27+
```shell
28+
cd e2e
29+
go test -count=1 ./...
30+
```
31+
32+
Add `-v` for per-shell/per-overlay output, or `-run <Test>/<shell>` to scope to one case.
33+
34+
Every test skips cleanly (`t.Skip`) when a shell's binary is not on `PATH`, or when the shell is
35+
not supported on the current platform. Set `OMP_E2E_REQUIRE` to a comma-separated list of shell
36+
names (e.g. `pwsh,nu`) to turn those skips into hard failures for the listed shells instead —
37+
useful to catch a shell that's supposed to be installed but silently isn't. It defaults to unset
38+
(everything skips); CI sets it to the shells each job installs so an expected shell hard-fails
39+
instead of skipping quietly.
40+
41+
| Shell | Linux/macOS | Windows |
42+
|-------|------------------------------|------------------------------------------------------------|
43+
| bash | yes (skip if binary missing) | skipped (msys/WSL bash under ConPTY is not representative) |
44+
| zsh | yes (skip if binary missing) | skipped |
45+
| fish | yes (skip if binary missing) | skipped |
46+
| pwsh | yes (skip if binary missing) | yes (skip if binary missing) |
47+
| nu | yes (skip if binary missing) | yes (skip if binary missing) |
48+
49+
Layer 1 (syntax) still runs its non-empty/no-placeholder assertions unconditionally for every
50+
shell; only the parser check itself is skipped when the binary is missing.
51+
52+
### Building the omp binary
53+
54+
The suite builds `../src` once per test run (guarded by `sync.Once`) into a temp directory and
55+
uses that binary for every `init` invocation. Set `OMP_E2E_BINARY` to an absolute path to skip
56+
the build and reuse a prebuilt binary instead — useful when iterating on tests without rebuilding
57+
oh-my-posh every run.
58+
59+
### Isolation
60+
61+
Every omp invocation and shell session gets `OMP_CACHE_DIR` pointed at a fresh `t.TempDir()`, so
62+
runs never share or pollute a developer's real cache. Sessions also fix `TERM=xterm-256color`
63+
and a 120x30 pty size.
64+
65+
## Adding a new shell
66+
67+
Add an entry to the `Shells` table in `harness/shells.go`. A `ShellDef` needs:
68+
69+
- `Name` — the omp shell name passed to `oh-my-posh init <Name>`.
70+
- `Binary` — the executable looked up via `LookupShellBinary`; missing means skip.
71+
- `SyntaxCheck(scriptPath) *exec.Cmd` — a command that parses (not executes) the script and
72+
exits non-zero on a syntax error.
73+
- `Launch(t, scriptPath, workDir) (bin string, args []string, env []string)` — how to boot the
74+
shell interactively with `scriptPath` sourced (e.g. a temp rc file plus `-i`).
75+
- `Fail` — an `ExitCommand{Command, Code}`: a command line that reliably exits non-zero when
76+
typed interactively, and the exit code oh-my-posh should report in the next prompt.
77+
78+
If the shell can't be driven faithfully on every platform, add a case to
79+
`ShellDef.SupportedOnHost` (see the bash/zsh/fish Windows exclusion for the reasoning).
80+
81+
Layers 1-3 all iterate `harness.Shells`, so a correctly filled-in entry is picked up everywhere
82+
automatically — no test file needs editing for a new shell on its own.
83+
84+
## Adding a new feature scenario
85+
86+
`features_test.go` drives every scenario through one table-driven test, `TestFeatures`, whose
87+
subtests are named `TestFeatures/<scenario>/<shell>`. To add a scenario:
88+
89+
1. If the feature needs a config change, add an `Overlay` function to `harness/config.go` that
90+
mutates the base config map (see `Transient`, `RPrompt`, `Colored`, `ShellIntegration` for
91+
examples). If the overlay changes the generated init script, register it in `overlaySets` in
92+
`syntax_test.go` so layer 1 covers it too (`Colored` doesn't change the script, so it's left
93+
out of that matrix).
94+
2. Append a `scenario` entry to `featureScenarios` in `features_test.go`:
95+
- `overlays` — the `Overlay`s to apply, if any.
96+
- `skips` — a `map[string]string` of shell name to skip reason, for shells that don't support
97+
the feature (see the bash entries on `transient`/`rprompt`, or the nu entry on `ftcs`, for the
98+
pattern). Leave it `nil` when every shell is expected to pass.
99+
- `run` — a `func(t *testing.T, sh harness.ShellDef, s *harness.Session)` with the scenario's
100+
assertions. The `TestFeatures` runner has already applied the overlays, skipped
101+
unsupported shells, started the session and waited for the first prompt by the time `run`
102+
is called; drive the rest of the session (`SendLine`, `WaitFor`) and assert against
103+
`Screen()`/`ScreenLines()` for rendered output, `Raw()` for escape-sequence assertions, or
104+
`MarkerColor()` for a rendered marker's cell colors.
105+
3. Never make a shell silently succeed or fail a feature it doesn't support — add it to `skips`
106+
with a comment explaining why instead.
107+
108+
## Harness internals
109+
110+
Things the harness does that are easy to break by accident:
111+
112+
- **DSR replies** — PSReadLine on a Unix pty repeatedly queries the cursor position (`CSI 6n`)
113+
and blocks rendering until it gets a reply. ConPTY answers this internally on Windows; on
114+
Linux/macOS the harness itself replies with the vt10x cursor position from its reader
115+
goroutine (`harness/session.go`). Remove that and pwsh-on-Linux wedges until the 30s timeout.
116+
- **Single pty reader** — exactly one goroutine reads the pty and feeds both the vt10x screen
117+
and the raw buffer under one mutex. A second reader silently loses buffered data.
118+
- **nu vendor autoload** — nu loads every `.nu` under `$nu.vendor-autoload-dirs` after
119+
`--config`, so a machine with the real oh-my-posh nu integration installed would clobber the
120+
test prompt. Sessions point `XDG_DATA_HOME` at an empty temp directory to prevent this.
121+
- **Absolute binary paths** — go-pty's Windows `Cmd` resolves bare executable names relative to
122+
`Cmd.Dir` when `Dir` is set, so `Start` always resolves the shell binary to an absolute path
123+
first.
124+
- **bash lookup on Windows** — plain `PATH` lookup finds System32's WSL launcher `bash.exe`,
125+
which cannot run Windows-style script paths; `LookupShellBinary` derives Git Bash's location
126+
from `git.exe` instead.
127+
128+
## Known limitations
129+
130+
- bash only renders a transient prompt or right prompt inside a `ble.sh` session (gated on
131+
`BLE_SESSION_ID`, see `src/shell/bash.go`); this harness's plain
132+
`bash --noprofile --rcfile ... -i` session doesn't provide one, so `TestFeatures/transient` and
133+
`TestFeatures/rprompt` skip bash explicitly.
134+
- nu never emits any FTCS mark: `Features().Nu()`'s switch does list a case for `FTCSMarks` (see
135+
`src/shell/nu.go`), but that case deliberately returns an empty `Code`, so the generated script
136+
never gets the hook that prints them. `TestFeatures/ftcs` skips nu explicitly.
137+
- cmd, elvish, xonsh and yash are not covered by any layer.
138+
139+
## CI
140+
141+
`.github/workflows/e2e.yml` runs this suite on `ubuntu-latest` (bash, pwsh preinstalled; zsh and
142+
fish installed via `apt`; nu installed from a pinned GitHub release) and on `windows-latest`
143+
(pwsh preinstalled; nu installed from a pinned release; bash/zsh/fish skip by design).

0 commit comments

Comments
 (0)