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
This commit is contained in:
Jan De Dobbeleer
2026-07-17 13:47:33 +02:00
committed by Jan De Dobbeleer
co-authored by Claude Fable 5
parent e98db08a60
commit 624e43436e
15 changed files with 1712 additions and 0 deletions
@@ -3,6 +3,25 @@
Patterns for functionally driving omp's shell integrations, mostly in WSL (verified on aarch64,
zsh 5.9, fish 4.1.2).
## The e2e module (`e2e/`)
A separate Go module with a cross-platform pty harness (go-pty + vt10x) that runs three layers
(syntax check, interactive smoke, feature scenarios) for bash/zsh/fish/pwsh/nu. See `e2e/README.md`
for usage. Gotchas baked into it, relevant to any future pty work:
- PSReadLine on a raw Unix pty floods `CSI 6n` (DSR cursor-position) queries and wedges without a
reply; ConPTY answers them internally on Windows. The harness's reader goroutine answers with
the vt10x cursor position (`harness/session.go`).
- nu autoloads every `.nu` under `$nu.vendor-autoload-dirs` AFTER `--config`, so a dev machine's
real oh-my-posh nu integration clobbers the test prompt. Isolate with `XDG_DATA_HOME` pointed
at an empty dir (works on Windows too).
- go-pty's Windows `Cmd` resolves bare executable names relative to `Cmd.Dir` when `Dir` is set.
Always pass an absolute binary path.
- Windows PATH resolves `bash` to System32's WSL launcher, not Git Bash; it fails on Windows-style
paths. `harness.LookupShellBinary` derives Git Bash from `git.exe`'s location.
- bash transient and rprompt are ble.sh-only (`bashBLEsession`, gated on `BLE_SESSION_ID` in
`src/shell/bash.go`). Plain interactive bash gets no code for either feature.
## WSL basics
- WSL `/tmp` is wiped between separate `wsl.exe` invocations (instance auto-shutdown). Either make
+55
View File
@@ -0,0 +1,55 @@
on:
pull_request:
paths:
- 'src/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
name: E2E Tests
jobs:
linux:
runs-on: ubuntu-latest
env:
NU_VERSION: "0.113.1"
OMP_E2E_REQUIRE: bash,zsh,fish,pwsh,nu
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install Go 🗳
uses: ./.github/workflows/composite/bootstrap-go
- name: Install zsh and fish
run: |
sudo apt-get update
sudo apt-get install -y zsh fish
- name: Install nushell
run: |
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"
tar xzf nu.tar.gz
echo "$PWD/nu-${NU_VERSION}-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
- name: E2E tests
working-directory: e2e
run: go test -count=1 -v ./...
windows:
runs-on: windows-latest
env:
NU_VERSION: "0.113.1"
OMP_E2E_REQUIRE: pwsh,nu
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install Go 🗳
uses: ./.github/workflows/composite/bootstrap-go
- name: Install nushell
shell: pwsh
run: |
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
Expand-Archive -Path nu.zip -DestinationPath nu
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\nu"
- name: E2E tests
working-directory: e2e
run: go test -count=1 -v ./...
+143
View File
@@ -0,0 +1,143 @@
# E2E Test Suite
End-to-end tests for the oh-my-posh shell integrations themselves (not the Go internals under
`src/`). They generate real init scripts with the `oh-my-posh` binary, feed them to the actual
shells, and drive interactive sessions in a pseudo-terminal.
This is a separate Go module (`github.com/jandedobbeleer/oh-my-posh/e2e`, own `go.mod`/`go.sum`)
so its dependencies never leak into `src/`.
## Layers
1. **Syntax** (`syntax_test.go`) — generate the init script per shell x config overlay and
validate it with the shell's own parser (`bash -n`, `zsh -n`, `fish --no-execute`, a
`System.Management.Automation.Language.Parser` call for pwsh, `nu-check` for nu).
2. **Smoke** (`smoke_test.go`) — boot each shell interactively in a real pty with the generated
init script, assert the prompt renders cleanly, a typed command runs, and the shell exits.
3. **Behavior** (`features_test.go`) — per-feature scenarios: exit-code propagation, transient
prompt, right prompt, styling colors, and FTCS marks.
Both layers 2 and 3 drive the shell through `harness.Session`, a pty wrapper with a vt10x screen
emulator for rendered-screen assertions and a raw-byte buffer for escape-sequence assertions.
The suite targets the "big five" shells: bash, zsh, fish, pwsh, nu.
## Running locally
```shell
cd e2e
go test -count=1 ./...
```
Add `-v` for per-shell/per-overlay output, or `-run <Test>/<shell>` to scope to one case.
Every test skips cleanly (`t.Skip`) when a shell's binary is not on `PATH`, or when the shell is
not supported on the current platform. Set `OMP_E2E_REQUIRE` to a comma-separated list of shell
names (e.g. `pwsh,nu`) to turn those skips into hard failures for the listed shells instead —
useful to catch a shell that's supposed to be installed but silently isn't. It defaults to unset
(everything skips); CI sets it to the shells each job installs so an expected shell hard-fails
instead of skipping quietly.
| Shell | Linux/macOS | Windows |
|-------|------------------------------|------------------------------------------------------------|
| bash | yes (skip if binary missing) | skipped (msys/WSL bash under ConPTY is not representative) |
| zsh | yes (skip if binary missing) | skipped |
| fish | yes (skip if binary missing) | skipped |
| pwsh | yes (skip if binary missing) | yes (skip if binary missing) |
| nu | yes (skip if binary missing) | yes (skip if binary missing) |
Layer 1 (syntax) still runs its non-empty/no-placeholder assertions unconditionally for every
shell; only the parser check itself is skipped when the binary is missing.
### Building the omp binary
The suite builds `../src` once per test run (guarded by `sync.Once`) into a temp directory and
uses that binary for every `init` invocation. Set `OMP_E2E_BINARY` to an absolute path to skip
the build and reuse a prebuilt binary instead — useful when iterating on tests without rebuilding
oh-my-posh every run.
### Isolation
Every omp invocation and shell session gets `OMP_CACHE_DIR` pointed at a fresh `t.TempDir()`, so
runs never share or pollute a developer's real cache. Sessions also fix `TERM=xterm-256color`
and a 120x30 pty size.
## Adding a new shell
Add an entry to the `Shells` table in `harness/shells.go`. A `ShellDef` needs:
- `Name` — the omp shell name passed to `oh-my-posh init <Name>`.
- `Binary` — the executable looked up via `LookupShellBinary`; missing means skip.
- `SyntaxCheck(scriptPath) *exec.Cmd` — a command that parses (not executes) the script and
exits non-zero on a syntax error.
- `Launch(t, scriptPath, workDir) (bin string, args []string, env []string)` — how to boot the
shell interactively with `scriptPath` sourced (e.g. a temp rc file plus `-i`).
- `Fail` — an `ExitCommand{Command, Code}`: a command line that reliably exits non-zero when
typed interactively, and the exit code oh-my-posh should report in the next prompt.
If the shell can't be driven faithfully on every platform, add a case to
`ShellDef.SupportedOnHost` (see the bash/zsh/fish Windows exclusion for the reasoning).
Layers 1-3 all iterate `harness.Shells`, so a correctly filled-in entry is picked up everywhere
automatically — no test file needs editing for a new shell on its own.
## Adding a new feature scenario
`features_test.go` drives every scenario through one table-driven test, `TestFeatures`, whose
subtests are named `TestFeatures/<scenario>/<shell>`. To add a scenario:
1. If the feature needs a config change, add an `Overlay` function to `harness/config.go` that
mutates the base config map (see `Transient`, `RPrompt`, `Colored`, `ShellIntegration` for
examples). If the overlay changes the generated init script, register it in `overlaySets` in
`syntax_test.go` so layer 1 covers it too (`Colored` doesn't change the script, so it's left
out of that matrix).
2. Append a `scenario` entry to `featureScenarios` in `features_test.go`:
- `overlays` — the `Overlay`s to apply, if any.
- `skips` — a `map[string]string` of shell name to skip reason, for shells that don't support
the feature (see the bash entries on `transient`/`rprompt`, or the nu entry on `ftcs`, for the
pattern). Leave it `nil` when every shell is expected to pass.
- `run` — a `func(t *testing.T, sh harness.ShellDef, s *harness.Session)` with the scenario's
assertions. The `TestFeatures` runner has already applied the overlays, skipped
unsupported shells, started the session and waited for the first prompt by the time `run`
is called; drive the rest of the session (`SendLine`, `WaitFor`) and assert against
`Screen()`/`ScreenLines()` for rendered output, `Raw()` for escape-sequence assertions, or
`MarkerColor()` for a rendered marker's cell colors.
3. Never make a shell silently succeed or fail a feature it doesn't support — add it to `skips`
with a comment explaining why instead.
## Harness internals
Things the harness does that are easy to break by accident:
- **DSR replies** — PSReadLine on a Unix pty repeatedly queries the cursor position (`CSI 6n`)
and blocks rendering until it gets a reply. ConPTY answers this internally on Windows; on
Linux/macOS the harness itself replies with the vt10x cursor position from its reader
goroutine (`harness/session.go`). Remove that and pwsh-on-Linux wedges until the 30s timeout.
- **Single pty reader** — exactly one goroutine reads the pty and feeds both the vt10x screen
and the raw buffer under one mutex. A second reader silently loses buffered data.
- **nu vendor autoload** — nu loads every `.nu` under `$nu.vendor-autoload-dirs` after
`--config`, so a machine with the real oh-my-posh nu integration installed would clobber the
test prompt. Sessions point `XDG_DATA_HOME` at an empty temp directory to prevent this.
- **Absolute binary paths** — go-pty's Windows `Cmd` resolves bare executable names relative to
`Cmd.Dir` when `Dir` is set, so `Start` always resolves the shell binary to an absolute path
first.
- **bash lookup on Windows** — plain `PATH` lookup finds System32's WSL launcher `bash.exe`,
which cannot run Windows-style script paths; `LookupShellBinary` derives Git Bash's location
from `git.exe` instead.
## Known limitations
- bash only renders a transient prompt or right prompt inside a `ble.sh` session (gated on
`BLE_SESSION_ID`, see `src/shell/bash.go`); this harness's plain
`bash --noprofile --rcfile ... -i` session doesn't provide one, so `TestFeatures/transient` and
`TestFeatures/rprompt` skip bash explicitly.
- nu never emits any FTCS mark: `Features().Nu()`'s switch does list a case for `FTCSMarks` (see
`src/shell/nu.go`), but that case deliberately returns an empty `Code`, so the generated script
never gets the hook that prints them. `TestFeatures/ftcs` skips nu explicitly.
- cmd, elvish, xonsh and yash are not covered by any layer.
## CI
`.github/workflows/e2e.yml` runs this suite on `ubuntu-latest` (bash, pwsh preinstalled; zsh and
fish installed via `apt`; nu installed from a pinned GitHub release) and on `windows-latest`
(pwsh preinstalled; nu installed from a pinned release; bash/zsh/fish skip by design).
+206
View File
@@ -0,0 +1,206 @@
package e2e
import (
"fmt"
"regexp"
"strings"
"testing"
"github.com/hinshun/vt10x"
"github.com/jandedobbeleer/oh-my-posh/e2e/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// featurePtyCols mirrors the pty width the harness fixes every session to (see
// harness/session.go), used here to assert how close to the right edge the rprompt
// renders.
const featurePtyCols = 120
// scenario is one behavior-layer case: the config overlays needed to enable it, any
// shells that must be skipped (declaratively, with a reason), and the assertions to run
// once the session's first prompt is up.
type scenario struct {
name string
overlays []harness.Overlay
skips map[string]string // shell name -> skip reason
run func(t *testing.T, sh harness.ShellDef, s *harness.Session)
}
// featureScenarios is the behavior-layer test matrix, crossed with harness.Shells by
// TestFeatures.
var featureScenarios = []scenario{
{
// exitCode boots with the base config, runs the shell's Fail command, and
// asserts the next prompt reports the expected exit code via the
// "E2E:<code>>" template.
name: "exit-code",
run: func(t *testing.T, sh harness.ShellDef, s *harness.Session) {
require.NotEmpty(t, sh.Fail.Command, "%s: no Fail command configured", sh.Name)
s.SendLine(sh.Fail.Command)
expected := fmt.Sprintf("E2E:%d>", sh.Fail.Code)
s.WaitFor(regexp.MustCompile(regexp.QuoteMeta(expected)))
},
},
{
// transient boots with the transient_prompt overlay, types a command, and
// asserts that once the command's output and the following prompt have both
// landed, the screen row that carried the accepted command line was
// rewritten to start with "TR>" — i.e. the primary prompt on that line was
// replaced by the transient one.
//
// bash only supports the transient prompt inside a ble.sh session (gated on
// the BLE_SESSION_ID environment variable, see src/shell/bash.go), which this
// harness's plain `bash --noprofile --rcfile ... -i` session does not
// provide, so bash is skipped explicitly rather than asserted against.
name: "transient",
overlays: []harness.Overlay{harness.Transient},
skips: map[string]string{
"bash": "bash only supports a transient prompt inside a ble.sh session; not supported by this harness",
},
run: func(t *testing.T, sh harness.ShellDef, s *harness.Session) {
const echoedText = "transient-check"
s.SendLine("echo " + echoedText)
// (?s) lets '.' cross line boundaries: this only matches once the
// echoed output AND a subsequent primary prompt are both on screen,
// proving the command has fully run and the shell moved on to its
// next prompt.
readyRe := regexp.MustCompile(`(?s)` + echoedText + `.*E2E:\d+>`)
screen := s.WaitFor(readyRe)
var commandLine string
for _, line := range s.ScreenLines() {
if strings.Contains(line, "echo "+echoedText) {
commandLine = line
break
}
}
require.NotEmpty(t, commandLine,
"%s: could not find the accepted command line on screen:\n%s", sh.Name, screen)
trimmed := strings.TrimLeft(commandLine, " ")
assert.True(t, strings.HasPrefix(trimmed, "TR>"),
"%s: command line was not rewritten with the transient prompt: %q", sh.Name, commandLine)
},
},
{
// rprompt boots with the rprompt overlay and asserts the fixed "RMARK"
// marker renders on the same screen row as the primary "E2E:0>" prompt,
// right-aligned near the pty's 120th column.
//
// bash only renders a right prompt inside a ble.sh session (gated on the
// BLE_SESSION_ID environment variable, see src/shell/bash.go), which this
// harness's plain `bash --noprofile --rcfile ... -i` session does not
// provide, so bash is skipped explicitly rather than asserted against.
name: "rprompt",
overlays: []harness.Overlay{harness.RPrompt},
skips: map[string]string{
"bash": "bash only renders a right prompt inside a ble.sh session; not supported by this harness",
},
run: func(t *testing.T, sh harness.ShellDef, s *harness.Session) {
var promptLine string
for _, line := range s.ScreenLines() {
if strings.Contains(line, "E2E:0>") {
promptLine = line
break
}
}
require.NotEmpty(t, promptLine,
"%s: could not find the primary prompt row on screen:\n%s", sh.Name, s.Screen())
require.Contains(t, promptLine, "RMARK",
"%s: RMARK not found on the same row as the primary prompt: %q", sh.Name, promptLine)
trimmed := strings.TrimRight(promptLine, " ")
assert.True(t, strings.HasSuffix(trimmed, "RMARK"),
"%s: RMARK is not right-aligned, trimmed row does not end with it: %q", sh.Name, promptLine)
endCol := strings.LastIndex(promptLine, "RMARK") + len("RMARK")
assert.InDelta(t, featurePtyCols, endCol, 2,
"%s: RMARK does not end near column %d (ended at %d): %q", sh.Name, featurePtyCols, endCol, promptLine)
},
},
{
// color boots with the Colored overlay and asserts the fixed "CLR" marker
// renders with its configured truecolor foreground/background, verifying
// screen-level color rendering rather than just the raw SGR bytes.
name: "color",
overlays: []harness.Overlay{harness.Colored},
run: func(t *testing.T, sh harness.ShellDef, s *harness.Session) {
s.WaitFor(regexp.MustCompile(regexp.QuoteMeta("CLR")))
fg, bg, found := s.MarkerColor("CLR")
require.True(t, found, "%s: CLR marker not found on screen:\n%s", sh.Name, s.Screen())
assert.Equal(t, vt10x.Color(0xff0000), fg,
"%s: unexpected foreground color for CLR: %#06x", sh.Name, uint32(fg))
assert.Equal(t, vt10x.Color(0x0000ff), bg,
"%s: unexpected background color for CLR: %#06x", sh.Name, uint32(bg))
},
},
{
// ftcs boots with the shell_integration overlay and asserts all four FTCS
// (Final Term Control Sequence) marks land in the raw byte stream: prompt
// start (133;A) and command start (133;B) around the first prompt, then
// pre-execution (133;C) and command-finished (133;D) around a typed command.
//
// nu's init script intentionally emits none of the FTCS marks: Features().Nu()'s
// switch does list a case for FTCSMarks (grouped with several other features), but
// that case deliberately returns an empty Code (see src/shell/nu.go), so the
// generated script never gets the hook that prints them.
name: "ftcs",
overlays: []harness.Overlay{harness.ShellIntegration},
skips: map[string]string{
"nu": "nu's FTCSMarks case in src/shell/nu.go deliberately emits nothing, so shell_integration marks never appear for nu",
},
run: func(t *testing.T, sh harness.ShellDef, s *harness.Session) {
raw := s.Raw()
require.Contains(t, raw, "\x1b]133;A",
"%s: missing FTCS prompt-start mark (133;A) after first prompt:\n%s", sh.Name, raw)
require.Contains(t, raw, "\x1b]133;B",
"%s: missing FTCS command-start mark (133;B) after first prompt:\n%s", sh.Name, raw)
s.SendLine("echo ftcs-check")
readyRe := regexp.MustCompile(`(?s)ftcs-check.*E2E:\d+>`)
s.WaitFor(readyRe)
raw = s.Raw()
assert.Contains(t, raw, "\x1b]133;C",
"%s: missing FTCS pre-execution mark (133;C):\n%s", sh.Name, raw)
assert.Contains(t, raw, "\x1b]133;D",
"%s: missing FTCS command-finished mark (133;D):\n%s", sh.Name, raw)
},
},
}
// TestFeatures runs every featureScenarios case against every harness.Shells entry, as
// "TestFeatures/<scenario>/<shell>". For each shell it skips cleanly (with the scenario's
// declared reason) when the feature is unsupported by this harness, otherwise it writes
// the scenario's config overlays, starts the shell, waits for the first prompt, and hands
// off to the scenario's assertions.
func TestFeatures(t *testing.T) {
for _, sc := range featureScenarios {
t.Run(sc.name, func(t *testing.T) {
for _, sh := range harness.Shells {
t.Run(sh.Name, func(t *testing.T) {
if reason, skip := sc.skips[sh.Name]; skip {
t.Skip(reason)
}
cfgPath := harness.WriteConfig(t, sc.overlays...)
session := harness.Start(t, sh, cfgPath)
session.WaitForPrompt()
sc.run(t, sh, session)
})
}
})
}
}
+19
View File
@@ -0,0 +1,19 @@
module github.com/jandedobbeleer/oh-my-posh/e2e
go 1.26.0
require (
github.com/aymanbagabas/go-pty v0.2.3
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
github.com/stretchr/testify v1.11.1
)
require (
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/u-root/u-root v0.16.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/aymanbagabas/go-pty v0.2.3 h1:hsqcTIUV8I4iTSh3HQl61CR2wh0YPS6gHOYLhAfWu/E=
github.com/aymanbagabas/go-pty v0.2.3/go.mod h1:GLkgQovzqN5A1xMB79yHWiG1rhcquZCjkwKQGKFPdPg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8=
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g=
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY=
github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8=
github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+106
View File
@@ -0,0 +1,106 @@
// Package harness provides shared helpers for the oh-my-posh shell end-to-end tests:
// building the omp binary once per test run, generating configs and init scripts, and
// (in later layers) driving interactive shell sessions in a pty.
package harness
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"testing"
)
// errCallerInfo is returned when the Go runtime cannot report the location of this
// source file, which should never happen in practice.
var errCallerInfo = errors.New("could not determine source file location")
// srcDir resolves ../src relative to this source file, not the test binary's working
// directory, so Binary works no matter which package's test invokes it.
func srcDir() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", errCallerInfo
}
// this file lives in e2e/harness/binary.go, so src is two levels up.
return filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..", "src"))
}
var (
buildOnce sync.Once
binPath string
buildErr error
)
// Binary returns the absolute path to the oh-my-posh executable used by the e2e tests.
// It builds ../src once per test run (guarded by sync.Once) into a temp directory. Set
// OMP_E2E_BINARY to skip the build and use a prebuilt binary instead. The test is failed
// with the build output if the build fails.
func Binary(t *testing.T) string {
t.Helper()
if override := os.Getenv("OMP_E2E_BINARY"); override != "" {
abs, err := filepath.Abs(override)
if err != nil {
t.Fatalf("resolving OMP_E2E_BINARY %q: %v", override, err)
}
return abs
}
buildOnce.Do(func() {
binPath, buildErr = buildBinary()
})
if buildErr != nil {
t.Fatalf("building omp binary: %v", buildErr)
}
return binPath
}
func buildBinary() (string, error) {
outDir, err := os.MkdirTemp("", "omp-e2e-bin")
if err != nil {
return "", err
}
name := "oh-my-posh"
if runtime.GOOS == "windows" {
name += ".exe"
}
out := filepath.Join(outDir, name)
src, err := srcDir()
if err != nil {
return "", err
}
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Dir = src
output, err := cmd.CombinedOutput()
if err != nil {
return "", &buildError{output: string(output), err: err}
}
return out, nil
}
// buildError wraps a failed build's combined output so callers can surface it verbatim.
type buildError struct {
err error
output string
}
func (e *buildError) Error() string {
return e.err.Error() + "\n" + e.output
}
func (e *buildError) Unwrap() error {
return e.err
}
+136
View File
@@ -0,0 +1,136 @@
package harness
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// Overlay mutates a base config map to enable a specific feature. Overlays are combined
// by applying them in order to the same base map.
type Overlay func(cfg map[string]any)
// BaseConfig returns a fresh, deterministic v4 config as a map[string]any: no git/time/
// network segments, a single left-aligned prompt block whose template renders the last
// exit code as "E2E:<code>>". Callers may apply Overlay functions to enable additional
// features before marshaling with WriteConfig.
func BaseConfig() map[string]any {
return map[string]any{
"version": 4,
"final_space": true,
"upgrade": map[string]any{
"notice": false,
"auto": false,
},
"blocks": []any{
map[string]any{
"type": "prompt",
"alignment": "left",
"segments": []any{
map[string]any{
"type": "text",
"style": "plain",
"template": "E2E:{{ .Code }}>",
},
},
},
},
}
}
// Transient enables the transient prompt feature. It replaces the primary prompt with
// "TR> " once a command has been accepted.
func Transient(cfg map[string]any) {
cfg["transient_prompt"] = map[string]any{
"type": "text",
"style": "plain",
"template": "TR> ",
}
}
// RPrompt appends a right-aligned prompt block rendering the fixed marker "RMARK".
func RPrompt(cfg map[string]any) {
blocks, _ := cfg["blocks"].([]any)
blocks = append(blocks, map[string]any{
"type": "rprompt",
"segments": []any{
map[string]any{
"type": "text",
"style": "plain",
"template": "RMARK",
},
},
})
cfg["blocks"] = blocks
}
// Tooltips enables a single tooltip segment rendering the fixed marker "TIP" for the
// "git" tip word.
func Tooltips(cfg map[string]any) {
cfg["tooltips"] = []any{
map[string]any{
"type": "text",
"style": "plain",
"template": "TIP",
"tips": []any{"git"},
},
}
}
// Full combines Transient, RPrompt and Tooltips.
func Full(cfg map[string]any) {
Transient(cfg)
RPrompt(cfg)
Tooltips(cfg)
}
// Colored appends a second text segment to the primary prompt block, rendering the
// fixed marker "CLR" in foreground "#ff0000" on background "#0000ff", for asserting
// rendered screen-cell colors.
func Colored(cfg map[string]any) {
blocks, _ := cfg["blocks"].([]any)
block, _ := blocks[0].(map[string]any)
segments, _ := block["segments"].([]any)
segments = append(segments, map[string]any{
"type": "text",
"style": "plain",
"template": "CLR",
"foreground": "#ff0000",
"background": "#0000ff",
})
block["segments"] = segments
}
// ShellIntegration enables shell_integration at the config root, which turns on FTCS
// (Final Term Control Sequence) marks around prompt rendering and command execution.
func ShellIntegration(cfg map[string]any) {
cfg["shell_integration"] = true
}
// WriteConfig builds a config from BaseConfig with the given overlays applied, in order,
// marshals it to JSON, writes it to a file in t.TempDir(), and returns the absolute path.
func WriteConfig(t *testing.T, overlays ...Overlay) string {
t.Helper()
cfg := BaseConfig()
for _, overlay := range overlays {
overlay(cfg)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshaling config: %v", err)
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}
+43
View File
@@ -0,0 +1,43 @@
package harness
import (
"os"
"strings"
"testing"
)
// requireEnvVar names the environment variable that lists shells the current run must not
// silently skip. See SkipUnavailable.
const requireEnvVar = "OMP_E2E_REQUIRE"
// SkipUnavailable skips the current test with reason, unless shellName is listed in the
// comma-separated OMP_E2E_REQUIRE environment variable, in which case it fails the test
// instead. CI sets OMP_E2E_REQUIRE to the shells it installs, so a shell that's supposed to
// be present but isn't found (or isn't supported on the host) turns into a hard failure
// instead of a quiet skip.
func SkipUnavailable(t *testing.T, shellName, reason string) {
t.Helper()
if required(shellName) {
t.Fatalf("shell %s is required by %s but unavailable: %s", shellName, requireEnvVar, reason)
}
t.Skip(reason)
}
// required reports whether shellName (case-insensitive) appears in the comma-separated
// OMP_E2E_REQUIRE environment variable.
func required(shellName string) bool {
list := os.Getenv(requireEnvVar)
if list == "" {
return false
}
for name := range strings.SplitSeq(list, ",") {
if strings.EqualFold(strings.TrimSpace(name), shellName) {
return true
}
}
return false
}
+54
View File
@@ -0,0 +1,54 @@
package harness
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// scriptExtensions maps an omp shell name to the file extension its init script should
// be written with, so shell parsers that dispatch on extension behave correctly.
var scriptExtensions = map[string]string{
"bash": ".sh",
"zsh": ".zsh",
"fish": ".fish",
"pwsh": ".ps1",
"nu": ".nu",
}
// InitScript runs `<omp> init <shellName> --config <cfgPath> --print` with OMP_CACHE_DIR
// pointed at a fresh t.TempDir(), and returns the printed init script text.
func InitScript(t *testing.T, shellName, cfgPath string) string {
t.Helper()
bin := Binary(t)
cmd := exec.Command(bin, "init", shellName, "--config", cfgPath, "--print")
cmd.Env = append(os.Environ(), "OMP_CACHE_DIR="+t.TempDir())
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("omp init %s --config %s --print failed: %v\n%s", shellName, cfgPath, err, output)
}
return string(output)
}
// WriteScript writes script to a temp file named with the extension appropriate for
// shellName (e.g. ".sh" for bash, ".ps1" for pwsh) and returns the absolute path.
func WriteScript(t *testing.T, shellName, script string) string {
t.Helper()
ext, ok := scriptExtensions[shellName]
if !ok {
t.Fatalf("no script extension known for shell %q", shellName)
}
path := filepath.Join(t.TempDir(), "init"+ext)
if err := os.WriteFile(path, []byte(script), 0o644); err != nil {
t.Fatalf("writing init script: %v", err)
}
return path
}
+352
View File
@@ -0,0 +1,352 @@
package harness
import (
"bytes"
"fmt"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/aymanbagabas/go-pty"
"github.com/hinshun/vt10x"
)
// promptRegexp matches the base config's prompt template "E2E:<exit code>>", used by
// WaitForPrompt to detect that a shell session is ready for input.
var promptRegexp = regexp.MustCompile(`E2E:\d+>`)
const (
ptyCols = 120
ptyRows = 30
waitForPollInterval = 50 * time.Millisecond
waitForDeadline = 30 * time.Second
exitDeadline = 15 * time.Second
)
// Session drives one interactive shell process in a pseudo-terminal. A single goroutine
// (started by Start) reads pty output and feeds it to both a vt10x terminal, for rendered-
// screen assertions, and a raw strings.Builder, for escape-sequence assertions; both are
// guarded by mu.
type Session struct {
t *testing.T
pty pty.Pty
cmd *pty.Cmd
term vt10x.Terminal
mu sync.Mutex
raw strings.Builder
writeMu sync.Mutex
waitMu sync.Mutex
waitStarted bool
}
// Start launches sh interactively with cfgPath as its oh-my-posh config, in a 120x30 pty.
// It skips the test if sh's binary is missing or unsupported on the current platform,
// otherwise it fails the test on any setup error - unless sh.Name is listed in
// OMP_E2E_REQUIRE, in which case an unavailable shell fails the test instead (see
// SkipUnavailable). The returned Session's process and pty are killed and closed, and the
// process reaped, via t.Cleanup.
func Start(t *testing.T, sh ShellDef, cfgPath string) *Session {
t.Helper()
if !sh.SupportedOnHost() {
SkipUnavailable(t, sh.Name, fmt.Sprintf("%s is not supported on this platform, skipping", sh.Name))
}
binPath, err := LookupShellBinary(sh.Binary)
if err != nil {
SkipUnavailable(t, sh.Name, fmt.Sprintf("%s not found on PATH, skipping", sh.Binary))
}
workDir := t.TempDir()
script := InitScript(t, sh.Name, cfgPath)
scriptPath := WriteScript(t, sh.Name, script)
_, args, env := sh.Launch(t, scriptPath, workDir)
env = append(env, "TERM=xterm-256color", "OMP_CACHE_DIR="+t.TempDir())
p, err := pty.New()
if err != nil {
t.Fatalf("creating pty: %v", err)
}
if err := p.Resize(ptyCols, ptyRows); err != nil {
_ = p.Close()
t.Fatalf("resizing pty: %v", err)
}
cmd := p.Command(binPath, args...)
cmd.Dir = workDir
cmd.Env = env
if err := cmd.Start(); err != nil {
_ = p.Close()
t.Fatalf("starting %s: %v", sh.Name, err)
}
s := &Session{
t: t,
pty: p,
cmd: cmd,
term: vt10x.New(vt10x.WithSize(ptyCols, ptyRows)),
}
go s.read()
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = p.Close()
// Only reap here if ExpectExit never claimed that responsibility: on its timeout
// path ExpectExit's own pending cmd.Wait() goroutine is released by the Kill above
// and reaps the process, so waiting again here would race a second Wait call
// against it.
if s.takeWaitOwnership() {
_ = cmd.Wait()
}
})
return s
}
// takeWaitOwnership reports whether the caller is the first to claim responsibility for
// reaping the process via cmd.Wait(), so exactly one of ExpectExit or the Cleanup
// registered by Start ever calls it. Later callers get false and must not call Wait.
func (s *Session) takeWaitOwnership() bool {
s.waitMu.Lock()
defer s.waitMu.Unlock()
if s.waitStarted {
return false
}
s.waitStarted = true
return true
}
// dsrRequest is the Device Status Report cursor-position query (CSI 6n). PSReadLine on
// Linux issues it repeatedly and blocks on the reply; ConPTY answers it internally on
// Windows, but on a raw Unix pty the "terminal" — this harness — must respond itself.
var dsrRequest = []byte("\x1b[6n")
// read copies pty output into both the vt10x terminal and the raw buffer until the pty is
// closed, answering DSR cursor-position queries along the way. It is the session's single
// reader goroutine.
func (s *Session) read() {
buf := make([]byte, 4096)
// carry holds a tail of the previous chunk that might be the start of a query split
// across two reads; see splitDSRQueries.
var carry []byte
for {
n, err := s.pty.Read(buf)
if n == 0 && err == nil {
// io.Reader permits returning (0, nil); without this backstop such a reader
// would spin this loop at 100% CPU. This is a defensive backstop, not a
// pacing mechanism - real ptys never take this path.
time.Sleep(time.Millisecond)
continue
}
if n > 0 {
window := make([]byte, 0, len(carry)+n)
window = append(window, carry...)
window = append(window, buf[:n]...)
queries, rest, newCarry := splitDSRQueries(window)
for _, query := range queries {
s.mu.Lock()
s.raw.Write(query)
_, _ = s.term.Write(query)
cursor := s.term.Cursor()
s.mu.Unlock()
s.writeMu.Lock()
_, _ = fmt.Fprintf(s.pty, "\x1b[%d;%dR", cursor.Y+1, cursor.X+1)
s.writeMu.Unlock()
}
if len(rest) > 0 {
s.mu.Lock()
s.raw.Write(rest)
_, _ = s.term.Write(rest)
s.mu.Unlock()
}
carry = append([]byte(nil), newCarry...)
}
if err != nil {
return
}
}
}
// splitDSRQueries scans data - the bytes carried over from the previous read plus the
// newly read chunk - for CSI 6n cursor-position queries (dsrRequest). It returns, in
// order, one entry per complete query found (each holding every byte since the end of the
// previous query through the end of this one), the non-query bytes following the last
// query, and a carry: a suffix of that remainder which is a proper prefix of dsrRequest,
// held back because it might complete a query split across the next read. A remainder that
// is not a genuine prefix of dsrRequest is never held back, so bytes that could not
// possibly become a query are never stalled.
func splitDSRQueries(data []byte) (queries [][]byte, rest, carry []byte) {
start := 0
for {
idx := bytes.Index(data[start:], dsrRequest)
if idx < 0 {
break
}
end := start + idx + len(dsrRequest)
queries = append(queries, data[start:end])
start = end
}
remainder := data[start:]
carryLen := dsrPrefixLen(remainder)
return queries, remainder[:len(remainder)-carryLen], remainder[len(remainder)-carryLen:]
}
// dsrPrefixLen returns the length of the longest suffix of data that is also a proper
// (non-full) prefix of dsrRequest, or 0 if data has no such suffix.
func dsrPrefixLen(data []byte) int {
maxLen := min(len(data), len(dsrRequest)-1)
for l := maxLen; l > 0; l-- {
if bytes.Equal(data[len(data)-l:], dsrRequest[:l]) {
return l
}
}
return 0
}
// Screen returns the current rendered terminal screen as text.
func (s *Session) Screen() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.term.String()
}
// ScreenLines returns the current rendered terminal screen split into its individual
// rows, each padded with trailing spaces to the pty's column width. Use this over Screen
// when an assertion needs to reason about a single row (e.g. what shares a line with the
// prompt, or how far right some text is rendered).
func (s *Session) ScreenLines() []string {
return strings.Split(s.Screen(), "\n")
}
// Raw returns the raw bytes captured from the pty so far, as a string.
func (s *Session) Raw() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.raw.String()
}
// WaitFor polls Screen every 50ms until it matches re, up to a 30s deadline. It returns
// the matching screen text, or fails the test with the full rendered screen and the tail
// of the raw output if the deadline elapses first.
func (s *Session) WaitFor(re *regexp.Regexp) string {
s.t.Helper()
deadline := time.Now().Add(waitForDeadline)
for time.Now().Before(deadline) {
screen := s.Screen()
if re.MatchString(screen) {
return screen
}
time.Sleep(waitForPollInterval)
}
s.t.Fatalf("timed out waiting for %q\n--- screen ---\n%s\n--- raw tail ---\n%s",
re.String(), s.Screen(), tail(s.Raw(), 2000))
return ""
}
// tail returns the last n bytes of s, or s unchanged if it is shorter than n.
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return s[len(s)-n:]
}
// WaitForPrompt waits for the "E2E:<code>>" prompt marker to appear on screen.
func (s *Session) WaitForPrompt() string {
return s.WaitFor(promptRegexp)
}
// MarkerColor locates the first occurrence of marker on the rendered screen and returns
// the vt10x foreground/background color of its first cell (found via the terminal's
// per-cell Cell(x, y) accessor, x=column, y=row). found is false if marker is not
// present anywhere on screen. Callers needing a marker that hasn't rendered yet should
// WaitFor it first; byte and rune offsets coincide here because every marker and
// everything rendered ahead of it in these tests is ASCII.
func (s *Session) MarkerColor(marker string) (fg, bg vt10x.Color, found bool) {
s.mu.Lock()
defer s.mu.Unlock()
for y, line := range strings.Split(s.term.String(), "\n") {
x := strings.Index(line, marker)
if x < 0 {
continue
}
glyph := s.term.Cell(x, y)
return glyph.FG, glyph.BG, true
}
return 0, 0, false
}
// SendLine writes cmd followed by a carriage return to the pty, as if typed interactively.
func (s *Session) SendLine(cmd string) {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, _ = s.pty.Write([]byte(cmd + "\r"))
}
// ExpectExit sends "exit\r" and waits up to 15s for the process to terminate. It fails the
// test (killing the process first) if the process does not exit in time. Call it at most
// once per session: it claims ownership of reaping the process via cmd.Wait(), so the
// Cleanup registered by Start knows not to wait a second time. On the timeout path, the
// Kill releases the pending Wait goroutine started below, which still reaps the process;
// Cleanup only kills and closes the pty at that point.
func (s *Session) ExpectExit() {
s.t.Helper()
s.SendLine("exit")
done := make(chan error, 1)
if s.takeWaitOwnership() {
go func() { done <- s.cmd.Wait() }()
}
select {
case <-done:
return
case <-time.After(exitDeadline):
_ = s.cmd.Process.Kill()
s.t.Fatalf("shell did not exit within %s\n--- screen ---\n%s", exitDeadline, s.Screen())
}
}
+160
View File
@@ -0,0 +1,160 @@
package harness
import (
"testing"
"github.com/stretchr/testify/assert"
)
// stringsOf converts a [][]byte, as returned by splitDSRQueries, into []string for easier
// assertions.
func stringsOf(bs [][]byte) []string {
if bs == nil {
return nil
}
out := make([]string, len(bs))
for i, b := range bs {
out[i] = string(b)
}
return out
}
// TestSplitDSRQueries covers splitDSRQueries given a single, already-complete window (no
// carry involved from a previous read). See TestSplitDSRQueriesAcrossChunks for the case
// where a query is split across two pty reads.
func TestSplitDSRQueries(t *testing.T) {
cases := []struct {
Case string
Data string
ExpectedQueries []string
ExpectedRest string
ExpectedCarry string
}{
{
Case: "no query",
Data: "plain output, nothing special",
ExpectedQueries: nil,
ExpectedRest: "plain output, nothing special",
ExpectedCarry: "",
},
{
Case: "one query mid-chunk with distinct output before and after",
Data: "before-text\x1b[6nafter-text",
ExpectedQueries: []string{"before-text\x1b[6n"},
ExpectedRest: "after-text",
ExpectedCarry: "",
},
{
Case: "multiple queries in one chunk",
Data: "A\x1b[6nB\x1b[6nC",
ExpectedQueries: []string{"A\x1b[6n", "B\x1b[6n"},
ExpectedRest: "C",
ExpectedCarry: "",
},
{
Case: "trailing 3-byte prefix held back",
Data: "trailing-text\x1b[6",
ExpectedQueries: nil,
ExpectedRest: "trailing-text",
ExpectedCarry: "\x1b[6",
},
{
Case: "trailing 2-byte prefix held back",
Data: "trailing-text\x1b[",
ExpectedQueries: nil,
ExpectedRest: "trailing-text",
ExpectedCarry: "\x1b[",
},
{
Case: "trailing 1-byte prefix held back",
Data: "trailing-text\x1b",
ExpectedQueries: nil,
ExpectedRest: "trailing-text",
ExpectedCarry: "\x1b",
},
{
Case: "trailing non-prefix not held back",
Data: "trailing-text-abc",
ExpectedQueries: nil,
ExpectedRest: "trailing-text-abc",
ExpectedCarry: "",
},
}
for _, c := range cases {
t.Run(c.Case, func(t *testing.T) {
queries, rest, carry := splitDSRQueries([]byte(c.Data))
assert.Equal(t, c.ExpectedQueries, stringsOf(queries), "queries")
assert.Equal(t, c.ExpectedRest, string(rest), "rest")
assert.Equal(t, c.ExpectedCarry, string(carry), "carry")
})
}
}
// TestSplitDSRQueriesAcrossChunks drives splitDSRQueries twice per case, exactly as read()
// does: the carry from the first call is prefixed onto the second chunk before the second
// call. It covers every possible split point of a 4-byte dsrRequest ("\x1b[6n") across two
// reads: after 1, 2, and 3 bytes of the sequence have arrived.
func TestSplitDSRQueriesAcrossChunks(t *testing.T) {
cases := []struct {
Case string
Chunk1 string
Chunk2 string
ExpectedCarry1 string
ExpectedRest1 string
ExpectedQueries2 []string
ExpectedRest2 string
ExpectedCarry2 string
}{
{
Case: "split after 1 byte",
Chunk1: "before\x1b",
Chunk2: "[6nafter",
ExpectedCarry1: "\x1b",
ExpectedRest1: "before",
ExpectedQueries2: []string{"\x1b[6n"},
ExpectedRest2: "after",
ExpectedCarry2: "",
},
{
Case: "split after 2 bytes",
Chunk1: "before\x1b[",
Chunk2: "6nafter",
ExpectedCarry1: "\x1b[",
ExpectedRest1: "before",
ExpectedQueries2: []string{"\x1b[6n"},
ExpectedRest2: "after",
ExpectedCarry2: "",
},
{
Case: "split after 3 bytes",
Chunk1: "before\x1b[6",
Chunk2: "nafter",
ExpectedCarry1: "\x1b[6",
ExpectedRest1: "before",
ExpectedQueries2: []string{"\x1b[6n"},
ExpectedRest2: "after",
ExpectedCarry2: "",
},
}
for _, c := range cases {
t.Run(c.Case, func(t *testing.T) {
queries1, rest1, carry1 := splitDSRQueries([]byte(c.Chunk1))
assert.Empty(t, queries1, "no complete query expected after the first chunk")
assert.Equal(t, c.ExpectedRest1, string(rest1), "rest after chunk 1")
assert.Equal(t, c.ExpectedCarry1, string(carry1), "carry after chunk 1")
window2 := append(append([]byte(nil), carry1...), []byte(c.Chunk2)...)
queries2, rest2, carry2 := splitDSRQueries(window2)
assert.Equal(t, c.ExpectedQueries2, stringsOf(queries2), "queries after chunk 2")
assert.Equal(t, c.ExpectedRest2, string(rest2), "rest after chunk 2")
assert.Equal(t, c.ExpectedCarry2, string(carry2), "carry after chunk 2")
})
}
}
+240
View File
@@ -0,0 +1,240 @@
package harness
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// ShellDef describes one of the shells the e2e suite exercises: how to find its
// executable, how to check a generated init script's syntax without running it, and (from
// the smoke/feature test layers, added in later tasks) how to launch it interactively and
// which command reliably fails.
type ShellDef struct {
// Name is the omp shell name passed to `oh-my-posh init <Name>`.
Name string
// Binary is the executable name looked up via LookupShellBinary. A missing binary
// means the test for this shell should be skipped.
Binary string
// SyntaxCheck returns a *exec.Cmd that parses scriptPath without executing it. A
// non-zero exit code means a syntax error was found.
SyntaxCheck func(scriptPath string) *exec.Cmd
// Launch is populated by the smoke/feature test layers (tasks 2-3): it returns the
// command, arguments and environment needed to boot the shell interactively with
// scriptPath sourced.
Launch func(t *testing.T, scriptPath, workDir string) (bin string, args []string, env []string)
// Fail is populated by the feature test layer (task 3): a command line that, typed
// interactively, reliably exits non-zero, paired with the exit code oh-my-posh should
// report in the following prompt.
Fail ExitCommand
}
// ExitCommand is a command line that reliably exits non-zero when typed interactively,
// together with the exit code the prompt is expected to report afterward (as
// "E2E:<Code>>").
type ExitCommand struct {
Command string
Code int
}
// platformExitCommand picks windows or unix depending on the current OS, so a ShellDef
// can declare one Fail command that differs between Windows and Unix-like hosts.
func platformExitCommand(windows, unix ExitCommand) ExitCommand {
if runtime.GOOS == "windows" {
return windows
}
return unix
}
// Shells is the per-shell definition table for the big five shells the e2e suite targets.
var Shells = []ShellDef{
{
Name: "bash",
Binary: "bash",
SyntaxCheck: func(scriptPath string) *exec.Cmd {
bin, err := LookupShellBinary("bash")
if err != nil {
bin = "bash"
}
return exec.Command(bin, "-n", scriptPath)
},
Launch: func(t *testing.T, scriptPath, workDir string) (string, []string, []string) {
rcPath := filepath.Join(workDir, "rc")
writeLaunchFile(t, rcPath, fmt.Sprintf("source '%s'\n", scriptPath))
return "bash", []string{"--noprofile", "--rcfile", rcPath, "-i"}, os.Environ()
},
Fail: ExitCommand{Command: "false", Code: 1},
},
{
Name: "zsh",
Binary: "zsh",
SyntaxCheck: func(scriptPath string) *exec.Cmd {
return exec.Command("zsh", "-n", scriptPath)
},
Launch: func(t *testing.T, scriptPath, workDir string) (string, []string, []string) {
writeLaunchFile(t, filepath.Join(workDir, ".zshrc"), fmt.Sprintf("source '%s'\n", scriptPath))
env := append(os.Environ(), "ZDOTDIR="+workDir)
return "zsh", []string{"-d", "-i"}, env
},
Fail: ExitCommand{Command: "false", Code: 1},
},
{
Name: "fish",
Binary: "fish",
SyntaxCheck: func(scriptPath string) *exec.Cmd {
return exec.Command("fish", "--no-execute", scriptPath)
},
Launch: func(t *testing.T, scriptPath, workDir string) (string, []string, []string) {
configPath := filepath.Join(workDir, "fish", "config.fish")
writeLaunchFile(t, configPath, fmt.Sprintf("source '%s'\n", scriptPath))
env := append(os.Environ(), "XDG_CONFIG_HOME="+workDir)
return "fish", []string{"-i"}, env
},
Fail: ExitCommand{Command: "false", Code: 1},
},
{
Name: "pwsh",
Binary: "pwsh",
SyntaxCheck: func(scriptPath string) *exec.Cmd {
script := `$errs = $null; [System.Management.Automation.Language.Parser]::ParseFile('` +
scriptPath + `', [ref]$null, [ref]$errs) | Out-Null; exit $errs.Count`
return exec.Command("pwsh", "-NoProfile", "-NonInteractive", "-Command", script)
},
Launch: func(t *testing.T, scriptPath, workDir string) (string, []string, []string) {
return "pwsh", []string{"-NoLogo", "-NoProfile", "-NoExit", "-Command", fmt.Sprintf(". '%s'", scriptPath)}, os.Environ()
},
Fail: platformExitCommand(
ExitCommand{Command: `cmd /c "exit 42"`, Code: 42},
ExitCommand{Command: "/bin/false", Code: 1},
),
},
{
Name: "nu",
Binary: "nu",
SyntaxCheck: func(scriptPath string) *exec.Cmd {
script := `exit (if (nu-check '` + scriptPath + `') { 0 } else { 1 })`
return exec.Command("nu", "--no-config-file", "--commands", script)
},
Launch: func(t *testing.T, scriptPath, workDir string) (string, []string, []string) {
// nu's `source` requires forward slashes even on Windows.
sourcePath := filepath.ToSlash(scriptPath)
configPath := filepath.Join(workDir, "config.nu")
writeLaunchFile(t, configPath, fmt.Sprintf("source '%s'\n", sourcePath))
envConfigPath := filepath.Join(workDir, "env.nu")
writeLaunchFile(t, envConfigPath, "")
// nu autoloads every *.nu file under $nu.vendor-autoload-dirs unconditionally,
// regardless of --config/--env-config, and runs it after config.nu. On a
// machine that already has oh-my-posh's nu integration installed as a vendor
// autoload script (e.g. via its own `oh-my-posh init nu`), that script would
// otherwise load after ours and silently clobber our test PROMPT_COMMAND with
// the developer's real prompt. Redirecting XDG_DATA_HOME to an empty temp
// directory points the vendor-autoload lookup at a directory with nothing in
// it, isolating the session from whatever is installed on the host.
env := append(os.Environ(), "XDG_DATA_HOME="+filepath.Join(workDir, "xdg-data"))
return "nu", []string{"-i", "--config", configPath, "--env-config", envConfigPath}, env
},
Fail: platformExitCommand(
ExitCommand{Command: `^cmd /c "exit 42"`, Code: 42},
ExitCommand{Command: "^false", Code: 1},
),
},
}
// writeLaunchFile writes content to path, creating any missing parent directories, and
// fails the test on error.
func writeLaunchFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("creating directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writing %s: %v", path, err)
}
}
// SupportedOnHost reports whether sh can be launched interactively on the current
// platform. bash, zsh and fish depend on POSIX rc-file semantics and process/job-control
// behavior that Windows does not provide faithfully (msys bash and WSL shells under
// ConPTY are not representative of a real Linux/macOS session), so they are only
// exercised there. pwsh and nu run natively everywhere the e2e suite targets.
func (sh ShellDef) SupportedOnHost() bool {
switch sh.Name {
case "bash", "zsh", "fish":
return runtime.GOOS == "linux" || runtime.GOOS == "darwin"
default:
return true
}
}
// errBashNotFound is returned by LookupShellBinary when "bash" is requested on Windows
// and no usable Git for Windows installation can be found.
var errBashNotFound = errors.New("no usable bash executable found")
// LookupShellBinary resolves the absolute path to a shell's executable.
//
// For every shell except bash on Windows this is exactly exec.LookPath(name). Bash on
// Windows is special-cased: Windows also ships a WSL launcher named bash.exe under
// System32, and since PATH commonly lists System32 before Git for Windows' cmd directory,
// a plain exec.LookPath("bash") resolves to the WSL launcher. That launcher only
// understands Linux-style paths, but every script path this harness hands to a syntax
// checker is a Windows path (e.g. a t.TempDir() path) — invoking it fails with "No such
// file or directory" regardless of whether the script is valid. On Windows, "bash"
// therefore prefers Git for Windows' own bash.exe.
func LookupShellBinary(name string) (string, error) {
if name == "bash" && runtime.GOOS == "windows" {
return lookupGitBash()
}
return exec.LookPath(name)
}
func lookupGitBash() (string, error) {
if gitPath, err := exec.LookPath("git"); err == nil {
// git.exe lives in <gitRoot>\cmd; Git's own bash.exe ships at <gitRoot>\bin and is
// duplicated at <gitRoot>\usr\bin.
gitRoot := filepath.Dir(filepath.Dir(gitPath))
for _, rel := range []string{filepath.Join("bin", "bash.exe"), filepath.Join("usr", "bin", "bash.exe")} {
candidate := filepath.Join(gitRoot, rel)
if isFile(candidate) {
return candidate, nil
}
}
}
for _, candidate := range []string{
`C:\Program Files\Git\bin\bash.exe`,
`C:\Program Files\Git\usr\bin\bash.exe`,
} {
if isFile(candidate) {
return candidate, nil
}
}
return "", errBashNotFound
}
func isFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
+61
View File
@@ -0,0 +1,61 @@
package e2e
import (
"regexp"
"strings"
"testing"
"github.com/jandedobbeleer/oh-my-posh/e2e/harness"
)
// forbiddenScreenText lists substrings that must never appear on a healthy shell's
// rendered screen right after the prompt comes up: shell-not-found errors, missing-command
// errors, or the init script raising an error of its own. Each phrase is a specific failure
// wording rather than a bare "error", so a benign occurrence (a hostname, a banner) can't
// fail the test.
var forbiddenScreenText = []string{
"command not found",
"not recognized",
"unable to find",
"syntax error",
"parse error",
"failed to",
}
// aliveMarker is echoed back by every shell after the prompt is confirmed, proving the
// session actually accepts and runs interactive input.
const aliveMarker = "omp-e2e-alive"
var aliveMarkerRegexp = regexp.MustCompile(regexp.QuoteMeta(aliveMarker))
// TestSmoke boots every shell interactively with the base config, in a real pty, and
// asserts: the prompt renders with exit code 0, the screen carries none of the forbidden
// error strings, a typed command echoes back, and the shell exits cleanly. Shells whose
// binary is missing or that are unsupported on this platform (see ShellDef.
// SupportedOnHost) skip cleanly via harness.Start.
func TestSmoke(t *testing.T) {
for _, sh := range harness.Shells {
t.Run(sh.Name, func(t *testing.T) {
cfgPath := harness.WriteConfig(t)
session := harness.Start(t, sh, cfgPath)
screen := session.WaitForPrompt()
if !strings.Contains(screen, "E2E:0>") {
t.Fatalf("%s: prompt screen missing \"E2E:0>\":\n%s", sh.Name, screen)
}
lower := strings.ToLower(screen)
for _, forbidden := range forbiddenScreenText {
if strings.Contains(lower, forbidden) {
t.Fatalf("%s: prompt screen contains forbidden text %q:\n%s", sh.Name, forbidden, screen)
}
}
session.SendLine("echo " + aliveMarker)
session.WaitFor(aliveMarkerRegexp)
session.ExpectExit()
})
}
}
+84
View File
@@ -0,0 +1,84 @@
// Package e2e contains layer 1 (syntax), layer 2 (smoke) and layer 3 (behavior)
// end-to-end tests for the oh-my-posh shell integrations.
package e2e
import (
"fmt"
"strings"
"testing"
"github.com/jandedobbeleer/oh-my-posh/e2e/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// placeholders lists template tokens that must never survive into a generated init
// script; their presence means rendering silently failed to substitute a value.
var placeholders = []string{"::OMP::", "::CONFIG::", "::SESSION_ID::"}
// overlaySets is the feature-config matrix layer 1 runs against every shell: the base
// config alone, and each documented overlay (or combination) applied on top of it.
var overlaySets = []struct {
Name string
Overlays []harness.Overlay
}{
{Name: "base"},
{Name: "transient", Overlays: []harness.Overlay{harness.Transient}},
{Name: "rprompt", Overlays: []harness.Overlay{harness.RPrompt}},
{Name: "tooltips", Overlays: []harness.Overlay{harness.Tooltips}},
{Name: "shell_integration", Overlays: []harness.Overlay{harness.ShellIntegration}},
{Name: "full", Overlays: []harness.Overlay{harness.Full}},
}
// TestSyntax generates the init script for every shell x config-overlay combination and
// validates it with the shell's own parser. The non-empty/no-placeholder assertions run
// unconditionally; the parser check only runs when the shell binary is available and
// skips cleanly otherwise.
func TestSyntax(t *testing.T) {
for _, sh := range harness.Shells {
for _, overlay := range overlaySets {
t.Run(sh.Name+"/"+overlay.Name, func(t *testing.T) {
cfgPath := harness.WriteConfig(t, overlay.Overlays...)
script := harness.InitScript(t, sh.Name, cfgPath)
require.NotEmpty(t, script, "init script for %s must not be empty", sh.Name)
for _, placeholder := range placeholders {
assert.NotContains(t, script, placeholder, "leftover placeholder in %s init script", sh.Name)
}
if _, err := harness.LookupShellBinary(sh.Binary); err != nil {
harness.SkipUnavailable(t, sh.Name, fmt.Sprintf("%s not found on PATH, skipping syntax check", sh.Binary))
}
scriptPath := harness.WriteScript(t, sh.Name, script)
cmd := sh.SyntaxCheck(scriptPath)
output, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "syntax check failed for %s:\n%s", sh.Name, output)
})
}
}
}
// TestTransientPromptOverlay verifies the transient_prompt overlay's shape against a real
// omp invocation: config.Config.TransientPrompt is a *Segment, so the overlay must marshal
// to an object (not a string), and enabling it must actually change the generated pwsh
// script versus the base config.
func TestTransientPromptOverlay(t *testing.T) {
baseCfg := harness.WriteConfig(t)
transientCfg := harness.WriteConfig(t, harness.Transient)
baseScript := harness.InitScript(t, "pwsh", baseCfg)
transientScript := harness.InitScript(t, "pwsh", transientCfg)
require.NotEmpty(t, baseScript)
require.NotEmpty(t, transientScript)
assert.NotEqual(t, baseScript, transientScript, "transient overlay should change the generated script")
const transientMarker = "$global:_ompTransientPrompt = $true"
assert.True(t, strings.Contains(transientScript, transientMarker), "transient script missing %q", transientMarker)
assert.False(t, strings.Contains(baseScript, transientMarker), "base script unexpectedly contains %q", transientMarker)
}