diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7da6b623..49f736c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,16 @@ jobs: include: - os: macos-latest bin: target/debug/tui-test + go-target: aarch64-apple-darwin + go-library: libtui_test_go.dylib - os: windows-latest bin: target/debug/tui-test.exe + go-target: x86_64-pc-windows-msvc + go-library: tui_test_go.dll - os: ubuntu-latest bin: target/debug/tui-test + go-target: x86_64-unknown-linux-gnu + go-library: libtui_test_go.so runs-on: ${{ matrix.os }} env: TUI_TEST_BIN: ${{ github.workspace }}/${{ matrix.bin }} @@ -59,6 +65,58 @@ jobs: - run: cargo build - run: cargo test --workspace -- --test-threads=1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: bindings/go/go.mod + cache: false + + - name: Build Go native library and verify header + shell: bash + run: | + cargo build --locked -p tui-test-go + git diff --exit-code -- bindings/go/internal/native/native.h + + - name: Test Go native library override + shell: bash + working-directory: bindings/go + env: + CGO_ENABLED: "0" + TUI_TEST_GO_NATIVE_LIBRARY: ${{ github.workspace }}/target/debug/${{ matrix.go-library }} + run: go run ./examples/smoke + + - name: Embed host Go native library + shell: bash + run: | + mkdir -p bindings/go/internal/native/embedded/${{ matrix.go-target }} + cp target/debug/${{ matrix.go-library }} bindings/go/internal/native/embedded/${{ matrix.go-target }}/ + + - name: Check Go formatting, vet, and tests + shell: bash + working-directory: bindings/go + env: + CGO_ENABLED: "0" + run: | + test -z "$(gofmt -l .)" + go vet ./... + go test ./... + go run ./examples/smoke + + - name: Check Go races + shell: bash + working-directory: bindings/go + env: + CGO_ENABLED: "1" + run: go test -race ./... + + - name: Check Go native layout against the C compiler + shell: bash + working-directory: bindings/go + env: + CGO_ENABLED: "1" + run: | + "$(go env CC)" --version + go test -tags=tuitest_abi_check -run '^TestNativeLayoutMatchesCompiler$' -count=1 ./internal/native + - name: Install Python binding run: python -m pip install --disable-pip-version-check -e ./bindings/python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f69e04..627fe85a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,6 +137,283 @@ jobs: tui-test-*.tar.gz tui-test-*.zip + build-go: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + os: ubuntu-22.04 + - target: aarch64-unknown-linux-musl + os: ubuntu-22.04-arm + - target: x86_64-apple-darwin + os: macos-15-intel + - target: aarch64-apple-darwin + os: macos-15 + - target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: bindings/go/go.mod + cache: false + + - run: | + rustup toolchain install stable --profile default + rustup default stable + rustup target add ${{ matrix.target }} + + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + if: contains(matrix.target, 'musl') + with: + version: "0.16.0" + cache-key: go-${{ matrix.target }} + + - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 + if: contains(matrix.target, 'musl') + with: + tool: cargo-zigbuild + + - name: Build Go native library + if: "!contains(matrix.target, 'musl')" + run: cargo build --release --locked -p tui-test-go --target ${{ matrix.target }} + + - name: Build Go native library for musl + if: contains(matrix.target, 'musl') + run: cargo zigbuild --release --locked -p tui-test-go --target ${{ matrix.target }} + env: + RUSTFLAGS: -C target-feature=-crt-static + + - name: Package native library + run: | + set -euo pipefail + mkdir -p go-package/include go-package/lib + cp bindings/go/internal/native/native.h go-package/include/tui_test_go.h + cp LICENSE go-package/ + printf 'version=%s\ntarget=%s\ncommit=%s\n' "$RELEASE_TAG" '${{ matrix.target }}' "$(git rev-parse HEAD)" > go-package/VERSION + case '${{ matrix.target }}' in + *windows*) + cp target/${{ matrix.target }}/release/tui_test_go.dll go-package/lib/ + ;; + *darwin*) cp target/${{ matrix.target }}/release/libtui_test_go.dylib go-package/lib/ ;; + *) cp target/${{ matrix.target }}/release/libtui_test_go.so go-package/lib/ ;; + esac + archive="tui-test-go-${{ matrix.target }}.tar.gz" + tar -czf "$archive" -C go-package . + if command -v sha256sum >/dev/null; then + sha256sum "$archive" > "$archive.sha256" + sha256sum -c "$archive.sha256" + else + shasum -a 256 "$archive" > "$archive.sha256" + shasum -a 256 -c "$archive.sha256" + fi + mkdir go-installed + tar -xzf "$archive" -C go-installed + mkdir -p bindings/go/internal/native/embedded/${{ matrix.target }} + cp go-installed/lib/* bindings/go/internal/native/embedded/${{ matrix.target }}/ + + - name: Prepare external consumer + run: | + mkdir go-consumer + cp bindings/go/examples/smoke/main.go go-consumer/ + cd go-consumer + go mod init example.com/go-package-smoke + go mod edit -require=github.com/microsoft/tui-test/bindings/go@v0.0.0 + go mod edit -replace=github.com/microsoft/tui-test/bindings/go=../bindings/go + + - name: Test packaged library without Rust on Unix + if: runner.os != 'Windows' && !contains(matrix.target, 'musl') + working-directory: go-consumer + run: | + go_bin="$(command -v go)" + export PATH="$(dirname "$go_bin"):/usr/bin:/bin:/usr/sbin:/sbin" + if command -v cargo >/dev/null 2>&1 || command -v rustc >/dev/null 2>&1; then + echo 'Rust is visible in consumer environment' >&2 + exit 1 + fi + export CGO_ENABLED=0 + go mod tidy + go build -o smoke . + ./smoke + + - name: Test packaged library without Rust on Windows + if: runner.os == 'Windows' + shell: pwsh + working-directory: go-consumer + run: | + $goBin = Split-Path (Get-Command go).Source + $env:PATH = "$goBin;$env:SystemRoot\System32;$env:SystemRoot" + if ((Get-Command cargo,rustc -ErrorAction SilentlyContinue)) { throw 'Rust is visible in consumer environment' } + $env:CGO_ENABLED = '0' + go mod tidy + if ($LASTEXITCODE) { exit $LASTEXITCODE } + go build -o smoke.exe . + if ($LASTEXITCODE) { exit $LASTEXITCODE } + ./smoke.exe + if ($LASTEXITCODE) { exit $LASTEXITCODE } + + - name: Test packaged musl library without Rust + if: contains(matrix.target, 'musl') + run: | + docker run --rm \ + --mount "type=bind,src=$GITHUB_WORKSPACE/go-consumer,dst=/workspace/go-consumer" \ + --mount "type=bind,src=$GITHUB_WORKSPACE/bindings/go,dst=/workspace/bindings/go,readonly" \ + --workdir /workspace/go-consumer \ + golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c sh -ec ' + if command -v cargo >/dev/null 2>&1 || command -v rustc >/dev/null 2>&1; then + echo "Rust is visible in consumer environment" >&2 + exit 1 + fi + export CGO_ENABLED=0 + go mod tidy + go build -o smoke . + ./smoke + ' + + - name: Check Go native layout against the host C compiler + working-directory: bindings/go + env: + CGO_ENABLED: "1" + run: | + "$(go env CC)" --version + go test -tags=tuitest_abi_check -run '^TestNativeLayoutMatchesCompiler$' -count=1 ./internal/native + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: tui-test-go-${{ matrix.target }} + path: | + tui-test-go-*.tar.gz + tui-test-go-*.sha256 + if-no-files-found: error + + assemble-go-module: + needs: build-go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: tui-test-go-* + path: go-packages + merge-multiple: true + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: bindings/go/go.mod + cache: false + + - name: Assemble the Go module with all native libraries + shell: bash + run: | + set -euo pipefail + targets=( + x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu + x86_64-unknown-linux-musl aarch64-unknown-linux-musl + x86_64-apple-darwin aarch64-apple-darwin + x86_64-pc-windows-msvc + ) + for target in "${targets[@]}"; do + (cd go-packages && sha256sum -c "tui-test-go-$target.tar.gz.sha256") + mkdir -p "go-unpacked/$target" "bindings/go/internal/native/embedded/$target" + tar -xzf "go-packages/tui-test-go-$target.tar.gz" -C "go-unpacked/$target" + grep -Fx "version=$RELEASE_TAG" "go-unpacked/$target/VERSION" + grep -Fx "target=$target" "go-unpacked/$target/VERSION" + grep -Fx "commit=$(git rev-parse HEAD)" "go-unpacked/$target/VERSION" + case "$target" in + *windows*) library=tui_test_go.dll ;; + *darwin*) library=libtui_test_go.dylib ;; + *) library=libtui_test_go.so ;; + esac + test -s "go-unpacked/$target/lib/$library" + test "$(wc -c < "go-unpacked/$target/lib/$library")" -lt 100000000 + cp "go-unpacked/$target/lib/$library" "bindings/go/internal/native/embedded/$target/$library" + done + cp LICENSE bindings/go/LICENSE + printf 'version=%s\nsource-tag=%s\nsource-commit=%s\n' "$RELEASE_TAG" "$RELEASE_TAG" "$(git rev-parse HEAD)" > bindings/go/internal/native/embedded/RELEASE + module_bytes="$(find bindings/go -type f -printf '%s\n' | awk '{bytes += $1} END {printf "%.0f", bytes}')" + test "$module_bytes" -lt 524288000 + tar -czf tui-test-go-module.tar.gz -C bindings/go . + sha256sum tui-test-go-module.tar.gz > tui-test-go-module.tar.gz.sha256 + + - name: Test the assembled module from an independent consumer + shell: bash + env: + CGO_ENABLED: "0" + run: | + mkdir assembled-module assembled-consumer + tar -xzf tui-test-go-module.tar.gz -C assembled-module + cp assembled-module/examples/smoke/main.go assembled-consumer/ + cd assembled-consumer + go mod init example.com/assembled-go-module-smoke + go mod edit -require=github.com/microsoft/tui-test/bindings/go@v0.0.0 + go mod edit -replace=github.com/microsoft/tui-test/bindings/go=../assembled-module + go mod tidy + go build -o smoke . + ./smoke + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: tui-test-go-module + path: | + tui-test-go-module.tar.gz + tui-test-go-module.tar.gz.sha256 + if-no-files-found: error + + publish-go-module: + needs: + - assemble-go-module + - release + if: always() && needs.assemble-go-module.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: tui-test-go-module + + - name: Publish the bundled Go module tag + shell: bash + run: | + set -euo pipefail + sha256sum -c tui-test-go-module.tar.gz.sha256 + tar -xzf tui-test-go-module.tar.gz -C bindings/go + grep -Fx "source-commit=$(git rev-parse HEAD)" bindings/go/internal/native/embedded/RELEASE + tag="bindings/go/v$RELEASE_TAG" + git add --force -- bindings/go/internal/native/embedded bindings/go/LICENSE + existing="$(git ls-remote --tags origin "refs/tags/$tag")" + if [[ -n "$existing" ]]; then + git fetch --depth=1 origin "refs/tags/$tag:refs/tags/$tag" + git diff --exit-code "$tag" -- bindings/go + echo "$tag already contains this bundled module" + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit --signoff -m "build(go): bundle native libraries for $RELEASE_TAG" + git tag "$tag" + git push origin "refs/tags/$tag:refs/tags/$tag" + build-node: needs: verify strategy: @@ -521,6 +798,7 @@ jobs: release: needs: - build-cli + - assemble-go-module - package-node - build-python if: github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 14b07708..d347c118 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ bindings/python/stub-gen/target trace-viewer/node_modules/ trace-viewer/.preview/ trace-viewer/test-results/ -trace-viewer/playwright-report/ \ No newline at end of file +trace-viewer/playwright-report/ +/bindings/go/internal/native/embedded/**/*.dll +/bindings/go/internal/native/embedded/**/*.so +/bindings/go/internal/native/embedded/**/*.dylib diff --git a/Cargo.lock b/Cargo.lock index 9e60f5d2..c2ac2060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,25 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "clap", + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.118", + "tempfile", + "toml", +] + [[package]] name = "cc" version = "1.4.0" @@ -691,6 +710,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "r-efi", +] + [[package]] name = "gif" version = "0.14.2" @@ -1369,6 +1399,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "recvmsg" version = "1.0.0" @@ -1390,7 +1426,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror 2.0.18", ] @@ -1845,6 +1881,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2046,6 +2095,7 @@ dependencies = [ name = "tui-test-go" version = "0.1.0" dependencies = [ + "cbindgen", "serde", "serde_json", "tui-test-rs", diff --git a/README.md b/README.md index 34a2b924..d84b04f5 100644 --- a/README.md +++ b/README.md @@ -48,13 +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) | +| Go 1.26+ | `go get github.com/microsoft/tui-test/bindings/go` | [Go installation and 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, 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. +The CLI and libraries expose the same terminal actions. Python, JavaScript, Rust, and Go sessions run in-process and do not require the CLI. Published Go modules bundle the native engine and load it automatically; see [Go installation](bindings/go/README.md#install). ### CLI diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index f0de70e0..6b3a7923 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -1,22 +1,22 @@ # Building and maintaining the Go binding -The [Go README](README.md) covers installation and application usage. This guide covers native builds and validation. +The [Go README](README.md) covers installation and application usage. This guide covers native builds, validation, and release maintenance. ## 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 `internal/native` package owns the C ABI, purego calls, native memory lifetimes, library loading, and embedded platform libraries. 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. +The Rust adapter source stays in `native/` and delegates to the existing `tui-test` session registry and operations. Published platform libraries live under `internal/native/embedded` and are embedded into the application. On first use, the native package verifies or extracts its engine into a content-addressed user cache and loads it for the process lifetime. A non-empty `TUI_TEST_GO_NATIVE_LIBRARY` is the sole load candidate and does not fall back to embedded libraries if it fails. 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. +The packaged native library and Go module must come from the same source release. Runtime compatibility is checked separately with the private ABI version described below. 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. +Install Go 1.26 or newer, Rust 1.90 or newer, and the native build and link tools required by your Rust target. Linux GNU builds use the native C/C++ toolchain, macOS builds use the Apple command-line tools, and Windows amd64 builds use the MSVC toolchain. Linux musl release builds additionally use Zig 0.16.0 and `cargo-zigbuild`. These are contributor prerequisites; application developers using a published module only need Go. From the repository root: @@ -24,7 +24,7 @@ From the repository root: 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. +The native adapter enables the same terminal backends and recording features as the JavaScript and Python bindings. The build verifies the generated C header against the checked-in `bindings/go/internal/native/native.h`. Set `TUI_TEST_GO_NATIVE_LIBRARY` to the built library before running a Go application or test. For Linux: @@ -57,6 +57,8 @@ go mod edit -replace github.com/microsoft/tui-test/bindings/go=/absolute/path/to go get github.com/microsoft/tui-test/bindings/go ``` +A source checkout contains placeholders for packaged platforms. The release pipeline supplies all seven engine builds before publishing the module, so source consumers must use `TUI_TEST_GO_NATIVE_LIBRARY`. + ## Validate changes After setting `TUI_TEST_GO_NATIVE_LIBRARY`, run these commands from `bindings/go`: @@ -78,4 +80,59 @@ 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. +The [CI workflow](../../.github/workflows/ci.yml) first smoke-tests `TUI_TEST_GO_NATIVE_LIBRARY`, then embeds the host library and runs the Go checks and smoke example through the bundled path. It also runs the JavaScript and Python binding regression suites. + +### Updating the C header + +When changing the native ABI, regenerate the header from the Rust definitions with cbindgen. Cargo obtains the repository's pinned cbindgen build dependency; no separate cbindgen executable is required. From the repository root on Linux or macOS: + +```sh +TUI_TEST_GO_UPDATE_HEADER=1 cargo build -p tui-test-go +cargo build --locked -p tui-test-go +``` + +On PowerShell: + +```powershell +$env:TUI_TEST_GO_UPDATE_HEADER = '1' +cargo build -p tui-test-go +Remove-Item Env:TUI_TEST_GO_UPDATE_HEADER +cargo build --locked -p tui-test-go +``` + +Review the generated `bindings/go/internal/native/native.h` alongside the Rust and Go changes. The second build checks the header without rewriting it. + +The private ABI version is separate from the workspace release version. Increment it when old and new Go/native components are not interchangeable, including changes to required symbols, function signatures, structure layouts, or field meanings. Header-preserving refactors and additive behavior behind an unchanged ABI do not require a bump. + +The exported `tui_abi_version()` function in `bindings/go/native/src/lib.rs` currently returns `4`. Go registers that symbol as `nativeFunctionTable.AbiVersion`; `loadNativeFunctions` in `bindings/go/internal/native/loader.go` requires the returned value to equal `4`, and `checkNativeVersion` in `bindings/go/internal/native/session.go` repeats that requirement before a session operation. Set all three locations to the same incremented value. The CI override and bundled smoke runs both initialize the native engine, so either run fails with an incompatible-version error if the Rust value and Go requirement differ. + +After changing ABI types, compare the Go layouts against the C compiler's sizes, alignments, and field offsets: + +```sh +CGO_ENABLED=1 go test -tags=tuitest_abi_check -run '^TestNativeLayoutMatchesCompiler$' -count=1 ./internal/native +``` + +Run this command from `bindings/go` with a C compiler installed. On PowerShell, set `$env:CGO_ENABLED = '1'` before the `go test` command. This contributor-only check uses the header; normal builds and tests need no cgo. + +## Release the binding + +The normal release starts when a maintainer pushes a repository tag matching `..` or `..-beta.`. For example: + +```sh +git tag 0.1.0 +git push origin 0.1.0 +``` + +The workflow checks out that tag, verifies its format and project versions, builds the release, and creates the GitHub release before publishing the bundled Go module tag. + +The [release workflow](../../.github/workflows/release.yml) builds libraries for Linux glibc and musl on amd64 and arm64, macOS on amd64 and arm64, and Windows on amd64. GNU targets use native Rust and linker runners, macOS uses the Apple toolchain, Windows uses MSVC, and musl targets use Zig 0.16.0 with `cargo-zigbuild`. Each native archive includes its header, library, license, version, target, and source commit, with a separate SHA-256 checksum. Each target is tested from an external Go consumer with cgo disabled and without Rust on its executable search path. + +The module assembly job verifies every checksum and the recorded version, target, source commit, library presence, and size before placing all seven libraries under `internal/native/embedded/`. It adds the license, records the source release, checks the assembled module size, and tests an independent consumer with cgo disabled. Platform-specific embedding includes only the relevant libraries in an application; Linux includes both libc variants and tries them in order. + +After successful release publication, the Go publication job creates a commit containing the bundled module and publishes `bindings/go/v`. For example, repository release `0.1.0` corresponds to Go tag `bindings/go/v0.1.0`. This tag points to the assembled sources containing the binaries, not the original release commit. The original source commit is recorded in the bundled provenance. The publication job does not update the main branch and refuses to replace an existing tag containing different module contents. + +GitHub release attachments alone cannot supply the binaries to `go get`; the nested Go tag must contain them. Use the workflow's publication job rather than manually tagging the unbundled source commit. + +If publication fails after the repository tag exists, open the `Release` workflow in GitHub Actions, choose **Run workflow**, and set its required `tag` input to that existing tag, such as `0.1.0`. A manual run rebuilds from the named tag and can retry the package and Go-module publication jobs; it does not recreate the push-only GitHub release. Go publication is successful when the `publish-go-module` job succeeds and the remote `bindings/go/v` tag contains the assembled module. If that nested tag already has identical contents, the job reports that it already contains the bundled module and succeeds; different contents cause the job to fail without replacing the tag. + +The module and its embedded engine must come from the same source release. diff --git a/bindings/go/README.md b/bindings/go/README.md index 27dd82ba..5f09bf5f 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -4,61 +4,19 @@ Control, inspect, test, and record terminal apps from Go. The binding runs the R ## 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. +With Go 1.26 or newer, add the binding to your project: -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 +go get github.com/microsoft/tui-test/bindings/go +``` - ```sh - export TUI_TEST_GO_NATIVE_LIBRARY=/absolute/path/to/tui-test/target/debug/libtui_test_go.so - go run . - ``` +Published Go module tags include the native engine. You do not need the CLI, Rust, a C compiler, or native library configuration. Supported targets are Windows amd64, macOS amd64 and arm64, and Linux amd64 and arm64 with glibc or musl. Bundled engines are not provided for other target combinations; where the package builds, initialization reports that no bundled engine is available. - Use the `.dylib` path shown above on macOS. On Windows PowerShell: +Source checkouts contain placeholders rather than engine binaries. Build the native library and set `TUI_TEST_GO_NATIVE_LIBRARY` to its absolute path before running a Go application from a checkout. See [building and maintaining the Go binding](CONTRIBUTING.md#build-from-source). - ```powershell - $env:TUI_TEST_GO_NATIVE_LIBRARY = (Resolve-Path C:\path\to\tui-test\target\debug\tui_test_go.dll) - go run . - ``` +When `TUI_TEST_GO_NATIVE_LIBRARY` is non-empty, its value is the only library path tried. An unreadable, unloadable, or ABI-incompatible override returns an initialization error; the binding does not fall back to the bundled engine. -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. +Without an override, the binding verifies and extracts the bundled candidate on first use, then keeps it loaded for the process lifetime. The library is stored under `tui-test/native/` in the directory returned by Go's `os.UserCacheDir`. Content-addressed directories keep engine builds separate, corrupt cached bytes are replaced from the bundle, and concurrent processes can share the cache safely. Loading fails if the cache directory is unavailable or unwritable, or if the operating system does not permit loading the extracted library. The binding does not download anything at runtime. ## Quick start @@ -104,6 +62,8 @@ func main() { Use `Run(program, args, SpawnOptions{})` to launch an application directly. Use `Open(OpenOptions{})` when you need a shell and its command tracking. +The example prints the shell output containing `hello` when the engine loads and the command completes. + ## 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`. @@ -235,4 +195,4 @@ Import `testing`, `github.com/microsoft/tui-test/bindings/go`, and `github.com/m ## Contributing -See [building and testing the Go binding](CONTRIBUTING.md). +See [building, testing, and releasing the Go binding](CONTRIBUTING.md). diff --git a/bindings/go/integration_test.go b/bindings/go/integration_test.go index 4f8671c0..62051cf7 100644 --- a/bindings/go/integration_test.go +++ b/bindings/go/integration_test.go @@ -305,7 +305,6 @@ func TestNamedHandlesReopen(t *testing.T) { } 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() }) } diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go index 733da79b..857047ca 100644 --- a/bindings/go/internal/native/abi.go +++ b/bindings/go/internal/native/abi.go @@ -220,3 +220,11 @@ type abiRecordingOptions struct { background abiString transparent bool } +type abiScreenshotOptions struct { + _ structs.HostLayout + full bool + path abiString + zoom abiOptionalF64 + background abiString + transparent bool +} diff --git a/bindings/go/internal/native/abi_check.go b/bindings/go/internal/native/abi_check.go new file mode 100644 index 00000000..b501d483 --- /dev/null +++ b/bindings/go/internal/native/abi_check.go @@ -0,0 +1,56 @@ +//go:build tuitest_abi_check && cgo + +package native + +/* +#include "native.h" +*/ +import "C" + +import ( + "reflect" + "slices" +) + +type nativeLayoutPair struct{ binding, compiler reflect.Type } + +// The C compiler supplies its actual native.h field offsets for this target. +func nativeLayoutPairs() []nativeLayoutPair { + return slices.Concat(nativeOutputLayouts(), nativeInputLayouts()) +} + +func nativeOutputLayouts() []nativeLayoutPair { + return []nativeLayoutPair{ + {binding: reflect.TypeFor[abiString](), compiler: reflect.TypeFor[C.TuiString]()}, + {binding: reflect.TypeFor[abiOptionalI32](), compiler: reflect.TypeFor[C.TuiOptionalI32]()}, + {binding: reflect.TypeFor[abiOptionalU64](), compiler: reflect.TypeFor[C.TuiOptionalU64]()}, + {binding: reflect.TypeFor[abiOpenResult](), compiler: reflect.TypeFor[C.TuiOpenResult]()}, + {binding: reflect.TypeFor[abiCursor](), compiler: reflect.TypeFor[C.TuiCursor]()}, + {binding: reflect.TypeFor[abiMode](), compiler: reflect.TypeFor[C.TuiMode]()}, + {binding: reflect.TypeFor[abiTerminalColors](), compiler: reflect.TypeFor[C.TuiTerminalColors]()}, + {binding: reflect.TypeFor[abiTimeouts](), compiler: reflect.TypeFor[C.TuiTimeouts]()}, + {binding: reflect.TypeFor[abiState](), compiler: reflect.TypeFor[C.TuiState]()}, + {binding: reflect.TypeFor[abiSize](), compiler: reflect.TypeFor[C.TuiSize]()}, + {binding: reflect.TypeFor[abiColor](), compiler: reflect.TypeFor[C.TuiColor]()}, + {binding: reflect.TypeFor[abiCell](), compiler: reflect.TypeFor[C.TuiCell]()}, + {binding: reflect.TypeFor[abiPosition](), compiler: reflect.TypeFor[C.TuiPosition]()}, + {binding: reflect.TypeFor[abiSpan](), compiler: reflect.TypeFor[C.TuiSpan]()}, + {binding: reflect.TypeFor[abiMatch](), compiler: reflect.TypeFor[C.TuiMatch]()}, + {binding: reflect.TypeFor[abiBellEvent](), compiler: reflect.TypeFor[C.TuiBellEvent]()}, + {binding: reflect.TypeFor[abiResult](), compiler: reflect.TypeFor[C.TuiResult]()}, + } +} + +func nativeInputLayouts() []nativeLayoutPair { + return []nativeLayoutPair{ + {binding: reflect.TypeFor[abiPair](), compiler: reflect.TypeFor[C.TuiPair]()}, + {binding: reflect.TypeFor[abiOptionalBool](), compiler: reflect.TypeFor[C.TuiOptionalBool]()}, + {binding: reflect.TypeFor[abiOpenOptions](), compiler: reflect.TypeFor[C.TuiOpenOptions]()}, + {binding: reflect.TypeFor[abiMouseOptions](), compiler: reflect.TypeFor[C.TuiMouseOptions]()}, + {binding: reflect.TypeFor[abiWaitOptions](), compiler: reflect.TypeFor[C.TuiWaitOptions]()}, + {binding: reflect.TypeFor[abiQuery](), compiler: reflect.TypeFor[C.TuiQuery]()}, + {binding: reflect.TypeFor[abiOptionalF64](), compiler: reflect.TypeFor[C.TuiOptionalF64]()}, + {binding: reflect.TypeFor[abiRecordingOptions](), compiler: reflect.TypeFor[C.TuiRecordingOptions]()}, + {binding: reflect.TypeFor[abiScreenshotOptions](), compiler: reflect.TypeFor[C.TuiScreenshotOptions]()}, + } +} diff --git a/bindings/go/internal/native/abi_check_test.go b/bindings/go/internal/native/abi_check_test.go new file mode 100644 index 00000000..7c880bdf --- /dev/null +++ b/bindings/go/internal/native/abi_check_test.go @@ -0,0 +1,57 @@ +//go:build tuitest_abi_check && cgo + +package native + +import ( + "reflect" + "strings" + "testing" +) + +func TestNativeLayoutMatchesCompiler(t *testing.T) { + for _, pair := range nativeLayoutPairs() { + t.Run(pair.binding.Name(), func(t *testing.T) { + if pair.binding.Size() != pair.compiler.Size() { + t.Errorf("size: Go=%d, C=%d", pair.binding.Size(), pair.compiler.Size()) + } + if pair.binding.Align() != pair.compiler.Align() { + t.Errorf("alignment: Go=%d, C=%d", pair.binding.Align(), pair.compiler.Align()) + } + compareNativeFields(t, pair) + }) + } +} + +func compareNativeFields(t *testing.T, pair nativeLayoutPair) { + t.Helper() + fields := nativeFields(pair.compiler) + for name, field := range nativeFields(pair.binding) { + native, found := fields[name] + if !found { + t.Errorf("field %s has no C counterpart", field.Name) + continue + } + if field.Offset != native.Offset || field.Type.Size() != native.Type.Size() { + t.Errorf("field %s: Go offset/size=%d/%d, C=%d/%d", field.Name, field.Offset, field.Type.Size(), native.Offset, native.Type.Size()) + } + delete(fields, name) + } + for name := range fields { + t.Errorf("C field %s has no Go counterpart", name) + } +} + +func nativeFields(layout reflect.Type) map[string]reflect.StructField { + fields := make(map[string]reflect.StructField) + for index := range layout.NumField() { + field := layout.Field(index) + if field.Name != "_" { + fields[normalizeABIField(field.Name)] = field + } + } + return fields +} + +func normalizeABIField(name string) string { + return strings.ToLower(strings.ReplaceAll(name, "_", "")) +} diff --git a/bindings/go/internal/native/embedded.go b/bindings/go/internal/native/embedded.go new file mode 100644 index 00000000..92274425 --- /dev/null +++ b/bindings/go/internal/native/embedded.go @@ -0,0 +1,87 @@ +package native + +import ( + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +func embeddedNativeLibraries() ([]string, error) { + if len(embeddedLibraryNames) == 0 { + return nil, fmt.Errorf("bundled native engine does not support %s/%s", runtime.GOOS, runtime.GOARCH) + } + cacheDirectory, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("locate native engine cache: %w", err) + } + libraries := make([]string, 0, len(embeddedLibraryNames)) + var failures []error + for _, name := range embeddedLibraryNames { + contents, readError := embeddedLibraries.ReadFile(name) + if readError != nil { + failures = append(failures, fmt.Errorf("read bundled engine %s: %w", name, readError)) + continue + } + library, extractError := cacheNativeLibrary(cacheDirectory, filepath.Base(name), contents) + if extractError != nil { + failures = append(failures, extractError) + continue + } + libraries = append(libraries, library) + } + if len(libraries) == 0 { + return nil, fmt.Errorf("no bundled native engine available for this platform (source checkouts require a native build; see CONTRIBUTING.md): %w", errors.Join(failures...)) + } + return libraries, nil +} + +// Each engine build has its own cache directory, so versions cannot overwrite +// libraries already loaded by another process. All file access stays in this root. +func cacheNativeLibrary(cacheDirectory, name string, contents []byte) (libraryPath string, resultError error) { + digest := sha256.Sum256(contents) + directory := filepath.Join(cacheDirectory, "tui-test", "native", fmt.Sprintf("%x", digest)) + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", fmt.Errorf("create native engine cache: %w", err) + } + cache, err := os.OpenRoot(directory) + if err != nil { + return "", fmt.Errorf("open native engine cache: %w", err) + } + defer func() { resultError = errors.Join(resultError, cache.Close()) }() + if err := ensureCachedLibrary(cache, name, contents); err != nil { + return "", err + } + return filepath.Join(directory, name), nil +} + +func ensureCachedLibrary(cache *os.Root, name string, contents []byte) (resultError error) { + if cachedLibraryMatches(cache, name, contents) { + return nil + } + temporaryName := ".engine-" + rand.Text() + if err := cache.WriteFile(temporaryName, contents, 0o600); err != nil { + return fmt.Errorf("write bundled native engine: %w", err) + } + defer func() { + if err := cache.Remove(temporaryName); err != nil && !errors.Is(err, os.ErrNotExist) { + resultError = errors.Join(resultError, fmt.Errorf("remove temporary native engine: %w", err)) + } + }() + if err := cache.Rename(temporaryName, name); err != nil { + // Another process may have published this exact engine first. Windows + // refuses to replace a DLL which that process has already loaded. + if !cachedLibraryMatches(cache, name, contents) { + return fmt.Errorf("publish bundled native engine: %w", err) + } + } + return nil +} + +func cachedLibraryMatches(cache *os.Root, name string, contents []byte) bool { + cached, err := cache.ReadFile(name) + return err == nil && sha256.Sum256(cached) == sha256.Sum256(contents) +} diff --git a/bindings/go/internal/native/embedded/aarch64-apple-darwin/README.txt b/bindings/go/internal/native/embedded/aarch64-apple-darwin/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/aarch64-apple-darwin/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/aarch64-unknown-linux-gnu/README.txt b/bindings/go/internal/native/embedded/aarch64-unknown-linux-gnu/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/aarch64-unknown-linux-gnu/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/aarch64-unknown-linux-musl/README.txt b/bindings/go/internal/native/embedded/aarch64-unknown-linux-musl/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/aarch64-unknown-linux-musl/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/x86_64-apple-darwin/README.txt b/bindings/go/internal/native/embedded/x86_64-apple-darwin/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/x86_64-apple-darwin/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/x86_64-pc-windows-msvc/README.txt b/bindings/go/internal/native/embedded/x86_64-pc-windows-msvc/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/x86_64-pc-windows-msvc/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/x86_64-unknown-linux-gnu/README.txt b/bindings/go/internal/native/embedded/x86_64-unknown-linux-gnu/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/x86_64-unknown-linux-gnu/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded/x86_64-unknown-linux-musl/README.txt b/bindings/go/internal/native/embedded/x86_64-unknown-linux-musl/README.txt new file mode 100644 index 00000000..90f22f6a --- /dev/null +++ b/bindings/go/internal/native/embedded/x86_64-unknown-linux-musl/README.txt @@ -0,0 +1 @@ +Release assembly places the native engine library here. Source builds normally set TUI_TEST_GO_NATIVE_LIBRARY instead. diff --git a/bindings/go/internal/native/embedded_darwin_amd64.go b/bindings/go/internal/native/embedded_darwin_amd64.go new file mode 100644 index 00000000..586e3bfb --- /dev/null +++ b/bindings/go/internal/native/embedded_darwin_amd64.go @@ -0,0 +1,8 @@ +package native + +import "embed" + +//go:embed embedded/x86_64-apple-darwin/* +var embeddedLibraries embed.FS + +var embeddedLibraryNames = []string{"embedded/x86_64-apple-darwin/libtui_test_go.dylib"} diff --git a/bindings/go/internal/native/embedded_darwin_arm64.go b/bindings/go/internal/native/embedded_darwin_arm64.go new file mode 100644 index 00000000..c222e0c9 --- /dev/null +++ b/bindings/go/internal/native/embedded_darwin_arm64.go @@ -0,0 +1,8 @@ +package native + +import "embed" + +//go:embed embedded/aarch64-apple-darwin/* +var embeddedLibraries embed.FS + +var embeddedLibraryNames = []string{"embedded/aarch64-apple-darwin/libtui_test_go.dylib"} diff --git a/bindings/go/internal/native/embedded_linux_amd64.go b/bindings/go/internal/native/embedded_linux_amd64.go new file mode 100644 index 00000000..90288898 --- /dev/null +++ b/bindings/go/internal/native/embedded_linux_amd64.go @@ -0,0 +1,9 @@ +package native + +import "embed" + +//go:embed embedded/x86_64-unknown-linux-gnu/* +//go:embed embedded/x86_64-unknown-linux-musl/* +var embeddedLibraries embed.FS + +var embeddedLibraryNames = []string{"embedded/x86_64-unknown-linux-gnu/libtui_test_go.so", "embedded/x86_64-unknown-linux-musl/libtui_test_go.so"} diff --git a/bindings/go/internal/native/embedded_linux_arm64.go b/bindings/go/internal/native/embedded_linux_arm64.go new file mode 100644 index 00000000..87abbad4 --- /dev/null +++ b/bindings/go/internal/native/embedded_linux_arm64.go @@ -0,0 +1,9 @@ +package native + +import "embed" + +//go:embed embedded/aarch64-unknown-linux-gnu/* +//go:embed embedded/aarch64-unknown-linux-musl/* +var embeddedLibraries embed.FS + +var embeddedLibraryNames = []string{"embedded/aarch64-unknown-linux-gnu/libtui_test_go.so", "embedded/aarch64-unknown-linux-musl/libtui_test_go.so"} diff --git a/bindings/go/internal/native/embedded_test.go b/bindings/go/internal/native/embedded_test.go new file mode 100644 index 00000000..1db41db1 --- /dev/null +++ b/bindings/go/internal/native/embedded_test.go @@ -0,0 +1,81 @@ +package native + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestNativeCacheRepairsCorruptionAndSeparatesVersions(t *testing.T) { + cache := t.TempDir() + first := []byte("first engine version") + firstPath, err := cacheNativeLibrary(cache, "engine.dll", first) + if err != nil { + t.Fatal(err) + } + if writeError := os.WriteFile(firstPath, []byte("corrupt"), 0o600); writeError != nil { + t.Fatal(writeError) + } + repairedPath, err := cacheNativeLibrary(cache, "engine.dll", first) + if err != nil { + t.Fatal(err) + } + assertCachedContents(t, repairedPath, first) + if repairedPath != firstPath { + t.Fatal("cache did not restore the embedded engine at its original path") + } + secondPath, err := cacheNativeLibrary(cache, "engine.dll", []byte("second engine version")) + if err != nil { + t.Fatal(err) + } + if secondPath == firstPath { + t.Fatal("different engine versions share a path") + } +} + +func TestNativeCacheSupportsConcurrentExtraction(t *testing.T) { + cache := t.TempDir() + contents := []byte("shared engine") + var workers sync.WaitGroup + for range 12 { + workers.Go(func() { + library, err := cacheNativeLibrary(cache, "engine.dll", contents) + if err != nil { + t.Error(err) + return + } + assertCachedContents(t, library, contents) + }) + } + workers.Wait() + library, err := cacheNativeLibrary(cache, "engine.dll", contents) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Dir(library)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "engine.dll" { + t.Fatalf("unexpected files after concurrent extraction: %v", entries) + } +} + +func assertCachedContents(t *testing.T, library string, expected []byte) { + t.Helper() + cache, err := os.OpenRoot(filepath.Dir(library)) + if err != nil { + t.Error(err) + return + } + defer func() { + if closeError := cache.Close(); closeError != nil { + t.Error(closeError) + } + }() + contents, err := cache.ReadFile(filepath.Base(library)) + if err != nil || string(contents) != string(expected) { + t.Errorf("incomplete cached library: %q, %v", contents, err) + } +} diff --git a/bindings/go/internal/native/embedded_unsupported.go b/bindings/go/internal/native/embedded_unsupported.go new file mode 100644 index 00000000..d5ec6c26 --- /dev/null +++ b/bindings/go/internal/native/embedded_unsupported.go @@ -0,0 +1,8 @@ +//go:build !((windows && amd64) || ((darwin || linux) && (amd64 || arm64))) + +package native + +import "embed" + +var embeddedLibraries embed.FS +var embeddedLibraryNames []string diff --git a/bindings/go/internal/native/embedded_windows_amd64.go b/bindings/go/internal/native/embedded_windows_amd64.go new file mode 100644 index 00000000..e684a850 --- /dev/null +++ b/bindings/go/internal/native/embedded_windows_amd64.go @@ -0,0 +1,8 @@ +package native + +import "embed" + +//go:embed embedded/x86_64-pc-windows-msvc/* +var embeddedLibraries embed.FS + +var embeddedLibraryNames = []string{"embedded/x86_64-pc-windows-msvc/tui_test_go.dll"} diff --git a/bindings/go/internal/native/functions.go b/bindings/go/internal/native/functions.go index 09c2fd96..24a9110b 100644 --- a/bindings/go/internal/native/functions.go +++ b/bindings/go/internal/native/functions.go @@ -56,7 +56,7 @@ type nativeFunctionTable struct { 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 + Screenshot func(abiString, *abiScreenshotOptions) *abiResult StartRecording func(abiString, abiRecordingOptions) *abiResult } diff --git a/bindings/go/internal/native/loader.go b/bindings/go/internal/native/loader.go index 186b521a..e853deec 100644 --- a/bindings/go/internal/native/loader.go +++ b/bindings/go/internal/native/loader.go @@ -19,16 +19,31 @@ 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) + paths, source, err := nativeLibraryPaths() if err != nil { - return fmt.Errorf("load native engine from TUI_TEST_GO_NATIVE_LIBRARY: %w", err) + return fmt.Errorf("prepare native engine: %w", err) } - nativeFunctions = table - return nil + failures := make([]error, 0, len(paths)) + for _, path := range paths { + table, loadErr := loadNativeFunctions(path) + if loadErr == nil { + nativeFunctions = table + return nil + } + failures = append(failures, loadErr) + } + if len(failures) == 0 { + return errors.New("no embedded native engine is available for this platform") + } + return fmt.Errorf("load %s: %w", source, errors.Join(failures...)) +} + +func nativeLibraryPaths() ([]string, string, error) { + if path := os.Getenv("TUI_TEST_GO_NATIVE_LIBRARY"); path != "" { + return []string{path}, "native engine configured by TUI_TEST_GO_NATIVE_LIBRARY", nil + } + paths, err := embeddedNativeLibraries() + return paths, "embedded native engine", err } func loadNativeFunctions(path string) (nativeFunctionTable, error) { @@ -42,8 +57,8 @@ func loadNativeFunctions(path string) (nativeFunctionTable, error) { 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()) + if version := table.AbiVersion(); version != 4 { + return nativeFunctionTable{}, errors.Join(fmt.Errorf("native ABI version %d is incompatible with required version 4", version), library.close()) } return table, nil } diff --git a/bindings/go/internal/native/loader_test.go b/bindings/go/internal/native/loader_test.go index 412ef913..1511808d 100644 --- a/bindings/go/internal/native/loader_test.go +++ b/bindings/go/internal/native/loader_test.go @@ -1,24 +1,32 @@ package native import ( + "path/filepath" "strings" "testing" ) -func TestInitializeNativeEngineRequiresConfiguredLibrary(t *testing.T) { - t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", "") +func TestNativeLibraryPathsPreferConfiguredLibrary(t *testing.T) { + configuredPath := filepath.Join(t.TempDir(), "tui-test-native") + t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", configuredPath) - err := initializeNativeEngine() - if err == nil || !strings.Contains(err.Error(), "TUI_TEST_GO_NATIVE_LIBRARY must name") { - t.Fatalf("unexpected error: %v", err) + paths, source, err := nativeLibraryPaths() + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 || paths[0] != configuredPath { + t.Fatalf("unexpected native library paths: %v", paths) + } + if source != "native engine configured by TUI_TEST_GO_NATIVE_LIBRARY" { + t.Fatalf("unexpected native library source: %s", source) } } func TestInitializeNativeEngineReportsConfiguredLibraryFailure(t *testing.T) { - t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", t.TempDir()) + t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", filepath.Join(t.TempDir(), "missing-native-library")) err := initializeNativeEngine() - if err == nil || !strings.Contains(err.Error(), "load native engine from TUI_TEST_GO_NATIVE_LIBRARY") { + if err == nil || !strings.Contains(err.Error(), "load native engine configured by TUI_TEST_GO_NATIVE_LIBRARY") { t.Fatalf("unexpected error: %v", err) } } diff --git a/bindings/go/internal/native/native.h b/bindings/go/internal/native/native.h new file mode 100644 index 00000000..33d09e04 --- /dev/null +++ b/bindings/go/internal/native/native.h @@ -0,0 +1,696 @@ +#ifndef TUI_TEST_GO_NATIVE_H +#define TUI_TEST_GO_NATIVE_H + +#pragma once + +#include +#include +#include +#include +#include + +/** + * 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. + */ +typedef struct TuiString { + const uint8_t *data; + size_t len; +} TuiString; + +typedef struct TuiOptionalI32 { + bool present; + int32_t value; +} TuiOptionalI32; + +typedef struct TuiOptionalU64 { + bool present; + uint64_t value; +} TuiOptionalU64; + +typedef struct TuiOpenResult { + struct TuiOptionalU64 shell_pid; + struct TuiString session; + bool ready; + struct TuiString recording; +} TuiOpenResult; + +typedef struct TuiCursor { + uint16_t x; + uint16_t y; + bool visible; + struct TuiString shape; + struct TuiString color; +} TuiCursor; + +typedef struct TuiMode { + struct TuiString name; + bool enabled; +} TuiMode; + +typedef struct TuiPair { + struct TuiString key; + struct TuiString value; +} TuiPair; + +typedef struct TuiTerminalColors { + struct TuiString foreground; + struct TuiString background; + struct TuiString cursor; + const struct TuiPair *palette; + size_t palette_len; +} TuiTerminalColors; + +typedef struct TuiTimeouts { + struct TuiOptionalU64 text; + struct TuiOptionalU64 idle; + struct TuiOptionalU64 command; + struct TuiOptionalU64 exit; + struct TuiOptionalU64 ready; +} TuiTimeouts; + +typedef struct TuiState { + struct TuiString session_shell; + uint16_t cols; + uint16_t rows; + struct TuiCursor cursor; + struct TuiString title; + struct TuiString cwd; + struct TuiString last_command; + struct TuiOptionalI32 last_exit; + struct TuiString exit_signal; + struct TuiOptionalI32 exited; + bool ready; + uint64_t bell_count; + const struct TuiMode *modes; + size_t modes_len; + struct TuiString mouse_mode; + struct TuiTerminalColors colors; + struct TuiTimeouts timeouts; + struct TuiString text; +} TuiState; + +typedef struct TuiSize { + uint16_t cols; + uint16_t rows; +} TuiSize; + +/** + * kind: 0 default, 1 indexed (index), 2 RGB (red/green/blue). + */ +typedef struct TuiColor { + uint32_t kind; + uint8_t index; + uint8_t red; + uint8_t green; + uint8_t blue; +} TuiColor; + +typedef struct TuiCell { + uint16_t x; + uint16_t y; + struct TuiString character; + struct TuiColor fg; + struct TuiColor bg; + bool bold; + bool dim; + bool italic; + bool inverse; + bool invisible; + bool strike; + bool blink; + bool underline; + struct TuiString underline_style; + struct TuiColor underline_color; + struct TuiString link; + struct TuiString link_id; +} TuiCell; + +typedef struct TuiPosition { + uint32_t row; + uint16_t column; +} TuiPosition; + +typedef struct TuiSpan { + uint32_t row; + uint16_t start; + uint16_t end; +} TuiSpan; + +typedef struct TuiMatch { + struct TuiString text; + struct TuiPosition start; + struct TuiPosition end; + const struct TuiSpan *spans; + size_t spans_len; +} TuiMatch; + +typedef struct TuiBellEvent { + uint64_t sequence; + uint64_t elapsed_ms; +} TuiBellEvent; + +/** + * 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. + */ +typedef struct TuiResult { + uint32_t error_kind; + struct TuiString error_message; + struct TuiString error_details; + struct TuiString error_artifact; + struct TuiString text; + uint64_t number; + struct TuiOptionalI32 exit_code; + struct TuiOpenResult open; + struct TuiState state; + struct TuiCursor cursor; + struct TuiSize size; + const struct TuiCell *cells; + size_t cells_len; + const struct TuiMatch *matches; + size_t matches_len; + const struct TuiBellEvent *bells; + size_t bells_len; + const struct TuiString *strings; + size_t strings_len; + uint32_t snapshot; + void *private_data; +} TuiResult; + +typedef struct TuiOptionalBool { + bool present; + bool value; +} TuiOptionalBool; + +typedef struct TuiOpenOptions { + struct TuiString backend; + struct TuiString shell; + struct TuiOptionalU64 cols; + struct TuiOptionalU64 rows; + struct TuiString cwd; + const struct TuiPair *env; + size_t env_len; + struct TuiOptionalBool wait_ready; + bool restart; + struct TuiOptionalU64 scrollback; + const struct TuiPair *colors; + size_t colors_len; + struct TuiTimeouts timeouts; + struct TuiString recording_mode; + struct TuiString recording_directory; +} TuiOpenOptions; + +/** + * button: 0 left, 1 middle, 2 right. + */ +typedef struct TuiMouseOptions { + uint32_t button; + bool alt; + bool ctrl; + bool shift; +} TuiMouseOptions; + +typedef struct TuiWaitOptions { + struct TuiOptionalU64 timeout_ms; + bool regex; + bool not; +} TuiWaitOptions; + +typedef struct TuiQuery { + struct TuiString json; +} TuiQuery; + +typedef struct TuiOptionalF64 { + bool present; + double value; +} TuiOptionalF64; + +typedef struct TuiScreenshotOptions { + bool full; + struct TuiString path; + struct TuiOptionalF64 zoom; + struct TuiString background; + bool transparent; +} TuiScreenshotOptions; + +typedef struct TuiRecordingOptions { + struct TuiString path; + struct TuiString format; + struct TuiOptionalU64 fps; + struct TuiOptionalF64 speed; + struct TuiOptionalF64 idle_time_limit; + struct TuiOptionalF64 zoom; + struct TuiString background; + bool transparent; +} TuiRecordingOptions; + +uint32_t tui_abi_version(void); + +/** + * # Safety + * result must be NULL or a live result returned by this library, freed once. + */ +void tui_result_free(struct TuiResult *result); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_open(struct TuiString session, struct TuiOpenOptions options); + +/** + * 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. + */ +struct TuiResult *tui_open_ptr(struct TuiString session, const struct TuiOpenOptions *options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_run(struct TuiString session, + struct TuiOpenOptions options, + struct TuiString program, + const struct TuiString *args, + size_t args_len); + +/** + * 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. + */ +struct TuiResult *tui_run_ptr(struct TuiString session, + const struct TuiOpenOptions *options, + struct TuiString program, + const struct TuiString *args, + size_t args_len); + +struct TuiResult *tui_sessions(void); + +struct TuiResult *tui_close_all(void); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_recording(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_close(struct TuiString session, struct TuiOptionalBool failed); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths. + */ +struct TuiResult *tui_restart(struct TuiString session, uint64_t graceful_timeout_ms); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_state(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_command(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_output(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_exit_code(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_cwd(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_cursor(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_size(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_title(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_clipboard(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_bell_count(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_get_bell_events(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_stop_recording(struct TuiString session); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_text(struct TuiString session, bool full); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_packed_screen(struct TuiString session, bool full); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_cells(struct TuiString session, + uint16_t x, + uint16_t y, + uint16_t w, + uint16_t h); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_write(struct TuiString session, struct TuiString text); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths. + */ +struct TuiResult *tui_type(struct TuiString session, struct TuiString text); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_submit(struct TuiString session, struct TuiString text); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_signal(struct TuiString session, struct TuiString text); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_key(struct TuiString session, + const struct TuiString *keys, + size_t len, + uint32_t action); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_resize(struct TuiString session, uint16_t cols, uint16_t rows); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_click(struct TuiString session, + struct TuiOptionalU64 x, + struct TuiOptionalU64 y, + struct TuiString on_text, + struct TuiMouseOptions options, + uint8_t clicks); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_move(struct TuiString session, uint16_t x, uint16_t y); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_down(struct TuiString session, + uint16_t x, + uint16_t y, + struct TuiMouseOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_up(struct TuiString session, + uint16_t x, + uint16_t y, + struct TuiMouseOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_drag(struct TuiString session, + uint16_t x1, + uint16_t y1, + uint16_t x2, + uint16_t y2, + struct TuiMouseOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_mouse_scroll(struct TuiString session, + struct TuiString direction, + uint16_t amount); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_title(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_expect_title(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_clipboard(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_idle(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_command(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_exit(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_ready(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_bell(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_find_locator(struct TuiString session, + struct TuiQuery query, + bool require_one); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_wait_locator(struct TuiString session, + struct TuiQuery query, + struct TuiWaitOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_expect_locator(struct TuiString session, + struct TuiQuery query, + struct TuiWaitOptions options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_click_locator(struct TuiString session, + struct TuiQuery query, + struct TuiMouseOptions options, + uint8_t clicks, + struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_highlight_locator(struct TuiString session, + struct TuiQuery query, + struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_expect_exit_code(struct TuiString session, + int32_t code, + struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_expect_output(struct TuiString session, struct TuiString text, bool regex); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_expect_bell_count(struct TuiString session, + uint64_t count, + struct TuiOptionalU64 timeout); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_snapshot(struct TuiString session, + struct TuiString name, + bool update, + bool include_style, + bool include_title, + struct TuiString cwd); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_screenshot(struct TuiString session, + const struct TuiScreenshotOptions *options); + +/** + * # Safety + * Borrowed input buffers must be valid and readable for their stated lengths + * throughout this call; see TuiString and the input structure contracts. + */ +struct TuiResult *tui_start_recording(struct TuiString session, struct TuiRecordingOptions options); + +#endif /* TUI_TEST_GO_NATIVE_H */ diff --git a/bindings/go/internal/native/session.go b/bindings/go/internal/native/session.go index dd6dcf95..e79ce799 100644 --- a/bindings/go/internal/native/session.go +++ b/bindings/go/internal/native/session.go @@ -46,8 +46,8 @@ 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)} + if version := nativeFunctions.AbiVersion(); version != 4 { + return &Error{Kind: InternalError, Message: fmt.Sprintf("native ABI version %d is incompatible with required version 4", version)} } return nil } @@ -423,7 +423,9 @@ func (runtime *Session) ExpectBellCount(count uint64, timeout *time.Duration) er 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) + converted := abiScreenshotOptions{full: options.Full, path: memory.nonemptyText(path), zoom: nativeFloat(options.Zoom), background: memory.nonemptyText(options.Background), transparent: options.Transparent} + memory.pinner.Pin(&converted) + return nativeFunctions.Screenshot(session, &converted) }) } diff --git a/bindings/go/native/Cargo.toml b/bindings/go/native/Cargo.toml index a7c05476..cb14030a 100644 --- a/bindings/go/native/Cargo.toml +++ b/bindings/go/native/Cargo.toml @@ -15,3 +15,6 @@ crate-type = ["cdylib", "rlib"] serde.workspace = true serde_json.workspace = true tui-test = { workspace = true, features = ["ghostty", "rio", "xtermjs", "recording-font-jetbrains-mono-styles"] } + +[build-dependencies] +cbindgen = "0.29" diff --git a/bindings/go/native/build.rs b/bindings/go/native/build.rs new file mode 100644 index 00000000..c47df173 --- /dev/null +++ b/bindings/go/native/build.rs @@ -0,0 +1,31 @@ +fn main() { + let root = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../internal/native/native.h"); + println!("cargo:rerun-if-env-changed=TUI_TEST_GO_UPDATE_HEADER"); + let bindings = cbindgen::Builder::new() + .with_config(cbindgen::Config { + usize_is_size_t: true, + ..Default::default() + }) + .with_crate(&root) + .with_language(cbindgen::Language::C) + .with_include_guard("TUI_TEST_GO_NATIVE_H") + .with_pragma_once(true) + .with_documentation(true) + .generate() + .expect("generate Go native header"); + let mut generated = Vec::new(); + bindings.write(&mut generated); + let header = root.join("../internal/native/native.h"); + if std::env::var_os("TUI_TEST_GO_UPDATE_HEADER").is_some() { + std::fs::write(&header, &generated).expect("write Go native header"); + } else { + let existing = + std::fs::read(&header).expect("native.h missing; run with TUI_TEST_GO_UPDATE_HEADER=1"); + assert!( + existing == generated, + "native.h is stale; run with TUI_TEST_GO_UPDATE_HEADER=1" + ); + } +} diff --git a/bindings/go/native/src/lib.rs b/bindings/go/native/src/lib.rs index 152579d3..83ac569d 100644 --- a/bindings/go/native/src/lib.rs +++ b/bindings/go/native/src/lib.rs @@ -45,7 +45,7 @@ fn finish_trace_and_close( } #[no_mangle] pub extern "C" fn tui_abi_version() -> u32 { - 3 + 4 } #[no_mangle] /// # Safety @@ -877,19 +877,19 @@ pub unsafe extern "C" fn tui_snapshot( /// 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, + options: *const TuiScreenshotOptions, ) -> *mut TuiResult { + if options.is_null() { + return boundary(|| Err(TuiTestError::usage("screenshot options pointer is null"))); + } + let options = unsafe { *options }; unsafe { execute(session, "screenshot", || { Ok(Operation::Screenshot { - full, - path: path.optional()?, - zoom: zoom.option(), - background: input::capture_background(background, transparent)?, + full: options.full, + path: options.path.optional()?, + zoom: options.zoom.option(), + background: input::capture_background(options.background, options.transparent)?, }) }) } diff --git a/bindings/go/native/src/tests.rs b/bindings/go/native/src/tests.rs index 3f2a576e..421de943 100644 --- a/bindings/go/native/src/tests.rs +++ b/bindings/go/native/src/tests.rs @@ -35,6 +35,7 @@ fn pointer_options_reject_null_and_preserve_validation() { [ tui_open_ptr(session, std::ptr::null()), tui_run_ptr(session, std::ptr::null(), text(""), std::ptr::null(), 0), + tui_screenshot(session, std::ptr::null()), tui_open_ptr(session, &options), tui_run_ptr(session, &options, text(""), std::ptr::null(), 0), ] diff --git a/bindings/go/native/src/types.rs b/bindings/go/native/src/types.rs index c8885a72..fde6ab74 100644 --- a/bindings/go/native/src/types.rs +++ b/bindings/go/native/src/types.rs @@ -102,6 +102,15 @@ pub struct TuiRecordingOptions { } #[repr(C)] #[derive(Clone, Copy, Default)] +pub struct TuiScreenshotOptions { + pub full: bool, + pub path: TuiString, + pub zoom: TuiOptionalF64, + pub background: TuiString, + pub transparent: bool, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] pub struct TuiCursor { pub x: u16, pub y: u16,