From c30c905b613d1e43731d47762c1f902626956614 Mon Sep 17 00:00:00 2001 From: "meza(codex)" <211977+meza@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:07:47 +0100 Subject: [PATCH] feat: add Go support --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 35 +- bindings/go/CONTRIBUTING.md | 81 ++ bindings/go/README.md | 238 +++++ bindings/go/client.go | 356 +++++++ bindings/go/engine.go | 309 ++++++ bindings/go/examples/smoke/main.go | 66 ++ bindings/go/go.mod | 5 + bindings/go/go.sum | 2 + bindings/go/input.go | 27 + bindings/go/integration_test.go | 447 +++++++++ bindings/go/internal/native/abi.go | 222 +++++ bindings/go/internal/native/functions.go | 140 +++ bindings/go/internal/native/input.go | 121 +++ bindings/go/internal/native/loader.go | 63 ++ bindings/go/internal/native/loader_test.go | 24 + bindings/go/internal/native/loader_unix.go | 32 + bindings/go/internal/native/loader_windows.go | 29 + bindings/go/internal/native/memory.go | 63 ++ bindings/go/internal/native/models.go | 325 +++++++ bindings/go/internal/native/results.go | 142 +++ bindings/go/internal/native/results_test.go | 42 + bindings/go/internal/native/session.go | 586 +++++++++++ bindings/go/locator.go | 278 ++++++ bindings/go/native/Cargo.toml | 17 + bindings/go/native/src/input.rs | 299 ++++++ bindings/go/native/src/lib.rs | 908 ++++++++++++++++++ bindings/go/native/src/output.rs | 298 ++++++ bindings/go/native/src/tests.rs | 184 ++++ bindings/go/native/src/types.rs | 249 +++++ bindings/go/native_pointer_test.go | 35 + bindings/go/spawn_test.go | 122 +++ bindings/go/tuitesttest/terminal.go | 61 ++ bindings/go/tuitesttest/terminal_test.go | 83 ++ bindings/go/types.go | 366 +++++++ 36 files changed, 6263 insertions(+), 2 deletions(-) create mode 100644 bindings/go/CONTRIBUTING.md create mode 100644 bindings/go/README.md create mode 100644 bindings/go/client.go create mode 100644 bindings/go/engine.go create mode 100644 bindings/go/examples/smoke/main.go create mode 100644 bindings/go/go.mod create mode 100644 bindings/go/go.sum create mode 100644 bindings/go/input.go create mode 100644 bindings/go/integration_test.go create mode 100644 bindings/go/internal/native/abi.go create mode 100644 bindings/go/internal/native/functions.go create mode 100644 bindings/go/internal/native/input.go create mode 100644 bindings/go/internal/native/loader.go create mode 100644 bindings/go/internal/native/loader_test.go create mode 100644 bindings/go/internal/native/loader_unix.go create mode 100644 bindings/go/internal/native/loader_windows.go create mode 100644 bindings/go/internal/native/memory.go create mode 100644 bindings/go/internal/native/models.go create mode 100644 bindings/go/internal/native/results.go create mode 100644 bindings/go/internal/native/results_test.go create mode 100644 bindings/go/internal/native/session.go create mode 100644 bindings/go/locator.go create mode 100644 bindings/go/native/Cargo.toml create mode 100644 bindings/go/native/src/input.rs create mode 100644 bindings/go/native/src/lib.rs create mode 100644 bindings/go/native/src/output.rs create mode 100644 bindings/go/native/src/tests.rs create mode 100644 bindings/go/native/src/types.rs create mode 100644 bindings/go/native_pointer_test.go create mode 100644 bindings/go/spawn_test.go create mode 100644 bindings/go/tuitesttest/terminal.go create mode 100644 bindings/go/tuitesttest/terminal_test.go create mode 100644 bindings/go/types.go diff --git a/Cargo.lock b/Cargo.lock index d73b66fc..9e60f5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2042,6 +2042,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tui-test-go" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tui-test-rs", +] + [[package]] name = "tui-test-node" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ac579ab3..a5017251 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "bindings/go/native", "bindings/js", "bindings/python/native", "crates/tui-test", diff --git a/README.md b/README.md index 702dfe3e..34a2b924 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tui-test -`tui-test` controls, inspects, tests, and records real shell sessions and full-screen terminal apps on Windows, Linux, and macOS. Use it from the CLI or call the same engine from Rust, Python, or JavaScript. It works for AI agents that need structured access to terminal state, terminal automation, and terminal ui application testing. +`tui-test` controls, inspects, tests, and records real shell sessions and full-screen terminal apps on Windows, Linux, and macOS. Use it from the CLI or call the same engine from Rust, Python, JavaScript, or Go. It works for AI agents that need structured access to terminal state, terminal automation, and terminal ui application testing.

Installation @@ -48,12 +48,13 @@ You can also download a binary from [GitHub Releases](https://github.com/microso | Rust 1.90+ | `cargo add tui-test-rs@0.1.0` | [docs.rs](https://docs.rs/tui-test-rs/latest/tui_test/) | | Python 3.8+ | `pip install tui-test` | [Python API](bindings/python/README.md) | | Node 20+ | `npm install @microsoft/tui-test` | [JavaScript API](bindings/js/README.md) | +| Go 1.26+ | Source build required; [installation steps](bindings/go/README.md#install) | [Go API](bindings/go/README.md) | Add the Rust `recording-raster` feature for APNG, GIF, and MP4 output. It uses installed fonts; `recording-font-jetbrains-mono*` bundles a font. ## Quick start -The CLI and libraries expose the same terminal actions. Python, JavaScript, and Rust sessions run in-process and do not require the CLI. +The CLI and libraries expose the same terminal actions. Python, JavaScript, Rust, and Go sessions run in-process and do not require the CLI. See [Go installation](bindings/go/README.md#install) for the Go binding's native build requirement. ### CLI @@ -122,6 +123,35 @@ fn main() -> Result<(), Box> { [Rust API reference](https://docs.rs/tui-test-rs/latest/tui_test/) +### Go + +```go +package main + +import ( + "log" + + "github.com/microsoft/tui-test/bindings/go" +) + +func main() { + terminal, err := tuitest.Ephemeral("example", tuitest.ClientOptions{}) + if err != nil { + log.Fatal(err) + } + defer terminal.CloseQuiet() + + if _, err := terminal.Run("my-app", nil, tuitest.SpawnOptions{}); err != nil { + log.Fatal(err) + } + if err := terminal.GetByText("Ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{}); err != nil { + log.Fatal(err) + } +} +``` + +[Go installation and API reference](bindings/go/README.md) + ## API references | Surface | Reference | @@ -130,6 +160,7 @@ fn main() -> Result<(), Box> { | Rust | [docs.rs](https://docs.rs/tui-test-rs/latest/tui_test/) | | Python | [bindings/python/README.md](bindings/python/README.md) | | JavaScript | [bindings/js/README.md](bindings/js/README.md) | +| Go | [bindings/go/README.md](bindings/go/README.md) | ## CLI reference diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md new file mode 100644 index 00000000..f0de70e0 --- /dev/null +++ b/bindings/go/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Building and maintaining the Go binding + +The [Go README](README.md) covers installation and application usage. This guide covers native builds and validation. + +## Architecture + +The public `tuitest` package at the module root owns clients, options, results, immutable locator queries, input helpers, lifecycle validation, and the typed error surface. Its `engine.go` adapter converts between public types and the internal native types. + +The `internal/native` package owns the C ABI, purego calls, native memory lifetimes, and library loading. It copies results into Go-owned values before releasing Rust allocations. Dependencies run from the public package into `internal/native`; the native package does not import the public package. + +The Rust adapter source stays in `native/` and delegates to the existing `tui-test` session registry and operations. Source builds load that adapter from the path in `TUI_TEST_GO_NATIVE_LIBRARY` and keep it loaded for the process lifetime. + +Terminal behavior, session synchronization, locator evaluation, assertions, diagnostics, traces, artifacts, and recording remain in the Rust engine. Go owns option ergonomics, query construction, error presentation, and test cleanup. The `tuitesttest` package reports the test result through failure-aware close so the engine can retain on-failure traces. Keep new terminal behavior in the engine so language bindings share it. + +The native library and Go module must come from the same source revision. The C ABI is private to this binding. Native allocations must be released by their matching Rust functions, and native code must not retain Go pointers. + +## Build from source + +Install Go 1.26 or newer, Rust 1.90 or newer, and Zig 0.16.0. Linux needs a native C/C++ compiler and linker, macOS needs Apple command-line build tools, and Windows amd64 needs the MSVC Build Tools and `x86_64-pc-windows-msvc` Rust toolchain. The [installation guide](README.md#install) lists the supported host OS and architecture combinations. + +From the repository root: + +```sh +cargo build --locked -p tui-test-go +``` + +The native adapter enables the same terminal backends and recording features as the JavaScript and Python bindings. Zig is required by the Ghostty dependency. + +Set `TUI_TEST_GO_NATIVE_LIBRARY` to the built library before running a Go application or test. For Linux: + +```sh +cd bindings/go +TUI_TEST_GO_NATIVE_LIBRARY=../../target/debug/libtui_test_go.so CGO_ENABLED=0 go test ./... +``` + +On macOS, run from `bindings/go`: + +```sh +TUI_TEST_GO_NATIVE_LIBRARY=../../target/debug/libtui_test_go.dylib CGO_ENABLED=0 go test ./... +``` + +On Windows amd64, run from the repository root: + +```powershell +Set-Location bindings/go +$env:TUI_TEST_GO_NATIVE_LIBRARY = (Resolve-Path ../../target/debug/tui_test_go.dll) +$env:CGO_ENABLED = '0' +go test ./... +``` + +For a release build, use `cargo build --release --locked -p tui-test-go` and point the environment variable at the library under `target/release`. + +To consume unpublished changes from another Go module, build the library as above, then add a local module replacement in the consuming project: + +```sh +go mod edit -replace github.com/microsoft/tui-test/bindings/go=/absolute/path/to/tui-test/bindings/go +go get github.com/microsoft/tui-test/bindings/go +``` + +## Validate changes + +After setting `TUI_TEST_GO_NATIVE_LIBRARY`, run these commands from `bindings/go`: + +```sh +gofmt -l . +go vet ./... +go test ./... +``` + +`gofmt -l .` should produce no output. Also run `go test -race ./...` with cgo enabled and a C compiler available for Go's race detector; the binding itself does not require cgo. + +From the repository root, also run the Rust checks: + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy -p tui-test-rs --all-targets --no-default-features -- -D warnings +cargo test --workspace -- --test-threads=1 +``` + +`TestCloseInterruptsPendingWait` is explicitly skipped for the accepted shared-runtime limitation in [issue #207](https://github.com/microsoft/tui-test/issues/207). Its regression body remains in place. Remove the skip and run the test when the upstream fix is incorporated. The `CloseAll` interruption test remains active. diff --git a/bindings/go/README.md b/bindings/go/README.md new file mode 100644 index 00000000..27dd82ba --- /dev/null +++ b/bindings/go/README.md @@ -0,0 +1,238 @@ +# tui-test for Go + +Control, inspect, test, and record terminal apps from Go. The binding runs the Rust engine in your process and does not require the CLI. + +## Install + +Install the Go binding from source. Keep the Go module and native library on one exact repository revision by using a local module replacement. + +Supported host builds are: + +| OS | Go architecture | Rust native target | Build prerequisites | +| --- | --- | --- | --- | +| Linux | `amd64` | `x86_64-unknown-linux-gnu` or `x86_64-unknown-linux-musl` | Go 1.26+, Rust 1.90+, Zig 0.16.0, and a native C/C++ compiler and linker | +| Linux | `arm64` | `aarch64-unknown-linux-gnu` or `aarch64-unknown-linux-musl` | Go 1.26+, Rust 1.90+, Zig 0.16.0, and a native C/C++ compiler and linker | +| macOS | `amd64` | `x86_64-apple-darwin` | Go 1.26+, Rust 1.90+, Zig 0.16.0, and Apple command-line build tools | +| macOS | `arm64` | `aarch64-apple-darwin` | Go 1.26+, Rust 1.90+, Zig 0.16.0, and Apple command-line build tools | +| Windows | `amd64` | `x86_64-pc-windows-msvc` | Go 1.26+, Rust 1.90+, Zig 0.16.0, and MSVC Build Tools | + +Use a host-native build. Cross-compilation also requires the destination Rust target and its linker or toolchain, and is not the recommended source-install path. + +1. Clone the repository, select an exact commit or tag that contains the Go binding, and record the resolved commit: + + ```sh + git clone https://github.com/microsoft/tui-test.git + cd tui-test + git checkout --detach + git rev-parse HEAD + ``` + +2. From that checkout's repository root, build the native library: + + ```sh + cargo build --locked -p tui-test-go + ``` + + The debug library is `target/debug/libtui_test_go.so` on Linux, `target/debug/libtui_test_go.dylib` on macOS, or `target/debug/tui_test_go.dll` on Windows. See [building and maintaining the Go binding](CONTRIBUTING.md#build-from-source) for release builds and platform-specific test commands. + +3. In the Go application module, point the module path at the same checkout, then add it: + + ```sh + go mod edit -replace github.com/microsoft/tui-test/bindings/go=/absolute/path/to/tui-test/bindings/go + go get github.com/microsoft/tui-test/bindings/go + ``` + + The replacement makes the selected checkout—not a separately resolved remote version—the source of the Go module. When changing revisions, check out the new revision, rebuild the library, and keep the replacement pointed at that checkout. + +4. Set `TUI_TEST_GO_NATIVE_LIBRARY` to the absolute path of the library before running the application. On Linux or macOS: + + ```sh + export TUI_TEST_GO_NATIVE_LIBRARY=/absolute/path/to/tui-test/target/debug/libtui_test_go.so + go run . + ``` + + Use the `.dylib` path shown above on macOS. On Windows PowerShell: + + ```powershell + $env:TUI_TEST_GO_NATIVE_LIBRARY = (Resolve-Path C:\path\to\tui-test\target\debug\tui_test_go.dll) + go run . + ``` + +The binding loads the configured engine on first use and keeps it loaded for the process lifetime. It does not download anything at runtime. Running the quick start below succeeds when its output contains `hello`. An empty `TUI_TEST_GO_NATIVE_LIBRARY`, an unloadable library, or a library from an incompatible revision returns an initialization error before the terminal opens. + +## Quick start + +```go +package main + +import ( + "fmt" + "log" + + "github.com/microsoft/tui-test/bindings/go" +) + +func main() { + terminal, err := tuitest.Ephemeral("example", tuitest.ClientOptions{}) + if err != nil { + log.Fatal(err) + } + defer terminal.CloseQuiet() + + if _, err := terminal.Open(tuitest.OpenOptions{}); err != nil { + log.Print(err) + return + } + if err := terminal.Submit(tuitest.Ptr("echo hello")); err != nil { + log.Print(err) + return + } + if err := terminal.WaitCommand(tuitest.WaitOptions{}); err != nil { + log.Print(err) + return + } + output, err := terminal.GetOutput() + if err != nil { + log.Print(err) + return + } + if output != nil { + fmt.Print(*output) + } +} +``` + +Use `Run(program, args, SpawnOptions{})` to launch an application directly. Use `Open(OpenOptions{})` when you need a shell and its command tracking. + +## Sessions and options + +`New(session, ClientOptions{})` creates a client for a named session. An empty name uses `TUI_TEST_SESSION`, falling back to `"default"`. `Ephemeral(prefix, options)` creates a unique name. Construction does not start a terminal; call `Open` or `Run`. + +Clients with the same name share the same in-process session. `Close` closes that session for every client. An existing client can open it again. `Restart` restarts the current launch; its optional `GracefulTimeout` defaults to 5 seconds. `Sessions` lists live sessions, and `CloseAll` closes them. When the effective automatic-recording mode is enabled, `OpenResult.Recording` contains its path and `Recording(session)` reads the current asciicast. After closure, `Recording` succeeds only if the recording was retained: always-mode recordings are retained, while on-failure recordings are retained only for failed sessions. When recording is disabled, `OpenResult.Recording` is empty and `Recording` returns an error. + +`ClientOptions` sets the backend, profile, timeouts, screen-history limit, automatic recording, failure artifacts, and traces. `SpawnOptions` controls dimensions, working directory, environment, readiness, restart, retries, and per-launch overrides. `OpenOptions` embeds `SpawnOptions` and adds `Shell`. + +The default backend is Alacritty. Supported backends are Alacritty, Ghostty, Rio, and xterm.js. The default size is 80 columns by 30 rows. + +Automatic recording is disabled when recording, trace, and recording-inclusive artifact options are all omitted. `ClientOptions.Recording` can select `RecordingAlways`, `RecordingOnFailure`, or `RecordingDisabled`. A supplied `ClientOptions.Trace` overrides that selection: `TraceOn` records always, `TraceOnFailure` records on failure, and `TraceOff` disables recording. When trace options are omitted, `Artifacts.IncludeRecording` promotes a disabled recording to on-failure recording. These options can persist terminal output, so select them deliberately. + +Optional pointer fields distinguish omission from an explicit value. For example: + +```go +options := tuitest.SpawnOptions{ + Cols: tuitest.Ptr(uint16(100)), + Rows: tuitest.Ptr(uint16(40)), + WaitReady: tuitest.Ptr(false), +} +``` + +Timeouts use `*time.Duration`. A nil timeout uses the client setting, then the engine default: 5 seconds for text and idle, or 30 seconds for command, exit, and ready. Explicit zero remains zero. Negative durations are rejected; positive fractions of a millisecond round upward. + +Methods block until their operation completes. Use goroutines for concurrent work. Operations on each session are serialized. This API does not accept contexts or promise per-call cancellation. + +`SpawnOptions.Retries` applies only to retryable launch failures. Usage errors are returned immediately, and each retryable attempt is closed before the next attempt starts. + +## API reference + +Use `go doc github.com/microsoft/tui-test/bindings/go` for exported type fields and signatures. Methods return ordinary Go errors; getters preserve unavailable values with pointers where applicable. + +| Capability | Methods | +| --- | --- | +| Construction and discovery | `New`, `Ephemeral`, `Ptr`, `Sessions`, `CloseAll`, `Recording` | +| Lifecycle | `Open`, `Run`, `Restart`, `Close`, `CloseQuiet`, `Session` | +| Input | `Type`, `Write`, `Submit`, `Press`, `Resize`, `Signal`, `Kill` | +| Keyboard | `Keyboard.Press`, `Down`, `Repeat`, `Up` | +| Mouse | `Mouse.Click`, `Move`, `Down`, `Up`, `Drag`, `Scroll` | +| Screen | `State`, `Text`, `Cells` | +| Command state | `GetCommand`, `GetOutput`, `GetExitCode`, `GetCwd` | +| Terminal state | `GetCursor`, `GetSize`, `GetTitle`, `GetClipboard`, `GetBellCount`, `GetBellEvents` | +| Waits | `WaitTitle`, `WaitClipboard`, `WaitIdle`, `WaitCommand`, `WaitExit`, `WaitReady`, `WaitBell` | +| Assertions | `ExpectTitle`, `ExpectExitCode`, `ExpectOutput`, `ExpectBellCount`, `ExpectSnapshot` | +| Capture | `Screenshot`, `StartRecording`, `StopRecording` | + +Use `WaitCommand` after submitting a shell command, and `WaitExit` after running a program directly. `WaitIdle` only establishes that the screen stopped changing. + +`State` returns the visible text and terminal dimensions together with command, process-exit, cursor, mode, mouse-mode, color, timeout, readiness, and bell state. `Cells` returns styles and OSC 8 link metadata through `Cell.Link` and `Cell.LinkID`. + +### Locators + +`GetByText`, `GetByStyle`, and `GetByLink` create lazy locators. Chaining returns a new locator and leaves the original unchanged. For example: + +```go +ready := terminal.GetByText("Ready", tuitest.TextSelectorOptions{}) +err := ready.Last().Expect(tuitest.LocatorExpectOptions{}) +``` + +Select matches with `Any`, `Unique`, `First`, `Last`, or `Nth`. Inspect them with `Locations`, `Location`, `Count`, or `All`. Use `Wait`, `Expect`, `Click`, and `Highlight` for actions. `Location` and `Click` require a unique match unless you select one explicitly. + +| Locator task | Methods | +| --- | --- | +| Create or refine | `GetByText`, `GetByStyle`, `GetByLink` | +| Compose | `And`, `Or`, `Filter` | +| Select | `Any`, `Unique`, `First`, `Last`, `Nth` | +| Inspect | `Locations`, `Location`, `Count`, `All` | +| Act or assert | `Wait`, `Expect`, `Click`, `Highlight` | + +Nested text, style, and link selectors support `Within`, `After`, and `Before` directions. Text selectors also support regular expressions, scrollback, and exact or normalized whitespace. Style pointer fields preserve explicit false values, such as `Bold: tuitest.Ptr(false)`. + +`GetByLink(uri, options)` matches an exact OSC 8 target rather than visible URL text; an empty URI requires cells with no link. Root style and link selectors find runs within each row. Chained selectors with the default `Within` direction test whole matches. Link selectors test every cell in a match. + +Compose locators with `And`, `Or`, and `Filter`: + +```go +label := terminal.GetByText("Documentation", tuitest.TextSelectorOptions{}) +link := terminal.GetByLink("https://example.com/docs", tuitest.LinkSelectorOptions{}) + +linkedLabel, err := label.And(link) +if err != nil { + log.Fatal(err) +} +container, err := label.Filter(tuitest.LocatorFilterOptions{Has: link}) +if err != nil { + log.Fatal(err) +} +``` + +`And` and `Or` form contiguous per-row cell runs. `Filter` preserves each input match: `Has` requires its locator inside the candidate, while `HasNot` requires no such match. Locators in a composition must belong to the same client. Composition leaves its operands unchanged and reads one fresh terminal snapshot when resolved. Apply `First`, `Last`, `Nth`, or `Unique` at the stage where selection should occur; selection before composition can produce a different result from selection afterward. If any branch sets `Full`, the whole query includes scrollback. + +### Errors and artifacts + +Use `errors.As` with `*tuitest.Error` to inspect `Kind`, `Message`, `Operation`, and optional diagnostics. Error kinds are `AssertionError`, `UsageError`, `NoSessionError`, and `InternalError`. Structured native failures expose `Details`; exported failure artifacts expose their paths and write status through `Artifact`. + +Configure `ClientOptions.Artifacts` with a directory and `ArtifactAll`, `ArtifactHTML`, `ArtifactText`, or `ArtifactNone`. `IncludeRecording` copies the automatic cast into the failure artifact. Failure to capture an optional artifact does not replace the assertion error. + +Configure `ClientOptions.Trace` with `TraceOn` to retain every session trace or `TraceOnFailure` to retain failed traces. Pass `CloseOptions{Failed: tuitest.Ptr(true)}` when closing a session whose failure happened outside a tui-test assertion so `TraceOnFailure` can retain it. The `tuitesttest` helper supplies this failure state during test cleanup. + +Trace and recording-inclusive artifact options also determine the effective automatic-recording mode as described in [sessions and options](#sessions-and-options). + +Trace and failure-artifact directories can contain terminal output, titles, locator operands, and recordings. Review them before uploading. Users can open `trace.html`; automated consumers should read `trace.md`, `trace.json`, and `timeline.json`. + +### Recording and snapshots + +`Screenshot` returns terminal text when its path is empty, or writes SVG or PNG when a path is supplied. `ScreenshotOptions` controls scrollback, zoom, background color, and transparency. Background color and transparency are mutually exclusive, and canvas options require an output path. + +`StartRecording` supports asciinema, APNG, GIF, and MP4. File extensions select the format unless `RecordingOptions.Format` overrides it. Recording options control FPS, speed, idle-time limit, zoom, background color, and transparency. Background color and transparency are mutually exclusive. MP4 requires `ffmpeg` on the executable search path and does not support transparency. `StopRecording` finishes the recording and returns its path. + +`ExpectSnapshot` returns `SnapshotPassed`, `SnapshotWritten`, or `SnapshotUpdated`. Set `SnapshotOptions.Update` only when you intend to update the baseline. Snapshot options also control style and title inclusion and the snapshot working directory. + +## Test cleanup + +The `tuitesttest` package creates unique sessions and registers cleanup before starting the terminal. It closes the session when the test ends, including after a fatal test failure. + +```go +func TestTerminal(t *testing.T) { + terminal := tuitesttest.New(t, tuitesttest.Options{}) + if err := terminal.Submit(tuitest.Ptr("echo hello")); err != nil { + t.Fatal(err) + } + if err := terminal.WaitCommand(tuitest.WaitOptions{}); err != nil { + t.Fatal(err) + } +} +``` + +Import `testing`, `github.com/microsoft/tui-test/bindings/go`, and `github.com/microsoft/tui-test/bindings/go/tuitesttest` in your test file. `tuitesttest.New` accepts client, spawn, shell, program, argument, and session-prefix settings through `tuitesttest.Options`. `ClientOptions.Artifacts` exports evidence when a tui-test assertion fails. `ClientOptions.Trace` with `TraceOnFailure` retains a trace when test cleanup reports that the test failed. `tuitesttest.TerminalSnapshot` removes trailing whitespace and blank lines for text comparisons. Each call has its own options, so parallel tests do not share mutable defaults. + +## Contributing + +See [building and testing the Go binding](CONTRIBUTING.md). diff --git a/bindings/go/client.go b/bindings/go/client.go new file mode 100644 index 00000000..1eec2fa3 --- /dev/null +++ b/bindings/go/client.go @@ -0,0 +1,356 @@ +package tuitest + +import ( + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Client is a named handle. Handles with the same name share a session and can +// be reused after that session closes. Its synchronous methods may run in goroutines. +type Client struct { + session string + options ClientOptions + runtime *nativeRuntime + Keyboard *Keyboard + Mouse *Mouse +} + +// New resolves an empty session from TUI_TEST_SESSION, then "default". +func New(session string, options ClientOptions) (*Client, error) { + if session == "" { + session = os.Getenv("TUI_TEST_SESSION") + } + if session == "" { + session = "default" + } + if err := validateTimeouts(options.Timeouts); err != nil { + return nil, err + } + if err := validateClientOptions(options); err != nil { + return nil, err + } + options = cloneClientOptions(options) + if options.Artifacts != nil { + if options.Artifacts.OnFailure == "" { + options.Artifacts.OnFailure = ArtifactAll + } + absoluteDirectory, err := filepath.Abs(options.Artifacts.Dir) + if err != nil { + return nil, &Error{Kind: UsageError, Message: "resolve artifact directory: " + err.Error()} + } + options.Artifacts.Dir = absoluteDirectory + } + if options.Trace != nil && options.Trace.Mode == "" { + options.Trace.Mode = TraceOff + } + runtime, err := newNativeRuntime(session, options) + if err != nil { + return nil, err + } + client := &Client{session: session, options: options, runtime: runtime} + client.Keyboard = &Keyboard{runtime: runtime} + client.Mouse = &Mouse{runtime: runtime} + return client, nil +} + +func validateClientOptions(options ClientOptions) error { + switch options.Backend { + case "", Alacritty, Ghostty, Rio, XtermJS: + default: + return &Error{Kind: UsageError, Message: "unknown backend: " + string(options.Backend)} + } + if options.Recording != nil { + switch options.Recording.Mode { + case "", RecordingDisabled, RecordingOnFailure, RecordingAlways: + default: + return &Error{Kind: UsageError, Message: "unknown recording mode: " + string(options.Recording.Mode)} + } + } + if options.Trace != nil { + switch options.Trace.Mode { + case "", TraceOff, TraceOnFailure, TraceOn: + default: + return &Error{Kind: UsageError, Message: "unknown trace mode: " + string(options.Trace.Mode)} + } + } + if options.ScreenHistoryLimit != nil && *options.ScreenHistoryLimit > 50 { + return &Error{Kind: UsageError, Message: "screen history limit must be at most 50"} + } + return validateArtifactOptions(options.Artifacts) +} +func validateArtifactOptions(options *ArtifactOptions) error { + if options != nil { + switch options.OnFailure { + case "", ArtifactAll, ArtifactHTML, ArtifactText, ArtifactNone: + default: + return &Error{Kind: UsageError, Message: "unknown artifact mode: " + string(options.OnFailure)} + } + if options.Dir == "" { + return &Error{Kind: UsageError, Message: "artifact directory must not be empty"} + } + } + return nil +} + +// Ephemeral creates a handle with a unique, filename-safe session name. +func Ephemeral(prefix string, options ClientOptions) (*Client, error) { + if prefix == "" { + prefix = "go" + } + prefix = strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_' { + return character + } + return '-' + }, prefix) + return New(prefix+"-"+rand.Text(), options) +} +func (client *Client) Session() string { return client.session } +func Sessions() ([]string, error) { return nativeSessions() } +func CloseAll() error { return nativeCloseAll() } +func Recording(session string) (string, error) { return nativeRecording(session) } + +func clonePointer[Value any](value *Value) *Value { + if value == nil { + return nil + } + return Ptr(*value) +} +func cloneTimeouts(options Timeouts) Timeouts { + return Timeouts{clonePointer(options.Text), clonePointer(options.Idle), clonePointer(options.Command), clonePointer(options.Exit), clonePointer(options.Ready)} +} +func cloneClientOptions(options ClientOptions) ClientOptions { + options.Timeouts = cloneTimeouts(options.Timeouts) + options.Profile = clonePointer(options.Profile) + if options.Profile != nil { + options.Profile.Scrollback = clonePointer(options.Profile.Scrollback) + } + options.Recording = clonePointer(options.Recording) + options.Artifacts = clonePointer(options.Artifacts) + options.Trace = clonePointer(options.Trace) + options.ScreenHistoryLimit = clonePointer(options.ScreenHistoryLimit) + return options +} +func validateTimeouts(options Timeouts) error { + for _, timeout := range []*time.Duration{options.Text, options.Idle, options.Command, options.Exit, options.Ready} { + if timeout == nil { + continue + } + if _, err := timeoutMilliseconds(*timeout); err != nil { + return err + } + } + return nil +} +func timeoutMilliseconds(timeout time.Duration) (uint64, error) { + return engineTimeoutMilliseconds(timeout) +} + +func withTimeoutDefaults(options, defaults Timeouts) Timeouts { + if options.Text == nil { + options.Text = defaults.Text + } + if options.Idle == nil { + options.Idle = defaults.Idle + } + if options.Command == nil { + options.Command = defaults.Command + } + if options.Exit == nil { + options.Exit = defaults.Exit + } + if options.Ready == nil { + options.Ready = defaults.Ready + } + return options +} + +func (client *Client) spawnOptions(options SpawnOptions) (SpawnOptions, error) { + if err := validateTimeouts(options.Timeouts); err != nil { + return options, err + } + if options.Backend == "" { + options.Backend = client.options.Backend + } + if options.Profile == nil { + options.Profile = client.options.Profile + } + options.Timeouts = withTimeoutDefaults(options.Timeouts, client.options.Timeouts) + if options.Cols == nil { + options.Cols = Ptr(uint16(80)) + } + if options.Rows == nil { + options.Rows = Ptr(uint16(30)) + } + return options, nil +} +func (client *Client) spawn(options SpawnOptions, action func(SpawnOptions) (OpenResult, error)) (OpenResult, error) { + resolved, err := client.spawnOptions(options) + if err != nil { + return OpenResult{}, err + } + for attempt := uint32(0); ; attempt++ { + result, err := action(resolved) + if err == nil || attempt == options.Retries || isUsageError(err) { + return result, err + } + client.CloseQuiet() + } +} +func (client *Client) Open(options OpenOptions) (OpenResult, error) { + return client.spawn(options.SpawnOptions, func(spawn SpawnOptions) (OpenResult, error) { + return client.runtime.open(OpenOptions{spawn, options.Shell}) + }) +} +func (client *Client) Run(program string, args []string, options SpawnOptions) (OpenResult, error) { + return client.spawn(options, func(spawn SpawnOptions) (OpenResult, error) { return client.runtime.run(program, args, spawn) }) +} +func (client *Client) Restart(values ...RestartOptions) (OpenResult, error) { + if len(values) > 1 { + return OpenResult{}, &Error{Kind: UsageError, Message: "restart accepts at most one options value"} + } + timeout := 5 * time.Second + if len(values) == 1 && values[0].GracefulTimeout != nil { + timeout = *values[0].GracefulTimeout + } + return client.runtime.restart(timeout) +} +func (client *Client) Close(options ...CloseOptions) error { + if len(options) > 1 { + return &Error{Kind: UsageError, Message: "close accepts at most one options value"} + } + var failed *bool + if len(options) == 1 { + failed = options[0].Failed + } + return client.runtime.close(failed) +} + +// CloseQuiet attempts cleanup and reports success without returning diagnostics. +func (client *Client) CloseQuiet() bool { return client.Close() == nil } +func (client *Client) Type(text string) error { return client.runtime.typeText(text) } +func (client *Client) Write(data string) error { return client.runtime.write(data) } +func (client *Client) Submit(text *string) error { return client.runtime.submit(text) } +func (client *Client) Press(keys ...string) error { return client.Keyboard.Press(keys...) } +func (client *Client) Resize(cols, rows uint16) error { return client.runtime.resize(cols, rows) } +func (client *Client) Signal(name string) error { return client.runtime.signal(name) } +func (client *Client) Kill() error { return client.Signal("KILL") } +func (client *Client) State() (State, error) { return client.runtime.state() } +func (client *Client) Text(options TextOptions) (string, error) { + return client.runtime.text(options.Full) +} +func (client *Client) Cells(x, y, width, height uint16) ([]Cell, error) { + return client.runtime.cells(x, y, width, height) +} +func (client *Client) GetCommand() (*string, error) { return client.runtime.getCommand() } +func (client *Client) GetOutput() (*string, error) { return client.runtime.getOutput() } +func (client *Client) GetExitCode() (*int32, error) { return client.runtime.getExitCode() } +func (client *Client) GetCwd() (*string, error) { return client.runtime.getCwd() } +func (client *Client) GetTitle() (*string, error) { return client.runtime.getTitle() } +func (client *Client) GetClipboard() (string, error) { return client.runtime.getClipboard() } +func (client *Client) GetCursor() (Cursor, error) { return client.runtime.getCursor() } +func (client *Client) GetSize() (Size, error) { return client.runtime.getSize() } +func (client *Client) GetBellCount() (uint64, error) { return client.runtime.getBellCount() } +func (client *Client) GetBellEvents() ([]BellEvent, error) { return client.runtime.getBellEvents() } + +// Screenshot returns terminal text when path is empty, otherwise the written path. +func (client *Client) Screenshot(path string, options ScreenshotOptions) (string, error) { + if path == "" && (options.Zoom != nil || options.Background != "" || options.Transparent) { + return "", &Error{Kind: UsageError, Message: "screenshot customization requires a path"} + } + if options.Background != "" && options.Transparent { + return "", &Error{Kind: UsageError, Message: "screenshot background and transparent options conflict"} + } + return client.runtime.screenshot(path, options) +} +func (client *Client) StartRecording(path string, options RecordingOptions) error { + if options.Background != "" && options.Transparent { + return &Error{Kind: UsageError, Message: "recording background and transparent options conflict"} + } + return client.runtime.startRecording(path, options) +} +func (client *Client) StopRecording() (string, error) { return client.runtime.stopRecording() } + +func (client *Client) guard(operation string, err error) error { + var failure *Error + if !errors.As(err, &failure) || failure.Kind != AssertionError { + return err + } + copied := *failure + copied.Operation = operation + return &copied +} +func isUsageError(err error) bool { + var failure *Error + return errors.As(err, &failure) && failure.Kind == UsageError +} +func (client *Client) wait(operation string, timeout, fallback *time.Duration, action func(*time.Duration) error) error { + resolved := timeout + if resolved == nil { + resolved = fallback + } + if resolved != nil { + if _, err := timeoutMilliseconds(*resolved); err != nil { + return err + } + } + return client.guard(operation, action(resolved)) +} +func (client *Client) WaitIdle(options WaitOptions) error { + return client.wait("waitIdle", options.Timeout, client.options.Timeouts.Idle, client.runtime.waitIdle) +} +func (client *Client) WaitCommand(options WaitOptions) error { + return client.wait("waitCommand", options.Timeout, client.options.Timeouts.Command, client.runtime.waitCommand) +} +func (client *Client) WaitExit(options WaitOptions) error { + return client.wait("waitExit", options.Timeout, client.options.Timeouts.Exit, client.runtime.waitExit) +} +func (client *Client) WaitReady(options WaitOptions) error { + return client.wait("waitReady", options.Timeout, client.options.Timeouts.Ready, client.runtime.waitReady) +} +func (client *Client) WaitBell(options WaitOptions) error { + return client.wait("waitBell", options.Timeout, client.options.Timeouts.Text, client.runtime.waitBell) +} +func (client *Client) WaitTitle(text string, options TitleOptions) error { + return client.wait("waitTitle", options.Timeout, client.options.Timeouts.Text, func(timeout *time.Duration) error { + options.Timeout = timeout + return client.runtime.waitTitle(text, options) + }) +} +func (client *Client) WaitClipboard(options ClipboardWaitOptions) error { + return client.wait("waitClipboard", options.Timeout, client.options.Timeouts.Text, func(timeout *time.Duration) error { + options.Timeout = timeout + return client.runtime.waitClipboard(options) + }) +} +func (client *Client) ExpectTitle(text string, options TitleOptions) error { + return client.wait("expectTitle", options.Timeout, client.options.Timeouts.Text, func(timeout *time.Duration) error { + options.Timeout = timeout + return client.runtime.expectTitle(text, options) + }) +} +func (client *Client) ExpectExitCode(code int32, options WaitOptions) error { + return client.wait("expectExitCode", options.Timeout, client.options.Timeouts.Command, func(timeout *time.Duration) error { return client.runtime.expectExitCode(code, timeout) }) +} +func (client *Client) ExpectOutput(text string, options OutputOptions) error { + return client.guard("expectOutput", client.runtime.expectOutput(text, options.Regex)) +} +func (client *Client) ExpectBellCount(count uint64, options WaitOptions) error { + return client.wait("expectBellCount", options.Timeout, client.options.Timeouts.Text, func(timeout *time.Duration) error { return client.runtime.expectBellCount(count, timeout) }) +} +func (client *Client) ExpectSnapshot(name string, options SnapshotOptions) (SnapshotResult, error) { + if options.Cwd == "" { + directory, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("snapshot working directory: %w", err) + } + options.Cwd = directory + } + result, err := client.runtime.snapshot(name, options) + return result, client.guard("expectSnapshot", err) +} diff --git a/bindings/go/engine.go b/bindings/go/engine.go new file mode 100644 index 00000000..d38fc334 --- /dev/null +++ b/bindings/go/engine.go @@ -0,0 +1,309 @@ +package tuitest + +import ( + "encoding/json" + "errors" + "time" + + "github.com/microsoft/tui-test/bindings/go/internal/native" +) + +type nativeRuntime struct{ session *native.Session } + +func newNativeRuntime(name string, options ClientOptions) (*nativeRuntime, error) { + var nativeRecording *native.AutomaticRecording + if options.Recording != nil { + nativeRecording = &native.AutomaticRecording{Mode: native.RecordingMode(options.Recording.Mode), Directory: options.Recording.Directory} + } + var artifacts *native.ArtifactOptions + if options.Artifacts != nil { + artifacts = &native.ArtifactOptions{Directory: options.Artifacts.Dir, Mode: string(options.Artifacts.OnFailure), IncludeRecording: options.Artifacts.IncludeRecording} + } + var trace *native.TraceOptions + if options.Trace != nil { + trace = &native.TraceOptions{Mode: string(options.Trace.Mode), Directory: options.Trace.Directory} + } + session, err := native.NewSession(name, native.SessionOptions{Recording: nativeRecording, Artifacts: artifacts, Trace: trace, ScreenHistoryLimit: options.ScreenHistoryLimit}) + if err != nil { + return nil, engineError(err) + } + return &nativeRuntime{session: session}, nil +} +func engineError(err error) error { + if err == nil { + return nil + } + var failure *native.Error + if errors.As(err, &failure) { + converted := &Error{Kind: ErrorKind(failure.Kind), Message: failure.Message} + if len(failure.DetailsJSON) != 0 && json.Unmarshal(failure.DetailsJSON, &converted.Details) != nil { + return &Error{Kind: InternalError, Message: "decode native failure details"} + } + if len(failure.ArtifactJSON) != 0 && json.Unmarshal(failure.ArtifactJSON, &converted.Artifact) != nil { + return &Error{Kind: InternalError, Message: "decode native failure artifact"} + } + return converted + } + return &Error{Kind: InternalError, Message: err.Error()} +} +func engineTimeoutMilliseconds(timeout time.Duration) (uint64, error) { + value, err := native.Milliseconds(timeout) + return value, engineError(err) +} +func nativeSessions() ([]string, error) { + value, err := native.Sessions() + return value, engineError(err) +} +func nativeCloseAll() error { return engineError(native.CloseAll()) } +func nativeRecording(session string) (string, error) { + value, err := native.Recording(session) + return value, engineError(err) +} +func (runtime *nativeRuntime) close(failed *bool) error { + return engineError(runtime.session.Close(failed)) +} +func (runtime *nativeRuntime) restart(gracefulTimeout time.Duration) (OpenResult, error) { + value, err := runtime.session.Restart(gracefulTimeout) + return OpenResult(value), engineError(err) +} +func (runtime *nativeRuntime) typeText(text string) error { + return engineError(runtime.session.TypeText(text)) +} +func (runtime *nativeRuntime) write(text string) error { + return engineError(runtime.session.Write(text)) +} +func (runtime *nativeRuntime) submit(text *string) error { + return engineError(runtime.session.Submit(text)) +} +func (runtime *nativeRuntime) signal(name string) error { + return engineError(runtime.session.Signal(name)) +} +func (runtime *nativeRuntime) resize(cols, rows uint16) error { + return engineError(runtime.session.Resize(cols, rows)) +} +func (runtime *nativeRuntime) key(keys []string, action uint32) error { + return engineError(runtime.session.Key(keys, action)) +} +func (runtime *nativeRuntime) waitIdle(timeout *time.Duration) error { + return engineError(runtime.session.WaitIdle(timeout)) +} +func (runtime *nativeRuntime) waitCommand(timeout *time.Duration) error { + return engineError(runtime.session.WaitCommand(timeout)) +} +func (runtime *nativeRuntime) waitExit(timeout *time.Duration) error { + return engineError(runtime.session.WaitExit(timeout)) +} +func (runtime *nativeRuntime) waitReady(timeout *time.Duration) error { + return engineError(runtime.session.WaitReady(timeout)) +} +func (runtime *nativeRuntime) waitBell(timeout *time.Duration) error { + return engineError(runtime.session.WaitBell(timeout)) +} +func (runtime *nativeRuntime) waitTitle(text string, options TitleOptions) error { + return engineError(runtime.session.WaitTitle(text, native.TitleOptions(options))) +} +func (runtime *nativeRuntime) expectTitle(text string, options TitleOptions) error { + return engineError(runtime.session.ExpectTitle(text, native.TitleOptions(options))) +} +func (runtime *nativeRuntime) waitClipboard(options ClipboardWaitOptions) error { + return engineError(runtime.session.WaitClipboard(native.ClipboardWaitOptions(options))) +} +func (runtime *nativeRuntime) expectExitCode(code int32, timeout *time.Duration) error { + return engineError(runtime.session.ExpectExitCode(code, timeout)) +} +func (runtime *nativeRuntime) expectOutput(text string, regex bool) error { + return engineError(runtime.session.ExpectOutput(text, regex)) +} +func (runtime *nativeRuntime) expectBellCount(count uint64, timeout *time.Duration) error { + return engineError(runtime.session.ExpectBellCount(count, timeout)) +} +func (runtime *nativeRuntime) startRecording(path string, options RecordingOptions) error { + return engineError(runtime.session.StartRecording(path, engineRecordingOptions(options))) +} +func (runtime *nativeRuntime) mouseClick(options MouseClickOptions) error { + return engineError(runtime.session.MouseClick(engineMouseClickOptions(options))) +} +func (runtime *nativeRuntime) mouseMove(column, row uint16) error { + return engineError(runtime.session.MouseMove(column, row)) +} +func (runtime *nativeRuntime) mouseDown(column, row uint16, options MouseButtonOptions) error { + return engineError(runtime.session.MouseDown(column, row, engineMouseOptions(options))) +} +func (runtime *nativeRuntime) mouseUp(column, row uint16, options MouseButtonOptions) error { + return engineError(runtime.session.MouseUp(column, row, engineMouseOptions(options))) +} +func (runtime *nativeRuntime) mouseDrag(fromColumn, fromRow, toColumn, toRow uint16, options MouseButtonOptions) error { + return engineError(runtime.session.MouseDrag(fromColumn, fromRow, toColumn, toRow, engineMouseOptions(options))) +} +func (runtime *nativeRuntime) mouseScroll(direction ScrollDirection, amount uint32) error { + return engineError(runtime.session.MouseScroll(native.ScrollDirection(direction), amount)) +} +func (runtime *nativeRuntime) waitLocator(query locatorQuery, hidden bool, timeout *time.Duration) error { + return engineError(runtime.session.WaitLocator(engineLocatorQuery(query), hidden, timeout)) +} +func (runtime *nativeRuntime) expectLocator(query locatorQuery, options LocatorExpectOptions) error { + return engineError(runtime.session.ExpectLocator(engineLocatorQuery(query), native.LocatorExpectOptions(options))) +} +func (runtime *nativeRuntime) clickLocator(query locatorQuery, options LocatorClickOptions) error { + return engineError(runtime.session.ClickLocator(engineLocatorQuery(query), engineLocatorClickOptions(options))) +} +func (runtime *nativeRuntime) highlightLocator(query locatorQuery, timeout *time.Duration) error { + return engineError(runtime.session.HighlightLocator(engineLocatorQuery(query), timeout)) +} +func (runtime *nativeRuntime) open(options OpenOptions) (OpenResult, error) { + value, err := runtime.session.Open(engineOpenOptions(options)) + return OpenResult(value), engineError(err) +} +func (runtime *nativeRuntime) run(program string, args []string, options SpawnOptions) (OpenResult, error) { + value, err := runtime.session.Run(program, args, engineSpawnOptions(options)) + return OpenResult(value), engineError(err) +} +func (runtime *nativeRuntime) state() (State, error) { + value, err := runtime.session.State() + return engineState(value), engineError(err) +} +func (runtime *nativeRuntime) text(full bool) (string, error) { + value, err := runtime.session.Text(full) + return value, engineError(err) +} +func (runtime *nativeRuntime) cells(column, row, width, height uint16) ([]Cell, error) { + value, err := runtime.session.Cells(column, row, width, height) + return engineCells(value), engineError(err) +} +func (runtime *nativeRuntime) getCursor() (Cursor, error) { + value, err := runtime.session.GetCursor() + return Cursor(value), engineError(err) +} +func (runtime *nativeRuntime) getSize() (Size, error) { + value, err := runtime.session.GetSize() + return Size(value), engineError(err) +} +func (runtime *nativeRuntime) getExitCode() (*int32, error) { + value, err := runtime.session.GetExitCode() + return value, engineError(err) +} +func (runtime *nativeRuntime) getBellCount() (uint64, error) { + value, err := runtime.session.GetBellCount() + return value, engineError(err) +} +func (runtime *nativeRuntime) getBellEvents() ([]BellEvent, error) { + value, err := runtime.session.GetBellEvents() + return engineBellEvents(value), engineError(err) +} +func (runtime *nativeRuntime) getCommand() (*string, error) { + value, err := runtime.session.GetCommand() + return value, engineError(err) +} +func (runtime *nativeRuntime) getOutput() (*string, error) { + value, err := runtime.session.GetOutput() + return value, engineError(err) +} +func (runtime *nativeRuntime) getCwd() (*string, error) { + value, err := runtime.session.GetCwd() + return value, engineError(err) +} +func (runtime *nativeRuntime) getTitle() (*string, error) { + value, err := runtime.session.GetTitle() + return value, engineError(err) +} +func (runtime *nativeRuntime) getClipboard() (string, error) { + value, err := runtime.session.GetClipboard() + return value, engineError(err) +} +func (runtime *nativeRuntime) stopRecording() (string, error) { + value, err := runtime.session.StopRecording() + return value, engineError(err) +} +func (runtime *nativeRuntime) screenshot(path string, options ScreenshotOptions) (string, error) { + value, err := runtime.session.Screenshot(path, native.ScreenshotOptions{Full: options.Full, Zoom: options.Zoom, Background: options.Background, Transparent: options.Transparent}) + return value, engineError(err) +} +func (runtime *nativeRuntime) snapshot(name string, options SnapshotOptions) (SnapshotResult, error) { + value, err := runtime.session.Snapshot(name, native.SnapshotOptions(options)) + return SnapshotResult(value), engineError(err) +} +func (runtime *nativeRuntime) findLocator(query locatorQuery, requireOne bool) ([]TextMatch, error) { + value, err := runtime.session.FindLocator(engineLocatorQuery(query), requireOne) + return engineMatches(value), engineError(err) +} + +func engineSpawnOptions(options SpawnOptions) native.SpawnOptions { + var profile *native.Profile + if options.Profile != nil { + profile = &native.Profile{Scrollback: options.Profile.Scrollback, Colors: native.Colors(options.Profile.Colors)} + } + return native.SpawnOptions{Backend: native.Backend(options.Backend), Cols: options.Cols, Rows: options.Rows, Cwd: options.Cwd, Env: options.Env, WaitReady: options.WaitReady, Restart: options.Restart, Profile: profile, Timeouts: native.Timeouts(options.Timeouts)} +} +func engineOpenOptions(options OpenOptions) native.OpenOptions { + return native.OpenOptions{SpawnOptions: engineSpawnOptions(options.SpawnOptions), Shell: native.Shell(options.Shell)} +} +func engineRecordingOptions(options RecordingOptions) native.RecordingOptions { + return native.RecordingOptions{Format: native.RecordingFormat(options.Format), FPS: options.FPS, Speed: options.Speed, IdleTimeLimit: options.IdleTimeLimit, Zoom: options.Zoom, Background: options.Background, Transparent: options.Transparent} +} +func engineMouseOptions(options MouseButtonOptions) native.MouseButtonOptions { + return native.MouseButtonOptions{Button: native.MouseButton(options.Button), Alt: options.Alt, Ctrl: options.Ctrl, Shift: options.Shift} +} +func engineMouseClickOptions(options MouseClickOptions) native.MouseClickOptions { + return native.MouseClickOptions{MouseButtonOptions: engineMouseOptions(options.MouseButtonOptions), X: options.X, Y: options.Y, OnText: options.OnText, Clicks: options.Clicks} +} +func engineLocatorClickOptions(options LocatorClickOptions) native.LocatorClickOptions { + return native.LocatorClickOptions{MouseButtonOptions: engineMouseOptions(options.MouseButtonOptions), Clicks: options.Clicks, Timeout: options.Timeout} +} +func engineTextStyle(style TextStyle) native.TextStyle { + var underline *native.UnderlineStyle + if style.UnderlineStyle != nil { + underline = Ptr(native.UnderlineStyle(*style.UnderlineStyle)) + } + return native.TextStyle{Foreground: style.Foreground, Background: style.Background, Bold: style.Bold, Dim: style.Dim, Italic: style.Italic, UnderlineStyle: underline, UnderlineColor: style.UnderlineColor, Inverse: style.Inverse, Hidden: style.Hidden, Strikethrough: style.Strikethrough, Blink: style.Blink} +} +func engineLocatorQuery(query locatorQuery) native.LocatorExpression { + converted := make([]native.LocatorNode, len(query.nodes)) + for index, node := range query.nodes { + converted[index] = native.LocatorNode{Kind: node.kind, Text: node.text, Regex: node.regex, Whitespace: (*native.Whitespace)(node.whitespace), Link: node.link, Full: node.full, Direction: (*native.Direction)(node.direction), Within: node.within, Left: node.left, Right: node.right, Input: node.input, Has: node.has, HasNot: node.hasNot, Occurrence: node.occurrence} + if node.style != nil { + style := engineTextStyle(*node.style) + converted[index].Style = &style + } + } + return native.LocatorExpression{Nodes: converted, Root: query.root} +} +func engineState(state native.State) State { + return State{SessionShell: state.SessionShell, Cols: state.Cols, Rows: state.Rows, Cursor: Cursor(state.Cursor), Title: state.Title, Cwd: state.Cwd, LastCommand: state.LastCommand, LastExit: state.LastExit, ExitSignal: state.ExitSignal, Exited: state.Exited, Ready: state.Ready, BellCount: state.BellCount, Modes: state.Modes, MouseMode: state.MouseMode, Colors: TerminalColors(state.Colors), Timeouts: EffectiveTimeouts(state.Timeouts), Text: state.Text} +} +func engineCells(cells []native.Cell) []Cell { + if cells == nil { + return nil + } + + converted := make([]Cell, len(cells)) + for index, cell := range cells { + converted[index] = Cell{X: cell.X, Y: cell.Y, Char: cell.Char, FG: Color(cell.FG), BG: Color(cell.BG), Bold: cell.Bold, Dim: cell.Dim, Italic: cell.Italic, Inverse: cell.Inverse, Invisible: cell.Invisible, Strike: cell.Strike, Blink: cell.Blink, Underline: cell.Underline, UnderlineStyle: UnderlineStyle(cell.UnderlineStyle), UnderlineColor: Color(cell.UnderlineColor), Link: cell.Link, LinkID: cell.LinkID} + } + return converted +} +func engineMatches(matches []native.TextMatch) []TextMatch { + if matches == nil { + return nil + } + + converted := make([]TextMatch, len(matches)) + for index, match := range matches { + spans := make([]TextSpan, len(match.Spans)) + for spanIndex, span := range match.Spans { + spans[spanIndex] = TextSpan(span) + } + converted[index] = TextMatch{Text: match.Text, Start: TextPosition(match.Start), End: TextPosition(match.End), Spans: spans} + } + return converted +} +func engineBellEvents(events []native.BellEvent) []BellEvent { + if events == nil { + return nil + } + + converted := make([]BellEvent, len(events)) + for index, event := range events { + converted[index] = BellEvent(event) + } + return converted +} diff --git a/bindings/go/examples/smoke/main.go b/bindings/go/examples/smoke/main.go new file mode 100644 index 00000000..334660f1 --- /dev/null +++ b/bindings/go/examples/smoke/main.go @@ -0,0 +1,66 @@ +// The smoke consumer runs itself in child mode, so it needs no external shell. +package main + +import ( + "bufio" + "fmt" + "os" + "time" + + tuitest "github.com/microsoft/tui-test/bindings/go" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--child" { + fmt.Println("go-binding-ready") + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + if scanner.Text() == "quit" { + return + } + fmt.Println("echo:" + scanner.Text()) + } + return + } + if err := smoke(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Go binding smoke passed") +} + +func smoke() error { + terminal, err := tuitest.Ephemeral("smoke", tuitest.ClientOptions{Recording: &tuitest.AutomaticRecording{Mode: tuitest.RecordingDisabled}}) + if err != nil { + return fmt.Errorf("create smoke terminal: %w", err) + } + defer terminal.CloseQuiet() + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate smoke executable: %w", err) + } + if _, err = terminal.Run(executable, []string{"--child"}, tuitest.SpawnOptions{WaitReady: tuitest.Ptr(false)}); err != nil { + return fmt.Errorf("run smoke child: %w", err) + } + return exerciseTerminal(terminal) +} + +func exerciseTerminal(terminal *tuitest.Client) error { + timeout := 10 * time.Second + if err := terminal.GetByText("go-binding-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("wait for child readiness: %w", err) + } + if err := terminal.Submit(tuitest.Ptr("hello")); err != nil { + return fmt.Errorf("submit child input: %w", err) + } + if err := terminal.GetByText("echo:hello", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("expect child response: %w", err) + } + if err := terminal.Submit(tuitest.Ptr("quit")); err != nil { + return fmt.Errorf("request child exit: %w", err) + } + if err := terminal.WaitExit(tuitest.WaitOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("wait for child exit: %w", err) + } + return nil +} diff --git a/bindings/go/go.mod b/bindings/go/go.mod new file mode 100644 index 00000000..e138ff7a --- /dev/null +++ b/bindings/go/go.mod @@ -0,0 +1,5 @@ +module github.com/microsoft/tui-test/bindings/go + +go 1.26 + +require github.com/ebitengine/purego v0.11.0 diff --git a/bindings/go/go.sum b/bindings/go/go.sum new file mode 100644 index 00000000..9374d87e --- /dev/null +++ b/bindings/go/go.sum @@ -0,0 +1,2 @@ +github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= +github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= diff --git a/bindings/go/input.go b/bindings/go/input.go new file mode 100644 index 00000000..9586c866 --- /dev/null +++ b/bindings/go/input.go @@ -0,0 +1,27 @@ +package tuitest + +// Keyboard sends terminal key events, including separate press and release events. +type Keyboard struct{ runtime *nativeRuntime } + +func (keyboard *Keyboard) Press(keys ...string) error { return keyboard.runtime.key(keys, 0) } +func (keyboard *Keyboard) Down(keys ...string) error { return keyboard.runtime.key(keys, 1) } +func (keyboard *Keyboard) Repeat(keys ...string) error { return keyboard.runtime.key(keys, 2) } +func (keyboard *Keyboard) Up(keys ...string) error { return keyboard.runtime.key(keys, 3) } + +// Mouse sends coordinates in terminal cells. +type Mouse struct{ runtime *nativeRuntime } + +func (mouse *Mouse) Click(options MouseClickOptions) error { return mouse.runtime.mouseClick(options) } +func (mouse *Mouse) Move(x, y uint16) error { return mouse.runtime.mouseMove(x, y) } +func (mouse *Mouse) Down(x, y uint16, options MouseButtonOptions) error { + return mouse.runtime.mouseDown(x, y, options) +} +func (mouse *Mouse) Up(x, y uint16, options MouseButtonOptions) error { + return mouse.runtime.mouseUp(x, y, options) +} +func (mouse *Mouse) Drag(x1, y1, x2, y2 uint16, options MouseButtonOptions) error { + return mouse.runtime.mouseDrag(x1, y1, x2, y2, options) +} +func (mouse *Mouse) Scroll(direction ScrollDirection, amount uint32) error { + return mouse.runtime.mouseScroll(direction, amount) +} diff --git a/bindings/go/integration_test.go b/bindings/go/integration_test.go new file mode 100644 index 00000000..4f8671c0 --- /dev/null +++ b/bindings/go/integration_test.go @@ -0,0 +1,447 @@ +package tuitest_test + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + tuitest "github.com/microsoft/tui-test/bindings/go" +) + +func TestTerminalProcess(t *testing.T) { + if !slices.Contains(os.Args, "--tui-go-child") { + return + } + fmt.Print("\x1b[2J\x1b[Hgo-ready\r\nitem item\r\n\x1b[1mstyled\x1b[0m plain\r\n界 café\r\n\x1b]8;;https://example.test/docs\x1b\\linked docs\x1b]8;;\x1b\\\r\n") + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + switch scanner.Text() { + case "quit": + os.Exit(0) + case "events": + fmt.Print("\x1b]2;Go title\x07\x1b]52;c;Z28tY2xpcGJvYXJk\x07\x07\r\nevents-done\r\n") + default: + fmt.Printf("received:%s\r\n", scanner.Text()) + } + } + os.Exit(0) +} + +func newTerminal(t *testing.T, options tuitest.ClientOptions) *tuitest.Client { + t.Helper() + terminal := newClient(t, options) + runTerminal(t, terminal, tuitest.SpawnOptions{}) + return terminal +} + +func newClient(t *testing.T, options tuitest.ClientOptions) *tuitest.Client { + t.Helper() + terminal, err := tuitest.Ephemeral(t.Name(), options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := terminal.Close(); err != nil { + t.Errorf("close terminal: %v", err) + } + }) + return terminal +} +func runTerminal(t *testing.T, terminal *tuitest.Client, options tuitest.SpawnOptions) { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + options.WaitReady = tuitest.Ptr(false) + if _, err = terminal.Run(executable, []string{"-test.run=^TestTerminalProcess$", "--", "--tui-go-child"}, options); err != nil { + t.Fatal(err) + } + if err = terminal.GetByText("go-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(10 * time.Second)}); err != nil { + t.Fatal(err) + } +} +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} +func requireKind(t *testing.T, err error, kind tuitest.ErrorKind) { + t.Helper() + var failure *tuitest.Error + if !errors.As(err, &failure) || failure.Kind != kind { + t.Fatalf("expected %s error, got %v", kind, err) + } +} + +func TestLocatorsPreserveSelection(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + items := terminal.GetByText("item", tuitest.TextSelectorOptions{}) + count, err := items.Count() + requireNoError(t, err) + if count != 2 { + t.Fatalf("item count=%d", count) + } + _, err = items.Location() + requireKind(t, err, tuitest.AssertionError) + first, err := items.First().Location() + requireNoError(t, err) + last, err := items.Last().Location() + requireNoError(t, err) + if first.Start.Column >= last.Start.Column { + t.Fatalf("unordered locations: %+v %+v", first, last) + } + requireNoError(t, items.Highlight(tuitest.WaitOptions{})) + requireNoError(t, items.First().Click(tuitest.LocatorClickOptions{})) + requireNoError(t, terminal.GetByText("absent", tuitest.TextSelectorOptions{}).Wait(tuitest.LocatorWaitOptions{State: tuitest.Hidden, Timeout: tuitest.Ptr(time.Duration(0))})) +} + +func TestLocatorEnumerationAndRelativeQueries(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + items := terminal.GetByText("item", tuitest.TextSelectorOptions{}) + all, err := items.All() + requireNoError(t, err) + for index, locator := range all { + actual, locationErr := locator.Location() + requireNoError(t, locationErr) + selected, selectedErr := items.Nth(uint32(index)).Location() + requireNoError(t, selectedErr) + if actual.Start != selected.Start { + t.Fatalf("all[%d]=%+v; nth=%+v", index, actual, selected) + } + } + count, err := items.Count() + requireNoError(t, err) + if count != 2 { + t.Fatal("selection mutated original locator") + } + requireNoError(t, terminal.GetByText("item item", tuitest.TextSelectorOptions{}).GetByText("item", tuitest.TextSelectorOptions{}).Nth(1).Expect(tuitest.LocatorExpectOptions{})) + requireNoError(t, items.First().GetByText("item", tuitest.TextSelectorOptions{Direction: tuitest.After}).Expect(tuitest.LocatorExpectOptions{})) +} + +func TestStylesAndWideCellCoordinates(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + bold := true + styled := terminal.GetByStyle(tuitest.TextStyle{Bold: &bold}, tuitest.StyleSelectorOptions{}) + bold = false + requireNoError(t, styled.GetByText("styled", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) + requireNoError(t, terminal.GetByText("plain", tuitest.TextSelectorOptions{}).GetByStyle(tuitest.TextStyle{Bold: tuitest.Ptr(false)}, tuitest.StyleSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) + wide, err := terminal.GetByText("界", tuitest.TextSelectorOptions{}).Location() + requireNoError(t, err) + if wide.Start.Column != 0 || wide.Start.Row != 3 { + t.Fatalf("unexpected wide cell coordinates: %+v", wide.Start) + } + cells, err := terminal.Cells(0, 3, 2, 1) + requireNoError(t, err) + if len(cells) != 2 || cells[0].Char != "界" || cells[1].Char != "" { + t.Fatalf("wide cells=%+v", cells) + } +} + +func TestLinkAndComposableLocators(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + row := terminal.GetByText("linked docs", tuitest.TextSelectorOptions{}) + link := terminal.GetByLink("https://example.test/docs", tuitest.LinkSelectorOptions{}) + intersection, err := row.And(link) + requireNoError(t, err) + match, err := intersection.Location() + requireNoError(t, err) + if match.Text != "linked docs" { + t.Fatalf("intersection=%+v", match) + } + union, err := terminal.GetByText("absent", tuitest.TextSelectorOptions{}).Or(link) + requireNoError(t, err) + requireNoError(t, union.Expect(tuitest.LocatorExpectOptions{})) + filtered, err := row.Filter(tuitest.LocatorFilterOptions{Has: link}) + requireNoError(t, err) + requireNoError(t, filtered.Expect(tuitest.LocatorExpectOptions{})) + linked, err := link.Location() + requireNoError(t, err) + cells, err := terminal.Cells(uint16(linked.Start.Column), uint16(linked.Start.Row), 1, 1) + requireNoError(t, err) + if len(cells) != 1 || cells[0].Link != "https://example.test/docs" { + t.Fatalf("linked cell=%+v", cells) + } +} + +func TestInputAndState(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + state, err := terminal.State() + requireNoError(t, err) + if state.Cols != 80 || state.Rows != 30 { + t.Fatalf("default size=%dx%d", state.Cols, state.Rows) + } + if state.Cursor.Shape == "" || state.Cursor.Color == "" || state.Colors.Foreground == "" || state.Colors.Background == "" || state.Colors.Cursor == "" || state.Modes == nil || state.MouseMode == "" { + t.Fatalf("incomplete rich state=%+v", state) + } + requireNoError(t, terminal.Resize(90, 32)) + size, err := terminal.GetSize() + requireNoError(t, err) + if size.Cols != 90 || size.Rows != 32 { + t.Fatalf("resized size=%+v", size) + } + _, err = terminal.GetCursor() + requireNoError(t, err) + _, err = terminal.GetCommand() + requireNoError(t, err) + _, err = terminal.GetOutput() + requireNoError(t, err) + _, err = terminal.GetExitCode() + requireNoError(t, err) + _, err = terminal.GetCwd() + requireNoError(t, err) + requireNoError(t, terminal.Type("hello")) + requireNoError(t, terminal.Press("Enter")) + requireNoError(t, terminal.GetByText("received:hello", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) + requireNoError(t, terminal.Submit(tuitest.Ptr("quit"))) + requireNoError(t, terminal.WaitExit(tuitest.WaitOptions{Timeout: tuitest.Ptr(10 * time.Second)})) +} + +func TestTerminalEvents(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + requireNoError(t, terminal.Submit(tuitest.Ptr("events"))) + requireNoError(t, terminal.WaitTitle("Go title", tuitest.TitleOptions{})) + requireNoError(t, terminal.ExpectTitle("^Go", tuitest.TitleOptions{Regex: true})) + requireNoError(t, terminal.WaitClipboard(tuitest.ClipboardWaitOptions{Text: tuitest.Ptr("go-clipboard")})) + title, err := terminal.GetTitle() + requireNoError(t, err) + if title == nil || *title != "Go title" { + t.Fatalf("title=%v", title) + } + clipboard, err := terminal.GetClipboard() + requireNoError(t, err) + if clipboard != "go-clipboard" { + t.Fatalf("clipboard=%q", clipboard) + } + requireNoError(t, terminal.ExpectBellCount(1, tuitest.WaitOptions{})) + events, err := terminal.GetBellEvents() + requireNoError(t, err) + if len(events) != 1 || events[0].Sequence != 1 { + t.Fatalf("bell events=%+v", events) + } + count, err := terminal.GetBellCount() + requireNoError(t, err) + if count != 1 { + t.Fatalf("bell count=%d", count) + } +} + +func TestScreenshotsAndSnapshots(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + text, err := terminal.Screenshot("", tuitest.ScreenshotOptions{}) + requireNoError(t, err) + if !strings.Contains(text, "go-ready") { + t.Fatalf("screenshot omitted terminal text: %.100s", text) + } + directory := t.TempDir() + path, err := terminal.Screenshot(filepath.Join(directory, "terminal.svg"), tuitest.ScreenshotOptions{}) + requireNoError(t, err) + if _, statErr := os.Stat(path); statErr != nil { + t.Fatal(statErr) + } + transparent, err := terminal.Screenshot(filepath.Join(directory, "transparent.png"), tuitest.ScreenshotOptions{Transparent: true}) + requireNoError(t, err) + if _, statErr := os.Stat(transparent); statErr != nil { + t.Fatal(statErr) + } + _, err = terminal.Screenshot(filepath.Join(directory, "conflict.png"), tuitest.ScreenshotOptions{Background: "#000000", Transparent: true}) + requireKind(t, err, tuitest.UsageError) + snapshot, err := terminal.ExpectSnapshot("terminal", tuitest.SnapshotOptions{Cwd: directory, Update: true}) + requireNoError(t, err) + if snapshot != tuitest.SnapshotWritten && snapshot != tuitest.SnapshotUpdated { + t.Fatalf("snapshot=%s", snapshot) + } + _, err = terminal.ExpectSnapshot("terminal", tuitest.SnapshotOptions{Cwd: directory, IncludeStyle: true, Update: true}) + requireNoError(t, err) + requireNoError(t, terminal.Submit(tuitest.Ptr("quit"))) + requireNoError(t, terminal.WaitExit(tuitest.WaitOptions{Timeout: tuitest.Ptr(10 * time.Second)})) +} + +func TestRestartAndFailureAwareTraceClose(t *testing.T) { + traceDirectory := t.TempDir() + terminal := newClient(t, tuitest.ClientOptions{Trace: &tuitest.TraceOptions{Mode: tuitest.TraceOn, Directory: traceDirectory}}) + runTerminal(t, terminal, tuitest.SpawnOptions{}) + result, err := terminal.Restart(tuitest.RestartOptions{GracefulTimeout: tuitest.Ptr(2 * time.Second)}) + requireNoError(t, err) + if result.Session != terminal.Session() { + t.Fatalf("restart result=%+v", result) + } + requireNoError(t, terminal.GetByText("go-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(10 * time.Second)})) + requireNoError(t, terminal.Close(tuitest.CloseOptions{Failed: tuitest.Ptr(true)})) + entries, err := os.ReadDir(traceDirectory) + requireNoError(t, err) + if len(entries) == 0 { + t.Fatal("failure-aware close did not retain a trace") + } +} + +func TestScreenHistoryLimitValidation(t *testing.T) { + _, err := tuitest.New("history", tuitest.ClientOptions{ScreenHistoryLimit: tuitest.Ptr(uint16(51))}) + requireKind(t, err, tuitest.UsageError) +} + +func TestNamedHandlesReopen(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + peer, err := tuitest.New(terminal.Session(), tuitest.ClientOptions{}) + requireNoError(t, err) + names, err := tuitest.Sessions() + requireNoError(t, err) + if !slices.Contains(names, terminal.Session()) { + t.Fatalf("missing session in %v", names) + } + requireNoError(t, terminal.Close()) + _, err = peer.Text(tuitest.TextOptions{}) + requireKind(t, err, tuitest.NoSessionError) + runTerminal(t, terminal, tuitest.SpawnOptions{}) + requireNoError(t, peer.GetByText("go-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) +} + +func TestCloseInterruptsPendingWait(t *testing.T) { + t.Skip("Known upstream limitation accepted for this binding: named Close blocks behind pending waits; https://github.com/microsoft/tui-test/issues/207") + terminal := newTerminal(t, tuitest.ClientOptions{}) + verifyWaitInterruption(t, terminal, func() error { return terminal.Close() }) +} + +func TestCloseAllInterruptsPendingWait(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + verifyWaitInterruption(t, terminal, tuitest.CloseAll) +} + +func verifyWaitInterruption(t *testing.T, terminal *tuitest.Client, closeSession func() error) { + t.Helper() + started := make(chan struct{}) + waitResult := make(chan error, 1) + go func() { + close(started) + waitResult <- terminal.GetByText("never-visible", tuitest.TextSelectorOptions{}).Wait(tuitest.LocatorWaitOptions{Timeout: tuitest.Ptr(5 * time.Second)}) + }() + <-started + select { + case err := <-waitResult: + t.Fatalf("wait returned before close: %v", err) + case <-time.After(100 * time.Millisecond): + } + closeResult := make(chan error, 1) + go func() { closeResult <- closeSession() }() + select { + case err := <-closeResult: + requireNoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("close blocked behind the pending wait") + } + select { + case err := <-waitResult: + if err == nil { + t.Fatal("interrupted wait unexpectedly succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("wait did not return after close") + } +} + +func TestTimeoutOptionsAndTypedFailures(t *testing.T) { + _, err := tuitest.New("", tuitest.ClientOptions{Timeouts: tuitest.Timeouts{Text: tuitest.Ptr(-time.Nanosecond)}}) + requireKind(t, err, tuitest.UsageError) + terminal := newTerminal(t, tuitest.ClientOptions{Timeouts: tuitest.Timeouts{Text: tuitest.Ptr(time.Duration(0))}, Artifacts: &tuitest.ArtifactOptions{Dir: t.TempDir(), OnFailure: tuitest.ArtifactAll}}) + requireNoError(t, terminal.Type("typed input")) + err = terminal.GetByText("missing", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{}) + requireKind(t, err, tuitest.AssertionError) + var failure *tuitest.Error + if !errors.As(err, &failure) || failure.Details == nil || failure.Artifact == nil || failure.Artifact.ScreenSVG == nil { + t.Fatalf("missing diagnostic/artifact: %#v", failure) + } + if failure.Details.Operation != "locator.expect" { + t.Fatalf("failure operation=%q", failure.Details.Operation) + } + _, err = terminal.Screenshot("", tuitest.ScreenshotOptions{Zoom: tuitest.Ptr(2.0)}) + requireKind(t, err, tuitest.UsageError) + requireNoError(t, terminal.Close()) + runTerminal(t, terminal, tuitest.SpawnOptions{Timeouts: tuitest.Timeouts{Text: tuitest.Ptr(1500 * time.Microsecond), Idle: tuitest.Ptr(time.Duration(0))}}) + state, err := terminal.State() + requireNoError(t, err) + if state.Timeouts.Text != 2*time.Millisecond || state.Timeouts.Idle != 0 { + t.Fatalf("effective timeouts=%+v", state.Timeouts) + } +} + +func TestPublicOperationNamesReachDiagnostics(t *testing.T) { + traceDirectory := t.TempDir() + terminal := newTerminal(t, tuitest.ClientOptions{ + Timeouts: tuitest.Timeouts{Text: tuitest.Ptr(time.Duration(0))}, + Trace: &tuitest.TraceOptions{Mode: tuitest.TraceOn, Directory: traceDirectory}, + }) + requireNoError(t, terminal.Type("typed input")) + err := terminal.GetByText("missing", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{}) + requireKind(t, err, tuitest.AssertionError) + var failure *tuitest.Error + if !errors.As(err, &failure) || failure.Details == nil || failure.Details.Operation != "locator.expect" { + t.Fatalf("failure details=%#v", failure) + } + requireNoError(t, terminal.Close(tuitest.CloseOptions{Failed: tuitest.Ptr(true)})) + + manifests, err := filepath.Glob(filepath.Join(traceDirectory, "*", "trace.json")) + requireNoError(t, err) + if len(manifests) != 1 { + t.Fatalf("trace manifests=%v", manifests) + } + manifestData, err := os.ReadFile(manifests[0]) + requireNoError(t, err) + var manifest struct { + RecentOperations []struct { + Name string `json:"name"` + } `json:"recent_operations"` + } + requireNoError(t, json.Unmarshal(manifestData, &manifest)) + if !slices.ContainsFunc(manifest.RecentOperations, func(operation struct { + Name string `json:"name"` + }) bool { + return operation.Name == "type" + }) { + t.Fatalf("recent operations=%+v", manifest.RecentOperations) + } +} + +func TestDefaultRecordingIsDisabled(t *testing.T) { + terminal, err := tuitest.Ephemeral(t.Name(), tuitest.ClientOptions{}) + requireNoError(t, err) + t.Cleanup(func() { _ = terminal.Close() }) + executable, err := os.Executable() + requireNoError(t, err) + result, err := terminal.Run(executable, []string{"-test.run=^TestTerminalProcess$", "--", "--tui-go-child"}, tuitest.SpawnOptions{WaitReady: tuitest.Ptr(false)}) + requireNoError(t, err) + if result.Recording != "" { + t.Fatalf("default recording path=%q", result.Recording) + } +} + +func TestRecordingAndCloseAll(t *testing.T) { + directory := t.TempDir() + terminal := newTerminal(t, tuitest.ClientOptions{Recording: &tuitest.AutomaticRecording{Mode: tuitest.RecordingAlways, Directory: directory}}) + recording, err := tuitest.Recording(terminal.Session()) + requireNoError(t, err) + if !strings.Contains(recording, `"version":2`) { + t.Fatal("missing automatic recording content") + } + requireNoError(t, terminal.StartRecording(filepath.Join(directory, "explicit.cast"), tuitest.RecordingOptions{Format: tuitest.Cast})) + requireNoError(t, terminal.Submit(tuitest.Ptr("recorded"))) + requireNoError(t, terminal.GetByText("received:recorded", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) + explicit, err := terminal.StopRecording() + requireNoError(t, err) + if _, statErr := os.Stat(explicit); statErr != nil { + t.Fatal(statErr) + } + requireNoError(t, tuitest.CloseAll()) + retained, err := tuitest.Recording(terminal.Session()) + requireNoError(t, err) + if !strings.Contains(retained, "recorded") { + t.Fatalf("retained recording omitted terminal output: %q", retained) + } +} diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go new file mode 100644 index 00000000..733da79b --- /dev/null +++ b/bindings/go/internal/native/abi.go @@ -0,0 +1,222 @@ +package native + +import ( + "structs" + "unsafe" +) + +// These host-layout structures mirror the private C ABI in native.h. +type abiString struct { + _ structs.HostLayout + data *uint8 + len uintptr +} +type abiOptionalI32 struct { + _ structs.HostLayout + present bool + value int32 +} +type abiOptionalU64 struct { + _ structs.HostLayout + present bool + value uint64 +} +type abiOpenResult struct { + _ structs.HostLayout + shellPID abiOptionalU64 + session abiString + ready bool + recording abiString +} +type abiCursor struct { + _ structs.HostLayout + x uint16 + y uint16 + visible bool + shape abiString + color abiString +} +type abiTimeouts struct { + _ structs.HostLayout + text abiOptionalU64 + idle abiOptionalU64 + command abiOptionalU64 + exit abiOptionalU64 + ready abiOptionalU64 +} +type abiState struct { + _ structs.HostLayout + sessionShell abiString + cols uint16 + rows uint16 + cursor abiCursor + title abiString + cwd abiString + lastCommand abiString + lastExit abiOptionalI32 + exitSignal abiString + exited abiOptionalI32 + ready bool + bellCount uint64 + modes *abiMode + modesLen uintptr + mouseMode abiString + colors abiTerminalColors + timeouts abiTimeouts + text abiString +} +type abiMode struct { + _ structs.HostLayout + name abiString + enabled bool +} +type abiTerminalColors struct { + _ structs.HostLayout + foreground abiString + background abiString + cursor abiString + palette *abiPair + paletteLen uintptr +} +type abiSize struct { + _ structs.HostLayout + cols uint16 + rows uint16 +} +type abiColor struct { + _ structs.HostLayout + kind uint32 + index uint8 + red uint8 + green uint8 + blue uint8 +} +type abiCell struct { + _ structs.HostLayout + x uint16 + y uint16 + character abiString + fg abiColor + bg abiColor + bold bool + dim bool + italic bool + inverse bool + invisible bool + strike bool + blink bool + underline bool + underlineStyle abiString + underlineColor abiColor + link abiString + linkID abiString +} +type abiPosition struct { + _ structs.HostLayout + row uint32 + column uint16 +} +type abiSpan struct { + _ structs.HostLayout + row uint32 + start uint16 + end uint16 +} +type abiMatch struct { + _ structs.HostLayout + text abiString + start abiPosition + end abiPosition + spans *abiSpan + spansLen uintptr +} +type abiBellEvent struct { + _ structs.HostLayout + sequence uint64 + elapsedMS uint64 +} +type abiResult struct { + _ structs.HostLayout + errorKind uint32 + errorMessage abiString + errorDetails abiString + errorArtifact abiString + text abiString + number uint64 + exitCode abiOptionalI32 + open abiOpenResult + state abiState + cursor abiCursor + size abiSize + cells *abiCell + cellsLen uintptr + matches *abiMatch + matchesLen uintptr + bells *abiBellEvent + bellsLen uintptr + strings *abiString + stringsLen uintptr + snapshot uint32 + privateData unsafe.Pointer +} +type abiPair struct { + _ structs.HostLayout + key abiString + value abiString +} +type abiOptionalBool struct { + _ structs.HostLayout + present bool + value bool +} +type abiOpenOptions struct { + _ structs.HostLayout + backend abiString + shell abiString + cols abiOptionalU64 + rows abiOptionalU64 + cwd abiString + env *abiPair + envLen uintptr + waitReady abiOptionalBool + restart bool + scrollback abiOptionalU64 + colors *abiPair + colorsLen uintptr + timeouts abiTimeouts + recordingMode abiString + recordingDirectory abiString +} +type abiMouseOptions struct { + _ structs.HostLayout + button uint32 + alt bool + ctrl bool + shift bool +} +type abiWaitOptions struct { + _ structs.HostLayout + timeoutMS abiOptionalU64 + regex bool + not bool +} +type abiQuery struct { + _ structs.HostLayout + json abiString +} +type abiOptionalF64 struct { + _ structs.HostLayout + present bool + value float64 +} +type abiRecordingOptions struct { + _ structs.HostLayout + path abiString + format abiString + fps abiOptionalU64 + speed abiOptionalF64 + idleTimeLimit abiOptionalF64 + zoom abiOptionalF64 + background abiString + transparent bool +} diff --git a/bindings/go/internal/native/functions.go b/bindings/go/internal/native/functions.go new file mode 100644 index 00000000..09c2fd96 --- /dev/null +++ b/bindings/go/internal/native/functions.go @@ -0,0 +1,140 @@ +package native + +import "slices" + +type nativeFunctionTable struct { + AbiVersion func() uint32 + ResultFree func(*abiResult) + Open func(abiString, *abiOpenOptions) *abiResult + Run func(abiString, *abiOpenOptions, abiString, *abiString, uintptr) *abiResult + Sessions func() *abiResult + CloseAll func() *abiResult + Recording func(abiString) *abiResult + Close func(abiString, abiOptionalBool) *abiResult + Restart func(abiString, uint64) *abiResult + State func(abiString) *abiResult + GetCommand func(abiString) *abiResult + GetOutput func(abiString) *abiResult + GetExitCode func(abiString) *abiResult + GetCwd func(abiString) *abiResult + GetCursor func(abiString) *abiResult + GetSize func(abiString) *abiResult + GetTitle func(abiString) *abiResult + GetClipboard func(abiString) *abiResult + GetBellCount func(abiString) *abiResult + GetBellEvents func(abiString) *abiResult + StopRecording func(abiString) *abiResult + Text func(abiString, bool) *abiResult + PackedScreen func(abiString, bool) *abiResult + Cells func(abiString, uint16, uint16, uint16, uint16) *abiResult + Write func(abiString, abiString) *abiResult + Type func(abiString, abiString) *abiResult + Submit func(abiString, abiString) *abiResult + Signal func(abiString, abiString) *abiResult + Key func(abiString, *abiString, uintptr, uint32) *abiResult + Resize func(abiString, uint16, uint16) *abiResult + MouseClick func(abiString, abiOptionalU64, abiOptionalU64, abiString, abiMouseOptions, uint8) *abiResult + MouseMove func(abiString, uint16, uint16) *abiResult + MouseDown func(abiString, uint16, uint16, abiMouseOptions) *abiResult + MouseUp func(abiString, uint16, uint16, abiMouseOptions) *abiResult + MouseDrag func(abiString, uint16, uint16, uint16, uint16, abiMouseOptions) *abiResult + MouseScroll func(abiString, abiString, uint16) *abiResult + WaitTitle func(abiString, abiString, abiWaitOptions) *abiResult + ExpectTitle func(abiString, abiString, abiWaitOptions) *abiResult + WaitClipboard func(abiString, abiString, abiWaitOptions) *abiResult + WaitIdle func(abiString, abiOptionalU64) *abiResult + WaitCommand func(abiString, abiOptionalU64) *abiResult + WaitExit func(abiString, abiOptionalU64) *abiResult + WaitReady func(abiString, abiOptionalU64) *abiResult + WaitBell func(abiString, abiOptionalU64) *abiResult + FindLocator func(abiString, abiQuery, bool) *abiResult + WaitLocator func(abiString, abiQuery, abiWaitOptions) *abiResult + ExpectLocator func(abiString, abiQuery, abiWaitOptions) *abiResult + ClickLocator func(abiString, abiQuery, abiMouseOptions, uint8, abiOptionalU64) *abiResult + HighlightLocator func(abiString, abiQuery, abiOptionalU64) *abiResult + ExpectExitCode func(abiString, int32, abiOptionalU64) *abiResult + ExpectOutput func(abiString, abiString, bool) *abiResult + ExpectBellCount func(abiString, uint64, abiOptionalU64) *abiResult + Snapshot func(abiString, abiString, bool, bool, bool, abiString) *abiResult + Screenshot func(abiString, bool, abiString, abiOptionalF64, abiString, bool) *abiResult + StartRecording func(abiString, abiRecordingOptions) *abiResult +} + +func nativeRegistrations(table *nativeFunctionTable) []nativeRegistration { + return slices.Concat(nativeLifecycleRegistrations(table), nativeInputRegistrations(table), nativeInspectionRegistrations(table), nativeWaitingRegistrations(table), nativeArtifactsRegistrations(table)) +} +func nativeLifecycleRegistrations(table *nativeFunctionTable) []nativeRegistration { + return []nativeRegistration{ + {symbol: "tui_abi_version", target: &table.AbiVersion}, + {symbol: "tui_result_free", target: &table.ResultFree}, + {symbol: "tui_open_ptr", target: &table.Open}, + {symbol: "tui_run_ptr", target: &table.Run}, + {symbol: "tui_sessions", target: &table.Sessions}, + {symbol: "tui_close_all", target: &table.CloseAll}, + {symbol: "tui_recording", target: &table.Recording}, + {symbol: "tui_close", target: &table.Close}, + {symbol: "tui_restart", target: &table.Restart}, + } +} +func nativeInputRegistrations(table *nativeFunctionTable) []nativeRegistration { + return []nativeRegistration{ + {symbol: "tui_write", target: &table.Write}, + {symbol: "tui_type", target: &table.Type}, + {symbol: "tui_submit", target: &table.Submit}, + {symbol: "tui_signal", target: &table.Signal}, + {symbol: "tui_key", target: &table.Key}, + {symbol: "tui_resize", target: &table.Resize}, + {symbol: "tui_mouse_click", target: &table.MouseClick}, + {symbol: "tui_mouse_move", target: &table.MouseMove}, + {symbol: "tui_mouse_down", target: &table.MouseDown}, + {symbol: "tui_mouse_up", target: &table.MouseUp}, + {symbol: "tui_mouse_drag", target: &table.MouseDrag}, + {symbol: "tui_mouse_scroll", target: &table.MouseScroll}, + } +} +func nativeInspectionRegistrations(table *nativeFunctionTable) []nativeRegistration { + return []nativeRegistration{ + {symbol: "tui_state", target: &table.State}, + {symbol: "tui_get_command", target: &table.GetCommand}, + {symbol: "tui_get_output", target: &table.GetOutput}, + {symbol: "tui_get_exit_code", target: &table.GetExitCode}, + {symbol: "tui_get_cwd", target: &table.GetCwd}, + {symbol: "tui_get_cursor", target: &table.GetCursor}, + {symbol: "tui_get_size", target: &table.GetSize}, + {symbol: "tui_get_title", target: &table.GetTitle}, + {symbol: "tui_get_clipboard", target: &table.GetClipboard}, + {symbol: "tui_get_bell_count", target: &table.GetBellCount}, + {symbol: "tui_get_bell_events", target: &table.GetBellEvents}, + {symbol: "tui_text", target: &table.Text}, + {symbol: "tui_packed_screen", target: &table.PackedScreen}, + {symbol: "tui_cells", target: &table.Cells}, + } +} +func nativeWaitingRegistrations(table *nativeFunctionTable) []nativeRegistration { + return []nativeRegistration{ + {symbol: "tui_wait_title", target: &table.WaitTitle}, + {symbol: "tui_expect_title", target: &table.ExpectTitle}, + {symbol: "tui_wait_clipboard", target: &table.WaitClipboard}, + {symbol: "tui_wait_idle", target: &table.WaitIdle}, + {symbol: "tui_wait_command", target: &table.WaitCommand}, + {symbol: "tui_wait_exit", target: &table.WaitExit}, + {symbol: "tui_wait_ready", target: &table.WaitReady}, + {symbol: "tui_wait_bell", target: &table.WaitBell}, + {symbol: "tui_find_locator", target: &table.FindLocator}, + {symbol: "tui_wait_locator", target: &table.WaitLocator}, + {symbol: "tui_expect_locator", target: &table.ExpectLocator}, + {symbol: "tui_click_locator", target: &table.ClickLocator}, + {symbol: "tui_highlight_locator", target: &table.HighlightLocator}, + {symbol: "tui_expect_exit_code", target: &table.ExpectExitCode}, + {symbol: "tui_expect_output", target: &table.ExpectOutput}, + {symbol: "tui_expect_bell_count", target: &table.ExpectBellCount}, + } +} +func nativeArtifactsRegistrations(table *nativeFunctionTable) []nativeRegistration { + return []nativeRegistration{ + {symbol: "tui_stop_recording", target: &table.StopRecording}, + {symbol: "tui_snapshot", target: &table.Snapshot}, + {symbol: "tui_screenshot", target: &table.Screenshot}, + {symbol: "tui_start_recording", target: &table.StartRecording}, + } +} diff --git a/bindings/go/internal/native/input.go b/bindings/go/internal/native/input.go new file mode 100644 index 00000000..bdc739d1 --- /dev/null +++ b/bindings/go/internal/native/input.go @@ -0,0 +1,121 @@ +package native + +import ( + "encoding/json" + "time" +) + +func nativeDuration(value *time.Duration) (abiOptionalU64, error) { + if value == nil { + return abiOptionalU64{}, nil + } + milliseconds, err := Milliseconds(*value) + if err != nil { + return abiOptionalU64{}, err + } + return nativeUint(&milliseconds), nil +} + +func nativeTimeouts(timeouts Timeouts) (abiTimeouts, error) { + result := abiTimeouts{} + targets := []*abiOptionalU64{&result.text, &result.idle, &result.command, &result.exit, &result.ready} + for index, value := range []*time.Duration{timeouts.Text, timeouts.Idle, timeouts.Command, timeouts.Exit, timeouts.Ready} { + converted, err := nativeDuration(value) + if err != nil { + return result, err + } + *targets[index] = converted + } + return result, nil +} + +func (memory *nativeMemory) colors(colors Colors) (*abiPair, uintptr) { + values := map[string]string{ + "foreground": colors.Foreground, "background": colors.Background, "cursor": colors.Cursor, + "black": colors.Black, "red": colors.Red, "green": colors.Green, "yellow": colors.Yellow, "blue": colors.Blue, "magenta": colors.Magenta, "cyan": colors.Cyan, "white": colors.White, + "bright_black": colors.BrightBlack, "bright_red": colors.BrightRed, "bright_green": colors.BrightGreen, "bright_yellow": colors.BrightYellow, "bright_blue": colors.BrightBlue, "bright_magenta": colors.BrightMagenta, "bright_cyan": colors.BrightCyan, "bright_white": colors.BrightWhite, + } + for key, value := range values { + if value == "" { + delete(values, key) + } + } + return memory.pairs(values) +} + +func (memory *nativeMemory) openOptions(options OpenOptions, recording *AutomaticRecording) (abiOpenOptions, error) { + timeouts, err := nativeTimeouts(options.Timeouts) + if err != nil { + return abiOpenOptions{}, err + } + result := abiOpenOptions{backend: memory.nonemptyText(string(options.Backend)), shell: memory.nonemptyText(string(options.Shell)), cols: nativeUint(options.Cols), rows: nativeUint(options.Rows), cwd: memory.nonemptyText(options.Cwd), waitReady: nativeBool(options.WaitReady), timeouts: timeouts} + if options.Restart != nil { + result.restart = *options.Restart + } + result.env, result.envLen = memory.pairs(options.Env) + if options.Profile != nil { + result.scrollback = nativeUint(options.Profile.Scrollback) + result.colors, result.colorsLen = memory.colors(options.Profile.Colors) + } + if recording != nil { + result.recordingMode = memory.nonemptyText(string(recording.Mode)) + result.recordingDirectory = memory.nonemptyText(recording.Directory) + } + return result, nil +} + +func nativeBool(value *bool) abiOptionalBool { + if value == nil { + return abiOptionalBool{} + } + return abiOptionalBool{present: true, value: *value} +} + +func nativeUint[Number ~uint16 | ~uint32 | ~uint64](value *Number) abiOptionalU64 { + if value == nil { + return abiOptionalU64{} + } + return abiOptionalU64{present: true, value: uint64(*value)} +} + +func nativeFloat(value *float64) abiOptionalF64 { + if value == nil { + return abiOptionalF64{} + } + return abiOptionalF64{present: true, value: float64(*value)} +} + +func (memory *nativeMemory) query(expression LocatorExpression) (abiQuery, error) { + if len(expression.Nodes) == 0 { + return abiQuery{}, &Error{Kind: UsageError, Message: "locator requires at least one stage"} + } + value, err := json.Marshal(expression) + if err != nil { + return abiQuery{}, &Error{Kind: InternalError, Message: "encode locator expression: " + err.Error()} + } + return abiQuery{json: memory.text(string(value))}, nil +} + +func nativeMouse(options MouseButtonOptions) (abiMouseOptions, error) { + result := abiMouseOptions{alt: options.Alt, ctrl: options.Ctrl, shift: options.Shift} + switch options.Button { + case "", Left: + case Middle: + result.button = 1 + case Right: + result.button = 2 + default: + return result, &Error{Kind: UsageError, Message: "unknown mouse button"} + } + return result, nil +} + +func nativeClicks(clicks *uint32) (uint8, error) { + if clicks == nil { + return 1, nil + } + if *clicks > 255 { + return 0, &Error{Kind: UsageError, Message: "clicks must be between 0 and 255"} + } + return uint8(*clicks), nil +} diff --git a/bindings/go/internal/native/loader.go b/bindings/go/internal/native/loader.go new file mode 100644 index 00000000..186b521a --- /dev/null +++ b/bindings/go/internal/native/loader.go @@ -0,0 +1,63 @@ +package native + +import ( + "errors" + "fmt" + "os" + "sync" + + "github.com/ebitengine/purego" +) + +type nativeRegistration struct { + symbol string + target any +} + +// Registered functions and the loaded library stay live for the process lifetime. +var nativeFunctions nativeFunctionTable +var loadNativeEngine = sync.OnceValue(initializeNativeEngine) + +func initializeNativeEngine() error { + path := os.Getenv("TUI_TEST_GO_NATIVE_LIBRARY") + if path == "" { + return errors.New("TUI_TEST_GO_NATIVE_LIBRARY must name the native engine library") + } + table, err := loadNativeFunctions(path) + if err != nil { + return fmt.Errorf("load native engine from TUI_TEST_GO_NATIVE_LIBRARY: %w", err) + } + nativeFunctions = table + return nil +} + +func loadNativeFunctions(path string) (nativeFunctionTable, error) { + table := nativeFunctionTable{} + library, err := openNativeLibrary(path) + if err != nil { + return table, err + } + for _, registration := range nativeRegistrations(&table) { + if registerErr := registerNativeFunction(library, registration); registerErr != nil { + return nativeFunctionTable{}, errors.Join(registerErr, library.close()) + } + } + if version := table.AbiVersion(); version != 3 { + return nativeFunctionTable{}, errors.Join(fmt.Errorf("native ABI version %d is incompatible with required version 3", version), library.close()) + } + return table, nil +} + +func registerNativeFunction(library *nativeLibraryHandle, registration nativeRegistration) (err error) { + address, err := library.symbol(registration.symbol) + if err != nil { + return err + } + defer func() { + if failure := recover(); failure != nil { + err = fmt.Errorf("register native function %s: %v", registration.symbol, failure) + } + }() + purego.RegisterFunc(registration.target, address) + return nil +} diff --git a/bindings/go/internal/native/loader_test.go b/bindings/go/internal/native/loader_test.go new file mode 100644 index 00000000..412ef913 --- /dev/null +++ b/bindings/go/internal/native/loader_test.go @@ -0,0 +1,24 @@ +package native + +import ( + "strings" + "testing" +) + +func TestInitializeNativeEngineRequiresConfiguredLibrary(t *testing.T) { + t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", "") + + err := initializeNativeEngine() + if err == nil || !strings.Contains(err.Error(), "TUI_TEST_GO_NATIVE_LIBRARY must name") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeNativeEngineReportsConfiguredLibraryFailure(t *testing.T) { + t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", t.TempDir()) + + err := initializeNativeEngine() + if err == nil || !strings.Contains(err.Error(), "load native engine from TUI_TEST_GO_NATIVE_LIBRARY") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/bindings/go/internal/native/loader_unix.go b/bindings/go/internal/native/loader_unix.go new file mode 100644 index 00000000..ec2756ad --- /dev/null +++ b/bindings/go/internal/native/loader_unix.go @@ -0,0 +1,32 @@ +//go:build linux || darwin || freebsd || netbsd + +package native + +import ( + "fmt" + + "github.com/ebitengine/purego" +) + +type nativeLibraryHandle struct{ library uintptr } + +func openNativeLibrary(path string) (*nativeLibraryHandle, error) { + library, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL) + if err != nil { + return nil, fmt.Errorf("load native library %q: %w", path, err) + } + return &nativeLibraryHandle{library: library}, nil +} +func (handle *nativeLibraryHandle) symbol(name string) (uintptr, error) { + address, err := purego.Dlsym(handle.library, name) + if err != nil { + return 0, fmt.Errorf("find native symbol %q: %w", name, err) + } + return address, nil +} +func (handle *nativeLibraryHandle) close() error { + if err := purego.Dlclose(handle.library); err != nil { + return fmt.Errorf("release native library: %w", err) + } + return nil +} diff --git a/bindings/go/internal/native/loader_windows.go b/bindings/go/internal/native/loader_windows.go new file mode 100644 index 00000000..5d973815 --- /dev/null +++ b/bindings/go/internal/native/loader_windows.go @@ -0,0 +1,29 @@ +package native + +import ( + "fmt" + "syscall" +) + +type nativeLibraryHandle struct{ library *syscall.DLL } + +func openNativeLibrary(path string) (*nativeLibraryHandle, error) { + library, err := syscall.LoadDLL(path) + if err != nil { + return nil, fmt.Errorf("load native library %q: %w", path, err) + } + return &nativeLibraryHandle{library: library}, nil +} +func (handle *nativeLibraryHandle) symbol(name string) (uintptr, error) { + procedure, err := handle.library.FindProc(name) + if err != nil { + return 0, fmt.Errorf("find native symbol %q: %w", name, err) + } + return procedure.Addr(), nil +} +func (handle *nativeLibraryHandle) close() error { + if err := handle.library.Release(); err != nil { + return fmt.Errorf("release native library: %w", err) + } + return nil +} diff --git a/bindings/go/internal/native/memory.go b/bindings/go/internal/native/memory.go new file mode 100644 index 00000000..1f1143c0 --- /dev/null +++ b/bindings/go/internal/native/memory.go @@ -0,0 +1,63 @@ +package native + +import ( + "runtime" +) + +// nativeMemory pins input buffers until the synchronous Rust call returns. +// Rust copies these buffers and does not retain Go pointers. +type nativeMemory struct{ pinner runtime.Pinner } + +func (memory *nativeMemory) release() { + memory.pinner.Unpin() +} + +func (memory *nativeMemory) text(value string) abiString { + buffer := make([]byte, len(value)+1) + copy(buffer, value) + pointer := &buffer[0] + memory.pinner.Pin(pointer) + return abiString{data: pointer, len: uintptr(len(value))} +} + +func (memory *nativeMemory) optionalText(value *string) abiString { + if value == nil { + return abiString{} + } + return memory.text(*value) +} + +func (memory *nativeMemory) nonemptyText(value string) abiString { + if value == "" { + return abiString{} + } + return memory.text(value) +} + +func (memory *nativeMemory) pairs(values map[string]string) (*abiPair, uintptr) { + if len(values) == 0 { + return nil, 0 + } + entries := make([]abiPair, len(values)) + pointer := &entries[0] + memory.pinner.Pin(pointer) + index := 0 + for key, value := range values { + entries[index] = abiPair{key: memory.text(key), value: memory.text(value)} + index++ + } + return pointer, uintptr(len(values)) +} + +func (memory *nativeMemory) strings(values []string) (*abiString, uintptr) { + if len(values) == 0 { + return nil, 0 + } + entries := make([]abiString, len(values)) + pointer := &entries[0] + memory.pinner.Pin(pointer) + for index, value := range values { + entries[index] = memory.text(value) + } + return pointer, uintptr(len(values)) +} diff --git a/bindings/go/internal/native/models.go b/bindings/go/internal/native/models.go new file mode 100644 index 00000000..2723f2ed --- /dev/null +++ b/bindings/go/internal/native/models.go @@ -0,0 +1,325 @@ +package native + +import "time" + +type Backend string + +const ( + Alacritty Backend = "alacritty" + Ghostty Backend = "ghostty" + Rio Backend = "rio" + XtermJS Backend = "xtermjs" +) + +type Shell string + +const ( + Bash Shell = "bash" + PowerShell Shell = "powershell" + Pwsh Shell = "pwsh" + Cmd Shell = "cmd" + Fish Shell = "fish" + Zsh Shell = "zsh" + Xonsh Shell = "xonsh" + Elvish Shell = "elvish" + Nushell Shell = "nushell" +) + +type UnderlineStyle string + +const ( + UnderlineNone UnderlineStyle = "none" + UnderlineSingle UnderlineStyle = "single" + UnderlineDouble UnderlineStyle = "double" + UnderlineCurly UnderlineStyle = "curly" + UnderlineDotted UnderlineStyle = "dotted" + UnderlineDashed UnderlineStyle = "dashed" +) + +type RecordingMode string + +const ( + RecordingDisabled RecordingMode = "disabled" + RecordingOnFailure RecordingMode = "on-failure" + RecordingAlways RecordingMode = "always" +) + +type RecordingFormat string + +const ( + APNG RecordingFormat = "apng" + GIF RecordingFormat = "gif" + MP4 RecordingFormat = "mp4" + Cast RecordingFormat = "cast" +) + +type MouseButton string + +const ( + Left MouseButton = "left" + Middle MouseButton = "middle" + Right MouseButton = "right" +) + +type Direction string + +const ( + Within Direction = "within" + After Direction = "after" + Before Direction = "before" +) + +type Whitespace string + +const ( + Exact Whitespace = "exact" + Normalize Whitespace = "normalize" +) + +type ScrollDirection string + +const ( + Up ScrollDirection = "up" + Down ScrollDirection = "down" +) + +type Timeouts struct{ Text, Idle, Command, Exit, Ready *time.Duration } + +type EffectiveTimeouts struct{ Text, Idle, Command, Exit, Ready time.Duration } + +type Colors struct { + Foreground, Background, Cursor string + Black, Red, Green, Yellow, Blue, Magenta, Cyan, White string + BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite string +} + +type Profile struct { + Scrollback *uint32 + Colors Colors +} + +type AutomaticRecording struct { + Mode RecordingMode + Directory string +} +type ArtifactOptions struct { + Directory string + Mode string + IncludeRecording bool +} +type TraceOptions struct { + Mode, Directory string +} + +type SpawnOptions struct { + Backend Backend + Cols, Rows *uint16 + Cwd string + Env map[string]string + WaitReady, Restart *bool + Profile *Profile + Timeouts Timeouts +} +type SessionOptions struct { + Recording *AutomaticRecording + Artifacts *ArtifactOptions + Trace *TraceOptions + ScreenHistoryLimit *uint16 +} + +type OpenOptions struct { + SpawnOptions + Shell Shell +} + +type TitleOptions struct { + Regex, Not bool + Timeout *time.Duration +} + +type ClipboardWaitOptions struct { + Text *string + Regex bool + Timeout *time.Duration +} + +type ScreenshotOptions struct { + Full bool + Zoom *float64 + Background string + Transparent bool +} + +type RecordingOptions struct { + Format RecordingFormat + FPS *uint32 + Speed, IdleTimeLimit, Zoom *float64 + Background string + Transparent bool +} + +type SnapshotOptions struct { + Update, IncludeStyle, IncludeTitle bool + Cwd string +} + +type SnapshotResult string + +const ( + SnapshotPassed SnapshotResult = "passed" + SnapshotWritten SnapshotResult = "written" + SnapshotUpdated SnapshotResult = "updated" +) + +type TextStyle struct { + Foreground *string `json:"foreground,omitempty"` + Background *string `json:"background,omitempty"` + Bold *bool `json:"bold,omitempty"` + Dim *bool `json:"dim,omitempty"` + Italic *bool `json:"italic,omitempty"` + UnderlineStyle *UnderlineStyle `json:"underline_style,omitempty"` + UnderlineColor *string `json:"underline_color,omitempty"` + Inverse *bool `json:"inverse,omitempty"` + Hidden *bool `json:"hidden,omitempty"` + Strikethrough *bool `json:"strikethrough,omitempty"` + Blink *bool `json:"blink,omitempty"` +} + +type LocatorExpectOptions struct { + Not bool + Timeout *time.Duration +} + +type MouseButtonOptions struct { + Button MouseButton + Alt, Ctrl, Shift bool +} + +type MouseClickOptions struct { + MouseButtonOptions + X, Y *uint16 + OnText *string + Clicks *uint32 +} + +type LocatorClickOptions struct { + MouseButtonOptions + Clicks *uint32 + Timeout *time.Duration +} + +type Cursor struct { + X, Y uint16 + Visible bool + Shape string + Color string +} +type TerminalColors struct { + Foreground, Background, Cursor string + Palette map[uint8]string +} + +type Size struct{ Cols, Rows uint16 } + +type Color string + +type Cell struct { + X, Y uint16 + Char string + FG, BG Color + Bold, Dim, Italic, Inverse, Invisible, Strike, Blink, Underline bool + UnderlineStyle UnderlineStyle + UnderlineColor Color + Link, LinkID string +} + +type BellEvent struct { + Sequence uint64 + Elapsed time.Duration +} + +type TextPosition struct{ Row, Column uint32 } + +type TextSpan struct{ Row, Start, End uint32 } + +type TextMatch struct { + Text string + Start, End TextPosition + Spans []TextSpan +} + +type OpenResult struct { + ShellPID *uint32 + Session string + Ready bool + Recording string +} + +type State struct { + SessionShell *string + Cols, Rows uint16 + Cursor Cursor + Title, Cwd, LastCommand *string + LastExit *int32 + ExitSignal *string + Exited *int32 + Ready bool + BellCount uint64 + Modes map[string]bool + MouseMode string + Colors TerminalColors + Timeouts EffectiveTimeouts + Text string +} + +type ErrorKind string + +const ( + AssertionError ErrorKind = "assertion" + UsageError ErrorKind = "usage" + NoSessionError ErrorKind = "no_session" + InternalError ErrorKind = "internal" +) + +type Error struct { + Kind ErrorKind + Message string + DetailsJSON []byte + ArtifactJSON []byte +} + +func (failure *Error) Error() string { return failure.Message } +func pointerTo[Value any](value Value) *Value { return &value } + +type LocatorNode struct { + Kind string `json:"kind"` + Text *string `json:"text,omitempty"` + Regex *bool `json:"regex,omitempty"` + Whitespace *Whitespace `json:"whitespace,omitempty"` + Style *TextStyle `json:"style,omitempty"` + Link *string `json:"link,omitempty"` + Full *bool `json:"full,omitempty"` + Direction *Direction `json:"direction,omitempty"` + Within *uint32 `json:"within,omitempty"` + Left *uint32 `json:"left,omitempty"` + Right *uint32 `json:"right,omitempty"` + Input *uint32 `json:"input,omitempty"` + Has *uint32 `json:"has,omitempty"` + HasNot *uint32 `json:"has_not,omitempty"` + Occurrence any `json:"occurrence"` +} +type LocatorExpression struct { + Nodes []LocatorNode `json:"nodes"` + Root uint32 `json:"root"` +} + +func Milliseconds(timeout time.Duration) (uint64, error) { + if timeout < 0 { + return 0, &Error{Kind: UsageError, Message: "timeout must not be negative"} + } + milliseconds := uint64(timeout / time.Millisecond) + if timeout%time.Millisecond != 0 { + milliseconds++ + } + return milliseconds, nil +} diff --git a/bindings/go/internal/native/results.go b/bindings/go/internal/native/results.go new file mode 100644 index 00000000..5a128dfe --- /dev/null +++ b/bindings/go/internal/native/results.go @@ -0,0 +1,142 @@ +package native + +import ( + "fmt" + "math" + "strconv" + "time" + "unsafe" +) + +func nativeText(value abiString) string { + if value.data == nil { + return "" + } + return string(unsafe.Slice(value.data, value.len)) //nolint:gosec // G103: Copies Rust-owned bytes into a Go string before the owning result is freed. +} + +func nativeOptionalText(value abiString) *string { + if value.data == nil { + return nil + } + return pointerTo(nativeText(value)) +} + +func readOpenResult(result *abiResult) (OpenResult, error) { + if err := nativeError(result); err != nil { + return OpenResult{}, err + } + opened := result.open + converted := OpenResult{Session: nativeText(opened.session), Ready: opened.ready, Recording: nativeText(opened.recording)} + if opened.shellPID.present { + if opened.shellPID.value > math.MaxUint32 { + return OpenResult{}, &Error{Kind: InternalError, Message: "native process identifier exceeds uint32"} + } + converted.ShellPID = pointerTo(uint32(opened.shellPID.value)) + } + return converted, nil +} + +func nativeInt(value abiOptionalI32) *int32 { + if !value.present { + return nil + } + return pointerTo(value.value) +} + +func nativeError(result *abiResult) error { + if result == nil { + return &Error{Kind: InternalError, Message: "native engine returned no result"} + } + if result.errorKind == 0 { + return nil + } + kind := InternalError + switch result.errorKind { + case 1: + kind = AssertionError + case 2: + kind = UsageError + case 3: + kind = NoSessionError + } + return &Error{Kind: kind, Message: nativeText(result.errorMessage), DetailsJSON: []byte(nativeText(result.errorDetails)), ArtifactJSON: []byte(nativeText(result.errorArtifact))} +} + +func nativeColor(value abiColor) Color { + switch value.kind { + case 1: + return Color(strconv.Itoa(int(value.index))) + case 2: + return Color(fmt.Sprintf("#%02x%02x%02x", value.red, value.green, value.blue)) + default: + return "default" + } +} + +func nativeCells(result *abiResult) []Cell { + cells := make([]Cell, int(result.cellsLen)) + for index, cell := range unsafe.Slice(result.cells, int(result.cellsLen)) { //nolint:gosec // G103: Rust supplies the cell buffer and length; all fields are copied before ResultFree. + cells[index] = Cell{X: cell.x, Y: cell.y, Char: nativeText(cell.character), FG: nativeColor(cell.fg), BG: nativeColor(cell.bg), Bold: cell.bold, Dim: cell.dim, Italic: cell.italic, Inverse: cell.inverse, Invisible: cell.invisible, Strike: cell.strike, Blink: cell.blink, Underline: cell.underline, UnderlineStyle: UnderlineStyle(nativeText(cell.underlineStyle)), UnderlineColor: nativeColor(cell.underlineColor), Link: nativeText(cell.link), LinkID: nativeText(cell.linkID)} + } + return cells +} + +func nativeMatches(result *abiResult) []TextMatch { + matches := make([]TextMatch, int(result.matchesLen)) + for index, match := range unsafe.Slice(result.matches, int(result.matchesLen)) { //nolint:gosec // G103: Rust supplies the match buffer and length; nested data is copied before ResultFree. + spans := make([]TextSpan, int(match.spansLen)) + for spanIndex, span := range unsafe.Slice(match.spans, int(match.spansLen)) { //nolint:gosec // G103: The owning Rust result keeps spans alive until this copy finishes, before ResultFree. + spans[spanIndex] = TextSpan{Row: span.row, Start: uint32(span.start), End: uint32(span.end)} + } + matches[index] = TextMatch{Text: nativeText(match.text), Start: TextPosition{Row: match.start.row, Column: uint32(match.start.column)}, End: TextPosition{Row: match.end.row, Column: uint32(match.end.column)}, Spans: spans} + } + return matches +} + +func nativeState(value abiState) State { + modes := make(map[string]bool, int(value.modesLen)) + for _, mode := range unsafe.Slice(value.modes, int(value.modesLen)) { //nolint:gosec // Rust-owned result remains live while values are copied. + modes[nativeText(mode.name)] = mode.enabled + } + return State{SessionShell: nativeOptionalText(value.sessionShell), Cols: value.cols, Rows: value.rows, Cursor: nativeCursor(value.cursor), Title: nativeOptionalText(value.title), Cwd: nativeOptionalText(value.cwd), LastCommand: nativeOptionalText(value.lastCommand), LastExit: nativeInt(value.lastExit), ExitSignal: nativeOptionalText(value.exitSignal), Exited: nativeInt(value.exited), Ready: value.ready, BellCount: value.bellCount, Modes: modes, MouseMode: nativeText(value.mouseMode), Colors: nativeTerminalColors(value.colors), Text: nativeText(value.text), Timeouts: EffectiveTimeouts{Text: nativeMilliseconds(value.timeouts.text.value), Idle: nativeMilliseconds(value.timeouts.idle.value), Command: nativeMilliseconds(value.timeouts.command.value), Exit: nativeMilliseconds(value.timeouts.exit.value), Ready: nativeMilliseconds(value.timeouts.ready.value)}} +} + +func nativeCursor(value abiCursor) Cursor { + return Cursor{X: value.x, Y: value.y, Visible: value.visible, Shape: nativeText(value.shape), Color: nativeText(value.color)} +} + +func nativeTerminalColors(value abiTerminalColors) TerminalColors { + palette := make(map[uint8]string, int(value.paletteLen)) + for _, entry := range unsafe.Slice(value.palette, int(value.paletteLen)) { //nolint:gosec // Rust-owned result remains live while values are copied. + index, err := strconv.ParseUint(nativeText(entry.key), 10, 8) + if err == nil { + palette[uint8(index)] = nativeText(entry.value) + } + } + return TerminalColors{Foreground: nativeText(value.foreground), Background: nativeText(value.background), Cursor: nativeText(value.cursor), Palette: palette} +} + +// Millisecond precision can round a maximum Go duration beyond its range. +func nativeMilliseconds(value uint64) time.Duration { + if value > uint64(math.MaxInt64/int64(time.Millisecond)) { + return time.Duration(math.MaxInt64) + } + return time.Duration(value) * time.Millisecond +} + +func nativeSessionNames(result *abiResult) []string { + sessions := make([]string, int(result.stringsLen)) + for index, value := range unsafe.Slice(result.strings, int(result.stringsLen)) { //nolint:gosec // G103: Rust supplies the session buffer and length; each string is copied before deferred ResultFree. + sessions[index] = nativeText(value) + } + return sessions +} + +func nativeBellEvents(result *abiResult) []BellEvent { + events := make([]BellEvent, int(result.bellsLen)) + for index, value := range unsafe.Slice(result.bells, int(result.bellsLen)) { //nolint:gosec // G103: Rust supplies the event buffer and length; values are copied before deferred ResultFree. + events[index] = BellEvent{Sequence: value.sequence, Elapsed: nativeMilliseconds(value.elapsedMS)} + } + return events +} diff --git a/bindings/go/internal/native/results_test.go b/bindings/go/internal/native/results_test.go new file mode 100644 index 00000000..adbc0790 --- /dev/null +++ b/bindings/go/internal/native/results_test.go @@ -0,0 +1,42 @@ +package native + +import "testing" + +func TestNativeStatePreservesRichTerminalMetadata(t *testing.T) { + memory := nativeMemory{} + defer memory.release() + modes := []abiMode{{name: memory.text("cursor_visible"), enabled: false}} + palette := []abiPair{{key: memory.text("1"), value: memory.text("#123456")}} + memory.pinner.Pin(&modes[0]) + memory.pinner.Pin(&palette[0]) + state := nativeState(abiState{ + cursor: abiCursor{x: 3, y: 4, visible: true, shape: memory.text("bar"), color: memory.text("#abcdef")}, + exitSignal: memory.text("KILL"), + modes: &modes[0], modesLen: 1, + mouseMode: memory.text("drag"), + colors: abiTerminalColors{ + foreground: memory.text("#ffffff"), background: memory.text("#000000"), cursor: memory.text("#abcdef"), + palette: &palette[0], paletteLen: 1, + }, + }) + if state.Cursor.Shape != "bar" || state.Cursor.Color != "#abcdef" || !state.Cursor.Visible { + t.Fatalf("cursor=%+v", state.Cursor) + } + if state.ExitSignal == nil || *state.ExitSignal != "KILL" || state.MouseMode != "drag" || state.Modes["cursor_visible"] { + t.Fatalf("state=%+v", state) + } + if state.Colors.Palette[1] != "#123456" || state.Colors.Foreground != "#ffffff" { + t.Fatalf("colors=%+v", state.Colors) + } +} + +func TestNativeCellsPreserveLinks(t *testing.T) { + memory := nativeMemory{} + defer memory.release() + values := []abiCell{{character: memory.text("x"), link: memory.text("https://example.test"), linkID: memory.text("docs")}} + memory.pinner.Pin(&values[0]) + cells := nativeCells(&abiResult{cells: &values[0], cellsLen: 1}) + if len(cells) != 1 || cells[0].Link != "https://example.test" || cells[0].LinkID != "docs" { + t.Fatalf("cells=%+v", cells) + } +} diff --git a/bindings/go/internal/native/session.go b/bindings/go/internal/native/session.go new file mode 100644 index 00000000..dd6dcf95 --- /dev/null +++ b/bindings/go/internal/native/session.go @@ -0,0 +1,586 @@ +package native + +import ( + "encoding/json" + "fmt" + "time" +) + +type Session struct { + name string + recording *AutomaticRecording +} + +func NewSession(name string, options SessionOptions) (*Session, error) { + if err := checkNativeVersion(); err != nil { + return nil, err + } + context := struct { + Name string `json:"name"` + ArtifactDirectory *string `json:"artifact_directory,omitempty"` + ArtifactMode *string `json:"artifact_mode,omitempty"` + ArtifactIncludeRecording bool `json:"artifact_include_recording,omitempty"` + TraceMode *string `json:"trace_mode,omitempty"` + TraceDirectory *string `json:"trace_directory,omitempty"` + ScreenHistoryLimit *uint16 `json:"screen_history_limit,omitempty"` + }{Name: name, ScreenHistoryLimit: options.ScreenHistoryLimit} + if options.Artifacts != nil { + context.ArtifactDirectory = pointerTo(options.Artifacts.Directory) + context.ArtifactMode = pointerTo(options.Artifacts.Mode) + context.ArtifactIncludeRecording = options.Artifacts.IncludeRecording + } + if options.Trace != nil { + context.TraceMode = pointerTo(options.Trace.Mode) + if options.Trace.Directory != "" { + context.TraceDirectory = pointerTo(options.Trace.Directory) + } + } + encoded, err := json.Marshal(context) + if err != nil { + return nil, &Error{Kind: InternalError, Message: "encode native session context: " + err.Error()} + } + return &Session{name: string(encoded), recording: options.Recording}, nil +} + +func checkNativeVersion() error { + if err := loadNativeEngine(); err != nil { + return &Error{Kind: InternalError, Message: err.Error()} + } + if version := nativeFunctions.AbiVersion(); version != 3 { + return &Error{Kind: InternalError, Message: fmt.Sprintf("native ABI version %d is incompatible with required version 3", version)} + } + return nil +} + +func Sessions() ([]string, error) { + if err := checkNativeVersion(); err != nil { + return nil, err + } + result := nativeFunctions.Sessions() + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return nil, err + } + return nativeSessionNames(result), nil +} + +func CloseAll() error { + if err := checkNativeVersion(); err != nil { + return err + } + result := nativeFunctions.CloseAll() + defer nativeFunctions.ResultFree(result) + return nativeError(result) +} + +func Recording(session string) (string, error) { + if err := checkNativeVersion(); err != nil { + return "", err + } + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Recording(memory.text(session)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return "", err + } + return nativeText(result.text), nil +} + +func (runtime *Session) Open(options OpenOptions) (OpenResult, error) { + memory := nativeMemory{} + defer memory.release() + converted, err := memory.openOptions(options, runtime.recording) + if err != nil { + return OpenResult{}, err + } + memory.pinner.Pin(&converted) + result := nativeFunctions.Open(memory.text(runtime.name), &converted) + defer nativeFunctions.ResultFree(result) + return readOpenResult(result) +} + +func (runtime *Session) Run(program string, args []string, options SpawnOptions) (OpenResult, error) { + memory := nativeMemory{} + defer memory.release() + converted, err := memory.openOptions(OpenOptions{SpawnOptions: options}, runtime.recording) + if err != nil { + return OpenResult{}, err + } + arguments, length := memory.strings(args) + memory.pinner.Pin(&converted) + result := nativeFunctions.Run(memory.text(runtime.name), &converted, memory.text(program), arguments, length) + defer nativeFunctions.ResultFree(result) + return readOpenResult(result) +} + +func (runtime *Session) State() (State, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.State(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return State{}, err + } + return nativeState(result.state), nil +} + +func (runtime *Session) Text(full bool) (string, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Text(memory.text(runtime.name), full) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return "", err + } + return nativeText(result.text), nil +} + +func (runtime *Session) Cells(column, row, width, height uint16) ([]Cell, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Cells(memory.text(runtime.name), column, row, width, height) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return nil, err + } + return nativeCells(result), nil +} + +func (runtime *Session) Resize(cols, rows uint16) error { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Resize(memory.text(runtime.name), cols, rows) + defer nativeFunctions.ResultFree(result) + return nativeError(result) +} + +func (runtime *Session) Submit(text *string) error { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Submit(memory.text(runtime.name), memory.optionalText(text)) + defer nativeFunctions.ResultFree(result) + return nativeError(result) +} + +func (runtime *Session) Key(keys []string, action uint32) error { + memory := nativeMemory{} + defer memory.release() + values, length := memory.strings(keys) + result := nativeFunctions.Key(memory.text(runtime.name), values, length, action) + defer nativeFunctions.ResultFree(result) + return nativeError(result) +} + +func (runtime *Session) GetCursor() (Cursor, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.GetCursor(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return Cursor{}, err + } + return nativeCursor(result.cursor), nil +} + +func (runtime *Session) GetSize() (Size, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.GetSize(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return Size{}, err + } + return Size{Cols: result.size.cols, Rows: result.size.rows}, nil +} + +func (runtime *Session) GetExitCode() (*int32, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.GetExitCode(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return nil, err + } + return nativeInt(result.exitCode), nil +} + +func (runtime *Session) GetBellCount() (uint64, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.GetBellCount(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return 0, err + } + return result.number, nil +} + +func (runtime *Session) GetBellEvents() ([]BellEvent, error) { + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.GetBellEvents(memory.text(runtime.name)) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return nil, err + } + return nativeBellEvents(result), nil +} + +func (runtime *Session) read(call func(abiString, *nativeMemory) *abiResult, read func(*abiResult)) error { + memory := nativeMemory{} + defer memory.release() + result := call(memory.text(runtime.name), &memory) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return err + } + read(result) + return nil +} + +func (runtime *Session) unit(call func(abiString, *nativeMemory) *abiResult) error { + return runtime.read(call, func(*abiResult) {}) +} + +func (runtime *Session) optionalTextResult(call func(abiString, *nativeMemory) *abiResult) (*string, error) { + var text *string + err := runtime.read(call, func(result *abiResult) { text = nativeOptionalText(result.text) }) + return text, err +} + +func (runtime *Session) textResult(call func(abiString, *nativeMemory) *abiResult) (string, error) { + var text string + err := runtime.read(call, func(result *abiResult) { text = nativeText(result.text) }) + return text, err +} + +func (runtime *Session) Close(failed *bool) error { + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.Close(session, nativeBool(failed)) + }) +} + +func (runtime *Session) Restart(gracefulTimeout time.Duration) (OpenResult, error) { + milliseconds, err := Milliseconds(gracefulTimeout) + if err != nil { + return OpenResult{}, err + } + memory := nativeMemory{} + defer memory.release() + result := nativeFunctions.Restart(memory.text(runtime.name), milliseconds) + defer nativeFunctions.ResultFree(result) + return readOpenResult(result) +} + +func (runtime *Session) Write(text string) error { + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.Write(session, memory.text(text)) + }) +} + +func (runtime *Session) TypeText(text string) error { + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.Type(session, memory.text(text)) + }) +} + +func (runtime *Session) Signal(text string) error { + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.Signal(session, memory.text(text)) + }) +} + +func (runtime *Session) GetCommand() (*string, error) { + return runtime.optionalTextResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.GetCommand(session) }) +} + +func (runtime *Session) GetOutput() (*string, error) { + return runtime.optionalTextResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.GetOutput(session) }) +} + +func (runtime *Session) GetCwd() (*string, error) { + return runtime.optionalTextResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.GetCwd(session) }) +} + +func (runtime *Session) GetTitle() (*string, error) { + return runtime.optionalTextResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.GetTitle(session) }) +} + +func (runtime *Session) GetClipboard() (string, error) { + return runtime.textResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.GetClipboard(session) }) +} + +func (runtime *Session) StopRecording() (string, error) { + return runtime.textResult(func(session abiString, _ *nativeMemory) *abiResult { return nativeFunctions.StopRecording(session) }) +} + +func (runtime *Session) WaitIdle(timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.WaitIdle(session, duration) + }) +} + +func (runtime *Session) WaitCommand(timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.WaitCommand(session, duration) + }) +} + +func (runtime *Session) WaitExit(timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.WaitExit(session, duration) + }) +} + +func (runtime *Session) WaitReady(timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.WaitReady(session, duration) + }) +} + +func (runtime *Session) WaitBell(timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.WaitBell(session, duration) + }) +} + +func (runtime *Session) WaitTitle(text string, options TitleOptions) error { + duration, err := nativeDuration(options.Timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.WaitTitle(session, memory.text(text), abiWaitOptions{timeoutMS: duration, regex: options.Regex, not: options.Not}) + }) +} + +func (runtime *Session) ExpectTitle(text string, options TitleOptions) error { + duration, err := nativeDuration(options.Timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.ExpectTitle(session, memory.text(text), abiWaitOptions{timeoutMS: duration, regex: options.Regex, not: options.Not}) + }) +} + +func (runtime *Session) WaitClipboard(options ClipboardWaitOptions) error { + duration, err := nativeDuration(options.Timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.WaitClipboard(session, memory.optionalText(options.Text), abiWaitOptions{timeoutMS: duration, regex: options.Regex}) + }) +} + +func (runtime *Session) ExpectExitCode(code int32, timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.ExpectExitCode(session, code, duration) + }) +} + +func (runtime *Session) ExpectOutput(text string, regex bool) error { + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.ExpectOutput(session, memory.text(text), regex) + }) +} + +func (runtime *Session) ExpectBellCount(count uint64, timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.ExpectBellCount(session, count, duration) + }) +} + +func (runtime *Session) Screenshot(path string, options ScreenshotOptions) (string, error) { + return runtime.textResult(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.Screenshot(session, options.Full, memory.nonemptyText(path), nativeFloat(options.Zoom), memory.nonemptyText(options.Background), options.Transparent) + }) +} + +func (runtime *Session) StartRecording(path string, options RecordingOptions) error { + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.StartRecording(session, abiRecordingOptions{path: memory.text(path), format: memory.nonemptyText(string(options.Format)), fps: nativeUint(options.FPS), speed: nativeFloat(options.Speed), idleTimeLimit: nativeFloat(options.IdleTimeLimit), zoom: nativeFloat(options.Zoom), background: memory.nonemptyText(options.Background), transparent: options.Transparent}) + }) +} + +func (runtime *Session) Snapshot(name string, options SnapshotOptions) (SnapshotResult, error) { + var snapshot SnapshotResult + err := runtime.read(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.Snapshot(session, memory.text(name), options.Update, options.IncludeStyle, options.IncludeTitle, memory.nonemptyText(options.Cwd)) + }, func(result *abiResult) { + switch result.snapshot { + case 1: + snapshot = SnapshotWritten + case 2: + snapshot = SnapshotUpdated + default: + snapshot = SnapshotPassed + } + }) + return snapshot, err +} + +func (runtime *Session) MouseClick(options MouseClickOptions) error { + mouse, err := nativeMouse(options.MouseButtonOptions) + if err != nil { + return err + } + clicks, err := nativeClicks(options.Clicks) + if err != nil { + return err + } + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.MouseClick(session, nativeUint(options.X), nativeUint(options.Y), memory.optionalText(options.OnText), mouse, clicks) + }) +} + +func (runtime *Session) MouseMove(column, row uint16) error { + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.MouseMove(session, column, row) + }) +} + +func (runtime *Session) MouseDown(column, row uint16, options MouseButtonOptions) error { + mouse, err := nativeMouse(options) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.MouseDown(session, column, row, mouse) + }) +} + +func (runtime *Session) MouseUp(column, row uint16, options MouseButtonOptions) error { + mouse, err := nativeMouse(options) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.MouseUp(session, column, row, mouse) + }) +} + +func (runtime *Session) MouseDrag(x1, y1, x2, y2 uint16, options MouseButtonOptions) error { + mouse, err := nativeMouse(options) + if err != nil { + return err + } + return runtime.unit(func(session abiString, _ *nativeMemory) *abiResult { + return nativeFunctions.MouseDrag(session, x1, y1, x2, y2, mouse) + }) +} + +func (runtime *Session) MouseScroll(direction ScrollDirection, amount uint32) error { + if amount > 65535 { + return &Error{Kind: UsageError, Message: "scroll amount must be between 0 and 65535"} + } + convertedAmount := uint16(amount) + return runtime.unit(func(session abiString, memory *nativeMemory) *abiResult { + return nativeFunctions.MouseScroll(session, memory.text(string(direction)), convertedAmount) + }) +} + +func (runtime *Session) FindLocator(expression LocatorExpression, requireOne bool) ([]TextMatch, error) { + memory := nativeMemory{} + defer memory.release() + query, err := memory.query(expression) + if err != nil { + return nil, err + } + result := nativeFunctions.FindLocator(memory.text(runtime.name), query, requireOne) + defer nativeFunctions.ResultFree(result) + if err := nativeError(result); err != nil { + return nil, err + } + return nativeMatches(result), nil +} + +func (runtime *Session) locatorUnit(expression LocatorExpression, call func(abiString, abiQuery) *abiResult) error { + memory := nativeMemory{} + defer memory.release() + query, err := memory.query(expression) + if err != nil { + return err + } + result := call(memory.text(runtime.name), query) + defer nativeFunctions.ResultFree(result) + return nativeError(result) +} + +func (runtime *Session) WaitLocator(expression LocatorExpression, hidden bool, timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.locatorUnit(expression, func(session abiString, query abiQuery) *abiResult { + return nativeFunctions.WaitLocator(session, query, abiWaitOptions{timeoutMS: duration, not: hidden}) + }) +} + +func (runtime *Session) ExpectLocator(expression LocatorExpression, options LocatorExpectOptions) error { + duration, err := nativeDuration(options.Timeout) + if err != nil { + return err + } + return runtime.locatorUnit(expression, func(session abiString, query abiQuery) *abiResult { + return nativeFunctions.ExpectLocator(session, query, abiWaitOptions{timeoutMS: duration, not: options.Not}) + }) +} + +func (runtime *Session) ClickLocator(expression LocatorExpression, options LocatorClickOptions) error { + duration, err := nativeDuration(options.Timeout) + if err != nil { + return err + } + mouse, err := nativeMouse(options.MouseButtonOptions) + if err != nil { + return err + } + clicks, err := nativeClicks(options.Clicks) + if err != nil { + return err + } + return runtime.locatorUnit(expression, func(session abiString, query abiQuery) *abiResult { + return nativeFunctions.ClickLocator(session, query, mouse, clicks, duration) + }) +} + +func (runtime *Session) HighlightLocator(expression LocatorExpression, timeout *time.Duration) error { + duration, err := nativeDuration(timeout) + if err != nil { + return err + } + return runtime.locatorUnit(expression, func(session abiString, query abiQuery) *abiResult { + return nativeFunctions.HighlightLocator(session, query, duration) + }) +} diff --git a/bindings/go/locator.go b/bindings/go/locator.go new file mode 100644 index 00000000..32f012ce --- /dev/null +++ b/bindings/go/locator.go @@ -0,0 +1,278 @@ +package tuitest + +import "time" + +type locatorNode struct { + kind string + text *string + regex *bool + whitespace *Whitespace + style *TextStyle + link *string + full *bool + direction *Direction + within *uint32 + left *uint32 + right *uint32 + input *uint32 + has *uint32 + hasNot *uint32 + occurrence any +} + +type locatorQuery struct { + nodes []locatorNode + root uint32 +} + +type LocatorFilterOptions struct { + Has *Locator + HasNot *Locator +} + +type LinkSelectorOptions struct { + Full bool + Direction Direction +} + +// Locator is an immutable query, resolved against the current grid on each operation. +// Invalid selector options are reported when an operation resolves the locator. +type Locator struct { + client *Client + query locatorQuery +} + +func (client *Client) GetByText(text string, options TextSelectorOptions) *Locator { + return newTextLocator(client, nil, text, options) +} + +func (client *Client) GetByStyle(style TextStyle, options StyleSelectorOptions) *Locator { + return newStyleLocator(client, nil, style, options) +} + +func (client *Client) GetByLink(uri string, options LinkSelectorOptions) *Locator { + return newLinkLocator(client, nil, uri, options) +} + +func cloneLocatorQuery(source locatorQuery) locatorQuery { + return locatorQuery{nodes: append([]locatorNode(nil), source.nodes...), root: source.root} +} + +func appendLocatorNode(parent *locatorQuery, node locatorNode) locatorQuery { + query := locatorQuery{} + if parent != nil { + query = cloneLocatorQuery(*parent) + node.within = Ptr(query.root) + } + query.root = uint32(len(query.nodes)) + query.nodes = append(query.nodes, node) + return query +} + +func newTextLocator(client *Client, parent *locatorQuery, text string, options TextSelectorOptions) *Locator { + whitespace := options.Whitespace + if whitespace == "" { + whitespace = Exact + } + direction := options.Direction + if direction == "" { + direction = Within + } + node := locatorNode{kind: "text", text: Ptr(text), regex: Ptr(options.Regex), whitespace: Ptr(whitespace), full: Ptr(options.Full), direction: Ptr(direction), occurrence: "any"} + return &Locator{client: client, query: appendLocatorNode(parent, node)} +} + +func newStyleLocator(client *Client, parent *locatorQuery, style TextStyle, options StyleSelectorOptions) *Locator { + style = cloneTextStyle(style) + direction := options.Direction + if direction == "" { + direction = Within + } + node := locatorNode{kind: "style", style: &style, full: Ptr(options.Full), direction: Ptr(direction), occurrence: "any"} + return &Locator{client: client, query: appendLocatorNode(parent, node)} +} + +func newLinkLocator(client *Client, parent *locatorQuery, uri string, options LinkSelectorOptions) *Locator { + direction := options.Direction + if direction == "" { + direction = Within + } + node := locatorNode{kind: "link", link: Ptr(uri), full: Ptr(options.Full), direction: Ptr(direction), occurrence: "any"} + return &Locator{client: client, query: appendLocatorNode(parent, node)} +} + +func cloneTextStyle(style TextStyle) TextStyle { + style.Foreground = clonePointer(style.Foreground) + style.Background = clonePointer(style.Background) + style.Bold = clonePointer(style.Bold) + style.Dim = clonePointer(style.Dim) + style.Italic = clonePointer(style.Italic) + style.UnderlineStyle = clonePointer(style.UnderlineStyle) + style.UnderlineColor = clonePointer(style.UnderlineColor) + style.Inverse = clonePointer(style.Inverse) + style.Hidden = clonePointer(style.Hidden) + style.Strikethrough = clonePointer(style.Strikethrough) + style.Blink = clonePointer(style.Blink) + return style +} + +func (locator *Locator) GetByText(text string, options TextSelectorOptions) *Locator { + return newTextLocator(locator.client, &locator.query, text, options) +} + +func (locator *Locator) GetByStyle(style TextStyle, options StyleSelectorOptions) *Locator { + return newStyleLocator(locator.client, &locator.query, style, options) +} + +func (locator *Locator) GetByLink(uri string, options LinkSelectorOptions) *Locator { + return newLinkLocator(locator.client, &locator.query, uri, options) +} + +func offsetReference(reference *uint32, offset uint32) *uint32 { + if reference == nil { + return nil + } + return Ptr(*reference + offset) +} + +func appendLocatorOperand(query *locatorQuery, operand locatorQuery) uint32 { + offset := uint32(len(query.nodes)) + for _, source := range operand.nodes { + source.within = offsetReference(source.within, offset) + source.left = offsetReference(source.left, offset) + source.right = offsetReference(source.right, offset) + source.input = offsetReference(source.input, offset) + source.has = offsetReference(source.has, offset) + source.hasNot = offsetReference(source.hasNot, offset) + query.nodes = append(query.nodes, source) + } + return operand.root + offset +} + +func (locator *Locator) operand(other *Locator) (locatorQuery, error) { + if other == nil || other.client != locator.client { + return locatorQuery{}, &Error{Kind: UsageError, Message: "locator operands must belong to the same terminal owner"} + } + return other.query, nil +} + +func (locator *Locator) combine(kind string, other *Locator) (*Locator, error) { + operand, err := locator.operand(other) + if err != nil { + return nil, err + } + query := cloneLocatorQuery(locator.query) + left := query.root + right := appendLocatorOperand(&query, operand) + query.root = uint32(len(query.nodes)) + query.nodes = append(query.nodes, locatorNode{kind: kind, left: Ptr(left), right: Ptr(right), occurrence: "any"}) + return &Locator{client: locator.client, query: query}, nil +} + +func (locator *Locator) And(other *Locator) (*Locator, error) { return locator.combine("and", other) } +func (locator *Locator) Or(other *Locator) (*Locator, error) { return locator.combine("or", other) } + +func (locator *Locator) Filter(options LocatorFilterOptions) (*Locator, error) { + if options.Has == nil && options.HasNot == nil { + return nil, &Error{Kind: UsageError, Message: "filter requires has or hasNot"} + } + query := cloneLocatorQuery(locator.query) + input := query.root + node := locatorNode{kind: "filter", input: Ptr(input), occurrence: "any"} + if options.Has != nil { + operand, err := locator.operand(options.Has) + if err != nil { + return nil, err + } + node.has = Ptr(appendLocatorOperand(&query, operand)) + } + if options.HasNot != nil { + operand, err := locator.operand(options.HasNot) + if err != nil { + return nil, err + } + node.hasNot = Ptr(appendLocatorOperand(&query, operand)) + } + query.root = uint32(len(query.nodes)) + query.nodes = append(query.nodes, node) + return &Locator{client: locator.client, query: query}, nil +} + +func (locator *Locator) selectOccurrence(occurrence any) *Locator { + query := cloneLocatorQuery(locator.query) + query.nodes[query.root].occurrence = occurrence + return &Locator{client: locator.client, query: query} +} + +func (locator *Locator) Any() *Locator { return locator.selectOccurrence("any") } +func (locator *Locator) Unique() *Locator { return locator.selectOccurrence("unique") } +func (locator *Locator) First() *Locator { return locator.selectOccurrence("first") } +func (locator *Locator) Last() *Locator { return locator.selectOccurrence("last") } +func (locator *Locator) Nth(index uint32) *Locator { + return locator.selectOccurrence(map[string]uint32{"nth": index}) +} + +func (locator *Locator) Locations() ([]TextMatch, error) { + matches, err := locator.client.runtime.findLocator(locator.query, false) + return matches, locator.client.guard("locator.locations", err) +} + +func (locator *Locator) Location() (TextMatch, error) { + matches, err := locator.client.runtime.findLocator(locator.query, true) + if err != nil { + return TextMatch{}, locator.client.guard("locator.location", err) + } + if len(matches) != 1 { + return TextMatch{}, &Error{Kind: InternalError, Message: "locator.location: native returned an invalid match count"} + } + return matches[0], nil +} + +func (locator *Locator) Count() (int, error) { + matches, err := locator.Locations() + return len(matches), err +} + +func (locator *Locator) All() ([]*Locator, error) { + matches, err := locator.Locations() + if err != nil { + return nil, err + } + locators := make([]*Locator, len(matches)) + for index := range matches { + locators[index] = locator + if locator.query.nodes[locator.query.root].occurrence == "any" { + locators[index] = locator.Nth(uint32(index)) + } + } + return locators, nil +} + +func (locator *Locator) Wait(options LocatorWaitOptions) error { + if options.State != "" && options.State != Visible && options.State != Hidden { + return &Error{Kind: UsageError, Message: "locator state must be visible or hidden"} + } + return locator.client.wait("locator.wait", options.Timeout, locator.client.options.Timeouts.Text, func(timeout *time.Duration) error { + return locator.client.runtime.waitLocator(locator.query, options.State == Hidden, timeout) + }) +} + +func (locator *Locator) Click(options LocatorClickOptions) error { + return locator.client.wait("locator.click", options.Timeout, locator.client.options.Timeouts.Text, func(timeout *time.Duration) error { + options.Timeout = timeout + return locator.client.runtime.clickLocator(locator.query, options) + }) +} + +func (locator *Locator) Highlight(options WaitOptions) error { + return locator.client.wait("locator.highlight", options.Timeout, locator.client.options.Timeouts.Text, func(timeout *time.Duration) error { + return locator.client.runtime.highlightLocator(locator.query, timeout) + }) +} + +func (locator *Locator) Expect(options LocatorExpectOptions) error { + return locator.client.wait("locator.expect", options.Timeout, locator.client.options.Timeouts.Text, func(timeout *time.Duration) error { + options.Timeout = timeout + return locator.client.runtime.expectLocator(locator.query, options) + }) +} diff --git a/bindings/go/native/Cargo.toml b/bindings/go/native/Cargo.toml new file mode 100644 index 00000000..a7c05476 --- /dev/null +++ b/bindings/go/native/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tui-test-go" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "tui_test_go" +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde.workspace = true +serde_json.workspace = true +tui-test = { workspace = true, features = ["ghostty", "rio", "xtermjs", "recording-font-jetbrains-mono-styles"] } diff --git a/bindings/go/native/src/input.rs b/bindings/go/native/src/input.rs new file mode 100644 index 00000000..56fc26ff --- /dev/null +++ b/bindings/go/native/src/input.rs @@ -0,0 +1,299 @@ +use crate::types::*; +use tui_test::api::*; +use tui_test::locator_query::LocatorExpression; +use tui_test::profile::{Profile, Rgb}; +use tui_test::shell::Shell; +use tui_test::{ + DiagnosticRetentionOptions, ExecutionContext, FailureArtifactMode, FailureArtifactOptions, + TraceMode, TraceOptions, +}; + +type Result = std::result::Result; + +impl TuiOptionalU64 { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} +impl TuiOptionalF64 { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} +impl TuiOptionalBool { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} + +// Every exported entrypoint is unsafe: the caller must provide valid readable +// buffers for the duration of the call. Null/length and UTF-8 checks catch +// representable usage mistakes, but cannot prove foreign pointer validity. +pub(crate) unsafe fn slice<'a, T>(data: *const T, len: usize) -> Result<&'a [T]> { + if len == 0 { + return Ok(&[]); + } + if data.is_null() || len > isize::MAX as usize / std::mem::size_of::() { + return Err(TuiTestError::usage( + "invalid native array pointer or length", + )); + } + Ok(unsafe { std::slice::from_raw_parts(data, len) }) +} +impl TuiString { + pub(crate) unsafe fn optional(self) -> Result> { + if self.data.is_null() { + if self.len != 0 { + return Err(TuiTestError::usage( + "null native string with nonzero length", + )); + } + return Ok(None); + } + let bytes = unsafe { slice(self.data, self.len)? }; + std::str::from_utf8(bytes) + .map(|s| Some(s.to_owned())) + .map_err(|_| TuiTestError::usage("native string must be UTF-8")) + } + pub(crate) unsafe fn required(self) -> Result { + unsafe { self.optional()? } + .ok_or_else(|| TuiTestError::usage("required native string is absent")) + } +} +pub(crate) unsafe fn strings(data: *const TuiString, len: usize) -> Result> { + unsafe { slice(data, len)? } + .iter() + .map(|s| unsafe { s.required() }) + .collect() +} +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionInput { + name: String, + #[serde(default)] + artifact_directory: Option, + #[serde(default)] + artifact_mode: Option, + #[serde(default)] + artifact_include_recording: bool, + #[serde(default)] + trace_mode: Option, + #[serde(default)] + trace_directory: Option, + #[serde(default)] + screen_history_limit: Option, +} + +pub(crate) unsafe fn session(value: TuiString) -> Result<(String, ExecutionContext)> { + let json = unsafe { value.required()? }; + let value: SessionInput = serde_json::from_str(&json) + .map_err(|error| TuiTestError::usage(format!("invalid native session context: {error}")))?; + let artifact = match value.artifact_mode { + None => None, + Some(mode) => { + let mode = match mode.as_str() { + "all" => FailureArtifactMode::All, + "html" => FailureArtifactMode::Html, + "text" => FailureArtifactMode::Text, + "none" => FailureArtifactMode::None, + other => { + return Err(TuiTestError::usage(format!( + "unknown failure artifact mode {other:?}; expected all, html, text, or none" + ))) + } + }; + let options = FailureArtifactOptions { + directory: value.artifact_directory.unwrap_or_default().into(), + mode, + include_recording: value.artifact_include_recording, + }; + options.validate().map_err(TuiTestError::usage)?; + Some(options) + } + }; + let trace = match value.trace_mode { + None => None, + Some(mode) => { + let mode = match mode.as_str() { + "off" => TraceMode::Off, + "on-failure" => TraceMode::OnFailure, + "on" => TraceMode::On, + other => { + return Err(TuiTestError::usage(format!( + "unknown trace mode {other:?}; expected off, on-failure, or on" + ))) + } + }; + let options = TraceOptions { + mode, + directory: value + .trace_directory + .map(Into::into) + .unwrap_or_else(|| TraceOptions::default().directory), + }; + options.validate().map_err(TuiTestError::usage)?; + Some(options) + } + }; + let mut retention = DiagnosticRetentionOptions::default(); + if let Some(limit) = value.screen_history_limit { + retention.screen_history_limit = limit; + } + retention.validate().map_err(TuiTestError::usage)?; + Ok(( + value.name, + ExecutionContext { + artifact, + trace, + retention, + ..ExecutionContext::default() + }, + )) +} +unsafe fn pairs(data: *const TuiPair, len: usize) -> Result> { + unsafe { slice(data, len)? } + .iter() + .map(|p| Ok((unsafe { p.key.required()? }, unsafe { p.value.required()? }))) + .collect() +} +pub(crate) fn u16_option(value: TuiOptionalU64, name: &str) -> Result> { + value + .option() + .map(|v| { + u16::try_from(v) + .map_err(|_| TuiTestError::usage(format!("{name} must be between 0 and 65535"))) + }) + .transpose() +} +pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result { + let backend = unsafe { value.backend.optional()? } + .map(|s| s.parse()) + .transpose() + .map_err(TuiTestError::usage)? + .unwrap_or_default(); + let shell = unsafe { value.shell.optional()? } + .map(|s| match s.as_str() { + "bash" => Ok(Shell::Bash), + "powershell" => Ok(Shell::Powershell), + "pwsh" => Ok(Shell::Pwsh), + "cmd" => Ok(Shell::Cmd), + "fish" => Ok(Shell::Fish), + "zsh" => Ok(Shell::Zsh), + "xonsh" => Ok(Shell::Xonsh), + "elvish" => Ok(Shell::Elvish), + "nushell" => Ok(Shell::Nushell), + _ => Err(TuiTestError::usage(format!("unknown shell {s:?}"))), + }) + .transpose()?; + let mut profile = Profile::default(); + if let Some(scrollback) = value.scrollback.option() { + profile.scrollback = usize::try_from(scrollback) + .map_err(|_| TuiTestError::usage("scrollback exceeds addressable size"))?; + } + for (name, color) in unsafe { pairs(value.colors, value.colors_len)? } { + let color = Rgb::parse(&color).map_err(TuiTestError::usage)?; + if !profile.colors.set_named(&name, color) { + return Err(TuiTestError::usage(format!( + "unknown profile color {name:?}" + ))); + } + } + let mode = match unsafe { value.recording_mode.optional()? } + .as_deref() + .unwrap_or("disabled") + { + "always" => AutomaticRecordingMode::Always, + "disabled" => AutomaticRecordingMode::Disabled, + "on-failure" => AutomaticRecordingMode::OnFailure, + other => { + return Err(TuiTestError::usage(format!( + "unknown automatic recording mode {other:?}" + ))) + } + }; + Ok(OpenOptions { + backend, + shell, + profile, + cols: u16_option(value.cols, "cols")?.unwrap_or(80), + rows: u16_option(value.rows, "rows")?.unwrap_or(30), + cwd: unsafe { value.cwd.optional()? }, + env: unsafe { pairs(value.env, value.env_len)? }, + wait_ready: value.wait_ready.option(), + restart: value.restart, + timeouts: Timeouts { + text: value.timeouts.text.option(), + idle: value.timeouts.idle.option(), + command: value.timeouts.command.option(), + exit: value.timeouts.exit.option(), + ready: value.timeouts.ready.option(), + }, + recording: AutomaticRecording { + mode, + directory: unsafe { value.recording_directory.optional()? }.map(Into::into), + }, + }) +} +pub(crate) fn mouse(value: TuiMouseOptions) -> Result { + let button = match value.button { + 0 => MouseButton::Left, + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + _ => return Err(TuiTestError::usage("unknown mouse button")), + }; + Ok(MouseOptions { + button, + alt: value.alt, + ctrl: value.ctrl, + shift: value.shift, + }) +} +pub(crate) unsafe fn query(value: TuiQuery) -> Result { + let json = unsafe { value.json.required()? }; + serde_json::from_str::(&json) + .map_err(|error| TuiTestError::usage(format!("invalid locator expression: {error}")))? + .into_query() +} + +pub(crate) unsafe fn capture_background( + background: TuiString, + transparent: bool, +) -> Result> { + let background = unsafe { background.optional()? }; + if background.is_some() && transparent { + return Err(TuiTestError::usage( + "background and transparent options conflict", + )); + } + if transparent { + return Ok(Some(CaptureBackground::Transparent)); + } + background + .map(|value| CaptureBackground::parse(&value)) + .transpose() +} +pub(crate) unsafe fn recording(value: TuiRecordingOptions) -> Result { + let format = unsafe { value.format.optional()? } + .map(|s| match s.as_str() { + "apng" => Ok(RecordingFormat::Apng), + "gif" => Ok(RecordingFormat::Gif), + "mp4" => Ok(RecordingFormat::Mp4), + "cast" => Ok(RecordingFormat::Cast), + _ => Err(TuiTestError::usage("unknown recording format")), + }) + .transpose()?; + let fps = value + .fps + .option() + .map(|v| u8::try_from(v).map_err(|_| TuiTestError::usage("fps must be between 0 and 255"))) + .transpose()?; + Ok(Operation::StartRecording { + path: unsafe { value.path.required()? }, + format, + fps, + speed: value.speed.option(), + idle_time_limit: value.idle_time_limit.option(), + zoom: value.zoom.option(), + background: unsafe { capture_background(value.background, value.transparent)? }, + }) +} diff --git a/bindings/go/native/src/lib.rs b/bindings/go/native/src/lib.rs new file mode 100644 index 00000000..152579d3 --- /dev/null +++ b/bindings/go/native/src/lib.rs @@ -0,0 +1,908 @@ +//! Private typed C ABI for the Go binding. All borrowed input buffers must +//! remain valid for the call. Inputs are copied before entering the engine; +//! output storage belongs to Rust until tui_result_free is called. +mod input; +mod output; +mod types; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use tui_test::api::*; +use tui_test::runtime::global_registry; +pub use types::*; + +type Result = std::result::Result; +fn boundary(f: impl FnOnce() -> Result) -> *mut TuiResult { + match catch_unwind(AssertUnwindSafe(|| f().map(output::operation))) { + Ok(Ok(value)) => value, + Ok(Err(error)) => output::error(error), + Err(_) => output::error(TuiTestError::internal("native Go adapter panicked")), + } +} +unsafe fn execute( + session: TuiString, + operation_name: &'static str, + operation: impl FnOnce() -> Result, +) -> *mut TuiResult { + boundary(|| { + let (session, context) = unsafe { input::session(session)? }; + global_registry() + .session(session) + .execute_with_context(operation()?, context.with_operation(operation_name)) + }) +} + +fn finish_trace_and_close( + finish_trace: impl FnOnce() -> Result, + close: impl FnOnce() -> Result, +) -> Result { + let trace = finish_trace(); + let closed = close(); + if let Err(error) = trace { + if error.kind != ErrorKind::NoSession { + return Err(error); + } + } + closed +} +#[no_mangle] +pub extern "C" fn tui_abi_version() -> u32 { + 3 +} +#[no_mangle] +/// # Safety +/// result must be NULL or a live result returned by this library, freed once. +pub unsafe extern "C" fn tui_result_free(result: *mut TuiResult) { + unsafe { output::free(result) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_open(session: TuiString, options: TuiOpenOptions) -> *mut TuiResult { + unsafe { + execute(session, "open", || { + Ok(Operation::Open(input::open(options)?)) + }) + } +} +#[no_mangle] +/// Pointer form for foreign callers with limited by-value argument space. +/// +/// # Safety +/// options must be NULL or point to a valid TuiOpenOptions for this call. +/// Its borrowed buffers follow the same contract as tui_open. +pub unsafe extern "C" fn tui_open_ptr( + session: TuiString, + options: *const TuiOpenOptions, +) -> *mut TuiResult { + if options.is_null() { + return boundary(|| Err(TuiTestError::usage("open options pointer is null"))); + } + unsafe { tui_open(session, *options) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_run( + session: TuiString, + options: TuiOpenOptions, + program: TuiString, + args: *const TuiString, + args_len: usize, +) -> *mut TuiResult { + unsafe { + execute(session, "run", || { + let o = input::open(options)?; + let program = program.required()?; + if program.is_empty() { + return Err(TuiTestError::usage("program must not be empty")); + } + Ok(Operation::Run(RunOptions { + program, + args: input::strings(args, args_len)?, + backend: o.backend, + profile: o.profile, + cols: o.cols, + rows: o.rows, + cwd: o.cwd, + env: o.env, + wait_ready: o.wait_ready, + restart: o.restart, + timeouts: o.timeouts, + recording: o.recording, + })) + }) + } +} +#[no_mangle] +/// Pointer form for foreign callers with limited by-value argument space. +/// +/// # Safety +/// options must be NULL or point to a valid TuiOpenOptions for this call. +/// All borrowed buffers follow the same contract as tui_run. +pub unsafe extern "C" fn tui_run_ptr( + session: TuiString, + options: *const TuiOpenOptions, + program: TuiString, + args: *const TuiString, + args_len: usize, +) -> *mut TuiResult { + if options.is_null() { + return boundary(|| Err(TuiTestError::usage("run options pointer is null"))); + } + unsafe { tui_run(session, *options, program, args, args_len) } +} +#[no_mangle] +pub extern "C" fn tui_sessions() -> *mut TuiResult { + match catch_unwind(AssertUnwindSafe(|| { + output::sessions(global_registry().sessions()) + })) { + Ok(value) => value, + Err(_) => output::error(TuiTestError::internal("native Go adapter panicked")), + } +} +#[no_mangle] +pub extern "C" fn tui_close_all() -> *mut TuiResult { + boundary(|| { + global_registry().close_all(); + Ok(OperationResult::Unit) + }) +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_recording(session: TuiString) -> *mut TuiResult { + boundary(|| { + let session = unsafe { session.required()? }; + global_registry() + .recording(&session) + .map(OperationResult::Recording) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + TuiTestError::new( + ErrorKind::NoSession, + format!("no recording for session '{session}'"), + ) + } else { + TuiTestError::internal(format!( + "failed to read recording for session '{session}': {e}" + )) + } + }) + }) +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_close(session: TuiString, failed: TuiOptionalBool) -> *mut TuiResult { + boundary(|| { + let (name, context) = unsafe { input::session(session)? }; + let handle = global_registry().session(name); + if let Some(failed) = failed.option() { + return finish_trace_and_close( + || { + handle.execute_with_context( + Operation::FinishTrace { failed }, + context.clone().with_operation("trace.finish"), + ) + }, + || { + handle.execute_with_context( + Operation::Close, + context.clone().with_operation("close"), + ) + }, + ); + } + handle.execute_with_context(Operation::Close, context.with_operation("close")) + }) +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths. +pub unsafe extern "C" fn tui_restart( + session: TuiString, + graceful_timeout_ms: u64, +) -> *mut TuiResult { + unsafe { + execute(session, "restart", || { + Ok(Operation::Restart { + graceful_timeout_ms, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_state(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "state", || Ok(Operation::State)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_command(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getCommand", || Ok(Operation::GetCommand)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_output(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getOutput", || Ok(Operation::GetOutput)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_exit_code(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getExitCode", || Ok(Operation::GetExitCode)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_cwd(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getCwd", || Ok(Operation::GetCwd)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_cursor(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getCursor", || Ok(Operation::GetCursor)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_size(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getSize", || Ok(Operation::GetSize)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_title(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getTitle", || Ok(Operation::GetTitle)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_clipboard(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getClipboard", || Ok(Operation::GetClipboard)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_bell_count(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getBellCount", || Ok(Operation::GetBellCount)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_get_bell_events(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "getBellEvents", || Ok(Operation::GetBellEvents)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_stop_recording(session: TuiString) -> *mut TuiResult { + unsafe { execute(session, "stopRecording", || Ok(Operation::StopRecording)) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_text(session: TuiString, full: bool) -> *mut TuiResult { + unsafe { execute(session, "text", || Ok(Operation::Text { full })) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_packed_screen(session: TuiString, full: bool) -> *mut TuiResult { + unsafe { + execute(session, "packedScreen", || { + Ok(Operation::PackedScreen { full }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_cells( + session: TuiString, + x: u16, + y: u16, + w: u16, + h: u16, +) -> *mut TuiResult { + unsafe { execute(session, "cells", || Ok(Operation::Cells { x, y, w, h })) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_write(session: TuiString, text: TuiString) -> *mut TuiResult { + unsafe { + execute(session, "write", || { + Ok(Operation::Write { + data: text.required()?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths. +pub unsafe extern "C" fn tui_type(session: TuiString, text: TuiString) -> *mut TuiResult { + unsafe { + execute(session, "type", || { + Ok(Operation::Write { + data: text.required()?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_submit(session: TuiString, text: TuiString) -> *mut TuiResult { + unsafe { + execute(session, "submit", || { + Ok(Operation::Submit { + data: text.optional()?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_signal(session: TuiString, text: TuiString) -> *mut TuiResult { + unsafe { + execute(session, "signal", || { + Ok(Operation::Signal { + name: text.required()?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_key( + session: TuiString, + keys: *const TuiString, + len: usize, + action: u32, +) -> *mut TuiResult { + let (operation_name, key_action) = match action { + 0 => ("press", KeyAction::Press), + 1 => ("keyDown", KeyAction::Down), + 2 => ("repeat", KeyAction::Repeat), + 3 => ("keyUp", KeyAction::Up), + _ => return boundary(|| Err(TuiTestError::usage("unknown key action"))), + }; + unsafe { + execute(session, operation_name, || { + Ok(Operation::Key { + keys: input::strings(keys, len)?, + action: key_action, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_resize(session: TuiString, cols: u16, rows: u16) -> *mut TuiResult { + unsafe { execute(session, "resize", || Ok(Operation::Resize { cols, rows })) } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_click( + session: TuiString, + x: TuiOptionalU64, + y: TuiOptionalU64, + on_text: TuiString, + options: TuiMouseOptions, + clicks: u8, +) -> *mut TuiResult { + unsafe { + execute(session, "mouse.click", || { + Ok(Operation::Mouse { + action: MouseAction::Click { + x: input::u16_option(x, "x")?, + y: input::u16_option(y, "y")?, + on_text: on_text.optional()?, + options: input::mouse(options)?, + clicks, + }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_move(session: TuiString, x: u16, y: u16) -> *mut TuiResult { + unsafe { + execute(session, "mouse.move", || { + Ok(Operation::Mouse { + action: MouseAction::Move { x, y }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_down( + session: TuiString, + x: u16, + y: u16, + options: TuiMouseOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "mouse.down", || { + Ok(Operation::Mouse { + action: MouseAction::Down { + x, + y, + options: input::mouse(options)?, + }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_up( + session: TuiString, + x: u16, + y: u16, + options: TuiMouseOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "mouse.up", || { + Ok(Operation::Mouse { + action: MouseAction::Up { + x, + y, + options: input::mouse(options)?, + }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_drag( + session: TuiString, + x1: u16, + y1: u16, + x2: u16, + y2: u16, + options: TuiMouseOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "mouse.drag", || { + Ok(Operation::Mouse { + action: MouseAction::Drag { + x1, + y1, + x2, + y2, + options: input::mouse(options)?, + }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_mouse_scroll( + session: TuiString, + direction: TuiString, + amount: u16, +) -> *mut TuiResult { + unsafe { + execute(session, "mouse.scroll", || { + Ok(Operation::Mouse { + action: MouseAction::Scroll { + direction: direction.required()?, + amount, + }, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_title( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "waitTitle", || { + Ok(Operation::WaitTitle { + text: text.required()?, + regex: options.regex, + not: options.not, + timeout_ms: options.timeout_ms.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_expect_title( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "expectTitle", || { + Ok(Operation::ExpectTitle { + text: text.required()?, + regex: options.regex, + not: options.not, + timeout_ms: options.timeout_ms.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_clipboard( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "waitClipboard", || { + let timeout_ms = options.timeout_ms.option(); + match text.optional()? { + Some(text) => Ok(Operation::WaitClipboardMatch { + pattern: if options.regex { + ClipboardPattern::regex(&text) + .map_err(|e| TuiTestError::usage(format!("invalid regex: {e}")))? + } else { + text.into() + }, + timeout_ms, + }), + None if options.regex => Err(TuiTestError::usage("clipboard regex requires text")), + None => Ok(Operation::WaitClipboard { timeout_ms }), + } + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_idle( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "waitIdle", || { + Ok(Operation::WaitIdle { + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_command( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "waitCommand", || { + Ok(Operation::WaitCommand { + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_exit( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "waitExit", || { + Ok(Operation::WaitExit { + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_ready( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "waitReady", || { + Ok(Operation::WaitReady { + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_bell( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "waitBell", || { + Ok(Operation::WaitBell { + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_find_locator( + session: TuiString, + query: TuiQuery, + require_one: bool, +) -> *mut TuiResult { + let operation_name = if require_one { + "locator.location" + } else { + "locator.locations" + }; + unsafe { + execute(session, operation_name, || { + let query = input::query(query)?; + Ok(if require_one { + Operation::ResolveLocator { query } + } else { + Operation::FindLocator { query } + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_wait_locator( + session: TuiString, + query: TuiQuery, + options: TuiWaitOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "locator.wait", || { + Ok(Operation::WaitLocator { + query: input::query(query)?, + not: options.not, + timeout_ms: options.timeout_ms.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_expect_locator( + session: TuiString, + query: TuiQuery, + options: TuiWaitOptions, +) -> *mut TuiResult { + unsafe { + execute(session, "locator.expect", || { + Ok(Operation::WaitLocator { + query: input::query(query)?, + not: options.not, + timeout_ms: options.timeout_ms.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_click_locator( + session: TuiString, + query: TuiQuery, + options: TuiMouseOptions, + clicks: u8, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "locator.click", || { + Ok(Operation::ClickLocator { + query: input::query(query)?, + options: input::mouse(options)?, + clicks, + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_highlight_locator( + session: TuiString, + query: TuiQuery, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "locator.highlight", || { + Ok(Operation::HighlightLocator { + query: input::query(query)?, + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_expect_exit_code( + session: TuiString, + code: i32, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "expectExitCode", || { + Ok(Operation::ExpectExitCode { + code, + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_expect_output( + session: TuiString, + text: TuiString, + regex: bool, +) -> *mut TuiResult { + unsafe { + execute(session, "expectOutput", || { + Ok(Operation::ExpectOutput { + text: text.required()?, + regex, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_expect_bell_count( + session: TuiString, + count: u64, + timeout: TuiOptionalU64, +) -> *mut TuiResult { + unsafe { + execute(session, "expectBellCount", || { + Ok(Operation::ExpectBellCount { + count, + timeout_ms: timeout.option(), + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_snapshot( + session: TuiString, + name: TuiString, + update: bool, + include_style: bool, + include_title: bool, + cwd: TuiString, +) -> *mut TuiResult { + unsafe { + execute(session, "expectSnapshot", || { + Ok(Operation::Snapshot { + name: name.required()?, + update, + include_style, + include_title, + cwd: cwd.optional()?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_screenshot( + session: TuiString, + full: bool, + path: TuiString, + zoom: TuiOptionalF64, + background: TuiString, + transparent: bool, +) -> *mut TuiResult { + unsafe { + execute(session, "screenshot", || { + Ok(Operation::Screenshot { + full, + path: path.optional()?, + zoom: zoom.option(), + background: input::capture_background(background, transparent)?, + }) + }) + } +} +#[no_mangle] +/// # Safety +/// Borrowed input buffers must be valid and readable for their stated lengths +/// throughout this call; see TuiString and the input structure contracts. +pub unsafe extern "C" fn tui_start_recording( + session: TuiString, + options: TuiRecordingOptions, +) -> *mut TuiResult { + unsafe { execute(session, "startRecording", || input::recording(options)) } +} +#[cfg(test)] +mod tests; diff --git a/bindings/go/native/src/output.rs b/bindings/go/native/src/output.rs new file mode 100644 index 00000000..226eb061 --- /dev/null +++ b/bindings/go/native/src/output.rs @@ -0,0 +1,298 @@ +use crate::types::*; +use tui_test::api::*; + +#[derive(Default)] +struct Storage { + bytes: Vec>, + cells: Vec, + matches: Vec, + spans: Vec>, + bells: Vec, + strings: Vec, + modes: Vec, + pairs: Vec, +} +impl Storage { + fn bytes(&mut self, value: Vec) -> TuiString { + let value = value.into_boxed_slice(); + let output = TuiString { + data: value.as_ptr(), + len: value.len(), + }; + self.bytes.push(value); + output + } + fn string(&mut self, value: String) -> TuiString { + self.bytes(value.into_bytes()) + } + fn optional(&mut self, value: Option) -> TuiString { + value.map(|s| self.string(s)).unwrap_or_default() + } +} +fn integer(value: Option) -> TuiOptionalI32 { + TuiOptionalI32 { + present: value.is_some(), + value: value.unwrap_or_default(), + } +} +fn unsigned(value: u64) -> TuiOptionalU64 { + TuiOptionalU64 { + present: true, + value, + } +} +fn color(value: CellColor) -> TuiColor { + match value { + CellColor::Default => TuiColor::default(), + CellColor::Indexed(index) => TuiColor { + kind: 1, + index, + ..Default::default() + }, + CellColor::Rgb(red, green, blue) => TuiColor { + kind: 2, + red, + green, + blue, + ..Default::default() + }, + } +} +fn complete(mut result: TuiResult, storage: Storage) -> *mut TuiResult { + result.private_data = Box::into_raw(Box::new(storage)).cast(); + Box::into_raw(Box::new(result)) +} +pub(crate) fn error(error: TuiTestError) -> *mut TuiResult { + let mut storage = Storage::default(); + let details = error + .details + .as_deref() + .and_then(|value| serde_json::to_vec(value).ok()) + .map(|value| storage.bytes(value)) + .unwrap_or_default(); + let artifact = error + .artifact + .as_deref() + .and_then(|value| serde_json::to_vec(value).ok()) + .map(|value| storage.bytes(value)) + .unwrap_or_default(); + let result = TuiResult { + error_kind: error.kind.exit_code() as u32, + error_message: storage.string(error.message), + error_details: details, + error_artifact: artifact, + ..Default::default() + }; + complete(result, storage) +} +pub(crate) fn sessions(names: Vec) -> *mut TuiResult { + let mut storage = Storage::default(); + for name in names { + let value = storage.string(name); + storage.strings.push(value); + } + let result = TuiResult { + strings: storage.strings.as_ptr(), + strings_len: storage.strings.len(), + ..Default::default() + }; + complete(result, storage) +} +pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { + let mut s = Storage::default(); + let mut r = TuiResult::default(); + match value { + OperationResult::Unit => {} + OperationResult::Open(v) => { + r.open = TuiOpenResult { + shell_pid: TuiOptionalU64 { + present: v.shell_pid.is_some(), + value: u64::from(v.shell_pid.unwrap_or_default()), + }, + session: s.string(v.session), + ready: v.ready, + recording: s.string(v.recording), + } + } + OperationResult::State(v) => { + for (name, enabled) in v.modes { + let name = s.string(name); + s.modes.push(TuiMode { name, enabled }); + } + for (index, color) in v.colors.palette { + let key = s.string(index.to_string()); + let value = s.string(color); + s.pairs.push(TuiPair { key, value }); + } + r.state = TuiState { + session_shell: s.optional(v.session_shell), + cols: v.cols, + rows: v.rows, + cursor: TuiCursor { + x: v.cursor.x, + y: v.cursor.y, + visible: v.cursor.visible, + shape: s.string(v.cursor.shape), + color: s.string(v.cursor.color), + }, + title: s.optional(v.title), + cwd: s.optional(v.cwd), + last_command: s.optional(v.last_command), + last_exit: integer(v.last_exit), + exit_signal: s.optional(v.exit_signal), + exited: integer(v.exited), + ready: v.ready, + bell_count: v.bell_count, + modes: s.modes.as_ptr(), + modes_len: s.modes.len(), + mouse_mode: s.string(v.mouse_mode), + colors: TuiTerminalColors { + foreground: s.string(v.colors.foreground), + background: s.string(v.colors.background), + cursor: s.string(v.colors.cursor), + palette: s.pairs.as_ptr(), + palette_len: s.pairs.len(), + }, + timeouts: TuiTimeouts { + text: unsigned(v.timeouts.text), + idle: unsigned(v.timeouts.idle), + command: unsigned(v.timeouts.command), + exit: unsigned(v.timeouts.exit), + ready: unsigned(v.timeouts.ready), + }, + text: s.string(v.text), + } + } + OperationResult::Text(v) + | OperationResult::Clipboard(v) + | OperationResult::Recording(v) => r.text = s.string(v), + OperationResult::Command(v) + | OperationResult::Output(v) + | OperationResult::Cwd(v) + | OperationResult::Title(v) => r.text = s.optional(v), + OperationResult::ExitCode(v) => r.exit_code = integer(v), + OperationResult::Cursor(v) => { + r.cursor = TuiCursor { + x: v.x, + y: v.y, + visible: v.visible, + shape: s.string(v.shape), + color: s.string(v.color), + } + } + OperationResult::Modes(v) => { + for (name, enabled) in v { + let name = s.string(name); + s.modes.push(TuiMode { name, enabled }); + } + } + OperationResult::Colors(v) => { + for (index, color) in v.palette { + let key = s.string(index.to_string()); + let value = s.string(color); + s.pairs.push(TuiPair { key, value }); + } + } + OperationResult::Size(v) => { + r.size = TuiSize { + cols: v.cols, + rows: v.rows, + } + } + OperationResult::BellCount(v) => r.number = v, + OperationResult::PackedScreen(v) => { + r.size = TuiSize { + cols: v.cols, + rows: v.rows, + }; + r.text = s.bytes(v.utf8); + } + OperationResult::Cells(v) => { + for c in v { + let cell = TuiCell { + x: c.x, + y: c.y, + character: s.string(c.char), + fg: color(c.fg), + bg: color(c.bg), + bold: c.bold, + dim: c.dim, + italic: c.italic, + inverse: c.inverse, + invisible: c.invisible, + strike: c.strike, + blink: c.blink, + underline: c.underline, + underline_style: s.string(c.underline_style), + underline_color: color(c.underline_color), + link: s.string(c.link), + link_id: s.string(c.link_id), + }; + s.cells.push(cell); + } + r.cells = s.cells.as_ptr(); + r.cells_len = s.cells.len(); + } + OperationResult::Matches(v) => { + for m in v { + let spans = m + .spans + .into_iter() + .map(|v| TuiSpan { + row: v.row, + start: v.start, + end: v.end, + }) + .collect::>() + .into_boxed_slice(); + let value = TuiMatch { + text: s.string(m.text), + start: TuiPosition { + row: m.start.row, + column: m.start.column, + }, + end: TuiPosition { + row: m.end.row, + column: m.end.column, + }, + spans: spans.as_ptr(), + spans_len: spans.len(), + }; + s.spans.push(spans); + s.matches.push(value); + } + r.matches = s.matches.as_ptr(); + r.matches_len = s.matches.len(); + } + OperationResult::BellEvents(v) => { + s.bells = v + .into_iter() + .map(|v| TuiBellEvent { + sequence: v.sequence, + elapsed_ms: v.elapsed_ms, + }) + .collect(); + r.bells = s.bells.as_ptr(); + r.bells_len = s.bells.len(); + } + OperationResult::Snapshot(v) => { + r.snapshot = match v { + SnapshotResult::Passed => 0, + SnapshotResult::Written => 1, + SnapshotResult::Updated => 2, + } + } + OperationResult::Screenshot(ScreenshotResult::Path(v) | ScreenshotResult::Text(v)) => { + r.text = s.string(v) + } + } + complete(r, s) +} +/// # Safety +/// result must be NULL or a live result returned by this library, freed once. +pub(crate) unsafe fn free(result: *mut TuiResult) { + if !result.is_null() { + let result = unsafe { Box::from_raw(result) }; + drop(unsafe { Box::from_raw(result.private_data.cast::()) }); + } +} diff --git a/bindings/go/native/src/tests.rs b/bindings/go/native/src/tests.rs new file mode 100644 index 00000000..3f2a576e --- /dev/null +++ b/bindings/go/native/src/tests.rs @@ -0,0 +1,184 @@ +use super::*; + +fn text(value: &str) -> TuiString { + TuiString { + data: value.as_ptr(), + len: value.len(), + } +} +unsafe fn message(result: *mut TuiResult) -> String { + unsafe { (*result).error_message.required().unwrap() } +} + +#[test] +fn missing_sessions_retain_structured_errors_and_owned_messages() { + for _ in 0..128 { + let result = unsafe { tui_state(text(r#"{"name":"go-abi-missing-session"}"#)) }; + assert_eq!(unsafe { (*result).error_kind }, 3); + assert!(unsafe { message(result) }.contains("no active session")); + unsafe { tui_result_free(result) }; + } + unsafe { tui_result_free(std::ptr::null_mut()) }; +} + +#[test] +fn pointer_options_reject_null_and_preserve_validation() { + let session = text(r#"{"name":"go-abi-pointer-options"}"#); + let options = TuiOpenOptions { + cols: TuiOptionalU64 { + present: true, + value: 65_536, + }, + ..Default::default() + }; + let results = unsafe { + [ + tui_open_ptr(session, std::ptr::null()), + tui_run_ptr(session, std::ptr::null(), text(""), std::ptr::null(), 0), + tui_open_ptr(session, &options), + tui_run_ptr(session, &options, text(""), std::ptr::null(), 0), + ] + }; + for result in results { + assert_eq!(unsafe { (*result).error_kind }, 2); + unsafe { tui_result_free(result) }; + } +} +#[test] +fn panic_is_contained_and_next_call_succeeds() { + let result = boundary(|| panic!("intentional boundary test")); + assert_eq!(unsafe { (*result).error_kind }, 5); + assert!(unsafe { message(result) }.contains("panicked")); + unsafe { tui_result_free(result) }; + let result = unsafe { + tui_close( + text(r#"{"name":"go-abi-missing-session"}"#), + TuiOptionalBool::default(), + ) + }; + assert_eq!(unsafe { (*result).error_kind }, 0); + unsafe { tui_result_free(result) }; +} +#[test] +fn invalid_utf8_and_absent_required_inputs_report_usage() { + let bytes = [0xff]; + for session in [ + TuiString::default(), + TuiString { + data: bytes.as_ptr(), + len: 1, + }, + TuiString { + data: std::ptr::null(), + len: 1, + }, + ] { + let result = unsafe { tui_state(session) }; + assert_eq!(unsafe { (*result).error_kind }, 2); + unsafe { tui_result_free(result) }; + } +} +#[test] +fn input_conversion_preserves_explicit_zero_false_and_empty_values() { + let options = TuiOpenOptions { + cols: TuiOptionalU64 { + present: true, + value: 0, + }, + wait_ready: TuiOptionalBool { + present: true, + value: false, + }, + cwd: text(""), + ..Default::default() + }; + let converted = unsafe { input::open(options).unwrap() }; + assert_eq!(converted.cols, 0); + assert_eq!(converted.rows, 30); + assert_eq!(converted.wait_ready, Some(false)); + assert_eq!(converted.cwd, Some(String::new())); + let absent = unsafe { input::open(TuiOpenOptions::default()).unwrap() }; + assert_eq!(absent.cols, 80); + assert_eq!(absent.wait_ready, None); + assert_eq!(absent.cwd, None); + assert_eq!(absent.recording.mode, AutomaticRecordingMode::Disabled); + + let explicit = unsafe { + input::open(TuiOpenOptions { + recording_mode: text("always"), + ..Default::default() + }) + .unwrap() + }; + assert_eq!(explicit.recording.mode, AutomaticRecordingMode::Always); +} + +#[test] +fn close_is_attempted_when_trace_finalization_fails() { + let close_attempted = std::cell::Cell::new(false); + let error = finish_trace_and_close( + || Err(TuiTestError::internal("trace failed")), + || { + close_attempted.set(true); + Err(TuiTestError::internal("close failed")) + }, + ) + .unwrap_err(); + + assert!(close_attempted.get()); + assert!(error.to_string().contains("trace failed")); + + let result = finish_trace_and_close( + || Err(TuiTestError::new(ErrorKind::NoSession, "trace missing")), + || Ok(OperationResult::Unit), + ); + assert!(matches!(result, Ok(OperationResult::Unit))); +} +#[test] +fn output_buffers_preserve_nuls_unicode_and_absence() { + let expected = "a\0界🙂"; + let result = output::operation(OperationResult::Text(expected.into())); + assert_eq!(unsafe { (*result).text.required().unwrap() }, expected); + unsafe { tui_result_free(result) }; + let result = output::operation(OperationResult::Title(None)); + assert!(unsafe { (*result).text.data.is_null() }); + unsafe { tui_result_free(result) }; + let result = output::operation(OperationResult::Title(Some(String::new()))); + assert!(!unsafe { (*result).text.data.is_null() }); + assert_eq!(unsafe { (*result).text.len }, 0); + unsafe { tui_result_free(result) }; +} +#[test] +fn locator_expressions_preserve_parent_selection() { + let value = r#"{"nodes":[{"kind":"text","text":"parent","occurrence":{"nth":2}},{"kind":"style","style":{"bold":false},"direction":"after","within":0,"occurrence":"any"}],"root":1}"#; + let query = TuiQuery { json: text(value) }; + let converted = unsafe { input::query(query).unwrap() }; + assert_eq!(converted.occurrence, MatchOccurrence::Any); + assert_eq!(converted.direction, LocatorDirection::After); + let LocatorSelector::Style(style) = converted.selector else { + panic!("expected style selector"); + }; + assert_eq!(style.style.bold, Some(false)); + assert_eq!( + converted.within.unwrap().occurrence, + MatchOccurrence::Nth(2) + ); +} +#[test] +fn match_output_owns_nested_spans() { + let result = output::operation(OperationResult::Matches(vec![TextMatch { + text: "界".into(), + start: TextPosition { row: 3, column: 2 }, + end: TextPosition { row: 3, column: 4 }, + spans: vec![TextSpan { + row: 3, + start: 2, + end: 4, + }], + }])); + let matches = unsafe { input::slice((*result).matches, (*result).matches_len).unwrap() }; + assert_eq!(unsafe { matches[0].text.required().unwrap() }, "界"); + let spans = unsafe { input::slice(matches[0].spans, matches[0].spans_len).unwrap() }; + assert_eq!(spans[0].end, 4); + unsafe { tui_result_free(result) }; +} diff --git a/bindings/go/native/src/types.rs b/bindings/go/native/src/types.rs new file mode 100644 index 00000000..c8885a72 --- /dev/null +++ b/bindings/go/native/src/types.rs @@ -0,0 +1,249 @@ +use std::ffi::c_void; + +/// A borrowed UTF-8 byte string. NULL denotes absence; a non-NULL pointer +/// with zero length denotes an explicitly empty string. Inputs live through +/// the call only. All output pointers live until tui_result_free. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiString { + pub data: *const u8, + pub len: usize, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOptionalU64 { + pub present: bool, + pub value: u64, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOptionalI32 { + pub present: bool, + pub value: i32, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOptionalBool { + pub present: bool, + pub value: bool, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOptionalF64 { + pub present: bool, + pub value: f64, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiPair { + pub key: TuiString, + pub value: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiTimeouts { + pub text: TuiOptionalU64, + pub idle: TuiOptionalU64, + pub command: TuiOptionalU64, + pub exit: TuiOptionalU64, + pub ready: TuiOptionalU64, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOpenOptions { + pub backend: TuiString, + pub shell: TuiString, + pub cols: TuiOptionalU64, + pub rows: TuiOptionalU64, + pub cwd: TuiString, + pub env: *const TuiPair, + pub env_len: usize, + pub wait_ready: TuiOptionalBool, + pub restart: bool, + pub scrollback: TuiOptionalU64, + pub colors: *const TuiPair, + pub colors_len: usize, + pub timeouts: TuiTimeouts, + pub recording_mode: TuiString, + pub recording_directory: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiQuery { + pub json: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiWaitOptions { + pub timeout_ms: TuiOptionalU64, + pub regex: bool, + pub not: bool, +} +/// button: 0 left, 1 middle, 2 right. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiMouseOptions { + pub button: u32, + pub alt: bool, + pub ctrl: bool, + pub shift: bool, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiRecordingOptions { + pub path: TuiString, + pub format: TuiString, + pub fps: TuiOptionalU64, + pub speed: TuiOptionalF64, + pub idle_time_limit: TuiOptionalF64, + pub zoom: TuiOptionalF64, + pub background: TuiString, + pub transparent: bool, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiCursor { + pub x: u16, + pub y: u16, + pub visible: bool, + pub shape: TuiString, + pub color: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiSize { + pub cols: u16, + pub rows: u16, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiOpenResult { + pub shell_pid: TuiOptionalU64, + pub session: TuiString, + pub ready: bool, + pub recording: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiState { + pub session_shell: TuiString, + pub cols: u16, + pub rows: u16, + pub cursor: TuiCursor, + pub title: TuiString, + pub cwd: TuiString, + pub last_command: TuiString, + pub last_exit: TuiOptionalI32, + pub exit_signal: TuiString, + pub exited: TuiOptionalI32, + pub ready: bool, + pub bell_count: u64, + pub modes: *const TuiMode, + pub modes_len: usize, + pub mouse_mode: TuiString, + pub colors: TuiTerminalColors, + pub timeouts: TuiTimeouts, + pub text: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiMode { + pub name: TuiString, + pub enabled: bool, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiTerminalColors { + pub foreground: TuiString, + pub background: TuiString, + pub cursor: TuiString, + pub palette: *const TuiPair, + pub palette_len: usize, +} +/// kind: 0 default, 1 indexed (index), 2 RGB (red/green/blue). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiColor { + pub kind: u32, + pub index: u8, + pub red: u8, + pub green: u8, + pub blue: u8, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiCell { + pub x: u16, + pub y: u16, + pub character: TuiString, + pub fg: TuiColor, + pub bg: TuiColor, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub inverse: bool, + pub invisible: bool, + pub strike: bool, + pub blink: bool, + pub underline: bool, + pub underline_style: TuiString, + pub underline_color: TuiColor, + pub link: TuiString, + pub link_id: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiPosition { + pub row: u32, + pub column: u16, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiSpan { + pub row: u32, + pub start: u16, + pub end: u16, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiMatch { + pub text: TuiString, + pub start: TuiPosition, + pub end: TuiPosition, + pub spans: *const TuiSpan, + pub spans_len: usize, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiBellEvent { + pub sequence: u64, + pub elapsed_ms: u64, +} +/// The function called determines the populated success field. error_kind is +/// 0 on success, 1 assertion, 2 usage, 3 no-session, 5 internal. +/// snapshot: 0 passed, 1 written, 2 updated. Free exactly once. +#[repr(C)] +#[derive(Default)] +pub struct TuiResult { + pub error_kind: u32, + pub error_message: TuiString, + pub error_details: TuiString, + pub error_artifact: TuiString, + pub text: TuiString, + pub number: u64, + pub exit_code: TuiOptionalI32, + pub open: TuiOpenResult, + pub state: TuiState, + pub cursor: TuiCursor, + pub size: TuiSize, + pub cells: *const TuiCell, + pub cells_len: usize, + pub matches: *const TuiMatch, + pub matches_len: usize, + pub bells: *const TuiBellEvent, + pub bells_len: usize, + pub strings: *const TuiString, + pub strings_len: usize, + pub snapshot: u32, + pub private_data: *mut c_void, +} diff --git a/bindings/go/native_pointer_test.go b/bindings/go/native_pointer_test.go new file mode 100644 index 00000000..910cad6a --- /dev/null +++ b/bindings/go/native_pointer_test.go @@ -0,0 +1,35 @@ +package tuitest + +import ( + "errors" + "testing" +) + +func TestNativeOptionPointersPreserveValidation(t *testing.T) { + terminal, err := Ephemeral(t.Name(), ClientOptions{Recording: &AutomaticRecording{Mode: RecordingDisabled}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := terminal.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + options := SpawnOptions{Backend: Backend("invalid-native-backend")} + t.Run("open", func(t *testing.T) { + _, openErr := terminal.Open(OpenOptions{SpawnOptions: options}) + requireNativeUsageError(t, openErr) + }) + t.Run("run", func(t *testing.T) { + _, runErr := terminal.Run("unused-program", nil, options) + requireNativeUsageError(t, runErr) + }) +} + +func requireNativeUsageError(t *testing.T, err error) { + t.Helper() + var failure *Error + if !errors.As(err, &failure) || failure.Kind != UsageError { + t.Fatalf("expected native usage error, got %v", err) + } +} diff --git a/bindings/go/spawn_test.go b/bindings/go/spawn_test.go new file mode 100644 index 00000000..2616a96e --- /dev/null +++ b/bindings/go/spawn_test.go @@ -0,0 +1,122 @@ +package tuitest_test + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "time" + + tuitest "github.com/microsoft/tui-test/bindings/go" +) + +func TestRetryTerminalProcess(t *testing.T) { + if !slices.Contains(os.Args, "--tui-go-retry-child") { + return + } + const marker = "first-launch" + _, err := os.Stat(marker) + if errors.Is(err, os.ErrNotExist) { + requireNoError(t, os.WriteFile(marker, []byte("first launch"), 0600)) + } else { + requireNoError(t, err) + fmt.Print("\x1b]133;B\x07retry-ready\r\n") + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + fmt.Printf("received:%s\r\n", scanner.Text()) + } + requireNoError(t, scanner.Err()) + os.Exit(0) +} + +func clientLaunchTimeouts() tuitest.Timeouts { + return tuitest.Timeouts{ + Text: tuitest.Ptr(11 * time.Millisecond), Idle: tuitest.Ptr(12 * time.Millisecond), + Command: tuitest.Ptr(13 * time.Millisecond), Exit: tuitest.Ptr(14 * time.Millisecond), + Ready: tuitest.Ptr(15 * time.Millisecond), + } +} + +type launchTimeoutCase struct { + name string + options tuitest.Timeouts + expected tuitest.EffectiveTimeouts +} + +func launchTimeoutCases() []launchTimeoutCase { + zero := time.Duration(0) + return []launchTimeoutCase{ + {name: "inherit", expected: tuitest.EffectiveTimeouts{Text: 11 * time.Millisecond, Idle: 12 * time.Millisecond, Command: 13 * time.Millisecond, Exit: 14 * time.Millisecond, Ready: 15 * time.Millisecond}}, + {name: "partial override", options: tuitest.Timeouts{Text: &zero, Ready: tuitest.Ptr(42 * time.Millisecond)}, expected: tuitest.EffectiveTimeouts{Idle: 12 * time.Millisecond, Command: 13 * time.Millisecond, Exit: 14 * time.Millisecond, Ready: 42 * time.Millisecond}}, + {name: "explicit zero", options: tuitest.Timeouts{Text: &zero, Idle: &zero, Command: &zero, Exit: &zero, Ready: &zero}}, + } +} + +func TestClientTimeoutDefaultsReachLaunch(t *testing.T) { + for _, launch := range []string{"open", "run"} { + for _, testCase := range launchTimeoutCases() { + t.Run(launch+"/"+testCase.name, func(t *testing.T) { verifyLaunchTimeouts(t, launch, testCase) }) + } + } +} + +func verifyLaunchTimeouts(t *testing.T, launch string, testCase launchTimeoutCase) { + t.Helper() + terminal := newClient(t, tuitest.ClientOptions{Timeouts: clientLaunchTimeouts()}) + options := tuitest.SpawnOptions{WaitReady: tuitest.Ptr(false), Timeouts: testCase.options} + if launch == "run" { + runTerminal(t, terminal, options) + } else { + _, err := terminal.Open(tuitest.OpenOptions{SpawnOptions: options}) + requireNoError(t, err) + } + state, err := terminal.State() + requireNoError(t, err) + if state.Timeouts != testCase.expected { + t.Fatalf("launch timeouts=%+v, expected %+v", state.Timeouts, testCase.expected) + } +} + +func TestInvalidLaunchRetryPreservesSharedSession(t *testing.T) { + for _, launch := range []string{"open", "run"} { + t.Run(launch, func(t *testing.T) { + terminal := newTerminal(t, tuitest.ClientOptions{}) + peer, err := tuitest.New(terminal.Session(), tuitest.ClientOptions{}) + requireNoError(t, err) + options := tuitest.SpawnOptions{Backend: tuitest.Backend("unknown"), Retries: 1, Restart: tuitest.Ptr(false)} + if launch == "run" { + _, err = peer.Run("ignored", nil, options) + } else { + _, err = peer.Open(tuitest.OpenOptions{SpawnOptions: options}) + } + requireKind(t, err, tuitest.UsageError) + requireNoError(t, terminal.GetByText("go-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(time.Duration(0))})) + requireNoError(t, terminal.Submit(tuitest.Ptr("session-survived"))) + requireNoError(t, peer.GetByText("received:session-survived", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(time.Second)})) + }) + } +} + +func TestLaunchRetriesReadinessFailure(t *testing.T) { + directory := t.TempDir() + terminal := newClient(t, tuitest.ClientOptions{}) + executable, err := os.Executable() + requireNoError(t, err) + marker := filepath.Join(directory, "first-launch") + result, err := terminal.Run(executable, []string{"-test.run=^TestRetryTerminalProcess$", "--", "--tui-go-retry-child"}, tuitest.SpawnOptions{ + Cwd: directory, WaitReady: tuitest.Ptr(true), Retries: 1, + Timeouts: tuitest.Timeouts{Ready: tuitest.Ptr(3 * time.Second)}, + }) + requireNoError(t, err) + if !result.Ready { + t.Fatal("retry returned before the second launch was ready") + } + _, err = os.Stat(marker) + requireNoError(t, err) + requireNoError(t, terminal.Submit(tuitest.Ptr("retried"))) + requireNoError(t, terminal.GetByText("received:retried", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(3 * time.Second)})) +} diff --git a/bindings/go/tuitesttest/terminal.go b/bindings/go/tuitesttest/terminal.go new file mode 100644 index 00000000..f2c4f3ab --- /dev/null +++ b/bindings/go/tuitesttest/terminal.go @@ -0,0 +1,61 @@ +// Package tuitesttest integrates terminal sessions with Go test cleanup. +package tuitesttest + +import ( + "strings" + "testing" + + tuitest "github.com/microsoft/tui-test/bindings/go" +) + +// Options are per-test defaults; there is no process-wide mutable configuration. +type Options struct { + Client tuitest.ClientOptions + Spawn tuitest.SpawnOptions + Shell tuitest.Shell + Program string + Args []string + Prefix string +} + +// New opens a unique terminal and registers cleanup even if spawning fails. +// Test failures are reported through Close so the engine can retain configured traces. +func New(tb testing.TB, options Options) *tuitest.Client { + tb.Helper() + prefix := options.Prefix + if prefix == "" { + prefix = tb.Name() + } + terminal, err := tuitest.Ephemeral(prefix, options.Client) + if err != nil { + tb.Fatalf("create terminal: %v", err) + return nil + } + tb.Cleanup(func() { + failed := tb.Failed() + if closeErr := terminal.Close(tuitest.CloseOptions{Failed: tuitest.Ptr(failed)}); closeErr != nil { + tb.Errorf("close terminal: %v", closeErr) + } + }) + if options.Program == "" { + _, err = terminal.Open(tuitest.OpenOptions{SpawnOptions: options.Spawn, Shell: options.Shell}) + } else { + _, err = terminal.Run(options.Program, options.Args, options.Spawn) + } + if err != nil { + tb.Fatalf("spawn terminal: %v", err) + } + return terminal +} + +// TerminalSnapshot removes trailing whitespace and blank lines for text comparisons. +func TerminalSnapshot(text string) string { + lines := strings.Split(text, "\n") + for index, line := range lines { + lines[index] = strings.TrimRight(line, " \t\r") + } + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return strings.Join(lines, "\n") +} diff --git a/bindings/go/tuitesttest/terminal_test.go b/bindings/go/tuitesttest/terminal_test.go new file mode 100644 index 00000000..8f2e9271 --- /dev/null +++ b/bindings/go/tuitesttest/terminal_test.go @@ -0,0 +1,83 @@ +package tuitesttest_test + +import ( + "bufio" + "fmt" + "os" + "slices" + "testing" + "time" + + tuitest "github.com/microsoft/tui-test/bindings/go" + "github.com/microsoft/tui-test/bindings/go/tuitesttest" +) + +func TestHelperChild(t *testing.T) { + if !slices.Contains(os.Args, "--helper-child") { + return + } + fmt.Println("helper-ready") + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + if scanner.Text() == "quit" { + os.Exit(0) + } + } + os.Exit(0) +} + +func helperOptions(t *testing.T) tuitesttest.Options { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return tuitesttest.Options{ + Client: tuitest.ClientOptions{Recording: &tuitest.AutomaticRecording{Mode: tuitest.RecordingDisabled}}, + Spawn: tuitest.SpawnOptions{WaitReady: tuitest.Ptr(false)}, + Program: executable, Args: []string{"-test.run=^TestHelperChild$", "--", "--helper-child"}, + } +} + +func TestCleanupAndParallelIsolation(t *testing.T) { + names := make(chan string, 2) + t.Cleanup(func() { + close(names) + verifyClosedSessions(t, names) + }) + for _, name := range []string{"one", "two"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + terminal := tuitesttest.New(t, helperOptions(t)) + names <- terminal.Session() + if err := terminal.GetByText("helper-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{Timeout: tuitest.Ptr(10 * time.Second)}); err != nil { + t.Fatal(err) + } + }) + } +} + +func verifyClosedSessions(t *testing.T, names <-chan string) { + t.Helper() + remaining, err := tuitest.Sessions() + if err != nil { + t.Fatal(err) + } + previous := "" + for name := range names { + if slices.Contains(remaining, name) { + t.Errorf("session %s survived cleanup", name) + } + if previous == name { + t.Errorf("parallel tests shared session %s", name) + } + previous = name + } +} + +func TestTerminalSnapshot(t *testing.T) { + actual := tuitesttest.TerminalSnapshot("one \r\n two\t\n\n") + if actual != "one\n two" { + t.Fatalf("snapshot=%q", actual) + } +} diff --git a/bindings/go/types.go b/bindings/go/types.go new file mode 100644 index 00000000..60cd009b --- /dev/null +++ b/bindings/go/types.go @@ -0,0 +1,366 @@ +// Package tuitest drives terminal programs through the in-process tui-test engine. +package tuitest + +import "time" + +// Ptr marks an option as explicitly supplied, including false and zero. +func Ptr[Value any](value Value) *Value { return &value } + +type Backend string + +const ( + Alacritty Backend = "alacritty" + Ghostty Backend = "ghostty" + Rio Backend = "rio" + XtermJS Backend = "xtermjs" +) + +type Shell string + +const ( + Bash Shell = "bash" + PowerShell Shell = "powershell" + Pwsh Shell = "pwsh" + Cmd Shell = "cmd" + Fish Shell = "fish" + Zsh Shell = "zsh" + Xonsh Shell = "xonsh" + Elvish Shell = "elvish" + Nushell Shell = "nushell" +) + +type UnderlineStyle string + +const ( + UnderlineNone UnderlineStyle = "none" + UnderlineSingle UnderlineStyle = "single" + UnderlineDouble UnderlineStyle = "double" + UnderlineCurly UnderlineStyle = "curly" + UnderlineDotted UnderlineStyle = "dotted" + UnderlineDashed UnderlineStyle = "dashed" +) + +type RecordingMode string + +const ( + RecordingDisabled RecordingMode = "disabled" + RecordingOnFailure RecordingMode = "on-failure" + RecordingAlways RecordingMode = "always" +) + +type RecordingFormat string + +const ( + APNG RecordingFormat = "apng" + GIF RecordingFormat = "gif" + MP4 RecordingFormat = "mp4" + Cast RecordingFormat = "cast" +) + +type MouseButton string + +const ( + Left MouseButton = "left" + Middle MouseButton = "middle" + Right MouseButton = "right" +) + +type Direction string + +const ( + Within Direction = "within" + After Direction = "after" + Before Direction = "before" +) + +type Whitespace string + +const ( + Exact Whitespace = "exact" + Normalize Whitespace = "normalize" +) + +type Visibility string + +const ( + Visible Visibility = "visible" + Hidden Visibility = "hidden" +) + +type ScrollDirection string + +const ( + Up ScrollDirection = "up" + Down ScrollDirection = "down" +) + +// Timeouts leaves nil fields unspecified and preserves explicit zero durations. +type Timeouts struct{ Text, Idle, Command, Exit, Ready *time.Duration } +type EffectiveTimeouts struct{ Text, Idle, Command, Exit, Ready time.Duration } +type Colors struct { + Foreground, Background, Cursor string + Black, Red, Green, Yellow, Blue, Magenta, Cyan, White string + BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite string +} +type Profile struct { + Scrollback *uint32 + Colors Colors +} +type AutomaticRecording struct { + Mode RecordingMode + Directory string +} +type ArtifactMode string + +const ( + ArtifactAll ArtifactMode = "all" + ArtifactHTML ArtifactMode = "html" + ArtifactText ArtifactMode = "text" + ArtifactNone ArtifactMode = "none" +) + +type ArtifactOptions struct { + Dir string + OnFailure ArtifactMode + IncludeRecording bool +} +type TraceMode string + +const ( + TraceOff TraceMode = "off" + TraceOnFailure TraceMode = "on-failure" + TraceOn TraceMode = "on" +) + +type TraceOptions struct { + Mode TraceMode + Directory string +} +type ClientOptions struct { + Backend Backend + Profile *Profile + Timeouts Timeouts + ScreenHistoryLimit *uint16 + Recording *AutomaticRecording + Artifacts *ArtifactOptions + Trace *TraceOptions +} +type SpawnOptions struct { + Backend Backend + Cols, Rows *uint16 + Cwd string + Env map[string]string + WaitReady, Restart *bool + Retries uint32 + Profile *Profile + Timeouts Timeouts +} +type OpenOptions struct { + SpawnOptions + Shell Shell +} +type WaitOptions struct{ Timeout *time.Duration } +type TitleOptions struct { + Regex, Not bool + Timeout *time.Duration +} +type ClipboardWaitOptions struct { + Text *string + Regex bool + Timeout *time.Duration +} +type TextOptions struct{ Full bool } +type ScreenshotOptions struct { + Full bool + Zoom *float64 + Background string + Transparent bool +} +type RecordingOptions struct { + Format RecordingFormat + FPS *uint32 + Speed, IdleTimeLimit, Zoom *float64 + Background string + Transparent bool +} +type SnapshotOptions struct { + Update, IncludeStyle, IncludeTitle bool + Cwd string +} +type RestartOptions struct{ GracefulTimeout *time.Duration } +type CloseOptions struct{ Failed *bool } +type SnapshotResult string + +const ( + SnapshotPassed SnapshotResult = "passed" + SnapshotWritten SnapshotResult = "written" + SnapshotUpdated SnapshotResult = "updated" +) + +type OutputOptions struct{ Regex bool } +type TextSelectorOptions struct { + Regex, Full bool + Whitespace Whitespace + Direction Direction +} +type StyleSelectorOptions struct { + Full bool + Direction Direction +} +type TextStyle struct { + Foreground, Background *string + Bold, Dim, Italic *bool + UnderlineStyle *UnderlineStyle + UnderlineColor *string + Inverse, Hidden, Strikethrough, Blink *bool +} +type LocatorWaitOptions struct { + State Visibility + Timeout *time.Duration +} +type LocatorExpectOptions struct { + Not bool + Timeout *time.Duration +} +type MouseButtonOptions struct { + Button MouseButton + Alt, Ctrl, Shift bool +} +type MouseClickOptions struct { + MouseButtonOptions + X, Y *uint16 + OnText *string + Clicks *uint32 +} +type LocatorClickOptions struct { + MouseButtonOptions + Clicks *uint32 + Timeout *time.Duration +} +type Cursor struct { + X, Y uint16 + Visible bool + Shape string + Color string +} +type Size struct{ Cols, Rows uint16 } +type TerminalColors struct { + Foreground, Background, Cursor string + Palette map[uint8]string +} + +// Color is "default", a named or RGB color, or an indexed decimal color. +type Color string +type Cell struct { + X, Y uint16 + Char string + FG, BG Color + Bold, Dim, Italic, Inverse, Invisible, Strike, Blink, Underline bool + UnderlineStyle UnderlineStyle + UnderlineColor Color + Link, LinkID string +} +type BellEvent struct { + Sequence uint64 + Elapsed time.Duration +} +type TextPosition struct{ Row, Column uint32 } +type TextSpan struct{ Row, Start, End uint32 } +type TextMatch struct { + Text string + Start, End TextPosition + Spans []TextSpan +} +type OpenResult struct { + ShellPID *uint32 + Session string + Ready bool + Recording string +} +type State struct { + SessionShell *string + Cols, Rows uint16 + Cursor Cursor + Title, Cwd, LastCommand *string + LastExit *int32 + ExitSignal *string + Exited *int32 + Ready bool + BellCount uint64 + Modes map[string]bool + MouseMode string + Colors TerminalColors + Timeouts EffectiveTimeouts + Text string +} +type ErrorKind string + +const ( + AssertionError ErrorKind = "assertion" + UsageError ErrorKind = "usage" + NoSessionError ErrorKind = "no_session" + InternalError ErrorKind = "internal" +) + +type FailureReason string +type LocatorFailureReason string +type FailureCellMismatch struct { + Location TextPosition `json:"location"` + Grapheme string `json:"grapheme"` + Property string `json:"property"` + Operator string `json:"operator"` + Expected string `json:"expected"` + Actual string `json:"actual"` + Resolved *string `json:"resolved,omitempty"` + Reason string `json:"reason"` +} +type FailureLocatorDetails struct { + Reason *LocatorFailureReason `json:"reason,omitempty"` + Selectors []string `json:"selectors"` + StageIndex *uint32 `json:"stage_index,omitempty"` + Locations []TextPosition `json:"locations"` + Mismatches []FailureCellMismatch `json:"mismatches"` +} +type FailureComparison struct { + Kind string `json:"kind"` + Expected *string `json:"expected,omitempty"` + Actual *string `json:"actual,omitempty"` +} +type FailureDetails struct { + SchemaVersion uint32 `json:"schema_version"` + Operation string `json:"operation"` + Reason FailureReason `json:"reason"` + Summary string `json:"summary"` + Locator *FailureLocatorDetails `json:"locator,omitempty"` + Comparison *FailureComparison `json:"comparison,omitempty"` + Truncated bool `json:"truncated"` +} +type FailureArtifactStatus string +type FailureArtifactRef struct { + Status FailureArtifactStatus `json:"status"` + Directory string `json:"directory"` + Manifest *string `json:"manifest,omitempty"` + Report *string `json:"report,omitempty"` + ReportHTML *string `json:"report_html,omitempty"` + Timeline *string `json:"timeline,omitempty"` + ScreenText *string `json:"screen_text,omitempty"` + ScreenSVG *string `json:"screen_svg,omitempty"` + Recording *string `json:"recording,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// Error retains the engine's category and diagnostic message for errors.As. +type Error struct { + Kind ErrorKind + Message string + Operation string + Details *FailureDetails + Artifact *FailureArtifactRef +} + +func (failure *Error) Error() string { + if failure.Operation != "" { + return failure.Operation + ": " + failure.Message + } + return failure.Message +}