From 286b73470a10b904c841805996f5e4b028ed8fc0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 06:21:03 -0400 Subject: [PATCH 01/15] feat: share the native engine across language bindings Promote the Go C adapter to tui-test-ffi and generate native declarations from its Rust API. Replace PyO3 with ctypes and load the shared library dynamically from N-API. Update package assembly, release builds, examples, and ownership coverage. Preserve the supported runtimes and typed binary screen buffers. Signed-off-by: Ayman Bagabas --- .github/scripts/release/package-node.mjs | 16 + .github/scripts/release/package-node.test.mjs | 63 + .../scripts/release/prepare-python-linux.sh | 30 + .github/workflows/ci.yml | 30 +- .github/workflows/release.yml | 85 +- .gitignore | 2 - Cargo.lock | 172 +- Cargo.toml | 4 +- README.md | 3 + bindings/go/CONTRIBUTING.md | 34 +- bindings/go/README.md | 12 +- bindings/go/internal/native/abi.go | 164 +- bindings/go/internal/native/functions.go | 48 +- bindings/go/internal/native/loader.go | 10 +- bindings/go/internal/native/loader_test.go | 8 +- bindings/go/internal/native/session.go | 4 +- bindings/js/.gitignore | 3 + bindings/js/Cargo.toml | 9 +- bindings/js/README.md | 19 +- bindings/js/native/abi.rs | 421 +++ bindings/js/native/convert.rs | 371 +++ bindings/js/native/index.d.ts | 16 +- bindings/js/native/index.js | 20 +- bindings/js/native/lib.rs | 2635 ++++++----------- bindings/js/native/types.rs | 447 +++ bindings/js/package.json | 7 +- bindings/js/scripts/build-native.mjs | 58 + bindings/js/scripts/patch-loader.mjs | 28 + bindings/js/test/fixtures/abi-version.c | 3 + bindings/js/test/integration.test.mjs | 10 +- bindings/js/test/native.test.mjs | 55 + bindings/python/.gitignore | 7 +- bindings/python/MANIFEST.in | 7 + bindings/python/README.md | 48 + bindings/python/examples/shared_library.py | 25 + bindings/python/native/Cargo.toml | 34 - bindings/python/native/src/lib.rs | 2325 --------------- bindings/python/pyproject.toml | 14 +- bindings/python/scripts/generate_stubs.py | 33 - bindings/python/setup.py | 79 + bindings/python/src/tui_test/_abi.py | 460 +++ bindings/python/src/tui_test/_native.py | 519 ++++ bindings/python/src/tui_test/_native.pyi | 5 +- bindings/python/src/tui_test/client.py | 4 - bindings/python/src/tui_test/py.typed | 0 bindings/python/stub-gen/.gitignore | 1 - bindings/python/stub-gen/Cargo.lock | 1003 ------- bindings/python/stub-gen/Cargo.toml | 13 - bindings/python/stub-gen/src/main.rs | 337 --- bindings/python/tests/test_ctypes_adapter.py | 236 ++ bindings/python/tests/test_integration.py | 14 +- .../native => crates/tui-test-ffi}/Cargo.toml | 4 +- crates/tui-test-ffi/README.md | 60 + crates/tui-test-ffi/include/tui_test.h | 701 +++++ .../tui-test-ffi}/src/input.rs | 0 .../native => crates/tui-test-ffi}/src/lib.rs | 10 +- .../tui-test-ffi}/src/output.rs | 0 .../tui-test-ffi}/src/tests.rs | 0 .../tui-test-ffi}/src/types.rs | 0 tools/bindgen/Cargo.toml | 16 + tools/bindgen/src/main.rs | 451 +++ 61 files changed, 5299 insertions(+), 5894 deletions(-) create mode 100644 .github/scripts/release/package-node.test.mjs create mode 100644 .github/scripts/release/prepare-python-linux.sh create mode 100644 bindings/js/native/abi.rs create mode 100644 bindings/js/native/convert.rs create mode 100644 bindings/js/native/types.rs create mode 100644 bindings/js/scripts/build-native.mjs create mode 100644 bindings/js/scripts/patch-loader.mjs create mode 100644 bindings/js/test/fixtures/abi-version.c create mode 100644 bindings/python/MANIFEST.in create mode 100644 bindings/python/examples/shared_library.py delete mode 100644 bindings/python/native/Cargo.toml delete mode 100644 bindings/python/native/src/lib.rs delete mode 100644 bindings/python/scripts/generate_stubs.py create mode 100644 bindings/python/setup.py create mode 100644 bindings/python/src/tui_test/_abi.py create mode 100644 bindings/python/src/tui_test/_native.py create mode 100644 bindings/python/src/tui_test/py.typed delete mode 100644 bindings/python/stub-gen/.gitignore delete mode 100644 bindings/python/stub-gen/Cargo.lock delete mode 100644 bindings/python/stub-gen/Cargo.toml delete mode 100644 bindings/python/stub-gen/src/main.rs create mode 100644 bindings/python/tests/test_ctypes_adapter.py rename {bindings/go/native => crates/tui-test-ffi}/Cargo.toml (89%) create mode 100644 crates/tui-test-ffi/README.md create mode 100644 crates/tui-test-ffi/include/tui_test.h rename {bindings/go/native => crates/tui-test-ffi}/src/input.rs (100%) rename {bindings/go/native => crates/tui-test-ffi}/src/lib.rs (98%) rename {bindings/go/native => crates/tui-test-ffi}/src/output.rs (100%) rename {bindings/go/native => crates/tui-test-ffi}/src/tests.rs (100%) rename {bindings/go/native => crates/tui-test-ffi}/src/types.rs (100%) create mode 100644 tools/bindgen/Cargo.toml create mode 100644 tools/bindgen/src/main.rs diff --git a/.github/scripts/release/package-node.mjs b/.github/scripts/release/package-node.mjs index 6762e385..c7ffe56c 100644 --- a/.github/scripts/release/package-node.mjs +++ b/.github/scripts/release/package-node.mjs @@ -29,6 +29,22 @@ for (const nativePackage of nativePackages) { throw new Error(`Missing native addon in ${nativePackage}`); } + const platform = path.basename(nativePackage); + const library = platform.startsWith("win32-") + ? `tui_test_ffi.${platform}.dll` + : `libtui_test_ffi.${platform}.${platform.startsWith("darwin-") ? "dylib" : "so"}`; + const source = path.join(bindingsDirectory, "artifacts", library); + if (!fs.existsSync(source)) { + throw new Error(`Missing shared engine in ${source}`); + } + fs.copyFileSync(source, path.join(nativePackage, library)); + const manifestPath = path.join(nativePackage, "package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.files !== undefined) { + manifest.files = [...new Set([...manifest.files, library])]; + } + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + runNpm([ "pack", nativePackage, diff --git a/.github/scripts/release/package-node.test.mjs b/.github/scripts/release/package-node.test.mjs new file mode 100644 index 00000000..c474fa01 --- /dev/null +++ b/.github/scripts/release/package-node.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const script = fileURLToPath(new URL("./package-node.mjs", import.meta.url)); +const targets = new Map([ + ["darwin-arm64", "libtui_test_ffi.darwin-arm64.dylib"], + ["linux-x64-gnu", "libtui_test_ffi.linux-x64-gnu.so"], + ["win32-x64-msvc", "tui_test_ffi.win32-x64-msvc.dll"], +]); + +function fixture(t) { + const target = path.resolve("target"); + fs.mkdirSync(target, { recursive: true }); + const directory = fs.mkdtempSync(path.join(target, "package-node-test-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const bindings = path.join(directory, "bindings/js"); + fs.mkdirSync(path.join(bindings, "artifacts"), { recursive: true }); + fs.writeFileSync(path.join(bindings, "package.json"), JSON.stringify({ + name: "@fixture/tui-test", + version: "1.0.0", + files: ["index.js"], + })); + fs.writeFileSync(path.join(bindings, "index.js"), "export {};\n"); + for (const [platform, library] of targets) { + const pkg = path.join(bindings, "npm", platform); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ + name: `@fixture/tui-test-${platform}`, + version: "1.0.0", + files: platform === "linux-x64-gnu" ? undefined : ["*.node"], + })); + fs.writeFileSync(path.join(pkg, `tui-test.${platform}.node`), "addon fixture"); + fs.writeFileSync(path.join(bindings, "artifacts", library), "engine fixture"); + } + return directory; +} + +test("platform packages contain both the addon and the shared engine", (t) => { + const directory = fixture(t); + const packagePath = path.join(directory, "bindings/js/package.json"); + const original = fs.readFileSync(packagePath, "utf8"); + execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }); + assert.equal(fs.readFileSync(packagePath, "utf8"), original); + for (const [platform, library] of targets) { + const archive = path.join(directory, "package-artifacts/npm", `fixture-tui-test-${platform}-1.0.0.tgz`); + const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" }).split("\n"); + assert.ok(entries.includes(`package/${library}`)); + assert.ok(entries.includes(`package/tui-test.${platform}.node`)); + } +}); + +test("packaging rejects an addon without its matching engine", (t) => { + const directory = fixture(t); + fs.unlinkSync(path.join(directory, "bindings/js/artifacts", targets.get("darwin-arm64"))); + assert.throws( + () => execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }), + /Missing shared engine/, + ); +}); diff --git a/.github/scripts/release/prepare-python-linux.sh b/.github/scripts/release/prepare-python-linux.sh new file mode 100644 index 00000000..230679d3 --- /dev/null +++ b/.github/scripts/release/prepare-python-linux.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | + sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path + +version=0.16.0 +case "$(uname -m)" in + x86_64|amd64) + arch=x86_64 + sha256=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 + ;; + aarch64|arm64) + arch=aarch64 + sha256=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 + ;; + *) + echo "unsupported Zig host architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +mkdir -p "$HOME/.local/bin" "$HOME/.local/lib" "$HOME/.cache/tui-test-release" +archive="$HOME/.cache/tui-test-release/zig-$arch-linux-$version.tar.xz" +curl --proto '=https' --tlsv1.2 -fsSLo "$archive" \ + "https://ziglang.org/download/$version/zig-$arch-linux-$version.tar.xz" +echo "$sha256 $archive" | sha256sum -c - +tar -xJf "$archive" -C "$HOME/.local/lib" +ln -sf "$HOME/.local/lib/zig-$arch-linux-$version/zig" "$HOME/.local/bin/zig" +"$HOME/.local/bin/zig" version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7da6b623..994b330f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,21 +53,43 @@ jobs: cache: npm cache-dependency-path: bindings/js/package-lock.json + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: bindings/go/go.mod + cache-dependency-path: bindings/go/go.sum + - run: cargo fmt --all -- --check + - name: Verify generated native declarations + run: cargo run --locked -p tui-test-bindgen -- --check + - name: Test native package assembly + if: runner.os == 'Linux' + run: node --test .github/scripts/release/package-node.test.mjs - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo clippy -p tui-test-rs --all-targets --no-default-features -- -D warnings - - run: cargo build + - run: cargo build -p tui-test-cli -p tui-test-ffi - run: cargo test --workspace -- --test-threads=1 + - name: Test Go binding + shell: bash + run: | + case "$RUNNER_OS" in + Linux) library=libtui_test_ffi.so ;; + macOS) library=libtui_test_ffi.dylib ;; + Windows) library=tui_test_ffi.dll ;; + *) echo "Unsupported runner: $RUNNER_OS" >&2; exit 1 ;; + esac + export TUI_TEST_NATIVE_LIBRARY="$GITHUB_WORKSPACE/target/debug/$library" + export CGO_ENABLED=0 + cd bindings/go + go test ./... + go run ./examples/smoke + - name: Install Python binding run: python -m pip install --disable-pip-version-check -e ./bindings/python - name: Test Python binding run: python -m unittest discover -s bindings/python/tests -v - - name: Verify generated Python stubs - run: python bindings/python/scripts/generate_stubs.py --check - - name: Install Node dependencies run: npm ci --prefix bindings/js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac7ec648..fa3bd8a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,7 +205,11 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: node-${{ matrix.target }} - path: bindings/js/native/*.node + path: | + bindings/js/native/*.node + bindings/js/native/libtui_test_ffi.*.so + bindings/js/native/libtui_test_ffi.*.dylib + bindings/js/native/tui_test_ffi.*.dll if-no-files-found: error package-node: @@ -278,22 +282,25 @@ jobs: include: - target: x86_64-unknown-linux-gnu os: ubuntu-latest - compatibility: "2_28" + wheel: cp311-manylinux_x86_64 - target: aarch64-unknown-linux-gnu os: ubuntu-latest - compatibility: "2_28" + wheel: cp311-manylinux_aarch64 - target: x86_64-unknown-linux-musl os: ubuntu-latest - compatibility: "musllinux_1_2" + wheel: cp311-musllinux_x86_64 - target: aarch64-unknown-linux-musl os: ubuntu-latest - compatibility: "musllinux_1_2" + wheel: cp311-musllinux_aarch64 - target: x86_64-apple-darwin os: macos-latest + wheel: cp311-macosx_x86_64 - target: aarch64-apple-darwin os: macos-latest + wheel: cp311-macosx_arm64 - target: x86_64-pc-windows-msvc os: windows-latest + wheel: cp311-win_amd64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -304,6 +311,14 @@ jobs: with: python-version: "3.11" + - name: Set up Rust for host wheel builds + if: runner.os != 'Linux' + shell: bash + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add ${{ matrix.target }} + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 if: runner.os != 'Linux' with: @@ -316,51 +331,27 @@ jobs: platforms: arm64 - name: Build Python wheel - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 - with: - command: build - working-directory: bindings/python - target: ${{ matrix.target }} - manylinux: ${{ matrix.compatibility || 'auto' }} - args: --release --locked --out dist --compatibility pypi - before-script-linux: | - set -euo pipefail - version=0.16.0 - case "$(uname -m)" in - x86_64|amd64) - arch=x86_64 - sha256=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 - ;; - aarch64|arm64) - arch=aarch64 - sha256=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 - ;; - *) - echo "unsupported Zig host architecture: $(uname -m)" >&2 - exit 1 - ;; - esac - if command -v yum >/dev/null 2>&1; then - yum install -y curl xz - elif command -v apt-get >/dev/null 2>&1; then - apt-get update - apt-get install -y --no-install-recommends curl xz-utils - fi - archive="zig-${arch}-linux-${version}.tar.xz" - curl -fsSLo "/tmp/${archive}" \ - "https://ziglang.org/download/${version}/${archive}" - echo "${sha256} /tmp/${archive}" | sha256sum -c - - tar -xJf "/tmp/${archive}" -C /opt - ln -s "/opt/zig-${arch}-linux-${version}/zig" /usr/local/bin/zig - zig version + uses: pypa/cibuildwheel@b3395e271a35f73dad77f21802abe9b986dfe299 # v4.3.1 + with: + package-dir: bindings/python + output-dir: bindings/python/dist + only: ${{ matrix.wheel }} + env: + CARGO_BUILD_TARGET: ${{ matrix.target }} + CIBW_ENVIRONMENT_PASS_LINUX: CARGO_BUILD_TARGET + CIBW_ENVIRONMENT_LINUX: PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" + CIBW_BEFORE_ALL_LINUX: bash {project}/.github/scripts/release/prepare-python-linux.sh + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 + CIBW_MUSLLINUX_X86_64_IMAGE: musllinux_1_2 + CIBW_MUSLLINUX_AARCH64_IMAGE: musllinux_1_2 + CIBW_TEST_COMMAND: python {project}/bindings/python/examples/shared_library.py - name: Build Python sdist if: matrix.target == 'x86_64-unknown-linux-gnu' - working-directory: bindings/python run: | - sudo chown -R "$(id -u):$(id -g)" dist - python -m pip install --disable-pip-version-check "maturin>=1.5,<2" - maturin sdist --manifest-path native/Cargo.toml --out dist + python -m pip install --disable-pip-version-check build + python -m build --sdist bindings/python --outdir bindings/python/dist - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 if: matrix.target == 'x86_64-unknown-linux-gnu' @@ -374,7 +365,7 @@ jobs: shopt -s nullglob wheels=(bindings/python/dist/*.whl) test "${#wheels[@]}" -eq 1 - [[ "${wheels[0]}" == *abi3* ]] + [[ "${wheels[0]}" == *py3-none* ]] python -m venv package-test-python-38 package-test-python-38/bin/python -m pip install \ --disable-pip-version-check bindings/python/dist/*.whl diff --git a/.gitignore b/.gitignore index 14b07708..b5864dd8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,6 @@ __pycache__/ bindings/js/target bindings/js/native/target bindings/python/target -bindings/python/native/target -bindings/python/stub-gen/target trace-viewer/node_modules/ trace-viewer/.preview/ trace-viewer/test-results/ diff --git a/Cargo.lock b/Cargo.lock index 9e60f5d2..9135b01b 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" @@ -1221,12 +1251,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - [[package]] name = "portable-pty" version = "0.9.0" @@ -1278,88 +1302,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "pyo3" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" -dependencies = [ - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-async-runtimes" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e7364a95bf00e8377bbf9b0f09d7ff9715a29d8fcf93b47d1a967363b973178" -dependencies = [ - "futures-channel", - "futures-util", - "once_cell", - "pin-project-lite", - "pyo3", - "tokio", -] - -[[package]] -name = "pyo3-build-config" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" -dependencies = [ - "python3-dll-a", - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "python3-dll-a" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d80ba7540edb18890d444c5aa8e1f1f99b1bdf26fb26ae383135325f4a36042b" -dependencies = [ - "cc", -] - [[package]] name = "quote" version = "1.0.46" @@ -1369,6 +1311,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 +1338,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", ] @@ -1822,12 +1770,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "teletypewriter" version = "0.5.5" @@ -1845,6 +1787,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" @@ -2023,6 +1978,17 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tui-test-bindgen" +version = "0.1.0" +dependencies = [ + "anyhow", + "cbindgen", + "quote", + "syn 2.0.118", + "tempfile", +] + [[package]] name = "tui-test-cli" version = "0.1.0" @@ -2043,7 +2009,7 @@ dependencies = [ ] [[package]] -name = "tui-test-go" +name = "tui-test-ffi" version = "0.1.0" dependencies = [ "serde", @@ -2055,24 +2021,12 @@ dependencies = [ name = "tui-test-node" version = "0.1.0" dependencies = [ + "libloading", "napi", "napi-build", "napi-derive", "serde", "serde_json", - "tui-test-rs", -] - -[[package]] -name = "tui-test-python" -version = "0.1.0" -dependencies = [ - "anyhow", - "pyo3", - "pyo3-async-runtimes", - "serde_json", - "tokio", - "tui-test-rs", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a5017251..e83c3427 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] members = [ - "bindings/go/native", "bindings/js", - "bindings/python/native", "crates/tui-test", "crates/tui-test-cli", + "crates/tui-test-ffi", + "tools/bindgen", ] default-members = [ "crates/tui-test", diff --git a/README.md b/README.md index 34a2b924..b72d46db 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ You can also download a binary from [GitHub Releases](https://github.com/microso Add the Rust `recording-raster` feature for APNG, GIF, and MP4 output. It uses installed fonts; `recording-font-jetbrains-mono*` bundles a font. +Go, Python, and JavaScript use one [shared native library](crates/tui-test-ffi/README.md) with generated ABI declarations. +Set `TUI_TEST_NATIVE_LIBRARY` to select a matching library explicitly. + ## 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. diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index f0de70e0..7657e221 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -8,11 +8,25 @@ The public `tuitest` package at the module root owns clients, options, results, 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. +The shared Rust adapter lives in `../../crates/tui-test-ffi/` and delegates to the existing session registry and operations. +Go, Python, and JavaScript use the same library. Source builds select its path through `TUI_TEST_NATIVE_LIBRARY`. +The library stays 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. +The native library and Go module must come from the same source revision. +The C ABI is an internal contract shared by all language bindings, not a stable public C SDK. +Native allocations must be released by their matching Rust functions, and native code must not retain Go pointers. + +`internal/native/abi.go` and `internal/native/functions.go` are generated from the Rust ABI declarations. +Do not edit these files directly. From the repository root, regenerate them with: + +```sh +cargo run --locked -p tui-test-bindgen +``` + +The same command updates the C header, Python declarations, and Node declarations. +Use `cargo run --locked -p tui-test-bindgen -- --check` to check for stale output. ## Build from source @@ -21,34 +35,34 @@ Install Go 1.26 or newer, Rust 1.90 or newer, and Zig 0.16.0. Linux needs a nati From the repository root: ```sh -cargo build --locked -p tui-test-go +cargo build --locked -p tui-test-ffi ``` -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 shared library enables all terminal backends and bundled recording font styles. 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: +Set `TUI_TEST_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 ./... +TUI_TEST_NATIVE_LIBRARY=../../target/debug/libtui_test_ffi.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 ./... +TUI_TEST_NATIVE_LIBRARY=../../target/debug/libtui_test_ffi.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:TUI_TEST_NATIVE_LIBRARY = (Resolve-Path ../../target/debug/tui_test_ffi.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`. +For a release build, use `cargo build --release --locked -p tui-test-ffi` 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: @@ -59,7 +73,7 @@ go get github.com/microsoft/tui-test/bindings/go ## Validate changes -After setting `TUI_TEST_GO_NATIVE_LIBRARY`, run these commands from `bindings/go`: +After setting `TUI_TEST_NATIVE_LIBRARY`, run these commands from `bindings/go`: ```sh gofmt -l . diff --git a/bindings/go/README.md b/bindings/go/README.md index 27dd82ba..f6980d9e 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -30,10 +30,10 @@ Use a host-native build. Cross-compilation also requires the destination Rust ta 2. From that checkout's repository root, build the native library: ```sh - cargo build --locked -p tui-test-go + cargo build --locked -p tui-test-ffi ``` - 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. + The debug library is `target/debug/libtui_test_ffi.so` on Linux, `target/debug/libtui_test_ffi.dylib` on macOS, or `target/debug/tui_test_ffi.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: @@ -44,21 +44,21 @@ Use a host-native build. Cross-compilation also requires the destination Rust ta 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: +4. Set `TUI_TEST_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 + export TUI_TEST_NATIVE_LIBRARY=/absolute/path/to/tui-test/target/debug/libtui_test_ffi.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) + $env:TUI_TEST_NATIVE_LIBRARY = (Resolve-Path C:\path\to\tui-test\target\debug\tui_test_ffi.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. +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_NATIVE_LIBRARY`, an unloadable library, or a library from an incompatible revision returns an initialization error before the terminal opens. ## Quick start diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go index 733da79b..c168158f 100644 --- a/bindings/go/internal/native/abi.go +++ b/bindings/go/internal/native/abi.go @@ -1,3 +1,4 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. package native import ( @@ -5,28 +6,91 @@ import ( "unsafe" ) -// These host-layout structures mirror the private C ABI in native.h. +const abiVersion = 3 + type abiString struct { _ structs.HostLayout data *uint8 len uintptr } +type abiOptionalU64 struct { + _ structs.HostLayout + present bool + value uint64 +} type abiOptionalI32 struct { _ structs.HostLayout present bool value int32 } -type abiOptionalU64 struct { +type abiOptionalBool struct { _ structs.HostLayout present bool - value uint64 + value bool } -type abiOpenResult struct { +type abiOptionalF64 struct { + _ structs.HostLayout + present bool + value float64 +} +type abiPair struct { + _ structs.HostLayout + key abiString + value abiString +} +type abiTimeouts struct { + _ structs.HostLayout + text abiOptionalU64 + idle abiOptionalU64 + command abiOptionalU64 + exit abiOptionalU64 + ready abiOptionalU64 +} +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 abiQuery struct { + _ structs.HostLayout + json abiString +} +type abiWaitOptions struct { _ structs.HostLayout - shellPID abiOptionalU64 - session abiString - ready bool - recording abiString + timeoutMS abiOptionalU64 + regex bool + not bool +} +type abiMouseOptions struct { + _ structs.HostLayout + button uint32 + alt bool + ctrl bool + shift bool +} +type abiRecordingOptions struct { + _ structs.HostLayout + path abiString + format abiString + fps abiOptionalU64 + speed abiOptionalF64 + idleTimeLimit abiOptionalF64 + zoom abiOptionalF64 + background abiString + transparent bool } type abiCursor struct { _ structs.HostLayout @@ -36,13 +100,17 @@ type abiCursor struct { shape abiString color abiString } -type abiTimeouts struct { - _ structs.HostLayout - text abiOptionalU64 - idle abiOptionalU64 - command abiOptionalU64 - exit abiOptionalU64 - ready abiOptionalU64 +type abiSize struct { + _ structs.HostLayout + cols uint16 + rows uint16 +} +type abiOpenResult struct { + _ structs.HostLayout + shellPID abiOptionalU64 + session abiString + ready bool + recording abiString } type abiState struct { _ structs.HostLayout @@ -78,11 +146,6 @@ type abiTerminalColors struct { palette *abiPair paletteLen uintptr } -type abiSize struct { - _ structs.HostLayout - cols uint16 - rows uint16 -} type abiColor struct { _ structs.HostLayout kind uint32 @@ -159,64 +222,3 @@ type abiResult struct { 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 index 09c2fd96..067bb3ba 100644 --- a/bindings/go/internal/native/functions.go +++ b/bindings/go/internal/native/functions.go @@ -1,7 +1,6 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. package native -import "slices" - type nativeFunctionTable struct { AbiVersion func() uint32 ResultFree func(*abiResult) @@ -61,9 +60,6 @@ type nativeFunctionTable struct { } 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}, @@ -74,26 +70,6 @@ func nativeLifecycleRegistrations(table *nativeFunctionTable) []nativeRegistrati {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}, @@ -105,13 +81,22 @@ func nativeInspectionRegistrations(table *nativeFunctionTable) []nativeRegistrat {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_stop_recording", target: &table.StopRecording}, {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_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}, {symbol: "tui_wait_title", target: &table.WaitTitle}, {symbol: "tui_expect_title", target: &table.ExpectTitle}, {symbol: "tui_wait_clipboard", target: &table.WaitClipboard}, @@ -128,11 +113,6 @@ func nativeWaitingRegistrations(table *nativeFunctionTable) []nativeRegistration {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/loader.go b/bindings/go/internal/native/loader.go index 186b521a..122e572d 100644 --- a/bindings/go/internal/native/loader.go +++ b/bindings/go/internal/native/loader.go @@ -19,13 +19,13 @@ var nativeFunctions nativeFunctionTable var loadNativeEngine = sync.OnceValue(initializeNativeEngine) func initializeNativeEngine() error { - path := os.Getenv("TUI_TEST_GO_NATIVE_LIBRARY") + path := os.Getenv("TUI_TEST_NATIVE_LIBRARY") if path == "" { - return errors.New("TUI_TEST_GO_NATIVE_LIBRARY must name the native engine library") + return errors.New("TUI_TEST_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) + return fmt.Errorf("load native engine from TUI_TEST_NATIVE_LIBRARY: %w", err) } nativeFunctions = table return nil @@ -42,8 +42,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 != abiVersion { + return nativeFunctionTable{}, errors.Join(fmt.Errorf("native ABI version %d is incompatible with required version %d", version, abiVersion), 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..075f0eec 100644 --- a/bindings/go/internal/native/loader_test.go +++ b/bindings/go/internal/native/loader_test.go @@ -6,19 +6,19 @@ import ( ) func TestInitializeNativeEngineRequiresConfiguredLibrary(t *testing.T) { - t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", "") + t.Setenv("TUI_TEST_NATIVE_LIBRARY", "") err := initializeNativeEngine() - if err == nil || !strings.Contains(err.Error(), "TUI_TEST_GO_NATIVE_LIBRARY must name") { + if err == nil || !strings.Contains(err.Error(), "TUI_TEST_NATIVE_LIBRARY must name") { t.Fatalf("unexpected error: %v", err) } } func TestInitializeNativeEngineReportsConfiguredLibraryFailure(t *testing.T) { - t.Setenv("TUI_TEST_GO_NATIVE_LIBRARY", t.TempDir()) + t.Setenv("TUI_TEST_NATIVE_LIBRARY", t.TempDir()) 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 from TUI_TEST_NATIVE_LIBRARY") { t.Fatalf("unexpected error: %v", err) } } diff --git a/bindings/go/internal/native/session.go b/bindings/go/internal/native/session.go index dd6dcf95..ed273fdd 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 != abiVersion { + return &Error{Kind: InternalError, Message: fmt.Sprintf("native ABI version %d is incompatible with required version %d", version, abiVersion)} } return nil } diff --git a/bindings/js/.gitignore b/bindings/js/.gitignore index 19f2db27..fd159d34 100644 --- a/bindings/js/.gitignore +++ b/bindings/js/.gitignore @@ -2,5 +2,8 @@ node_modules/ dist/ *.tsbuildinfo native/*.node +native/*.dylib +native/*.so +native/*.dll npm/ target/ diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml index 39dad007..8f31e312 100644 --- a/bindings/js/Cargo.toml +++ b/bindings/js/Cargo.toml @@ -11,7 +11,7 @@ publish = false [lib] name = "tui_test_node" path = "native/lib.rs" -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib"] test = false [dependencies] @@ -19,12 +19,7 @@ napi = { version = "3", default-features = false, features = ["napi4", "dyn-symb napi-derive = "3" serde = { workspace = true } serde_json = { workspace = true } -tui-test = { workspace = true, features = [ - "ghostty", - "rio", - "xtermjs", - "recording-font-jetbrains-mono-styles", -] } +libloading = "0.9" [build-dependencies] napi-build = "2" diff --git a/bindings/js/README.md b/bindings/js/README.md index fc1441ce..f3c7378a 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -10,6 +10,17 @@ npm install @microsoft/tui-test Node 20+ is supported. The package is ESM only. Bun and Deno support is best effort. Deno 2 needs local `node_modules` and `--allow-ffi`. +The Node addon loads the shared `tui-test-ffi` library through its generated C ABI. +Each platform package includes the addon and its matching library. +The library stays loaded for the process lifetime, including calls from workers. +Set `TUI_TEST_NATIVE_LIBRARY` before the first operation to select another compatible library. +The addon rejects an incompatible ABI version. + +For a local build, run `npm ci`, then `npm run build`. +Use `npm run build:native:debug` for a debug build. +These commands build both native components and copy them into `native/`. +They also regenerate the N-API declarations and apply the shared-library loader extension. + ## Quick start ```js @@ -18,10 +29,12 @@ import { TuiTest } from "@microsoft/tui-test"; const terminal = TuiTest.ephemeral(); try { - await terminal.run("my-app"); + await terminal.run(process.execPath, [ + "-e", + "console.log('Ready'); setInterval(() => {}, 1000)", + ]); await terminal.getByText("Ready").expect(); - await terminal.getByText("Continue").click(); - await terminal.getByText("Done").expect(); + console.log(await terminal.text()); } finally { await terminal.closeQuiet(); } diff --git a/bindings/js/native/abi.rs b/bindings/js/native/abi.rs new file mode 100644 index 00000000..581ea817 --- /dev/null +++ b/bindings/js/native/abi.rs @@ -0,0 +1,421 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +#![allow(dead_code)] +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, +} + +pub const ABI_VERSION: u32 = 3; + +pub struct Functions { + pub tui_abi_version: unsafe extern "C" fn() -> u32, + pub tui_result_free: unsafe extern "C" fn(*mut TuiResult), + pub tui_open: unsafe extern "C" fn(TuiString, TuiOpenOptions) -> *mut TuiResult, + pub tui_open_ptr: unsafe extern "C" fn(TuiString, *const TuiOpenOptions) -> *mut TuiResult, + pub tui_run: unsafe extern "C" fn( + TuiString, + TuiOpenOptions, + TuiString, + *const TuiString, + usize, + ) -> *mut TuiResult, + pub tui_run_ptr: unsafe extern "C" fn( + TuiString, + *const TuiOpenOptions, + TuiString, + *const TuiString, + usize, + ) -> *mut TuiResult, + pub tui_sessions: unsafe extern "C" fn() -> *mut TuiResult, + pub tui_close_all: unsafe extern "C" fn() -> *mut TuiResult, + pub tui_recording: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_close: unsafe extern "C" fn(TuiString, TuiOptionalBool) -> *mut TuiResult, + pub tui_restart: unsafe extern "C" fn(TuiString, u64) -> *mut TuiResult, + pub tui_state: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_command: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_output: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_exit_code: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_cwd: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_cursor: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_size: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_title: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_clipboard: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_bell_count: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_get_bell_events: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_stop_recording: unsafe extern "C" fn(TuiString) -> *mut TuiResult, + pub tui_text: unsafe extern "C" fn(TuiString, bool) -> *mut TuiResult, + pub tui_packed_screen: unsafe extern "C" fn(TuiString, bool) -> *mut TuiResult, + pub tui_cells: unsafe extern "C" fn(TuiString, u16, u16, u16, u16) -> *mut TuiResult, + pub tui_write: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, + pub tui_type: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, + pub tui_submit: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, + pub tui_signal: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, + pub tui_key: unsafe extern "C" fn(TuiString, *const TuiString, usize, u32) -> *mut TuiResult, + pub tui_resize: unsafe extern "C" fn(TuiString, u16, u16) -> *mut TuiResult, + pub tui_mouse_click: unsafe extern "C" fn( + TuiString, + TuiOptionalU64, + TuiOptionalU64, + TuiString, + TuiMouseOptions, + u8, + ) -> *mut TuiResult, + pub tui_mouse_move: unsafe extern "C" fn(TuiString, u16, u16) -> *mut TuiResult, + pub tui_mouse_down: + unsafe extern "C" fn(TuiString, u16, u16, TuiMouseOptions) -> *mut TuiResult, + pub tui_mouse_up: unsafe extern "C" fn(TuiString, u16, u16, TuiMouseOptions) -> *mut TuiResult, + pub tui_mouse_drag: + unsafe extern "C" fn(TuiString, u16, u16, u16, u16, TuiMouseOptions) -> *mut TuiResult, + pub tui_mouse_scroll: unsafe extern "C" fn(TuiString, TuiString, u16) -> *mut TuiResult, + pub tui_wait_title: + unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, + pub tui_expect_title: + unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, + pub tui_wait_clipboard: + unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, + pub tui_wait_idle: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, + pub tui_wait_command: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, + pub tui_wait_exit: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, + pub tui_wait_ready: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, + pub tui_wait_bell: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, + pub tui_find_locator: unsafe extern "C" fn(TuiString, TuiQuery, bool) -> *mut TuiResult, + pub tui_wait_locator: + unsafe extern "C" fn(TuiString, TuiQuery, TuiWaitOptions) -> *mut TuiResult, + pub tui_expect_locator: + unsafe extern "C" fn(TuiString, TuiQuery, TuiWaitOptions) -> *mut TuiResult, + pub tui_click_locator: unsafe extern "C" fn( + TuiString, + TuiQuery, + TuiMouseOptions, + u8, + TuiOptionalU64, + ) -> *mut TuiResult, + pub tui_highlight_locator: + unsafe extern "C" fn(TuiString, TuiQuery, TuiOptionalU64) -> *mut TuiResult, + pub tui_expect_exit_code: + unsafe extern "C" fn(TuiString, i32, TuiOptionalU64) -> *mut TuiResult, + pub tui_expect_output: unsafe extern "C" fn(TuiString, TuiString, bool) -> *mut TuiResult, + pub tui_expect_bell_count: + unsafe extern "C" fn(TuiString, u64, TuiOptionalU64) -> *mut TuiResult, + pub tui_snapshot: + unsafe extern "C" fn(TuiString, TuiString, bool, bool, bool, TuiString) -> *mut TuiResult, + pub tui_screenshot: unsafe extern "C" fn( + TuiString, + bool, + TuiString, + TuiOptionalF64, + TuiString, + bool, + ) -> *mut TuiResult, + pub tui_start_recording: unsafe extern "C" fn(TuiString, TuiRecordingOptions) -> *mut TuiResult, +} +impl Functions { + /// # Safety + /// The library must implement this ABI and outlive all calls through this table. + pub unsafe fn load(library: &libloading::Library) -> Result { + Ok(Self { + tui_abi_version: unsafe { *library.get(b"tui_abi_version\0")? }, + tui_result_free: unsafe { *library.get(b"tui_result_free\0")? }, + tui_open: unsafe { *library.get(b"tui_open\0")? }, + tui_open_ptr: unsafe { *library.get(b"tui_open_ptr\0")? }, + tui_run: unsafe { *library.get(b"tui_run\0")? }, + tui_run_ptr: unsafe { *library.get(b"tui_run_ptr\0")? }, + tui_sessions: unsafe { *library.get(b"tui_sessions\0")? }, + tui_close_all: unsafe { *library.get(b"tui_close_all\0")? }, + tui_recording: unsafe { *library.get(b"tui_recording\0")? }, + tui_close: unsafe { *library.get(b"tui_close\0")? }, + tui_restart: unsafe { *library.get(b"tui_restart\0")? }, + tui_state: unsafe { *library.get(b"tui_state\0")? }, + tui_get_command: unsafe { *library.get(b"tui_get_command\0")? }, + tui_get_output: unsafe { *library.get(b"tui_get_output\0")? }, + tui_get_exit_code: unsafe { *library.get(b"tui_get_exit_code\0")? }, + tui_get_cwd: unsafe { *library.get(b"tui_get_cwd\0")? }, + tui_get_cursor: unsafe { *library.get(b"tui_get_cursor\0")? }, + tui_get_size: unsafe { *library.get(b"tui_get_size\0")? }, + tui_get_title: unsafe { *library.get(b"tui_get_title\0")? }, + tui_get_clipboard: unsafe { *library.get(b"tui_get_clipboard\0")? }, + tui_get_bell_count: unsafe { *library.get(b"tui_get_bell_count\0")? }, + tui_get_bell_events: unsafe { *library.get(b"tui_get_bell_events\0")? }, + tui_stop_recording: unsafe { *library.get(b"tui_stop_recording\0")? }, + tui_text: unsafe { *library.get(b"tui_text\0")? }, + tui_packed_screen: unsafe { *library.get(b"tui_packed_screen\0")? }, + tui_cells: unsafe { *library.get(b"tui_cells\0")? }, + tui_write: unsafe { *library.get(b"tui_write\0")? }, + tui_type: unsafe { *library.get(b"tui_type\0")? }, + tui_submit: unsafe { *library.get(b"tui_submit\0")? }, + tui_signal: unsafe { *library.get(b"tui_signal\0")? }, + tui_key: unsafe { *library.get(b"tui_key\0")? }, + tui_resize: unsafe { *library.get(b"tui_resize\0")? }, + tui_mouse_click: unsafe { *library.get(b"tui_mouse_click\0")? }, + tui_mouse_move: unsafe { *library.get(b"tui_mouse_move\0")? }, + tui_mouse_down: unsafe { *library.get(b"tui_mouse_down\0")? }, + tui_mouse_up: unsafe { *library.get(b"tui_mouse_up\0")? }, + tui_mouse_drag: unsafe { *library.get(b"tui_mouse_drag\0")? }, + tui_mouse_scroll: unsafe { *library.get(b"tui_mouse_scroll\0")? }, + tui_wait_title: unsafe { *library.get(b"tui_wait_title\0")? }, + tui_expect_title: unsafe { *library.get(b"tui_expect_title\0")? }, + tui_wait_clipboard: unsafe { *library.get(b"tui_wait_clipboard\0")? }, + tui_wait_idle: unsafe { *library.get(b"tui_wait_idle\0")? }, + tui_wait_command: unsafe { *library.get(b"tui_wait_command\0")? }, + tui_wait_exit: unsafe { *library.get(b"tui_wait_exit\0")? }, + tui_wait_ready: unsafe { *library.get(b"tui_wait_ready\0")? }, + tui_wait_bell: unsafe { *library.get(b"tui_wait_bell\0")? }, + tui_find_locator: unsafe { *library.get(b"tui_find_locator\0")? }, + tui_wait_locator: unsafe { *library.get(b"tui_wait_locator\0")? }, + tui_expect_locator: unsafe { *library.get(b"tui_expect_locator\0")? }, + tui_click_locator: unsafe { *library.get(b"tui_click_locator\0")? }, + tui_highlight_locator: unsafe { *library.get(b"tui_highlight_locator\0")? }, + tui_expect_exit_code: unsafe { *library.get(b"tui_expect_exit_code\0")? }, + tui_expect_output: unsafe { *library.get(b"tui_expect_output\0")? }, + tui_expect_bell_count: unsafe { *library.get(b"tui_expect_bell_count\0")? }, + tui_snapshot: unsafe { *library.get(b"tui_snapshot\0")? }, + tui_screenshot: unsafe { *library.get(b"tui_screenshot\0")? }, + tui_start_recording: unsafe { *library.get(b"tui_start_recording\0")? }, + }) + } +} diff --git a/bindings/js/native/convert.rs b/bindings/js/native/convert.rs new file mode 100644 index 00000000..6ce702c0 --- /dev/null +++ b/bindings/js/native/convert.rs @@ -0,0 +1,371 @@ +use super::{abi::*, internal, types::*, usage, MAX_SAFE_INTEGER}; +use napi::bindgen_prelude::Either; +use napi::Result; + +pub fn borrowed(value: &str) -> TuiString { + TuiString { + data: value.as_ptr(), + len: value.len(), + } +} + +pub fn optional_borrowed(value: Option<&str>) -> TuiString { + value.map(borrowed).unwrap_or_default() +} + +pub fn array<'a, T>(data: *const T, len: usize) -> &'a [T] { + if len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, len) } + } +} + +pub fn bytes<'a>(value: TuiString) -> &'a [u8] { + array(value.data, value.len) +} +pub fn text(value: TuiString) -> String { + String::from_utf8_lossy(bytes(value)).into_owned() +} +pub fn optional_text(value: TuiString) -> Option { + (!value.data.is_null()).then(|| text(value)) +} + +pub fn optional_bool(value: Option) -> TuiOptionalBool { + TuiOptionalBool { + present: value.is_some(), + value: value.unwrap_or_default(), + } +} +pub fn optional_float(value: Option) -> TuiOptionalF64 { + TuiOptionalF64 { + present: value.is_some(), + value: value.unwrap_or_default(), + } +} +pub fn integer(value: f64, name: &str, max: u64) -> Result { + let max = max.min(MAX_SAFE_INTEGER); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > max as f64 { + return Err(usage(format!( + "{name} must be an integer between 0 and {max}" + ))); + } + Ok(value as u64) +} +pub fn optional_integer(value: Option, name: &str, max: u64) -> Result { + Ok(TuiOptionalU64 { + present: value.is_some(), + value: value + .map(|value| integer(value, name, max)) + .transpose()? + .unwrap_or_default(), + }) +} +pub fn u16_value(value: f64, name: &str) -> Result { + Ok(integer(value, name, u16::MAX.into())? as u16) +} +pub fn u8_value(value: f64, name: &str) -> Result { + Ok(integer(value, name, u8::MAX.into())? as u8) +} +pub fn i32_value(value: f64, name: &str) -> Result { + if !value.is_finite() + || value.fract() != 0.0 + || value < i32::MIN as f64 + || value > i32::MAX as f64 + { + return Err(usage(format!( + "{name} must be an integer between {} and {}", + i32::MIN, + i32::MAX + ))); + } + Ok(value as i32) +} +pub fn wait_options(timeout: Option, regex: bool, not: bool) -> Result { + Ok(TuiWaitOptions { + timeout_ms: optional_integer(timeout, "timeoutMs", u64::MAX)?, + regex, + not, + }) +} +pub fn mouse_options(value: f64) -> Result { + let code = u8_value(value, "button")?; + if code & !31 != 0 || code & 3 == 3 { + return Err(usage(format!("invalid mouse button code {code}"))); + } + Ok(TuiMouseOptions { + button: (code & 3).into(), + shift: code & 4 != 0, + alt: code & 8 != 0, + ctrl: code & 16 != 0, + }) +} +pub fn session_context(context: &serde_json::Value, limit: Option) -> Result { + let mut context = context.clone(); + if let Some(limit) = limit { + context["screen_history_limit"] = u16_value(limit, "screenHistoryLimit")?.into(); + } + Ok(context.to_string()) +} + +pub struct OpenInput { + pub options: TuiOpenOptions, + _value: OpenOptions, + _env: Vec, + _colors: Vec, + _directory: Option, +} + +impl OpenInput { + pub fn new(value: OpenOptions, directory: Option) -> Result { + let pairs = |pairs: &Option>| -> Vec { + pairs + .as_deref() + .unwrap_or_default() + .iter() + .map(|(key, value)| TuiPair { + key: borrowed(key), + value: borrowed(value), + }) + .collect() + }; + let env = pairs(&value.env); + let colors = pairs(&value.profile_colors); + let timeouts = value.timeouts.as_ref(); + let options = TuiOpenOptions { + backend: optional_borrowed(value.backend.as_ref().map(|value| match value { + Backend::Alacritty => "alacritty", + Backend::Ghostty => "ghostty", + Backend::Rio => "rio", + Backend::Xtermjs => "xtermjs", + })), + shell: optional_borrowed(value.shell.as_ref().map(|value| match value { + Shell::Bash => "bash", + Shell::Powershell => "powershell", + Shell::Pwsh => "pwsh", + Shell::Cmd => "cmd", + Shell::Fish => "fish", + Shell::Zsh => "zsh", + Shell::Xonsh => "xonsh", + Shell::Elvish => "elvish", + Shell::Nushell => "nushell", + })), + cols: optional_integer(value.cols, "cols", u16::MAX.into())?, + rows: optional_integer(value.rows, "rows", u16::MAX.into())?, + cwd: optional_borrowed(value.cwd.as_deref()), + env: env.as_ptr(), + env_len: env.len(), + wait_ready: optional_bool(value.wait_ready), + restart: value.restart.unwrap_or(false), + scrollback: optional_integer( + value.profile_scrollback, + "profile.scrollback", + usize::MAX as u64, + )?, + colors: colors.as_ptr(), + colors_len: colors.len(), + timeouts: TuiTimeouts { + text: optional_integer( + timeouts.and_then(|value| value.text), + "timeouts.text", + u64::MAX, + )?, + idle: optional_integer( + timeouts.and_then(|value| value.idle), + "timeouts.idle", + u64::MAX, + )?, + command: optional_integer( + timeouts.and_then(|value| value.command), + "timeouts.command", + u64::MAX, + )?, + exit: optional_integer( + timeouts.and_then(|value| value.exit), + "timeouts.exit", + u64::MAX, + )?, + ready: optional_integer( + timeouts.and_then(|value| value.ready), + "timeouts.ready", + u64::MAX, + )?, + }, + recording_directory: optional_borrowed(directory.as_deref()), + ..Default::default() + }; + Ok(Self { + options, + _value: value, + _env: env, + _colors: colors, + _directory: directory, + }) + } +} + +pub fn query(expression: LocatorExpression) -> Result { + let mut nodes = Vec::with_capacity(expression.nodes.len()); + for (index, node) in expression.nodes.into_iter().enumerate() { + let occurrence = if let Some(nth) = node.nth { + if let Some(value) = node.occurrence.as_deref().filter(|value| *value != "nth") { + return Err(usage(format!( + "nodes[{index}].nth cannot be used with occurrence '{value}'" + ))); + } + serde_json::json!({"nth": integer(nth, &format!("nodes[{index}].nth"), usize::MAX as u64)?}) + } else { + match node.occurrence.as_deref().unwrap_or("any") { + "any" | "unique" | "first" | "last" => { + node.occurrence.unwrap_or_else(|| "any".into()).into() + } + "nth" => return Err(usage(format!("nodes[{index}].nth requires an nth index"))), + value => return Err(usage(format!("invalid occurrence '{value}'"))), + } + }; + let reference = |value: Option| { + value + .map(|value| integer(value, "locator reference", 255)) + .transpose() + }; + nodes.push(serde_json::json!({ + "kind": node.kind, "direction": node.direction, "text": node.text, "regex": node.regex, + "full": node.full, "whitespace": node.whitespace, "occurrence": occurrence, "style": node.style, + "link": node.link, "within": reference(node.within)?, "left": reference(node.left)?, + "right": reference(node.right)?, "input": reference(node.input)?, + "has": reference(node.has)?, "has_not": reference(node.has_not)?, + })); + } + Ok( + serde_json::json!({"nodes": nodes, "root": integer(expression.root, "locator root", 255)?}) + .to_string(), + ) +} + +pub fn open_result(value: &TuiResult) -> Result { + Ok(OpenResult { + shell_pid: value + .open + .shell_pid + .present + .then_some(value.open.shell_pid.value as u32), + session: text(value.open.session), + ready: value.open.ready, + recording: text(value.open.recording), + }) +} +pub fn cursor(value: TuiCursor) -> Cursor { + Cursor { + x: value.x, + y: value.y, + visible: value.visible, + shape: text(value.shape), + color: text(value.color), + } +} +pub fn state(value: &TuiState) -> State { + State { + session_shell: optional_text(value.session_shell), + cols: value.cols, + rows: value.rows, + cursor: cursor(value.cursor), + title: optional_text(value.title), + cwd: optional_text(value.cwd), + last_command: optional_text(value.last_command), + last_exit: value.last_exit.present.then_some(value.last_exit.value), + exit_signal: optional_text(value.exit_signal), + exited: value.exited.present.then_some(value.exited.value), + ready: value.ready, + bell_count: value.bell_count as f64, + modes: array(value.modes, value.modes_len) + .iter() + .map(|value| (text(value.name), value.enabled)) + .collect(), + mouse_mode: text(value.mouse_mode), + colors: TerminalColors { + foreground: text(value.colors.foreground), + background: text(value.colors.background), + cursor: text(value.colors.cursor), + palette: array(value.colors.palette, value.colors.palette_len) + .iter() + .map(|value| (text(value.key), text(value.value))) + .collect(), + }, + timeouts: EffectiveTimeouts { + text: value.timeouts.text.value as f64, + idle: value.timeouts.idle.value as f64, + command: value.timeouts.command.value as f64, + exit: value.timeouts.exit.value as f64, + ready: value.timeouts.ready.value as f64, + }, + text: text(value.text), + } +} +pub fn color(value: TuiColor) -> Result { + match value.kind { + 0 => Ok(Either::B("default".into())), + 1 => Ok(Either::A(value.index.into())), + 2 => Ok(Either::B(format!( + "#{:02x}{:02x}{:02x}", + value.red, value.green, value.blue + ))), + _ => Err(internal("terminal returned unknown color kind")), + } +} +pub fn cell(value: &TuiCell) -> Result { + Ok(Cell { + x: value.x, + y: value.y, + r#char: text(value.character), + fg: color(value.fg)?, + bg: color(value.bg)?, + bold: value.bold, + dim: value.dim, + italic: value.italic, + inverse: value.inverse, + invisible: value.invisible, + strike: value.strike, + blink: value.blink, + underline: value.underline, + underline_style: match text(value.underline_style).as_str() { + "none" => UnderlineStyle::None, + "single" => UnderlineStyle::Single, + "double" => UnderlineStyle::Double, + "curly" => UnderlineStyle::Curly, + "dotted" => UnderlineStyle::Dotted, + "dashed" => UnderlineStyle::Dashed, + value => { + return Err(internal(format!( + "terminal returned unknown underline style '{value}'" + ))) + } + }, + underline_color: color(value.underline_color)?, + link: text(value.link), + link_id: text(value.link_id), + }) +} +pub fn matches(value: &TuiResult) -> Result> { + Ok(array(value.matches, value.matches_len) + .iter() + .map(|value| TextMatch { + text: text(value.text), + start: TextPosition { + row: value.start.row, + column: value.start.column, + }, + end: TextPosition { + row: value.end.row, + column: value.end.column, + }, + spans: array(value.spans, value.spans_len) + .iter() + .map(|value| TextSpan { + row: value.row, + start: value.start, + end: value.end, + }) + .collect(), + }) + .collect()) +} diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 286639e9..5fe8a63b 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -9,17 +9,20 @@ export declare class NativeSession { close(failed?: boolean | undefined | null): Promise state(): Promise text(full?: boolean | undefined | null): Promise + packedScreen(full?: boolean | undefined | null): Promise + cells(x: number, y: number, w?: number | undefined | null, h?: number | undefined | null): Promise> findLocator(expression: LocatorExpression, requireOne?: boolean | undefined | null): Promise> waitLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise + expectLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise clickLocator(expression: LocatorExpression, button?: number | undefined | null, clicks?: number | undefined | null, timeoutMs?: number | undefined | null): Promise highlightLocator(expression: LocatorExpression, timeoutMs?: number | undefined | null): Promise> - expectLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise - packedScreen(full?: boolean | undefined | null): Promise - cells(x: number, y: number, w?: number | undefined | null, h?: number | undefined | null): Promise> + panicProbe(): Promise getCommand(): Promise getOutput(): Promise getExitCode(): Promise getCwd(): Promise + getTitle(): Promise + getClipboard(): Promise getCursor(): Promise getSize(): Promise getBellCount(): Promise @@ -39,11 +42,9 @@ export declare class NativeSession { mouseScroll(direction: string, amount?: number | undefined | null): Promise resize(cols: number, rows: number): Promise signal(name: string): Promise - getTitle(): Promise - getClipboard(): Promise waitTitle(text: string, options?: TitleOptions | undefined | null): Promise - waitClipboard(text?: string | undefined | null, options?: ClipboardWaitOptions | undefined | null): Promise expectTitle(text: string, options?: TitleOptions | undefined | null): Promise + waitClipboard(text?: string | undefined | null, options?: ClipboardWaitOptions | undefined | null): Promise waitIdle(timeoutMs?: number | undefined | null): Promise waitCommand(timeoutMs?: number | undefined | null): Promise waitExit(timeoutMs?: number | undefined | null): Promise @@ -56,7 +57,6 @@ export declare class NativeSession { screenshot(options?: ScreenshotOptions | undefined | null): Promise startRecording(options: RecordingOptions): Promise stopRecording(): Promise - panicProbe(): Promise } export interface AutomaticRecordingOptions { @@ -117,6 +117,8 @@ export declare function closeAllSync(): void export type Color = number | string +export declare function configureLibrary(path: string): void + export interface Cursor { x: number y: number diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index c5ac3869..217fe6ea 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -4,7 +4,22 @@ /* auto-generated by NAPI-RS */ import { createRequire } from 'module' -const require = createRequire(import.meta.url) +const nativeRequire = createRequire(import.meta.url) +let sharedLibraryPath +const require = (name) => { + const binding = nativeRequire(name) + if (binding && typeof binding.configureLibrary === 'function') { + const addon = nativeRequire.resolve(name) + const { basename, dirname, join } = nativeRequire('node:path') + const tag = basename(addon).replace(/^tui-test\./, '').replace(/\.node$/, '') + const windows = tag.startsWith('win32-') + const extension = windows ? 'dll' : tag.startsWith('darwin-') ? 'dylib' : 'so' + sharedLibraryPath = join(dirname(addon), `${windows ? '' : 'lib'}tui_test_ffi.${tag}.${extension}`) + binding.configureLibrary(sharedLibraryPath) + } + return binding +} +require.resolve = nativeRequire.resolve const __dirname = new URL('.', import.meta.url).pathname const { readFileSync } = require('fs') @@ -590,11 +605,12 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { NativeSession, Backend, closeAll, closeAllSync, LocatorNodeKind, LocatorStageDirection, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding +const { NativeSession, Backend, closeAll, closeAllSync, configureLibrary, LocatorNodeKind, LocatorStageDirection, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding export { NativeSession } export { Backend } export { closeAll } export { closeAllSync } +export { configureLibrary } export { LocatorNodeKind } export { LocatorStageDirection } export { recording } diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index 608c4026..01b98c39 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -1,1100 +1,193 @@ #![deny(clippy::all)] -use std::any::Any; -use std::panic::{catch_unwind, AssertUnwindSafe}; +mod abi; +mod convert; +mod types; -use napi::bindgen_prelude::{ - spawn_blocking, Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, -}; -use napi::{Error, Result, Status}; -use std::collections::HashMap; +use abi::*; +use convert::*; +use types::*; +use napi::bindgen_prelude::spawn_blocking; +use napi::{Error, Result, Status}; use napi_derive::napi; -use tui_test::profile::{Profile as CoreProfile, Rgb}; -use tui_test::shell::Shell as CoreShell; -use tui_test::{ - global_registry, AutomaticRecording as CoreAutomaticRecording, Backend as CoreBackend, - BellEvent as CoreBellEvent, CaptureBackground, Cell as CoreCell, CellColor, ClipboardPattern, - Cursor as CoreCursor, DiagnosticRetentionOptions as CoreDiagnosticRetentionOptions, - EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, - ExecutionContext as CoreExecutionContext, FailureArtifactMode as CoreFailureArtifactMode, - FailureArtifactOptions as CoreFailureArtifactOptions, KeyAction, - LocatorDirection as CoreLocatorDirection, LocatorQuery as CoreLocatorQuery, - MatchOccurrence as CoreMatchOccurrence, MouseAction, MouseOptions as CoreMouseOptions, - OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, Operation, OperationResult, - RecordingFormat as CoreRecordingFormat, RunOptions as CoreRunOptions, - ScreenshotResult as CoreScreenshotResult, SessionHandle, Size as CoreSize, - SnapshotResult as CoreSnapshotResult, State as CoreState, TerminalColors as CoreTerminalColors, - TextMatch as CoreTextMatch, TextStyle as CoreTextStyle, Timeouts as CoreTimeouts, TuiTestError, - WhitespaceMode as CoreWhitespaceMode, -}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; const ERROR_PREFIX: &str = "__tui_test_native_error__:"; const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; -#[napi(string_enum = "lowercase")] -pub enum Backend { - Alacritty, - Ghostty, - Rio, - Xtermjs, -} - -impl From for CoreBackend { - fn from(value: Backend) -> Self { - match value { - Backend::Alacritty => Self::Alacritty, - Backend::Ghostty => Self::Ghostty, - Backend::Rio => Self::Rio, - Backend::Xtermjs => Self::Xtermjs, - } - } -} - -#[napi(string_enum = "lowercase")] -pub enum Shell { - Bash, - Powershell, - Pwsh, - Cmd, - Fish, - Zsh, - Xonsh, - Elvish, - Nushell, -} - -impl From for CoreShell { - fn from(value: Shell) -> Self { - match value { - Shell::Bash => Self::Bash, - Shell::Powershell => Self::Powershell, - Shell::Pwsh => Self::Pwsh, - Shell::Cmd => Self::Cmd, - Shell::Fish => Self::Fish, - Shell::Zsh => Self::Zsh, - Shell::Xonsh => Self::Xonsh, - Shell::Elvish => Self::Elvish, - Shell::Nushell => Self::Nushell, - } - } -} - -#[napi(string_enum = "lowercase")] -pub enum RecordingFormat { - Apng, - Gif, - Mp4, - Cast, +struct Library { + functions: Functions, + // Keep the library loaded until all native calls and result guards finish. + _library: libloading::Library, } -impl From for CoreRecordingFormat { - fn from(value: RecordingFormat) -> Self { - match value { - RecordingFormat::Apng => Self::Apng, - RecordingFormat::Gif => Self::Gif, - RecordingFormat::Mp4 => Self::Mp4, - RecordingFormat::Cast => Self::Cast, - } - } -} - -#[napi(object)] -pub struct Timeouts { - pub text: Option, - pub idle: Option, - pub command: Option, - pub exit: Option, - pub ready: Option, -} - -#[napi(object)] -pub struct AutomaticRecordingOptions { - pub directory: Option, -} - -#[napi(object)] -pub struct TraceOptions { - pub mode: Option, - pub directory: Option, -} - -#[napi(object)] -pub struct FailureArtifactOptions { - pub directory: String, - pub mode: Option, - pub include_recording: Option, -} - -#[napi(object)] -pub struct OpenOptions { - pub backend: Option, - pub shell: Option, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} - -#[napi(object)] -pub struct RunOptions { - pub backend: Option, - pub program: String, - pub args: Option>, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} - -#[napi(object, use_nullable = true)] -pub struct OpenResult { - #[napi(js_name = "shell_pid")] - pub shell_pid: Option, - pub session: String, - pub ready: bool, - pub recording: String, -} - -impl From for OpenResult { - fn from(value: CoreOpenResult) -> Self { - Self { - shell_pid: value.shell_pid, - session: value.session, - ready: value.ready, - recording: value.recording, - } - } -} - -#[napi(object)] -pub struct Cursor { - pub x: u16, - pub y: u16, - pub visible: bool, - pub shape: String, - pub color: String, -} - -impl From for Cursor { - fn from(value: CoreCursor) -> Self { - Self { - x: value.x, - y: value.y, - visible: value.visible, - shape: value.shape, - color: value.color, - } - } -} - -#[napi(object)] -pub struct Size { - pub cols: u16, - pub rows: u16, -} - -impl From for Size { - fn from(value: CoreSize) -> Self { - Self { - cols: value.cols, - rows: value.rows, - } - } -} - -#[napi(object)] -pub struct EffectiveTimeouts { - pub text: f64, - pub idle: f64, - pub command: f64, - pub exit: f64, - pub ready: f64, -} - -impl From for EffectiveTimeouts { - fn from(value: CoreEffectiveTimeouts) -> Self { - Self { - text: value.text as f64, - idle: value.idle as f64, - command: value.command as f64, - exit: value.exit as f64, - ready: value.ready as f64, - } - } -} - -#[napi(object)] -pub struct BellEvent { - pub sequence: f64, - #[napi(js_name = "elapsed_ms")] - pub elapsed_ms: f64, -} - -impl From for BellEvent { - fn from(value: CoreBellEvent) -> Self { - Self { - sequence: value.sequence as f64, - elapsed_ms: value.elapsed_ms as f64, - } - } -} - -#[napi(object, use_nullable = true)] -pub struct State { - #[napi(js_name = "session_shell")] - pub session_shell: Option, - pub cols: u16, - pub rows: u16, - pub cursor: Cursor, - pub title: Option, - pub cwd: Option, - #[napi(js_name = "last_command")] - pub last_command: Option, - #[napi(js_name = "last_exit")] - pub last_exit: Option, - #[napi(js_name = "exit_signal")] - pub exit_signal: Option, - pub exited: Option, - pub ready: bool, - #[napi(js_name = "bell_count")] - pub bell_count: f64, - pub modes: HashMap, - #[napi(js_name = "mouse_mode")] - pub mouse_mode: String, - pub colors: TerminalColors, - pub timeouts: EffectiveTimeouts, - pub text: String, -} - -/// The colors the terminal is painting with. -#[napi(object)] -pub struct TerminalColors { - /// The default foreground (`OSC 10`). - pub foreground: String, - /// The default background (`OSC 11`). - pub background: String, - /// The cursor color (`OSC 12`). - pub cursor: String, - /// Palette entries a program overrode (`OSC 4`), keyed by index. - /// - /// Only entries that differ from the profile are listed. - pub palette: HashMap, -} - -impl From for TerminalColors { - fn from(value: CoreTerminalColors) -> Self { - Self { - foreground: value.foreground, - background: value.background, - cursor: value.cursor, - palette: value - .palette - .into_iter() - .map(|(index, color)| (index.to_string(), color)) - .collect(), - } - } -} - -impl From for State { - fn from(value: CoreState) -> Self { - Self { - session_shell: value.session_shell, - cols: value.cols, - rows: value.rows, - cursor: value.cursor.into(), - title: value.title, - cwd: value.cwd, - last_command: value.last_command, - last_exit: value.last_exit, - exit_signal: value.exit_signal, - exited: value.exited, - ready: value.ready, - bell_count: value.bell_count as f64, - modes: value.modes.into_iter().collect(), - mouse_mode: value.mouse_mode, - colors: value.colors.into(), - timeouts: value.timeouts.into(), - text: value.text, - } - } -} - -#[napi(string_enum = "lowercase")] -pub enum UnderlineStyle { - None, - Single, - Double, - Curly, - Dotted, - Dashed, -} - -fn underline_style(value: String) -> std::result::Result { - match value.as_str() { - "none" => Ok(UnderlineStyle::None), - "single" => Ok(UnderlineStyle::Single), - "double" => Ok(UnderlineStyle::Double), - "curly" => Ok(UnderlineStyle::Curly), - "dotted" => Ok(UnderlineStyle::Dotted), - "dashed" => Ok(UnderlineStyle::Dashed), - _ => Err(TuiTestError::internal(format!( - "terminal returned unknown underline style '{value}'" - ))), - } -} +static LIBRARY: OnceLock> = OnceLock::new(); +static LIBRARY_PATH: Mutex> = Mutex::new(None); #[napi] -pub type Color = Either; - -fn color(value: CellColor) -> Color { - match value { - CellColor::Default => Either::B("default".to_string()), - CellColor::Indexed(index) => Either::A(u32::from(index)), - CellColor::Rgb(red, green, blue) => Either::B(format!("#{red:02x}{green:02x}{blue:02x}")), - } -} - -#[napi(object)] -pub struct Cell { - pub x: u16, - pub y: u16, - pub r#char: String, - pub fg: Color, - pub bg: Color, - pub bold: bool, - pub dim: bool, - pub italic: bool, - pub inverse: bool, - pub invisible: bool, - pub strike: bool, - pub blink: bool, - pub underline: bool, - #[napi(js_name = "underline_style")] - pub underline_style: UnderlineStyle, - #[napi(js_name = "underline_color")] - pub underline_color: Color, - /// The OSC 8 URI this cell links to, empty when it links nowhere. - pub link: String, - /// The link's `id=` parameter, empty when the sequence carried none. - /// - /// Identifies a link across a wrap rather than describing where it points: - /// a program that wraps its own links tags each run with a shared `id=`. - /// - /// Backend-dependent. Ghostty reports a link's URI and nothing else, so - /// this is always empty there and no locator matches on it. - #[napi(js_name = "link_id")] - pub link_id: String, -} - -impl TryFrom for Cell { - type Error = TuiTestError; - - fn try_from(value: CoreCell) -> std::result::Result { - Ok(Self { - x: value.x, - y: value.y, - r#char: value.char, - fg: color(value.fg), - bg: color(value.bg), - bold: value.bold, - dim: value.dim, - italic: value.italic, - inverse: value.inverse, - invisible: value.invisible, - strike: value.strike, - blink: value.blink, - underline: value.underline, - underline_style: underline_style(value.underline_style)?, - underline_color: color(value.underline_color), - link: value.link, - link_id: value.link_id, - }) - } -} - -#[napi(object)] -pub struct TextPosition { - pub row: u32, - pub column: u16, -} - -#[napi(object)] -pub struct TextSpan { - pub row: u32, - pub start: u16, - pub end: u16, -} - -#[napi(object)] -pub struct TextMatch { - pub text: String, - pub start: TextPosition, - pub end: TextPosition, - pub spans: Vec, -} - -impl From for TextMatch { - fn from(value: CoreTextMatch) -> Self { - Self { - text: value.text, - start: TextPosition { - row: value.start.row, - column: value.start.column, - }, - end: TextPosition { - row: value.end.row, - column: value.end.column, - }, - spans: value - .spans - .into_iter() - .map(|span| TextSpan { - row: span.row, - start: span.start, - end: span.end, - }) - .collect(), - } - } -} - -#[napi(object)] -/// Private native-owned packed screen snapshot. -/// -/// `utf8` decodes to exactly `rows` newline-delimited logical rows. Trailing -/// spaces and blank rows are retained. UTF-8 byte offsets are not terminal cell -/// offsets when rows contain Unicode graphemes. -pub struct PackedScreen { - #[napi(readonly)] - /// Logical column count. - pub cols: u16, - #[napi(readonly)] - /// Number of logical rows encoded in `utf8`. - pub rows: u16, - #[napi(readonly, ts_type = "Uint8Array")] - /// Detached native-owned UTF-8 bytes. Treat this private snapshot as immutable. - pub utf8: Buffer, -} - -#[napi(object)] -pub struct MouseClickOptions { - pub x: Option, - pub y: Option, - pub on_text: Option, - pub button: Option, - pub clicks: Option, -} - -#[napi(string_enum = "lowercase")] -pub enum LocatorNodeKind { - Text, - Style, - Link, - And, - Or, - Filter, -} - -#[napi(string_enum = "lowercase")] -pub enum LocatorStageDirection { - Within, - After, - Before, -} - -#[derive(Default)] -#[napi(object, object_from_js = false)] -pub struct LocatorStyle { - pub foreground: Option, - pub background: Option, - pub bold: Option, - pub dim: Option, - pub italic: Option, - pub underline_style: Option, - pub underline_color: Option, - pub inverse: Option, - pub hidden: Option, - pub strikethrough: Option, - pub blink: Option, -} - -#[napi(object, object_from_js = false)] -pub struct LocatorNode { - pub kind: LocatorNodeKind, - pub direction: Option, - pub text: Option, - pub regex: Option, - pub full: Option, - pub whitespace: Option, - pub occurrence: Option, - pub nth: Option, - pub style: Option, - pub link: Option, - pub within: Option, - pub left: Option, - pub right: Option, - pub input: Option, - pub has: Option, - pub has_not: Option, -} - -#[napi(object, object_from_js = false)] -pub struct LocatorExpression { - pub nodes: Vec, - pub root: f64, -} - -// Reject unknown fields before conversion so misspelled predicates cannot -// silently broaden a locator. -macro_rules! strict_locator_input { - ($name:ident { $($field:ident => $key:literal),* $(,)? }) => { - impl ValidateNapiValue for $name {} - impl FromNapiValue for $name { - unsafe fn from_napi_value(env: napi::sys::napi_env, value: napi::sys::napi_value) -> Result { - unsafe { Object::validate(env, value)?; } - let object = unsafe { Object::from_napi_value(env, value)? }; - for key in Object::keys(&object)? { - if ![$($key),*].contains(&key.as_str()) { - return Err(Error::new(Status::InvalidArg, format!("unknown locator field {key:?}"))); - } - } - Ok(Self { $($field: object.get_named_property($key)?),* }) - } - } - }; -} - -strict_locator_input!(LocatorStyle { - foreground => "foreground", background => "background", bold => "bold", - dim => "dim", italic => "italic", underline_style => "underlineStyle", - underline_color => "underlineColor", inverse => "inverse", hidden => "hidden", - strikethrough => "strikethrough", blink => "blink", -}); -strict_locator_input!(LocatorNode { - kind => "kind", direction => "direction", text => "text", regex => "regex", - full => "full", whitespace => "whitespace", occurrence => "occurrence", - nth => "nth", style => "style", link => "link", within => "within", - left => "left", right => "right", input => "input", has => "has", has_not => "hasNot", -}); -strict_locator_input!(LocatorExpression { nodes => "nodes", root => "root" }); - -#[napi(object)] -pub struct TitleOptions { - pub regex: Option, - pub not: Option, - pub timeout_ms: Option, -} - -#[napi(object)] -pub struct ClipboardWaitOptions { - pub regex: Option, - pub timeout_ms: Option, -} - -#[napi(object)] -pub struct SnapshotOptions { - pub update: Option, - pub include_style: Option, - pub include_title: Option, - pub cwd: Option, -} - -#[napi(object)] -pub struct ScreenshotOptions { - pub full: Option, - pub path: Option, - pub zoom: Option, - pub background: Option, - pub transparent: Option, -} - -#[napi(object)] -pub struct RecordingOptions { - pub path: String, - pub format: Option, - pub fps: Option, - pub speed: Option, - pub idle_time_limit: Option, - pub zoom: Option, - pub background: Option, - pub transparent: Option, -} - -#[napi(string_enum = "lowercase")] -pub enum SnapshotResult { - Passed, - Written, - Updated, -} - -impl From for SnapshotResult { - fn from(value: CoreSnapshotResult) -> Self { - match value { - CoreSnapshotResult::Passed => Self::Passed, - CoreSnapshotResult::Written => Self::Written, - CoreSnapshotResult::Updated => Self::Updated, - } - } -} - -fn native_error(error: TuiTestError) -> Error { - #[derive(serde::Serialize)] - struct ErrorEnvelope<'a> { - kind: &'a str, - message: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - details: Option<&'a tui_test::FailureDetails>, - #[serde(skip_serializing_if = "Option::is_none")] - artifact: Option<&'a tui_test::FailureArtifactRef>, - } - - let envelope = ErrorEnvelope { - kind: error.kind.as_str(), - message: &error.message, - details: error.details.as_deref(), - artifact: error.artifact.as_deref(), - }; - let body = serde_json::to_string(&envelope).unwrap_or_else(|serialization_error| { - serde_json::json!({ - "kind": "internal", - "message": format!("failed to serialize native error: {serialization_error}"), - }) - .to_string() - }); - Error::new(Status::GenericFailure, format!("{ERROR_PREFIX}{body}")) -} - -fn capture_background( - background: Option, - transparent: bool, -) -> Result> { - if background.is_some() && transparent { - return Err(native_error(TuiTestError::usage( - "background and transparent options conflict", +pub fn configure_library(path: String) -> Result<()> { + *LIBRARY_PATH + .lock() + .map_err(|_| internal("library path lock poisoned"))? = Some(path.into()); + Ok(()) +} + +fn library() -> Result> { + if let Some(library) = LIBRARY.get() { + return Ok(library.clone()); + } + let configured = LIBRARY_PATH + .lock() + .map_err(|_| internal("library path lock poisoned"))?; + if let Some(library) = LIBRARY.get() { + return Ok(library.clone()); + } + let path = std::env::var_os("TUI_TEST_NATIVE_LIBRARY") + .map(PathBuf::from) + .or_else(|| configured.clone()) + .ok_or_else(|| internal("shared native library path is not configured"))?; + let loaded = unsafe { libloading::Library::new(&path) }.map_err(|error| { + internal(format!( + "failed to load shared native library {}: {error}", + path.display() + )) + })?; + let version = unsafe { loaded.get:: u32>(b"tui_abi_version\0") } + .map_err(|error| internal(format!("failed to read native ABI version: {error}")))?; + let actual = unsafe { version() }; + if actual != ABI_VERSION { + return Err(internal(format!( + "native ABI version mismatch: expected {ABI_VERSION}, got {actual}" ))); } - if transparent { - return Ok(Some(CaptureBackground::Transparent)); - } - background - .map(|value| CaptureBackground::parse(&value).map_err(native_error)) - .transpose() -} - -fn panic_message(payload: &(dyn Any + Send)) -> String { - if let Some(message) = payload.downcast_ref::<&'static str>() { - (*message).to_string() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "non-string panic payload".to_string() - } -} - -fn ffi_boundary(work: impl FnOnce() -> std::result::Result) -> Result { - match catch_unwind(AssertUnwindSafe(work)) { - Ok(Ok(value)) => Ok(value), - Ok(Err(error)) => Err(native_error(error)), - Err(payload) => Err(native_error(TuiTestError::internal(format!( - "native binding panicked: {}", - panic_message(payload.as_ref()) - )))), + let functions = unsafe { Functions::load(&loaded) } + .map_err(|error| internal(format!("failed to load native ABI symbols: {error}")))?; + let loaded = Arc::new(Library { + functions, + _library: loaded, + }); + let _ = LIBRARY.set(loaded.clone()); + Ok(loaded) +} + +fn error(kind: &str, message: impl Into) -> Error { + Error::new( + Status::GenericFailure, + format!( + "{ERROR_PREFIX}{}", + serde_json::json!({ + "kind": kind, "message": message.into(), + }) + ), + ) +} + +fn internal(message: impl Into) -> Error { + error("internal", message) +} +fn usage(message: impl Into) -> Error { + error("usage", message) +} + +fn boundary(work: impl FnOnce() -> Result) -> Result { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) { + Ok(result) => result, + Err(payload) => { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload"); + Err(internal(format!("native binding panicked: {message}"))) + } } } -async fn blocking( - context: &'static str, - work: impl FnOnce() -> std::result::Result + Send + 'static, -) -> Result -where - T: Send + 'static, -{ - spawn_blocking(move || ffi_boundary(work)) +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + spawn_blocking(move || boundary(work)) .await - .map_err(|error| { - native_error(TuiTestError::internal(format!( - "{context} worker failed: {error}" - ))) - })? + .map_err(|error| internal(format!("native worker failed: {error}")))? } -fn timeout(value: Option, name: &str) -> std::result::Result, TuiTestError> { - value - .map(|value| integer(value, name, u64::MAX)) - .transpose() +struct ResultGuard<'a> { + pointer: *mut TuiResult, + library: &'a Library, } -fn integer(value: f64, name: &str, max: u64) -> std::result::Result { - let max = max.min(MAX_SAFE_INTEGER); - if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > max as f64 { - return Err(TuiTestError::usage(format!( - "{name} must be an integer between 0 and {max}" - ))); - } - Ok(value as u64) -} - -fn u16_value(value: f64, name: &str) -> std::result::Result { - Ok(integer(value, name, u64::from(u16::MAX))? as u16) -} - -fn u8_value(value: f64, name: &str) -> std::result::Result { - Ok(integer(value, name, u64::from(u8::MAX))? as u8) -} - -fn mouse_options(value: f64) -> std::result::Result { - let code = u8_value(value, "button")?; - CoreMouseOptions::from_sgr_code(code) - .ok_or_else(|| TuiTestError::usage(format!("invalid mouse button code {code}"))) -} - -fn core_occurrence( - value: Option, - nth: Option, - default: CoreMatchOccurrence, - name: &str, -) -> std::result::Result { - if let Some(index) = nth { - if let Some(value) = value.as_deref().filter(|value| *value != "nth") { - return Err(TuiTestError::usage(format!( - "{name} cannot be used with occurrence '{value}'" - ))); - } - return Ok(CoreMatchOccurrence::Nth( - integer(index, name, usize::MAX as u64)? as usize, - )); - } - match value.as_deref() { - None => Ok(default), - Some("any") => Ok(CoreMatchOccurrence::Any), - Some("unique") => Ok(CoreMatchOccurrence::Unique), - Some("first") => Ok(CoreMatchOccurrence::First), - Some("last") => Ok(CoreMatchOccurrence::Last), - Some("nth") => Err(TuiTestError::usage(format!("{name} requires an nth index"))), - Some(value) => Err(TuiTestError::usage(format!( - "{name} must be any, unique, first, last, or nth (got '{value}')" - ))), +impl Drop for ResultGuard<'_> { + fn drop(&mut self) { + unsafe { (self.library.functions.tui_result_free)(self.pointer) }; } } -fn core_style(style: LocatorStyle) -> CoreTextStyle { - CoreTextStyle { - foreground: style.foreground, - background: style.background, - bold: style.bold, - dim: style.dim, - italic: style.italic, - underline_style: style.underline_style, - underline_color: style.underline_color, - inverse: style.inverse, - hidden: style.hidden, - strikethrough: style.strikethrough, - blink: style.blink, +fn result( + library: &Library, + pointer: *mut TuiResult, + convert: impl FnOnce(&TuiResult) -> Result, +) -> Result { + if pointer.is_null() { + return Err(internal("native operation returned a null result")); } -} - -fn core_query( - expression: LocatorExpression, -) -> std::result::Result { - use tui_test::locator_query::{ - LocatorExpression as Expression, LocatorNode as Node, LocatorNodeKind as Kind, - }; - let mut nodes = Vec::new(); - for (index, node) in expression.nodes.into_iter().enumerate() { - let occurrence = core_occurrence( - node.occurrence, - node.nth, - CoreMatchOccurrence::Any, - &format!("nodes[{index}].nth"), - )?; - let reference = |value: Option| { - value - .map(|v| integer(v, "locator reference", 255).map(|v| v as usize)) - .transpose() + let guard = ResultGuard { pointer, library }; + let value = unsafe { &*guard.pointer }; + if value.error_kind != 0 { + let kind = match value.error_kind { + 1 => "assertion", + 2 => "usage", + 3 => "no_session", + _ => "internal", }; - let whitespace = match node.whitespace.as_deref() { - None => None, - Some("exact") => Some(CoreWhitespaceMode::Exact), - Some("normalize") => Some(CoreWhitespaceMode::Normalize), - Some(value) => { - return Err(TuiTestError::usage(format!("invalid whitespace {value:?}"))) + let mut envelope = serde_json::json!({"kind": kind, "message": text(value.error_message)}); + for (key, value) in [ + ("details", value.error_details), + ("artifact", value.error_artifact), + ] { + if let Some(value) = optional_text(value) { + envelope[key] = serde_json::from_str(&value) + .map_err(|error| internal(format!("invalid native {key}: {error}")))?; } - }; - nodes.push(Node { - kind: match node.kind { - LocatorNodeKind::Text => Kind::Text, - LocatorNodeKind::Style => Kind::Style, - LocatorNodeKind::Link => Kind::Link, - LocatorNodeKind::And => Kind::And, - LocatorNodeKind::Or => Kind::Or, - LocatorNodeKind::Filter => Kind::Filter, - }, - occurrence, - text: node.text, - regex: node.regex, - whitespace, - full: node.full, - style: node.style.map(core_style), - link: node.link, - within: reference(node.within)?, - left: reference(node.left)?, - right: reference(node.right)?, - input: reference(node.input)?, - has: reference(node.has)?, - has_not: reference(node.has_not)?, - direction: node.direction.map(|direction| match direction { - LocatorStageDirection::Within => CoreLocatorDirection::Within, - LocatorStageDirection::After => CoreLocatorDirection::After, - LocatorStageDirection::Before => CoreLocatorDirection::Before, - }), - }); - } - Expression { - nodes, - root: integer(expression.root, "locator root", 255)? as usize, - } - .into_query() -} - -fn i32_value(value: f64, name: &str) -> std::result::Result { - if !value.is_finite() - || value.fract() != 0.0 - || value < f64::from(i32::MIN) - || value > f64::from(i32::MAX) - { - return Err(TuiTestError::usage(format!( - "{name} must be an integer between {} and {}", - i32::MIN, - i32::MAX - ))); - } - Ok(value as i32) -} - -fn core_timeouts(value: Option) -> std::result::Result { - let Some(value) = value else { - return Ok(CoreTimeouts::default()); - }; - Ok(CoreTimeouts { - text: timeout(value.text, "timeouts.text")?, - idle: timeout(value.idle, "timeouts.idle")?, - command: timeout(value.command, "timeouts.command")?, - exit: timeout(value.exit, "timeouts.exit")?, - ready: timeout(value.ready, "timeouts.ready")?, - }) -} - -fn core_recording( - value: Option, -) -> std::result::Result { - let Some(value) = value else { - return Ok(CoreAutomaticRecording::default()); - }; - Ok(CoreAutomaticRecording { - directory: value.directory.map(Into::into), - ..CoreAutomaticRecording::default() - }) -} - -fn core_trace( - value: Option, -) -> std::result::Result, TuiTestError> { - let Some(value) = value else { return Ok(None) }; - let mode = match value.mode.as_deref().unwrap_or("off") { - "off" => tui_test::TraceMode::Off, - "on-failure" => tui_test::TraceMode::OnFailure, - "on" => tui_test::TraceMode::On, - other => { - return Err(TuiTestError::usage(format!( - "unknown trace mode {other:?}; expected off, on-failure, or on" - ))) - } - }; - let options = tui_test::TraceOptions { - mode, - directory: value - .directory - .map(Into::into) - .unwrap_or_else(|| tui_test::TraceOptions::default().directory), - }; - options.validate().map_err(TuiTestError::usage)?; - Ok(Some(options)) -} - -fn core_failure_artifact( - value: Option, -) -> std::result::Result, TuiTestError> { - let Some(value) = value else { - return Ok(None); - }; - let mode = match value.mode.as_deref().unwrap_or("all") { - "all" => CoreFailureArtifactMode::All, - "html" => CoreFailureArtifactMode::Html, - "text" => CoreFailureArtifactMode::Text, - "none" => CoreFailureArtifactMode::None, - other => { - return Err(TuiTestError::usage(format!( - "unknown failure artifact mode {other:?}; expected all, html, text, or none" - ))) - } - }; - let options = CoreFailureArtifactOptions { - directory: value.directory.into(), - mode, - include_recording: value.include_recording.unwrap_or(false), - }; - options.validate().map_err(TuiTestError::usage)?; - Ok(Some(options)) -} - -fn core_diagnostics( - screen_history_limit: Option, -) -> std::result::Result { - let mut diagnostics = CoreDiagnosticRetentionOptions::default(); - if let Some(limit) = screen_history_limit { - diagnostics.screen_history_limit = u16_value(limit, "screenHistoryLimit")?; - } - diagnostics.validate().map_err(TuiTestError::usage)?; - Ok(diagnostics) -} - -fn core_profile( - scrollback: Option, - colors: &[(String, String)], -) -> std::result::Result { - let mut profile = CoreProfile::default(); - if let Some(scrollback) = scrollback { - profile.scrollback = integer(scrollback, "profile.scrollback", usize::MAX as u64)? as usize; - } - for (name, value) in colors { - let value = Rgb::parse(value) - .map_err(|error| TuiTestError::usage(format!("profile.colors.{name}: {error}")))?; - if !profile.colors.set_named(name, value) { - return Err(TuiTestError::usage(format!( - "unknown profile color {name:?}" - ))); } + return Err(Error::new( + Status::GenericFailure, + format!("{ERROR_PREFIX}{envelope}"), + )); } - Ok(profile) + convert(value) } -fn open_options( - value: Option, - recording: CoreAutomaticRecording, -) -> std::result::Result { - let Some(value) = value else { - return Ok(CoreOpenOptions { - recording, - ..CoreOpenOptions::default() - }); - }; - let profile = core_profile( - value.profile_scrollback, - value.profile_colors.as_deref().unwrap_or_default(), - )?; - Ok(CoreOpenOptions { - backend: value.backend.map(Into::into).unwrap_or_default(), - profile, - shell: value.shell.map(Into::into), - cols: match value.cols { - Some(cols) => u16_value(cols, "cols")?, - None => tui_test::config::DEFAULT_COLS, - }, - rows: match value.rows { - Some(rows) => u16_value(rows, "rows")?, - None => tui_test::config::DEFAULT_ROWS, - }, - cwd: value.cwd, - env: value.env.unwrap_or_default(), - wait_ready: value.wait_ready, - restart: value.restart.unwrap_or(false), - timeouts: core_timeouts(value.timeouts)?, - recording, - }) +fn unit(_: &TuiResult) -> Result<()> { + Ok(()) } - -fn run_options( - value: RunOptions, - recording: CoreAutomaticRecording, -) -> std::result::Result { - if value.program.is_empty() { - return Err(TuiTestError::usage("program must not be empty")); - } - let profile = core_profile( - value.profile_scrollback, - value.profile_colors.as_deref().unwrap_or_default(), - )?; - Ok(CoreRunOptions { - backend: value.backend.map(Into::into).unwrap_or_default(), - profile, - program: value.program, - args: value.args.unwrap_or_default(), - cols: match value.cols { - Some(cols) => u16_value(cols, "cols")?, - None => tui_test::config::DEFAULT_COLS, - }, - rows: match value.rows { - Some(rows) => u16_value(rows, "rows")?, - None => tui_test::config::DEFAULT_ROWS, - }, - cwd: value.cwd, - env: value.env.unwrap_or_default(), - wait_ready: value.wait_ready, - restart: value.restart.unwrap_or(false), - timeouts: core_timeouts(value.timeouts)?, - recording, - }) +fn string_result(value: &TuiResult) -> Result { + Ok(text(value.text)) } - -fn unexpected(operation: &str) -> TuiTestError { - TuiTestError::internal(format!("{operation} returned an unexpected result type")) -} - -fn execute_core( - handle: &SessionHandle, - context: &CoreExecutionContext, - operation_name: &'static str, - operation: Operation, -) -> std::result::Result { - handle.execute_with_context(operation, context.clone().with_operation(operation_name)) -} - -async fn execute( - handle: SessionHandle, - context: CoreExecutionContext, - operation_name: &'static str, - operation: Operation, - convert: impl FnOnce(OperationResult) -> std::result::Result + Send + 'static, -) -> Result -where - T: Send + 'static, -{ - blocking(operation_name, move || { - let result = execute_core(&handle, &context, operation_name, operation)?; - convert(result) - }) - .await +fn optional_string_result(value: &TuiResult) -> Result> { + Ok(optional_text(value.text)) } #[napi] pub struct NativeSession { - handle: SessionHandle, - recording: CoreAutomaticRecording, - context: CoreExecutionContext, + name: String, + context: serde_json::Value, + recording_directory: Option, +} + +impl NativeSession { + async fn call( + &self, + work: impl FnOnce(&Library, TuiString) -> Result + Send + 'static, + ) -> Result { + let context = self.context.to_string(); + blocking(move || { + let library = library()?; + work(&library, borrowed(&context)) + }) + .await + } } #[napi] @@ -1106,153 +199,180 @@ impl NativeSession { artifacts: Option, trace: Option, ) -> Result { + let mut context = serde_json::json!({"name": name}); + if let Some(options) = artifacts { + context["artifact_directory"] = options.directory.into(); + context["artifact_mode"] = options.mode.unwrap_or_else(|| "all".into()).into(); + context["artifact_include_recording"] = + options.include_recording.unwrap_or(false).into(); + } + if let Some(options) = trace { + context["trace_mode"] = options.mode.unwrap_or_else(|| "off".into()).into(); + context["trace_directory"] = options.directory.into(); + } Ok(Self { - handle: global_registry().session(name), - recording: core_recording(recording).map_err(native_error)?, - context: CoreExecutionContext { - artifact: core_failure_artifact(artifacts).map_err(native_error)?, - trace: core_trace(trace).map_err(native_error)?, - ..CoreExecutionContext::default() - }, + name, + context, + recording_directory: recording.and_then(|options| options.directory), }) } #[napi] pub fn name(&self) -> String { - self.handle.name().to_string() + self.name.clone() } #[napi] pub async fn open(&self, options: Option) -> Result { - let handle = self.handle.clone(); - let recording = self.recording.clone(); - let context = self.context.clone(); - blocking("open", move || { - let diagnostics = core_diagnostics( - options - .as_ref() - .and_then(|value| value.screen_history_limit), - )?; - let mut context = context; - context.retention = diagnostics; - let result = execute_core( - &handle, - &context, - "open", - Operation::Open(open_options(options, recording)?), - )?; - match result { - OperationResult::Open(value) => Ok(value.into()), - _ => Err(unexpected("open")), - } + let options = options.unwrap_or_default(); + let context = session_context(&self.context, options.screen_history_limit)?; + let directory = self.recording_directory.clone(); + blocking(move || { + let library = library()?; + let input = OpenInput::new(options, directory)?; + result( + &library, + unsafe { (library.functions.tui_open_ptr)(borrowed(&context), &input.options) }, + open_result, + ) }) .await } #[napi] pub async fn run(&self, options: RunOptions) -> Result { - let handle = self.handle.clone(); - let recording = self.recording.clone(); - let context = self.context.clone(); - blocking("run", move || { - let diagnostics = core_diagnostics(options.screen_history_limit)?; - let mut context = context; - context.retention = diagnostics; - let result = execute_core( - &handle, - &context, - "run", - Operation::Run(run_options(options, recording)?), + let context = session_context(&self.context, options.screen_history_limit)?; + let directory = self.recording_directory.clone(); + blocking(move || { + let library = library()?; + let args = options.args.unwrap_or_default(); + let borrowed_args: Vec<_> = args.iter().map(|value| borrowed(value)).collect(); + let input = OpenInput::new( + OpenOptions { + backend: options.backend, + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + wait_ready: options.wait_ready, + restart: options.restart, + profile_scrollback: options.profile_scrollback, + profile_colors: options.profile_colors, + timeouts: options.timeouts, + ..Default::default() + }, + directory, )?; - match result { - OperationResult::Open(value) => Ok(value.into()), - _ => Err(unexpected("run")), - } + result( + &library, + unsafe { + (library.functions.tui_run_ptr)( + borrowed(&context), + &input.options, + borrowed(&options.program), + borrowed_args.as_ptr(), + borrowed_args.len(), + ) + }, + open_result, + ) }) .await } #[napi] pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("restart", move || { - let result = execute_core( - &handle, - &context, - "restart", - Operation::Restart { - graceful_timeout_ms: integer( - graceful_timeout_ms, - "gracefulTimeoutMs", - u64::MAX, - )?, + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_restart)( + session, + integer(graceful_timeout_ms, "gracefulTimeoutMs", u64::MAX)?, + ) }, - )?; - match result { - OperationResult::Open(value) => Ok(value.into()), - _ => Err(unexpected("restart")), - } + open_result, + ) }) .await } #[napi] pub async fn close(&self, failed: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("close", move || { - let trace = if let Some(failed) = failed { - execute_core( - &handle, - &context, - "trace.finish", - Operation::FinishTrace { failed }, - ) - } else { - Ok(OperationResult::Unit) - }; - let closed = execute_core(&handle, &context, "close", Operation::Close); - if let Err(error) = trace { - if error.kind != ErrorKind::NoSession { - return Err(error); - } - } - closed?; - Ok(()) + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_close)(session, optional_bool(failed)) }, + unit, + ) }) .await } #[napi] pub async fn state(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "state", - Operation::State, - |result| match result { - OperationResult::State(value) => Ok((*value).into()), - _ => Err(unexpected("state")), - }, - ) + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_state)(session) }, + |value| Ok(state(&value.state)), + ) + }) .await } #[napi] pub async fn text(&self, full: Option) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "text", - Operation::Text { - full: full.unwrap_or(false), - }, - |result| match result { - OperationResult::Text(value) => Ok(value), - _ => Err(unexpected("text")), - }, - ) + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_text)(session, full.unwrap_or(false)) }, + string_result, + ) + }) + .await + } + + #[napi] + pub async fn packed_screen(&self, full: Option) -> Result { + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_packed_screen)(session, full.unwrap_or(false)) }, + |value| { + Ok(PackedScreen { + cols: value.size.cols, + rows: value.size.rows, + utf8: bytes(value.text).to_vec().into(), + }) + }, + ) + }) + .await + } + + #[napi] + pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_cells)( + session, + u16_value(x, "x")?, + u16_value(y, "y")?, + u16_value(w.unwrap_or(1.0), "w")?, + u16_value(h.unwrap_or(1.0), "h")?, + ) + }, + |value| { + array(value.cells, value.cells_len) + .iter() + .map(cell) + .collect() + }, + ) + }) .await } @@ -1262,31 +382,21 @@ impl NativeSession { expression: LocatorExpression, require_one: Option, ) -> Result> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("findLocator", move || { - let query = core_query(expression)?; - let require_one = require_one.unwrap_or(false); - let operation = if require_one { - Operation::ResolveLocator { query } - } else { - Operation::FindLocator { query } - }; - match execute_core( - &handle, - &context, - if require_one { - "locator.location" - } else { - "locator.locations" + self.call(move |library, session| { + let query = query(expression)?; + result( + library, + unsafe { + (library.functions.tui_find_locator)( + session, + TuiQuery { + json: borrowed(&query), + }, + require_one.unwrap_or(false), + ) }, - operation, - )? { - OperationResult::Matches(matches) => { - Ok(matches.into_iter().map(TextMatch::from).collect()) - } - _ => Err(unexpected("findLocator")), - } + matches, + ) }) .await } @@ -1298,419 +408,375 @@ impl NativeSession { not: Option, timeout_ms: Option, ) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("waitLocator", move || { - let query = core_query(expression)?; - match execute_core( - &handle, - &context, - "locator.wait", - Operation::WaitLocator { - query, - not: not.unwrap_or(false), - timeout_ms: timeout(timeout_ms, "timeoutMs")?, + self.call(move |library, session| { + let query = query(expression)?; + result( + library, + unsafe { + (library.functions.tui_wait_locator)( + session, + TuiQuery { + json: borrowed(&query), + }, + wait_options(timeout_ms, false, not.unwrap_or(false))?, + ) }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("waitLocator")), - } + unit, + ) }) .await } #[napi] - pub async fn click_locator( + pub async fn expect_locator( &self, expression: LocatorExpression, - button: Option, - clicks: Option, + not: Option, timeout_ms: Option, ) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("clickLocator", move || { - let query = core_query(expression)?; - match execute_core( - &handle, - &context, - "locator.click", - Operation::ClickLocator { - query, - options: mouse_options(button.unwrap_or(0.0))?, - clicks: u8_value(clicks.unwrap_or(1.0), "clicks")?, - timeout_ms: timeout(timeout_ms, "timeoutMs")?, + self.call(move |library, session| { + let query = query(expression)?; + result( + library, + unsafe { + (library.functions.tui_expect_locator)( + session, + TuiQuery { + json: borrowed(&query), + }, + wait_options(timeout_ms, false, not.unwrap_or(false))?, + ) }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("clickLocator")), - } + unit, + ) }) .await } #[napi] - pub async fn highlight_locator( + pub async fn click_locator( &self, expression: LocatorExpression, + button: Option, + clicks: Option, timeout_ms: Option, - ) -> Result> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("highlightLocator", move || { - let query = core_query(expression)?; - match execute_core( - &handle, - &context, - "locator.highlight", - Operation::HighlightLocator { - query, - timeout_ms: timeout(timeout_ms, "timeoutMs")?, + ) -> Result<()> { + self.call(move |library, session| { + let query = query(expression)?; + result( + library, + unsafe { + (library.functions.tui_click_locator)( + session, + TuiQuery { + json: borrowed(&query), + }, + mouse_options(button.unwrap_or(0.0))?, + u8_value(clicks.unwrap_or(1.0), "clicks")?, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) }, - )? { - OperationResult::Matches(matches) => { - Ok(matches.into_iter().map(TextMatch::from).collect()) - } - _ => Err(unexpected("highlightLocator")), - } + unit, + ) }) .await } #[napi] - pub async fn expect_locator( + pub async fn highlight_locator( &self, expression: LocatorExpression, - not: Option, timeout_ms: Option, - ) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("expectLocator", move || { - let query = core_query(expression)?; - match execute_core( - &handle, - &context, - "locator.expect", - Operation::WaitLocator { - query, - not: not.unwrap_or(false), - timeout_ms: timeout(timeout_ms, "timeoutMs")?, + ) -> Result> { + self.call(move |library, session| { + let query = query(expression)?; + result( + library, + unsafe { + (library.functions.tui_highlight_locator)( + session, + TuiQuery { + json: borrowed(&query), + }, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("expectLocator")), - } + matches, + ) }) .await } #[napi] - pub async fn packed_screen(&self, full: Option) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "packedScreen", - Operation::PackedScreen { - full: full.unwrap_or(false), - }, - |result| match result { - OperationResult::PackedScreen(value) => Ok(PackedScreen { - cols: value.cols, - rows: value.rows, - utf8: value.utf8.into(), - }), - _ => Err(unexpected("packedScreen")), - }, - ) + pub async fn panic_probe(&self) -> Result<()> { + blocking(|| panic!("intentional native panic probe")).await + } + + #[napi] + pub async fn get_command(&self) -> Result> { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_command)(session) }, + optional_string_result, + ) + }) .await } #[napi] - pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("cells", move || { - let operation = Operation::Cells { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - w: u16_value(w.unwrap_or(1.0), "w")?, - h: u16_value(h.unwrap_or(1.0), "h")?, - }; - match execute_core(&handle, &context, "cells", operation)? { - OperationResult::Cells(values) => values.into_iter().map(Cell::try_from).collect(), - _ => Err(unexpected("cells")), - } + pub async fn get_output(&self) -> Result> { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_output)(session) }, + optional_string_result, + ) }) .await } #[napi] - pub async fn get_command(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getCommand", - Operation::GetCommand, - |result| match result { - OperationResult::Command(value) => Ok(value), - _ => Err(unexpected("getCommand")), - }, - ) + pub async fn get_exit_code(&self) -> Result> { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_exit_code)(session) }, + |value| Ok(value.exit_code.present.then_some(value.exit_code.value)), + ) + }) .await } #[napi] - pub async fn get_output(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getOutput", - Operation::GetOutput, - |result| match result { - OperationResult::Output(value) => Ok(value), - _ => Err(unexpected("getOutput")), - }, - ) + pub async fn get_cwd(&self) -> Result> { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_cwd)(session) }, + optional_string_result, + ) + }) .await } #[napi] - pub async fn get_exit_code(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getExitCode", - Operation::GetExitCode, - |result| match result { - OperationResult::ExitCode(value) => Ok(value), - _ => Err(unexpected("getExitCode")), - }, - ) + pub async fn get_title(&self) -> Result> { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_title)(session) }, + optional_string_result, + ) + }) .await } #[napi] - pub async fn get_cwd(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getCwd", - Operation::GetCwd, - |result| match result { - OperationResult::Cwd(value) => Ok(value), - _ => Err(unexpected("getCwd")), - }, - ) + pub async fn get_clipboard(&self) -> Result { + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_clipboard)(session) }, + string_result, + ) + }) .await } #[napi] pub async fn get_cursor(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "getCursor", - Operation::GetCursor, - |result| match result { - OperationResult::Cursor(value) => Ok(value.into()), - _ => Err(unexpected("getCursor")), - }, - ) + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_cursor)(session) }, + |value| Ok(cursor(value.cursor)), + ) + }) .await } #[napi] pub async fn get_size(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "getSize", - Operation::GetSize, - |result| match result { - OperationResult::Size(value) => Ok(value.into()), - _ => Err(unexpected("getSize")), - }, - ) + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_size)(session) }, + |value| { + Ok(Size { + cols: value.size.cols, + rows: value.size.rows, + }) + }, + ) + }) .await } #[napi] pub async fn get_bell_count(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "getBellCount", - Operation::GetBellCount, - |result| match result { - OperationResult::BellCount(value) => Ok(value as f64), - _ => Err(unexpected("getBellCount")), - }, - ) + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_bell_count)(session) }, + |value| Ok(value.number as f64), + ) + }) .await } #[napi] pub async fn get_bell_events(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getBellEvents", - Operation::GetBellEvents, - |result| match result { - OperationResult::BellEvents(events) => { - Ok(events.into_iter().map(Into::into).collect()) - } - _ => Err(unexpected("getBellEvents")), - }, - ) + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_get_bell_events)(session) }, + |value| { + Ok(array(value.bells, value.bells_len) + .iter() + .map(|value| BellEvent { + sequence: value.sequence as f64, + elapsed_ms: value.elapsed_ms as f64, + }) + .collect()) + }, + ) + }) .await } #[napi] pub async fn write(&self, data: String) -> Result<()> { - self.unit("write", Operation::Write { data }).await + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_write)(session, borrowed(&data)) }, + unit, + ) + }) + .await } #[napi(js_name = "type")] pub async fn type_text(&self, text: String) -> Result<()> { - self.unit("type", Operation::Write { data: text }).await + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_type)(session, borrowed(&text)) }, + unit, + ) + }) + .await } #[napi] pub async fn submit(&self, data: Option) -> Result<()> { - self.unit("submit", Operation::Submit { data }).await + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_submit)(session, optional_borrowed(data.as_deref())) + }, + unit, + ) + }) + .await } #[napi] pub async fn press(&self, keys: Vec) -> Result<()> { - self.unit( - "press", - Operation::Key { - keys, - action: KeyAction::Press, - }, - ) - .await + self.keys(keys, 0).await } #[napi] pub async fn key_down(&self, keys: Vec) -> Result<()> { - self.unit( - "keydown", - Operation::Key { - keys, - action: KeyAction::Down, - }, - ) - .await + self.keys(keys, 1).await } #[napi] pub async fn repeat(&self, keys: Vec) -> Result<()> { - self.unit( - "repeat", - Operation::Key { - keys, - action: KeyAction::Repeat, - }, - ) - .await + self.keys(keys, 2).await } #[napi] pub async fn key_up(&self, keys: Vec) -> Result<()> { - self.unit( - "keyup", - Operation::Key { - keys, - action: KeyAction::Up, - }, - ) - .await + self.keys(keys, 3).await } #[napi] pub async fn mouse_click(&self, options: Option) -> Result<()> { - let options = options.unwrap_or(MouseClickOptions { - x: None, - y: None, - on_text: None, - button: None, - clicks: None, - }); - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseClick", move || { - let action = MouseAction::Click { - x: options.x.map(|value| u16_value(value, "x")).transpose()?, - y: options.y.map(|value| u16_value(value, "y")).transpose()?, - on_text: options.on_text, - options: mouse_options(options.button.unwrap_or(0.0))?, - clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, - }; - match execute_core( - &handle, - &context, - "mouse.click", - Operation::Mouse { action }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseClick")), - } + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_click)( + session, + optional_integer(options.x, "x", u16::MAX.into())?, + optional_integer(options.y, "y", u16::MAX.into())?, + optional_borrowed(options.on_text.as_deref()), + mouse_options(options.button.unwrap_or(0.0))?, + u8_value(options.clicks.unwrap_or(1.0), "clicks")?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseMove", move || { - let action = MouseAction::Move { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - }; - match execute_core(&handle, &context, "mouse.move", Operation::Mouse { action })? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseMove")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_move)( + session, + u16_value(x, "x")?, + u16_value(y, "y")?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseDown", move || { - let action = MouseAction::Down { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }; - match execute_core(&handle, &context, "mouse.down", Operation::Mouse { action })? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseDown")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_down)( + session, + u16_value(x, "x")?, + u16_value(y, "y")?, + mouse_options(button.unwrap_or(0.0))?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseUp", move || { - let action = MouseAction::Up { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }; - match execute_core(&handle, &context, "mouse.up", Operation::Mouse { action })? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseUp")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_up)( + session, + u16_value(x, "x")?, + u16_value(y, "y")?, + mouse_options(button.unwrap_or(0.0))?, + ) + }, + unit, + ) }) .await } @@ -1724,118 +790,115 @@ impl NativeSession { y2: f64, button: Option, ) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseDrag", move || { - let action = MouseAction::Drag { - x1: u16_value(x1, "x1")?, - y1: u16_value(y1, "y1")?, - x2: u16_value(x2, "x2")?, - y2: u16_value(y2, "y2")?, - options: mouse_options(button.unwrap_or(0.0))?, - }; - match execute_core(&handle, &context, "mouse.drag", Operation::Mouse { action })? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseDrag")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_drag)( + session, + u16_value(x1, "x1")?, + u16_value(y1, "y1")?, + u16_value(x2, "x2")?, + u16_value(y2, "y2")?, + mouse_options(button.unwrap_or(0.0))?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("mouseScroll", move || { - let action = MouseAction::Scroll { - direction, - amount: u16_value(amount.unwrap_or(3.0), "amount")?, - }; - match execute_core( - &handle, - &context, - "mouse.scroll", - Operation::Mouse { action }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("mouseScroll")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_mouse_scroll)( + session, + borrowed(&direction), + u16_value(amount.unwrap_or(3.0), "amount")?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("resize", move || { - let operation = Operation::Resize { - cols: u16_value(cols, "cols")?, - rows: u16_value(rows, "rows")?, - }; - match execute_core(&handle, &context, "resize", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("resize")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_resize)( + session, + u16_value(cols, "cols")?, + u16_value(rows, "rows")?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn signal(&self, name: String) -> Result<()> { - self.unit("signal", Operation::Signal { name }).await - } - - #[napi] - pub async fn get_title(&self) -> Result> { - execute( - self.handle.clone(), - self.context.clone(), - "getTitle", - Operation::GetTitle, - |result| match result { - OperationResult::Title(value) => Ok(value), - _ => Err(unexpected("getTitle")), - }, - ) + self.call(move |library, session| { + result( + library, + unsafe { (library.functions.tui_signal)(session, borrowed(&name)) }, + unit, + ) + }) .await } #[napi] - pub async fn get_clipboard(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "getClipboard", - Operation::GetClipboard, - |result| match result { - OperationResult::Clipboard(value) => Ok(value), - _ => Err(unexpected("getClipboard")), - }, - ) + pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_title)( + session, + borrowed(&text), + wait_options( + options.timeout_ms, + options.regex.unwrap_or(false), + options.not.unwrap_or(false), + )?, + ) + }, + unit, + ) + }) .await } #[napi] - pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or(TitleOptions { - regex: None, - not: None, - timeout_ms: None, - }); - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("waitTitle", move || { - let operation = Operation::WaitTitle { - text, - regex: options.regex.unwrap_or(false), - timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, - not: options.not.unwrap_or(false), - }; - match execute_core(&handle, &context, "waitTitle", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("waitTitle")), - } + pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_expect_title)( + session, + borrowed(&text), + wait_options( + options.timeout_ms, + options.regex.unwrap_or(false), + options.not.unwrap_or(false), + )?, + ) + }, + unit, + ) }) .await } @@ -1846,146 +909,158 @@ impl NativeSession { text: Option, options: Option, ) -> Result<()> { - let options = options.unwrap_or(ClipboardWaitOptions { - regex: None, - timeout_ms: None, - }); - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("waitClipboard", move || { - let regex = options.regex.unwrap_or(false); - let timeout_ms = timeout(options.timeout_ms, "timeoutMs")?; - let operation = match text { - Some(text) => { - let pattern = if regex { - ClipboardPattern::regex(&text).map_err(|error| { - TuiTestError::usage(format!("invalid regex: {error}")) - })? - } else { - text.into() - }; - Operation::WaitClipboardMatch { - pattern, - timeout_ms, - } - } - None if regex => return Err(TuiTestError::usage("clipboard regex requires text")), - None => Operation::WaitClipboard { timeout_ms }, - }; - match execute_core(&handle, &context, "waitClipboard", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("waitClipboard")), - } - }) - .await - } - - #[napi] - pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or(TitleOptions { - regex: None, - not: None, - timeout_ms: None, - }); - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("expectTitle", move || { - let operation = Operation::ExpectTitle { - text, - regex: options.regex.unwrap_or(false), - not: options.not.unwrap_or(false), - timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, - }; - match execute_core(&handle, &context, "expectTitle", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("expectTitle")), - } + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_clipboard)( + session, + optional_borrowed(text.as_deref()), + wait_options(options.timeout_ms, options.regex.unwrap_or(false), false)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitIdle", timeout_ms, |timeout_ms| Operation::WaitIdle { - timeout_ms, + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_idle)( + session, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitCommand", timeout_ms, |timeout_ms| { - Operation::WaitCommand { timeout_ms } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_command)( + session, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitExit", timeout_ms, |timeout_ms| Operation::WaitExit { - timeout_ms, + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_exit)( + session, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitReady", timeout_ms, |timeout_ms| Operation::WaitReady { - timeout_ms, + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_ready)( + session, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitBell", timeout_ms, |timeout_ms| Operation::WaitBell { - timeout_ms, + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_wait_bell)( + session, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("expectExitCode", move || { - let operation = Operation::ExpectExitCode { - code: i32_value(code, "code")?, - timeout_ms: timeout(timeout_ms, "timeoutMs")?, - }; - match execute_core(&handle, &context, "expectExitCode", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("expectExitCode")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_expect_exit_code)( + session, + i32_value(code, "code")?, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } #[napi] pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.unit( - "expectOutput", - Operation::ExpectOutput { - text, - regex: regex.unwrap_or(false), - }, - ) + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_expect_output)( + session, + borrowed(&text), + regex.unwrap_or(false), + ) + }, + unit, + ) + }) .await } #[napi] pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking("expectBellCount", move || { - let operation = Operation::ExpectBellCount { - count: integer(count, "count", u64::MAX)?, - timeout_ms: timeout(timeout_ms, "timeoutMs")?, - }; - match execute_core(&handle, &context, "expectBellCount", operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("expectBellCount")), - } + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_expect_bell_count)( + session, + integer(count, "count", u64::MAX)?, + optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + ) + }, + unit, + ) }) .await } @@ -1996,138 +1071,117 @@ impl NativeSession { name: String, options: Option, ) -> Result { - let options = options.unwrap_or(SnapshotOptions { - update: None, - include_style: None, - include_title: None, - cwd: None, - }); - execute( - self.handle.clone(), - self.context.clone(), - "snapshot", - Operation::Snapshot { - name, - update: options.update.unwrap_or(false), - include_style: options.include_style.unwrap_or(false), - include_title: options.include_title.unwrap_or(false), - cwd: options.cwd, - }, - |result| match result { - OperationResult::Snapshot(value) => Ok(value.into()), - _ => Err(unexpected("snapshot")), - }, - ) + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_snapshot)( + session, + borrowed(&name), + options.update.unwrap_or(false), + options.include_style.unwrap_or(false), + options.include_title.unwrap_or(false), + optional_borrowed(options.cwd.as_deref()), + ) + }, + |value| match value.snapshot { + 0 => Ok(SnapshotResult::Passed), + 1 => Ok(SnapshotResult::Written), + 2 => Ok(SnapshotResult::Updated), + _ => Err(internal( + "native operation returned an unknown snapshot result", + )), + }, + ) + }) .await } #[napi] pub async fn screenshot(&self, options: Option) -> Result { - let options = options.unwrap_or(ScreenshotOptions { - full: None, - path: None, - zoom: None, - background: None, - transparent: None, - }); - let background = - capture_background(options.background, options.transparent.unwrap_or(false))?; - execute( - self.handle.clone(), - self.context.clone(), - "screenshot", - Operation::Screenshot { - full: options.full.unwrap_or(false), - path: options.path, - zoom: options.zoom, - background, - }, - |result| match result { - OperationResult::Screenshot(CoreScreenshotResult::Path(value)) - | OperationResult::Screenshot(CoreScreenshotResult::Text(value)) => Ok(value), - _ => Err(unexpected("screenshot")), - }, - ) + let options = options.unwrap_or_default(); + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_screenshot)( + session, + options.full.unwrap_or(false), + optional_borrowed(options.path.as_deref()), + optional_float(options.zoom), + optional_borrowed(options.background.as_deref()), + options.transparent.unwrap_or(false), + ) + }, + string_result, + ) + }) .await } #[napi] pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { - let fps = options - .fps - .map(|value| u8_value(value, "fps")) - .transpose() - .map_err(native_error)?; - let background = - capture_background(options.background, options.transparent.unwrap_or(false))?; - self.unit( - "startRecording", - Operation::StartRecording { - path: options.path, - format: options.format.map(Into::into), - fps, - speed: options.speed, - idle_time_limit: options.idle_time_limit, - zoom: options.zoom, - background, - }, - ) + self.call(move |library, session| { + result( + library, + unsafe { + (library.functions.tui_start_recording)( + session, + TuiRecordingOptions { + path: borrowed(&options.path), + format: optional_borrowed(options.format.as_ref().map(|format| { + match format { + RecordingFormat::Apng => "apng", + RecordingFormat::Gif => "gif", + RecordingFormat::Mp4 => "mp4", + RecordingFormat::Cast => "cast", + } + })), + fps: optional_integer(options.fps, "fps", u8::MAX.into())?, + speed: optional_float(options.speed), + idle_time_limit: optional_float(options.idle_time_limit), + zoom: optional_float(options.zoom), + background: optional_borrowed(options.background.as_deref()), + transparent: options.transparent.unwrap_or(false), + }, + ) + }, + unit, + ) + }) .await } #[napi] pub async fn stop_recording(&self) -> Result { - execute( - self.handle.clone(), - self.context.clone(), - "stopRecording", - Operation::StopRecording, - |result| match result { - OperationResult::Recording(path) => Ok(path), - _ => Err(unexpected("stopRecording")), - }, - ) - .await - } - - #[napi] - pub async fn panic_probe(&self) -> Result<()> { - blocking("panicProbe", || -> std::result::Result<(), TuiTestError> { - panic!("intentional native panic probe") + self.call(|library, session| { + result( + library, + unsafe { (library.functions.tui_stop_recording)(session) }, + string_result, + ) }) .await } } impl NativeSession { - async fn unit(&self, operation_name: &'static str, operation: Operation) -> Result<()> { - execute( - self.handle.clone(), - self.context.clone(), - operation_name, - operation, - move |result| match result { - OperationResult::Unit => Ok(()), - _ => Err(unexpected(operation_name)), - }, - ) - .await - } - - async fn timeout_unit( - &self, - operation_name: &'static str, - timeout_ms: Option, - operation: impl FnOnce(Option) -> Operation + Send + 'static, - ) -> Result<()> { - let handle = self.handle.clone(); - let context = self.context.clone(); - blocking(operation_name, move || { - let operation = operation(timeout(timeout_ms, "timeoutMs")?); - match execute_core(&handle, &context, operation_name, operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected(operation_name)), - } + async fn keys(&self, keys: Vec, action: u32) -> Result<()> { + self.call(move |library, session| { + let borrowed_keys: Vec<_> = keys.iter().map(|value| borrowed(value)).collect(); + result( + library, + unsafe { + (library.functions.tui_key)( + session, + borrowed_keys.as_ptr(), + borrowed_keys.len(), + action, + ) + }, + unit, + ) }) .await } @@ -2135,41 +1189,48 @@ impl NativeSession { #[napi] pub async fn sessions() -> Result> { - blocking("sessions", || Ok(global_registry().sessions())).await + blocking(|| { + let library = library()?; + result( + &library, + unsafe { (library.functions.tui_sessions)() }, + |value| { + Ok(array(value.strings, value.strings_len) + .iter() + .map(|value| text(*value)) + .collect()) + }, + ) + }) + .await } #[napi] pub async fn close_all() -> Result<()> { - blocking("closeAll", || { - global_registry().close_all(); - Ok(()) - }) - .await + blocking(close_all_sync).await } #[napi] pub fn close_all_sync() -> Result<()> { - ffi_boundary(|| { - global_registry().close_all(); - Ok(()) + boundary(|| { + let library = library()?; + result( + &library, + unsafe { (library.functions.tui_close_all)() }, + unit, + ) }) } #[napi] pub async fn recording(name: String) -> Result { - blocking("recording", move || { - global_registry().recording(&name).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - TuiTestError::new( - ErrorKind::NoSession, - format!("no recording for session '{name}'"), - ) - } else { - TuiTestError::internal(format!( - "failed to read the recording for session '{name}': {error}" - )) - } - }) + blocking(move || { + let library = library()?; + result( + &library, + unsafe { (library.functions.tui_recording)(borrowed(&name)) }, + string_result, + ) }) .await } diff --git a/bindings/js/native/types.rs b/bindings/js/native/types.rs new file mode 100644 index 00000000..a1a57165 --- /dev/null +++ b/bindings/js/native/types.rs @@ -0,0 +1,447 @@ +use napi::bindgen_prelude::{ + Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, +}; +use napi::{Error, Result, Status}; +use napi_derive::napi; +use std::collections::HashMap; + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum Backend { + Alacritty, + Ghostty, + Rio, + Xtermjs, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum Shell { + Bash, + Powershell, + Pwsh, + Cmd, + Fish, + Zsh, + Xonsh, + Elvish, + Nushell, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum RecordingFormat { + Apng, + Gif, + Mp4, + Cast, +} + +#[derive(Default)] +#[napi(object)] +pub struct Timeouts { + pub text: Option, + pub idle: Option, + pub command: Option, + pub exit: Option, + pub ready: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct AutomaticRecordingOptions { + pub directory: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct TraceOptions { + pub mode: Option, + pub directory: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct FailureArtifactOptions { + pub directory: String, + pub mode: Option, + pub include_recording: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct OpenOptions { + pub backend: Option, + pub shell: Option, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub restart: Option, + pub profile_scrollback: Option, + pub profile_colors: Option>, + pub timeouts: Option, + pub screen_history_limit: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct RunOptions { + pub backend: Option, + pub program: String, + pub args: Option>, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub restart: Option, + pub profile_scrollback: Option, + pub profile_colors: Option>, + pub timeouts: Option, + pub screen_history_limit: Option, +} + +#[napi(object, use_nullable = true)] +pub struct OpenResult { + #[napi(js_name = "shell_pid")] + pub shell_pid: Option, + pub session: String, + pub ready: bool, + pub recording: String, +} + +#[derive(Default)] +#[napi(object)] +pub struct Cursor { + pub x: u16, + pub y: u16, + pub visible: bool, + pub shape: String, + pub color: String, +} + +#[derive(Default)] +#[napi(object)] +pub struct Size { + pub cols: u16, + pub rows: u16, +} + +#[derive(Default)] +#[napi(object)] +pub struct EffectiveTimeouts { + pub text: f64, + pub idle: f64, + pub command: f64, + pub exit: f64, + pub ready: f64, +} + +#[derive(Default)] +#[napi(object)] +pub struct BellEvent { + pub sequence: f64, + #[napi(js_name = "elapsed_ms")] + pub elapsed_ms: f64, +} + +#[napi(object, use_nullable = true)] +pub struct State { + #[napi(js_name = "session_shell")] + pub session_shell: Option, + pub cols: u16, + pub rows: u16, + pub cursor: Cursor, + pub title: Option, + pub cwd: Option, + #[napi(js_name = "last_command")] + pub last_command: Option, + #[napi(js_name = "last_exit")] + pub last_exit: Option, + #[napi(js_name = "exit_signal")] + pub exit_signal: Option, + pub exited: Option, + pub ready: bool, + #[napi(js_name = "bell_count")] + pub bell_count: f64, + pub modes: HashMap, + #[napi(js_name = "mouse_mode")] + pub mouse_mode: String, + pub colors: TerminalColors, + pub timeouts: EffectiveTimeouts, + pub text: String, +} + +/// The colors the terminal is painting with. +#[derive(Default)] +#[napi(object)] +pub struct TerminalColors { + /// The default foreground (`OSC 10`). + pub foreground: String, + /// The default background (`OSC 11`). + pub background: String, + /// The cursor color (`OSC 12`). + pub cursor: String, + /// Palette entries a program overrode (`OSC 4`), keyed by index. + /// + /// Only entries that differ from the profile are listed. + pub palette: HashMap, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum UnderlineStyle { + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} + +#[napi] +pub type Color = Either; + +#[napi(object)] +pub struct Cell { + pub x: u16, + pub y: u16, + pub r#char: String, + pub fg: Color, + pub bg: Color, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub inverse: bool, + pub invisible: bool, + pub strike: bool, + pub blink: bool, + pub underline: bool, + #[napi(js_name = "underline_style")] + pub underline_style: UnderlineStyle, + #[napi(js_name = "underline_color")] + pub underline_color: Color, + /// The OSC 8 URI this cell links to, empty when it links nowhere. + pub link: String, + /// The link's `id=` parameter, empty when the sequence carried none. + /// + /// Identifies a link across a wrap rather than describing where it points: + /// a program that wraps its own links tags each run with a shared `id=`. + /// + /// Backend-dependent. Ghostty reports a link's URI and nothing else, so + /// this is always empty there and no locator matches on it. + #[napi(js_name = "link_id")] + pub link_id: String, +} + +#[derive(Default)] +#[napi(object)] +pub struct TextPosition { + pub row: u32, + pub column: u16, +} + +#[derive(Default)] +#[napi(object)] +pub struct TextSpan { + pub row: u32, + pub start: u16, + pub end: u16, +} + +#[derive(Default)] +#[napi(object)] +pub struct TextMatch { + pub text: String, + pub start: TextPosition, + pub end: TextPosition, + pub spans: Vec, +} + +#[derive(Default)] +#[napi(object)] +/// Private native-owned packed screen snapshot. +/// +/// `utf8` decodes to exactly `rows` newline-delimited logical rows. Trailing +/// spaces and blank rows are retained. UTF-8 byte offsets are not terminal cell +/// offsets when rows contain Unicode graphemes. +pub struct PackedScreen { + #[napi(readonly)] + /// Logical column count. + pub cols: u16, + #[napi(readonly)] + /// Number of logical rows encoded in `utf8`. + pub rows: u16, + #[napi(readonly, ts_type = "Uint8Array")] + /// Detached native-owned UTF-8 bytes. Treat this private snapshot as immutable. + pub utf8: Buffer, +} + +#[derive(Default)] +#[napi(object)] +pub struct MouseClickOptions { + pub x: Option, + pub y: Option, + pub on_text: Option, + pub button: Option, + pub clicks: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum LocatorNodeKind { + Text, + Style, + Link, + And, + Or, + Filter, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum LocatorStageDirection { + Within, + After, + Before, +} + +#[derive(Default, serde::Serialize)] +#[napi(object, object_from_js = false)] +pub struct LocatorStyle { + pub foreground: Option, + pub background: Option, + pub bold: Option, + pub dim: Option, + pub italic: Option, + pub underline_style: Option, + pub underline_color: Option, + pub inverse: Option, + pub hidden: Option, + pub strikethrough: Option, + pub blink: Option, +} + +#[napi(object, object_from_js = false)] +pub struct LocatorNode { + pub kind: LocatorNodeKind, + pub direction: Option, + pub text: Option, + pub regex: Option, + pub full: Option, + pub whitespace: Option, + pub occurrence: Option, + pub nth: Option, + pub style: Option, + pub link: Option, + pub within: Option, + pub left: Option, + pub right: Option, + pub input: Option, + pub has: Option, + pub has_not: Option, +} + +#[napi(object, object_from_js = false)] +pub struct LocatorExpression { + pub nodes: Vec, + pub root: f64, +} + +// Reject unknown fields before conversion so misspelled predicates cannot +// silently broaden a locator. +macro_rules! strict_locator_input { + ($name:ident { $($field:ident => $key:literal),* $(,)? }) => { + impl ValidateNapiValue for $name {} + impl FromNapiValue for $name { + unsafe fn from_napi_value(env: napi::sys::napi_env, value: napi::sys::napi_value) -> Result { + unsafe { Object::validate(env, value)?; } + let object = unsafe { Object::from_napi_value(env, value)? }; + for key in Object::keys(&object)? { + if ![$($key),*].contains(&key.as_str()) { + return Err(Error::new(Status::InvalidArg, format!("unknown locator field {key:?}"))); + } + } + Ok(Self { $($field: object.get_named_property($key)?),* }) + } + } + }; +} + +strict_locator_input!(LocatorStyle { + foreground => "foreground", background => "background", bold => "bold", + dim => "dim", italic => "italic", underline_style => "underlineStyle", + underline_color => "underlineColor", inverse => "inverse", hidden => "hidden", + strikethrough => "strikethrough", blink => "blink", +}); +strict_locator_input!(LocatorNode { + kind => "kind", direction => "direction", text => "text", regex => "regex", + full => "full", whitespace => "whitespace", occurrence => "occurrence", + nth => "nth", style => "style", link => "link", within => "within", + left => "left", right => "right", input => "input", has => "has", has_not => "hasNot", +}); +strict_locator_input!(LocatorExpression { nodes => "nodes", root => "root" }); + +#[derive(Default)] +#[napi(object)] +pub struct TitleOptions { + pub regex: Option, + pub not: Option, + pub timeout_ms: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct ClipboardWaitOptions { + pub regex: Option, + pub timeout_ms: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct SnapshotOptions { + pub update: Option, + pub include_style: Option, + pub include_title: Option, + pub cwd: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct ScreenshotOptions { + pub full: Option, + pub path: Option, + pub zoom: Option, + pub background: Option, + pub transparent: Option, +} + +#[derive(Default)] +#[napi(object)] +pub struct RecordingOptions { + pub path: String, + pub format: Option, + pub fps: Option, + pub speed: Option, + pub idle_time_limit: Option, + pub zoom: Option, + pub background: Option, + pub transparent: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum SnapshotResult { + Passed, + Written, + Updated, +} diff --git a/bindings/js/package.json b/bindings/js/package.json index 4767015a..790f0ca7 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -19,6 +19,9 @@ "native/index.js", "native/index.d.ts", "native/*.node", + "native/*.dylib", + "native/*.so", + "native/*.dll", "README.md" ], "engines": { @@ -39,8 +42,8 @@ ] }, "scripts": { - "build:native": "napi build --platform --esm --release --strip --output-dir native --js index.js --dts index.d.ts", - "build:native:debug": "napi build --platform --esm --output-dir native --js index.js --dts index.d.ts", + "build:native": "node scripts/build-native.mjs", + "build:native:debug": "node scripts/build-native.mjs --debug", "build:ts": "npm run clean && tsc -p tsconfig.json", "build": "npm run build:native && npm run build:ts", "create:npm-dirs": "napi create-npm-dirs", diff --git a/bindings/js/scripts/build-native.mjs b/bindings/js/scripts/build-native.mjs new file mode 100644 index 00000000..20b36c3c --- /dev/null +++ b/bindings/js/scripts/build-native.mjs @@ -0,0 +1,58 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { patchLoader } from "./patch-loader.mjs"; + +const directory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const root = resolve(directory, "../.."); +const args = process.argv.slice(2); +const debug = args.includes("--debug"); +const napiArgs = args.filter((arg) => arg !== "--debug"); +const targetIndex = args.indexOf("--target"); +const explicitTarget = targetIndex >= 0 + ? args[targetIndex + 1] + : args.find((arg) => arg.startsWith("--target="))?.slice("--target=".length); +const target = explicitTarget ?? execFileSync("rustc", ["-vV"], { encoding: "utf8" }) + .match(/^host: (.+)$/m)?.[1]; +const tags = { + "x86_64-pc-windows-msvc": "win32-x64-msvc", + "x86_64-apple-darwin": "darwin-x64", + "aarch64-apple-darwin": "darwin-arm64", + "x86_64-unknown-linux-gnu": "linux-x64-gnu", + "aarch64-unknown-linux-gnu": "linux-arm64-gnu", + "x86_64-unknown-linux-musl": "linux-x64-musl", + "aarch64-unknown-linux-musl": "linux-arm64-musl", +}; +const tag = tags[target]; +if (!tag) throw new Error(`Unsupported native target: ${target}`); +const windows = target.includes("windows"); +const extension = windows ? "dll" : target.includes("darwin") ? "dylib" : "so"; +const stem = `${windows ? "" : "lib"}tui_test_ffi`; +const env = { ...process.env }; +delete env.CARGO_TARGET_DIR; +if (target.endsWith("-musl")) { + env.RUSTFLAGS = `${env.RUSTFLAGS ?? ""} -C target-feature=-crt-static`.trim(); +} +const run = (command, commandArgs, cwd = directory) => + execFileSync(command, commandArgs, { cwd, env, stdio: "inherit" }); + +run("cargo", [ + args.includes("-x") || args.includes("--cross-compile") ? "zigbuild" : "build", + "-p", "tui-test-ffi", + ...(debug ? [] : ["--release"]), + ...(explicitTarget ? ["--target", explicitTarget] : []), +], root); +const profile = debug ? "debug" : "release"; +copyFileSync( + resolve(root, "target", explicitTarget ?? "", profile, `${stem}.${extension}`), + resolve(directory, "native", `${stem}.${tag}.${extension}`), +); +run(process.execPath, [ + resolve(directory, "node_modules/@napi-rs/cli/dist/cli.js"), + "build", "--platform", "--esm", + ...(debug ? [] : ["--release", "--strip"]), + "--output-dir", "native", "--js", "index.js", "--dts", "index.d.ts", + ...napiArgs, +]); +patchLoader(resolve(directory, "native/index.js")); diff --git a/bindings/js/scripts/patch-loader.mjs b/bindings/js/scripts/patch-loader.mjs new file mode 100644 index 00000000..8e8eeccf --- /dev/null +++ b/bindings/js/scripts/patch-loader.mjs @@ -0,0 +1,28 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +// Apply this deterministic extension after each napi-rs loader regeneration. +export function patchLoader(path) { + let source = readFileSync(path, "utf8"); + if (source.includes("let sharedLibraryPath")) return; + const original = "const require = createRequire(import.meta.url)"; + if (!source.includes(original)) { + throw new Error("The generated napi-rs loader changed its require declaration"); + } + source = source.replace(original, `const nativeRequire = createRequire(import.meta.url) +let sharedLibraryPath +const require = (name) => { + const binding = nativeRequire(name) + if (binding && typeof binding.configureLibrary === 'function') { + const addon = nativeRequire.resolve(name) + const { basename, dirname, join } = nativeRequire('node:path') + const tag = basename(addon).replace(/^tui-test\\./, '').replace(/\\.node$/, '') + const windows = tag.startsWith('win32-') + const extension = windows ? 'dll' : tag.startsWith('darwin-') ? 'dylib' : 'so' + sharedLibraryPath = join(dirname(addon), \`\${windows ? '' : 'lib'}tui_test_ffi.\${tag}.\${extension}\`) + binding.configureLibrary(sharedLibraryPath) + } + return binding +} +require.resolve = nativeRequire.resolve`); + writeFileSync(path, source); +} diff --git a/bindings/js/test/fixtures/abi-version.c b/bindings/js/test/fixtures/abi-version.c new file mode 100644 index 00000000..d8b0ccbe --- /dev/null +++ b/bindings/js/test/fixtures/abi-version.c @@ -0,0 +1,3 @@ +unsigned int tui_abi_version(void) { + return ABI_VERSION; +} diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 3943023c..70624ef6 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -42,6 +42,10 @@ const nonzeroExitArgs = typeof globalThis.Deno === "undefined" ? ["-e", "process.exit(7)"] : ["eval", "Deno.exit(7)"]; +const runtimeScript = (script) => + typeof globalThis.Deno === "undefined" + ? ["-e", script] + : ["eval", `import process from "node:process"; ${script}`]; test("echo roundtrip drives a real session", async () => { await withTerminal({ shell }, async (su) => { @@ -606,7 +610,7 @@ test("locators support scoped text matches and style assertions", async () => { "process.stdout.write('Settings One\\n Save\\nSettings Two\\n Save\\n\\x1b[1mWarning\\x1b[0m\\n\\x1b[1mPart\\x1b[0mial\\n');" + "setInterval(() => {}, 1000)"; try { - await su.run(process.execPath, ["-e", script]); + await su.run(process.execPath, runtimeScript(script)); await su.getByText("Warning").wait({ timeout: 2000 }); const matches = await su .getByText("Settings") @@ -805,7 +809,7 @@ test("link locators compose cell sets and filter whole candidates", async () => const su = new TuiTest(uniqueSession("link-composition"), { backend }); try { const script = `setTimeout(() => process.stdout.write(${JSON.stringify(output)}), 150);setInterval(() => {}, 1000)`; - await su.run(process.execPath, ["-e", script]); + await su.run(process.execPath, runtimeScript(script)); const row = su.getByText("ABC"); const bold = su.getByStyle({ bold: true }); const link = su.getByLink(uri); @@ -888,7 +892,7 @@ test("ghostty backend preserves blink", async () => { try { await su.run( process.execPath, - ["-e", "process.stdout.write('\\u001b[5mX\\u001b[0m'); setInterval(() => {}, 1000)"], + runtimeScript("process.stdout.write('\\u001b[5mX\\u001b[0m'); setInterval(() => {}, 1000)"), { cols: 10, rows: 2 }, ); await su.getByText("X").wait({ timeout: 5000 }); diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 5e5358dd..5ead47d2 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { TuiTest } from "../dist/index.js"; @@ -13,6 +18,56 @@ import { NativeRuntime, mapNativeError } from "../dist/native.js"; const ERROR_PREFIX = "__tui_test_native_error__:"; +function checkLibraryFailure(library, expected) { + const script = ` + (async () => { + const assert = (await import("node:assert/strict")).default; + const { NativeRuntime } = await import(${JSON.stringify(new URL("../dist/native.js", import.meta.url).href)}); + const { InternalError } = await import(${JSON.stringify(new URL("../dist/index.js", import.meta.url).href)}); + await assert.rejects(new NativeRuntime("loader-check").state(), (error) => { + assert.ok(error instanceof InternalError); + assert.match(error.message, new RegExp(${JSON.stringify(expected)})); + return true; + }); + })().catch(error => { console.error(error); process.exitCode = 1; }); + `; + const args = typeof globalThis.Deno === "undefined" ? ["-e", script] : ["eval", script]; + const child = spawnSync(process.execPath, args, { + encoding: "utf8", + env: { ...process.env, TUI_TEST_NATIVE_LIBRARY: library }, + }); + assert.equal(child.status, 0, child.stderr); +} + +test("shared library override failures retain the public error taxonomy", () => { + checkLibraryFailure( + fileURLToPath(new URL("./does-not-exist/native-library", import.meta.url)), + "failed to load shared native library", + ); +}); + +test("shared library rejects incompatible versions and missing symbols", (t) => { + if (process.platform === "win32" || spawnSync("cc", ["--version"]).status !== 0) { + t.skip("This fixture requires a Unix C compiler"); + return; + } + const directory = mkdtempSync(join(tmpdir(), "tui-test-node-abi-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const fixture = fileURLToPath(new URL("./fixtures/abi-version.c", import.meta.url)); + for (const [version, expected] of [ + [0, "native ABI version mismatch: expected 3, got 0"], + [3, "failed to load native ABI symbols"], + ]) { + const library = join(directory, `abi-${version}.${process.platform === "darwin" ? "dylib" : "so"}`); + const compiled = spawnSync("cc", [ + process.platform === "darwin" ? "-dynamiclib" : "-shared", + "-fPIC", `-DABI_VERSION=${version}`, fixture, "-o", library, + ], { encoding: "utf8" }); + assert.equal(compiled.status, 0, compiled.stderr); + checkLibraryFailure(library, expected); + } +}); + test("native JSON error envelopes expose structured diagnostics", () => { const details = { schema_version: 1, diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore index 6aada11c..54809f62 100644 --- a/bindings/python/.gitignore +++ b/bindings/python/.gitignore @@ -5,7 +5,6 @@ dist/ *.egg-info/ .pytest_cache/ .venv/ -src/tui_test/_native*.pyd -src/tui_test/_native*.pdb -src/tui_test/_native*.so -src/tui_test/_native*.dylib +src/tui_test/tui_test_ffi.dll +src/tui_test/libtui_test_ffi.so +src/tui_test/libtui_test_ffi.dylib diff --git a/bindings/python/MANIFEST.in b/bindings/python/MANIFEST.in new file mode 100644 index 00000000..081a3a8a --- /dev/null +++ b/bindings/python/MANIFEST.in @@ -0,0 +1,7 @@ +include README.md +include setup.py +recursive-include src *.py *.pyi py.typed +recursive-include tests *.py +recursive-include examples *.py +graft _rust +global-exclude __pycache__ *.pyc diff --git a/bindings/python/README.md b/bindings/python/README.md index ad77c32e..946e82ad 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -10,6 +10,54 @@ pip install tui-test Python 3.8+ is supported. +The wheel contains an ordinary shared library. Python uses generated `ctypes` +declarations for its typed C ABI. The package has no Python native extension. + +Set `TUI_TEST_NATIVE_LIBRARY` to an absolute library path to override the bundled +library. The loader checks the ABI version before the first operation. + +### Build from source + +Install Rust 1.90 or later and the build dependencies for the supported terminal +backends. Build a wheel with the standard Python build frontend: + +```sh +python -m pip install build +env -u CARGO_TARGET_DIR python -m build bindings/python +``` + +The build uses `setuptools-rust` with `Binding.NoBinding`. Wheels use +`py3-none-` tags. The source distribution includes the shared ABI, +the Rust engine, and their assets. Source builds need Cargo dependency access. + +For cross builds, set `CARGO_BUILD_TARGET` and pass +`-C--build-option=--plat-name=` to `python -m build --wheel`. +Build Linux release wheels in the required manylinux or musllinux container. +Use `auditwheel repair` before publication. Setuptools-rust disables static +libc linkage for musl shared libraries. + +For a checkout, use the shared library directly: + +```sh +env -u CARGO_TARGET_DIR cargo build -p tui-test-ffi +PYTHONPATH=bindings/python/src \ +TUI_TEST_NATIVE_LIBRARY="$PWD/target/debug/libtui_test_ffi.dylib" \ +python bindings/python/examples/shared_library.py +``` + +Use `libtui_test_ffi.so` on Linux or `tui_test_ffi.dll` on Windows. +The example proves that a copied screen remains valid after the session closes. + +Each asynchronous call uses a separate worker thread. A blocked wait cannot +prevent `close()` from interrupting waits. Cancellation releases each native +result when its call returns. + +Cancellation stops the Python await, not the native operation. A canceled +`open()`, `run()`, or `restart()` can still complete. The adapter does not close +the session automatically after cancellation. This preserves any replacement +session with the same name. The caller remains responsible for explicit session +cleanup; cancellation does not confirm that a spawn stopped. + ## Quick start ```python diff --git a/bindings/python/examples/shared_library.py b/bindings/python/examples/shared_library.py new file mode 100644 index 00000000..891b0321 --- /dev/null +++ b/bindings/python/examples/shared_library.py @@ -0,0 +1,25 @@ +"""Run with an installed wheel or TUI_TEST_NATIVE_LIBRARY.""" + +import asyncio +import sys + +from tui_test import TuiTest + + +async def main(): + async with TuiTest.ephemeral("shared-library") as terminal: + await terminal.run( + sys.executable, "-u", "-c", + "print('Shared typed ABI: Python'); input()", + ) + await terminal.get_by_text("Shared typed ABI: Python").expect() + packed, cols, rows = await terminal._packed_screen() + assert packed.readonly + assert b"Shared typed ABI: Python" in packed.tobytes() + print("Shared library: {} columns, {} rows".format(cols, rows)) + print("Copied screen remains valid after close:") + print(packed.tobytes().decode("utf-8").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml deleted file mode 100644 index 02a04cd4..00000000 --- a/bindings/python/native/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "tui-test-python" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -description = "PyO3 bindings for the tui_test in-process terminal engine" -license.workspace = true -repository.workspace = true -publish = false - -[lib] -name = "_native" -crate-type = ["cdylib", "rlib"] -test = false - -[dependencies] -anyhow.workspace = true -tui-test = { workspace = true, features = [ - "ghostty", - "rio", - "xtermjs", - "recording-font-jetbrains-mono-styles", -] } -pyo3 = { version = "0.28" } -pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } -serde_json.workspace = true -tokio = { version = "1", features = ["rt-multi-thread"] } - -[features] -extension-module = [ - "pyo3/abi3-py38", - "pyo3/extension-module", - "pyo3/generate-import-lib", -] diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs deleted file mode 100644 index cdce8759..00000000 --- a/bindings/python/native/src/lib.rs +++ /dev/null @@ -1,2325 +0,0 @@ -use std::any::Any; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -use pyo3::exceptions::PyException; -use pyo3::prelude::*; -use pyo3::types::{PyBool, PyBytes, PyDict, PyInt, PyList, PyMemoryView, PyModule, PyTuple}; -use tui_test::profile::{Profile as CoreProfile, Rgb}; -use tui_test::runtime::global_registry; -use tui_test::shell::Shell; -use tui_test::{ - AutomaticRecording as CoreAutomaticRecording, Backend, BellEvent, CaptureBackground, Cell, - CellColor, ClipboardPattern, Cursor, DiagnosticRetentionOptions, ErrorKind, ExecutionContext, - FailureArtifactMode, FailureArtifactOptions, KeyAction, LocatorDirection, LocatorQuery, - MatchOccurrence, MouseAction, MouseOptions, OpenOptions, OpenResult, Operation, - OperationResult, PackedScreen, RecordingFormat, RunOptions, ScreenshotResult, Size, - SnapshotResult, State, TerminalColors, TextMatch, TextStyle, Timeouts, TuiTestError, - WhitespaceMode, -}; - -pyo3::create_exception!( - tui_test._native, - NativeAssertionError, - PyException, - "Native assertion failure." -); -pyo3::create_exception!( - tui_test._native, - NativeUsageError, - PyException, - "Native usage error." -); -pyo3::create_exception!( - tui_test._native, - NativeNoSessionError, - PyException, - "Native session was not found." -); -pyo3::create_exception!( - tui_test._native, - NativeInternalError, - PyException, - "Native internal error." -); - -#[derive(Clone)] -#[pyclass(module = "tui_test._native", frozen, skip_from_py_object)] -struct NativeSession { - #[pyo3(get)] - name: String, - recording: CoreAutomaticRecording, - artifact: Option, - trace: Option, -} - -impl NativeSession { - fn execution_context(&self) -> ExecutionContext { - ExecutionContext { - artifact: self.artifact.clone(), - trace: self.trace.clone(), - ..ExecutionContext::default() - } - } - - fn execute(&self, operation: Operation) -> Result { - global_registry() - .session(self.name.clone()) - .execute_with_context(operation, self.execution_context()) - } - - fn execute_named( - &self, - operation_name: &'static str, - operation: Operation, - ) -> Result { - global_registry() - .session(self.name.clone()) - .execute_with_context( - operation, - self.execution_context().with_operation(operation_name), - ) - } - - fn execute_with_retention( - &self, - operation: Operation, - retention: DiagnosticRetentionOptions, - ) -> Result { - let mut context = self.execution_context(); - context.retention = retention; - global_registry() - .session(self.name.clone()) - .execute_with_context(operation, context) - } -} - -#[pymethods] -impl NativeSession { - #[new] - #[pyo3(signature = ( - name, - recording_directory = None, - artifact_directory = None, - artifact_mode = None, - artifact_include_recording = false, - trace_mode = None, - trace_directory = None - ))] - fn new( - name: String, - recording_directory: Option, - artifact_directory: Option, - artifact_mode: Option, - artifact_include_recording: bool, - trace_mode: Option, - trace_directory: Option, - ) -> PyResult { - let trace = if trace_mode.is_some() || trace_directory.is_some() { - let mode = match trace_mode.as_deref().unwrap_or("off") { - "off" => tui_test::TraceMode::Off, - "on" => tui_test::TraceMode::On, - "on-failure" => tui_test::TraceMode::OnFailure, - other => { - return Err(shell_error_to_py(TuiTestError::usage(format!( - "unknown trace mode {other:?}; expected off, on-failure, or on" - )))) - } - }; - let options = tui_test::TraceOptions { - mode, - directory: trace_directory - .map(Into::into) - .unwrap_or_else(|| tui_test::TraceOptions::default().directory), - }; - options - .validate() - .map_err(|error| shell_error_to_py(TuiTestError::usage(error)))?; - Some(options) - } else { - None - }; - Ok(Self { - name, - trace, - recording: CoreAutomaticRecording { - mode: tui_test::AutomaticRecordingMode::Disabled, - directory: recording_directory.map(Into::into), - }, - artifact: artifact_mode - .map(|mode| { - let mode = match mode.as_str() { - "all" => FailureArtifactMode::All, - "html" => FailureArtifactMode::Html, - "text" => FailureArtifactMode::Text, - "none" => FailureArtifactMode::None, - other => { - let message = format!( - "unknown failure artifact mode {other:?}; expected all, html, text, or none" - ); - return Err(TuiTestError::usage(message)); - } - }; - Ok(FailureArtifactOptions { - directory: artifact_directory.unwrap_or_default().into(), - mode, - include_recording: artifact_include_recording, - }) - }) - .transpose() - .map_err(shell_error_to_py)?, - }) - } - - #[pyo3(signature = ( - shell, - backend, - cols, - rows, - cwd, - env, - wait_ready, - restart, - profile_scrollback, - profile_colors, - text_timeout, - idle_timeout, - command_timeout, - exit_timeout, - ready_timeout, - screen_history_limit - ))] - #[allow(clippy::too_many_arguments)] - fn open<'py>( - &self, - py: Python<'py>, - shell: Option, - backend: Option, - cols: Bound<'py, PyAny>, - rows: Bound<'py, PyAny>, - cwd: Option, - env: Vec<(String, String)>, - wait_ready: Option, - restart: bool, - profile_scrollback: Option>, - profile_colors: Vec<(String, String)>, - text_timeout: Option>, - idle_timeout: Option>, - command_timeout: Option>, - exit_timeout: Option>, - ready_timeout: Option>, - screen_history_limit: Option>, - ) -> PyResult> { - let cols = capture_integer(&cols); - let rows = capture_integer(&rows); - let profile_scrollback = capture_optional_integer(profile_scrollback); - let text_timeout = capture_optional_integer(text_timeout); - let idle_timeout = capture_optional_integer(idle_timeout); - let command_timeout = capture_optional_integer(command_timeout); - let exit_timeout = capture_optional_integer(exit_timeout); - let ready_timeout = capture_optional_integer(ready_timeout); - let screen_history_limit = capture_optional_integer(screen_history_limit); - let session = self.clone(); - let recording = self.recording.clone(); - future_blocking( - py, - move || { - execute_open( - &session, - Operation::Open(OpenOptions { - backend: parse_backend(backend.as_deref())?, - profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?, - shell: parse_shell(shell.as_deref())?, - cols: integer_u16(&cols, "cols")?, - rows: integer_u16(&rows, "rows")?, - cwd, - env, - wait_ready, - restart, - timeouts: Timeouts { - text: optional_u64(text_timeout.as_ref(), "text_timeout")?, - idle: optional_u64(idle_timeout.as_ref(), "idle_timeout")?, - command: optional_u64(command_timeout.as_ref(), "command_timeout")?, - exit: optional_u64(exit_timeout.as_ref(), "exit_timeout")?, - ready: optional_u64(ready_timeout.as_ref(), "ready_timeout")?, - }, - recording, - }), - diagnostic_retention(screen_history_limit.as_ref())?, - ) - }, - open_to_py, - ) - } - - #[pyo3(signature = ( - program, - args, - backend, - cols, - rows, - cwd, - env, - wait_ready, - restart, - profile_scrollback, - profile_colors, - text_timeout, - idle_timeout, - command_timeout, - exit_timeout, - ready_timeout, - screen_history_limit - ))] - #[allow(clippy::too_many_arguments)] - fn run<'py>( - &self, - py: Python<'py>, - program: String, - args: Vec, - backend: Option, - cols: Bound<'py, PyAny>, - rows: Bound<'py, PyAny>, - cwd: Option, - env: Vec<(String, String)>, - wait_ready: Option, - restart: bool, - profile_scrollback: Option>, - profile_colors: Vec<(String, String)>, - text_timeout: Option>, - idle_timeout: Option>, - command_timeout: Option>, - exit_timeout: Option>, - ready_timeout: Option>, - screen_history_limit: Option>, - ) -> PyResult> { - let cols = capture_integer(&cols); - let rows = capture_integer(&rows); - let profile_scrollback = capture_optional_integer(profile_scrollback); - let text_timeout = capture_optional_integer(text_timeout); - let idle_timeout = capture_optional_integer(idle_timeout); - let command_timeout = capture_optional_integer(command_timeout); - let exit_timeout = capture_optional_integer(exit_timeout); - let ready_timeout = capture_optional_integer(ready_timeout); - let screen_history_limit = capture_optional_integer(screen_history_limit); - let session = self.clone(); - let recording = self.recording.clone(); - future_blocking( - py, - move || { - execute_open( - &session, - Operation::Run(RunOptions { - backend: parse_backend(backend.as_deref())?, - profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?, - program, - args, - cols: integer_u16(&cols, "cols")?, - rows: integer_u16(&rows, "rows")?, - cwd, - env, - wait_ready, - restart, - timeouts: Timeouts { - text: optional_u64(text_timeout.as_ref(), "text_timeout")?, - idle: optional_u64(idle_timeout.as_ref(), "idle_timeout")?, - command: optional_u64(command_timeout.as_ref(), "command_timeout")?, - exit: optional_u64(exit_timeout.as_ref(), "exit_timeout")?, - ready: optional_u64(ready_timeout.as_ref(), "ready_timeout")?, - }, - recording, - }), - diagnostic_retention(screen_history_limit.as_ref())?, - ) - }, - open_to_py, - ) - } - - fn restart<'py>( - &self, - py: Python<'py>, - graceful_timeout_ms: Bound<'py, PyAny>, - ) -> PyResult> { - let graceful_timeout_ms = capture_integer(&graceful_timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_open( - &session, - Operation::Restart { - graceful_timeout_ms: integer_u64( - &graceful_timeout_ms, - "graceful_timeout_ms", - )?, - }, - DiagnosticRetentionOptions::default(), - ) - }, - open_to_py, - ) - } - - #[pyo3(signature = (failed = None))] - fn close<'py>(&self, py: Python<'py>, failed: Option) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - let trace = if let Some(failed) = failed { - execute_unit(&session, Operation::FinishTrace { failed }) - } else { - Ok(()) - }; - let closed = execute_unit(&session, Operation::Close); - if let Err(error) = trace { - if error.kind != ErrorKind::NoSession { - return Err(error); - } - } - closed - }, - unit_to_py, - ) - } - - fn state<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_state(&session, Operation::State), - state_to_py, - ) - } - - fn text<'py>(&self, py: Python<'py>, full: bool) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_text(&session, Operation::Text { full }), - string_to_py, - ) - } - - fn find_locator<'py>( - &self, - py: Python<'py>, - expression: Bound<'py, PyDict>, - require_one: bool, - ) -> PyResult> { - let query = capture_locator_query(&expression); - let session = self.clone(); - future_blocking( - py, - move || { - let operation = if require_one { - Operation::ResolveLocator { query: query? } - } else { - Operation::FindLocator { query: query? } - }; - execute_matches(&session, operation) - }, - matches_to_py, - ) - } - - #[pyo3(signature = (expression, not_, timeout_ms))] - fn wait_locator<'py>( - &self, - py: Python<'py>, - expression: Bound<'py, PyDict>, - not_: bool, - timeout_ms: Option>, - ) -> PyResult> { - let query = capture_locator_query(&expression); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitLocator { - query: query?, - not: not_, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (expression, button, clicks, timeout_ms))] - fn click_locator<'py>( - &self, - py: Python<'py>, - expression: Bound<'py, PyDict>, - button: Bound<'py, PyAny>, - clicks: Bound<'py, PyAny>, - timeout_ms: Option>, - ) -> PyResult> { - let query = capture_locator_query(&expression); - let button = capture_integer(&button); - let clicks = capture_integer(&clicks); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::ClickLocator { - query: query?, - options: mouse_options(&button)?, - clicks: integer_u8(&clicks, "clicks")?, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (expression, timeout_ms))] - fn highlight_locator<'py>( - &self, - py: Python<'py>, - expression: Bound<'py, PyDict>, - timeout_ms: Option>, - ) -> PyResult> { - let query = capture_locator_query(&expression); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_matches( - &session, - Operation::HighlightLocator { - query: query?, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - matches_to_py, - ) - } - - #[pyo3(signature = (expression, not_, timeout_ms))] - fn expect_locator<'py>( - &self, - py: Python<'py>, - expression: Bound<'py, PyDict>, - not_: bool, - timeout_ms: Option>, - ) -> PyResult> { - let query = capture_locator_query(&expression); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit_named( - &session, - "locator.expect", - Operation::WaitLocator { - query: query?, - not: not_, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - fn packed_screen<'py>(&self, py: Python<'py>, full: bool) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_packed_screen(&session, Operation::PackedScreen { full }), - packed_screen_to_py, - ) - } - - fn cells<'py>( - &self, - py: Python<'py>, - x: Bound<'py, PyAny>, - y: Bound<'py, PyAny>, - w: Bound<'py, PyAny>, - h: Bound<'py, PyAny>, - ) -> PyResult> { - let x = capture_integer(&x); - let y = capture_integer(&y); - let w = capture_integer(&w); - let h = capture_integer(&h); - let session = self.clone(); - future_blocking( - py, - move || { - execute_cells( - &session, - Operation::Cells { - x: integer_u16(&x, "x")?, - y: integer_u16(&y, "y")?, - w: integer_u16(&w, "w")?, - h: integer_u16(&h, "h")?, - }, - ) - }, - cells_to_py, - ) - } - - fn get_command<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_command(&session, Operation::GetCommand), - optional_string_to_py, - ) - } - - fn get_output<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_output(&session, Operation::GetOutput), - optional_string_to_py, - ) - } - - fn get_exit_code<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_exit_code(&session, Operation::GetExitCode), - optional_i32_to_py, - ) - } - - fn get_cwd<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_cwd(&session, Operation::GetCwd), - optional_string_to_py, - ) - } - - fn get_title<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_title(&session, Operation::GetTitle), - optional_string_to_py, - ) - } - - fn get_clipboard<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_clipboard(&session, Operation::GetClipboard), - string_to_py, - ) - } - - fn get_cursor<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_cursor(&session, Operation::GetCursor), - cursor_to_py, - ) - } - - fn get_size<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_size(&session, Operation::GetSize), - size_to_py, - ) - } - - fn get_bell_count<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_bell_count(&session, Operation::GetBellCount), - u64_to_py, - ) - } - - fn get_bell_events<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_bell_events(&session, Operation::GetBellEvents), - bell_events_to_py_object, - ) - } - - fn write<'py>(&self, py: Python<'py>, data: String) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_unit(&session, Operation::Write { data }), - unit_to_py, - ) - } - - #[pyo3(name = "type")] - fn type_text<'py>(&self, py: Python<'py>, text: String) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_unit(&session, Operation::Write { data: text }), - unit_to_py, - ) - } - - #[pyo3(signature = (data))] - fn submit<'py>(&self, py: Python<'py>, data: Option) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_unit(&session, Operation::Submit { data }), - unit_to_py, - ) - } - - fn press<'py>(&self, py: Python<'py>, keys: Vec) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Key { - keys, - action: KeyAction::Press, - }, - ) - }, - unit_to_py, - ) - } - - fn key_down<'py>(&self, py: Python<'py>, keys: Vec) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Key { - keys, - action: KeyAction::Down, - }, - ) - }, - unit_to_py, - ) - } - - fn repeat<'py>(&self, py: Python<'py>, keys: Vec) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Key { - keys, - action: KeyAction::Repeat, - }, - ) - }, - unit_to_py, - ) - } - - fn key_up<'py>(&self, py: Python<'py>, keys: Vec) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Key { - keys, - action: KeyAction::Up, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (x, y, on_text, button, clicks))] - fn mouse_click<'py>( - &self, - py: Python<'py>, - x: Option>, - y: Option>, - on_text: Option, - button: Bound<'py, PyAny>, - clicks: Bound<'py, PyAny>, - ) -> PyResult> { - let x = capture_optional_integer(x); - let y = capture_optional_integer(y); - let button = capture_integer(&button); - let clicks = capture_integer(&clicks); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Click { - x: x.as_ref().map(|x| integer_u16(x, "x")).transpose()?, - y: y.as_ref().map(|y| integer_u16(y, "y")).transpose()?, - on_text, - options: mouse_options(&button)?, - clicks: integer_u8(&clicks, "clicks")?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn mouse_move<'py>( - &self, - py: Python<'py>, - x: Bound<'py, PyAny>, - y: Bound<'py, PyAny>, - ) -> PyResult> { - let x = capture_integer(&x); - let y = capture_integer(&y); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Move { - x: integer_u16(&x, "x")?, - y: integer_u16(&y, "y")?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn mouse_down<'py>( - &self, - py: Python<'py>, - x: Bound<'py, PyAny>, - y: Bound<'py, PyAny>, - button: Bound<'py, PyAny>, - ) -> PyResult> { - let x = capture_integer(&x); - let y = capture_integer(&y); - let button = capture_integer(&button); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Down { - x: integer_u16(&x, "x")?, - y: integer_u16(&y, "y")?, - options: mouse_options(&button)?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn mouse_up<'py>( - &self, - py: Python<'py>, - x: Bound<'py, PyAny>, - y: Bound<'py, PyAny>, - button: Bound<'py, PyAny>, - ) -> PyResult> { - let x = capture_integer(&x); - let y = capture_integer(&y); - let button = capture_integer(&button); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Up { - x: integer_u16(&x, "x")?, - y: integer_u16(&y, "y")?, - options: mouse_options(&button)?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn mouse_drag<'py>( - &self, - py: Python<'py>, - x1: Bound<'py, PyAny>, - y1: Bound<'py, PyAny>, - x2: Bound<'py, PyAny>, - y2: Bound<'py, PyAny>, - button: Bound<'py, PyAny>, - ) -> PyResult> { - let x1 = capture_integer(&x1); - let y1 = capture_integer(&y1); - let x2 = capture_integer(&x2); - let y2 = capture_integer(&y2); - let button = capture_integer(&button); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Drag { - x1: integer_u16(&x1, "x1")?, - y1: integer_u16(&y1, "y1")?, - x2: integer_u16(&x2, "x2")?, - y2: integer_u16(&y2, "y2")?, - options: mouse_options(&button)?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn mouse_scroll<'py>( - &self, - py: Python<'py>, - direction: String, - amount: Bound<'py, PyAny>, - ) -> PyResult> { - let amount = capture_integer(&amount); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Mouse { - action: MouseAction::Scroll { - direction, - amount: integer_u16(&amount, "amount")?, - }, - }, - ) - }, - unit_to_py, - ) - } - - fn resize<'py>( - &self, - py: Python<'py>, - cols: Bound<'py, PyAny>, - rows: Bound<'py, PyAny>, - ) -> PyResult> { - let cols = capture_integer(&cols); - let rows = capture_integer(&rows); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Resize { - cols: integer_u16(&cols, "cols")?, - rows: integer_u16(&rows, "rows")?, - }, - ) - }, - unit_to_py, - ) - } - - fn signal<'py>(&self, py: Python<'py>, signal: String) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_unit(&session, Operation::Signal { name: signal }), - unit_to_py, - ) - } - - fn kill<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::Signal { - name: "KILL".to_string(), - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (text, regex, not_, timeout_ms))] - fn wait_title<'py>( - &self, - py: Python<'py>, - text: String, - regex: bool, - not_: bool, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitTitle { - text, - regex, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - not: not_, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (text, regex, timeout_ms))] - fn wait_clipboard<'py>( - &self, - py: Python<'py>, - text: Option, - regex: bool, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - let operation = match text { - Some(text) => { - let pattern = if regex { - ClipboardPattern::regex(&text).map_err(|error| { - TuiTestError::usage(format!("invalid regex: {error}")) - })? - } else { - text.into() - }; - Operation::WaitClipboardMatch { - pattern, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - } - } - None if regex => { - return Err(TuiTestError::usage("clipboard regex requires text")) - } - None => Operation::WaitClipboard { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - }; - execute_unit(&session, operation) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (timeout_ms))] - fn wait_idle<'py>( - &self, - py: Python<'py>, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitIdle { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (timeout_ms))] - fn wait_command<'py>( - &self, - py: Python<'py>, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitCommand { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (timeout_ms))] - fn wait_exit<'py>( - &self, - py: Python<'py>, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitExit { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (timeout_ms))] - fn wait_ready<'py>( - &self, - py: Python<'py>, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitReady { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (timeout_ms))] - fn wait_bell<'py>( - &self, - py: Python<'py>, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::WaitBell { - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (text, regex, not_, timeout_ms))] - fn expect_title<'py>( - &self, - py: Python<'py>, - text: String, - regex: bool, - not_: bool, - timeout_ms: Option>, - ) -> PyResult> { - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::ExpectTitle { - text, - regex, - not: not_, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (code, timeout_ms))] - fn expect_exit_code<'py>( - &self, - py: Python<'py>, - code: Bound<'py, PyAny>, - timeout_ms: Option>, - ) -> PyResult> { - let code = capture_integer(&code); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::ExpectExitCode { - code: integer_i32(&code, "code")?, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - fn expect_output<'py>( - &self, - py: Python<'py>, - text: String, - regex: bool, - ) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_unit(&session, Operation::ExpectOutput { text, regex }), - unit_to_py, - ) - } - - #[pyo3(signature = (count, timeout_ms))] - fn expect_bell_count<'py>( - &self, - py: Python<'py>, - count: Bound<'py, PyAny>, - timeout_ms: Option>, - ) -> PyResult> { - let count = capture_integer(&count); - let timeout_ms = capture_optional_integer(timeout_ms); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::ExpectBellCount { - count: integer_u64(&count, "count")?, - timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, - }, - ) - }, - unit_to_py, - ) - } - - #[pyo3(signature = (name, update, include_style, include_title, cwd))] - fn snapshot<'py>( - &self, - py: Python<'py>, - name: String, - update: bool, - include_style: bool, - include_title: bool, - cwd: Option, - ) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_snapshot( - &session, - Operation::Snapshot { - name, - update, - include_style, - include_title, - cwd, - }, - ) - }, - snapshot_to_py, - ) - } - - #[pyo3(signature = (path, full, zoom=None, background=None, transparent=false))] - fn screenshot<'py>( - &self, - py: Python<'py>, - path: Option, - full: bool, - zoom: Option, - background: Option, - transparent: bool, - ) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - execute_screenshot( - &session, - Operation::Screenshot { - full, - path, - zoom, - background: capture_background(background, transparent)?, - }, - ) - }, - screenshot_to_py, - ) - } - - #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (path, format, fps, speed, idle_time_limit, zoom=None, background=None, transparent=false))] - fn start_recording<'py>( - &self, - py: Python<'py>, - path: String, - format: Option, - fps: Option>, - speed: Option, - idle_time_limit: Option, - zoom: Option, - background: Option, - transparent: bool, - ) -> PyResult> { - let fps = capture_optional_integer(fps); - let session = self.clone(); - future_blocking( - py, - move || { - execute_unit( - &session, - Operation::StartRecording { - path, - format: parse_recording_format(format.as_deref())?, - fps: fps - .as_ref() - .map(|value| integer_u8(value, "fps")) - .transpose()?, - speed, - idle_time_limit, - zoom, - background: capture_background(background, transparent)?, - }, - ) - }, - unit_to_py, - ) - } - - fn stop_recording<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || execute_recording(&session, Operation::StopRecording), - string_to_py, - ) - } - - fn recording<'py>(&self, py: Python<'py>) -> PyResult> { - let session = self.clone(); - future_blocking( - py, - move || { - global_registry() - .recording(&session.name) - .map_err(io_error_to_shell_error) - }, - string_to_py, - ) - } -} - -#[pyfunction] -fn sessions(py: Python<'_>) -> PyResult> { - future_blocking(py, || Ok(global_registry().sessions()), string_list_to_py) -} - -#[pyfunction] -fn close_all(py: Python<'_>) -> PyResult> { - future_blocking( - py, - || { - global_registry().close_all(); - Ok(()) - }, - unit_to_py, - ) -} - -#[pyfunction] -fn recording(py: Python<'_>, name: String) -> PyResult> { - future_blocking( - py, - move || { - global_registry() - .recording(&name) - .map_err(io_error_to_shell_error) - }, - string_to_py, - ) -} - -#[pyfunction] -fn panic_probe(py: Python<'_>) -> PyResult> { - future_blocking( - py, - || -> Result<(), TuiTestError> { - panic!("tui-test Python panic probe"); - }, - unit_to_py, - ) -} - -#[pyfunction] -fn _close_all_blocking(py: Python<'_>) { - py.detach(|| global_registry().close_all()); -} - -fn future_blocking<'py, T, F>( - py: Python<'py>, - task: F, - convert: for<'a> fn(Python<'a>, T) -> PyResult>, -) -> PyResult> -where - T: Send + 'static, - F: FnOnce() -> Result + Send + 'static, -{ - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let value = run_blocking(task).await.map_err(shell_error_to_py)?; - Python::attach(|py| convert(py, value)) - }) -} - -async fn run_blocking(task: F) -> Result -where - T: Send + 'static, - F: FnOnce() -> Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || { - catch_unwind(AssertUnwindSafe(task)).unwrap_or_else(|payload| { - Err(TuiTestError::internal(format!( - "native Python operation panicked: {}", - panic_message(payload.as_ref()) - ))) - }) - }) - .await - .map_err(|error| TuiTestError::internal(format!("native Python worker failed: {error}")))? -} - -fn panic_message(payload: &(dyn Any + Send)) -> String { - if let Some(message) = payload.downcast_ref::<&str>() { - (*message).to_string() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "unknown panic payload".to_string() - } -} - -fn shell_error_to_py(error: TuiTestError) -> PyErr { - Python::attach(|py| { - let exception = match error.kind { - ErrorKind::Assertion => py.get_type::(), - ErrorKind::Usage => py.get_type::(), - ErrorKind::NoSession => py.get_type::(), - ErrorKind::Internal => py.get_type::(), - }; - let envelope = serde_json::json!({ - "kind": error.kind.as_str(), - "message": &error.message, - "details": error.details, - "artifact": error.artifact, - }); - let py_error = PyErr::from_type(exception, error.message); - if let Err(error) = py_error - .value(py) - .setattr("_tui_test_error_json", envelope.to_string()) - { - return error; - } - py_error - }) -} - -fn io_error_to_shell_error(error: std::io::Error) -> TuiTestError { - if error.kind() == std::io::ErrorKind::NotFound { - TuiTestError::new(ErrorKind::NoSession, error.to_string()) - } else { - TuiTestError::internal(error.to_string()) - } -} - -#[derive(Clone)] -enum IntegerInput { - Negative(i64), - NonNegative(u64), - Invalid, -} - -fn capture_integer(value: &Bound<'_, PyAny>) -> IntegerInput { - if value.is_instance_of::() { - return IntegerInput::Invalid; - } - - let Ok(index) = value.call_method0("__index__") else { - return IntegerInput::Invalid; - }; - if index.is_instance_of::() || !index.is_instance_of::() { - return IntegerInput::Invalid; - } - - if let Ok(value) = index.extract::() { - return if value < 0 { - IntegerInput::Negative(value) - } else { - IntegerInput::NonNegative(value as u64) - }; - } - index - .extract::() - .map(IntegerInput::NonNegative) - .unwrap_or(IntegerInput::Invalid) -} - -fn capture_optional_integer(value: Option>) -> Option { - value.as_ref().map(capture_integer) -} - -fn integer_u8(value: &IntegerInput, name: &str) -> Result { - integer_unsigned(value, name, u8::MAX as u128).map(|value| value as u8) -} - -fn mouse_options(value: &IntegerInput) -> Result { - let code = integer_u8(value, "button")?; - MouseOptions::from_sgr_code(code) - .ok_or_else(|| TuiTestError::usage(format!("invalid mouse button code {code}"))) -} - -fn integer_u16(value: &IntegerInput, name: &str) -> Result { - integer_unsigned(value, name, u16::MAX as u128).map(|value| value as u16) -} - -fn integer_u64(value: &IntegerInput, name: &str) -> Result { - integer_unsigned(value, name, u64::MAX as u128).map(|value| value as u64) -} - -fn optional_u64(value: Option<&IntegerInput>, name: &str) -> Result, TuiTestError> { - value.map(|value| integer_u64(value, name)).transpose() -} - -fn diagnostic_retention( - screen_history_limit: Option<&IntegerInput>, -) -> Result { - let mut diagnostics = DiagnosticRetentionOptions::default(); - if let Some(limit) = screen_history_limit { - diagnostics.screen_history_limit = integer_u16(limit, "screen_history_limit")?; - } - diagnostics.validate().map_err(TuiTestError::usage)?; - Ok(diagnostics) -} - -fn py_item<'py>( - dict: &Bound<'py, PyDict>, - key: &str, -) -> Result>, TuiTestError> { - dict.get_item(key) - .map_err(|error| TuiTestError::usage(error.to_string())) - .map(|value| value.filter(|value| !value.is_none())) -} - -fn py_string(dict: &Bound<'_, PyDict>, key: &str) -> Result, TuiTestError> { - py_item(dict, key)? - .map(|value| { - value - .extract::() - .map_err(|error| TuiTestError::usage(format!("{key}: {error}"))) - }) - .transpose() -} - -fn py_bool(dict: &Bound<'_, PyDict>, key: &str) -> Result, TuiTestError> { - py_item(dict, key)? - .map(|value| { - value - .extract::() - .map_err(|error| TuiTestError::usage(format!("{key}: {error}"))) - }) - .transpose() -} - -fn py_usize(dict: &Bound<'_, PyDict>, key: &str) -> Result, TuiTestError> { - py_item(dict, key)? - .map(|value| { - integer_u64(&capture_integer(&value), key).and_then(|value| { - usize::try_from(value) - .map_err(|_| TuiTestError::usage(format!("{key} is too large"))) - }) - }) - .transpose() -} - -fn core_occurrence( - value: Option, - nth: Option, - name: &str, -) -> Result { - if let Some(index) = nth { - if let Some(value) = value.as_deref().filter(|value| *value != "nth") { - return Err(TuiTestError::usage(format!( - "{name} cannot be used with occurrence '{value}'" - ))); - } - return Ok(MatchOccurrence::Nth(index)); - } - match value.as_deref() { - None | Some("any") => Ok(MatchOccurrence::Any), - Some("unique") => Ok(MatchOccurrence::Unique), - Some("first") => Ok(MatchOccurrence::First), - Some("last") => Ok(MatchOccurrence::Last), - Some("nth") => Err(TuiTestError::usage(format!("{name} requires an nth index"))), - Some(value) => Err(TuiTestError::usage(format!( - "{name} must be any, unique, first, last, or nth (got '{value}')" - ))), - } -} - -fn core_style(dict: &Bound<'_, PyDict>) -> Result { - require_keys( - dict, - &[ - "foreground", - "background", - "bold", - "dim", - "italic", - "underline_style", - "underline_color", - "inverse", - "hidden", - "strikethrough", - "blink", - ], - )?; - Ok(TextStyle { - foreground: py_string(dict, "foreground")?, - background: py_string(dict, "background")?, - bold: py_bool(dict, "bold")?, - dim: py_bool(dict, "dim")?, - italic: py_bool(dict, "italic")?, - underline_style: py_string(dict, "underline_style")?, - underline_color: py_string(dict, "underline_color")?, - inverse: py_bool(dict, "inverse")?, - hidden: py_bool(dict, "hidden")?, - strikethrough: py_bool(dict, "strikethrough")?, - blink: py_bool(dict, "blink")?, - }) -} - -fn require_keys(dict: &Bound<'_, PyDict>, allowed: &[&str]) -> Result<(), TuiTestError> { - for (key, _) in dict.iter() { - let key = key - .extract::() - .map_err(|error| TuiTestError::usage(error.to_string()))?; - if !allowed.contains(&key.as_str()) { - return Err(TuiTestError::usage(format!( - "unknown locator field {key:?}" - ))); - } - } - Ok(()) -} - -fn capture_locator_query(expression: &Bound<'_, PyDict>) -> Result { - use tui_test::locator_query::{LocatorExpression, LocatorNode, LocatorNodeKind as Kind}; - require_keys(expression, &["nodes", "root"])?; - let root = py_usize(expression, "root")? - .ok_or_else(|| TuiTestError::usage("locator expression requires root"))?; - let values = py_item(expression, "nodes")? - .ok_or_else(|| TuiTestError::usage("locator expression requires nodes"))?; - let values = values - .extract::>>() - .map_err(|error| TuiTestError::usage(error.to_string()))?; - let mut nodes = Vec::new(); - for (index, node) in values.iter().enumerate() { - let dict = node - .cast::() - .map_err(|error| TuiTestError::usage(format!("nodes[{index}]: {error}")))?; - require_keys( - dict, - &[ - "kind", - "text", - "regex", - "full", - "whitespace", - "direction", - "occurrence", - "nth", - "style", - "link", - "within", - "left", - "right", - "input", - "has", - "has_not", - ], - )?; - let occurrence = core_occurrence( - py_string(dict, "occurrence")?, - py_usize(dict, "nth")?, - &format!("nodes[{index}].nth"), - )?; - let direction = match py_string(dict, "direction")?.as_deref() { - None => None, - Some("within") => Some(LocatorDirection::Within), - Some("after") => Some(LocatorDirection::After), - Some("before") => Some(LocatorDirection::Before), - Some(value) => { - return Err(TuiTestError::usage(format!( - "locator direction must be within, after, or before (got '{value}')" - ))) - } - }; - let kind = match py_string(dict, "kind")?.as_deref() { - Some("text") => Kind::Text, - Some("style") => Kind::Style, - Some("link") => Kind::Link, - Some("and") => Kind::And, - Some("or") => Kind::Or, - Some("filter") => Kind::Filter, - value => { - return Err(TuiTestError::usage(format!( - "unknown locator node kind {value:?}" - ))) - } - }; - let whitespace = match py_string(dict, "whitespace")?.as_deref() { - None => None, - Some("exact") => Some(WhitespaceMode::Exact), - Some("normalize") => Some(WhitespaceMode::Normalize), - value => return Err(TuiTestError::usage(format!("invalid whitespace {value:?}"))), - }; - let style = py_item(dict, "style")? - .map(|style| { - let dict = style - .cast::() - .map_err(|error| TuiTestError::usage(error.to_string()))?; - core_style(dict) - }) - .transpose()?; - nodes.push(LocatorNode { - kind, - occurrence, - direction, - whitespace, - style, - text: py_string(dict, "text")?, - regex: py_bool(dict, "regex")?, - full: py_bool(dict, "full")?, - link: py_string(dict, "link")?, - within: py_usize(dict, "within")?, - left: py_usize(dict, "left")?, - right: py_usize(dict, "right")?, - input: py_usize(dict, "input")?, - has: py_usize(dict, "has")?, - has_not: py_usize(dict, "has_not")?, - }); - } - LocatorExpression { nodes, root }.into_query() -} - -fn profile_from_parts( - scrollback: Option<&IntegerInput>, - colors: &[(String, String)], -) -> Result { - let mut profile = CoreProfile::default(); - if let Some(scrollback) = scrollback { - profile.scrollback = - integer_unsigned(scrollback, "profile.scrollback", usize::MAX as u128)? as usize; - } - for (name, raw) in colors { - let color = Rgb::parse(raw) - .map_err(|error| TuiTestError::usage(format!("profile.colors.{name}: {error}")))?; - if !profile.colors.set_named(name, color) { - return Err(TuiTestError::usage(format!( - "unknown profile color {name:?}" - ))); - } - } - Ok(profile) -} - -fn integer_unsigned(value: &IntegerInput, name: &str, maximum: u128) -> Result { - let parsed = match value { - IntegerInput::NonNegative(value) => Some(*value as u128), - IntegerInput::Negative(_) | IntegerInput::Invalid => None, - }; - parsed.filter(|value| *value <= maximum).ok_or_else(|| { - TuiTestError::usage(format!( - "{name} must be an integer in the range 0..={maximum}" - )) - }) -} - -fn integer_i32(value: &IntegerInput, name: &str) -> Result { - let parsed = match value { - IntegerInput::Negative(value) => Some(*value as i128), - IntegerInput::NonNegative(value) => Some(*value as i128), - IntegerInput::Invalid => None, - }; - parsed - .filter(|value| (i32::MIN as i128..=i32::MAX as i128).contains(value)) - .map(|value| value as i32) - .ok_or_else(|| { - TuiTestError::usage(format!( - "{name} must be an integer in the range {}..={}", - i32::MIN, - i32::MAX - )) - }) -} - -fn parse_shell(value: Option<&str>) -> Result, TuiTestError> { - value - .map(|value| { - let shell = match value { - "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, - other => { - return Err(TuiTestError::usage(format!( - "unknown shell '{other}'; expected bash, powershell, pwsh, cmd, fish, zsh, xonsh, elvish, or nushell" - ))); - } - }; - Ok(shell) - }) - .transpose() -} - -fn parse_backend(value: Option<&str>) -> Result { - value - .unwrap_or("alacritty") - .parse() - .map_err(TuiTestError::usage) -} - -fn parse_recording_format(value: Option<&str>) -> Result, TuiTestError> { - value - .map(|value| match value { - "apng" => Ok(RecordingFormat::Apng), - "gif" => Ok(RecordingFormat::Gif), - "mp4" => Ok(RecordingFormat::Mp4), - "cast" => Ok(RecordingFormat::Cast), - other => Err(TuiTestError::usage(format!( - "unknown recording format '{other}'; expected apng, gif, mp4, or cast" - ))), - }) - .transpose() -} - -fn capture_background( - background: Option, - transparent: bool, -) -> Result, TuiTestError> { - 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() -} - -fn unexpected_result(expected: &str) -> TuiTestError { - TuiTestError::internal(format!( - "native Python binding expected {expected}, but the engine returned another result type" - )) -} - -fn execute_unit(session: &NativeSession, operation: Operation) -> Result<(), TuiTestError> { - match session.execute(operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected_result("no value")), - } -} - -fn execute_unit_named( - session: &NativeSession, - operation_name: &'static str, - operation: Operation, -) -> Result<(), TuiTestError> { - match session.execute_named(operation_name, operation)? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected_result("no value")), - } -} - -fn execute_open( - session: &NativeSession, - operation: Operation, - retention: DiagnosticRetentionOptions, -) -> Result { - match session.execute_with_retention(operation, retention)? { - OperationResult::Open(value) => Ok(value), - _ => Err(unexpected_result("an open result")), - } -} - -fn execute_state(session: &NativeSession, operation: Operation) -> Result { - match session.execute(operation)? { - OperationResult::State(value) => Ok(*value), - _ => Err(unexpected_result("terminal state")), - } -} - -fn execute_text(session: &NativeSession, operation: Operation) -> Result { - match session.execute(operation)? { - OperationResult::Text(value) => Ok(value), - _ => Err(unexpected_result("terminal text")), - } -} - -fn execute_packed_screen( - session: &NativeSession, - operation: Operation, -) -> Result { - match session.execute(operation)? { - OperationResult::PackedScreen(value) => Ok(value), - _ => Err(unexpected_result("a packed screen")), - } -} - -fn execute_cells(session: &NativeSession, operation: Operation) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Cells(value) => Ok(value), - _ => Err(unexpected_result("terminal cells")), - } -} - -fn execute_matches( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Matches(value) => Ok(value), - _ => Err(unexpected_result("text matches")), - } -} - -fn execute_command( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Command(value) => Ok(value), - _ => Err(unexpected_result("the last command")), - } -} - -fn execute_output( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Output(value) => Ok(value), - _ => Err(unexpected_result("the last output")), - } -} - -fn execute_exit_code( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::ExitCode(value) => Ok(value), - _ => Err(unexpected_result("the last exit code")), - } -} - -fn execute_cwd( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Cwd(value) => Ok(value), - _ => Err(unexpected_result("the current working directory")), - } -} - -fn execute_title( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::Title(value) => Ok(value), - _ => Err(unexpected_result("the window title")), - } -} - -fn execute_clipboard( - session: &NativeSession, - operation: Operation, -) -> Result { - match session.execute(operation)? { - OperationResult::Clipboard(value) => Ok(value), - _ => Err(unexpected_result("the clipboard content")), - } -} - -fn execute_cursor(session: &NativeSession, operation: Operation) -> Result { - match session.execute(operation)? { - OperationResult::Cursor(value) => Ok(value), - _ => Err(unexpected_result("the cursor position")), - } -} - -fn execute_size(session: &NativeSession, operation: Operation) -> Result { - match session.execute(operation)? { - OperationResult::Size(value) => Ok(value), - _ => Err(unexpected_result("the terminal size")), - } -} - -fn execute_bell_count(session: &NativeSession, operation: Operation) -> Result { - match session.execute(operation)? { - OperationResult::BellCount(value) => Ok(value), - _ => Err(unexpected_result("the terminal bell count")), - } -} - -fn execute_bell_events( - session: &NativeSession, - operation: Operation, -) -> Result, TuiTestError> { - match session.execute(operation)? { - OperationResult::BellEvents(value) => Ok(value), - _ => Err(unexpected_result("the terminal bell events")), - } -} - -fn execute_snapshot( - session: &NativeSession, - operation: Operation, -) -> Result { - match session.execute(operation)? { - OperationResult::Snapshot(value) => Ok(value), - _ => Err(unexpected_result("a snapshot status")), - } -} - -fn execute_screenshot( - session: &NativeSession, - operation: Operation, -) -> Result { - match session.execute(operation)? { - OperationResult::Screenshot(value) => Ok(value), - _ => Err(unexpected_result("a screenshot")), - } -} - -fn execute_recording( - session: &NativeSession, - operation: Operation, -) -> Result { - match session.execute(operation)? { - OperationResult::Recording(path) => Ok(path), - _ => Err(unexpected_result("a recording path")), - } -} - -fn unit_to_py(py: Python<'_>, (): ()) -> PyResult> { - Ok(py.None()) -} - -fn string_to_py(py: Python<'_>, value: String) -> PyResult> { - Ok(value.into_pyobject(py)?.into_any().unbind()) -} - -fn optional_string_to_py(py: Python<'_>, value: Option) -> PyResult> { - Ok(value.into_pyobject(py)?.into_any().unbind()) -} - -fn optional_i32_to_py(py: Python<'_>, value: Option) -> PyResult> { - Ok(value.into_pyobject(py)?.into_any().unbind()) -} - -fn u64_to_py(py: Python<'_>, value: u64) -> PyResult> { - Ok(value.into_pyobject(py)?.into_any().unbind()) -} - -fn string_list_to_py(py: Python<'_>, value: Vec) -> PyResult> { - Ok(PyList::new(py, value)?.into_any().unbind()) -} - -fn open_to_py(py: Python<'_>, value: OpenResult) -> PyResult> { - let result = PyDict::new(py); - result.set_item("shell_pid", value.shell_pid)?; - result.set_item("session", value.session)?; - result.set_item("ready", value.ready)?; - result.set_item("recording", value.recording)?; - Ok(result.into_any().unbind()) -} - -fn cursor_dict(py: Python<'_>, cursor: Cursor) -> PyResult> { - let value = PyDict::new(py); - value.set_item("x", cursor.x)?; - value.set_item("y", cursor.y)?; - value.set_item("visible", cursor.visible)?; - value.set_item("shape", cursor.shape)?; - value.set_item("color", cursor.color)?; - Ok(value) -} - -/// The terminal's colors, with the `OSC 4` palette keyed by index. -fn colors_dict(py: Python<'_>, colors: TerminalColors) -> PyResult> { - let value = PyDict::new(py); - value.set_item("foreground", colors.foreground)?; - value.set_item("background", colors.background)?; - value.set_item("cursor", colors.cursor)?; - let palette = PyDict::new(py); - for (index, color) in colors.palette { - palette.set_item(index, color)?; - } - value.set_item("palette", palette)?; - Ok(value) -} - -fn size_dict(py: Python<'_>, size: Size) -> PyResult> { - let value = PyDict::new(py); - value.set_item("cols", size.cols)?; - value.set_item("rows", size.rows)?; - Ok(value) -} - -fn bell_events_to_py<'py>(py: Python<'py>, events: Vec) -> PyResult> { - let values = PyList::empty(py); - for event in events { - let value = PyDict::new(py); - value.set_item("sequence", event.sequence)?; - value.set_item("elapsed_ms", event.elapsed_ms)?; - values.append(value)?; - } - Ok(values) -} - -fn bell_events_to_py_object(py: Python<'_>, events: Vec) -> PyResult> { - Ok(bell_events_to_py(py, events)?.into_any().unbind()) -} - -fn state_to_py(py: Python<'_>, value: State) -> PyResult> { - let result = PyDict::new(py); - result.set_item("session_shell", value.session_shell)?; - result.set_item("cols", value.cols)?; - result.set_item("rows", value.rows)?; - result.set_item("cursor", cursor_dict(py, value.cursor)?)?; - result.set_item("title", value.title)?; - result.set_item("cwd", value.cwd)?; - result.set_item("last_command", value.last_command)?; - result.set_item("last_exit", value.last_exit)?; - result.set_item("exit_signal", value.exit_signal)?; - result.set_item("exited", value.exited)?; - result.set_item("ready", value.ready)?; - result.set_item("bell_count", value.bell_count)?; - let modes = PyDict::new(py); - for (name, enabled) in value.modes { - modes.set_item(name, enabled)?; - } - result.set_item("modes", modes)?; - result.set_item("mouse_mode", value.mouse_mode)?; - result.set_item("colors", colors_dict(py, value.colors)?)?; - let timeouts = PyDict::new(py); - timeouts.set_item("text", value.timeouts.text)?; - timeouts.set_item("idle", value.timeouts.idle)?; - timeouts.set_item("command", value.timeouts.command)?; - timeouts.set_item("exit", value.timeouts.exit)?; - timeouts.set_item("ready", value.timeouts.ready)?; - result.set_item("timeouts", timeouts)?; - result.set_item("text", value.text)?; - Ok(result.into_any().unbind()) -} - -fn set_color(value: &Bound<'_, PyDict>, key: &str, color: CellColor) -> PyResult<()> { - match color { - CellColor::Default => value.set_item(key, "default"), - CellColor::Indexed(index) => value.set_item(key, index), - CellColor::Rgb(red, green, blue) => { - value.set_item(key, format!("#{red:02x}{green:02x}{blue:02x}")) - } - } -} - -fn cell_to_py(py: Python<'_>, cell: Cell) -> PyResult> { - let value = PyDict::new(py); - value.set_item("x", cell.x)?; - value.set_item("y", cell.y)?; - value.set_item("char", cell.char)?; - set_color(&value, "fg", cell.fg)?; - set_color(&value, "bg", cell.bg)?; - value.set_item("bold", cell.bold)?; - value.set_item("dim", cell.dim)?; - value.set_item("italic", cell.italic)?; - value.set_item("inverse", cell.inverse)?; - value.set_item("invisible", cell.invisible)?; - value.set_item("strike", cell.strike)?; - value.set_item("blink", cell.blink)?; - value.set_item("underline", cell.underline)?; - value.set_item("underline_style", cell.underline_style)?; - set_color(&value, "underline_color", cell.underline_color)?; - value.set_item("link", cell.link)?; - value.set_item("link_id", cell.link_id)?; - Ok(value) -} - -fn cells_to_py(py: Python<'_>, cells: Vec) -> PyResult> { - let values = PyList::empty(py); - for cell in cells { - values.append(cell_to_py(py, cell)?)?; - } - Ok(values.into_any().unbind()) -} - -fn matches_to_py(py: Python<'_>, matches: Vec) -> PyResult> { - let values = PyList::empty(py); - for matched in matches { - let value = PyDict::new(py); - value.set_item("text", matched.text)?; - let start = PyDict::new(py); - start.set_item("row", matched.start.row)?; - start.set_item("column", matched.start.column)?; - value.set_item("start", start)?; - let end = PyDict::new(py); - end.set_item("row", matched.end.row)?; - end.set_item("column", matched.end.column)?; - value.set_item("end", end)?; - let spans = PyList::empty(py); - for span in matched.spans { - let item = PyDict::new(py); - item.set_item("row", span.row)?; - item.set_item("start", span.start)?; - item.set_item("end", span.end)?; - spans.append(item)?; - } - value.set_item("spans", spans)?; - values.append(value)?; - } - Ok(values.into_any().unbind()) -} - -fn cursor_to_py(py: Python<'_>, cursor: Cursor) -> PyResult> { - Ok(cursor_dict(py, cursor)?.into_any().unbind()) -} - -fn size_to_py(py: Python<'_>, size: Size) -> PyResult> { - Ok(size_dict(py, size)?.into_any().unbind()) -} - -fn snapshot_to_py(py: Python<'_>, status: SnapshotResult) -> PyResult> { - let value = match status { - SnapshotResult::Passed => "passed", - SnapshotResult::Written => "written", - SnapshotResult::Updated => "updated", - }; - string_to_py(py, value.to_string()) -} - -fn screenshot_to_py(py: Python<'_>, screenshot: ScreenshotResult) -> PyResult> { - match screenshot { - ScreenshotResult::Path(path) | ScreenshotResult::Text(path) => string_to_py(py, path), - } -} - -fn packed_screen_to_py(py: Python<'_>, screen: PackedScreen) -> PyResult> { - let bytes = PyBytes::new(py, &screen.utf8); - let view = PyMemoryView::from(bytes.as_any())?; - let values = [ - view.into_any().unbind(), - screen.cols.into_pyobject(py)?.into_any().unbind(), - screen.rows.into_pyobject(py)?.into_any().unbind(), - ]; - Ok(PyTuple::new(py, values)?.into_any().unbind()) -} - -#[pymodule] -fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add( - "NativeAssertionError", - m.py().get_type::(), - )?; - m.add("NativeUsageError", m.py().get_type::())?; - m.add( - "NativeNoSessionError", - m.py().get_type::(), - )?; - m.add( - "NativeInternalError", - m.py().get_type::(), - )?; - m.add_function(wrap_pyfunction!(sessions, m)?)?; - m.add_function(wrap_pyfunction!(close_all, m)?)?; - m.add_function(wrap_pyfunction!(recording, m)?)?; - m.add_function(wrap_pyfunction!(panic_probe, m)?)?; - m.add_function(wrap_pyfunction!(_close_all_blocking, m)?)?; - Ok(()) -} diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index b5636fa5..9b0a76fe 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["maturin>=1.5,<2.0"] -build-backend = "maturin" +requires = ["setuptools>=68", "setuptools-rust>=1.9,<2", "wheel>=0.41"] +build-backend = "setuptools.build_meta" [project] name = "tui-test" @@ -23,8 +23,8 @@ dependencies = [] Homepage = "https://github.com/microsoft/tui-test" Repository = "https://github.com/microsoft/tui-test" -[tool.maturin] -manifest-path = "native/Cargo.toml" -module-name = "tui_test._native" -python-source = "src" -features = ["extension-module"] +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +tui_test = ["*.pyi", "py.typed"] diff --git a/bindings/python/scripts/generate_stubs.py b/bindings/python/scripts/generate_stubs.py deleted file mode 100644 index e9c0a7dd..00000000 --- a/bindings/python/scripts/generate_stubs.py +++ /dev/null @@ -1,33 +0,0 @@ -import argparse -import subprocess -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--check", - action="store_true", - help="fail if src/tui_test/_native.pyi is out of date", - ) - args = parser.parse_args() - - root = Path(__file__).resolve().parents[1] - command = [ - "cargo", - "run", - "--quiet", - "--manifest-path", - str(root / "stub-gen" / "Cargo.toml"), - ] - if args.check: - command.extend(["--", "--check"]) - subprocess.run( - command, - cwd=str(root), - check=True, - ) - - -if __name__ == "__main__": - main() diff --git a/bindings/python/setup.py b/bindings/python/setup.py new file mode 100644 index 00000000..d2d5a810 --- /dev/null +++ b/bindings/python/setup.py @@ -0,0 +1,79 @@ +"""Build the ordinary shared library with setuptools-rust, without Python linkage.""" + +import os +from pathlib import Path +import re +import shutil + +from setuptools import setup +from setuptools.command.sdist import sdist +from setuptools_rust import Binding, RustExtension +from setuptools_rust.build import build_rust +from wheel.bdist_wheel import bdist_wheel + + +ROOT = Path(__file__).resolve().parent +RUST_ROOT = ROOT / "_rust" if (ROOT / "_rust").is_dir() else ROOT.parent.parent + + +class SharedLibraryBuild(build_rust): + def get_dylib_ext_path(self, ext, target_fname): + extension_path = Path(super().get_dylib_ext_path(ext, target_fname)) + platform = self.plat_name.lower() + filename = ( + "tui_test_ffi.dll" if platform.startswith("win") + else "libtui_test_ffi.dylib" if platform.startswith("macosx") + else "libtui_test_ffi.so" + ) + return str(extension_path.with_name(filename)) + + +class SharedLibraryWheel(bdist_wheel): + def get_tag(self): + _, _, platform = super().get_tag() + return "py3", "none", platform + + +class SourceDistribution(sdist): + def make_release_tree(self, base_dir, files): + super().make_release_tree(base_dir, files) + destination = Path(base_dir) / "_rust" + if destination.is_dir(): + return + destination.mkdir() + for filename in ("Cargo.lock", "LICENSE", "README.md"): + shutil.copy2(RUST_ROOT / filename, destination / filename) + manifest = (RUST_ROOT / "Cargo.toml").read_text(encoding="utf-8") + # An sdist needs only the shared ABI and its engine, not the CLI or other bindings. + for key in ("members", "default-members"): + manifest = re.sub( + r"(?m)^" + key + r"\s*=\s*\[[^\]]*\]", + key + ' = ["crates/tui-test", "crates/tui-test-ffi"]', + manifest, + ) + (destination / "Cargo.toml").write_text(manifest, encoding="utf-8") + for crate in ("tui-test", "tui-test-ffi"): + source = RUST_ROOT / "crates" / crate + target = destination / "crates" / crate + target.mkdir(parents=True) + shutil.copy2(source / "Cargo.toml", target / "Cargo.toml") + for directory in ("src", "assets", "shell", "include"): + if (source / directory).is_dir(): + shutil.copytree(source / directory, target / directory) + + +setup( + rust_extensions=[ + RustExtension( + "tui_test.tui_test_ffi", + path=os.path.relpath(RUST_ROOT / "crates/tui-test-ffi/Cargo.toml", ROOT), + binding=Binding.NoBinding, + ), + ], + cmdclass={ + "build_rust": SharedLibraryBuild, + "bdist_wheel": SharedLibraryWheel, + "sdist": SourceDistribution, + }, + zip_safe=False, +) diff --git a/bindings/python/src/tui_test/_abi.py b/bindings/python/src/tui_test/_abi.py new file mode 100644 index 00000000..f53cf0a2 --- /dev/null +++ b/bindings/python/src/tui_test/_abi.py @@ -0,0 +1,460 @@ +# Generated by cargo run -p tui-test-bindgen. Do not edit. +import ctypes + +ABI_VERSION = 3 + + +class TuiString(ctypes.Structure): + pass + + +class TuiOptionalU64(ctypes.Structure): + pass + + +class TuiOptionalI32(ctypes.Structure): + pass + + +class TuiOptionalBool(ctypes.Structure): + pass + + +class TuiOptionalF64(ctypes.Structure): + pass + + +class TuiPair(ctypes.Structure): + pass + + +class TuiTimeouts(ctypes.Structure): + pass + + +class TuiOpenOptions(ctypes.Structure): + pass + + +class TuiQuery(ctypes.Structure): + pass + + +class TuiWaitOptions(ctypes.Structure): + pass + + +class TuiMouseOptions(ctypes.Structure): + pass + + +class TuiRecordingOptions(ctypes.Structure): + pass + + +class TuiCursor(ctypes.Structure): + pass + + +class TuiSize(ctypes.Structure): + pass + + +class TuiOpenResult(ctypes.Structure): + pass + + +class TuiState(ctypes.Structure): + pass + + +class TuiMode(ctypes.Structure): + pass + + +class TuiTerminalColors(ctypes.Structure): + pass + + +class TuiColor(ctypes.Structure): + pass + + +class TuiCell(ctypes.Structure): + pass + + +class TuiPosition(ctypes.Structure): + pass + + +class TuiSpan(ctypes.Structure): + pass + + +class TuiMatch(ctypes.Structure): + pass + + +class TuiBellEvent(ctypes.Structure): + pass + + +class TuiResult(ctypes.Structure): + pass + + +TuiString._fields_ = [ + ("data", ctypes.POINTER(ctypes.c_uint8)), + ("len", ctypes.c_size_t), +] + + +TuiOptionalU64._fields_ = [ + ("present", ctypes.c_bool), + ("value", ctypes.c_uint64), +] + + +TuiOptionalI32._fields_ = [ + ("present", ctypes.c_bool), + ("value", ctypes.c_int32), +] + + +TuiOptionalBool._fields_ = [ + ("present", ctypes.c_bool), + ("value", ctypes.c_bool), +] + + +TuiOptionalF64._fields_ = [ + ("present", ctypes.c_bool), + ("value", ctypes.c_double), +] + + +TuiPair._fields_ = [ + ("key", TuiString), + ("value", TuiString), +] + + +TuiTimeouts._fields_ = [ + ("text", TuiOptionalU64), + ("idle", TuiOptionalU64), + ("command", TuiOptionalU64), + ("exit", TuiOptionalU64), + ("ready", TuiOptionalU64), +] + + +TuiOpenOptions._fields_ = [ + ("backend", TuiString), + ("shell", TuiString), + ("cols", TuiOptionalU64), + ("rows", TuiOptionalU64), + ("cwd", TuiString), + ("env", ctypes.POINTER(TuiPair)), + ("env_len", ctypes.c_size_t), + ("wait_ready", TuiOptionalBool), + ("restart", ctypes.c_bool), + ("scrollback", TuiOptionalU64), + ("colors", ctypes.POINTER(TuiPair)), + ("colors_len", ctypes.c_size_t), + ("timeouts", TuiTimeouts), + ("recording_mode", TuiString), + ("recording_directory", TuiString), +] + + +TuiQuery._fields_ = [ + ("json", TuiString), +] + + +TuiWaitOptions._fields_ = [ + ("timeout_ms", TuiOptionalU64), + ("regex", ctypes.c_bool), + ("not", ctypes.c_bool), +] + + +TuiMouseOptions._fields_ = [ + ("button", ctypes.c_uint32), + ("alt", ctypes.c_bool), + ("ctrl", ctypes.c_bool), + ("shift", ctypes.c_bool), +] + + +TuiRecordingOptions._fields_ = [ + ("path", TuiString), + ("format", TuiString), + ("fps", TuiOptionalU64), + ("speed", TuiOptionalF64), + ("idle_time_limit", TuiOptionalF64), + ("zoom", TuiOptionalF64), + ("background", TuiString), + ("transparent", ctypes.c_bool), +] + + +TuiCursor._fields_ = [ + ("x", ctypes.c_uint16), + ("y", ctypes.c_uint16), + ("visible", ctypes.c_bool), + ("shape", TuiString), + ("color", TuiString), +] + + +TuiSize._fields_ = [ + ("cols", ctypes.c_uint16), + ("rows", ctypes.c_uint16), +] + + +TuiOpenResult._fields_ = [ + ("shell_pid", TuiOptionalU64), + ("session", TuiString), + ("ready", ctypes.c_bool), + ("recording", TuiString), +] + + +TuiMode._fields_ = [ + ("name", TuiString), + ("enabled", ctypes.c_bool), +] + + +TuiTerminalColors._fields_ = [ + ("foreground", TuiString), + ("background", TuiString), + ("cursor", TuiString), + ("palette", ctypes.POINTER(TuiPair)), + ("palette_len", ctypes.c_size_t), +] + + +TuiColor._fields_ = [ + ("kind", ctypes.c_uint32), + ("index", ctypes.c_uint8), + ("red", ctypes.c_uint8), + ("green", ctypes.c_uint8), + ("blue", ctypes.c_uint8), +] + + +TuiCell._fields_ = [ + ("x", ctypes.c_uint16), + ("y", ctypes.c_uint16), + ("character", TuiString), + ("fg", TuiColor), + ("bg", TuiColor), + ("bold", ctypes.c_bool), + ("dim", ctypes.c_bool), + ("italic", ctypes.c_bool), + ("inverse", ctypes.c_bool), + ("invisible", ctypes.c_bool), + ("strike", ctypes.c_bool), + ("blink", ctypes.c_bool), + ("underline", ctypes.c_bool), + ("underline_style", TuiString), + ("underline_color", TuiColor), + ("link", TuiString), + ("link_id", TuiString), +] + + +TuiPosition._fields_ = [ + ("row", ctypes.c_uint32), + ("column", ctypes.c_uint16), +] + + +TuiSpan._fields_ = [ + ("row", ctypes.c_uint32), + ("start", ctypes.c_uint16), + ("end", ctypes.c_uint16), +] + + +TuiMatch._fields_ = [ + ("text", TuiString), + ("start", TuiPosition), + ("end", TuiPosition), + ("spans", ctypes.POINTER(TuiSpan)), + ("spans_len", ctypes.c_size_t), +] + + +TuiBellEvent._fields_ = [ + ("sequence", ctypes.c_uint64), + ("elapsed_ms", ctypes.c_uint64), +] + + +TuiState._fields_ = [ + ("session_shell", TuiString), + ("cols", ctypes.c_uint16), + ("rows", ctypes.c_uint16), + ("cursor", TuiCursor), + ("title", TuiString), + ("cwd", TuiString), + ("last_command", TuiString), + ("last_exit", TuiOptionalI32), + ("exit_signal", TuiString), + ("exited", TuiOptionalI32), + ("ready", ctypes.c_bool), + ("bell_count", ctypes.c_uint64), + ("modes", ctypes.POINTER(TuiMode)), + ("modes_len", ctypes.c_size_t), + ("mouse_mode", TuiString), + ("colors", TuiTerminalColors), + ("timeouts", TuiTimeouts), + ("text", TuiString), +] + + +TuiResult._fields_ = [ + ("error_kind", ctypes.c_uint32), + ("error_message", TuiString), + ("error_details", TuiString), + ("error_artifact", TuiString), + ("text", TuiString), + ("number", ctypes.c_uint64), + ("exit_code", TuiOptionalI32), + ("open", TuiOpenResult), + ("state", TuiState), + ("cursor", TuiCursor), + ("size", TuiSize), + ("cells", ctypes.POINTER(TuiCell)), + ("cells_len", ctypes.c_size_t), + ("matches", ctypes.POINTER(TuiMatch)), + ("matches_len", ctypes.c_size_t), + ("bells", ctypes.POINTER(TuiBellEvent)), + ("bells_len", ctypes.c_size_t), + ("strings", ctypes.POINTER(TuiString)), + ("strings_len", ctypes.c_size_t), + ("snapshot", ctypes.c_uint32), + ("private_data", ctypes.c_void_p), +] + + +def configure(library): + library.tui_abi_version.argtypes = [] + library.tui_abi_version.restype = ctypes.c_uint32 + library.tui_result_free.argtypes = [ctypes.POINTER(TuiResult)] + library.tui_result_free.restype = None + library.tui_open.argtypes = [TuiString, TuiOpenOptions] + library.tui_open.restype = ctypes.POINTER(TuiResult) + library.tui_open_ptr.argtypes = [TuiString, ctypes.POINTER(TuiOpenOptions)] + library.tui_open_ptr.restype = ctypes.POINTER(TuiResult) + library.tui_run.argtypes = [TuiString, TuiOpenOptions, TuiString, ctypes.POINTER(TuiString), ctypes.c_size_t] + library.tui_run.restype = ctypes.POINTER(TuiResult) + library.tui_run_ptr.argtypes = [TuiString, ctypes.POINTER(TuiOpenOptions), TuiString, ctypes.POINTER(TuiString), ctypes.c_size_t] + library.tui_run_ptr.restype = ctypes.POINTER(TuiResult) + library.tui_sessions.argtypes = [] + library.tui_sessions.restype = ctypes.POINTER(TuiResult) + library.tui_close_all.argtypes = [] + library.tui_close_all.restype = ctypes.POINTER(TuiResult) + library.tui_recording.argtypes = [TuiString] + library.tui_recording.restype = ctypes.POINTER(TuiResult) + library.tui_close.argtypes = [TuiString, TuiOptionalBool] + library.tui_close.restype = ctypes.POINTER(TuiResult) + library.tui_restart.argtypes = [TuiString, ctypes.c_uint64] + library.tui_restart.restype = ctypes.POINTER(TuiResult) + library.tui_state.argtypes = [TuiString] + library.tui_state.restype = ctypes.POINTER(TuiResult) + library.tui_get_command.argtypes = [TuiString] + library.tui_get_command.restype = ctypes.POINTER(TuiResult) + library.tui_get_output.argtypes = [TuiString] + library.tui_get_output.restype = ctypes.POINTER(TuiResult) + library.tui_get_exit_code.argtypes = [TuiString] + library.tui_get_exit_code.restype = ctypes.POINTER(TuiResult) + library.tui_get_cwd.argtypes = [TuiString] + library.tui_get_cwd.restype = ctypes.POINTER(TuiResult) + library.tui_get_cursor.argtypes = [TuiString] + library.tui_get_cursor.restype = ctypes.POINTER(TuiResult) + library.tui_get_size.argtypes = [TuiString] + library.tui_get_size.restype = ctypes.POINTER(TuiResult) + library.tui_get_title.argtypes = [TuiString] + library.tui_get_title.restype = ctypes.POINTER(TuiResult) + library.tui_get_clipboard.argtypes = [TuiString] + library.tui_get_clipboard.restype = ctypes.POINTER(TuiResult) + library.tui_get_bell_count.argtypes = [TuiString] + library.tui_get_bell_count.restype = ctypes.POINTER(TuiResult) + library.tui_get_bell_events.argtypes = [TuiString] + library.tui_get_bell_events.restype = ctypes.POINTER(TuiResult) + library.tui_stop_recording.argtypes = [TuiString] + library.tui_stop_recording.restype = ctypes.POINTER(TuiResult) + library.tui_text.argtypes = [TuiString, ctypes.c_bool] + library.tui_text.restype = ctypes.POINTER(TuiResult) + library.tui_packed_screen.argtypes = [TuiString, ctypes.c_bool] + library.tui_packed_screen.restype = ctypes.POINTER(TuiResult) + library.tui_cells.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16, ctypes.c_uint16, ctypes.c_uint16] + library.tui_cells.restype = ctypes.POINTER(TuiResult) + library.tui_write.argtypes = [TuiString, TuiString] + library.tui_write.restype = ctypes.POINTER(TuiResult) + library.tui_type.argtypes = [TuiString, TuiString] + library.tui_type.restype = ctypes.POINTER(TuiResult) + library.tui_submit.argtypes = [TuiString, TuiString] + library.tui_submit.restype = ctypes.POINTER(TuiResult) + library.tui_signal.argtypes = [TuiString, TuiString] + library.tui_signal.restype = ctypes.POINTER(TuiResult) + library.tui_key.argtypes = [TuiString, ctypes.POINTER(TuiString), ctypes.c_size_t, ctypes.c_uint32] + library.tui_key.restype = ctypes.POINTER(TuiResult) + library.tui_resize.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16] + library.tui_resize.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_click.argtypes = [TuiString, TuiOptionalU64, TuiOptionalU64, TuiString, TuiMouseOptions, ctypes.c_uint8] + library.tui_mouse_click.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_move.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16] + library.tui_mouse_move.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_down.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16, TuiMouseOptions] + library.tui_mouse_down.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_up.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16, TuiMouseOptions] + library.tui_mouse_up.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_drag.argtypes = [TuiString, ctypes.c_uint16, ctypes.c_uint16, ctypes.c_uint16, ctypes.c_uint16, TuiMouseOptions] + library.tui_mouse_drag.restype = ctypes.POINTER(TuiResult) + library.tui_mouse_scroll.argtypes = [TuiString, TuiString, ctypes.c_uint16] + library.tui_mouse_scroll.restype = ctypes.POINTER(TuiResult) + library.tui_wait_title.argtypes = [TuiString, TuiString, TuiWaitOptions] + library.tui_wait_title.restype = ctypes.POINTER(TuiResult) + library.tui_expect_title.argtypes = [TuiString, TuiString, TuiWaitOptions] + library.tui_expect_title.restype = ctypes.POINTER(TuiResult) + library.tui_wait_clipboard.argtypes = [TuiString, TuiString, TuiWaitOptions] + library.tui_wait_clipboard.restype = ctypes.POINTER(TuiResult) + library.tui_wait_idle.argtypes = [TuiString, TuiOptionalU64] + library.tui_wait_idle.restype = ctypes.POINTER(TuiResult) + library.tui_wait_command.argtypes = [TuiString, TuiOptionalU64] + library.tui_wait_command.restype = ctypes.POINTER(TuiResult) + library.tui_wait_exit.argtypes = [TuiString, TuiOptionalU64] + library.tui_wait_exit.restype = ctypes.POINTER(TuiResult) + library.tui_wait_ready.argtypes = [TuiString, TuiOptionalU64] + library.tui_wait_ready.restype = ctypes.POINTER(TuiResult) + library.tui_wait_bell.argtypes = [TuiString, TuiOptionalU64] + library.tui_wait_bell.restype = ctypes.POINTER(TuiResult) + library.tui_find_locator.argtypes = [TuiString, TuiQuery, ctypes.c_bool] + library.tui_find_locator.restype = ctypes.POINTER(TuiResult) + library.tui_wait_locator.argtypes = [TuiString, TuiQuery, TuiWaitOptions] + library.tui_wait_locator.restype = ctypes.POINTER(TuiResult) + library.tui_expect_locator.argtypes = [TuiString, TuiQuery, TuiWaitOptions] + library.tui_expect_locator.restype = ctypes.POINTER(TuiResult) + library.tui_click_locator.argtypes = [TuiString, TuiQuery, TuiMouseOptions, ctypes.c_uint8, TuiOptionalU64] + library.tui_click_locator.restype = ctypes.POINTER(TuiResult) + library.tui_highlight_locator.argtypes = [TuiString, TuiQuery, TuiOptionalU64] + library.tui_highlight_locator.restype = ctypes.POINTER(TuiResult) + library.tui_expect_exit_code.argtypes = [TuiString, ctypes.c_int32, TuiOptionalU64] + library.tui_expect_exit_code.restype = ctypes.POINTER(TuiResult) + library.tui_expect_output.argtypes = [TuiString, TuiString, ctypes.c_bool] + library.tui_expect_output.restype = ctypes.POINTER(TuiResult) + library.tui_expect_bell_count.argtypes = [TuiString, ctypes.c_uint64, TuiOptionalU64] + library.tui_expect_bell_count.restype = ctypes.POINTER(TuiResult) + library.tui_snapshot.argtypes = [TuiString, TuiString, ctypes.c_bool, ctypes.c_bool, ctypes.c_bool, TuiString] + library.tui_snapshot.restype = ctypes.POINTER(TuiResult) + library.tui_screenshot.argtypes = [TuiString, ctypes.c_bool, TuiString, TuiOptionalF64, TuiString, ctypes.c_bool] + library.tui_screenshot.restype = ctypes.POINTER(TuiResult) + library.tui_start_recording.argtypes = [TuiString, TuiRecordingOptions] + library.tui_start_recording.restype = ctypes.POINTER(TuiResult) diff --git a/bindings/python/src/tui_test/_native.py b/bindings/python/src/tui_test/_native.py new file mode 100644 index 00000000..32de404b --- /dev/null +++ b/bindings/python/src/tui_test/_native.py @@ -0,0 +1,519 @@ +"""Private Python adapter for the shared, typed C ABI.""" + +import asyncio +import ctypes as C +import json +import os +from pathlib import Path +import sys +import threading + +from . import _abi as abi + + +class NativeAssertionError(Exception): + pass + + +class NativeUsageError(Exception): + pass + + +class NativeNoSessionError(Exception): + pass + + +class NativeInternalError(Exception): + pass + + +_ERRORS = { + 1: ("assertion", NativeAssertionError), + 2: ("usage", NativeUsageError), + 3: ("no_session", NativeNoSessionError), + 5: ("internal", NativeInternalError), +} +_library = None +_library_lock = threading.Lock() + + +def _error(code, message, details=None, artifact=None): + kind, cls = _ERRORS.get(code, _ERRORS[5]) + error = cls(message) + error._tui_test_error_json = json.dumps( + dict(kind=kind, message=message, details=details, artifact=artifact) + ) + return error + + +def _load(): + global _library + with _library_lock: + if _library is None: + filename = ( + "tui_test_ffi.dll" if sys.platform == "win32" + else "libtui_test_ffi.dylib" if sys.platform == "darwin" + else "libtui_test_ffi.so" + ) + path = os.environ.get("TUI_TEST_NATIVE_LIBRARY") + if not path: + path = str(Path(__file__).with_name(filename)) + try: + library = C.CDLL(path) + abi.configure(library) + except (OSError, AttributeError) as error: + raise _error(5, "cannot load native library {!r}: {}".format(path, error)) from error + version = library.tui_abi_version() + if version != abi.ABI_VERSION: + raise _error(5, "native ABI version mismatch: expected {}, got {}".format( + abi.ABI_VERSION, version + )) + _library = library + return _library + + +def _integer(value, name, bits=64, signed=False): + if isinstance(value, bool): + raise _error(2, "{} must be an integer".format(name)) + try: + value = value.__index__() + except Exception: + raise _error(2, "{} must be an integer".format(name)) from None + if isinstance(value, bool) or not isinstance(value, int): + raise _error(2, "{} must be an integer".format(name)) + minimum = -(1 << (bits - 1)) if signed else 0 + maximum = (1 << (bits - int(signed))) - 1 + if not minimum <= value <= maximum: + raise _error(2, "{} must be an integer between {} and {}".format(name, minimum, maximum)) + return value + + +def _optional(value, name="timeout_ms", bits=64): + return abi.TuiOptionalU64( + value is not None, 0 if value is None else _integer(value, name, bits) + ) + + +def _boolean(value): + return abi.TuiOptionalBool(value is not None, False if value is None else value) + + +def _float(value): + return abi.TuiOptionalF64(value is not None, 0.0 if value is None else value) + + +def _string(value): + if value is None: + return abi.TuiString() + if not isinstance(value, str): + raise TypeError("expected a string") + encoded = value.encode("utf-8") + # The extra byte keeps an explicit empty string distinct from NULL. + buffer = C.create_string_buffer(encoded) + return abi.TuiString(C.cast(buffer, C.POINTER(C.c_uint8)), len(encoded)) + + +def _strings(values): + return (abi.TuiString * len(values))(*(_string(value) for value in values)) + + +def _pairs(values): + return (abi.TuiPair * len(values))(*( + abi.TuiPair(_string(key), _string(value)) for key, value in values + )) + + +def _text(value): + if not value.data: + return None + return C.string_at(value.data, value.len).decode("utf-8") + + +def _copy(value): + """Copy every borrowed field before the result allocation is released.""" + if isinstance(value, abi.TuiString): + return _text(value) + if isinstance(value, (abi.TuiOptionalU64, abi.TuiOptionalI32, + abi.TuiOptionalBool, abi.TuiOptionalF64)): + return value.value if value.present else None + if isinstance(value, abi.TuiColor): + if value.kind == 0: + return "default" + if value.kind == 1: + return value.index + return "#{:02x}{:02x}{:02x}".format(value.red, value.green, value.blue) + result = {} + fields = {name for name, _ in value._fields_} + for name, _ in value._fields_: + if name.endswith("_len"): + continue + item = getattr(value, name) + if name + "_len" in fields: + item = [_copy(item[index]) for index in range(getattr(value, name + "_len"))] + elif isinstance(item, C.Structure): + item = _copy(item) + result["char" if name == "character" else name] = item + if isinstance(value, abi.TuiState): + result["modes"] = {entry["name"]: entry["enabled"] for entry in result["modes"]} + if isinstance(value, abi.TuiTerminalColors): + result["palette"] = {int(entry["key"]): entry["value"] for entry in result["palette"]} + return result + + +def _result(result, kind): + if kind is None: + return None + if kind == "packed": + return (memoryview(C.string_at(result.text.data, result.text.len)), + result.size.cols, result.size.rows) + if kind in ("cells", "matches", "bells", "strings"): + values = getattr(result, kind) + return [_copy(values[index]) for index in range(getattr(result, kind + "_len"))] + if kind == "snapshot": + return ("passed", "written", "updated")[result.snapshot] + value = getattr(result, kind) + return _copy(value) if isinstance(value, C.Structure) else value + + +def _invoke(name, args, kind=None): + library = _load() + pointer = getattr(library, "tui_" + name)(*args) + if not pointer: + raise _error(5, "native operation returned a null result") + try: + result = pointer.contents + if result.error_kind: + details = _text(result.error_details) + artifact = _text(result.error_artifact) + raise _error( + result.error_kind, _text(result.error_message), + json.loads(details) if details is not None else None, + json.loads(artifact) if artifact is not None else None, + ) + return _result(result, kind) + finally: + library.tui_result_free(pointer) + + +async def _background(function): + loop = asyncio.get_running_loop() + future = loop.create_future() + + def deliver(value, error): + if not future.done(): + if error is None: + future.set_result(value) + else: + future.set_exception(error) + + def worker(): + value, error = None, None + try: + value = function() + except Exception as caught: + error = caught + try: + loop.call_soon_threadsafe(deliver, value, error) + except RuntimeError: + pass # The event loop can close before a cancelled wait returns. + + # A blocked wait must never consume the last worker needed by close(). + threading.Thread(target=worker, name="tui-test-ffi", daemon=True).start() + return await future + + +def _wait(timeout_ms, regex=False, not_=False): + return abi.TuiWaitOptions(_optional(timeout_ms), regex, not_) + + +def _mouse(button): + # The existing Python API uses terminal button codes, including modifiers. + code = _integer(button, "button", 8) + if code & ~31 or code & 3 == 3: + raise _error(2, "invalid mouse button code {}".format(code)) + return abi.TuiMouseOptions(code & 3, bool(code & 8), bool(code & 16), bool(code & 4)) + + +def _query(expression): + expression = dict(expression) + nodes = [] + for source in expression["nodes"]: + node = dict(source) + nth = node.pop("nth", None) + occurrence = node.get("occurrence", "any") + if occurrence == "nth": + occurrence = {"nth": _integer(nth, "nth", C.sizeof(C.c_size_t) * 8)} + node["occurrence"] = occurrence + nodes.append(node) + expression["nodes"] = nodes + return abi.TuiQuery(_string(json.dumps(expression))) + + +class NativeSession: + def __init__(self, name, recording_directory=None, artifact_directory=None, + artifact_mode=None, artifact_include_recording=False, + trace_mode=None, trace_directory=None): + self.name = name + self._recording_directory = recording_directory + self._context = dict( + name=name, artifact_directory=artifact_directory, artifact_mode=artifact_mode, + artifact_include_recording=artifact_include_recording, + trace_mode=trace_mode, trace_directory=trace_directory, + ) + + def _session(self, screen_history_limit=None): + context = dict(self._context) + if screen_history_limit is not None: + context["screen_history_limit"] = _integer( + screen_history_limit, "screen_history_limit", 16 + ) + return _string(json.dumps(context)) + + async def _call(self, name, *args, **kwargs): + return await _background( + lambda: _invoke(name, (self._session(),) + args, kwargs.get("kind")) + ) + + def _open_options(self, shell, backend, cols, rows, cwd, env, wait_ready, + restart, profile_scrollback, profile_colors, text_timeout, + idle_timeout, command_timeout, exit_timeout, ready_timeout): + return abi.TuiOpenOptions( + _string(backend), _string(shell), + _optional(_integer(cols, "cols", 16)), _optional(_integer(rows, "rows", 16)), + _string(cwd), _pairs(env), len(env), _boolean(wait_ready), restart, + _optional(profile_scrollback, "scrollback"), _pairs(profile_colors), + len(profile_colors), abi.TuiTimeouts(*( + _optional(value, name + "_timeout") for name, value in zip( + ("text", "idle", "command", "exit", "ready"), + (text_timeout, idle_timeout, command_timeout, exit_timeout, ready_timeout) + ) + )), _string(None), _string(self._recording_directory), + ) + + async def open(self, shell, backend, cols, rows, cwd, env, wait_ready, restart, + profile_scrollback, profile_colors, text_timeout, idle_timeout, + command_timeout, exit_timeout, ready_timeout, screen_history_limit): + options = self._open_options( + shell, backend, cols, rows, cwd, env, wait_ready, restart, + profile_scrollback, profile_colors, text_timeout, idle_timeout, + command_timeout, exit_timeout, ready_timeout, + ) + session = self._session(screen_history_limit) + return await _background( + lambda: _invoke("open_ptr", (session, C.byref(options)), "open"), + ) + + async def run(self, program, args, backend, cols, rows, cwd, env, wait_ready, restart, + profile_scrollback, profile_colors, text_timeout, idle_timeout, + command_timeout, exit_timeout, ready_timeout, screen_history_limit): + options = self._open_options( + None, backend, cols, rows, cwd, env, wait_ready, restart, + profile_scrollback, profile_colors, text_timeout, idle_timeout, + command_timeout, exit_timeout, ready_timeout, + ) + session = self._session(screen_history_limit) + arguments = (session, C.byref(options), _string(program), _strings(args), len(args)) + return await _background( + lambda: _invoke("run_ptr", arguments, "open"), + ) + + async def restart(self, graceful_timeout_ms): + timeout = _integer(graceful_timeout_ms, "graceful_timeout_ms") + session = self._session() + return await _background( + lambda: _invoke("restart", (session, timeout), "open"), + ) + + async def close(self, failed=None): + return await self._call("close", _boolean(failed)) + + async def state(self): + return await self._call("state", kind="state") + + async def text(self, full): + return await self._call("text", full, kind="text") + + async def packed_screen(self, full): + return await self._call("packed_screen", full, kind="packed") + + async def cells(self, x, y, w, h): + return await self._call("cells", *( + _integer(value, name, 16) for name, value in zip(("x", "y", "w", "h"), (x, y, w, h)) + ), kind="cells") + + async def find_locator(self, expression, require_one): + return await self._call("find_locator", _query(expression), require_one, kind="matches") + + async def wait_locator(self, expression, not_, timeout_ms): + return await self._call("wait_locator", _query(expression), _wait(timeout_ms, not_=not_)) + + async def expect_locator(self, expression, not_, timeout_ms): + return await self._call("expect_locator", _query(expression), _wait(timeout_ms, not_=not_)) + + async def click_locator(self, expression, button, clicks, timeout_ms): + return await self._call("click_locator", _query(expression), _mouse(button), + _integer(clicks, "clicks", 8), _optional(timeout_ms)) + + async def highlight_locator(self, expression, timeout_ms): + return await self._call("highlight_locator", _query(expression), _optional(timeout_ms), + kind="matches") + + async def get_command(self): + return await self._call("get_command", kind="text") + + async def get_output(self): + return await self._call("get_output", kind="text") + + async def get_exit_code(self): + return await self._call("get_exit_code", kind="exit_code") + + async def get_cwd(self): + return await self._call("get_cwd", kind="text") + + async def get_title(self): + return await self._call("get_title", kind="text") + + async def get_clipboard(self): + return await self._call("get_clipboard", kind="text") + + async def get_cursor(self): + return await self._call("get_cursor", kind="cursor") + + async def get_size(self): + return await self._call("get_size", kind="size") + + async def get_bell_count(self): + return await self._call("get_bell_count", kind="number") + + async def get_bell_events(self): + return await self._call("get_bell_events", kind="bells") + + async def write(self, data): + return await self._call("write", _string(data)) + + async def type(self, text): + return await self._call("type", _string(text)) + + async def submit(self, data): + return await self._call("submit", _string(data)) + + async def _key(self, keys, action): + return await self._call("key", _strings(keys), len(keys), action) + + async def press(self, keys): + return await self._key(keys, 0) + + async def key_down(self, keys): + return await self._key(keys, 1) + + async def repeat(self, keys): + return await self._key(keys, 2) + + async def key_up(self, keys): + return await self._key(keys, 3) + + async def mouse_click(self, x, y, on_text, button, clicks): + return await self._call("mouse_click", _optional(x, "x", 16), _optional(y, "y", 16), + _string(on_text), _mouse(button), _integer(clicks, "clicks", 8)) + + async def mouse_move(self, x, y): + return await self._call("mouse_move", _integer(x, "x", 16), _integer(y, "y", 16)) + + async def mouse_down(self, x, y, button): + return await self._call("mouse_down", _integer(x, "x", 16), _integer(y, "y", 16), + _mouse(button)) + + async def mouse_up(self, x, y, button): + return await self._call("mouse_up", _integer(x, "x", 16), _integer(y, "y", 16), + _mouse(button)) + + async def mouse_drag(self, x1, y1, x2, y2, button): + coordinates = [_integer(value, name, 16) for name, value in zip( + ("x1", "y1", "x2", "y2"), (x1, y1, x2, y2) + )] + return await self._call("mouse_drag", *coordinates, _mouse(button)) + + async def mouse_scroll(self, direction, amount): + return await self._call("mouse_scroll", _string(direction), _integer(amount, "amount", 16)) + + async def resize(self, cols, rows): + return await self._call("resize", _integer(cols, "cols", 16), _integer(rows, "rows", 16)) + + async def signal(self, signal): + return await self._call("signal", _string(signal)) + + async def kill(self): + return await self.signal("KILL") + + async def wait_title(self, text, regex, not_, timeout_ms): + return await self._call("wait_title", _string(text), _wait(timeout_ms, regex, not_)) + + async def wait_clipboard(self, text, regex, timeout_ms): + return await self._call("wait_clipboard", _string(text), _wait(timeout_ms, regex)) + + async def wait_idle(self, timeout_ms): + return await self._call("wait_idle", _optional(timeout_ms)) + + async def wait_command(self, timeout_ms): + return await self._call("wait_command", _optional(timeout_ms)) + + async def wait_exit(self, timeout_ms): + return await self._call("wait_exit", _optional(timeout_ms)) + + async def wait_ready(self, timeout_ms): + return await self._call("wait_ready", _optional(timeout_ms)) + + async def wait_bell(self, timeout_ms): + return await self._call("wait_bell", _optional(timeout_ms)) + + async def expect_title(self, text, regex, not_, timeout_ms): + return await self._call("expect_title", _string(text), _wait(timeout_ms, regex, not_)) + + async def expect_exit_code(self, code, timeout_ms): + return await self._call("expect_exit_code", _integer(code, "code", 32, signed=True), + _optional(timeout_ms)) + + async def expect_output(self, text, regex): + return await self._call("expect_output", _string(text), regex) + + async def expect_bell_count(self, count, timeout_ms): + return await self._call("expect_bell_count", _integer(count, "count"), _optional(timeout_ms)) + + async def snapshot(self, name, update, include_style, include_title, cwd): + return await self._call("snapshot", _string(name), update, include_style, + include_title, _string(cwd), kind="snapshot") + + async def screenshot(self, path, full, zoom=None, background=None, transparent=False): + return await self._call("screenshot", full, _string(path), _float(zoom), + _string(background), transparent, kind="text") + + async def start_recording(self, path, format, fps, speed, idle_time_limit, zoom=None, + background=None, transparent=False): + options = abi.TuiRecordingOptions( + _string(path), _string(format), _optional(fps, "fps", 8), _float(speed), + _float(idle_time_limit), _float(zoom), _string(background), transparent, + ) + return await self._call("start_recording", options) + + async def stop_recording(self): + return await self._call("stop_recording", kind="text") + + async def recording(self): + return await recording(self.name) + + +async def sessions(): + return await _background(lambda: _invoke("sessions", (), "strings")) + + +async def close_all(): + return await _background(lambda: _invoke("close_all", ())) + + +def _close_all_blocking(): + if _library is not None: + _invoke("close_all", ()) + + +async def recording(name): + return await _background(lambda: _invoke("recording", (_string(name),), "text")) diff --git a/bindings/python/src/tui_test/_native.pyi b/bindings/python/src/tui_test/_native.pyi index 25dff7d5..1639eb61 100644 --- a/bindings/python/src/tui_test/_native.pyi +++ b/bindings/python/src/tui_test/_native.pyi @@ -1,4 +1,4 @@ -# This file is automatically generated by pyo3_stub_gen +# Private adapter types. The public API types live in tui_test. # ruff: noqa: E501, F401, F403, F405 import builtins @@ -11,7 +11,6 @@ __all__ = [ "NativeSession", "NativeUsageError", "close_all", - "panic_probe", "recording", "sessions", ] @@ -108,8 +107,6 @@ def _close_all_blocking() -> None: ... def close_all() -> typing.Awaitable[None]: ... -def panic_probe() -> typing.Awaitable[None]: ... - def recording(name: str) -> typing.Awaitable[str]: ... def sessions() -> typing.Awaitable[typing.List[str]]: ... diff --git a/bindings/python/src/tui_test/client.py b/bindings/python/src/tui_test/client.py index 5b39bef7..34be866f 100644 --- a/bindings/python/src/tui_test/client.py +++ b/bindings/python/src/tui_test/client.py @@ -1231,7 +1231,3 @@ async def get_recording(session: Optional[str] = None) -> str: return await _await_native(native.recording(name)) except NoSessionError as error: raise NoSessionError(f"no recording for session '{name}'") from error - - -async def _panic_probe() -> None: - await _await_native(native.panic_probe()) diff --git a/bindings/python/src/tui_test/py.typed b/bindings/python/src/tui_test/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/stub-gen/.gitignore b/bindings/python/stub-gen/.gitignore deleted file mode 100644 index b83d2226..00000000 --- a/bindings/python/stub-gen/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/bindings/python/stub-gen/Cargo.lock b/bindings/python/stub-gen/Cargo.lock deleted file mode 100644 index e9bea8c5..00000000 --- a/bindings/python/stub-gen/Cargo.lock +++ /dev/null @@ -1,1003 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "is-macro" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lalrpop-util" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pyo3" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc153f5fd745cc038b5eed86622125969f8a39834a57bc96beaaf2512b1da729" -dependencies = [ - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-build-config" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb77d9aa6d647507b55c69ee714d266d84c526c78ff0bc6dd8757f58591e64d1" -dependencies = [ - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3160087aa5733bce7d9a729f8c55f85a2a53b74742a09613178eeebff0722253" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91f9d455db760a9a0b0ddeaac25f1390b8a36ba73dfbda9f127cac6fc340d4d5" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e343bcec300ff262f5806a33a4e51b6d097a8a46435f512fcb83e95592581625" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pyo3-stub-gen" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a267ea4da9a831f534def7f1d8e14f581d5640d3e5af6527072724f216f4dbbf" -dependencies = [ - "anyhow", - "chrono", - "indexmap", - "inventory", - "itertools 0.14.0", - "log", - "maplit", - "num-complex", - "pyo3", - "pyo3-stub-gen-derive", - "rustpython-parser", - "serde", - "serde_json", - "time", - "toml", -] - -[[package]] -name = "pyo3-stub-gen-derive" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6573423a5e8cc43ec7565eccccbc2dd35e1fc9032d9ca404afab875429eb8cf0" -dependencies = [ - "heck", - "indexmap", - "proc-macro2", - "quote", - "rustpython-parser", - "syn 2.0.119", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustpython-ast" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" -dependencies = [ - "is-macro", - "num-bigint", - "rustpython-parser-core", - "static_assertions", -] - -[[package]] -name = "rustpython-parser" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" -dependencies = [ - "anyhow", - "is-macro", - "itertools 0.11.0", - "lalrpop-util", - "log", - "num-bigint", - "num-traits", - "phf", - "phf_codegen", - "rustc-hash", - "rustpython-ast", - "rustpython-parser-core", - "tiny-keccak", - "unic-emoji-char", - "unic-ucd-ident", - "unicode_names2", -] - -[[package]] -name = "rustpython-parser-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" -dependencies = [ - "is-macro", - "memchr", - "rustpython-parser-vendored", -] - -[[package]] -name = "rustpython-parser-vendored" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" -dependencies = [ - "memchr", - "once_cell", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] -name = "tui-test-python-stub-gen" -version = "0.0.0" -dependencies = [ - "pyo3-stub-gen", - "pyo3-stub-gen-derive", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-emoji-char" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode_names2" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" -dependencies = [ - "phf", - "unicode_names2_generator", -] - -[[package]] -name = "unicode_names2_generator" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" -dependencies = [ - "getopts", - "log", - "phf_codegen", - "rand", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bindings/python/stub-gen/Cargo.toml b/bindings/python/stub-gen/Cargo.toml deleted file mode 100644 index 37d0f95e..00000000 --- a/bindings/python/stub-gen/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "tui-test-python-stub-gen" -version = "0.0.0" -edition = "2021" -rust-version = "1.88" -publish = false - -[workspace] -resolver = "2" - -[dependencies] -pyo3-stub-gen = { version = "0.23", default-features = false } -pyo3-stub-gen-derive = "0.23" diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs deleted file mode 100644 index 585b85af..00000000 --- a/bindings/python/stub-gen/src/main.rs +++ /dev/null @@ -1,337 +0,0 @@ -mod stubs { - use std::any::TypeId; - use std::error::Error; - use std::fs; - use std::io; - use std::path::{Path, PathBuf}; - - use pyo3_stub_gen::type_info::{MemberInfo, PyClassInfo}; - use pyo3_stub_gen::TypeInfo; - - struct NativeSession; - struct NativeAssertionError; - struct NativeUsageError; - struct NativeNoSessionError; - struct NativeInternalError; - - fn builtins_exception() -> TypeInfo { - TypeInfo::builtin("Exception") - } - - fn builtins_str() -> TypeInfo { - TypeInfo::builtin("str") - } - - pyo3_stub_gen::inventory::submit! { - PyClassInfo { - struct_id: TypeId::of::, - pyclass_name: "NativeSession", - module: Some("tui_test._native"), - doc: "", - getters: &[MemberInfo { - name: "name", - r#type: builtins_str, - doc: "", - default: None, - deprecated: None, - }], - setters: &[], - bases: &[], - has_eq: false, - has_ord: false, - has_hash: false, - has_str: false, - subclass: false, - } - } - - macro_rules! submit_exception_stub { - ($exception:ty, $name:literal, $doc:literal) => { - pyo3_stub_gen::inventory::submit! { - PyClassInfo { - struct_id: TypeId::of::<$exception>, - pyclass_name: $name, - module: Some("tui_test._native"), - doc: $doc, - getters: &[], - setters: &[], - bases: &[builtins_exception], - has_eq: false, - has_ord: false, - has_hash: false, - has_str: false, - subclass: true, - } - } - }; - } - - submit_exception_stub!( - NativeAssertionError, - "NativeAssertionError", - "Native assertion failure." - ); - submit_exception_stub!(NativeUsageError, "NativeUsageError", "Native usage error."); - submit_exception_stub!( - NativeNoSessionError, - "NativeNoSessionError", - "Native session was not found." - ); - submit_exception_stub!( - NativeInternalError, - "NativeInternalError", - "Native internal error." - ); - - pyo3_stub_gen::inventory::submit! { - pyo3_stub_gen_derive::gen_methods_from_python! { - r#" - import typing - import tui_test - - class NativeSession: - def __new__( - cls, - name: str, - recording_directory: typing.Optional[str] = None, - artifact_directory: typing.Optional[str] = None, - artifact_mode: typing.Optional[str] = None, - artifact_include_recording: bool = False, - trace_mode: typing.Optional[str] = None, - trace_directory: typing.Optional[str] = None, - ) -> NativeSession: ... - - def open( - self, - shell: typing.Optional[str], - backend: typing.Optional[str], - cols: int, - rows: int, - cwd: typing.Optional[str], - env: typing.List[typing.Tuple[str, str]], - wait_ready: typing.Optional[bool], - restart: bool, - profile_scrollback: typing.Optional[int], - profile_colors: typing.List[typing.Tuple[str, str]], - text_timeout: typing.Optional[int], - idle_timeout: typing.Optional[int], - command_timeout: typing.Optional[int], - exit_timeout: typing.Optional[int], - ready_timeout: typing.Optional[int], - screen_history_limit: typing.Optional[int], - ) -> typing.Awaitable[tui_test.OpenResult]: ... - - def run( - self, - program: str, - args: typing.List[str], - backend: typing.Optional[str], - cols: int, - rows: int, - cwd: typing.Optional[str], - env: typing.List[typing.Tuple[str, str]], - wait_ready: typing.Optional[bool], - restart: bool, - profile_scrollback: typing.Optional[int], - profile_colors: typing.List[typing.Tuple[str, str]], - text_timeout: typing.Optional[int], - idle_timeout: typing.Optional[int], - command_timeout: typing.Optional[int], - exit_timeout: typing.Optional[int], - ready_timeout: typing.Optional[int], - screen_history_limit: typing.Optional[int], - ) -> typing.Awaitable[tui_test.OpenResult]: ... - - def restart(self, graceful_timeout_ms: int) -> typing.Awaitable[tui_test.OpenResult]: ... - def close(self, failed: typing.Optional[bool] = None) -> typing.Awaitable[None]: ... - def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... - def text(self, full: bool) -> typing.Awaitable[str]: ... - def find_locator(self, expression: typing.Dict[str, typing.Any], require_one: bool) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... - def wait_locator(self, expression: typing.Dict[str, typing.Any], not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def click_locator(self, expression: typing.Dict[str, typing.Any], button: int, clicks: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def highlight_locator(self, expression: typing.Dict[str, typing.Any], timeout_ms: typing.Optional[int]) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... - def expect_locator(self, expression: typing.Dict[str, typing.Any], not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def packed_screen(self, full: bool) -> typing.Awaitable[typing.Tuple[memoryview, int, int]]: - """Return immutable UTF-8 logical rows plus cell dimensions.""" - def cells(self, x: int, y: int, w: int, h: int) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... - def get_command(self) -> typing.Awaitable[typing.Optional[str]]: ... - def get_output(self) -> typing.Awaitable[typing.Optional[str]]: ... - def get_exit_code(self) -> typing.Awaitable[typing.Optional[int]]: ... - def get_cwd(self) -> typing.Awaitable[typing.Optional[str]]: ... - def get_title(self) -> typing.Awaitable[typing.Optional[str]]: ... - def get_clipboard(self) -> typing.Awaitable[str]: ... - def get_cursor(self) -> typing.Awaitable[tui_test.Cursor]: ... - def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... - def get_bell_count(self) -> typing.Awaitable[int]: ... - def get_bell_events(self) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... - def write(self, data: str) -> typing.Awaitable[None]: ... - def type(self, text: str) -> typing.Awaitable[None]: ... - def submit(self, data: typing.Optional[str]) -> typing.Awaitable[None]: ... - def press(self, keys: typing.List[str]) -> typing.Awaitable[None]: ... - def key_down(self, keys: typing.List[str]) -> typing.Awaitable[None]: ... - def repeat(self, keys: typing.List[str]) -> typing.Awaitable[None]: ... - def key_up(self, keys: typing.List[str]) -> typing.Awaitable[None]: ... - def mouse_click( - self, - x: typing.Optional[int], - y: typing.Optional[int], - on_text: typing.Optional[str], - button: int, - clicks: int, - ) -> typing.Awaitable[None]: ... - def mouse_move(self, x: int, y: int) -> typing.Awaitable[None]: ... - def mouse_down(self, x: int, y: int, button: int) -> typing.Awaitable[None]: ... - def mouse_up(self, x: int, y: int, button: int) -> typing.Awaitable[None]: ... - def mouse_drag(self, x1: int, y1: int, x2: int, y2: int, button: int) -> typing.Awaitable[None]: ... - def mouse_scroll(self, direction: str, amount: int) -> typing.Awaitable[None]: ... - def resize(self, cols: int, rows: int) -> typing.Awaitable[None]: ... - def signal(self, signal: str) -> typing.Awaitable[None]: ... - def kill(self) -> typing.Awaitable[None]: ... - def wait_title( - self, - text: str, - regex: bool, - not_: bool, - timeout_ms: typing.Optional[int], - ) -> typing.Awaitable[None]: ... - def wait_clipboard( - self, - text: typing.Optional[str], - regex: bool, - timeout_ms: typing.Optional[int], - ) -> typing.Awaitable[None]: ... - def wait_idle(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def wait_command(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def wait_bell(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def expect_title( - self, - text: str, - regex: bool, - not_: bool, - timeout_ms: typing.Optional[int], - ) -> typing.Awaitable[None]: ... - def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... - def expect_bell_count(self, count: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... - def snapshot( - self, - name: str, - update: bool, - include_style: bool, - include_title: bool, - cwd: typing.Optional[str], - ) -> typing.Awaitable[str]: ... - def screenshot( - self, - path: typing.Optional[str], - full: bool, - zoom: typing.Optional[float], - background: typing.Optional[str], - transparent: bool, - ) -> typing.Awaitable[str]: ... - def start_recording( - self, - path: str, - format: typing.Optional[str], - fps: typing.Optional[int], - speed: typing.Optional[float], - idle_time_limit: typing.Optional[float], - zoom: typing.Optional[float], - background: typing.Optional[str], - transparent: bool, - ) -> typing.Awaitable[None]: ... - def stop_recording(self) -> typing.Awaitable[str]: ... - def recording(self) -> typing.Awaitable[str]: ... - "# - } - } - - macro_rules! submit_function_stub { - ($source:literal) => { - pyo3_stub_gen::inventory::submit! { - pyo3_stub_gen_derive::gen_function_from_python! { - module = "tui_test._native", - $source - } - } - }; - } - - submit_function_stub!( - r#" - import typing - def sessions() -> typing.Awaitable[typing.List[str]]: ... - "# - ); - submit_function_stub!( - r#" - import typing - def close_all() -> typing.Awaitable[None]: ... - "# - ); - submit_function_stub!( - r#" - import typing - def recording(name: str) -> typing.Awaitable[str]: ... - "# - ); - submit_function_stub!( - r#" - import typing - def panic_probe() -> typing.Awaitable[None]: ... - "# - ); - submit_function_stub!( - r#" - def _close_all_blocking() -> None: ... - "# - ); - - pub fn run(check_only: bool) -> Result<(), Box> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let pyproject = manifest_dir.join("../pyproject.toml"); - let destination = manifest_dir.join("../src/tui_test/_native.pyi"); - let stub_info = pyo3_stub_gen::StubInfo::from_pyproject_toml(pyproject)?; - let module = stub_info.modules.get("tui_test._native").ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "missing tui_test._native stub metadata", - ) - })?; - let generated = format!( - "{}\n", - module - .format_with_config(stub_info.config.use_type_statement) - .trim_end() - ); - - if check_only { - check(&destination, &generated)?; - } else { - fs::write(&destination, generated)?; - } - - Ok(()) - } - - fn check(destination: &Path, generated: &str) -> Result<(), Box> { - let current = fs::read_to_string(destination)?; - if current != generated { - return Err(format!( - "{} is out of date; run `python scripts/generate_stubs.py` from bindings/python", - destination.display() - ) - .into()); - } - Ok(()) - } -} - -fn main() { - let check_only = std::env::args().skip(1).any(|arg| arg == "--check"); - if let Err(error) = stubs::run(check_only) { - panic!("{error}"); - } -} diff --git a/bindings/python/tests/test_ctypes_adapter.py b/bindings/python/tests/test_ctypes_adapter.py new file mode 100644 index 00000000..0d9fd5f3 --- /dev/null +++ b/bindings/python/tests/test_ctypes_adapter.py @@ -0,0 +1,236 @@ +import asyncio +import ctypes as C +import json +import threading +import unittest +from unittest.mock import Mock, patch + +from tui_test import _abi as abi +from tui_test import _native as native + + +class OwnershipTests(unittest.TestCase): + def test_null_and_empty_strings_remain_distinct(self): + missing, empty = native._string(None), native._string("") + self.assertFalse(missing.data) + self.assertTrue(empty.data) + self.assertEqual(empty.len, 0) + self.assertIsNone(native._text(missing)) + self.assertEqual(native._text(empty), "") + + def test_result_is_freed_after_success_error_and_conversion_failure(self): + for code, text, exception in ( + (0, "copied", None), + (2, "bad argument", native.NativeUsageError), + (0, "copied", IndexError), + ): + with self.subTest(code=code, exception=exception): + result = abi.TuiResult() + result.error_kind = code + result.error_message = native._string(text) + result.text = native._string(text) + result.snapshot = 99 + pointer = C.pointer(result) + library = Mock() + library.tui_text.return_value = pointer + with patch.object(native, "_load", return_value=library): + kind = "snapshot" if exception is IndexError else "text" + if exception: + with self.assertRaises(exception): + native._invoke("text", (), kind) + else: + self.assertEqual(native._invoke("text", (), kind), "copied") + library.tui_result_free.assert_called_once_with(pointer) + + def test_packed_screen_owns_immutable_copied_bytes(self): + result = abi.TuiResult() + result.text = native._string("UTF-8: café\n") + result.size = abi.TuiSize(80, 24) + library = Mock() + library.tui_packed_screen.return_value = C.pointer(result) + + def overwrite(pointer): + C.memset(pointer.contents.text.data, ord("x"), pointer.contents.text.len) + + library.tui_result_free.side_effect = overwrite + with patch.object(native, "_load", return_value=library): + screen, cols, rows = native._invoke("packed_screen", (), "packed") + self.assertEqual((cols, rows), (80, 24)) + self.assertTrue(screen.readonly) + self.assertEqual(screen.tobytes(), "UTF-8: café\n".encode()) + + def test_error_details_and_artifact_survive_result_release(self): + result = abi.TuiResult() + result.error_kind = 1 + result.error_message = native._string("not found") + result.error_details = native._string('{"reason":"timeout"}') + result.error_artifact = native._string('{"directory":"artifacts"}') + library = Mock() + library.tui_expect_locator.return_value = C.pointer(result) + with patch.object(native, "_load", return_value=library): + with self.assertRaises(native.NativeAssertionError) as caught: + native._invoke("expect_locator", ()) + envelope = json.loads(caught.exception._tui_test_error_json) + self.assertEqual(envelope["details"], {"reason": "timeout"}) + self.assertEqual(envelope["artifact"], {"directory": "artifacts"}) + library.tui_result_free.assert_called_once() + + def test_abi_version_mismatch_is_structured(self): + library = Mock() + library.tui_abi_version.return_value = abi.ABI_VERSION + 1 + with patch.object(native, "_library", None), patch.object(C, "CDLL", return_value=library): + with self.assertRaisesRegex(native.NativeInternalError, "ABI version mismatch"): + native._load() + + def test_locator_translation_does_not_mutate_input(self): + expression = { + "nodes": [{"kind": "text", "text": "x", "occurrence": "nth", "nth": 2}], + "root": 0, + } + query = native._query(expression) + self.assertEqual(expression["nodes"][0]["nth"], 2) + self.assertEqual(expression["nodes"][0]["occurrence"], "nth") + self.assertEqual(json.loads(native._text(query.json))["nodes"][0]["occurrence"], {"nth": 2}) + + def test_integer_validation_precedes_narrowing(self): + class BooleanIndex: + def __index__(self): + return True + + for value in (-1, 65536, True, 1.5, BooleanIndex()): + with self.subTest(value=value): + with self.assertRaises(native.NativeUsageError): + native._integer(value, "cols", 16) + + +class WorkerTests(unittest.TestCase): + def test_waits_do_not_starve_close(self): + async def scenario(): + release = threading.Event() + started = 0 + lock = threading.Lock() + all_started = asyncio.get_running_loop().create_future() + loop = asyncio.get_running_loop() + + def wait(): + nonlocal started + with lock: + started += 1 + if started == 40: + loop.call_soon_threadsafe(all_started.set_result, None) + if not release.wait(10): + raise AssertionError("close was starved") + + waits = [asyncio.create_task(native._background(wait)) for _ in range(40)] + try: + await asyncio.wait_for(all_started, 5) + await asyncio.wait_for(native._background(release.set), 2) + await asyncio.gather(*waits) + finally: + release.set() + await asyncio.gather(*waits, return_exceptions=True) + + asyncio.run(scenario()) + + def test_cancelled_spawn_does_not_close_replacement_session(self): + async def scenario(method): + loop = asyncio.get_running_loop() + started = loop.create_future() + release = threading.Event() + workers = [] + thread_class = threading.Thread + generation = 0 + current = None + + def thread(*args, **kwargs): + worker = thread_class(*args, **kwargs) + workers.append(worker) + return worker + + def invoke(name, args, kind=None): + nonlocal generation, current + if name == "close": + current = None + elif name in ("open_ptr", "run_ptr", "restart"): + generation += 1 + current = generation + result = {"generation": generation} + if generation == 1: + loop.call_soon_threadsafe(started.set_result, None) + if not release.wait(10): + raise AssertionError("spawn did not finish") + return result + + session = native.NativeSession("same-name") + options = (None, 80, 24, None, [], None, False, None, [], + None, None, None, None, None, None) + calls = { + "open": lambda: session.open("bash", *options), + "run": lambda: session.run("program", [], *options), + "restart": lambda: session.restart(0), + } + with patch.object(native, "_invoke", side_effect=invoke), \ + patch.object(native.threading, "Thread", side_effect=thread): + task = asyncio.create_task(calls[method]()) + try: + await asyncio.wait_for(started, 5) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + await session.close() + await calls[method]() + self.assertEqual(current, 2) + release.set() + for worker in workers: + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(current, 2) + finally: + release.set() + for worker in workers: + worker.join(5) + + for method in ("open", "run", "restart"): + with self.subTest(method=method): + asyncio.run(scenario(method)) + + def test_cancelled_call_still_frees_its_result_once(self): + async def scenario(): + loop = asyncio.get_running_loop() + started = loop.create_future() + release = threading.Event() + workers = [] + result = abi.TuiResult() + result.text = native._string("copied") + pointer = C.pointer(result) + library = Mock() + + def text(*args): + workers.append(threading.current_thread()) + loop.call_soon_threadsafe(started.set_result, None) + if not release.wait(10): + raise AssertionError("native call did not finish") + return pointer + + library.tui_text.side_effect = text + try: + with patch.object(native, "_load", return_value=library): + task = asyncio.create_task(native.NativeSession("cancelled").text(False)) + await asyncio.wait_for(started, 5) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + release.set() + workers[0].join(5) + self.assertFalse(workers[0].is_alive()) + library.tui_result_free.assert_called_once_with(pointer) + finally: + release.set() + for worker in workers: + worker.join(5) + + asyncio.run(scenario()) + + +if __name__ == "__main__": + unittest.main() diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index 8a57d30b..c451c917 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -9,6 +9,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch import tui_test from tui_test import ( @@ -28,9 +29,9 @@ testing, unique_session, ) -from tui_test.client import _panic_probe +from tui_test import _native -SHELL = "pwsh" if sys.platform == "win32" else None +SHELL = "pwsh" if sys.platform == "win32" else "bash" TWO_BELLS_COMMAND = ( "[Console]::Out.Write([char]7); [Console]::Out.Write([char]7)" if sys.platform == "win32" @@ -1078,11 +1079,12 @@ async def scenario(): run(scenario()) - def test_panic_probe_maps_to_internal_error_and_process_survives(self): + def test_worker_internal_error_preserves_later_operations(self): async def scenario(): - with self.assertRaises(InternalError) as raised: - await _panic_probe() - self.assertIn("panic probe", str(raised.exception)) + with patch.object(_native, "_invoke", side_effect=_native._error(5, "worker failure")): + with self.assertRaises(InternalError) as raised: + await self._client().state() + self.assertIn("worker failure", str(raised.exception)) async with self._client() as su: await su.open(shell=SHELL) self.assertGreater((await su.state()).cols, 0) diff --git a/bindings/go/native/Cargo.toml b/crates/tui-test-ffi/Cargo.toml similarity index 89% rename from bindings/go/native/Cargo.toml rename to crates/tui-test-ffi/Cargo.toml index a7c05476..9a5db00a 100644 --- a/bindings/go/native/Cargo.toml +++ b/crates/tui-test-ffi/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "tui-test-go" +name = "tui-test-ffi" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -8,7 +8,7 @@ repository.workspace = true publish = false [lib] -name = "tui_test_go" +name = "tui_test_ffi" crate-type = ["cdylib", "rlib"] [dependencies] diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md new file mode 100644 index 00000000..3ee6152d --- /dev/null +++ b/crates/tui-test-ffi/README.md @@ -0,0 +1,60 @@ +# Shared native library + +`tui-test-ffi` exposes the Rust engine through a typed C ABI. +Go uses `purego`, Python uses `ctypes`, and Node uses a small N-API bridge. +The Node bridge loads this library at runtime. It does not contain a second Rust engine. + +The library and language package must come from the same source revision. +This ABI is an internal contract, not a stable public C SDK. + +## Build and use + +From the repository root: + +```sh +cargo build --locked -p tui-test-ffi +``` + +The library is `target/debug/libtui_test_ffi.so` on Linux, `libtui_test_ffi.dylib` on macOS, or `tui_test_ffi.dll` on Windows. +Set `TUI_TEST_NATIVE_LIBRARY` to its absolute path to select it explicitly in any language. +Python wheels and Node platform packages also include the library. + +For example, run the Go smoke example on macOS: + +```sh +export TUI_TEST_NATIVE_LIBRARY="$PWD/target/debug/libtui_test_ffi.dylib" +cd bindings/go +CGO_ENABLED=0 go run ./examples/smoke +``` + +The example starts a child terminal, submits input, checks its output, and prints `Go binding smoke passed`. + +## Generate declarations + +The Rust declarations in `src/types.rs` and `src/lib.rs` are the source of truth. +The generator parses Rust syntax and rejects unsupported field types. It does not parse source with regular expressions. + +```sh +cargo run --locked -p tui-test-bindgen +cargo run --locked -p tui-test-bindgen -- --check +``` + +The generator uses `cbindgen` for `include/tui_test.h`. +It also generates the Go structs and function table, Python `ctypes` declarations, and Node function table. +Run the generator with Go and Rust formatters on `PATH`. + +The language adapters still own asynchronous execution, error presentation, input storage, and result conversion. +They do not implement terminal behavior. + +## Ownership + +Input buffers must remain valid until a call returns. The engine copies inputs before it executes an operation. +A null string pointer means absent. A non-null pointer with zero length means an explicit empty string. + +Every result belongs to this library until `tui_result_free` releases it. +Copy strings, arrays, and packed screen bytes before that call. Do not free individual fields. +The function called determines which result field contains the successful output. +Check `error_kind` before reading a successful result. + +Keep the library loaded while any call or allocation remains active. +Increase `ABI_VERSION` when a change makes existing declarations incompatible. diff --git a/crates/tui-test-ffi/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h new file mode 100644 index 00000000..bfa7bd77 --- /dev/null +++ b/crates/tui-test-ffi/include/tui_test.h @@ -0,0 +1,701 @@ +/* Generated by cargo run -p tui-test-bindgen. Do not edit. */ + +#ifndef TUI_TEST_FFI_H +#define TUI_TEST_FFI_H + +#include +#include +#include +#include + +#define ABI_VERSION 3 + +/** + * 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; + uintptr_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; + uintptr_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; + uintptr_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; + uintptr_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; + uintptr_t cells_len; + const struct TuiMatch *matches; + uintptr_t matches_len; + const struct TuiBellEvent *bells; + uintptr_t bells_len; + const struct TuiString *strings; + uintptr_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; + uintptr_t env_len; + struct TuiOptionalBool wait_ready; + bool restart; + struct TuiOptionalU64 scrollback; + const struct TuiPair *colors; + uintptr_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 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; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +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, + uintptr_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, + uintptr_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, + uintptr_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, + bool full, + struct TuiString path, + struct TuiOptionalF64 zoom, + struct TuiString background, + bool transparent); + +/** + * # 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); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* TUI_TEST_FFI_H */ diff --git a/bindings/go/native/src/input.rs b/crates/tui-test-ffi/src/input.rs similarity index 100% rename from bindings/go/native/src/input.rs rename to crates/tui-test-ffi/src/input.rs diff --git a/bindings/go/native/src/lib.rs b/crates/tui-test-ffi/src/lib.rs similarity index 98% rename from bindings/go/native/src/lib.rs rename to crates/tui-test-ffi/src/lib.rs index 152579d3..558985b2 100644 --- a/bindings/go/native/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -1,4 +1,4 @@ -//! Private typed C ABI for the Go binding. All borrowed input buffers must +//! Shared typed C ABI for language bindings. 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; @@ -9,12 +9,14 @@ use tui_test::api::*; use tui_test::runtime::global_registry; pub use types::*; +pub const ABI_VERSION: u32 = 3; + 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")), + Err(_) => output::error(TuiTestError::internal("native engine adapter panicked")), } } unsafe fn execute( @@ -45,7 +47,7 @@ fn finish_trace_and_close( } #[no_mangle] pub extern "C" fn tui_abi_version() -> u32 { - 3 + ABI_VERSION } #[no_mangle] /// # Safety @@ -138,7 +140,7 @@ pub extern "C" fn tui_sessions() -> *mut TuiResult { output::sessions(global_registry().sessions()) })) { Ok(value) => value, - Err(_) => output::error(TuiTestError::internal("native Go adapter panicked")), + Err(_) => output::error(TuiTestError::internal("native engine adapter panicked")), } } #[no_mangle] diff --git a/bindings/go/native/src/output.rs b/crates/tui-test-ffi/src/output.rs similarity index 100% rename from bindings/go/native/src/output.rs rename to crates/tui-test-ffi/src/output.rs diff --git a/bindings/go/native/src/tests.rs b/crates/tui-test-ffi/src/tests.rs similarity index 100% rename from bindings/go/native/src/tests.rs rename to crates/tui-test-ffi/src/tests.rs diff --git a/bindings/go/native/src/types.rs b/crates/tui-test-ffi/src/types.rs similarity index 100% rename from bindings/go/native/src/types.rs rename to crates/tui-test-ffi/src/types.rs diff --git a/tools/bindgen/Cargo.toml b/tools/bindgen/Cargo.toml new file mode 100644 index 00000000..c6c7f523 --- /dev/null +++ b/tools/bindgen/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tui-test-bindgen" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +cbindgen = "0.29" +quote = "1" +syn = { version = "2", features = ["full"] } + +[dev-dependencies] +tempfile = "3" diff --git a/tools/bindgen/src/main.rs b/tools/bindgen/src/main.rs new file mode 100644 index 00000000..eff5d443 --- /dev/null +++ b/tools/bindgen/src/main.rs @@ -0,0 +1,451 @@ +use anyhow::{bail, Context, Result}; +use quote::ToTokens; +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; +use syn::{FnArg, Item, ItemFn, ItemStruct, ReturnType, Type}; + +const NOTICE: &str = "Generated by cargo run -p tui-test-bindgen. Do not edit."; + +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let check = match args.as_slice() { + [] => false, + [arg] if arg == "--check" => true, + _ => bail!("usage: cargo run -p tui-test-bindgen -- [--check]"), + }; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source = root.join("crates/tui-test-ffi"); + let types_source = fs::read_to_string(source.join("src/types.rs"))?; + let types = syn::parse_file(&types_source)?; + let structs: Vec<_> = types + .items + .iter() + .filter_map(|item| match item { + Item::Struct(item) => Some(item), + _ => None, + }) + .collect(); + let library = syn::parse_file(&fs::read_to_string(source.join("src/lib.rs"))?)?; + let functions: Vec<_> = library + .items + .iter() + .filter_map(|item| match item { + Item::Fn(item) + if item + .attrs + .iter() + .any(|attr| attr.path().is_ident("no_mangle")) => + { + Some(item) + } + _ => None, + }) + .collect(); + let version = library + .items + .iter() + .find_map(|item| match item { + Item::Const(item) if item.ident == "ABI_VERSION" => { + Some(item.expr.to_token_stream().to_string()) + } + _ => None, + }) + .context("missing ABI_VERSION")?; + for function in &functions { + if function + .sig + .abi + .as_ref() + .and_then(|abi| abi.name.as_ref()) + .map(|name| name.value()) + != Some("C".into()) + { + bail!("{} does not use the C ABI", function.sig.ident); + } + } + + let mut header = Vec::new(); + cbindgen::Builder::new() + .with_crate(&source) + .with_language(cbindgen::Language::C) + .with_include_guard("TUI_TEST_FFI_H") + .with_cpp_compat(true) + .with_header(format!("/* {NOTICE} */")) + .generate() + .map_err(|error| anyhow::anyhow!("{error}"))? + .write(&mut header); + emit( + &root, + "crates/tui-test-ffi/include/tui_test.h", + &String::from_utf8(header)?, + check, + )?; + emit( + &root, + "bindings/go/internal/native/abi.go", + &format_source("gofmt", &[], &go_types(&structs, &version)?)?, + check, + )?; + emit( + &root, + "bindings/go/internal/native/functions.go", + &format_source("gofmt", &[], &go_functions(&functions)?)?, + check, + )?; + emit( + &root, + "bindings/python/src/tui_test/_abi.py", + &python(&structs, &functions, &version)?, + check, + )?; + emit( + &root, + "bindings/js/native/abi.rs", + &format_source( + "rustfmt", + &["--edition", "2021", "--emit", "stdout"], + &rust(&types_source, &functions, &version)?, + )?, + check, + )?; + Ok(()) +} + +fn emit(root: &Path, path: &str, content: &str, check: bool) -> Result<()> { + let destination = root.join(path); + if check { + let existing = fs::read_to_string(&destination).with_context(|| format!("read {path}"))?; + if existing.replace("\r\n", "\n") != content.replace("\r\n", "\n") { + bail!("{path} is stale; run cargo run -p tui-test-bindgen"); + } + } else { + fs::create_dir_all(destination.parent().context("output parent")?)?; + fs::write(&destination, content)?; + } + Ok(()) +} + +fn format_source(command: &str, args: &[&str], content: &str) -> Result { + use std::io::Write; + let mut child = Command::new(command) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .with_context(|| format!("start {command}"))?; + let mut stdin = child.stdin.take().context("formatter stdin")?; + let input = content.as_bytes().to_vec(); + // Read stdout while writing stdin so large generated files cannot fill both pipes. + let writer = std::thread::spawn(move || stdin.write_all(&input)); + let output = child.wait_with_output()?; + writer + .join() + .map_err(|_| anyhow::anyhow!("formatter input thread panicked"))??; + if !output.status.success() { + bail!("{command} failed: {}", output.status); + } + Ok(String::from_utf8(output.stdout)?) +} + +fn name(ty: &Type) -> Result { + match ty { + Type::Path(path) if path.qself.is_none() => Ok(path + .path + .segments + .last() + .context("type path")? + .ident + .to_string()), + _ => bail!("unsupported ABI type: {}", ty.to_token_stream()), + } +} + +fn camel(value: &str, upper: bool) -> String { + value + .split('_') + .enumerate() + .map(|(i, word)| { + if i == 0 && !upper { + word.to_owned() + } else { + match word { + "id" => "ID".into(), + "pid" => "PID".into(), + "ms" => "MS".into(), + _ => { + let mut chars = word.chars(); + chars + .next() + .map(|c| c.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + } + } + } + }) + .collect() +} + +fn go_type(ty: &Type) -> Result { + if let Type::Ptr(pointer) = ty { + if matches!(pointer.elem.as_ref(), Type::Path(_)) && name(&pointer.elem)? == "c_void" { + return Ok("unsafe.Pointer".into()); + } + return Ok(format!("*{}", go_type(&pointer.elem)?)); + } + let name = name(ty)?; + Ok(match name.as_str() { + "u8" => "uint8".into(), + "u16" => "uint16".into(), + "u32" => "uint32".into(), + "u64" => "uint64".into(), + "i32" => "int32".into(), + "usize" => "uintptr".into(), + "f64" => "float64".into(), + "bool" => "bool".into(), + _ if name.starts_with("Tui") => format!("abi{}", &name[3..]), + _ => bail!("unsupported Go ABI type: {name}"), + }) +} + +fn go_types(structs: &[&ItemStruct], version: &str) -> Result { + let mut out = format!("// {NOTICE}\npackage native\n\nimport (\"structs\"; \"unsafe\")\n\nconst abiVersion = {version}\n\n"); + for item in structs { + writeln!( + out, + "type abi{} struct {{\n_ structs.HostLayout", + item.ident.to_string().trim_start_matches("Tui") + )?; + for field in &item.fields { + writeln!( + out, + "{} {}", + camel( + &field.ident.as_ref().context("named field")?.to_string(), + false + ), + go_type(&field.ty)? + )?; + } + out.push_str("}\n"); + } + Ok(out) +} + +fn arguments(function: &ItemFn) -> Result> { + function + .sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Typed(arg) => Ok(arg.ty.as_ref()), + _ => bail!("unexpected receiver in C function"), + }) + .collect() +} + +fn go_function_name(function: &ItemFn) -> String { + camel( + function + .sig + .ident + .to_string() + .trim_start_matches("tui_") + .trim_end_matches("_ptr"), + true, + ) +} + +fn go_functions(functions: &[&ItemFn]) -> Result { + let functions: Vec<_> = functions + .iter() + .copied() + .filter(|function| { + !matches!( + function.sig.ident.to_string().as_str(), + "tui_open" | "tui_run" + ) + }) + .collect(); + let mut out = format!("// {NOTICE}\npackage native\n\ntype nativeFunctionTable struct {{\n"); + for function in &functions { + let args = arguments(function)? + .into_iter() + .map(go_type) + .collect::>>()? + .join(", "); + let result = match &function.sig.output { + ReturnType::Default => String::new(), + ReturnType::Type(_, ty) => go_type(ty)?, + }; + writeln!(out, "{} func({args}) {result}", go_function_name(function))?; + } + out.push_str("}\n\nfunc nativeRegistrations(table *nativeFunctionTable) []nativeRegistration {\nreturn []nativeRegistration{\n"); + for function in &functions { + writeln!( + out, + "{{symbol: {:?}, target: &table.{}}},", + function.sig.ident.to_string(), + go_function_name(function) + )?; + } + out.push_str("}\n}\n"); + Ok(out) +} + +fn python_type(ty: &Type) -> Result { + if let Type::Ptr(pointer) = ty { + if matches!(pointer.elem.as_ref(), Type::Path(_)) && name(&pointer.elem)? == "c_void" { + return Ok("ctypes.c_void_p".into()); + } + return Ok(format!("ctypes.POINTER({})", python_type(&pointer.elem)?)); + } + let name = name(ty)?; + Ok(match name.as_str() { + "u8" => "ctypes.c_uint8".into(), + "u16" => "ctypes.c_uint16".into(), + "u32" => "ctypes.c_uint32".into(), + "u64" => "ctypes.c_uint64".into(), + "i32" => "ctypes.c_int32".into(), + "usize" => "ctypes.c_size_t".into(), + "f64" => "ctypes.c_double".into(), + "bool" => "ctypes.c_bool".into(), + _ if name.starts_with("Tui") => name, + _ => bail!("unsupported Python ABI type: {name}"), + }) +} + +fn python(structs: &[&ItemStruct], functions: &[&ItemFn], version: &str) -> Result { + let mut out = format!("# {NOTICE}\nimport ctypes\n\nABI_VERSION = {version}\n"); + for item in structs { + writeln!(out, "\n\nclass {}(ctypes.Structure):\n pass", item.ident)?; + } + let mut emitted = BTreeSet::new(); + while emitted.len() != structs.len() { + let before = emitted.len(); + for item in structs { + let item_name = item.ident.to_string(); + if emitted.contains(&item_name) { + continue; + } + let mut ready = true; + for field in &item.fields { + if let Type::Path(_) = &field.ty { + let field_name = name(&field.ty)?; + if field_name.starts_with("Tui") && !emitted.contains(&field_name) { + ready = false; + } + } + } + if !ready { + continue; + } + writeln!(out, "\n\n{item_name}._fields_ = [")?; + for field in &item.fields { + writeln!( + out, + " ({:?}, {}),", + field.ident.as_ref().context("named field")?.to_string(), + python_type(&field.ty)? + )?; + } + out.push_str("]\n"); + emitted.insert(item_name); + } + if before == emitted.len() { + bail!("cyclic or missing by-value ABI type"); + } + } + out.push_str("\n\ndef configure(library):\n"); + for function in functions { + let args = arguments(function)? + .into_iter() + .map(python_type) + .collect::>>()? + .join(", "); + let result = match &function.sig.output { + ReturnType::Default => "None".into(), + ReturnType::Type(_, ty) => python_type(ty)?, + }; + writeln!( + out, + " library.{}.argtypes = [{args}]\n library.{}.restype = {result}", + function.sig.ident, function.sig.ident + )?; + } + Ok(out) +} + +fn rust(types: &str, functions: &[&ItemFn], version: &str) -> Result { + let mut out = format!("// {NOTICE}\n#![allow(dead_code)]\n{types}\npub const ABI_VERSION: u32 = {version};\n\npub struct Functions {{\n"); + for function in functions { + let args = arguments(function)? + .into_iter() + .map(|ty| ty.to_token_stream().to_string()) + .collect::>() + .join(", "); + writeln!( + out, + "pub {}: unsafe extern \"C\" fn({args}) {},", + function.sig.ident, + function.sig.output.to_token_stream() + )?; + } + out.push_str("}\nimpl Functions {\n/// # Safety\n/// The library must implement this ABI and outlive all calls through this table.\npub unsafe fn load(library: &libloading::Library) -> Result {\nOk(Self {\n"); + for function in functions { + writeln!( + out, + "{}: unsafe {{ *library.get(b\"{}\\0\")? }},", + function.sig.ident, function.sig.ident + )?; + } + out.push_str("})\n}\n}\n"); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn python_defines_embedded_structures_before_use() { + let source = syn::parse_file("pub struct TuiOuter { pub value: TuiInner, pub next: *const TuiOuter } pub struct TuiInner { pub value: u64 }").unwrap(); + let structs: Vec<_> = source + .items + .iter() + .filter_map(|item| { + if let Item::Struct(item) = item { + Some(item) + } else { + None + } + }) + .collect(); + let output = python(&structs, &[], "3").unwrap(); + assert!( + output.find("TuiInner._fields_").unwrap() < output.find("TuiOuter._fields_").unwrap() + ); + assert!(output.contains("ctypes.POINTER(TuiOuter)")); + } + + #[test] + fn unsupported_abi_types_fail_generation() { + let ty = syn::parse_str::("String").unwrap(); + assert!(go_type(&ty).is_err()); + assert!(python_type(&ty).is_err()); + } + + #[test] + fn check_accepts_windows_checkout_line_endings() { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("generated.txt"), + "first\r\nsecond\r\n", + ) + .unwrap(); + emit(directory.path(), "generated.txt", "first\nsecond\n", true).unwrap(); + assert!(emit(directory.path(), "generated.txt", "changed\n", true).is_err()); + } +} From 5b00c1fcc507ee7eb01af8143718df435a954a28 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 13:51:50 -0400 Subject: [PATCH 02/15] refactor: embed optional N-API bindings in the shared FFI crate Signed-off-by: Ayman Bagabas --- .github/scripts/release/package-node.mjs | 16 - .github/scripts/release/package-node.test.mjs | 21 +- .github/workflows/release.yml | 6 +- Cargo.lock | 11 +- Cargo.toml | 1 - README.md | 5 +- bindings/go/CONTRIBUTING.md | 5 +- bindings/js/Cargo.toml | 25 - bindings/js/README.md | 13 +- bindings/js/native/abi.rs | 421 ---------------- bindings/js/native/index.d.ts | 2 - bindings/js/native/index.js | 20 +- bindings/js/package.json | 3 - bindings/js/scripts/build-native.mjs | 21 +- bindings/js/scripts/patch-loader.mjs | 28 -- bindings/js/test/fixtures/abi-version.c | 3 - bindings/js/test/native.test.mjs | 70 ++- bindings/python/MANIFEST.in | 1 + bindings/python/README.md | 3 +- bindings/python/setup.py | 5 + crates/tui-test-ffi/Cargo.toml | 8 + crates/tui-test-ffi/README.md | 35 +- {bindings/js => crates/tui-test-ffi}/build.rs | 1 + crates/tui-test-ffi/src/lib.rs | 2 + .../tui-test-ffi/src/napi}/convert.rs | 3 +- .../tui-test-ffi/src/napi/mod.rs | 461 ++++++------------ .../tui-test-ffi/src/napi}/types.rs | 2 +- tools/bindgen/src/main.rs | 37 -- 28 files changed, 247 insertions(+), 982 deletions(-) delete mode 100644 bindings/js/Cargo.toml delete mode 100644 bindings/js/native/abi.rs delete mode 100644 bindings/js/scripts/patch-loader.mjs delete mode 100644 bindings/js/test/fixtures/abi-version.c rename {bindings/js => crates/tui-test-ffi}/build.rs (57%) rename {bindings/js/native => crates/tui-test-ffi/src/napi}/convert.rs (99%) rename bindings/js/native/lib.rs => crates/tui-test-ffi/src/napi/mod.rs (67%) rename {bindings/js/native => crates/tui-test-ffi/src/napi}/types.rs (99%) diff --git a/.github/scripts/release/package-node.mjs b/.github/scripts/release/package-node.mjs index c7ffe56c..6762e385 100644 --- a/.github/scripts/release/package-node.mjs +++ b/.github/scripts/release/package-node.mjs @@ -29,22 +29,6 @@ for (const nativePackage of nativePackages) { throw new Error(`Missing native addon in ${nativePackage}`); } - const platform = path.basename(nativePackage); - const library = platform.startsWith("win32-") - ? `tui_test_ffi.${platform}.dll` - : `libtui_test_ffi.${platform}.${platform.startsWith("darwin-") ? "dylib" : "so"}`; - const source = path.join(bindingsDirectory, "artifacts", library); - if (!fs.existsSync(source)) { - throw new Error(`Missing shared engine in ${source}`); - } - fs.copyFileSync(source, path.join(nativePackage, library)); - const manifestPath = path.join(nativePackage, "package.json"); - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - if (manifest.files !== undefined) { - manifest.files = [...new Set([...manifest.files, library])]; - } - fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - runNpm([ "pack", nativePackage, diff --git a/.github/scripts/release/package-node.test.mjs b/.github/scripts/release/package-node.test.mjs index c474fa01..0bc3002e 100644 --- a/.github/scripts/release/package-node.test.mjs +++ b/.github/scripts/release/package-node.test.mjs @@ -6,11 +6,7 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; const script = fileURLToPath(new URL("./package-node.mjs", import.meta.url)); -const targets = new Map([ - ["darwin-arm64", "libtui_test_ffi.darwin-arm64.dylib"], - ["linux-x64-gnu", "libtui_test_ffi.linux-x64-gnu.so"], - ["win32-x64-msvc", "tui_test_ffi.win32-x64-msvc.dll"], -]); +const targets = ["darwin-arm64", "linux-x64-gnu", "win32-x64-msvc"]; function fixture(t) { const target = path.resolve("target"); @@ -25,7 +21,7 @@ function fixture(t) { files: ["index.js"], })); fs.writeFileSync(path.join(bindings, "index.js"), "export {};\n"); - for (const [platform, library] of targets) { + for (const platform of targets) { const pkg = path.join(bindings, "npm", platform); fs.mkdirSync(pkg, { recursive: true }); fs.writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ @@ -34,30 +30,29 @@ function fixture(t) { files: platform === "linux-x64-gnu" ? undefined : ["*.node"], })); fs.writeFileSync(path.join(pkg, `tui-test.${platform}.node`), "addon fixture"); - fs.writeFileSync(path.join(bindings, "artifacts", library), "engine fixture"); } return directory; } -test("platform packages contain both the addon and the shared engine", (t) => { +test("platform packages need only the self-contained addon", (t) => { const directory = fixture(t); const packagePath = path.join(directory, "bindings/js/package.json"); const original = fs.readFileSync(packagePath, "utf8"); execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }); assert.equal(fs.readFileSync(packagePath, "utf8"), original); - for (const [platform, library] of targets) { + for (const platform of targets) { const archive = path.join(directory, "package-artifacts/npm", `fixture-tui-test-${platform}-1.0.0.tgz`); const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" }).split("\n"); - assert.ok(entries.includes(`package/${library}`)); assert.ok(entries.includes(`package/tui-test.${platform}.node`)); + assert.ok(!entries.some((entry) => /\.(so|dylib|dll)$/.test(entry))); } }); -test("packaging rejects an addon without its matching engine", (t) => { +test("packaging rejects a platform package without its addon", (t) => { const directory = fixture(t); - fs.unlinkSync(path.join(directory, "bindings/js/artifacts", targets.get("darwin-arm64"))); + fs.unlinkSync(path.join(directory, "bindings/js/npm/darwin-arm64/tui-test.darwin-arm64.node")); assert.throws( () => execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }), - /Missing shared engine/, + /Missing native addon/, ); }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3bd8a6..59a61ffd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,11 +205,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: node-${{ matrix.target }} - path: | - bindings/js/native/*.node - bindings/js/native/libtui_test_ffi.*.so - bindings/js/native/libtui_test_ffi.*.dylib - bindings/js/native/tui_test_ffi.*.dll + path: bindings/js/native/*.node if-no-files-found: error package-node: diff --git a/Cargo.lock b/Cargo.lock index 9135b01b..2d149a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2012,21 +2012,12 @@ dependencies = [ name = "tui-test-ffi" version = "0.1.0" dependencies = [ - "serde", - "serde_json", - "tui-test-rs", -] - -[[package]] -name = "tui-test-node" -version = "0.1.0" -dependencies = [ - "libloading", "napi", "napi-build", "napi-derive", "serde", "serde_json", + "tui-test-rs", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e83c3427..fdbd3086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [workspace] members = [ - "bindings/js", "crates/tui-test", "crates/tui-test-cli", "crates/tui-test-ffi", diff --git a/README.md b/README.md index b72d46db..361fdabc 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ You can also download a binary from [GitHub Releases](https://github.com/microso Add the Rust `recording-raster` feature for APNG, GIF, and MP4 output. It uses installed fonts; `recording-font-jetbrains-mono*` bundles a font. -Go, Python, and JavaScript use one [shared native library](crates/tui-test-ffi/README.md) with generated ABI declarations. -Set `TUI_TEST_NATIVE_LIBRARY` to select a matching library explicitly. +Go, Python, and JavaScript use one [native adapter crate](crates/tui-test-ffi/README.md). +Go and Python use its generated C ABI declarations. JavaScript uses its optional N-API feature in a self-contained addon. +Set `TUI_TEST_NATIVE_LIBRARY` to select a matching library explicitly for Go or Python. ## Quick start diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index 7657e221..8911eea0 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -9,7 +9,8 @@ The public `tuitest` package at the module root owns clients, options, results, 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 shared Rust adapter lives in `../../crates/tui-test-ffi/` and delegates to the existing session registry and operations. -Go, Python, and JavaScript use the same library. Source builds select its path through `TUI_TEST_NATIVE_LIBRARY`. +Go and Python use the same library. JavaScript uses the crate's optional N-API build. +Source builds select the Go/Python library path through `TUI_TEST_NATIVE_LIBRARY`. The library stays 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. @@ -25,7 +26,7 @@ Do not edit these files directly. From the repository root, regenerate them with cargo run --locked -p tui-test-bindgen ``` -The same command updates the C header, Python declarations, and Node declarations. +The same command updates the C header and Python declarations. Use `cargo run --locked -p tui-test-bindgen -- --check` to check for stale output. ## Build from source diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml deleted file mode 100644 index 8f31e312..00000000 --- a/bindings/js/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "tui-test-node" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -description = "napi-rs Node.js addon for the tui_test terminal engine" -repository.workspace = true -publish = false - -[lib] -name = "tui_test_node" -path = "native/lib.rs" -crate-type = ["cdylib"] -test = false - -[dependencies] -napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"] } -napi-derive = "3" -serde = { workspace = true } -serde_json = { workspace = true } -libloading = "0.9" - -[build-dependencies] -napi-build = "2" diff --git a/bindings/js/README.md b/bindings/js/README.md index f3c7378a..d9bf278f 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -10,16 +10,15 @@ npm install @microsoft/tui-test Node 20+ is supported. The package is ESM only. Bun and Deno support is best effort. Deno 2 needs local `node_modules` and `--allow-ffi`. -The Node addon loads the shared `tui-test-ffi` library through its generated C ABI. -Each platform package includes the addon and its matching library. -The library stays loaded for the process lifetime, including calls from workers. -Set `TUI_TEST_NATIVE_LIBRARY` before the first operation to select another compatible library. -The addon rejects an incompatible ABI version. +Each platform package contains one self-contained `.node` addon. +The addon uses the optional `napi` feature in `tui-test-ffi` and calls the typed C ABI directly. +It needs no companion shared library or CLI. +`TUI_TEST_NATIVE_LIBRARY` applies only to Go and Python; JavaScript ignores it. For a local build, run `npm ci`, then `npm run build`. Use `npm run build:native:debug` for a debug build. -These commands build both native components and copy them into `native/`. -They also regenerate the N-API declarations and apply the shared-library loader extension. +These commands build `tui-test-ffi` with `--features napi` and put the addon in `native/`. +They also regenerate the N-API loader and declarations. ## Quick start diff --git a/bindings/js/native/abi.rs b/bindings/js/native/abi.rs deleted file mode 100644 index 581ea817..00000000 --- a/bindings/js/native/abi.rs +++ /dev/null @@ -1,421 +0,0 @@ -// Generated by cargo run -p tui-test-bindgen. Do not edit. -#![allow(dead_code)] -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, -} - -pub const ABI_VERSION: u32 = 3; - -pub struct Functions { - pub tui_abi_version: unsafe extern "C" fn() -> u32, - pub tui_result_free: unsafe extern "C" fn(*mut TuiResult), - pub tui_open: unsafe extern "C" fn(TuiString, TuiOpenOptions) -> *mut TuiResult, - pub tui_open_ptr: unsafe extern "C" fn(TuiString, *const TuiOpenOptions) -> *mut TuiResult, - pub tui_run: unsafe extern "C" fn( - TuiString, - TuiOpenOptions, - TuiString, - *const TuiString, - usize, - ) -> *mut TuiResult, - pub tui_run_ptr: unsafe extern "C" fn( - TuiString, - *const TuiOpenOptions, - TuiString, - *const TuiString, - usize, - ) -> *mut TuiResult, - pub tui_sessions: unsafe extern "C" fn() -> *mut TuiResult, - pub tui_close_all: unsafe extern "C" fn() -> *mut TuiResult, - pub tui_recording: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_close: unsafe extern "C" fn(TuiString, TuiOptionalBool) -> *mut TuiResult, - pub tui_restart: unsafe extern "C" fn(TuiString, u64) -> *mut TuiResult, - pub tui_state: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_command: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_output: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_exit_code: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_cwd: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_cursor: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_size: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_title: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_clipboard: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_bell_count: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_get_bell_events: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_stop_recording: unsafe extern "C" fn(TuiString) -> *mut TuiResult, - pub tui_text: unsafe extern "C" fn(TuiString, bool) -> *mut TuiResult, - pub tui_packed_screen: unsafe extern "C" fn(TuiString, bool) -> *mut TuiResult, - pub tui_cells: unsafe extern "C" fn(TuiString, u16, u16, u16, u16) -> *mut TuiResult, - pub tui_write: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, - pub tui_type: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, - pub tui_submit: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, - pub tui_signal: unsafe extern "C" fn(TuiString, TuiString) -> *mut TuiResult, - pub tui_key: unsafe extern "C" fn(TuiString, *const TuiString, usize, u32) -> *mut TuiResult, - pub tui_resize: unsafe extern "C" fn(TuiString, u16, u16) -> *mut TuiResult, - pub tui_mouse_click: unsafe extern "C" fn( - TuiString, - TuiOptionalU64, - TuiOptionalU64, - TuiString, - TuiMouseOptions, - u8, - ) -> *mut TuiResult, - pub tui_mouse_move: unsafe extern "C" fn(TuiString, u16, u16) -> *mut TuiResult, - pub tui_mouse_down: - unsafe extern "C" fn(TuiString, u16, u16, TuiMouseOptions) -> *mut TuiResult, - pub tui_mouse_up: unsafe extern "C" fn(TuiString, u16, u16, TuiMouseOptions) -> *mut TuiResult, - pub tui_mouse_drag: - unsafe extern "C" fn(TuiString, u16, u16, u16, u16, TuiMouseOptions) -> *mut TuiResult, - pub tui_mouse_scroll: unsafe extern "C" fn(TuiString, TuiString, u16) -> *mut TuiResult, - pub tui_wait_title: - unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, - pub tui_expect_title: - unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, - pub tui_wait_clipboard: - unsafe extern "C" fn(TuiString, TuiString, TuiWaitOptions) -> *mut TuiResult, - pub tui_wait_idle: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, - pub tui_wait_command: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, - pub tui_wait_exit: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, - pub tui_wait_ready: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, - pub tui_wait_bell: unsafe extern "C" fn(TuiString, TuiOptionalU64) -> *mut TuiResult, - pub tui_find_locator: unsafe extern "C" fn(TuiString, TuiQuery, bool) -> *mut TuiResult, - pub tui_wait_locator: - unsafe extern "C" fn(TuiString, TuiQuery, TuiWaitOptions) -> *mut TuiResult, - pub tui_expect_locator: - unsafe extern "C" fn(TuiString, TuiQuery, TuiWaitOptions) -> *mut TuiResult, - pub tui_click_locator: unsafe extern "C" fn( - TuiString, - TuiQuery, - TuiMouseOptions, - u8, - TuiOptionalU64, - ) -> *mut TuiResult, - pub tui_highlight_locator: - unsafe extern "C" fn(TuiString, TuiQuery, TuiOptionalU64) -> *mut TuiResult, - pub tui_expect_exit_code: - unsafe extern "C" fn(TuiString, i32, TuiOptionalU64) -> *mut TuiResult, - pub tui_expect_output: unsafe extern "C" fn(TuiString, TuiString, bool) -> *mut TuiResult, - pub tui_expect_bell_count: - unsafe extern "C" fn(TuiString, u64, TuiOptionalU64) -> *mut TuiResult, - pub tui_snapshot: - unsafe extern "C" fn(TuiString, TuiString, bool, bool, bool, TuiString) -> *mut TuiResult, - pub tui_screenshot: unsafe extern "C" fn( - TuiString, - bool, - TuiString, - TuiOptionalF64, - TuiString, - bool, - ) -> *mut TuiResult, - pub tui_start_recording: unsafe extern "C" fn(TuiString, TuiRecordingOptions) -> *mut TuiResult, -} -impl Functions { - /// # Safety - /// The library must implement this ABI and outlive all calls through this table. - pub unsafe fn load(library: &libloading::Library) -> Result { - Ok(Self { - tui_abi_version: unsafe { *library.get(b"tui_abi_version\0")? }, - tui_result_free: unsafe { *library.get(b"tui_result_free\0")? }, - tui_open: unsafe { *library.get(b"tui_open\0")? }, - tui_open_ptr: unsafe { *library.get(b"tui_open_ptr\0")? }, - tui_run: unsafe { *library.get(b"tui_run\0")? }, - tui_run_ptr: unsafe { *library.get(b"tui_run_ptr\0")? }, - tui_sessions: unsafe { *library.get(b"tui_sessions\0")? }, - tui_close_all: unsafe { *library.get(b"tui_close_all\0")? }, - tui_recording: unsafe { *library.get(b"tui_recording\0")? }, - tui_close: unsafe { *library.get(b"tui_close\0")? }, - tui_restart: unsafe { *library.get(b"tui_restart\0")? }, - tui_state: unsafe { *library.get(b"tui_state\0")? }, - tui_get_command: unsafe { *library.get(b"tui_get_command\0")? }, - tui_get_output: unsafe { *library.get(b"tui_get_output\0")? }, - tui_get_exit_code: unsafe { *library.get(b"tui_get_exit_code\0")? }, - tui_get_cwd: unsafe { *library.get(b"tui_get_cwd\0")? }, - tui_get_cursor: unsafe { *library.get(b"tui_get_cursor\0")? }, - tui_get_size: unsafe { *library.get(b"tui_get_size\0")? }, - tui_get_title: unsafe { *library.get(b"tui_get_title\0")? }, - tui_get_clipboard: unsafe { *library.get(b"tui_get_clipboard\0")? }, - tui_get_bell_count: unsafe { *library.get(b"tui_get_bell_count\0")? }, - tui_get_bell_events: unsafe { *library.get(b"tui_get_bell_events\0")? }, - tui_stop_recording: unsafe { *library.get(b"tui_stop_recording\0")? }, - tui_text: unsafe { *library.get(b"tui_text\0")? }, - tui_packed_screen: unsafe { *library.get(b"tui_packed_screen\0")? }, - tui_cells: unsafe { *library.get(b"tui_cells\0")? }, - tui_write: unsafe { *library.get(b"tui_write\0")? }, - tui_type: unsafe { *library.get(b"tui_type\0")? }, - tui_submit: unsafe { *library.get(b"tui_submit\0")? }, - tui_signal: unsafe { *library.get(b"tui_signal\0")? }, - tui_key: unsafe { *library.get(b"tui_key\0")? }, - tui_resize: unsafe { *library.get(b"tui_resize\0")? }, - tui_mouse_click: unsafe { *library.get(b"tui_mouse_click\0")? }, - tui_mouse_move: unsafe { *library.get(b"tui_mouse_move\0")? }, - tui_mouse_down: unsafe { *library.get(b"tui_mouse_down\0")? }, - tui_mouse_up: unsafe { *library.get(b"tui_mouse_up\0")? }, - tui_mouse_drag: unsafe { *library.get(b"tui_mouse_drag\0")? }, - tui_mouse_scroll: unsafe { *library.get(b"tui_mouse_scroll\0")? }, - tui_wait_title: unsafe { *library.get(b"tui_wait_title\0")? }, - tui_expect_title: unsafe { *library.get(b"tui_expect_title\0")? }, - tui_wait_clipboard: unsafe { *library.get(b"tui_wait_clipboard\0")? }, - tui_wait_idle: unsafe { *library.get(b"tui_wait_idle\0")? }, - tui_wait_command: unsafe { *library.get(b"tui_wait_command\0")? }, - tui_wait_exit: unsafe { *library.get(b"tui_wait_exit\0")? }, - tui_wait_ready: unsafe { *library.get(b"tui_wait_ready\0")? }, - tui_wait_bell: unsafe { *library.get(b"tui_wait_bell\0")? }, - tui_find_locator: unsafe { *library.get(b"tui_find_locator\0")? }, - tui_wait_locator: unsafe { *library.get(b"tui_wait_locator\0")? }, - tui_expect_locator: unsafe { *library.get(b"tui_expect_locator\0")? }, - tui_click_locator: unsafe { *library.get(b"tui_click_locator\0")? }, - tui_highlight_locator: unsafe { *library.get(b"tui_highlight_locator\0")? }, - tui_expect_exit_code: unsafe { *library.get(b"tui_expect_exit_code\0")? }, - tui_expect_output: unsafe { *library.get(b"tui_expect_output\0")? }, - tui_expect_bell_count: unsafe { *library.get(b"tui_expect_bell_count\0")? }, - tui_snapshot: unsafe { *library.get(b"tui_snapshot\0")? }, - tui_screenshot: unsafe { *library.get(b"tui_screenshot\0")? }, - tui_start_recording: unsafe { *library.get(b"tui_start_recording\0")? }, - }) - } -} diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 5fe8a63b..87f7cf22 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -117,8 +117,6 @@ export declare function closeAllSync(): void export type Color = number | string -export declare function configureLibrary(path: string): void - export interface Cursor { x: number y: number diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index 217fe6ea..c5ac3869 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -4,22 +4,7 @@ /* auto-generated by NAPI-RS */ import { createRequire } from 'module' -const nativeRequire = createRequire(import.meta.url) -let sharedLibraryPath -const require = (name) => { - const binding = nativeRequire(name) - if (binding && typeof binding.configureLibrary === 'function') { - const addon = nativeRequire.resolve(name) - const { basename, dirname, join } = nativeRequire('node:path') - const tag = basename(addon).replace(/^tui-test\./, '').replace(/\.node$/, '') - const windows = tag.startsWith('win32-') - const extension = windows ? 'dll' : tag.startsWith('darwin-') ? 'dylib' : 'so' - sharedLibraryPath = join(dirname(addon), `${windows ? '' : 'lib'}tui_test_ffi.${tag}.${extension}`) - binding.configureLibrary(sharedLibraryPath) - } - return binding -} -require.resolve = nativeRequire.resolve +const require = createRequire(import.meta.url) const __dirname = new URL('.', import.meta.url).pathname const { readFileSync } = require('fs') @@ -605,12 +590,11 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { NativeSession, Backend, closeAll, closeAllSync, configureLibrary, LocatorNodeKind, LocatorStageDirection, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding +const { NativeSession, Backend, closeAll, closeAllSync, LocatorNodeKind, LocatorStageDirection, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding export { NativeSession } export { Backend } export { closeAll } export { closeAllSync } -export { configureLibrary } export { LocatorNodeKind } export { LocatorStageDirection } export { recording } diff --git a/bindings/js/package.json b/bindings/js/package.json index 790f0ca7..77018942 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -19,9 +19,6 @@ "native/index.js", "native/index.d.ts", "native/*.node", - "native/*.dylib", - "native/*.so", - "native/*.dll", "README.md" ], "engines": { diff --git a/bindings/js/scripts/build-native.mjs b/bindings/js/scripts/build-native.mjs index 20b36c3c..030062a4 100644 --- a/bindings/js/scripts/build-native.mjs +++ b/bindings/js/scripts/build-native.mjs @@ -1,15 +1,13 @@ import { execFileSync } from "node:child_process"; -import { copyFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { patchLoader } from "./patch-loader.mjs"; const directory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(directory, "../.."); const args = process.argv.slice(2); const debug = args.includes("--debug"); const napiArgs = args.filter((arg) => arg !== "--debug"); -const targetIndex = args.indexOf("--target"); +const targetIndex = args.findIndex((arg) => arg === "--target" || arg === "-t"); const explicitTarget = targetIndex >= 0 ? args[targetIndex + 1] : args.find((arg) => arg.startsWith("--target="))?.slice("--target=".length); @@ -26,9 +24,6 @@ const tags = { }; const tag = tags[target]; if (!tag) throw new Error(`Unsupported native target: ${target}`); -const windows = target.includes("windows"); -const extension = windows ? "dll" : target.includes("darwin") ? "dylib" : "so"; -const stem = `${windows ? "" : "lib"}tui_test_ffi`; const env = { ...process.env }; delete env.CARGO_TARGET_DIR; if (target.endsWith("-musl")) { @@ -37,22 +32,12 @@ if (target.endsWith("-musl")) { const run = (command, commandArgs, cwd = directory) => execFileSync(command, commandArgs, { cwd, env, stdio: "inherit" }); -run("cargo", [ - args.includes("-x") || args.includes("--cross-compile") ? "zigbuild" : "build", - "-p", "tui-test-ffi", - ...(debug ? [] : ["--release"]), - ...(explicitTarget ? ["--target", explicitTarget] : []), -], root); -const profile = debug ? "debug" : "release"; -copyFileSync( - resolve(root, "target", explicitTarget ?? "", profile, `${stem}.${extension}`), - resolve(directory, "native", `${stem}.${tag}.${extension}`), -); run(process.execPath, [ resolve(directory, "node_modules/@napi-rs/cli/dist/cli.js"), "build", "--platform", "--esm", + "--manifest-path", resolve(root, "crates/tui-test-ffi/Cargo.toml"), + "-p", "tui-test-ffi", "--features", "napi", ...(debug ? [] : ["--release", "--strip"]), "--output-dir", "native", "--js", "index.js", "--dts", "index.d.ts", ...napiArgs, ]); -patchLoader(resolve(directory, "native/index.js")); diff --git a/bindings/js/scripts/patch-loader.mjs b/bindings/js/scripts/patch-loader.mjs deleted file mode 100644 index 8e8eeccf..00000000 --- a/bindings/js/scripts/patch-loader.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; - -// Apply this deterministic extension after each napi-rs loader regeneration. -export function patchLoader(path) { - let source = readFileSync(path, "utf8"); - if (source.includes("let sharedLibraryPath")) return; - const original = "const require = createRequire(import.meta.url)"; - if (!source.includes(original)) { - throw new Error("The generated napi-rs loader changed its require declaration"); - } - source = source.replace(original, `const nativeRequire = createRequire(import.meta.url) -let sharedLibraryPath -const require = (name) => { - const binding = nativeRequire(name) - if (binding && typeof binding.configureLibrary === 'function') { - const addon = nativeRequire.resolve(name) - const { basename, dirname, join } = nativeRequire('node:path') - const tag = basename(addon).replace(/^tui-test\\./, '').replace(/\\.node$/, '') - const windows = tag.startsWith('win32-') - const extension = windows ? 'dll' : tag.startsWith('darwin-') ? 'dylib' : 'so' - sharedLibraryPath = join(dirname(addon), \`\${windows ? '' : 'lib'}tui_test_ffi.\${tag}.\${extension}\`) - binding.configureLibrary(sharedLibraryPath) - } - return binding -} -require.resolve = nativeRequire.resolve`); - writeFileSync(path, source); -} diff --git a/bindings/js/test/fixtures/abi-version.c b/bindings/js/test/fixtures/abi-version.c deleted file mode 100644 index d8b0ccbe..00000000 --- a/bindings/js/test/fixtures/abi-version.c +++ /dev/null @@ -1,3 +0,0 @@ -unsigned int tui_abi_version(void) { - return ABI_VERSION; -} diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 5ead47d2..d78f57d3 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; import { test } from "node:test"; import { TuiTest } from "../dist/index.js"; @@ -18,54 +18,44 @@ import { NativeRuntime, mapNativeError } from "../dist/native.js"; const ERROR_PREFIX = "__tui_test_native_error__:"; -function checkLibraryFailure(library, expected) { +test("standalone addon works without a companion library and ignores the library override", (t) => { + const directory = mkdtempSync(join(tmpdir(), "tui-test-node-standalone-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const native = new URL("../native/", import.meta.url); + for (const file of readdirSync(native).filter((file) => file.endsWith(".node"))) { + copyFileSync(new URL(file, native), join(directory, file)); + } + const loader = join(directory, "index.mjs"); + copyFileSync(new URL("index.js", native), loader); + assert.ok(readdirSync(directory).some((file) => file.endsWith(".node"))); + assert.ok(readdirSync(directory).every((file) => file.endsWith(".node") || file === "index.mjs")); const script = ` (async () => { const assert = (await import("node:assert/strict")).default; - const { NativeRuntime } = await import(${JSON.stringify(new URL("../dist/native.js", import.meta.url).href)}); - const { InternalError } = await import(${JSON.stringify(new URL("../dist/index.js", import.meta.url).href)}); - await assert.rejects(new NativeRuntime("loader-check").state(), (error) => { - assert.ok(error instanceof InternalError); - assert.match(error.message, new RegExp(${JSON.stringify(expected)})); - return true; - }); + const binding = await import(${JSON.stringify(pathToFileURL(loader).href)}); + assert.equal(binding.configureLibrary, undefined); + const session = new binding.NativeSession("standalone-" + process.pid); + try { + const args = typeof globalThis.Deno === "undefined" + ? ["-e", "console.log('standalone-addon')"] + : ["eval", "console.log('standalone-addon')"]; + await session.run({ program: process.execPath, args }); + await session.waitExit(5000); + assert.match(await session.text(), /standalone-addon/); + } finally { + await session.close(); + } })().catch(error => { console.error(error); process.exitCode = 1; }); `; const args = typeof globalThis.Deno === "undefined" ? ["-e", script] : ["eval", script]; const child = spawnSync(process.execPath, args, { encoding: "utf8", - env: { ...process.env, TUI_TEST_NATIVE_LIBRARY: library }, + env: { + ...process.env, + TUI_TEST_NATIVE_LIBRARY: join(directory, "does-not-exist"), + }, }); assert.equal(child.status, 0, child.stderr); -} - -test("shared library override failures retain the public error taxonomy", () => { - checkLibraryFailure( - fileURLToPath(new URL("./does-not-exist/native-library", import.meta.url)), - "failed to load shared native library", - ); -}); - -test("shared library rejects incompatible versions and missing symbols", (t) => { - if (process.platform === "win32" || spawnSync("cc", ["--version"]).status !== 0) { - t.skip("This fixture requires a Unix C compiler"); - return; - } - const directory = mkdtempSync(join(tmpdir(), "tui-test-node-abi-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const fixture = fileURLToPath(new URL("./fixtures/abi-version.c", import.meta.url)); - for (const [version, expected] of [ - [0, "native ABI version mismatch: expected 3, got 0"], - [3, "failed to load native ABI symbols"], - ]) { - const library = join(directory, `abi-${version}.${process.platform === "darwin" ? "dylib" : "so"}`); - const compiled = spawnSync("cc", [ - process.platform === "darwin" ? "-dynamiclib" : "-shared", - "-fPIC", `-DABI_VERSION=${version}`, fixture, "-o", library, - ], { encoding: "utf8" }); - assert.equal(compiled.status, 0, compiled.stderr); - checkLibraryFailure(library, expected); - } }); test("native JSON error envelopes expose structured diagnostics", () => { diff --git a/bindings/python/MANIFEST.in b/bindings/python/MANIFEST.in index 081a3a8a..675f1086 100644 --- a/bindings/python/MANIFEST.in +++ b/bindings/python/MANIFEST.in @@ -4,4 +4,5 @@ recursive-include src *.py *.pyi py.typed recursive-include tests *.py recursive-include examples *.py graft _rust +graft .cargo global-exclude __pycache__ *.pyc diff --git a/bindings/python/README.md b/bindings/python/README.md index 946e82ad..2ec853b4 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -28,7 +28,8 @@ env -u CARGO_TARGET_DIR python -m build bindings/python The build uses `setuptools-rust` with `Binding.NoBinding`. Wheels use `py3-none-` tags. The source distribution includes the shared ABI, -the Rust engine, and their assets. Source builds need Cargo dependency access. +the Rust engine, their assets, and the Cargo configuration that separates backend symbols. +Source builds need Cargo dependency access. For cross builds, set `CARGO_BUILD_TARGET` and pass `-C--build-option=--plat-name=` to `python -m build --wheel`. diff --git a/bindings/python/setup.py b/bindings/python/setup.py index d2d5a810..c4bf1287 100644 --- a/bindings/python/setup.py +++ b/bindings/python/setup.py @@ -41,6 +41,9 @@ def make_release_tree(self, base_dir, files): if destination.is_dir(): return destination.mkdir() + cargo_config = Path(base_dir) / ".cargo" + cargo_config.mkdir(exist_ok=True) + shutil.copy2(RUST_ROOT / ".cargo/config.toml", cargo_config / "config.toml") for filename in ("Cargo.lock", "LICENSE", "README.md"): shutil.copy2(RUST_ROOT / filename, destination / filename) manifest = (RUST_ROOT / "Cargo.toml").read_text(encoding="utf-8") @@ -57,6 +60,8 @@ def make_release_tree(self, base_dir, files): target = destination / "crates" / crate target.mkdir(parents=True) shutil.copy2(source / "Cargo.toml", target / "Cargo.toml") + if (source / "build.rs").is_file(): + shutil.copy2(source / "build.rs", target / "build.rs") for directory in ("src", "assets", "shell", "include"): if (source / directory).is_dir(): shutil.copytree(source / directory, target / directory) diff --git a/crates/tui-test-ffi/Cargo.toml b/crates/tui-test-ffi/Cargo.toml index 9a5db00a..7de6ca09 100644 --- a/crates/tui-test-ffi/Cargo.toml +++ b/crates/tui-test-ffi/Cargo.toml @@ -11,7 +11,15 @@ publish = false name = "tui_test_ffi" crate-type = ["cdylib", "rlib"] +[features] +napi = ["dep:napi", "dep:napi-derive", "dep:napi-build"] + [dependencies] +napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"], optional = true } +napi-derive = { version = "3", optional = true } serde.workspace = true serde_json.workspace = true tui-test = { workspace = true, features = ["ghostty", "rio", "xtermjs", "recording-font-jetbrains-mono-styles"] } + +[build-dependencies] +napi-build = { version = "2", optional = true } diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index 3ee6152d..70b2ce76 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -1,8 +1,10 @@ # Shared native library `tui-test-ffi` exposes the Rust engine through a typed C ABI. -Go uses `purego`, Python uses `ctypes`, and Node uses a small N-API bridge. -The Node bridge loads this library at runtime. It does not contain a second Rust engine. +Go uses `purego`, and Python uses `ctypes`, to load the default shared library. +The optional `napi` feature adds the JavaScript adapter to this crate. +That adapter calls the C ABI functions directly and produces one self-contained `.node` addon. +The default build has no N-API dependencies. The library and language package must come from the same source revision. This ABI is an internal contract, not a stable public C SDK. @@ -16,8 +18,8 @@ cargo build --locked -p tui-test-ffi ``` The library is `target/debug/libtui_test_ffi.so` on Linux, `libtui_test_ffi.dylib` on macOS, or `tui_test_ffi.dll` on Windows. -Set `TUI_TEST_NATIVE_LIBRARY` to its absolute path to select it explicitly in any language. -Python wheels and Node platform packages also include the library. +Set `TUI_TEST_NATIVE_LIBRARY` to its absolute path to select it explicitly in Go or Python. +Python wheels also include the library. JavaScript ignores this variable and needs no companion library. For example, run the Go smoke example on macOS: @@ -29,6 +31,28 @@ CGO_ENABLED=0 go run ./examples/smoke The example starts a child terminal, submits input, checks its output, and prints `Go binding smoke passed`. +Build the JavaScript addon and declarations from the same crate: + +```sh +cd bindings/js +npm run build:native:debug +npm run build:ts +node --input-type=module -e ' + import { TuiTest } from "./dist/index.js"; + const terminal = TuiTest.ephemeral(); + try { + await terminal.run(process.execPath, ["-e", "console.log(\"Ready\")"]); + await terminal.getByText("Ready").expect(); + console.log(await terminal.text()); + } finally { + await terminal.closeQuiet(); + } +' +``` + +The build enables `--features napi` and writes the addon, loader, and declarations to `bindings/js/native/`. +The example prints terminal text that contains `Ready`. + ## Generate declarations The Rust declarations in `src/types.rs` and `src/lib.rs` are the source of truth. @@ -40,7 +64,8 @@ cargo run --locked -p tui-test-bindgen -- --check ``` The generator uses `cbindgen` for `include/tui_test.h`. -It also generates the Go structs and function table, Python `ctypes` declarations, and Node function table. +It also generates the Go structs and function table, and Python `ctypes` declarations. +The JavaScript adapter uses the canonical Rust ABI types without a generated function table. Run the generator with Go and Rust formatters on `PATH`. The language adapters still own asynchronous execution, error presentation, input storage, and result conversion. diff --git a/bindings/js/build.rs b/crates/tui-test-ffi/build.rs similarity index 57% rename from bindings/js/build.rs rename to crates/tui-test-ffi/build.rs index 0f1b0100..f2be9938 100644 --- a/bindings/js/build.rs +++ b/crates/tui-test-ffi/build.rs @@ -1,3 +1,4 @@ fn main() { + #[cfg(feature = "napi")] napi_build::setup(); } diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index 558985b2..25ef3297 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -2,6 +2,8 @@ //! 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; +#[cfg(all(feature = "napi", not(test)))] +mod napi; mod output; mod types; use std::panic::{catch_unwind, AssertUnwindSafe}; diff --git a/bindings/js/native/convert.rs b/crates/tui-test-ffi/src/napi/convert.rs similarity index 99% rename from bindings/js/native/convert.rs rename to crates/tui-test-ffi/src/napi/convert.rs index 6ce702c0..ea72b439 100644 --- a/bindings/js/native/convert.rs +++ b/crates/tui-test-ffi/src/napi/convert.rs @@ -1,4 +1,5 @@ -use super::{abi::*, internal, types::*, usage, MAX_SAFE_INTEGER}; +use super::{internal, types::*, usage, MAX_SAFE_INTEGER}; +use crate::types::*; use napi::bindgen_prelude::Either; use napi::Result; diff --git a/bindings/js/native/lib.rs b/crates/tui-test-ffi/src/napi/mod.rs similarity index 67% rename from bindings/js/native/lib.rs rename to crates/tui-test-ffi/src/napi/mod.rs index 01b98c39..7456f5da 100644 --- a/bindings/js/native/lib.rs +++ b/crates/tui-test-ffi/src/napi/mod.rs @@ -1,77 +1,19 @@ #![deny(clippy::all)] -mod abi; mod convert; mod types; -use abi::*; +use self::types::*; +use crate::types::*; use convert::*; -use types::*; -use napi::bindgen_prelude::spawn_blocking; -use napi::{Error, Result, Status}; +use ::napi::bindgen_prelude::spawn_blocking; +use ::napi::{Error, Result, Status}; use napi_derive::napi; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; const ERROR_PREFIX: &str = "__tui_test_native_error__:"; const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; -struct Library { - functions: Functions, - // Keep the library loaded until all native calls and result guards finish. - _library: libloading::Library, -} - -static LIBRARY: OnceLock> = OnceLock::new(); -static LIBRARY_PATH: Mutex> = Mutex::new(None); - -#[napi] -pub fn configure_library(path: String) -> Result<()> { - *LIBRARY_PATH - .lock() - .map_err(|_| internal("library path lock poisoned"))? = Some(path.into()); - Ok(()) -} - -fn library() -> Result> { - if let Some(library) = LIBRARY.get() { - return Ok(library.clone()); - } - let configured = LIBRARY_PATH - .lock() - .map_err(|_| internal("library path lock poisoned"))?; - if let Some(library) = LIBRARY.get() { - return Ok(library.clone()); - } - let path = std::env::var_os("TUI_TEST_NATIVE_LIBRARY") - .map(PathBuf::from) - .or_else(|| configured.clone()) - .ok_or_else(|| internal("shared native library path is not configured"))?; - let loaded = unsafe { libloading::Library::new(&path) }.map_err(|error| { - internal(format!( - "failed to load shared native library {}: {error}", - path.display() - )) - })?; - let version = unsafe { loaded.get:: u32>(b"tui_abi_version\0") } - .map_err(|error| internal(format!("failed to read native ABI version: {error}")))?; - let actual = unsafe { version() }; - if actual != ABI_VERSION { - return Err(internal(format!( - "native ABI version mismatch: expected {ABI_VERSION}, got {actual}" - ))); - } - let functions = unsafe { Functions::load(&loaded) } - .map_err(|error| internal(format!("failed to load native ABI symbols: {error}")))?; - let loaded = Arc::new(Library { - functions, - _library: loaded, - }); - let _ = LIBRARY.set(loaded.clone()); - Ok(loaded) -} - fn error(kind: &str, message: impl Into) -> Error { Error::new( Status::GenericFailure, @@ -113,26 +55,21 @@ async fn blocking( .map_err(|error| internal(format!("native worker failed: {error}")))? } -struct ResultGuard<'a> { +struct ResultGuard { pointer: *mut TuiResult, - library: &'a Library, } -impl Drop for ResultGuard<'_> { +impl Drop for ResultGuard { fn drop(&mut self) { - unsafe { (self.library.functions.tui_result_free)(self.pointer) }; + unsafe { crate::tui_result_free(self.pointer) }; } } -fn result( - library: &Library, - pointer: *mut TuiResult, - convert: impl FnOnce(&TuiResult) -> Result, -) -> Result { +fn result(pointer: *mut TuiResult, convert: impl FnOnce(&TuiResult) -> Result) -> Result { if pointer.is_null() { return Err(internal("native operation returned a null result")); } - let guard = ResultGuard { pointer, library }; + let guard = ResultGuard { pointer }; let value = unsafe { &*guard.pointer }; if value.error_kind != 0 { let kind = match value.error_kind { @@ -179,14 +116,10 @@ pub struct NativeSession { impl NativeSession { async fn call( &self, - work: impl FnOnce(&Library, TuiString) -> Result + Send + 'static, + work: impl FnOnce(TuiString) -> Result + Send + 'static, ) -> Result { let context = self.context.to_string(); - blocking(move || { - let library = library()?; - work(&library, borrowed(&context)) - }) - .await + blocking(move || work(borrowed(&context))).await } } @@ -228,11 +161,9 @@ impl NativeSession { let context = session_context(&self.context, options.screen_history_limit)?; let directory = self.recording_directory.clone(); blocking(move || { - let library = library()?; let input = OpenInput::new(options, directory)?; result( - &library, - unsafe { (library.functions.tui_open_ptr)(borrowed(&context), &input.options) }, + unsafe { crate::tui_open_ptr(borrowed(&context), &input.options) }, open_result, ) }) @@ -244,7 +175,6 @@ impl NativeSession { let context = session_context(&self.context, options.screen_history_limit)?; let directory = self.recording_directory.clone(); blocking(move || { - let library = library()?; let args = options.args.unwrap_or_default(); let borrowed_args: Vec<_> = args.iter().map(|value| borrowed(value)).collect(); let input = OpenInput::new( @@ -264,9 +194,8 @@ impl NativeSession { directory, )?; result( - &library, unsafe { - (library.functions.tui_run_ptr)( + crate::tui_run_ptr( borrowed(&context), &input.options, borrowed(&options.program), @@ -282,11 +211,10 @@ impl NativeSession { #[napi] pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_restart)( + crate::tui_restart( session, integer(graceful_timeout_ms, "gracefulTimeoutMs", u64::MAX)?, ) @@ -299,10 +227,9 @@ impl NativeSession { #[napi] pub async fn close(&self, failed: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, - unsafe { (library.functions.tui_close)(session, optional_bool(failed)) }, + unsafe { crate::tui_close(session, optional_bool(failed)) }, unit, ) }) @@ -311,22 +238,19 @@ impl NativeSession { #[napi] pub async fn state(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_state)(session) }, - |value| Ok(state(&value.state)), - ) + self.call(|session| { + result(unsafe { crate::tui_state(session) }, |value| { + Ok(state(&value.state)) + }) }) .await } #[napi] pub async fn text(&self, full: Option) -> Result { - self.call(move |library, session| { + self.call(move |session| { result( - library, - unsafe { (library.functions.tui_text)(session, full.unwrap_or(false)) }, + unsafe { crate::tui_text(session, full.unwrap_or(false)) }, string_result, ) }) @@ -335,10 +259,9 @@ impl NativeSession { #[napi] pub async fn packed_screen(&self, full: Option) -> Result { - self.call(move |library, session| { + self.call(move |session| { result( - library, - unsafe { (library.functions.tui_packed_screen)(session, full.unwrap_or(false)) }, + unsafe { crate::tui_packed_screen(session, full.unwrap_or(false)) }, |value| { Ok(PackedScreen { cols: value.size.cols, @@ -353,11 +276,10 @@ impl NativeSession { #[napi] pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_cells)( + crate::tui_cells( session, u16_value(x, "x")?, u16_value(y, "y")?, @@ -382,12 +304,11 @@ impl NativeSession { expression: LocatorExpression, require_one: Option, ) -> Result> { - self.call(move |library, session| { + self.call(move |session| { let query = query(expression)?; result( - library, unsafe { - (library.functions.tui_find_locator)( + crate::tui_find_locator( session, TuiQuery { json: borrowed(&query), @@ -408,12 +329,11 @@ impl NativeSession { not: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { let query = query(expression)?; result( - library, unsafe { - (library.functions.tui_wait_locator)( + crate::tui_wait_locator( session, TuiQuery { json: borrowed(&query), @@ -434,12 +354,11 @@ impl NativeSession { not: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { let query = query(expression)?; result( - library, unsafe { - (library.functions.tui_expect_locator)( + crate::tui_expect_locator( session, TuiQuery { json: borrowed(&query), @@ -461,12 +380,11 @@ impl NativeSession { clicks: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { let query = query(expression)?; result( - library, unsafe { - (library.functions.tui_click_locator)( + crate::tui_click_locator( session, TuiQuery { json: borrowed(&query), @@ -488,12 +406,11 @@ impl NativeSession { expression: LocatorExpression, timeout_ms: Option, ) -> Result> { - self.call(move |library, session| { + self.call(move |session| { let query = query(expression)?; result( - library, unsafe { - (library.functions.tui_highlight_locator)( + crate::tui_highlight_locator( session, TuiQuery { json: borrowed(&query), @@ -514,10 +431,9 @@ impl NativeSession { #[napi] pub async fn get_command(&self) -> Result> { - self.call(|library, session| { + self.call(|session| { result( - library, - unsafe { (library.functions.tui_get_command)(session) }, + unsafe { crate::tui_get_command(session) }, optional_string_result, ) }) @@ -526,10 +442,9 @@ impl NativeSession { #[napi] pub async fn get_output(&self) -> Result> { - self.call(|library, session| { + self.call(|session| { result( - library, - unsafe { (library.functions.tui_get_output)(session) }, + unsafe { crate::tui_get_output(session) }, optional_string_result, ) }) @@ -538,22 +453,19 @@ impl NativeSession { #[napi] pub async fn get_exit_code(&self) -> Result> { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_exit_code)(session) }, - |value| Ok(value.exit_code.present.then_some(value.exit_code.value)), - ) + self.call(|session| { + result(unsafe { crate::tui_get_exit_code(session) }, |value| { + Ok(value.exit_code.present.then_some(value.exit_code.value)) + }) }) .await } #[napi] pub async fn get_cwd(&self) -> Result> { - self.call(|library, session| { + self.call(|session| { result( - library, - unsafe { (library.functions.tui_get_cwd)(session) }, + unsafe { crate::tui_get_cwd(session) }, optional_string_result, ) }) @@ -562,10 +474,9 @@ impl NativeSession { #[napi] pub async fn get_title(&self) -> Result> { - self.call(|library, session| { + self.call(|session| { result( - library, - unsafe { (library.functions.tui_get_title)(session) }, + unsafe { crate::tui_get_title(session) }, optional_string_result, ) }) @@ -574,109 +485,78 @@ impl NativeSession { #[napi] pub async fn get_clipboard(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_clipboard)(session) }, - string_result, - ) - }) - .await + self.call(|session| result(unsafe { crate::tui_get_clipboard(session) }, string_result)) + .await } #[napi] pub async fn get_cursor(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_cursor)(session) }, - |value| Ok(cursor(value.cursor)), - ) + self.call(|session| { + result(unsafe { crate::tui_get_cursor(session) }, |value| { + Ok(cursor(value.cursor)) + }) }) .await } #[napi] pub async fn get_size(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_size)(session) }, - |value| { - Ok(Size { - cols: value.size.cols, - rows: value.size.rows, - }) - }, - ) + self.call(|session| { + result(unsafe { crate::tui_get_size(session) }, |value| { + Ok(Size { + cols: value.size.cols, + rows: value.size.rows, + }) + }) }) .await } #[napi] pub async fn get_bell_count(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_bell_count)(session) }, - |value| Ok(value.number as f64), - ) + self.call(|session| { + result(unsafe { crate::tui_get_bell_count(session) }, |value| { + Ok(value.number as f64) + }) }) .await } #[napi] pub async fn get_bell_events(&self) -> Result> { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_get_bell_events)(session) }, - |value| { - Ok(array(value.bells, value.bells_len) - .iter() - .map(|value| BellEvent { - sequence: value.sequence as f64, - elapsed_ms: value.elapsed_ms as f64, - }) - .collect()) - }, - ) + self.call(|session| { + result(unsafe { crate::tui_get_bell_events(session) }, |value| { + Ok(array(value.bells, value.bells_len) + .iter() + .map(|value| BellEvent { + sequence: value.sequence as f64, + elapsed_ms: value.elapsed_ms as f64, + }) + .collect()) + }) }) .await } #[napi] pub async fn write(&self, data: String) -> Result<()> { - self.call(move |library, session| { - result( - library, - unsafe { (library.functions.tui_write)(session, borrowed(&data)) }, - unit, - ) + self.call(move |session| { + result(unsafe { crate::tui_write(session, borrowed(&data)) }, unit) }) .await } #[napi(js_name = "type")] pub async fn type_text(&self, text: String) -> Result<()> { - self.call(move |library, session| { - result( - library, - unsafe { (library.functions.tui_type)(session, borrowed(&text)) }, - unit, - ) - }) - .await + self.call(move |session| result(unsafe { crate::tui_type(session, borrowed(&text)) }, unit)) + .await } #[napi] pub async fn submit(&self, data: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, - unsafe { - (library.functions.tui_submit)(session, optional_borrowed(data.as_deref())) - }, + unsafe { crate::tui_submit(session, optional_borrowed(data.as_deref())) }, unit, ) }) @@ -706,11 +586,10 @@ impl NativeSession { #[napi] pub async fn mouse_click(&self, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_mouse_click)( + crate::tui_mouse_click( session, optional_integer(options.x, "x", u16::MAX.into())?, optional_integer(options.y, "y", u16::MAX.into())?, @@ -727,16 +606,9 @@ impl NativeSession { #[napi] pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, - unsafe { - (library.functions.tui_mouse_move)( - session, - u16_value(x, "x")?, - u16_value(y, "y")?, - ) - }, + unsafe { crate::tui_mouse_move(session, u16_value(x, "x")?, u16_value(y, "y")?) }, unit, ) }) @@ -745,11 +617,10 @@ impl NativeSession { #[napi] pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_mouse_down)( + crate::tui_mouse_down( session, u16_value(x, "x")?, u16_value(y, "y")?, @@ -764,11 +635,10 @@ impl NativeSession { #[napi] pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_mouse_up)( + crate::tui_mouse_up( session, u16_value(x, "x")?, u16_value(y, "y")?, @@ -790,11 +660,10 @@ impl NativeSession { y2: f64, button: Option, ) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_mouse_drag)( + crate::tui_mouse_drag( session, u16_value(x1, "x1")?, u16_value(y1, "y1")?, @@ -811,11 +680,10 @@ impl NativeSession { #[napi] pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_mouse_scroll)( + crate::tui_mouse_scroll( session, borrowed(&direction), u16_value(amount.unwrap_or(3.0), "amount")?, @@ -829,15 +697,10 @@ impl NativeSession { #[napi] pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_resize)( - session, - u16_value(cols, "cols")?, - u16_value(rows, "rows")?, - ) + crate::tui_resize(session, u16_value(cols, "cols")?, u16_value(rows, "rows")?) }, unit, ) @@ -847,12 +710,8 @@ impl NativeSession { #[napi] pub async fn signal(&self, name: String) -> Result<()> { - self.call(move |library, session| { - result( - library, - unsafe { (library.functions.tui_signal)(session, borrowed(&name)) }, - unit, - ) + self.call(move |session| { + result(unsafe { crate::tui_signal(session, borrowed(&name)) }, unit) }) .await } @@ -860,11 +719,10 @@ impl NativeSession { #[napi] pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_title)( + crate::tui_wait_title( session, borrowed(&text), wait_options( @@ -883,11 +741,10 @@ impl NativeSession { #[napi] pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_expect_title)( + crate::tui_expect_title( session, borrowed(&text), wait_options( @@ -910,11 +767,10 @@ impl NativeSession { options: Option, ) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_clipboard)( + crate::tui_wait_clipboard( session, optional_borrowed(text.as_deref()), wait_options(options.timeout_ms, options.regex.unwrap_or(false), false)?, @@ -928,11 +784,10 @@ impl NativeSession { #[napi] pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_idle)( + crate::tui_wait_idle( session, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, ) @@ -945,11 +800,10 @@ impl NativeSession { #[napi] pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_command)( + crate::tui_wait_command( session, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, ) @@ -962,11 +816,10 @@ impl NativeSession { #[napi] pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_exit)( + crate::tui_wait_exit( session, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, ) @@ -979,11 +832,10 @@ impl NativeSession { #[napi] pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_ready)( + crate::tui_wait_ready( session, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, ) @@ -996,11 +848,10 @@ impl NativeSession { #[napi] pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_wait_bell)( + crate::tui_wait_bell( session, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, ) @@ -1013,11 +864,10 @@ impl NativeSession { #[napi] pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_expect_exit_code)( + crate::tui_expect_exit_code( session, i32_value(code, "code")?, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, @@ -1031,15 +881,10 @@ impl NativeSession { #[napi] pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_expect_output)( - session, - borrowed(&text), - regex.unwrap_or(false), - ) + crate::tui_expect_output(session, borrowed(&text), regex.unwrap_or(false)) }, unit, ) @@ -1049,11 +894,10 @@ impl NativeSession { #[napi] pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_expect_bell_count)( + crate::tui_expect_bell_count( session, integer(count, "count", u64::MAX)?, optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, @@ -1072,11 +916,10 @@ impl NativeSession { options: Option, ) -> Result { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_snapshot)( + crate::tui_snapshot( session, borrowed(&name), options.update.unwrap_or(false), @@ -1101,11 +944,10 @@ impl NativeSession { #[napi] pub async fn screenshot(&self, options: Option) -> Result { let options = options.unwrap_or_default(); - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_screenshot)( + crate::tui_screenshot( session, options.full.unwrap_or(false), optional_borrowed(options.path.as_deref()), @@ -1122,11 +964,10 @@ impl NativeSession { #[napi] pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { result( - library, unsafe { - (library.functions.tui_start_recording)( + crate::tui_start_recording( session, TuiRecordingOptions { path: borrowed(&options.path), @@ -1155,30 +996,18 @@ impl NativeSession { #[napi] pub async fn stop_recording(&self) -> Result { - self.call(|library, session| { - result( - library, - unsafe { (library.functions.tui_stop_recording)(session) }, - string_result, - ) - }) - .await + self.call(|session| result(unsafe { crate::tui_stop_recording(session) }, string_result)) + .await } } impl NativeSession { async fn keys(&self, keys: Vec, action: u32) -> Result<()> { - self.call(move |library, session| { + self.call(move |session| { let borrowed_keys: Vec<_> = keys.iter().map(|value| borrowed(value)).collect(); result( - library, unsafe { - (library.functions.tui_key)( - session, - borrowed_keys.as_ptr(), - borrowed_keys.len(), - action, - ) + crate::tui_key(session, borrowed_keys.as_ptr(), borrowed_keys.len(), action) }, unit, ) @@ -1190,17 +1019,12 @@ impl NativeSession { #[napi] pub async fn sessions() -> Result> { blocking(|| { - let library = library()?; - result( - &library, - unsafe { (library.functions.tui_sessions)() }, - |value| { - Ok(array(value.strings, value.strings_len) - .iter() - .map(|value| text(*value)) - .collect()) - }, - ) + result(crate::tui_sessions(), |value| { + Ok(array(value.strings, value.strings_len) + .iter() + .map(|value| text(*value)) + .collect()) + }) }) .await } @@ -1212,23 +1036,14 @@ pub async fn close_all() -> Result<()> { #[napi] pub fn close_all_sync() -> Result<()> { - boundary(|| { - let library = library()?; - result( - &library, - unsafe { (library.functions.tui_close_all)() }, - unit, - ) - }) + boundary(|| result(crate::tui_close_all(), unit)) } #[napi] pub async fn recording(name: String) -> Result { blocking(move || { - let library = library()?; result( - &library, - unsafe { (library.functions.tui_recording)(borrowed(&name)) }, + unsafe { crate::tui_recording(borrowed(&name)) }, string_result, ) }) diff --git a/bindings/js/native/types.rs b/crates/tui-test-ffi/src/napi/types.rs similarity index 99% rename from bindings/js/native/types.rs rename to crates/tui-test-ffi/src/napi/types.rs index a1a57165..91256dba 100644 --- a/bindings/js/native/types.rs +++ b/crates/tui-test-ffi/src/napi/types.rs @@ -1,4 +1,4 @@ -use napi::bindgen_prelude::{ +use ::napi::bindgen_prelude::{ Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, }; use napi::{Error, Result, Status}; diff --git a/tools/bindgen/src/main.rs b/tools/bindgen/src/main.rs index eff5d443..292a95a4 100644 --- a/tools/bindgen/src/main.rs +++ b/tools/bindgen/src/main.rs @@ -101,16 +101,6 @@ fn main() -> Result<()> { &python(&structs, &functions, &version)?, check, )?; - emit( - &root, - "bindings/js/native/abi.rs", - &format_source( - "rustfmt", - &["--edition", "2021", "--emit", "stdout"], - &rust(&types_source, &functions, &version)?, - )?, - check, - )?; Ok(()) } @@ -378,33 +368,6 @@ fn python(structs: &[&ItemStruct], functions: &[&ItemFn], version: &str) -> Resu Ok(out) } -fn rust(types: &str, functions: &[&ItemFn], version: &str) -> Result { - let mut out = format!("// {NOTICE}\n#![allow(dead_code)]\n{types}\npub const ABI_VERSION: u32 = {version};\n\npub struct Functions {{\n"); - for function in functions { - let args = arguments(function)? - .into_iter() - .map(|ty| ty.to_token_stream().to_string()) - .collect::>() - .join(", "); - writeln!( - out, - "pub {}: unsafe extern \"C\" fn({args}) {},", - function.sig.ident, - function.sig.output.to_token_stream() - )?; - } - out.push_str("}\nimpl Functions {\n/// # Safety\n/// The library must implement this ABI and outlive all calls through this table.\npub unsafe fn load(library: &libloading::Library) -> Result {\nOk(Self {\n"); - for function in functions { - writeln!( - out, - "{}: unsafe {{ *library.get(b\"{}\\0\")? }},", - function.sig.ident, function.sig.ident - )?; - } - out.push_str("})\n}\n}\n"); - Ok(out) -} - #[cfg(test)] mod tests { use super::*; From 452cdde63c8d509e0becabe011b5754096213ab3 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 14:06:30 -0400 Subject: [PATCH 03/15] refactor: share safe execution between native adapters Signed-off-by: Ayman Bagabas --- bindings/go/CONTRIBUTING.md | 2 +- bindings/js/README.md | 4 +- bindings/js/test/integration.test.mjs | 4 + bindings/js/test/native.test.mjs | 43 + crates/tui-test-ffi/README.md | 24 +- crates/tui-test-ffi/src/input.rs | 99 +- crates/tui-test-ffi/src/lib.rs | 46 +- crates/tui-test-ffi/src/napi/convert.rs | 400 ++++--- crates/tui-test-ffi/src/napi/mod.rs | 1281 +++++++++++------------ crates/tui-test-ffi/src/session.rs | 155 +++ crates/tui-test-ffi/src/tests.rs | 64 ++ 11 files changed, 1099 insertions(+), 1023 deletions(-) create mode 100644 crates/tui-test-ffi/src/session.rs diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index 8911eea0..715a7789 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -16,7 +16,7 @@ The library stays 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 an internal contract shared by all language bindings, not a stable public C SDK. +The C ABI is an internal contract shared by Go and Python, not a stable public C SDK. Native allocations must be released by their matching Rust functions, and native code must not retain Go pointers. `internal/native/abi.go` and `internal/native/functions.go` are generated from the Rust ABI declarations. diff --git a/bindings/js/README.md b/bindings/js/README.md index d9bf278f..a3d61d0a 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -11,7 +11,9 @@ npm install @microsoft/tui-test Node 20+ is supported. The package is ESM only. Bun and Deno support is best effort. Deno 2 needs local `node_modules` and `--allow-ffi`. Each platform package contains one self-contained `.node` addon. -The addon uses the optional `napi` feature in `tui-test-ffi` and calls the typed C ABI directly. +The addon uses the optional `napi` feature in `tui-test-ffi` and calls Rust operations directly. +It shares safe execution, context validation, and close logic with the C ABI adapter. +It converts owned Rust results into JavaScript values without C result pointers. It needs no companion shared library or CLI. `TUI_TEST_NATIVE_LIBRARY` applies only to Go and Python; JavaScript ignores it. diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 70624ef6..e10e879c 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -597,8 +597,12 @@ test("private packed screens retain full UTF-8 logical rows and own their bytes" const second = await runtime.packedScreen(false); assert.notStrictEqual(first.utf8, second.utf8); assert.match(decoder.decode(second.utf8), /I🙂R/); + const retainedBytes = Uint8Array.from(second.utf8); + const retainedCells = structuredClone(cells); await su.close(); assert.equal(first.utf8.length > 0, true); + assert.deepEqual(Uint8Array.from(second.utf8), retainedBytes); + assert.deepEqual(cells, retainedCells); } finally { await su.closeQuiet(); } diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index d78f57d3..ad44e6b2 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -167,6 +167,49 @@ test("native constructor validation errors use the public error taxonomy", async import { NativeSession } from "../native/index.js"; +test("owned native inputs preserve numeric and operation validation", async () => { + const session = new NativeSession(`owned-validation-${process.pid}`); + const failures = [ + [() => session.open({ cols: 65536 }), /cols must be an integer between 0 and 65535/], + [() => session.open({ timeouts: { text: Number.NaN } }), /timeouts.text must be an integer/], + [() => session.run({ program: "" }), /program must not be empty/], + [() => session.run({ program: "unused", screenHistoryLimit: 65535 }), /screen history limit/], + [() => session.resize(1.5, 20), /cols must be an integer/], + [() => session.waitIdle(Number.MAX_SAFE_INTEGER + 1), /timeoutMs must be an integer/], + [() => session.mouseDown(0, 0, 3), /invalid mouse button code 3/], + [() => session.mouseClick({ x: -1 }), /x must be an integer/], + [() => session.expectExitCode(2147483648), /code must be an integer/], + [() => session.startRecording({ path: "unused", fps: 256 }), /fps must be an integer/], + [() => session.screenshot({ background: "#000000", transparent: true }), /background and transparent options conflict/], + [() => session.waitClipboard(undefined, { regex: true }), /clipboard regex requires text/], + [() => session.waitClipboard("[", { regex: true }), /invalid regex/], + ]; + for (const [call, message] of failures) { + await assert.rejects(call, (error) => { + const mapped = mapNativeError(error); + assert.ok(mapped instanceof UsageError, mapped.message); + assert.match(mapped.message, message); + return true; + }); + } + await session.close(false); + await session.close(true); +}); + +test("owned session contexts validate each call without changing caller options", async () => { + const trace = { mode: "invalid", directory: "" }; + const session = new NativeSession(`owned-context-${process.pid}`, undefined, undefined, trace); + for (const call of [() => session.state(), () => session.open(), () => session.close(false)]) { + await assert.rejects(call, (error) => { + const mapped = mapNativeError(error); + assert.ok(mapped instanceof UsageError); + assert.match(mapped.message, /unknown trace mode/); + return true; + }); + } + assert.deepEqual(trace, { mode: "invalid", directory: "" }); +}); + test("locator expressions reject invalid fields and references", async () => { const session = new NativeSession(`invalid-expression-${process.pid}`); for (const node of [ diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index 70b2ce76..a4497d61 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -3,7 +3,9 @@ `tui-test-ffi` exposes the Rust engine through a typed C ABI. Go uses `purego`, and Python uses `ctypes`, to load the default shared library. The optional `napi` feature adds the JavaScript adapter to this crate. -That adapter calls the C ABI functions directly and produces one self-contained `.node` addon. +That adapter constructs Rust `Operation` values and converts owned `OperationResult` values directly. +Both adapters share safe Rust execution, context validation, and close logic. +The JavaScript build produces one self-contained `.node` addon without C ABI calls or result pointers. The default build has no N-API dependencies. The library and language package must come from the same source revision. @@ -41,8 +43,11 @@ node --input-type=module -e ' import { TuiTest } from "./dist/index.js"; const terminal = TuiTest.ephemeral(); try { - await terminal.run(process.execPath, ["-e", "console.log(\"Ready\")"]); - await terminal.getByText("Ready").expect(); + await terminal.run(process.execPath, ["-e", "console.log(\"Ready 界🙂\")"]); + await terminal.getByText("Ready 界🙂").expect(); + await terminal.waitExit(); + const state = await terminal.state(); + console.log(`Terminal size: ${state.cols}x${state.rows}`); console.log(await terminal.text()); } finally { await terminal.closeQuiet(); @@ -51,7 +56,7 @@ node --input-type=module -e ' ``` The build enables `--features napi` and writes the addon, loader, and declarations to `bindings/js/native/`. -The example prints terminal text that contains `Ready`. +The example prints the terminal size and text that contains `Ready 界🙂`. ## Generate declarations @@ -65,13 +70,13 @@ cargo run --locked -p tui-test-bindgen -- --check The generator uses `cbindgen` for `include/tui_test.h`. It also generates the Go structs and function table, and Python `ctypes` declarations. -The JavaScript adapter uses the canonical Rust ABI types without a generated function table. +The JavaScript adapter uses Rust operations and its own JavaScript DTOs, not the C ABI declarations. Run the generator with Go and Rust formatters on `PATH`. The language adapters still own asynchronous execution, error presentation, input storage, and result conversion. They do not implement terminal behavior. -## Ownership +## C ABI ownership Input buffers must remain valid until a call returns. The engine copies inputs before it executes an operation. A null string pointer means absent. A non-null pointer with zero length means an explicit empty string. @@ -83,3 +88,10 @@ Check `error_kind` before reading a successful result. Keep the library loaded while any call or allocation remains active. Increase `ABI_VERSION` when a change makes existing declarations incompatible. + +## JavaScript ownership + +The N-API adapter moves owned strings, arrays, and screen bytes from Rust results into JavaScript values. +It does not allocate or free C ABI results. +Blocking workers execute operations with a typed session context; they do not serialize that context for each call. +The adapter retains JavaScript numeric validation and the structured error envelope. diff --git a/crates/tui-test-ffi/src/input.rs b/crates/tui-test-ffi/src/input.rs index 56fc26ff..97c69ab5 100644 --- a/crates/tui-test-ffi/src/input.rs +++ b/crates/tui-test-ffi/src/input.rs @@ -1,12 +1,10 @@ +use crate::session::SessionInput; 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, -}; +use tui_test::ExecutionContext; type Result = std::result::Result; @@ -66,89 +64,11 @@ pub(crate) unsafe fn strings(data: *const TuiString, len: usize) -> Result, - #[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() - }, - )) + value.validate() } unsafe fn pairs(data: *const TuiPair, len: usize) -> Result> { unsafe { slice(data, len)? } @@ -259,18 +179,7 @@ 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() + crate::session::capture_background(unsafe { background.optional()? }, transparent) } pub(crate) unsafe fn recording(value: TuiRecordingOptions) -> Result { let format = unsafe { value.format.optional()? } diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index 25ef3297..789d9ee6 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -1,10 +1,11 @@ -//! Shared typed C ABI for language bindings. 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. +//! Native language adapters with shared Rust execution and an optional N-API adapter. +//! C ABI input buffers must remain valid for the call. The adapter copies inputs +//! before execution; C output storage belongs to Rust until tui_result_free. mod input; #[cfg(all(feature = "napi", not(test)))] mod napi; mod output; +mod session; mod types; use std::panic::{catch_unwind, AssertUnwindSafe}; use tui_test::api::*; @@ -28,9 +29,7 @@ unsafe fn execute( ) -> *mut TuiResult { boundary(|| { let (session, context) = unsafe { input::session(session)? }; - global_registry() - .session(session) - .execute_with_context(operation()?, context.with_operation(operation_name)) + session::execute(session, context, operation_name, operation()?) }) } @@ -159,21 +158,7 @@ pub extern "C" fn tui_close_all() -> *mut TuiResult { 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}" - )) - } - }) + session::recording(&session) }) } #[no_mangle] @@ -183,24 +168,7 @@ pub unsafe extern "C" fn tui_recording(session: TuiString) -> *mut TuiResult { 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")) + session::close(name, context, failed.option()) }) } #[no_mangle] diff --git a/crates/tui-test-ffi/src/napi/convert.rs b/crates/tui-test-ffi/src/napi/convert.rs index ea72b439..1e37ceb2 100644 --- a/crates/tui-test-ffi/src/napi/convert.rs +++ b/crates/tui-test-ffi/src/napi/convert.rs @@ -1,49 +1,10 @@ -use super::{internal, types::*, usage, MAX_SAFE_INTEGER}; -use crate::types::*; +use super::{internal, native_error, types::*, usage, MAX_SAFE_INTEGER}; use napi::bindgen_prelude::Either; use napi::Result; +use tui_test::api::{self, OperationResult}; +use tui_test::profile::{Profile, Rgb}; +use tui_test::terminal::backend::Backend as TerminalBackend; -pub fn borrowed(value: &str) -> TuiString { - TuiString { - data: value.as_ptr(), - len: value.len(), - } -} - -pub fn optional_borrowed(value: Option<&str>) -> TuiString { - value.map(borrowed).unwrap_or_default() -} - -pub fn array<'a, T>(data: *const T, len: usize) -> &'a [T] { - if len == 0 { - &[] - } else { - unsafe { std::slice::from_raw_parts(data, len) } - } -} - -pub fn bytes<'a>(value: TuiString) -> &'a [u8] { - array(value.data, value.len) -} -pub fn text(value: TuiString) -> String { - String::from_utf8_lossy(bytes(value)).into_owned() -} -pub fn optional_text(value: TuiString) -> Option { - (!value.data.is_null()).then(|| text(value)) -} - -pub fn optional_bool(value: Option) -> TuiOptionalBool { - TuiOptionalBool { - present: value.is_some(), - value: value.unwrap_or_default(), - } -} -pub fn optional_float(value: Option) -> TuiOptionalF64 { - TuiOptionalF64 { - present: value.is_some(), - value: value.unwrap_or_default(), - } -} pub fn integer(value: f64, name: &str, max: u64) -> Result { let max = max.min(MAX_SAFE_INTEGER); if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > max as f64 { @@ -53,21 +14,23 @@ pub fn integer(value: f64, name: &str, max: u64) -> Result { } Ok(value as u64) } -pub fn optional_integer(value: Option, name: &str, max: u64) -> Result { - Ok(TuiOptionalU64 { - present: value.is_some(), - value: value - .map(|value| integer(value, name, max)) - .transpose()? - .unwrap_or_default(), - }) + +pub fn optional_integer(value: Option, name: &str, max: u64) -> Result> { + value.map(|value| integer(value, name, max)).transpose() } + pub fn u16_value(value: f64, name: &str) -> Result { Ok(integer(value, name, u16::MAX.into())? as u16) } + +pub fn optional_u16(value: Option, name: &str) -> Result> { + value.map(|value| u16_value(value, name)).transpose() +} + pub fn u8_value(value: f64, name: &str) -> Result { Ok(integer(value, name, u8::MAX.into())? as u8) } + pub fn i32_value(value: f64, name: &str) -> Result { if !value.is_finite() || value.fract() != 0.0 @@ -82,130 +45,81 @@ pub fn i32_value(value: f64, name: &str) -> Result { } Ok(value as i32) } -pub fn wait_options(timeout: Option, regex: bool, not: bool) -> Result { - Ok(TuiWaitOptions { - timeout_ms: optional_integer(timeout, "timeoutMs", u64::MAX)?, - regex, - not, - }) -} -pub fn mouse_options(value: f64) -> Result { + +pub fn mouse_options(value: f64) -> Result { let code = u8_value(value, "button")?; if code & !31 != 0 || code & 3 == 3 { return Err(usage(format!("invalid mouse button code {code}"))); } - Ok(TuiMouseOptions { - button: (code & 3).into(), + Ok(api::MouseOptions { + button: match code & 3 { + 0 => api::MouseButton::Left, + 1 => api::MouseButton::Middle, + _ => api::MouseButton::Right, + }, shift: code & 4 != 0, alt: code & 8 != 0, ctrl: code & 16 != 0, }) } -pub fn session_context(context: &serde_json::Value, limit: Option) -> Result { - let mut context = context.clone(); - if let Some(limit) = limit { - context["screen_history_limit"] = u16_value(limit, "screenHistoryLimit")?.into(); - } - Ok(context.to_string()) -} - -pub struct OpenInput { - pub options: TuiOpenOptions, - _value: OpenOptions, - _env: Vec, - _colors: Vec, - _directory: Option, -} -impl OpenInput { - pub fn new(value: OpenOptions, directory: Option) -> Result { - let pairs = |pairs: &Option>| -> Vec { - pairs - .as_deref() - .unwrap_or_default() - .iter() - .map(|(key, value)| TuiPair { - key: borrowed(key), - value: borrowed(value), - }) - .collect() - }; - let env = pairs(&value.env); - let colors = pairs(&value.profile_colors); - let timeouts = value.timeouts.as_ref(); - let options = TuiOpenOptions { - backend: optional_borrowed(value.backend.as_ref().map(|value| match value { - Backend::Alacritty => "alacritty", - Backend::Ghostty => "ghostty", - Backend::Rio => "rio", - Backend::Xtermjs => "xtermjs", - })), - shell: optional_borrowed(value.shell.as_ref().map(|value| match value { - Shell::Bash => "bash", - Shell::Powershell => "powershell", - Shell::Pwsh => "pwsh", - Shell::Cmd => "cmd", - Shell::Fish => "fish", - Shell::Zsh => "zsh", - Shell::Xonsh => "xonsh", - Shell::Elvish => "elvish", - Shell::Nushell => "nushell", - })), - cols: optional_integer(value.cols, "cols", u16::MAX.into())?, - rows: optional_integer(value.rows, "rows", u16::MAX.into())?, - cwd: optional_borrowed(value.cwd.as_deref()), - env: env.as_ptr(), - env_len: env.len(), - wait_ready: optional_bool(value.wait_ready), - restart: value.restart.unwrap_or(false), - scrollback: optional_integer( - value.profile_scrollback, - "profile.scrollback", - usize::MAX as u64, - )?, - colors: colors.as_ptr(), - colors_len: colors.len(), - timeouts: TuiTimeouts { - text: optional_integer( - timeouts.and_then(|value| value.text), - "timeouts.text", - u64::MAX, - )?, - idle: optional_integer( - timeouts.and_then(|value| value.idle), - "timeouts.idle", - u64::MAX, - )?, - command: optional_integer( - timeouts.and_then(|value| value.command), - "timeouts.command", - u64::MAX, - )?, - exit: optional_integer( - timeouts.and_then(|value| value.exit), - "timeouts.exit", - u64::MAX, - )?, - ready: optional_integer( - timeouts.and_then(|value| value.ready), - "timeouts.ready", - u64::MAX, - )?, - }, - recording_directory: optional_borrowed(directory.as_deref()), - ..Default::default() - }; - Ok(Self { - options, - _value: value, - _env: env, - _colors: colors, - _directory: directory, - }) +pub fn open_options(value: OpenOptions, directory: Option) -> Result { + let mut profile = Profile::default(); + if let Some(scrollback) = optional_integer( + value.profile_scrollback, + "profile.scrollback", + usize::MAX as u64, + )? { + profile.scrollback = scrollback as usize; + } + for (name, color) in value.profile_colors.unwrap_or_default() { + let color = Rgb::parse(&color).map_err(usage)?; + if !profile.colors.set_named(&name, color) { + return Err(usage(format!("unknown profile color {name:?}"))); + } } + let timeouts = value.timeouts.unwrap_or_default(); + Ok(api::OpenOptions { + backend: match value.backend { + None => Default::default(), + Some(Backend::Alacritty) => TerminalBackend::Alacritty, + Some(Backend::Ghostty) => TerminalBackend::Ghostty, + Some(Backend::Rio) => TerminalBackend::Rio, + Some(Backend::Xtermjs) => TerminalBackend::Xtermjs, + }, + shell: value.shell.map(|shell| match shell { + Shell::Bash => tui_test::shell::Shell::Bash, + Shell::Powershell => tui_test::shell::Shell::Powershell, + Shell::Pwsh => tui_test::shell::Shell::Pwsh, + Shell::Cmd => tui_test::shell::Shell::Cmd, + Shell::Fish => tui_test::shell::Shell::Fish, + Shell::Zsh => tui_test::shell::Shell::Zsh, + Shell::Xonsh => tui_test::shell::Shell::Xonsh, + Shell::Elvish => tui_test::shell::Shell::Elvish, + Shell::Nushell => tui_test::shell::Shell::Nushell, + }), + profile, + cols: u16_value(value.cols.unwrap_or(80.0), "cols")?, + rows: u16_value(value.rows.unwrap_or(30.0), "rows")?, + cwd: value.cwd, + env: value.env.unwrap_or_default(), + wait_ready: value.wait_ready, + restart: value.restart.unwrap_or(false), + timeouts: api::Timeouts { + text: optional_integer(timeouts.text, "timeouts.text", u64::MAX)?, + idle: optional_integer(timeouts.idle, "timeouts.idle", u64::MAX)?, + command: optional_integer(timeouts.command, "timeouts.command", u64::MAX)?, + exit: optional_integer(timeouts.exit, "timeouts.exit", u64::MAX)?, + ready: optional_integer(timeouts.ready, "timeouts.ready", u64::MAX)?, + }, + recording: api::AutomaticRecording { + mode: api::AutomaticRecordingMode::Disabled, + directory: directory.map(Into::into), + }, + }) } -pub fn query(expression: LocatorExpression) -> Result { +pub fn query(expression: LocatorExpression) -> Result { let mut nodes = Vec::with_capacity(expression.nodes.len()); for (index, node) in expression.nodes.into_iter().enumerate() { let occurrence = if let Some(nth) = node.nth { @@ -237,89 +151,120 @@ pub fn query(expression: LocatorExpression) -> Result { "has": reference(node.has)?, "has_not": reference(node.has_not)?, })); } - Ok( - serde_json::json!({"nodes": nodes, "root": integer(expression.root, "locator root", 255)?}) - .to_string(), + serde_json::from_value::( + serde_json::json!({"nodes": nodes, "root": integer(expression.root, "locator root", 255)?}), ) + .map_err(|error| usage(format!("invalid locator expression: {error}")))? + .into_query() + .map_err(native_error) +} + +pub fn unit(value: OperationResult) -> Result<()> { + match value { + OperationResult::Unit => Ok(()), + _ => Err(internal("native operation returned an unexpected result")), + } +} + +pub fn string_result(value: OperationResult) -> Result { + match value { + OperationResult::Text(value) + | OperationResult::Clipboard(value) + | OperationResult::Recording(value) + | OperationResult::Screenshot( + api::ScreenshotResult::Path(value) | api::ScreenshotResult::Text(value), + ) => Ok(value), + _ => Err(internal("native operation returned an unexpected result")), + } +} + +pub fn optional_string_result(value: OperationResult) -> Result> { + match value { + OperationResult::Command(value) + | OperationResult::Output(value) + | OperationResult::Cwd(value) + | OperationResult::Title(value) => Ok(value), + _ => Err(internal("native operation returned an unexpected result")), + } } -pub fn open_result(value: &TuiResult) -> Result { +pub fn open_result(value: OperationResult) -> Result { + let OperationResult::Open(value) = value else { + return Err(internal("native operation returned an unexpected result")); + }; Ok(OpenResult { - shell_pid: value - .open - .shell_pid - .present - .then_some(value.open.shell_pid.value as u32), - session: text(value.open.session), - ready: value.open.ready, - recording: text(value.open.recording), + shell_pid: value.shell_pid, + session: value.session, + ready: value.ready, + recording: value.recording, }) } -pub fn cursor(value: TuiCursor) -> Cursor { + +pub fn cursor(value: api::Cursor) -> Cursor { Cursor { x: value.x, y: value.y, visible: value.visible, - shape: text(value.shape), - color: text(value.color), + shape: value.shape, + color: value.color, } } -pub fn state(value: &TuiState) -> State { + +pub fn state(value: api::State) -> State { State { - session_shell: optional_text(value.session_shell), + session_shell: value.session_shell, cols: value.cols, rows: value.rows, cursor: cursor(value.cursor), - title: optional_text(value.title), - cwd: optional_text(value.cwd), - last_command: optional_text(value.last_command), - last_exit: value.last_exit.present.then_some(value.last_exit.value), - exit_signal: optional_text(value.exit_signal), - exited: value.exited.present.then_some(value.exited.value), + title: value.title, + cwd: value.cwd, + last_command: value.last_command, + last_exit: value.last_exit, + exit_signal: value.exit_signal, + exited: value.exited, ready: value.ready, bell_count: value.bell_count as f64, - modes: array(value.modes, value.modes_len) - .iter() - .map(|value| (text(value.name), value.enabled)) - .collect(), - mouse_mode: text(value.mouse_mode), + modes: value.modes.into_iter().collect(), + mouse_mode: value.mouse_mode, colors: TerminalColors { - foreground: text(value.colors.foreground), - background: text(value.colors.background), - cursor: text(value.colors.cursor), - palette: array(value.colors.palette, value.colors.palette_len) - .iter() - .map(|value| (text(value.key), text(value.value))) + foreground: value.colors.foreground, + background: value.colors.background, + cursor: value.colors.cursor, + palette: value + .colors + .palette + .into_iter() + .map(|(index, color)| (index.to_string(), color)) .collect(), }, timeouts: EffectiveTimeouts { - text: value.timeouts.text.value as f64, - idle: value.timeouts.idle.value as f64, - command: value.timeouts.command.value as f64, - exit: value.timeouts.exit.value as f64, - ready: value.timeouts.ready.value as f64, + text: value.timeouts.text as f64, + idle: value.timeouts.idle as f64, + command: value.timeouts.command as f64, + exit: value.timeouts.exit as f64, + ready: value.timeouts.ready as f64, }, - text: text(value.text), + text: value.text, } } -pub fn color(value: TuiColor) -> Result { - match value.kind { - 0 => Ok(Either::B("default".into())), - 1 => Ok(Either::A(value.index.into())), - 2 => Ok(Either::B(format!( - "#{:02x}{:02x}{:02x}", - value.red, value.green, value.blue - ))), - _ => Err(internal("terminal returned unknown color kind")), + +pub fn color(value: api::CellColor) -> Color { + match value { + api::CellColor::Default => Either::B("default".into()), + api::CellColor::Indexed(index) => Either::A(index.into()), + api::CellColor::Rgb(red, green, blue) => { + Either::B(format!("#{red:02x}{green:02x}{blue:02x}")) + } } } -pub fn cell(value: &TuiCell) -> Result { + +pub fn cell(value: api::Cell) -> Result { Ok(Cell { x: value.x, y: value.y, - r#char: text(value.character), - fg: color(value.fg)?, - bg: color(value.bg)?, + r#char: value.char, + fg: color(value.fg), + bg: color(value.bg), bold: value.bold, dim: value.dim, italic: value.italic, @@ -328,7 +273,7 @@ pub fn cell(value: &TuiCell) -> Result { strike: value.strike, blink: value.blink, underline: value.underline, - underline_style: match text(value.underline_style).as_str() { + underline_style: match value.underline_style.as_str() { "none" => UnderlineStyle::None, "single" => UnderlineStyle::Single, "double" => UnderlineStyle::Double, @@ -341,16 +286,20 @@ pub fn cell(value: &TuiCell) -> Result { ))) } }, - underline_color: color(value.underline_color)?, - link: text(value.link), - link_id: text(value.link_id), + underline_color: color(value.underline_color), + link: value.link, + link_id: value.link_id, }) } -pub fn matches(value: &TuiResult) -> Result> { - Ok(array(value.matches, value.matches_len) - .iter() + +pub fn matches(value: OperationResult) -> Result> { + let OperationResult::Matches(value) = value else { + return Err(internal("native operation returned an unexpected result")); + }; + Ok(value + .into_iter() .map(|value| TextMatch { - text: text(value.text), + text: value.text, start: TextPosition { row: value.start.row, column: value.start.column, @@ -359,8 +308,9 @@ pub fn matches(value: &TuiResult) -> Result> { row: value.end.row, column: value.end.column, }, - spans: array(value.spans, value.spans_len) - .iter() + spans: value + .spans + .into_iter() .map(|value| TextSpan { row: value.row, start: value.start, diff --git a/crates/tui-test-ffi/src/napi/mod.rs b/crates/tui-test-ffi/src/napi/mod.rs index 7456f5da..058fe4d8 100644 --- a/crates/tui-test-ffi/src/napi/mod.rs +++ b/crates/tui-test-ffi/src/napi/mod.rs @@ -4,8 +4,10 @@ mod convert; mod types; use self::types::*; -use crate::types::*; +use crate::session::{self, SessionInput}; use convert::*; +use tui_test::api::{self, KeyAction, MouseAction, Operation, OperationResult}; +use tui_test::runtime::global_registry; use ::napi::bindgen_prelude::spawn_blocking; use ::napi::{Error, Result, Status}; @@ -33,6 +35,25 @@ fn usage(message: impl Into) -> Error { error("usage", message) } +fn native_error(error: api::TuiTestError) -> Error { + let mut envelope = serde_json::json!({ + "kind": error.kind.as_str(), "message": error.message, + }); + if let Some(details) = error.details { + envelope["details"] = match serde_json::to_value(details) { + Ok(value) => value, + Err(error) => return internal(format!("failed to serialize native details: {error}")), + }; + } + if let Some(artifact) = error.artifact { + envelope["artifact"] = match serde_json::to_value(artifact) { + Ok(value) => value, + Err(error) => return internal(format!("failed to serialize native artifact: {error}")), + }; + } + Error::new(Status::GenericFailure, format!("{ERROR_PREFIX}{envelope}")) +} + fn boundary(work: impl FnOnce() -> Result) -> Result { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) { Ok(result) => result, @@ -55,71 +76,36 @@ async fn blocking( .map_err(|error| internal(format!("native worker failed: {error}")))? } -struct ResultGuard { - pointer: *mut TuiResult, -} - -impl Drop for ResultGuard { - fn drop(&mut self) { - unsafe { crate::tui_result_free(self.pointer) }; - } -} - -fn result(pointer: *mut TuiResult, convert: impl FnOnce(&TuiResult) -> Result) -> Result { - if pointer.is_null() { - return Err(internal("native operation returned a null result")); - } - let guard = ResultGuard { pointer }; - let value = unsafe { &*guard.pointer }; - if value.error_kind != 0 { - let kind = match value.error_kind { - 1 => "assertion", - 2 => "usage", - 3 => "no_session", - _ => "internal", - }; - let mut envelope = serde_json::json!({"kind": kind, "message": text(value.error_message)}); - for (key, value) in [ - ("details", value.error_details), - ("artifact", value.error_artifact), - ] { - if let Some(value) = optional_text(value) { - envelope[key] = serde_json::from_str(&value) - .map_err(|error| internal(format!("invalid native {key}: {error}")))?; - } - } - return Err(Error::new( - Status::GenericFailure, - format!("{ERROR_PREFIX}{envelope}"), - )); - } - convert(value) -} - -fn unit(_: &TuiResult) -> Result<()> { - Ok(()) -} -fn string_result(value: &TuiResult) -> Result { - Ok(text(value.text)) -} -fn optional_string_result(value: &TuiResult) -> Result> { - Ok(optional_text(value.text)) +async fn execute( + context: SessionInput, + operation_name: &'static str, + operation: impl FnOnce() -> Result + Send + 'static, + convert: impl FnOnce(OperationResult) -> Result + Send + 'static, +) -> Result { + blocking(move || { + let (name, context) = context.validate().map_err(native_error)?; + convert( + session::execute(name, context, operation_name, operation()?).map_err(native_error)?, + ) + }) + .await } #[napi] pub struct NativeSession { name: String, - context: serde_json::Value, + context: SessionInput, recording_directory: Option, } impl NativeSession { async fn call( &self, - work: impl FnOnce(TuiString) -> Result + Send + 'static, + operation_name: &'static str, + operation: impl FnOnce() -> Result + Send + 'static, + convert: impl FnOnce(OperationResult) -> Result + Send + 'static, ) -> Result { - let context = self.context.to_string(); - blocking(move || work(borrowed(&context))).await + execute(self.context.clone(), operation_name, operation, convert).await } } @@ -132,16 +118,18 @@ impl NativeSession { artifacts: Option, trace: Option, ) -> Result { - let mut context = serde_json::json!({"name": name}); + let mut context = SessionInput { + name: name.clone(), + ..Default::default() + }; if let Some(options) = artifacts { - context["artifact_directory"] = options.directory.into(); - context["artifact_mode"] = options.mode.unwrap_or_else(|| "all".into()).into(); - context["artifact_include_recording"] = - options.include_recording.unwrap_or(false).into(); + context.artifact_directory = Some(options.directory); + context.artifact_mode = Some(options.mode.unwrap_or_else(|| "all".into())); + context.artifact_include_recording = options.include_recording.unwrap_or(false); } if let Some(options) = trace { - context["trace_mode"] = options.mode.unwrap_or_else(|| "off".into()).into(); - context["trace_directory"] = options.directory.into(); + context.trace_mode = Some(options.mode.unwrap_or_else(|| "off".into())); + context.trace_directory = options.directory; } Ok(Self { name, @@ -158,143 +146,161 @@ impl NativeSession { #[napi] pub async fn open(&self, options: Option) -> Result { let options = options.unwrap_or_default(); - let context = session_context(&self.context, options.screen_history_limit)?; + let mut context = self.context.clone(); + context.screen_history_limit = + optional_u16(options.screen_history_limit, "screenHistoryLimit")?; let directory = self.recording_directory.clone(); - blocking(move || { - let input = OpenInput::new(options, directory)?; - result( - unsafe { crate::tui_open_ptr(borrowed(&context), &input.options) }, - open_result, - ) - }) + execute( + context, + "open", + move || Ok(Operation::Open(open_options(options, directory)?)), + open_result, + ) .await } #[napi] pub async fn run(&self, options: RunOptions) -> Result { - let context = session_context(&self.context, options.screen_history_limit)?; + let mut context = self.context.clone(); + context.screen_history_limit = + optional_u16(options.screen_history_limit, "screenHistoryLimit")?; let directory = self.recording_directory.clone(); - blocking(move || { - let args = options.args.unwrap_or_default(); - let borrowed_args: Vec<_> = args.iter().map(|value| borrowed(value)).collect(); - let input = OpenInput::new( - OpenOptions { - backend: options.backend, - cols: options.cols, - rows: options.rows, - cwd: options.cwd, - env: options.env, - wait_ready: options.wait_ready, - restart: options.restart, - profile_scrollback: options.profile_scrollback, - profile_colors: options.profile_colors, - timeouts: options.timeouts, - ..Default::default() - }, - directory, - )?; - result( - unsafe { - crate::tui_run_ptr( - borrowed(&context), - &input.options, - borrowed(&options.program), - borrowed_args.as_ptr(), - borrowed_args.len(), - ) - }, - open_result, - ) - }) + execute( + context, + "run", + move || { + let open = open_options( + OpenOptions { + backend: options.backend, + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + wait_ready: options.wait_ready, + restart: options.restart, + profile_scrollback: options.profile_scrollback, + profile_colors: options.profile_colors, + timeouts: options.timeouts, + ..Default::default() + }, + directory, + )?; + if options.program.is_empty() { + return Err(usage("program must not be empty")); + } + Ok(Operation::Run(api::RunOptions { + program: options.program, + args: options.args.unwrap_or_default(), + backend: open.backend, + profile: open.profile, + cols: open.cols, + rows: open.rows, + cwd: open.cwd, + env: open.env, + wait_ready: open.wait_ready, + restart: open.restart, + timeouts: open.timeouts, + recording: open.recording, + })) + }, + open_result, + ) .await } #[napi] pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { - self.call(move |session| { - result( - unsafe { - crate::tui_restart( - session, - integer(graceful_timeout_ms, "gracefulTimeoutMs", u64::MAX)?, - ) - }, - open_result, - ) - }) + self.call( + "restart", + move || { + Ok(Operation::Restart { + graceful_timeout_ms: integer( + graceful_timeout_ms, + "gracefulTimeoutMs", + u64::MAX, + )?, + }) + }, + open_result, + ) .await } #[napi] pub async fn close(&self, failed: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { crate::tui_close(session, optional_bool(failed)) }, - unit, - ) + let context = self.context.clone(); + blocking(move || { + let (name, context) = context.validate().map_err(native_error)?; + unit(session::close(name, context, failed).map_err(native_error)?) }) .await } #[napi] pub async fn state(&self) -> Result { - self.call(|session| { - result(unsafe { crate::tui_state(session) }, |value| { - Ok(state(&value.state)) - }) - }) + self.call( + "state", + || Ok(Operation::State), + |value| match value { + OperationResult::State(value) => Ok(state(*value)), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } #[napi] pub async fn text(&self, full: Option) -> Result { - self.call(move |session| { - result( - unsafe { crate::tui_text(session, full.unwrap_or(false)) }, - string_result, - ) - }) + self.call( + "text", + move || { + Ok(Operation::Text { + full: full.unwrap_or(false), + }) + }, + string_result, + ) .await } #[napi] pub async fn packed_screen(&self, full: Option) -> Result { - self.call(move |session| { - result( - unsafe { crate::tui_packed_screen(session, full.unwrap_or(false)) }, - |value| { - Ok(PackedScreen { - cols: value.size.cols, - rows: value.size.rows, - utf8: bytes(value.text).to_vec().into(), - }) - }, - ) - }) + self.call( + "packedScreen", + move || { + Ok(Operation::PackedScreen { + full: full.unwrap_or(false), + }) + }, + |value| match value { + OperationResult::PackedScreen(value) => Ok(PackedScreen { + cols: value.cols, + rows: value.rows, + utf8: value.utf8.into(), + }), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } #[napi] pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { - self.call(move |session| { - result( - unsafe { - crate::tui_cells( - session, - u16_value(x, "x")?, - u16_value(y, "y")?, - u16_value(w.unwrap_or(1.0), "w")?, - u16_value(h.unwrap_or(1.0), "h")?, - ) - }, - |value| { - array(value.cells, value.cells_len) - .iter() - .map(cell) - .collect() - }, - ) - }) + self.call( + "cells", + move || { + Ok(Operation::Cells { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + w: u16_value(w.unwrap_or(1.0), "w")?, + h: u16_value(h.unwrap_or(1.0), "h")?, + }) + }, + |value| match value { + OperationResult::Cells(value) => value.into_iter().map(cell).collect(), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } @@ -304,21 +310,23 @@ impl NativeSession { expression: LocatorExpression, require_one: Option, ) -> Result> { - self.call(move |session| { - let query = query(expression)?; - result( - unsafe { - crate::tui_find_locator( - session, - TuiQuery { - json: borrowed(&query), - }, - require_one.unwrap_or(false), - ) - }, - matches, - ) - }) + let require_one = require_one.unwrap_or(false); + self.call( + if require_one { + "locator.location" + } else { + "locator.locations" + }, + move || { + let query = query(expression)?; + Ok(if require_one { + Operation::ResolveLocator { query } + } else { + Operation::FindLocator { query } + }) + }, + matches, + ) .await } @@ -329,21 +337,17 @@ impl NativeSession { not: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |session| { - let query = query(expression)?; - result( - unsafe { - crate::tui_wait_locator( - session, - TuiQuery { - json: borrowed(&query), - }, - wait_options(timeout_ms, false, not.unwrap_or(false))?, - ) - }, - unit, - ) - }) + self.call( + "locator.wait", + move || { + Ok(Operation::WaitLocator { + query: query(expression)?, + not: not.unwrap_or(false), + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } @@ -354,21 +358,17 @@ impl NativeSession { not: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |session| { - let query = query(expression)?; - result( - unsafe { - crate::tui_expect_locator( - session, - TuiQuery { - json: borrowed(&query), - }, - wait_options(timeout_ms, false, not.unwrap_or(false))?, - ) - }, - unit, - ) - }) + self.call( + "locator.expect", + move || { + Ok(Operation::WaitLocator { + query: query(expression)?, + not: not.unwrap_or(false), + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } @@ -380,23 +380,18 @@ impl NativeSession { clicks: Option, timeout_ms: Option, ) -> Result<()> { - self.call(move |session| { - let query = query(expression)?; - result( - unsafe { - crate::tui_click_locator( - session, - TuiQuery { - json: borrowed(&query), - }, - mouse_options(button.unwrap_or(0.0))?, - u8_value(clicks.unwrap_or(1.0), "clicks")?, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "locator.click", + move || { + Ok(Operation::ClickLocator { + query: query(expression)?, + options: mouse_options(button.unwrap_or(0.0))?, + clicks: u8_value(clicks.unwrap_or(1.0), "clicks")?, + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } @@ -406,21 +401,16 @@ impl NativeSession { expression: LocatorExpression, timeout_ms: Option, ) -> Result> { - self.call(move |session| { - let query = query(expression)?; - result( - unsafe { - crate::tui_highlight_locator( - session, - TuiQuery { - json: borrowed(&query), - }, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - matches, - ) - }) + self.call( + "locator.highlight", + move || { + Ok(Operation::HighlightLocator { + query: query(expression)?, + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + matches, + ) .await } @@ -431,226 +421,257 @@ impl NativeSession { #[napi] pub async fn get_command(&self) -> Result> { - self.call(|session| { - result( - unsafe { crate::tui_get_command(session) }, - optional_string_result, - ) - }) + self.call( + "getCommand", + || Ok(Operation::GetCommand), + optional_string_result, + ) .await } - #[napi] pub async fn get_output(&self) -> Result> { - self.call(|session| { - result( - unsafe { crate::tui_get_output(session) }, - optional_string_result, - ) - }) + self.call( + "getOutput", + || Ok(Operation::GetOutput), + optional_string_result, + ) .await } - #[napi] pub async fn get_exit_code(&self) -> Result> { - self.call(|session| { - result(unsafe { crate::tui_get_exit_code(session) }, |value| { - Ok(value.exit_code.present.then_some(value.exit_code.value)) - }) - }) + self.call( + "getExitCode", + || Ok(Operation::GetExitCode), + |value| match value { + OperationResult::ExitCode(value) => Ok(value), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } - #[napi] pub async fn get_cwd(&self) -> Result> { - self.call(|session| { - result( - unsafe { crate::tui_get_cwd(session) }, - optional_string_result, - ) - }) - .await + self.call("getCwd", || Ok(Operation::GetCwd), optional_string_result) + .await } - #[napi] pub async fn get_title(&self) -> Result> { - self.call(|session| { - result( - unsafe { crate::tui_get_title(session) }, - optional_string_result, - ) - }) + self.call( + "getTitle", + || Ok(Operation::GetTitle), + optional_string_result, + ) .await } - #[napi] pub async fn get_clipboard(&self) -> Result { - self.call(|session| result(unsafe { crate::tui_get_clipboard(session) }, string_result)) - .await + self.call( + "getClipboard", + || Ok(Operation::GetClipboard), + string_result, + ) + .await } - #[napi] pub async fn get_cursor(&self) -> Result { - self.call(|session| { - result(unsafe { crate::tui_get_cursor(session) }, |value| { - Ok(cursor(value.cursor)) - }) - }) + self.call( + "getCursor", + || Ok(Operation::GetCursor), + |value| match value { + OperationResult::Cursor(value) => Ok(cursor(value)), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } - #[napi] pub async fn get_size(&self) -> Result { - self.call(|session| { - result(unsafe { crate::tui_get_size(session) }, |value| { - Ok(Size { - cols: value.size.cols, - rows: value.size.rows, - }) - }) - }) + self.call( + "getSize", + || Ok(Operation::GetSize), + |value| match value { + OperationResult::Size(value) => Ok(Size { + cols: value.cols, + rows: value.rows, + }), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } - #[napi] pub async fn get_bell_count(&self) -> Result { - self.call(|session| { - result(unsafe { crate::tui_get_bell_count(session) }, |value| { - Ok(value.number as f64) - }) - }) + self.call( + "getBellCount", + || Ok(Operation::GetBellCount), + |value| match value { + OperationResult::BellCount(value) => Ok(value as f64), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } - #[napi] pub async fn get_bell_events(&self) -> Result> { - self.call(|session| { - result(unsafe { crate::tui_get_bell_events(session) }, |value| { - Ok(array(value.bells, value.bells_len) - .iter() + self.call( + "getBellEvents", + || Ok(Operation::GetBellEvents), + |value| match value { + OperationResult::BellEvents(value) => Ok(value + .into_iter() .map(|value| BellEvent { sequence: value.sequence as f64, elapsed_ms: value.elapsed_ms as f64, }) - .collect()) - }) - }) + .collect()), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } #[napi] pub async fn write(&self, data: String) -> Result<()> { - self.call(move |session| { - result(unsafe { crate::tui_write(session, borrowed(&data)) }, unit) - }) - .await + self.call("write", move || Ok(Operation::Write { data }), unit) + .await } - #[napi(js_name = "type")] pub async fn type_text(&self, text: String) -> Result<()> { - self.call(move |session| result(unsafe { crate::tui_type(session, borrowed(&text)) }, unit)) + self.call("type", move || Ok(Operation::Write { data: text }), unit) .await } - #[napi] pub async fn submit(&self, data: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { crate::tui_submit(session, optional_borrowed(data.as_deref())) }, - unit, - ) - }) - .await + self.call("submit", move || Ok(Operation::Submit { data }), unit) + .await } - #[napi] pub async fn press(&self, keys: Vec) -> Result<()> { - self.keys(keys, 0).await + self.call( + "press", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Press, + }) + }, + unit, + ) + .await } - #[napi] pub async fn key_down(&self, keys: Vec) -> Result<()> { - self.keys(keys, 1).await + self.call( + "keyDown", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Down, + }) + }, + unit, + ) + .await } - #[napi] pub async fn repeat(&self, keys: Vec) -> Result<()> { - self.keys(keys, 2).await + self.call( + "repeat", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Repeat, + }) + }, + unit, + ) + .await } - #[napi] pub async fn key_up(&self, keys: Vec) -> Result<()> { - self.keys(keys, 3).await + self.call( + "keyUp", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Up, + }) + }, + unit, + ) + .await } #[napi] pub async fn mouse_click(&self, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_mouse_click( - session, - optional_integer(options.x, "x", u16::MAX.into())?, - optional_integer(options.y, "y", u16::MAX.into())?, - optional_borrowed(options.on_text.as_deref()), - mouse_options(options.button.unwrap_or(0.0))?, - u8_value(options.clicks.unwrap_or(1.0), "clicks")?, - ) - }, - unit, - ) - }) + self.call( + "mouse.click", + move || { + Ok(Operation::Mouse { + action: MouseAction::Click { + x: optional_u16(options.x, "x")?, + y: optional_u16(options.y, "y")?, + on_text: options.on_text, + options: mouse_options(options.button.unwrap_or(0.0))?, + clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { - self.call(move |session| { - result( - unsafe { crate::tui_mouse_move(session, u16_value(x, "x")?, u16_value(y, "y")?) }, - unit, - ) - }) + self.call( + "mouse.move", + move || { + Ok(Operation::Mouse { + action: MouseAction::Move { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_mouse_down( - session, - u16_value(x, "x")?, - u16_value(y, "y")?, - mouse_options(button.unwrap_or(0.0))?, - ) - }, - unit, - ) - }) + self.call( + "mouse.down", + move || { + Ok(Operation::Mouse { + action: MouseAction::Down { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_mouse_up( - session, - u16_value(x, "x")?, - u16_value(y, "y")?, - mouse_options(button.unwrap_or(0.0))?, - ) - }, - unit, - ) - }) + self.call( + "mouse.up", + move || { + Ok(Operation::Mouse { + action: MouseAction::Up { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn mouse_drag( &self, @@ -660,106 +681,93 @@ impl NativeSession { y2: f64, button: Option, ) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_mouse_drag( - session, - u16_value(x1, "x1")?, - u16_value(y1, "y1")?, - u16_value(x2, "x2")?, - u16_value(y2, "y2")?, - mouse_options(button.unwrap_or(0.0))?, - ) - }, - unit, - ) - }) + self.call( + "mouse.drag", + move || { + Ok(Operation::Mouse { + action: MouseAction::Drag { + x1: u16_value(x1, "x1")?, + y1: u16_value(y1, "y1")?, + x2: u16_value(x2, "x2")?, + y2: u16_value(y2, "y2")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_mouse_scroll( - session, - borrowed(&direction), - u16_value(amount.unwrap_or(3.0), "amount")?, - ) - }, - unit, - ) - }) + self.call( + "mouse.scroll", + move || { + Ok(Operation::Mouse { + action: MouseAction::Scroll { + direction, + amount: u16_value(amount.unwrap_or(3.0), "amount")?, + }, + }) + }, + unit, + ) .await } - #[napi] pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_resize(session, u16_value(cols, "cols")?, u16_value(rows, "rows")?) - }, - unit, - ) - }) + self.call( + "resize", + move || { + Ok(Operation::Resize { + cols: u16_value(cols, "cols")?, + rows: u16_value(rows, "rows")?, + }) + }, + unit, + ) .await } - #[napi] pub async fn signal(&self, name: String) -> Result<()> { - self.call(move |session| { - result(unsafe { crate::tui_signal(session, borrowed(&name)) }, unit) - }) - .await + self.call("signal", move || Ok(Operation::Signal { name }), unit) + .await } #[napi] pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_wait_title( - session, - borrowed(&text), - wait_options( - options.timeout_ms, - options.regex.unwrap_or(false), - options.not.unwrap_or(false), - )?, - ) - }, - unit, - ) - }) + self.call( + "waitTitle", + move || { + Ok(Operation::WaitTitle { + text, + regex: options.regex.unwrap_or(false), + not: options.not.unwrap_or(false), + timeout_ms: optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_expect_title( - session, - borrowed(&text), - wait_options( - options.timeout_ms, - options.regex.unwrap_or(false), - options.not.unwrap_or(false), - )?, - ) - }, - unit, - ) - }) + self.call( + "expectTitle", + move || { + Ok(Operation::ExpectTitle { + text, + regex: options.regex.unwrap_or(false), + not: options.not.unwrap_or(false), + timeout_ms: optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn wait_clipboard( &self, @@ -767,145 +775,135 @@ impl NativeSession { options: Option, ) -> Result<()> { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_wait_clipboard( - session, - optional_borrowed(text.as_deref()), - wait_options(options.timeout_ms, options.regex.unwrap_or(false), false)?, - ) - }, - unit, - ) - }) + self.call( + "waitClipboard", + move || { + let timeout_ms = optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?; + match text { + Some(text) => Ok(Operation::WaitClipboardMatch { + pattern: if options.regex.unwrap_or(false) { + api::ClipboardPattern::regex(&text) + .map_err(|error| usage(format!("invalid regex: {error}")))? + } else { + text.into() + }, + timeout_ms, + }), + None if options.regex.unwrap_or(false) => { + Err(usage("clipboard regex requires text")) + } + None => Ok(Operation::WaitClipboard { timeout_ms }), + } + }, + unit, + ) .await } - #[napi] pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_wait_idle( - session, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "waitIdle", + move || { + Ok(Operation::WaitIdle { + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_wait_command( - session, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "waitCommand", + move || { + Ok(Operation::WaitCommand { + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_wait_exit( - session, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "waitExit", + move || { + Ok(Operation::WaitExit { + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_wait_ready( - session, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "waitReady", + move || { + Ok(Operation::WaitReady { + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_wait_bell( - session, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "waitBell", + move || { + Ok(Operation::WaitBell { + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_expect_exit_code( - session, - i32_value(code, "code")?, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "expectExitCode", + move || { + Ok(Operation::ExpectExitCode { + code: i32_value(code, "code")?, + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_expect_output(session, borrowed(&text), regex.unwrap_or(false)) - }, - unit, - ) - }) + self.call( + "expectOutput", + move || { + Ok(Operation::ExpectOutput { + text, + regex: regex.unwrap_or(false), + }) + }, + unit, + ) .await } - #[napi] pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_expect_bell_count( - session, - integer(count, "count", u64::MAX)?, - optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - ) - }, - unit, - ) - }) + self.call( + "expectBellCount", + move || { + Ok(Operation::ExpectBellCount { + count: integer(count, "count", u64::MAX)?, + timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, + }) + }, + unit, + ) .await } @@ -916,117 +914,91 @@ impl NativeSession { options: Option, ) -> Result { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_snapshot( - session, - borrowed(&name), - options.update.unwrap_or(false), - options.include_style.unwrap_or(false), - options.include_title.unwrap_or(false), - optional_borrowed(options.cwd.as_deref()), - ) - }, - |value| match value.snapshot { - 0 => Ok(SnapshotResult::Passed), - 1 => Ok(SnapshotResult::Written), - 2 => Ok(SnapshotResult::Updated), - _ => Err(internal( - "native operation returned an unknown snapshot result", - )), - }, - ) - }) + self.call( + "expectSnapshot", + move || { + Ok(Operation::Snapshot { + name, + update: options.update.unwrap_or(false), + include_style: options.include_style.unwrap_or(false), + include_title: options.include_title.unwrap_or(false), + cwd: options.cwd, + }) + }, + |value| match value { + OperationResult::Snapshot(value) => Ok(match value { + api::SnapshotResult::Passed => SnapshotResult::Passed, + api::SnapshotResult::Written => SnapshotResult::Written, + api::SnapshotResult::Updated => SnapshotResult::Updated, + }), + _ => Err(internal("native operation returned an unexpected result")), + }, + ) .await } - #[napi] pub async fn screenshot(&self, options: Option) -> Result { let options = options.unwrap_or_default(); - self.call(move |session| { - result( - unsafe { - crate::tui_screenshot( - session, - options.full.unwrap_or(false), - optional_borrowed(options.path.as_deref()), - optional_float(options.zoom), - optional_borrowed(options.background.as_deref()), + self.call( + "screenshot", + move || { + Ok(Operation::Screenshot { + full: options.full.unwrap_or(false), + path: options.path, + zoom: options.zoom, + background: session::capture_background( + options.background, options.transparent.unwrap_or(false), ) - }, - string_result, - ) - }) + .map_err(native_error)?, + }) + }, + string_result, + ) .await } - #[napi] pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { - self.call(move |session| { - result( - unsafe { - crate::tui_start_recording( - session, - TuiRecordingOptions { - path: borrowed(&options.path), - format: optional_borrowed(options.format.as_ref().map(|format| { - match format { - RecordingFormat::Apng => "apng", - RecordingFormat::Gif => "gif", - RecordingFormat::Mp4 => "mp4", - RecordingFormat::Cast => "cast", - } - })), - fps: optional_integer(options.fps, "fps", u8::MAX.into())?, - speed: optional_float(options.speed), - idle_time_limit: optional_float(options.idle_time_limit), - zoom: optional_float(options.zoom), - background: optional_borrowed(options.background.as_deref()), - transparent: options.transparent.unwrap_or(false), - }, + self.call( + "startRecording", + move || { + Ok(Operation::StartRecording { + path: options.path, + format: options.format.map(|format| match format { + RecordingFormat::Apng => api::RecordingFormat::Apng, + RecordingFormat::Gif => api::RecordingFormat::Gif, + RecordingFormat::Mp4 => api::RecordingFormat::Mp4, + RecordingFormat::Cast => api::RecordingFormat::Cast, + }), + fps: options.fps.map(|fps| u8_value(fps, "fps")).transpose()?, + speed: options.speed, + idle_time_limit: options.idle_time_limit, + zoom: options.zoom, + background: session::capture_background( + options.background, + options.transparent.unwrap_or(false), ) - }, - unit, - ) - }) + .map_err(native_error)?, + }) + }, + unit, + ) .await } - #[napi] pub async fn stop_recording(&self) -> Result { - self.call(|session| result(unsafe { crate::tui_stop_recording(session) }, string_result)) - .await - } -} - -impl NativeSession { - async fn keys(&self, keys: Vec, action: u32) -> Result<()> { - self.call(move |session| { - let borrowed_keys: Vec<_> = keys.iter().map(|value| borrowed(value)).collect(); - result( - unsafe { - crate::tui_key(session, borrowed_keys.as_ptr(), borrowed_keys.len(), action) - }, - unit, - ) - }) + self.call( + "stopRecording", + || Ok(Operation::StopRecording), + string_result, + ) .await } } #[napi] pub async fn sessions() -> Result> { - blocking(|| { - result(crate::tui_sessions(), |value| { - Ok(array(value.strings, value.strings_len) - .iter() - .map(|value| text(*value)) - .collect()) - }) - }) - .await + blocking(|| Ok(global_registry().sessions())).await } #[napi] @@ -1036,16 +1008,13 @@ pub async fn close_all() -> Result<()> { #[napi] pub fn close_all_sync() -> Result<()> { - boundary(|| result(crate::tui_close_all(), unit)) + boundary(|| { + global_registry().close_all(); + Ok(()) + }) } #[napi] pub async fn recording(name: String) -> Result { - blocking(move || { - result( - unsafe { crate::tui_recording(borrowed(&name)) }, - string_result, - ) - }) - .await + blocking(move || string_result(session::recording(&name).map_err(native_error)?)).await } diff --git a/crates/tui-test-ffi/src/session.rs b/crates/tui-test-ffi/src/session.rs new file mode 100644 index 00000000..d5d28b12 --- /dev/null +++ b/crates/tui-test-ffi/src/session.rs @@ -0,0 +1,155 @@ +use crate::{finish_trace_and_close, Result}; +use tui_test::api::*; +use tui_test::runtime::global_registry; +use tui_test::{ + DiagnosticRetentionOptions, ExecutionContext, FailureArtifactMode, FailureArtifactOptions, + TraceMode, TraceOptions, +}; + +#[derive(Clone, Default, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionInput { + pub name: String, + pub artifact_directory: Option, + pub artifact_mode: Option, + #[serde(default)] + pub artifact_include_recording: bool, + pub trace_mode: Option, + pub trace_directory: Option, + pub screen_history_limit: Option, +} + +impl SessionInput { + pub fn validate(self) -> Result<(String, ExecutionContext)> { + let artifact = match self.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: self.artifact_directory.unwrap_or_default().into(), + mode, + include_recording: self.artifact_include_recording, + }; + options.validate().map_err(TuiTestError::usage)?; + Some(options) + } + }; + let trace = match self.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: self + .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) = self.screen_history_limit { + retention.screen_history_limit = limit; + } + retention.validate().map_err(TuiTestError::usage)?; + Ok(( + self.name, + ExecutionContext { + artifact, + trace, + retention, + ..ExecutionContext::default() + }, + )) + } +} + +pub(crate) fn execute( + name: String, + context: ExecutionContext, + operation_name: &'static str, + operation: Operation, +) -> Result { + global_registry() + .session(name) + .execute_with_context(operation, context.with_operation(operation_name)) +} + +pub(crate) fn close( + name: String, + context: ExecutionContext, + failed: Option, +) -> Result { + let handle = global_registry().session(name); + if let Some(failed) = failed { + 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")) +} + +pub(crate) fn recording(name: &str) -> Result { + global_registry() + .recording(name) + .map(OperationResult::Recording) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + TuiTestError::new( + ErrorKind::NoSession, + format!("no recording for session '{name}'"), + ) + } else { + TuiTestError::internal(format!( + "failed to read recording for session '{name}': {error}" + )) + } + }) +} + +pub(crate) fn capture_background( + background: Option, + transparent: bool, +) -> Result> { + 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() +} diff --git a/crates/tui-test-ffi/src/tests.rs b/crates/tui-test-ffi/src/tests.rs index 3f2a576e..b890d0f4 100644 --- a/crates/tui-test-ffi/src/tests.rs +++ b/crates/tui-test-ffi/src/tests.rs @@ -133,6 +133,70 @@ fn close_is_attempted_when_trace_finalization_fails() { || Ok(OperationResult::Unit), ); assert!(matches!(result, Ok(OperationResult::Unit))); + + let error = finish_trace_and_close( + || Err(TuiTestError::new(ErrorKind::NoSession, "trace missing")), + || Err(TuiTestError::internal("close failed")), + ) + .unwrap_err(); + assert_eq!(error.message, "close failed"); +} + +#[test] +fn c_context_and_owned_context_share_validation() { + for (field, value) in [ + ("artifact_mode", serde_json::json!("invalid")), + ("trace_mode", serde_json::json!("invalid")), + ("screen_history_limit", serde_json::json!(65535)), + ("trace_directory", serde_json::json!("")), + ] { + let mut context = serde_json::json!({"name": "ffi-context", "trace_mode": "on"}); + context[field] = value; + let owned = serde_json::from_value::(context.clone()) + .unwrap() + .validate() + .unwrap_err(); + let encoded = context.to_string(); + let result = unsafe { tui_state(text(&encoded)) }; + assert_eq!( + unsafe { (*result).error_kind }, + owned.kind.exit_code() as u32 + ); + assert_eq!(unsafe { message(result) }, owned.message); + unsafe { tui_result_free(result) }; + } + + let context = session::SessionInput { + name: "ffi-context".into(), + screen_history_limit: Some(0), + ..Default::default() + }; + let (name, validated) = context.clone().validate().unwrap(); + assert_eq!(validated.retention.screen_history_limit, 0); + let error = session::execute(name, validated, "state", Operation::State).unwrap_err(); + assert_eq!(error.kind, ErrorKind::NoSession); + assert_eq!(context.screen_history_limit, Some(0)); + + let result = unsafe { tui_state(text(r#"{"name":"ffi-context","unknown":true}"#)) }; + assert_eq!(unsafe { (*result).error_kind }, 2); + assert!(unsafe { message(result) }.contains("unknown field")); + unsafe { tui_result_free(result) }; +} + +#[test] +fn shared_close_preserves_missing_session_behavior() { + for failed in [None, Some(false), Some(true)] { + let (name, context) = session::SessionInput { + name: "ffi-close-missing".into(), + ..Default::default() + } + .validate() + .unwrap(); + assert!(matches!( + session::close(name, context, failed), + Ok(OperationResult::Unit) + )); + } } #[test] fn output_buffers_preserve_nuls_unicode_and_absence() { From c0da2c1d7f0b03f0bbcd9dc6857abe334117a13e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 14:28:29 -0400 Subject: [PATCH 04/15] refactor: generate native boundaries from owned Rust models Signed-off-by: Ayman Bagabas --- bindings/go/CONTRIBUTING.md | 2 + bindings/go/internal/native/abi.go | 178 +-- bindings/js/native/index.d.ts | 82 +- bindings/python/src/tui_test/_abi.py | 176 +-- crates/tui-test-ffi/README.md | 91 +- crates/tui-test-ffi/src/input.rs | 81 +- crates/tui-test-ffi/src/input_generated.rs | 57 + crates/tui-test-ffi/src/napi/convert.rs | 225 +--- .../src/napi/convert_generated.rs | 405 ++++++ crates/tui-test-ffi/src/napi/mod.rs | 138 +-- crates/tui-test-ffi/src/napi/type_helpers.rs | 40 + crates/tui-test-ffi/src/napi/types.rs | 415 +++---- crates/tui-test-ffi/src/output.rs | 165 +-- crates/tui-test-ffi/src/output_generated.rs | 130 ++ crates/tui-test-ffi/src/session.rs | 88 +- crates/tui-test-ffi/src/session_generated.rs | 61 + crates/tui-test-ffi/src/type_helpers.rs | 90 ++ crates/tui-test-ffi/src/types.rs | 195 +-- tools/bindgen/src/boundaries.rs | 1103 +++++++++++++++++ tools/bindgen/src/main.rs | 5 +- 20 files changed, 2618 insertions(+), 1109 deletions(-) create mode 100644 crates/tui-test-ffi/src/input_generated.rs create mode 100644 crates/tui-test-ffi/src/napi/convert_generated.rs create mode 100644 crates/tui-test-ffi/src/napi/type_helpers.rs create mode 100644 crates/tui-test-ffi/src/output_generated.rs create mode 100644 crates/tui-test-ffi/src/session_generated.rs create mode 100644 crates/tui-test-ffi/src/type_helpers.rs create mode 100644 tools/bindgen/src/boundaries.rs diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index 715a7789..4eaeb3ba 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -20,6 +20,8 @@ The C ABI is an internal contract shared by Go and Python, not a stable public C Native allocations must be released by their matching Rust functions, and native code must not retain Go pointers. `internal/native/abi.go` and `internal/native/functions.go` are generated from the Rust ABI declarations. +The generator first derives Rust boundary structs and conversions from the owned Rust models. +See [the shared library guide](../../crates/tui-test-ffi/README.md#generate-boundaries) for authoritative sources and transport mappings. Do not edit these files directly. From the repository root, regenerate them with: ```sh diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go index c168158f..a93b4220 100644 --- a/bindings/go/internal/native/abi.go +++ b/bindings/go/internal/native/abi.go @@ -38,59 +38,53 @@ type abiPair struct { key abiString value abiString } -type abiTimeouts struct { - _ structs.HostLayout - text abiOptionalU64 - idle abiOptionalU64 - command abiOptionalU64 - exit abiOptionalU64 - ready abiOptionalU64 -} -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 abiQuery struct { _ structs.HostLayout json abiString } -type abiWaitOptions struct { - _ structs.HostLayout - timeoutMS abiOptionalU64 - regex bool - not bool +type abiMode struct { + _ structs.HostLayout + name abiString + enabled bool } -type abiMouseOptions struct { - _ structs.HostLayout - button uint32 - alt bool - ctrl bool - shift bool +type abiColor struct { + _ structs.HostLayout + kind uint32 + index uint8 + red uint8 + green uint8 + blue uint8 } -type abiRecordingOptions struct { +type abiResult struct { _ structs.HostLayout - path abiString - format abiString - fps abiOptionalU64 - speed abiOptionalF64 - idleTimeLimit abiOptionalF64 - zoom abiOptionalF64 - background abiString - transparent bool + 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 abiOpenResult struct { + _ structs.HostLayout + shellPID abiOptionalU64 + session abiString + ready bool + recording abiString } type abiCursor struct { _ structs.HostLayout @@ -105,12 +99,10 @@ type abiSize struct { cols uint16 rows uint16 } -type abiOpenResult struct { +type abiBellEvent struct { _ structs.HostLayout - shellPID abiOptionalU64 - session abiString - ready bool - recording abiString + sequence uint64 + elapsedMS uint64 } type abiState struct { _ structs.HostLayout @@ -133,11 +125,6 @@ type abiState struct { timeouts abiTimeouts text abiString } -type abiMode struct { - _ structs.HostLayout - name abiString - enabled bool -} type abiTerminalColors struct { _ structs.HostLayout foreground abiString @@ -146,14 +133,6 @@ type abiTerminalColors struct { palette *abiPair paletteLen uintptr } -type abiColor struct { - _ structs.HostLayout - kind uint32 - index uint8 - red uint8 - green uint8 - blue uint8 -} type abiCell struct { _ structs.HostLayout x uint16 @@ -193,32 +172,53 @@ type abiMatch struct { spans *abiSpan spansLen uintptr } -type abiBellEvent struct { +type abiTimeouts struct { + _ structs.HostLayout + text abiOptionalU64 + idle abiOptionalU64 + command abiOptionalU64 + exit abiOptionalU64 + ready abiOptionalU64 +} +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 - sequence uint64 - elapsedMS uint64 + timeoutMS abiOptionalU64 + regex bool + not bool } -type abiResult struct { +type abiRecordingOptions 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 + path abiString + format abiString + fps abiOptionalU64 + speed abiOptionalF64 + idleTimeLimit abiOptionalF64 + zoom abiOptionalF64 + background abiString + transparent bool } diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 87f7cf22..e746049f 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -96,11 +96,16 @@ export interface Cell { /** * The link's `id=` parameter, empty when the sequence carried none. * - * Identifies a link across a wrap rather than describing where it points: - * a program that wraps its own links tags each run with a shared `id=`. + * Separate from `link` because it identifies a link across a wrap rather + * than describing where the link points: a program that wraps its own + * links tags each run with a shared `id=` so a terminal can treat them as + * one. * - * Backend-dependent. Ghostty reports a link's URI and nothing else, so - * this is always empty there and no locator matches on it. + * Backend-dependent, unlike everything else on a cell. Ghostty's FFI + * exposes a link's URI and nothing else, so this is always empty there + * and "no `id=` was sent" cannot be told apart from "this backend cannot + * see it". An assertion built on it will not mean the same thing on every + * backend, which is why no locator matches on it. */ link_id: string } @@ -120,8 +125,11 @@ export type Color = export interface Cursor { x: number y: number + /** Whether the cursor is drawn (`DECTCEM`). */ visible: boolean + /** `block`, `underline`, or `bar` (`DECSCUSR`). */ shape: string + /** The cursor color as `#rrggbb`, after any `OSC 12` a program sent. */ color: string } @@ -146,21 +154,21 @@ export interface LocatorExpression { export interface LocatorNode { kind: LocatorNodeKind - direction?: LocatorStageDirection text?: string regex?: boolean - full?: boolean whitespace?: string - occurrence?: string - nth?: number style?: LocatorStyle link?: string + full?: boolean + direction?: LocatorStageDirection within?: number left?: number right?: number input?: number has?: number hasNot?: number + occurrence?: string + nth?: number } export declare const enum LocatorNodeKind { @@ -222,19 +230,14 @@ export interface OpenResult { recording: string } -/** - * Private native-owned packed screen snapshot. - * - * `utf8` decodes to exactly `rows` newline-delimited logical rows. Trailing - * spaces and blank rows are retained. UTF-8 byte offsets are not terminal cell - * offsets when rows contain Unicode graphemes. - */ export interface PackedScreen { - /** Logical column count. */ + /** + * Logical terminal dimensions for the newline-delimited UTF-8 snapshot. + * Rows retain trailing spaces and blank lines; byte offsets are not cell + * offsets because Unicode graphemes may occupy multiple bytes. + */ readonly cols: number - /** Number of logical rows encoded in `utf8`. */ readonly rows: number - /** Detached native-owned UTF-8 bytes. Treat this private snapshot as immutable. */ readonly utf8: Uint8Array } @@ -323,18 +326,49 @@ export interface State { cwd: string | null last_command: string | null last_exit: number | null + /** Signal reported when the process was terminated by one. */ exit_signal: string | null exited: number | null ready: boolean bell_count: number + /** + * Terminal modes the child has turned on, by name. + * + * A map rather than a list, so a reader can tell "off" from "this build + * does not know that mode" and every key is always present. + */ modes: Record + /** + * Mouse tracking level: `none`, `click`, `drag`, or `motion`. + * + * Separate from `modes` because mouse tracking is not a set of + * independent switches: `CSI ?1002 h` replaces `CSI ?1000 h` rather than + * joining it, so reporting it as booleans would say two are on when the + * terminal only honors the last. + * + * Independent of how the child asked for the reports to be encoded. + * `CSI ?1000 h` on its own is `click`, whether or not `CSI ?1006 h` + * followed it to ask for SGR coordinates. + */ mouse_mode: string + /** + * The terminal's colors, as `#rrggbb`: the three defaults and any + * palette entry a program overrode. + */ colors: TerminalColors timeouts: EffectiveTimeouts text: string } -/** The colors the terminal is painting with. */ +/** + * The colors the terminal paints with. + * + * Grouped because they are one concept — the colors a program chooses + * rather than the ones a cell names — set by sibling sequences and reset by + * `OSC 104` and `OSC 110/111/112`. A slot nothing has overridden reports the + * color the session's profile gives it, so every field always has an + * answer. + */ export interface TerminalColors { /** The default foreground (`OSC 10`). */ foreground: string @@ -343,9 +377,11 @@ export interface TerminalColors { /** The cursor color (`OSC 12`). */ cursor: string /** - * Palette entries a program overrode (`OSC 4`), keyed by index. + * Palette entries a program moved with `OSC 4`, keyed by index. * - * Only entries that differ from the profile are listed. + * Only the entries that differ from the profile are listed: a program + * that recolors slot 1 is interesting, and the 255 it left alone are + * not. */ palette: Record } @@ -353,7 +389,9 @@ export interface TerminalColors { export interface TextMatch { text: string start: TextPosition + /** Exclusive end position. */ end: TextPosition + /** Per-row column ranges with exclusive ends. */ spans: Array } @@ -378,8 +416,8 @@ export interface Timeouts { export interface TitleOptions { regex?: boolean - not?: boolean timeoutMs?: number + not?: boolean } export interface TraceOptions { diff --git a/bindings/python/src/tui_test/_abi.py b/bindings/python/src/tui_test/_abi.py index f53cf0a2..c2cc58a0 100644 --- a/bindings/python/src/tui_test/_abi.py +++ b/bindings/python/src/tui_test/_abi.py @@ -28,27 +28,23 @@ class TuiPair(ctypes.Structure): pass -class TuiTimeouts(ctypes.Structure): - pass - - -class TuiOpenOptions(ctypes.Structure): +class TuiQuery(ctypes.Structure): pass -class TuiQuery(ctypes.Structure): +class TuiMode(ctypes.Structure): pass -class TuiWaitOptions(ctypes.Structure): +class TuiColor(ctypes.Structure): pass -class TuiMouseOptions(ctypes.Structure): +class TuiResult(ctypes.Structure): pass -class TuiRecordingOptions(ctypes.Structure): +class TuiOpenResult(ctypes.Structure): pass @@ -60,7 +56,7 @@ class TuiSize(ctypes.Structure): pass -class TuiOpenResult(ctypes.Structure): +class TuiBellEvent(ctypes.Structure): pass @@ -68,39 +64,43 @@ class TuiState(ctypes.Structure): pass -class TuiMode(ctypes.Structure): +class TuiTerminalColors(ctypes.Structure): pass -class TuiTerminalColors(ctypes.Structure): +class TuiCell(ctypes.Structure): pass -class TuiColor(ctypes.Structure): +class TuiPosition(ctypes.Structure): pass -class TuiCell(ctypes.Structure): +class TuiSpan(ctypes.Structure): pass -class TuiPosition(ctypes.Structure): +class TuiMatch(ctypes.Structure): pass -class TuiSpan(ctypes.Structure): +class TuiTimeouts(ctypes.Structure): pass -class TuiMatch(ctypes.Structure): +class TuiOpenOptions(ctypes.Structure): pass -class TuiBellEvent(ctypes.Structure): +class TuiMouseOptions(ctypes.Structure): pass -class TuiResult(ctypes.Structure): +class TuiWaitOptions(ctypes.Structure): + pass + + +class TuiRecordingOptions(ctypes.Structure): pass @@ -140,63 +140,31 @@ class TuiResult(ctypes.Structure): ] -TuiTimeouts._fields_ = [ - ("text", TuiOptionalU64), - ("idle", TuiOptionalU64), - ("command", TuiOptionalU64), - ("exit", TuiOptionalU64), - ("ready", TuiOptionalU64), -] - - -TuiOpenOptions._fields_ = [ - ("backend", TuiString), - ("shell", TuiString), - ("cols", TuiOptionalU64), - ("rows", TuiOptionalU64), - ("cwd", TuiString), - ("env", ctypes.POINTER(TuiPair)), - ("env_len", ctypes.c_size_t), - ("wait_ready", TuiOptionalBool), - ("restart", ctypes.c_bool), - ("scrollback", TuiOptionalU64), - ("colors", ctypes.POINTER(TuiPair)), - ("colors_len", ctypes.c_size_t), - ("timeouts", TuiTimeouts), - ("recording_mode", TuiString), - ("recording_directory", TuiString), -] - - TuiQuery._fields_ = [ ("json", TuiString), ] -TuiWaitOptions._fields_ = [ - ("timeout_ms", TuiOptionalU64), - ("regex", ctypes.c_bool), - ("not", ctypes.c_bool), +TuiMode._fields_ = [ + ("name", TuiString), + ("enabled", ctypes.c_bool), ] -TuiMouseOptions._fields_ = [ - ("button", ctypes.c_uint32), - ("alt", ctypes.c_bool), - ("ctrl", ctypes.c_bool), - ("shift", ctypes.c_bool), +TuiColor._fields_ = [ + ("kind", ctypes.c_uint32), + ("index", ctypes.c_uint8), + ("red", ctypes.c_uint8), + ("green", ctypes.c_uint8), + ("blue", ctypes.c_uint8), ] -TuiRecordingOptions._fields_ = [ - ("path", TuiString), - ("format", TuiString), - ("fps", TuiOptionalU64), - ("speed", TuiOptionalF64), - ("idle_time_limit", TuiOptionalF64), - ("zoom", TuiOptionalF64), - ("background", TuiString), - ("transparent", ctypes.c_bool), +TuiOpenResult._fields_ = [ + ("shell_pid", TuiOptionalU64), + ("session", TuiString), + ("ready", ctypes.c_bool), + ("recording", TuiString), ] @@ -215,17 +183,9 @@ class TuiResult(ctypes.Structure): ] -TuiOpenResult._fields_ = [ - ("shell_pid", TuiOptionalU64), - ("session", TuiString), - ("ready", ctypes.c_bool), - ("recording", TuiString), -] - - -TuiMode._fields_ = [ - ("name", TuiString), - ("enabled", ctypes.c_bool), +TuiBellEvent._fields_ = [ + ("sequence", ctypes.c_uint64), + ("elapsed_ms", ctypes.c_uint64), ] @@ -238,15 +198,6 @@ class TuiResult(ctypes.Structure): ] -TuiColor._fields_ = [ - ("kind", ctypes.c_uint32), - ("index", ctypes.c_uint8), - ("red", ctypes.c_uint8), - ("green", ctypes.c_uint8), - ("blue", ctypes.c_uint8), -] - - TuiCell._fields_ = [ ("x", ctypes.c_uint16), ("y", ctypes.c_uint16), @@ -290,9 +241,58 @@ class TuiResult(ctypes.Structure): ] -TuiBellEvent._fields_ = [ - ("sequence", ctypes.c_uint64), - ("elapsed_ms", ctypes.c_uint64), +TuiTimeouts._fields_ = [ + ("text", TuiOptionalU64), + ("idle", TuiOptionalU64), + ("command", TuiOptionalU64), + ("exit", TuiOptionalU64), + ("ready", TuiOptionalU64), +] + + +TuiOpenOptions._fields_ = [ + ("backend", TuiString), + ("shell", TuiString), + ("cols", TuiOptionalU64), + ("rows", TuiOptionalU64), + ("cwd", TuiString), + ("env", ctypes.POINTER(TuiPair)), + ("env_len", ctypes.c_size_t), + ("wait_ready", TuiOptionalBool), + ("restart", ctypes.c_bool), + ("scrollback", TuiOptionalU64), + ("colors", ctypes.POINTER(TuiPair)), + ("colors_len", ctypes.c_size_t), + ("timeouts", TuiTimeouts), + ("recording_mode", TuiString), + ("recording_directory", TuiString), +] + + +TuiMouseOptions._fields_ = [ + ("button", ctypes.c_uint32), + ("alt", ctypes.c_bool), + ("ctrl", ctypes.c_bool), + ("shift", ctypes.c_bool), +] + + +TuiWaitOptions._fields_ = [ + ("timeout_ms", TuiOptionalU64), + ("regex", ctypes.c_bool), + ("not", ctypes.c_bool), +] + + +TuiRecordingOptions._fields_ = [ + ("path", TuiString), + ("format", TuiString), + ("fps", TuiOptionalU64), + ("speed", TuiOptionalF64), + ("idle_time_limit", TuiOptionalF64), + ("zoom", TuiOptionalF64), + ("background", TuiString), + ("transparent", ctypes.c_bool), ] diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index a4497d61..a85ae98d 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -16,7 +16,7 @@ This ABI is an internal contract, not a stable public C SDK. From the repository root: ```sh -cargo build --locked -p tui-test-ffi +env -u CARGO_TARGET_DIR cargo build --locked -p tui-test-ffi ``` The library is `target/debug/libtui_test_ffi.so` on Linux, `libtui_test_ffi.dylib` on macOS, or `tui_test_ffi.dll` on Windows. @@ -58,23 +58,90 @@ node --input-type=module -e ' The build enables `--features napi` and writes the addon, loader, and declarations to `bindings/js/native/`. The example prints the terminal size and text that contains `Ready 界🙂`. -## Generate declarations +## Generate boundaries -The Rust declarations in `src/types.rs` and `src/lib.rs` are the source of truth. -The generator parses Rust syntax and rejects unsupported field types. It does not parse source with regular expressions. +The owned Rust models define the shared fields. Do not edit generated boundary files. +The existing `tools/bindgen` tool reads these models with `syn`. +It generates boundary structs and fieldwise conversions before it runs `cbindgen`. +It rejects field types without a transport mapping. + +| Authoritative source in `crates/tui-test/src/` | Coverage | +| --- | --- | +| `api.rs` | Open/run options, recording options, timeouts, mouse options, styles, results, and operation option fields | +| `locator_query.rs` | Locator nodes, expressions, and node kinds | +| `profile.rs` | Profile fields flattened into open/run options | +| `diagnostics.rs` | Artifact, trace, and retention options, including the shared session input | +| `shell/mod.rs`, `terminal/backend.rs` | Shell and backend enum variants | +| `terminal/cell.rs` | Underline variants for the JavaScript string enum | + +The result models include `OpenResult`, `Cursor`, `Size`, `BellEvent`, `State`, `TerminalColors`, and `Cell`. +They also include `TextPosition`, `TextSpan`, `TextMatch`, `EffectiveTimeouts`, and `PackedScreen`. +The operation variants supply title, clipboard, snapshot, screenshot, and recording option fields. +The clipboard options select the regex and timeout fields from the title operation. ```sh -cargo run --locked -p tui-test-bindgen -cargo run --locked -p tui-test-bindgen -- --check +env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen +env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen -- --check ``` -The generator uses `cbindgen` for `include/tui_test.h`. -It also generates the Go structs and function table, and Python `ctypes` declarations. -The JavaScript adapter uses Rust operations and its own JavaScript DTOs, not the C ABI declarations. -Run the generator with Go and Rust formatters on `PATH`. +The generator writes these Rust files under `crates/tui-test-ffi/src/`: + +- `types.rs` and `napi/types.rs` +- `input_generated.rs` and `output_generated.rs` +- `napi/convert_generated.rs` +- `session_generated.rs` + +It then generates `include/tui_test.h`, the Go ABI structs and function table, and Python `ctypes` declarations. +The C function signatures in `src/lib.rs` remain authoritative for the C entrypoints. +The JavaScript adapter still calls owned Rust operations directly. It does not call the C ABI. + +`--check` checks all these outputs without changing files. +Normal generation can replace missing or stale generated Rust files. +The generator does not compile the FFI crate to discover its models. +Keep `rustfmt` and `gofmt` on `PATH`. + +Run `npm run build:native:debug` from `bindings/js` after generation. +The N-API CLI updates `native/index.d.ts` and the loader from the generated Rust types. +CI separately checks these N-API outputs after the native build. +Release packages include generated Rust files. They do not need the generator during a package build. + +### Transport mappings and handwritten code + +`tools/bindgen/src/boundaries.rs` contains transport mappings, not another list of shared fields. +It maps C strings, pointer/length pairs, option tags, and fixed ABI field order. +It maps JavaScript numbers, nullable properties, property names, color unions, maps, and owned buffers. +It also generates strict locator input keys from the source fields. +The existing C ABI v3 layouts and JavaScript signatures remain unchanged. + +These parts remain handwritten: + +- `src/type_helpers.rs`: C strings, option tags, map entries, color tags, and the result envelope. +- `src/napi/type_helpers.rs`: the color union, overloaded mouse-click options, and the strict-input macro. +- `input.rs`, `output.rs`, and `napi/convert.rs`: pointer ownership, numeric validation, profile validation, and semantic adapters. +- `lib.rs`, `napi/mod.rs`, and `session.rs`: entrypoints, operation dispatch, error presentation, and execution. +- The public Go, Python, and TypeScript convenience APIs. + +The generator copies C transport helpers into `types.rs`; only `type_helpers.rs` is handwritten. +The result envelope is a transport container, not a mirror of the Rust result enum. +Mouse codes, snapshot codes, capture backgrounds, and locator occurrence rules retain explicit transport logic. +The core still has separate input and effective timeout models. +Generation does not merge different owned API concepts or remove handwritten language convenience APIs. + +### Source propagation example + +Run this example from the repository root: + +```sh +env -u CARGO_TARGET_DIR cargo test --locked -p tui-test-bindgen \ + boundaries::tests::owned_field_propagates_to_both_declarations_and_conversions -- --exact +env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen -- --check +``` -The language adapters still own asynchronous execution, error presentation, input storage, and result conversion. -They do not implement terminal behavior. +The test adds a cursor field to the source model in memory. +It verifies that both boundary declarations and both conversions contain the new field. +Other generator tests cover input numbers, strict locator keys, enum variants, missing outputs, and stale outputs. +Then run the JavaScript terminal example above or the Go smoke example. +These examples exercise the generated result conversions with a real terminal. ## C ABI ownership diff --git a/crates/tui-test-ffi/src/input.rs b/crates/tui-test-ffi/src/input.rs index 97c69ab5..5903f418 100644 --- a/crates/tui-test-ffi/src/input.rs +++ b/crates/tui-test-ffi/src/input.rs @@ -8,6 +8,8 @@ use tui_test::ExecutionContext; type Result = std::result::Result; +include!("input_generated.rs"); + impl TuiOptionalU64 { pub(crate) fn option(self) -> Option { self.present.then_some(self.value) @@ -92,17 +94,9 @@ pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result { .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:?}"))), + .map(|s| { + serde_json::from_value(s.clone().into()) + .map_err(|_| TuiTestError::usage(format!("unknown shell {s:?}"))) }) .transpose()?; let mut profile = Profile::default(); @@ -118,41 +112,10 @@ pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result { ))); } } - 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), - }, - }) + let mode = unsafe { value.recording_mode.optional()? }.unwrap_or_else(|| "disabled".into()); + let mode = serde_json::from_value(mode.clone().into()) + .map_err(|_| TuiTestError::usage(format!("unknown automatic recording mode {mode:?}")))?; + unsafe { open_input(value, backend, shell, profile, mode) } } pub(crate) fn mouse(value: TuiMouseOptions) -> Result { let button = match value.button { @@ -161,12 +124,7 @@ pub(crate) fn mouse(value: TuiMouseOptions) -> Result { 2 => MouseButton::Right, _ => return Err(TuiTestError::usage("unknown mouse button")), }; - Ok(MouseOptions { - button, - alt: value.alt, - ctrl: value.ctrl, - shift: value.shift, - }) + Ok(mouse_input(value, button)) } pub(crate) unsafe fn query(value: TuiQuery) -> Result { let json = unsafe { value.json.required()? }; @@ -183,12 +141,9 @@ pub(crate) unsafe fn capture_background( } 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")), + .map(|s| { + serde_json::from_value(s.into()) + .map_err(|_| TuiTestError::usage("unknown recording format")) }) .transpose()?; let fps = value @@ -196,13 +151,5 @@ pub(crate) unsafe fn recording(value: TuiRecordingOptions) -> Result .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)? }, - }) + unsafe { recording_input(value, format, fps) } } diff --git a/crates/tui-test-ffi/src/input_generated.rs b/crates/tui-test-ffi/src/input_generated.rs new file mode 100644 index 00000000..5e3278e4 --- /dev/null +++ b/crates/tui-test-ffi/src/input_generated.rs @@ -0,0 +1,57 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +fn timeouts(value: TuiTimeouts) -> Timeouts { + Timeouts { + text: value.text.option(), + idle: value.idle.option(), + command: value.command.option(), + exit: value.exit.option(), + ready: value.ready.option(), + } +} +unsafe fn open_input( + value: TuiOpenOptions, + backend: tui_test::terminal::backend::Backend, + shell: Option, + profile: Profile, + mode: AutomaticRecordingMode, +) -> Result { + 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(value.timeouts), + recording: AutomaticRecording { + mode, + directory: unsafe { value.recording_directory.optional()? }.map(Into::into), + }, + }) +} +fn mouse_input(value: TuiMouseOptions, button: MouseButton) -> MouseOptions { + MouseOptions { + button, + alt: value.alt, + ctrl: value.ctrl, + shift: value.shift, + } +} +unsafe fn recording_input( + value: TuiRecordingOptions, + format: Option, + fps: Option, +) -> Result { + 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/crates/tui-test-ffi/src/napi/convert.rs b/crates/tui-test-ffi/src/napi/convert.rs index 1e37ceb2..c5faf305 100644 --- a/crates/tui-test-ffi/src/napi/convert.rs +++ b/crates/tui-test-ffi/src/napi/convert.rs @@ -3,7 +3,8 @@ use napi::bindgen_prelude::Either; use napi::Result; use tui_test::api::{self, OperationResult}; use tui_test::profile::{Profile, Rgb}; -use tui_test::terminal::backend::Backend as TerminalBackend; + +include!("convert_generated.rs"); pub fn integer(value: f64, name: &str, max: u64) -> Result { let max = max.min(MAX_SAFE_INTEGER); @@ -72,91 +73,36 @@ pub fn open_options(value: OpenOptions, directory: Option) -> Result Default::default(), - Some(Backend::Alacritty) => TerminalBackend::Alacritty, - Some(Backend::Ghostty) => TerminalBackend::Ghostty, - Some(Backend::Rio) => TerminalBackend::Rio, - Some(Backend::Xtermjs) => TerminalBackend::Xtermjs, - }, - shell: value.shell.map(|shell| match shell { - Shell::Bash => tui_test::shell::Shell::Bash, - Shell::Powershell => tui_test::shell::Shell::Powershell, - Shell::Pwsh => tui_test::shell::Shell::Pwsh, - Shell::Cmd => tui_test::shell::Shell::Cmd, - Shell::Fish => tui_test::shell::Shell::Fish, - Shell::Zsh => tui_test::shell::Shell::Zsh, - Shell::Xonsh => tui_test::shell::Shell::Xonsh, - Shell::Elvish => tui_test::shell::Shell::Elvish, - Shell::Nushell => tui_test::shell::Shell::Nushell, - }), - profile, - cols: u16_value(value.cols.unwrap_or(80.0), "cols")?, - rows: u16_value(value.rows.unwrap_or(30.0), "rows")?, - cwd: value.cwd, - env: value.env.unwrap_or_default(), - wait_ready: value.wait_ready, - restart: value.restart.unwrap_or(false), - timeouts: api::Timeouts { - text: optional_integer(timeouts.text, "timeouts.text", u64::MAX)?, - idle: optional_integer(timeouts.idle, "timeouts.idle", u64::MAX)?, - command: optional_integer(timeouts.command, "timeouts.command", u64::MAX)?, - exit: optional_integer(timeouts.exit, "timeouts.exit", u64::MAX)?, - ready: optional_integer(timeouts.ready, "timeouts.ready", u64::MAX)?, - }, - recording: api::AutomaticRecording { - mode: api::AutomaticRecordingMode::Disabled, - directory: directory.map(Into::into), - }, - }) -} - -pub fn query(expression: LocatorExpression) -> Result { - let mut nodes = Vec::with_capacity(expression.nodes.len()); - for (index, node) in expression.nodes.into_iter().enumerate() { - let occurrence = if let Some(nth) = node.nth { - if let Some(value) = node.occurrence.as_deref().filter(|value| *value != "nth") { - return Err(usage(format!( - "nodes[{index}].nth cannot be used with occurrence '{value}'" - ))); - } - serde_json::json!({"nth": integer(nth, &format!("nodes[{index}].nth"), usize::MAX as u64)?}) - } else { - match node.occurrence.as_deref().unwrap_or("any") { - "any" | "unique" | "first" | "last" => { - node.occurrence.unwrap_or_else(|| "any".into()).into() - } - "nth" => return Err(usage(format!("nodes[{index}].nth requires an nth index"))), - value => return Err(usage(format!("invalid occurrence '{value}'"))), - } - }; - let reference = |value: Option| { - value - .map(|value| integer(value, "locator reference", 255)) - .transpose() - }; - nodes.push(serde_json::json!({ - "kind": node.kind, "direction": node.direction, "text": node.text, "regex": node.regex, - "full": node.full, "whitespace": node.whitespace, "occurrence": occurrence, "style": node.style, - "link": node.link, "within": reference(node.within)?, "left": reference(node.left)?, - "right": reference(node.right)?, "input": reference(node.input)?, - "has": reference(node.has)?, "has_not": reference(node.has_not)?, - })); - } - serde_json::from_value::( - serde_json::json!({"nodes": nodes, "root": integer(expression.root, "locator root", 255)?}), - ) - .map_err(|error| usage(format!("invalid locator expression: {error}")))? - .into_query() - .map_err(native_error) + open_input(value, profile, directory) +} + +fn occurrence( + value: Option, + nth: Option, + index: usize, +) -> Result { + let occurrence = if let Some(nth) = nth { + if let Some(value) = value.as_deref().filter(|value| *value != "nth") { + return Err(usage(format!( + "nodes[{index}].nth cannot be used with occurrence '{value}'" + ))); + } + serde_json::json!({"nth": integer(nth, &format!("nodes[{index}].nth"), usize::MAX as u64)?}) + } else { + match value.as_deref().unwrap_or("any") { + "any" | "unique" | "first" | "last" => value.unwrap_or_else(|| "any".into()).into(), + "nth" => return Err(usage(format!("nodes[{index}].nth requires an nth index"))), + value => return Err(usage(format!("invalid occurrence '{value}'"))), + } + }; + serde_json::from_value(occurrence) + .map_err(|error| usage(format!("invalid locator expression: {error}"))) } pub fn unit(value: OperationResult) -> Result<()> { @@ -192,60 +138,7 @@ pub fn open_result(value: OperationResult) -> Result { let OperationResult::Open(value) = value else { return Err(internal("native operation returned an unexpected result")); }; - Ok(OpenResult { - shell_pid: value.shell_pid, - session: value.session, - ready: value.ready, - recording: value.recording, - }) -} - -pub fn cursor(value: api::Cursor) -> Cursor { - Cursor { - x: value.x, - y: value.y, - visible: value.visible, - shape: value.shape, - color: value.color, - } -} - -pub fn state(value: api::State) -> State { - State { - session_shell: value.session_shell, - cols: value.cols, - rows: value.rows, - cursor: cursor(value.cursor), - title: value.title, - cwd: value.cwd, - last_command: value.last_command, - last_exit: value.last_exit, - exit_signal: value.exit_signal, - exited: value.exited, - ready: value.ready, - bell_count: value.bell_count as f64, - modes: value.modes.into_iter().collect(), - mouse_mode: value.mouse_mode, - colors: TerminalColors { - foreground: value.colors.foreground, - background: value.colors.background, - cursor: value.colors.cursor, - palette: value - .colors - .palette - .into_iter() - .map(|(index, color)| (index.to_string(), color)) - .collect(), - }, - timeouts: EffectiveTimeouts { - text: value.timeouts.text as f64, - idle: value.timeouts.idle as f64, - command: value.timeouts.command as f64, - exit: value.timeouts.exit as f64, - ready: value.timeouts.ready as f64, - }, - text: value.text, - } + Ok(open_value(value)) } pub fn color(value: api::CellColor) -> Color { @@ -258,65 +151,9 @@ pub fn color(value: api::CellColor) -> Color { } } -pub fn cell(value: api::Cell) -> Result { - Ok(Cell { - x: value.x, - y: value.y, - r#char: value.char, - fg: color(value.fg), - bg: color(value.bg), - bold: value.bold, - dim: value.dim, - italic: value.italic, - inverse: value.inverse, - invisible: value.invisible, - strike: value.strike, - blink: value.blink, - underline: value.underline, - underline_style: match value.underline_style.as_str() { - "none" => UnderlineStyle::None, - "single" => UnderlineStyle::Single, - "double" => UnderlineStyle::Double, - "curly" => UnderlineStyle::Curly, - "dotted" => UnderlineStyle::Dotted, - "dashed" => UnderlineStyle::Dashed, - value => { - return Err(internal(format!( - "terminal returned unknown underline style '{value}'" - ))) - } - }, - underline_color: color(value.underline_color), - link: value.link, - link_id: value.link_id, - }) -} - pub fn matches(value: OperationResult) -> Result> { let OperationResult::Matches(value) = value else { return Err(internal("native operation returned an unexpected result")); }; - Ok(value - .into_iter() - .map(|value| TextMatch { - text: value.text, - start: TextPosition { - row: value.start.row, - column: value.start.column, - }, - end: TextPosition { - row: value.end.row, - column: value.end.column, - }, - spans: value - .spans - .into_iter() - .map(|value| TextSpan { - row: value.row, - start: value.start, - end: value.end, - }) - .collect(), - }) - .collect()) + Ok(value.into_iter().map(text_match).collect()) } diff --git a/crates/tui-test-ffi/src/napi/convert_generated.rs b/crates/tui-test-ffi/src/napi/convert_generated.rs new file mode 100644 index 00000000..21f9bc3e --- /dev/null +++ b/crates/tui-test-ffi/src/napi/convert_generated.rs @@ -0,0 +1,405 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +pub fn open_value(value: api::OpenResult) -> OpenResult { + OpenResult { + shell_pid: value.shell_pid, + session: value.session, + ready: value.ready, + recording: value.recording, + } +} +pub fn cursor(value: api::Cursor) -> Cursor { + Cursor { + x: value.x, + y: value.y, + visible: value.visible, + shape: value.shape, + color: value.color, + } +} +pub fn size(value: api::Size) -> Size { + Size { + cols: value.cols, + rows: value.rows, + } +} +pub fn effective_timeouts(value: api::EffectiveTimeouts) -> EffectiveTimeouts { + EffectiveTimeouts { + text: value.text as f64, + idle: value.idle as f64, + command: value.command as f64, + exit: value.exit as f64, + ready: value.ready as f64, + } +} +pub fn bell_event(value: api::BellEvent) -> BellEvent { + BellEvent { + sequence: value.sequence as f64, + elapsed_ms: value.elapsed_ms as f64, + } +} +pub fn state(value: api::State) -> State { + State { + session_shell: value.session_shell, + cols: value.cols, + rows: value.rows, + cursor: cursor(value.cursor), + title: value.title, + cwd: value.cwd, + last_command: value.last_command, + last_exit: value.last_exit, + exit_signal: value.exit_signal, + exited: value.exited, + ready: value.ready, + bell_count: value.bell_count as f64, + modes: value.modes.into_iter().collect(), + mouse_mode: value.mouse_mode, + colors: terminal_colors(value.colors), + timeouts: effective_timeouts(value.timeouts), + text: value.text, + } +} +pub fn terminal_colors(value: api::TerminalColors) -> TerminalColors { + TerminalColors { + foreground: value.foreground, + background: value.background, + cursor: value.cursor, + palette: value + .palette + .into_iter() + .map(|(index, color)| (index.to_string(), color)) + .collect(), + } +} +pub fn cell(value: api::Cell) -> Result { + Ok(Cell { + x: value.x, + y: value.y, + r#char: value.r#char, + fg: color(value.fg), + bg: color(value.bg), + bold: value.bold, + dim: value.dim, + italic: value.italic, + inverse: value.inverse, + invisible: value.invisible, + strike: value.strike, + blink: value.blink, + underline: value.underline, + underline_style: underline_style(&value.underline_style)?, + underline_color: color(value.underline_color), + link: value.link, + link_id: value.link_id, + }) +} +pub fn text_position(value: api::TextPosition) -> TextPosition { + TextPosition { + row: value.row, + column: value.column, + } +} +pub fn text_span(value: api::TextSpan) -> TextSpan { + TextSpan { + row: value.row, + start: value.start, + end: value.end, + } +} +pub fn text_match(value: api::TextMatch) -> TextMatch { + TextMatch { + text: value.text, + start: text_position(value.start), + end: text_position(value.end), + spans: value.spans.into_iter().map(text_span).collect(), + } +} +pub fn packed_screen(value: api::PackedScreen) -> PackedScreen { + PackedScreen { + cols: value.cols, + rows: value.rows, + utf8: value.utf8.into(), + } +} +impl From for tui_test::terminal::backend::Backend { + fn from(value: Backend) -> Self { + match value { + Backend::Alacritty => Self::Alacritty, + Backend::Ghostty => Self::Ghostty, + Backend::Rio => Self::Rio, + Backend::Xtermjs => Self::Xtermjs, + } + } +} +impl From for tui_test::shell::Shell { + fn from(value: Shell) -> Self { + match value { + Shell::Bash => Self::Bash, + Shell::Powershell => Self::Powershell, + Shell::Pwsh => Self::Pwsh, + Shell::Cmd => Self::Cmd, + Shell::Fish => Self::Fish, + Shell::Zsh => Self::Zsh, + Shell::Xonsh => Self::Xonsh, + Shell::Elvish => Self::Elvish, + Shell::Nushell => Self::Nushell, + } + } +} +impl From for tui_test::api::RecordingFormat { + fn from(value: RecordingFormat) -> Self { + match value { + RecordingFormat::Apng => Self::Apng, + RecordingFormat::Gif => Self::Gif, + RecordingFormat::Mp4 => Self::Mp4, + RecordingFormat::Cast => Self::Cast, + } + } +} +impl From for tui_test::api::SnapshotResult { + fn from(value: SnapshotResult) -> Self { + match value { + SnapshotResult::Passed => Self::Passed, + SnapshotResult::Written => Self::Written, + SnapshotResult::Updated => Self::Updated, + } + } +} +impl From for SnapshotResult { + fn from(value: tui_test::api::SnapshotResult) -> Self { + match value { + tui_test::api::SnapshotResult::Passed => Self::Passed, + tui_test::api::SnapshotResult::Written => Self::Written, + tui_test::api::SnapshotResult::Updated => Self::Updated, + } + } +} +fn underline_style(value: &str) -> Result { + Ok(match value { + "none" => UnderlineStyle::None, + "single" => UnderlineStyle::Single, + "double" => UnderlineStyle::Double, + "curly" => UnderlineStyle::Curly, + "dotted" => UnderlineStyle::Dotted, + "dashed" => UnderlineStyle::Dashed, + value => { + return Err(internal(format!( + "terminal returned unknown underline style '{value}'" + ))) + } + }) +} +impl From for tui_test::locator_query::LocatorNodeKind { + fn from(value: LocatorNodeKind) -> Self { + match value { + LocatorNodeKind::Text => Self::Text, + LocatorNodeKind::Style => Self::Style, + LocatorNodeKind::Link => Self::Link, + LocatorNodeKind::And => Self::And, + LocatorNodeKind::Or => Self::Or, + LocatorNodeKind::Filter => Self::Filter, + } + } +} +impl From for tui_test::api::LocatorDirection { + fn from(value: LocatorStageDirection) -> Self { + match value { + LocatorStageDirection::Within => Self::Within, + LocatorStageDirection::After => Self::After, + LocatorStageDirection::Before => Self::Before, + } + } +} +pub fn timeouts(value: Timeouts) -> Result { + Ok(api::Timeouts { + text: optional_integer(value.text, "timeouts.text", u64::MAX)?, + idle: optional_integer(value.idle, "timeouts.idle", u64::MAX)?, + command: optional_integer(value.command, "timeouts.command", u64::MAX)?, + exit: optional_integer(value.exit, "timeouts.exit", u64::MAX)?, + ready: optional_integer(value.ready, "timeouts.ready", u64::MAX)?, + }) +} +fn open_input( + value: OpenOptions, + profile: Profile, + directory: Option, +) -> Result { + Ok(api::OpenOptions { + backend: value.backend.map(Into::into).unwrap_or_default(), + shell: value.shell.map(Into::into), + profile, + cols: u16_value(value.cols.unwrap_or(80.0), "cols")?, + rows: u16_value(value.rows.unwrap_or(30.0), "rows")?, + cwd: value.cwd, + env: value.env.unwrap_or_default(), + wait_ready: value.wait_ready, + restart: value.restart.unwrap_or_default(), + timeouts: timeouts(value.timeouts.unwrap_or_default())?, + recording: api::AutomaticRecording { + mode: api::AutomaticRecordingMode::Disabled, + directory: directory.map(Into::into), + }, + }) +} +pub fn apply_artifact(context: &mut crate::session::SessionInput, value: FailureArtifactOptions) { + context.artifact_directory = Some(value.directory); + context.artifact_mode = Some(value.mode.unwrap_or_else(|| "all".into())); + context.artifact_include_recording = value.include_recording.unwrap_or(false); +} +pub fn apply_trace(context: &mut crate::session::SessionInput, value: TraceOptions) { + context.trace_mode = Some(value.mode.unwrap_or_else(|| "off".into())); + context.trace_directory = value.directory; +} +pub fn open_options_context( + context: &mut crate::session::SessionInput, + value: &OpenOptions, +) -> Result<()> { + context.screen_history_limit = optional_u16(value.screen_history_limit, "screenHistoryLimit")?; + Ok(()) +} +pub fn run_options_context( + context: &mut crate::session::SessionInput, + value: &RunOptions, +) -> Result<()> { + context.screen_history_limit = optional_u16(value.screen_history_limit, "screenHistoryLimit")?; + Ok(()) +} +pub fn run_options(value: RunOptions, directory: Option) -> Result { + let open = open_options( + OpenOptions { + backend: value.backend, + cols: value.cols, + rows: value.rows, + cwd: value.cwd, + env: value.env, + wait_ready: value.wait_ready, + restart: value.restart, + profile_scrollback: value.profile_scrollback, + profile_colors: value.profile_colors, + timeouts: value.timeouts, + screen_history_limit: value.screen_history_limit, + ..Default::default() + }, + directory, + )?; + if value.program.is_empty() { + return Err(usage("program must not be empty")); + } + Ok(api::RunOptions { + backend: open.backend, + program: value.program, + args: value.args.unwrap_or_default(), + profile: open.profile, + cols: open.cols, + rows: open.rows, + cwd: open.cwd, + env: open.env, + wait_ready: open.wait_ready, + restart: open.restart, + timeouts: open.timeouts, + recording: open.recording, + }) +} +pub fn snapshot_operation(value: SnapshotOptions, name: String) -> Result { + Ok(api::Operation::Snapshot { + name, + update: value.update.unwrap_or(false), + include_style: value.include_style.unwrap_or(false), + include_title: value.include_title.unwrap_or(false), + cwd: value.cwd, + }) +} +pub fn screenshot_operation(value: ScreenshotOptions) -> Result { + Ok(api::Operation::Screenshot { + full: value.full.unwrap_or(false), + path: value.path, + zoom: value.zoom, + background: crate::session::capture_background( + value.background, + value.transparent.unwrap_or(false), + ) + .map_err(native_error)?, + }) +} +pub fn start_recording_operation(value: RecordingOptions) -> Result { + Ok(api::Operation::StartRecording { + path: value.path, + format: value.format.map(Into::into), + fps: value.fps.map(|fps| u8_value(fps, "fps")).transpose()?, + speed: value.speed, + idle_time_limit: value.idle_time_limit, + zoom: value.zoom, + background: crate::session::capture_background( + value.background, + value.transparent.unwrap_or(false), + ) + .map_err(native_error)?, + }) +} +pub fn wait_title_operation(value: TitleOptions, text: String) -> Result { + Ok(api::Operation::WaitTitle { + text, + regex: value.regex.unwrap_or(false), + timeout_ms: optional_integer(value.timeout_ms, "timeoutMs", u64::MAX)?, + not: value.not.unwrap_or(false), + }) +} +pub fn expect_title_operation(value: TitleOptions, text: String) -> Result { + Ok(api::Operation::ExpectTitle { + text, + regex: value.regex.unwrap_or(false), + not: value.not.unwrap_or(false), + timeout_ms: optional_integer(value.timeout_ms, "timeoutMs", u64::MAX)?, + }) +} +fn locator_style(value: LocatorStyle) -> api::TextStyle { + api::TextStyle { + foreground: value.foreground, + background: value.background, + bold: value.bold, + dim: value.dim, + italic: value.italic, + underline_style: value.underline_style, + underline_color: value.underline_color, + inverse: value.inverse, + hidden: value.hidden, + strikethrough: value.strikethrough, + blink: value.blink, + } +} +fn locator_node(value: LocatorNode, index: usize) -> Result { + Ok(tui_test::locator_query::LocatorNode { + kind: value.kind.into(), + text: value.text, + regex: value.regex, + whitespace: value + .whitespace + .map(|s| { + serde_json::from_value(serde_json::Value::String(s)) + .map_err(|error| usage(format!("invalid locator expression: {error}"))) + }) + .transpose()?, + style: value.style.map(locator_style), + link: value.link, + full: value.full, + direction: value.direction.map(Into::into), + within: optional_integer(value.within, "locator reference", 255)?.map(|v| v as usize), + left: optional_integer(value.left, "locator reference", 255)?.map(|v| v as usize), + right: optional_integer(value.right, "locator reference", 255)?.map(|v| v as usize), + input: optional_integer(value.input, "locator reference", 255)?.map(|v| v as usize), + has: optional_integer(value.has, "locator reference", 255)?.map(|v| v as usize), + has_not: optional_integer(value.has_not, "locator reference", 255)?.map(|v| v as usize), + occurrence: occurrence(value.occurrence, value.nth, index)?, + }) +} +pub fn query(value: LocatorExpression) -> Result { + tui_test::locator_query::LocatorExpression { + nodes: value + .nodes + .into_iter() + .enumerate() + .map(|(i, v)| locator_node(v, i)) + .collect::>()?, + root: integer(value.root, "locator root", 255)? as usize, + } + .into_query() + .map_err(native_error) +} diff --git a/crates/tui-test-ffi/src/napi/mod.rs b/crates/tui-test-ffi/src/napi/mod.rs index 058fe4d8..0bfee191 100644 --- a/crates/tui-test-ffi/src/napi/mod.rs +++ b/crates/tui-test-ffi/src/napi/mod.rs @@ -123,13 +123,10 @@ impl NativeSession { ..Default::default() }; if let Some(options) = artifacts { - context.artifact_directory = Some(options.directory); - context.artifact_mode = Some(options.mode.unwrap_or_else(|| "all".into())); - context.artifact_include_recording = options.include_recording.unwrap_or(false); + apply_artifact(&mut context, options); } if let Some(options) = trace { - context.trace_mode = Some(options.mode.unwrap_or_else(|| "off".into())); - context.trace_directory = options.directory; + apply_trace(&mut context, options); } Ok(Self { name, @@ -147,8 +144,7 @@ impl NativeSession { pub async fn open(&self, options: Option) -> Result { let options = options.unwrap_or_default(); let mut context = self.context.clone(); - context.screen_history_limit = - optional_u16(options.screen_history_limit, "screenHistoryLimit")?; + open_options_context(&mut context, &options)?; let directory = self.recording_directory.clone(); execute( context, @@ -162,47 +158,12 @@ impl NativeSession { #[napi] pub async fn run(&self, options: RunOptions) -> Result { let mut context = self.context.clone(); - context.screen_history_limit = - optional_u16(options.screen_history_limit, "screenHistoryLimit")?; + run_options_context(&mut context, &options)?; let directory = self.recording_directory.clone(); execute( context, "run", - move || { - let open = open_options( - OpenOptions { - backend: options.backend, - cols: options.cols, - rows: options.rows, - cwd: options.cwd, - env: options.env, - wait_ready: options.wait_ready, - restart: options.restart, - profile_scrollback: options.profile_scrollback, - profile_colors: options.profile_colors, - timeouts: options.timeouts, - ..Default::default() - }, - directory, - )?; - if options.program.is_empty() { - return Err(usage("program must not be empty")); - } - Ok(Operation::Run(api::RunOptions { - program: options.program, - args: options.args.unwrap_or_default(), - backend: open.backend, - profile: open.profile, - cols: open.cols, - rows: open.rows, - cwd: open.cwd, - env: open.env, - wait_ready: open.wait_ready, - restart: open.restart, - timeouts: open.timeouts, - recording: open.recording, - })) - }, + move || Ok(Operation::Run(run_options(options, directory)?)), open_result, ) .await @@ -273,11 +234,7 @@ impl NativeSession { }) }, |value| match value { - OperationResult::PackedScreen(value) => Ok(PackedScreen { - cols: value.cols, - rows: value.rows, - utf8: value.utf8.into(), - }), + OperationResult::PackedScreen(value) => Ok(packed_screen(value)), _ => Err(internal("native operation returned an unexpected result")), }, ) @@ -490,10 +447,7 @@ impl NativeSession { "getSize", || Ok(Operation::GetSize), |value| match value { - OperationResult::Size(value) => Ok(Size { - cols: value.cols, - rows: value.rows, - }), + OperationResult::Size(value) => Ok(size(value)), _ => Err(internal("native operation returned an unexpected result")), }, ) @@ -517,13 +471,9 @@ impl NativeSession { "getBellEvents", || Ok(Operation::GetBellEvents), |value| match value { - OperationResult::BellEvents(value) => Ok(value - .into_iter() - .map(|value| BellEvent { - sequence: value.sequence as f64, - elapsed_ms: value.elapsed_ms as f64, - }) - .collect()), + OperationResult::BellEvents(value) => { + Ok(value.into_iter().map(bell_event).collect()) + } _ => Err(internal("native operation returned an unexpected result")), }, ) @@ -739,14 +689,7 @@ impl NativeSession { let options = options.unwrap_or_default(); self.call( "waitTitle", - move || { - Ok(Operation::WaitTitle { - text, - regex: options.regex.unwrap_or(false), - not: options.not.unwrap_or(false), - timeout_ms: optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, + move || wait_title_operation(options, text), unit, ) .await @@ -756,14 +699,7 @@ impl NativeSession { let options = options.unwrap_or_default(); self.call( "expectTitle", - move || { - Ok(Operation::ExpectTitle { - text, - regex: options.regex.unwrap_or(false), - not: options.not.unwrap_or(false), - timeout_ms: optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, + move || expect_title_operation(options, text), unit, ) .await @@ -916,21 +852,9 @@ impl NativeSession { let options = options.unwrap_or_default(); self.call( "expectSnapshot", - move || { - Ok(Operation::Snapshot { - name, - update: options.update.unwrap_or(false), - include_style: options.include_style.unwrap_or(false), - include_title: options.include_title.unwrap_or(false), - cwd: options.cwd, - }) - }, + move || snapshot_operation(options, name), |value| match value { - OperationResult::Snapshot(value) => Ok(match value { - api::SnapshotResult::Passed => SnapshotResult::Passed, - api::SnapshotResult::Written => SnapshotResult::Written, - api::SnapshotResult::Updated => SnapshotResult::Updated, - }), + OperationResult::Snapshot(value) => Ok(value.into()), _ => Err(internal("native operation returned an unexpected result")), }, ) @@ -941,18 +865,7 @@ impl NativeSession { let options = options.unwrap_or_default(); self.call( "screenshot", - move || { - Ok(Operation::Screenshot { - full: options.full.unwrap_or(false), - path: options.path, - zoom: options.zoom, - background: session::capture_background( - options.background, - options.transparent.unwrap_or(false), - ) - .map_err(native_error)?, - }) - }, + move || screenshot_operation(options), string_result, ) .await @@ -961,26 +874,7 @@ impl NativeSession { pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { self.call( "startRecording", - move || { - Ok(Operation::StartRecording { - path: options.path, - format: options.format.map(|format| match format { - RecordingFormat::Apng => api::RecordingFormat::Apng, - RecordingFormat::Gif => api::RecordingFormat::Gif, - RecordingFormat::Mp4 => api::RecordingFormat::Mp4, - RecordingFormat::Cast => api::RecordingFormat::Cast, - }), - fps: options.fps.map(|fps| u8_value(fps, "fps")).transpose()?, - speed: options.speed, - idle_time_limit: options.idle_time_limit, - zoom: options.zoom, - background: session::capture_background( - options.background, - options.transparent.unwrap_or(false), - ) - .map_err(native_error)?, - }) - }, + move || start_recording_operation(options), unit, ) .await diff --git a/crates/tui-test-ffi/src/napi/type_helpers.rs b/crates/tui-test-ffi/src/napi/type_helpers.rs new file mode 100644 index 00000000..2687af33 --- /dev/null +++ b/crates/tui-test-ffi/src/napi/type_helpers.rs @@ -0,0 +1,40 @@ +use ::napi::bindgen_prelude::{ + Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, +}; +use napi::{Error, Result, Status}; +use napi_derive::napi; +use std::collections::HashMap; + +#[napi] +pub type Color = Either; + +// This convenience operation selects either coordinates or text, unlike core MouseAction. +#[derive(Default)] +#[napi(object)] +pub struct MouseClickOptions { + pub x: Option, + pub y: Option, + pub on_text: Option, + pub button: Option, + pub clicks: Option, +} + +// Reject unknown fields before conversion so misspelled predicates cannot +// silently broaden a locator. The generator supplies the field names. +macro_rules! strict_locator_input { + ($name:ident { $($field:ident => $key:literal),* $(,)? }) => { + impl ValidateNapiValue for $name {} + impl FromNapiValue for $name { + unsafe fn from_napi_value(env: napi::sys::napi_env, value: napi::sys::napi_value) -> Result { + unsafe { Object::validate(env, value)?; } + let object = unsafe { Object::from_napi_value(env, value)? }; + for key in Object::keys(&object)? { + if ![$($key),*].contains(&key.as_str()) { + return Err(Error::new(Status::InvalidArg, format!("unknown locator field {key:?}"))); + } + } + Ok(Self { $($field: object.get_named_property($key)?),* }) + } + } + }; +} diff --git a/crates/tui-test-ffi/src/napi/types.rs b/crates/tui-test-ffi/src/napi/types.rs index 91256dba..65415dff 100644 --- a/crates/tui-test-ffi/src/napi/types.rs +++ b/crates/tui-test-ffi/src/napi/types.rs @@ -1,111 +1,5 @@ -use ::napi::bindgen_prelude::{ - Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, -}; -use napi::{Error, Result, Status}; -use napi_derive::napi; -use std::collections::HashMap; - -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum Backend { - Alacritty, - Ghostty, - Rio, - Xtermjs, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum Shell { - Bash, - Powershell, - Pwsh, - Cmd, - Fish, - Zsh, - Xonsh, - Elvish, - Nushell, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum RecordingFormat { - Apng, - Gif, - Mp4, - Cast, -} - -#[derive(Default)] -#[napi(object)] -pub struct Timeouts { - pub text: Option, - pub idle: Option, - pub command: Option, - pub exit: Option, - pub ready: Option, -} - -#[derive(Default)] -#[napi(object)] -pub struct AutomaticRecordingOptions { - pub directory: Option, -} - -#[derive(Default)] -#[napi(object)] -pub struct TraceOptions { - pub mode: Option, - pub directory: Option, -} - -#[derive(Default)] -#[napi(object)] -pub struct FailureArtifactOptions { - pub directory: String, - pub mode: Option, - pub include_recording: Option, -} - -#[derive(Default)] -#[napi(object)] -pub struct OpenOptions { - pub backend: Option, - pub shell: Option, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} - -#[derive(Default)] -#[napi(object)] -pub struct RunOptions { - pub backend: Option, - pub program: String, - pub args: Option>, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} - +// Generated by cargo run -p tui-test-bindgen. Do not edit. +include!("type_helpers.rs"); #[napi(object, use_nullable = true)] pub struct OpenResult { #[napi(js_name = "shell_pid")] @@ -114,24 +8,24 @@ pub struct OpenResult { pub ready: bool, pub recording: String, } - #[derive(Default)] #[napi(object)] pub struct Cursor { pub x: u16, pub y: u16, + /// Whether the cursor is drawn (`DECTCEM`). pub visible: bool, + /// `block`, `underline`, or `bar` (`DECSCUSR`). pub shape: String, + /// The cursor color as `#rrggbb`, after any `OSC 12` a program sent. pub color: String, } - #[derive(Default)] #[napi(object)] pub struct Size { pub cols: u16, pub rows: u16, } - #[derive(Default)] #[napi(object)] pub struct EffectiveTimeouts { @@ -141,7 +35,6 @@ pub struct EffectiveTimeouts { pub exit: f64, pub ready: f64, } - #[derive(Default)] #[napi(object)] pub struct BellEvent { @@ -149,7 +42,6 @@ pub struct BellEvent { #[napi(js_name = "elapsed_ms")] pub elapsed_ms: f64, } - #[napi(object, use_nullable = true)] pub struct State { #[napi(js_name = "session_shell")] @@ -164,20 +56,42 @@ pub struct State { #[napi(js_name = "last_exit")] pub last_exit: Option, #[napi(js_name = "exit_signal")] + /// Signal reported when the process was terminated by one. pub exit_signal: Option, pub exited: Option, pub ready: bool, #[napi(js_name = "bell_count")] pub bell_count: f64, + /// Terminal modes the child has turned on, by name. + /// + /// A map rather than a list, so a reader can tell "off" from "this build + /// does not know that mode" and every key is always present. pub modes: HashMap, #[napi(js_name = "mouse_mode")] + /// Mouse tracking level: `none`, `click`, `drag`, or `motion`. + /// + /// Separate from `modes` because mouse tracking is not a set of + /// independent switches: `CSI ?1002 h` replaces `CSI ?1000 h` rather than + /// joining it, so reporting it as booleans would say two are on when the + /// terminal only honors the last. + /// + /// Independent of how the child asked for the reports to be encoded. + /// `CSI ?1000 h` on its own is `click`, whether or not `CSI ?1006 h` + /// followed it to ask for SGR coordinates. pub mouse_mode: String, + /// The terminal's colors, as `#rrggbb`: the three defaults and any + /// palette entry a program overrode. pub colors: TerminalColors, pub timeouts: EffectiveTimeouts, pub text: String, } - -/// The colors the terminal is painting with. +/// The colors the terminal paints with. +/// +/// Grouped because they are one concept — the colors a program chooses +/// rather than the ones a cell names — set by sibling sequences and reset by +/// `OSC 104` and `OSC 110/111/112`. A slot nothing has overridden reports the +/// color the session's profile gives it, so every field always has an +/// answer. #[derive(Default)] #[napi(object)] pub struct TerminalColors { @@ -187,27 +101,13 @@ pub struct TerminalColors { pub background: String, /// The cursor color (`OSC 12`). pub cursor: String, - /// Palette entries a program overrode (`OSC 4`), keyed by index. + /// Palette entries a program moved with `OSC 4`, keyed by index. /// - /// Only entries that differ from the profile are listed. + /// Only the entries that differ from the profile are listed: a program + /// that recolors slot 1 is interesting, and the 255 it left alone are + /// not. pub palette: HashMap, } - -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum UnderlineStyle { - None, - Single, - Double, - Curly, - Dotted, - Dashed, -} - -#[napi] -pub type Color = Either; - #[napi(object)] pub struct Cell { pub x: u16, @@ -229,24 +129,27 @@ pub struct Cell { pub underline_color: Color, /// The OSC 8 URI this cell links to, empty when it links nowhere. pub link: String, + #[napi(js_name = "link_id")] /// The link's `id=` parameter, empty when the sequence carried none. /// - /// Identifies a link across a wrap rather than describing where it points: - /// a program that wraps its own links tags each run with a shared `id=`. + /// Separate from `link` because it identifies a link across a wrap rather + /// than describing where the link points: a program that wraps its own + /// links tags each run with a shared `id=` so a terminal can treat them as + /// one. /// - /// Backend-dependent. Ghostty reports a link's URI and nothing else, so - /// this is always empty there and no locator matches on it. - #[napi(js_name = "link_id")] + /// Backend-dependent, unlike everything else on a cell. Ghostty's FFI + /// exposes a link's URI and nothing else, so this is always empty there + /// and "no `id=` was sent" cannot be told apart from "this backend cannot + /// see it". An assertion built on it will not mean the same thing on every + /// backend, which is why no locator matches on it. pub link_id: String, } - #[derive(Default)] #[napi(object)] pub struct TextPosition { pub row: u32, pub column: u16, } - #[derive(Default)] #[napi(object)] pub struct TextSpan { @@ -254,45 +157,80 @@ pub struct TextSpan { pub start: u16, pub end: u16, } - #[derive(Default)] #[napi(object)] pub struct TextMatch { pub text: String, pub start: TextPosition, + /// Exclusive end position. pub end: TextPosition, + /// Per-row column ranges with exclusive ends. pub spans: Vec, } - #[derive(Default)] #[napi(object)] -/// Private native-owned packed screen snapshot. -/// -/// `utf8` decodes to exactly `rows` newline-delimited logical rows. Trailing -/// spaces and blank rows are retained. UTF-8 byte offsets are not terminal cell -/// offsets when rows contain Unicode graphemes. pub struct PackedScreen { + /// Logical terminal dimensions for the newline-delimited UTF-8 snapshot. + /// Rows retain trailing spaces and blank lines; byte offsets are not cell + /// offsets because Unicode graphemes may occupy multiple bytes. #[napi(readonly)] - /// Logical column count. pub cols: u16, #[napi(readonly)] - /// Number of logical rows encoded in `utf8`. pub rows: u16, #[napi(readonly, ts_type = "Uint8Array")] - /// Detached native-owned UTF-8 bytes. Treat this private snapshot as immutable. pub utf8: Buffer, } - -#[derive(Default)] -#[napi(object)] -pub struct MouseClickOptions { - pub x: Option, - pub y: Option, - pub on_text: Option, - pub button: Option, - pub clicks: Option, -} - +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum Backend { + Alacritty, + Ghostty, + Rio, + Xtermjs, +} +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum Shell { + Bash, + Powershell, + Pwsh, + Cmd, + Fish, + Zsh, + Xonsh, + Elvish, + Nushell, +} +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum RecordingFormat { + Apng, + Gif, + Mp4, + Cast, +} +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum SnapshotResult { + Passed, + Written, + Updated, +} +#[derive(serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[napi(string_enum = "lowercase")] +pub enum UnderlineStyle { + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} #[derive(serde::Serialize)] #[serde(rename_all = "lowercase")] #[napi(string_enum = "lowercase")] @@ -304,7 +242,6 @@ pub enum LocatorNodeKind { Or, Filter, } - #[derive(serde::Serialize)] #[serde(rename_all = "lowercase")] #[napi(string_enum = "lowercase")] @@ -313,7 +250,66 @@ pub enum LocatorStageDirection { After, Before, } - +#[derive(Default)] +#[napi(object)] +pub struct Timeouts { + pub text: Option, + pub idle: Option, + pub command: Option, + pub exit: Option, + pub ready: Option, +} +#[derive(Default)] +#[napi(object)] +pub struct AutomaticRecordingOptions { + pub directory: Option, +} +#[derive(Default)] +#[napi(object)] +pub struct TraceOptions { + pub mode: Option, + pub directory: Option, +} +#[derive(Default)] +#[napi(object)] +pub struct FailureArtifactOptions { + pub directory: String, + pub mode: Option, + pub include_recording: Option, +} +#[derive(Default)] +#[napi(object)] +pub struct OpenOptions { + pub backend: Option, + pub shell: Option, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub restart: Option, + pub profile_scrollback: Option, + pub profile_colors: Option>, + pub timeouts: Option, + pub screen_history_limit: Option, +} +#[derive(Default)] +#[napi(object)] +pub struct RunOptions { + pub backend: Option, + pub program: String, + pub args: Option>, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub restart: Option, + pub profile_scrollback: Option, + pub profile_colors: Option>, + pub timeouts: Option, + pub screen_history_limit: Option, +} #[derive(Default, serde::Serialize)] #[napi(object, object_from_js = false)] pub struct LocatorStyle { @@ -329,82 +325,78 @@ pub struct LocatorStyle { pub strikethrough: Option, pub blink: Option, } - +strict_locator_input!(LocatorStyle { +foreground => "foreground", +background => "background", +bold => "bold", +dim => "dim", +italic => "italic", +underline_style => "underlineStyle", +underline_color => "underlineColor", +inverse => "inverse", +hidden => "hidden", +strikethrough => "strikethrough", +blink => "blink", +}); #[napi(object, object_from_js = false)] pub struct LocatorNode { pub kind: LocatorNodeKind, - pub direction: Option, pub text: Option, pub regex: Option, - pub full: Option, pub whitespace: Option, - pub occurrence: Option, - pub nth: Option, pub style: Option, pub link: Option, + pub full: Option, + pub direction: Option, pub within: Option, pub left: Option, pub right: Option, pub input: Option, pub has: Option, pub has_not: Option, + pub occurrence: Option, + pub nth: Option, } - +strict_locator_input!(LocatorNode { +kind => "kind", +text => "text", +regex => "regex", +whitespace => "whitespace", +style => "style", +link => "link", +full => "full", +direction => "direction", +within => "within", +left => "left", +right => "right", +input => "input", +has => "has", +has_not => "hasNot", +occurrence => "occurrence", +nth => "nth", +}); #[napi(object, object_from_js = false)] pub struct LocatorExpression { pub nodes: Vec, pub root: f64, } - -// Reject unknown fields before conversion so misspelled predicates cannot -// silently broaden a locator. -macro_rules! strict_locator_input { - ($name:ident { $($field:ident => $key:literal),* $(,)? }) => { - impl ValidateNapiValue for $name {} - impl FromNapiValue for $name { - unsafe fn from_napi_value(env: napi::sys::napi_env, value: napi::sys::napi_value) -> Result { - unsafe { Object::validate(env, value)?; } - let object = unsafe { Object::from_napi_value(env, value)? }; - for key in Object::keys(&object)? { - if ![$($key),*].contains(&key.as_str()) { - return Err(Error::new(Status::InvalidArg, format!("unknown locator field {key:?}"))); - } - } - Ok(Self { $($field: object.get_named_property($key)?),* }) - } - } - }; -} - -strict_locator_input!(LocatorStyle { - foreground => "foreground", background => "background", bold => "bold", - dim => "dim", italic => "italic", underline_style => "underlineStyle", - underline_color => "underlineColor", inverse => "inverse", hidden => "hidden", - strikethrough => "strikethrough", blink => "blink", -}); -strict_locator_input!(LocatorNode { - kind => "kind", direction => "direction", text => "text", regex => "regex", - full => "full", whitespace => "whitespace", occurrence => "occurrence", - nth => "nth", style => "style", link => "link", within => "within", - left => "left", right => "right", input => "input", has => "has", has_not => "hasNot", +strict_locator_input!(LocatorExpression { +nodes => "nodes", +root => "root", }); -strict_locator_input!(LocatorExpression { nodes => "nodes", root => "root" }); - #[derive(Default)] #[napi(object)] pub struct TitleOptions { pub regex: Option, - pub not: Option, pub timeout_ms: Option, + pub not: Option, } - #[derive(Default)] #[napi(object)] pub struct ClipboardWaitOptions { pub regex: Option, pub timeout_ms: Option, } - #[derive(Default)] #[napi(object)] pub struct SnapshotOptions { @@ -413,7 +405,6 @@ pub struct SnapshotOptions { pub include_title: Option, pub cwd: Option, } - #[derive(Default)] #[napi(object)] pub struct ScreenshotOptions { @@ -423,7 +414,6 @@ pub struct ScreenshotOptions { pub background: Option, pub transparent: Option, } - #[derive(Default)] #[napi(object)] pub struct RecordingOptions { @@ -436,12 +426,3 @@ pub struct RecordingOptions { pub background: Option, pub transparent: Option, } - -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum SnapshotResult { - Passed, - Written, - Updated, -} diff --git a/crates/tui-test-ffi/src/output.rs b/crates/tui-test-ffi/src/output.rs index 226eb061..a512918d 100644 --- a/crates/tui-test-ffi/src/output.rs +++ b/crates/tui-test-ffi/src/output.rs @@ -1,6 +1,8 @@ use crate::types::*; use tui_test::api::*; +include!("output_generated.rs"); + #[derive(Default)] struct Storage { bytes: Vec>, @@ -28,6 +30,37 @@ impl Storage { fn optional(&mut self, value: Option) -> TuiString { value.map(|s| self.string(s)).unwrap_or_default() } + fn modes( + &mut self, + values: std::collections::BTreeMap, + ) -> (*const TuiMode, usize) { + for (name, enabled) in values { + let name = self.string(name); + self.modes.push(TuiMode { name, enabled }); + } + (self.modes.as_ptr(), self.modes.len()) + } + fn palette( + &mut self, + values: std::collections::BTreeMap, + ) -> (*const TuiPair, usize) { + for (index, color) in values { + let key = self.string(index.to_string()); + let value = self.string(color); + self.pairs.push(TuiPair { key, value }); + } + (self.pairs.as_ptr(), self.pairs.len()) + } + fn spans(&mut self, values: Vec) -> (*const TuiSpan, usize) { + let spans = values + .into_iter() + .map(|v| text_span(self, v)) + .collect::>() + .into_boxed_slice(); + let result = (spans.as_ptr(), spans.len()); + self.spans.push(spans); + result + } } fn integer(value: Option) -> TuiOptionalI32 { TuiOptionalI32 { @@ -103,66 +136,8 @@ pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { 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::Open(v) => r.open = open_value(&mut s, v), + OperationResult::State(v) => r.state = state(&mut s, *v), OperationResult::Text(v) | OperationResult::Clipboard(v) | OperationResult::Recording(v) => r.text = s.string(v), @@ -171,15 +146,7 @@ pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { | 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::Cursor(v) => r.cursor = cursor(&mut s, v), OperationResult::Modes(v) => { for (name, enabled) in v { let name = s.string(name); @@ -193,12 +160,7 @@ pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { s.pairs.push(TuiPair { key, value }); } } - OperationResult::Size(v) => { - r.size = TuiSize { - cols: v.cols, - rows: v.rows, - } - } + OperationResult::Size(v) => r.size = size(&mut s, v), OperationResult::BellCount(v) => r.number = v, OperationResult::PackedScreen(v) => { r.size = TuiSize { @@ -209,25 +171,7 @@ pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { } 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), - }; + let cell = cell(&mut s, c); s.cells.push(cell); } r.cells = s.cells.as_ptr(); @@ -235,43 +179,14 @@ pub(crate) fn operation(value: OperationResult) -> *mut TuiResult { } 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); + let value = text_match(&mut s, m); 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(); + s.bells = v.into_iter().map(|v| bell_event(&mut s, v)).collect(); r.bells = s.bells.as_ptr(); r.bells_len = s.bells.len(); } diff --git a/crates/tui-test-ffi/src/output_generated.rs b/crates/tui-test-ffi/src/output_generated.rs new file mode 100644 index 00000000..d4f533c3 --- /dev/null +++ b/crates/tui-test-ffi/src/output_generated.rs @@ -0,0 +1,130 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +#[allow(unused_variables)] +fn open_value(s: &mut Storage, v: tui_test::api::OpenResult) -> TuiOpenResult { + 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), + } +} +#[allow(unused_variables)] +fn cursor(s: &mut Storage, v: tui_test::api::Cursor) -> TuiCursor { + TuiCursor { + x: v.x, + y: v.y, + visible: v.visible, + shape: s.string(v.shape), + color: s.string(v.color), + } +} +#[allow(unused_variables)] +fn size(s: &mut Storage, v: tui_test::api::Size) -> TuiSize { + TuiSize { + cols: v.cols, + rows: v.rows, + } +} +#[allow(unused_variables)] +fn effective_timeouts(s: &mut Storage, v: tui_test::api::EffectiveTimeouts) -> TuiTimeouts { + TuiTimeouts { + text: unsigned(v.text), + idle: unsigned(v.idle), + command: unsigned(v.command), + exit: unsigned(v.exit), + ready: unsigned(v.ready), + } +} +#[allow(unused_variables)] +fn bell_event(s: &mut Storage, v: tui_test::api::BellEvent) -> TuiBellEvent { + TuiBellEvent { + sequence: v.sequence, + elapsed_ms: v.elapsed_ms, + } +} +#[allow(unused_variables)] +fn state(s: &mut Storage, v: tui_test::api::State) -> TuiState { + let modes = s.modes(v.modes); + TuiState { + session_shell: s.optional(v.session_shell), + cols: v.cols, + rows: v.rows, + cursor: cursor(s, v.cursor), + 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: modes.0, + modes_len: modes.1, + mouse_mode: s.string(v.mouse_mode), + colors: terminal_colors(s, v.colors), + timeouts: effective_timeouts(s, v.timeouts), + text: s.string(v.text), + } +} +#[allow(unused_variables)] +fn terminal_colors(s: &mut Storage, v: tui_test::api::TerminalColors) -> TuiTerminalColors { + let palette = s.palette(v.palette); + TuiTerminalColors { + foreground: s.string(v.foreground), + background: s.string(v.background), + cursor: s.string(v.cursor), + palette: palette.0, + palette_len: palette.1, + } +} +#[allow(unused_variables)] +fn cell(s: &mut Storage, v: tui_test::api::Cell) -> TuiCell { + TuiCell { + x: v.x, + y: v.y, + character: s.string(v.r#char), + fg: color(v.fg), + bg: color(v.bg), + bold: v.bold, + dim: v.dim, + italic: v.italic, + inverse: v.inverse, + invisible: v.invisible, + strike: v.strike, + blink: v.blink, + underline: v.underline, + underline_style: s.string(v.underline_style), + underline_color: color(v.underline_color), + link: s.string(v.link), + link_id: s.string(v.link_id), + } +} +#[allow(unused_variables)] +fn text_position(s: &mut Storage, v: tui_test::api::TextPosition) -> TuiPosition { + TuiPosition { + row: v.row, + column: v.column, + } +} +#[allow(unused_variables)] +fn text_span(s: &mut Storage, v: tui_test::api::TextSpan) -> TuiSpan { + TuiSpan { + row: v.row, + start: v.start, + end: v.end, + } +} +#[allow(unused_variables)] +fn text_match(s: &mut Storage, v: tui_test::api::TextMatch) -> TuiMatch { + let spans = s.spans(v.spans); + TuiMatch { + text: s.string(v.text), + start: text_position(s, v.start), + end: text_position(s, v.end), + spans: spans.0, + spans_len: spans.1, + } +} diff --git a/crates/tui-test-ffi/src/session.rs b/crates/tui-test-ffi/src/session.rs index d5d28b12..90e9c94d 100644 --- a/crates/tui-test-ffi/src/session.rs +++ b/crates/tui-test-ffi/src/session.rs @@ -6,83 +6,21 @@ use tui_test::{ TraceMode, TraceOptions, }; -#[derive(Clone, Default, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct SessionInput { - pub name: String, - pub artifact_directory: Option, - pub artifact_mode: Option, - #[serde(default)] - pub artifact_include_recording: bool, - pub trace_mode: Option, - pub trace_directory: Option, - pub screen_history_limit: Option, -} +include!("session_generated.rs"); -impl SessionInput { - pub fn validate(self) -> Result<(String, ExecutionContext)> { - let artifact = match self.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: self.artifact_directory.unwrap_or_default().into(), - mode, - include_recording: self.artifact_include_recording, - }; - options.validate().map_err(TuiTestError::usage)?; - Some(options) - } - }; - let trace = match self.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: self - .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) = self.screen_history_limit { - retention.screen_history_limit = limit; - } - retention.validate().map_err(TuiTestError::usage)?; - Ok(( - self.name, - ExecutionContext { - artifact, - trace, - retention, - ..ExecutionContext::default() - }, +fn artifact_mode(mode: &str) -> Result { + serde_json::from_value(mode.into()).map_err(|_| { + TuiTestError::usage(format!( + "unknown failure artifact mode {mode:?}; expected all, html, text, or none" )) - } + }) +} +fn trace_mode(mode: &str) -> Result { + serde_json::from_value(mode.into()).map_err(|_| { + TuiTestError::usage(format!( + "unknown trace mode {mode:?}; expected off, on-failure, or on" + )) + }) } pub(crate) fn execute( diff --git a/crates/tui-test-ffi/src/session_generated.rs b/crates/tui-test-ffi/src/session_generated.rs new file mode 100644 index 00000000..8eed9723 --- /dev/null +++ b/crates/tui-test-ffi/src/session_generated.rs @@ -0,0 +1,61 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +#[derive(Clone, Default, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionInput { + pub name: String, + pub artifact_directory: Option, + pub artifact_mode: Option, + #[serde(default)] + pub artifact_include_recording: bool, + pub trace_mode: Option, + pub trace_directory: Option, + pub screen_history_limit: Option, +} +impl SessionInput { + pub fn validate(self) -> Result<(String, ExecutionContext)> { + let artifact = match self.artifact_mode { + None => None, + Some(mode) => { + let mode = artifact_mode(&mode)?; + let options = FailureArtifactOptions { + directory: self.artifact_directory.unwrap_or_default().into(), + mode, + include_recording: self.artifact_include_recording, + }; + options.validate().map_err(TuiTestError::usage)?; + Some(options) + } + }; + let trace = match self.trace_mode { + None => None, + Some(mode) => { + let mode = trace_mode(&mode)?; + let options = TraceOptions { + mode, + directory: self + .trace_directory + .map(Into::into) + .unwrap_or_else(|| TraceOptions::default().directory), + }; + options.validate().map_err(TuiTestError::usage)?; + Some(options) + } + }; + let defaults = DiagnosticRetentionOptions::default(); + let retention = DiagnosticRetentionOptions { + screen_history_limit: self + .screen_history_limit + .unwrap_or(defaults.screen_history_limit), + }; + retention.validate().map_err(TuiTestError::usage)?; + Ok(( + self.name, + ExecutionContext { + artifact, + trace, + retention, + ..ExecutionContext::default() + }, + )) + } +} diff --git a/crates/tui-test-ffi/src/type_helpers.rs b/crates/tui-test-ffi/src/type_helpers.rs new file mode 100644 index 00000000..04c38292 --- /dev/null +++ b/crates/tui-test-ffi/src/type_helpers.rs @@ -0,0 +1,90 @@ +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 TuiQuery { + pub json: TuiString, +} +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiMode { + pub name: TuiString, + pub enabled: bool, +} +/// 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, +} +/// 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/crates/tui-test-ffi/src/types.rs b/crates/tui-test-ffi/src/types.rs index c8885a72..8715395f 100644 --- a/crates/tui-test-ffi/src/types.rs +++ b/crates/tui-test-ffi/src/types.rs @@ -1,3 +1,4 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. use std::ffi::c_void; /// A borrowed UTF-8 byte string. NULL denotes absence; a non-NULL pointer @@ -41,64 +42,60 @@ pub struct TuiPair { } #[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, +pub struct TuiMode { + pub name: TuiString, + pub enabled: bool, } -/// button: 0 left, 1 middle, 2 right. +/// kind: 0 default, 1 indexed (index), 2 RGB (red/green/blue). #[repr(C)] #[derive(Clone, Copy, Default)] -pub struct TuiMouseOptions { - pub button: u32, - pub alt: bool, - pub ctrl: bool, - pub shift: bool, +pub struct TuiColor { + pub kind: u32, + pub index: u8, + pub red: u8, + pub green: u8, + pub blue: u8, +} +/// 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, } #[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, +pub struct TuiOpenResult { + pub shell_pid: TuiOptionalU64, + pub session: TuiString, + pub ready: bool, + pub recording: TuiString, } #[repr(C)] #[derive(Clone, Copy, Default)] @@ -117,11 +114,9 @@ pub struct TuiSize { } #[repr(C)] #[derive(Clone, Copy, Default)] -pub struct TuiOpenResult { - pub shell_pid: TuiOptionalU64, - pub session: TuiString, - pub ready: bool, - pub recording: TuiString, +pub struct TuiBellEvent { + pub sequence: u64, + pub elapsed_ms: u64, } #[repr(C)] #[derive(Clone, Copy, Default)] @@ -147,12 +142,6 @@ pub struct TuiState { } #[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, @@ -160,16 +149,6 @@ pub struct TuiTerminalColors { 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 { @@ -215,35 +194,57 @@ pub struct TuiMatch { } #[repr(C)] #[derive(Clone, Copy, Default)] -pub struct TuiBellEvent { - pub sequence: u64, - pub elapsed_ms: u64, +pub struct TuiTimeouts { + pub text: TuiOptionalU64, + pub idle: TuiOptionalU64, + pub command: TuiOptionalU64, + pub exit: TuiOptionalU64, + pub ready: TuiOptionalU64, } -/// 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, +#[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, +} +/// 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 TuiWaitOptions { + pub timeout_ms: TuiOptionalU64, + pub regex: bool, + pub not: 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, } diff --git a/tools/bindgen/src/boundaries.rs b/tools/bindgen/src/boundaries.rs new file mode 100644 index 00000000..6d72f53b --- /dev/null +++ b/tools/bindgen/src/boundaries.rs @@ -0,0 +1,1103 @@ +//! Boundary fields come from owned Rust models. Overrides describe transport differences only. +use crate::{emit, format_source, NOTICE}; +use anyhow::{bail, Context, Result}; +use quote::ToTokens; +use std::{collections::BTreeMap, fmt::Write, fs, path::Path}; +use syn::{Fields, Item}; + +#[derive(Clone)] +struct Field { + name: String, + ty: String, +} + +struct Models { + fields: BTreeMap>, + enums: BTreeMap>, + docs: BTreeMap, +} + +impl Models { + fn load(root: &Path) -> Result { + let mut models = Self { + fields: BTreeMap::new(), + enums: BTreeMap::new(), + docs: BTreeMap::new(), + }; + for module in [ + "api", + "locator_query", + "diagnostics", + "profile", + "shell/mod", + "terminal/backend", + "terminal/cell", + ] { + let source = fs::read_to_string(root.join(format!("crates/tui-test/src/{module}.rs")))?; + models.parse(&source)?; + } + Ok(models) + } + + fn parse(&mut self, source: &str) -> Result<()> { + let fields = |fields: &Fields| -> Result> { + fields + .iter() + .map(|f| { + Ok(Field { + name: f.ident.as_ref().context("named model field")?.to_string(), + ty: f.ty.to_token_stream().to_string().replace(' ', ""), + }) + }) + .collect() + }; + for item in syn::parse_file(source)?.items { + match item { + Item::Struct(item) if matches!(item.fields, Fields::Named(_)) => { + let docs = |attrs: &[syn::Attribute]| { + attrs + .iter() + .filter(|a| a.path().is_ident("doc")) + .filter_map(|a| match &a.meta { + syn::Meta::NameValue(meta) => match &meta.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) => Some( + text.value() + .split('\n') + .map(|line| format!("///{line}\n")) + .collect::(), + ), + _ => None, + }, + _ => None, + }) + .collect() + }; + self.docs.insert(item.ident.to_string(), docs(&item.attrs)); + for f in &item.fields { + self.docs.insert( + format!("{}.{}", item.ident, f.ident.as_ref().unwrap()), + docs(&f.attrs), + ); + } + self.fields + .insert(item.ident.to_string(), fields(&item.fields)?); + } + Item::Enum(item) => { + let name = item.ident.to_string(); + if item + .variants + .iter() + .all(|v| matches!(v.fields, Fields::Unit)) + { + self.enums.insert( + name.clone(), + item.variants.iter().map(|v| v.ident.to_string()).collect(), + ); + } + for v in item.variants { + if matches!(v.fields, Fields::Named(_)) { + self.fields + .insert(format!("{name}::{}", v.ident), fields(&v.fields)?); + } + } + } + _ => {} + } + } + Ok(()) + } + + fn fields(&self, name: &str) -> Result> { + self.fields + .get(name) + .cloned() + .with_context(|| format!("missing model {name}")) + } +} + +const OUTPUTS: &[&str] = &[ + "OpenResult", + "Cursor", + "Size", + "EffectiveTimeouts", + "BellEvent", + "State", + "TerminalColors", + "Cell", + "TextPosition", + "TextSpan", + "TextMatch", + "PackedScreen", +]; + +fn c_name(name: &str) -> String { + format!( + "Tui{}", + match name { + "EffectiveTimeouts" => "Timeouts", + "TextPosition" => "Position", + "TextSpan" => "Span", + "TextMatch" => "Match", + other => other, + } + ) +} + +fn ident(name: &str) -> &str { + if name == "char" { + "r#char" + } else { + name + } +} + +fn snake(name: &str) -> String { + if name == "OpenResult" { + return "open_value".into(); + } + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_uppercase() && i != 0 { + out.push('_'); + } + out.extend(c.to_lowercase()); + } + out +} + +fn option(ty: &str) -> Option<&str> { + ty.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) +} + +fn c_type(ty: &str) -> Result { + Ok(match ty { + "String" | "Option" | "PathBuf" | "Option" => "TuiString".into(), + "Option" | "Option" | "Option" => "TuiOptionalU64".into(), + "Option" => "TuiOptionalI32".into(), + "Option" => "TuiOptionalBool".into(), + "Option" => "TuiOptionalF64".into(), + "CellColor" => "TuiColor".into(), + "bool" | "u16" | "u32" | "u64" | "u8" | "f64" | "i32" => ty.into(), + t if OUTPUTS.contains(&t) || t == "Timeouts" => c_name(t), + _ => bail!("no C mapping for {ty}"), + }) +} + +fn js_type(ty: &str) -> Result { + if let Some(inner) = option(ty) { + return Ok(format!("Option<{}>", js_type(inner)?)); + } + if let Some(inner) = ty.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) { + return Ok(format!("Vec<{}>", js_type(inner)?)); + } + Ok(match ty { + "u64" | "usize" => "f64".into(), + "PathBuf" | "WhitespaceMode" | "TraceMode" | "FailureArtifactMode" => "String".into(), + "CellColor" => "Color".into(), + "TextStyle" => "LocatorStyle".into(), + "LocatorDirection" => "LocatorStageDirection".into(), + "BTreeMap" => "HashMap".into(), + "std::collections::BTreeMap" => "HashMap".into(), + "(String,String)" => ty.into(), + t if t.contains("::") => js_type(t.rsplit("::").next().unwrap())?, + t if matches!(t, "String" | "bool" | "f64" | "u8" | "u16" | "u32" | "i32") + || OUTPUTS.contains(&t) + || matches!( + t, + "Backend" + | "Shell" + | "RecordingFormat" + | "Timeouts" + | "LocatorNode" + | "LocatorNodeKind" + ) => + { + t.into() + } + _ => bail!("no N-API mapping for {ty}"), + }) +} + +fn js_output(ty: &str, value: &str) -> Result { + Ok(match ty { + "u64" | "usize" => format!("{value} as f64"), + "CellColor" => format!("color({value})"), + "BTreeMap" => format!("{value}.into_iter().collect()"), + "std::collections::BTreeMap" => { + format!("{value}.into_iter().map(|(index,color)| (index.to_string(),color)).collect()") + } + "Vec" => format!("{value}.into()"), + t if t.starts_with("Vec<") => { + let inner = &t[4..t.len() - 1]; + format!("{value}.into_iter().map({}).collect()", snake(inner)) + } + t if OUTPUTS.contains(&t) => format!("{}({value})", snake(t)), + _ => { + js_type(ty)?; + value.into() + } + }) +} + +fn c_output(ty: &str, value: &str) -> Result { + Ok(match ty { + "String" => format!("s.string({value})"), + "Option" => format!("s.optional({value})"), + "Option" => format!("integer({value})"), + "Option" => format!("TuiOptionalU64 {{ present: {value}.is_some(), value: u64::from({value}.unwrap_or_default()) }}"), + "CellColor" => format!("color({value})"), + t if OUTPUTS.contains(&t) => format!("{}(s, {value})", snake(t)), + _ => { c_type(ty)?; value.into() } + }) +} + +fn output_models( + models: &Models, + c: &mut String, + js: &mut String, + cc: &mut String, + jc: &mut String, +) -> Result<()> { + for &model in OUTPUTS { + let fields = models.fields(model)?; + let c_enabled = !matches!(model, "PackedScreen" | "EffectiveTimeouts"); + if c_enabled { + writeln!( + c, + "#[repr(C)] #[derive(Clone,Copy,Default)] pub struct {} {{", + c_name(model) + )?; + } + let nullable = matches!(model, "State" | "OpenResult"); + js.push_str( + models + .docs + .get(model) + .map(String::as_str) + .unwrap_or_default(), + ); + if !nullable && model != "Cell" { + js.push_str("#[derive(Default)]\n"); + } + writeln!( + js, + "#[napi(object{})] pub struct {model} {{", + if nullable { + ", use_nullable = true" + } else { + "" + } + )?; + if model != "PackedScreen" { + writeln!(cc, "#[allow(unused_variables)] fn {}(s: &mut Storage, v: tui_test::api::{model}) -> {} {{", snake(model), c_name(model))?; + } + writeln!( + jc, + "pub fn {}(value: api::{model}) -> {}{model}{} {{", + snake(model), + if model == "Cell" { "Result<" } else { "" }, + if model == "Cell" { ">" } else { "" } + )?; + writeln!(jc, "{}{model} {{", if model == "Cell" { "Ok(" } else { "" })?; + let mut init = String::new(); + for f in fields { + let field = ident(&f.name); + let cfield = if f.name == "char" { "character" } else { field }; + let value = format!("v.{field}"); + let array = match f.ty.as_str() { + "Vec" => Some(("TuiSpan", "spans")), + "BTreeMap" => Some(("TuiMode", "modes")), + "std::collections::BTreeMap" => Some(("TuiPair", "palette")), + _ => None, + }; + if c_enabled { + if let Some((ty, _)) = array { + writeln!(c, "pub {cfield}: *const {ty}, pub {cfield}_len: usize,")?; + } else { + writeln!(c, "pub {cfield}: {},", c_type(&f.ty)?)?; + } + } + if model != "PackedScreen" { + if let Some((_, helper)) = array { + writeln!(cc, "let {cfield} = s.{helper}({value});")?; + writeln!(init, "{cfield}: {cfield}.0, {cfield}_len: {cfield}.1,")?; + } else { + let expr = if model == "EffectiveTimeouts" { + format!("unsigned({value})") + } else { + c_output(&f.ty, &value)? + }; + writeln!(init, "{cfield}: {expr},")?; + } + } + if matches!(model, "OpenResult" | "State" | "Cell" | "BellEvent") + && f.name.contains('_') + { + writeln!(js, "#[napi(js_name = {:?})]", f.name)?; + } + js.push_str( + models + .docs + .get(&format!("{model}.{}", f.name)) + .map(String::as_str) + .unwrap_or_default(), + ); + if model == "PackedScreen" { + writeln!( + js, + "#[napi(readonly{})]", + if f.name == "utf8" { + ", ts_type = \"Uint8Array\"" + } else { + "" + } + )?; + } + let ty = if model == "Cell" && f.name == "underline_style" { + "UnderlineStyle".into() + } else if model == "PackedScreen" && f.name == "utf8" { + "Buffer".into() + } else { + js_type(&f.ty)? + }; + writeln!(js, "pub {field}: {ty},")?; + let expr = if model == "Cell" && f.name == "underline_style" { + "underline_style(&value.underline_style)?".into() + } else { + js_output(&f.ty, &format!("value.{field}"))? + }; + writeln!(jc, "{field}: {expr},")?; + } + if c_enabled { + c.push_str("}\n"); + } + if model != "PackedScreen" { + writeln!(cc, "{} {{ {init} }} }}", c_name(model))?; + } + writeln!(jc, "}}{} }}", if model == "Cell" { ")" } else { "" })?; + js.push_str("}\n"); + } + Ok(()) +} + +fn enum_models(models: &Models, js: &mut String, jc: &mut String) -> Result<()> { + for (model, target, path) in [ + ("Backend", "Backend", "terminal::backend"), + ("Shell", "Shell", "shell"), + ("RecordingFormat", "RecordingFormat", "api"), + ("SnapshotResult", "SnapshotResult", "api"), + ("UnderlineStyle", "UnderlineStyle", "terminal::cell"), + ("LocatorNodeKind", "LocatorNodeKind", "locator_query"), + ("LocatorDirection", "LocatorStageDirection", "api"), + ] { + let variants = models + .enums + .get(model) + .with_context(|| format!("missing enum {model}"))?; + writeln!(js, "#[derive(serde::Serialize)] #[serde(rename_all = \"lowercase\")] #[napi(string_enum = \"lowercase\")] pub enum {target} {{ {} }}", variants.join(","))?; + // UnderlineStyle's owned API field is a string, not the terminal enum. + if model == "UnderlineStyle" { + jc.push_str( + "fn underline_style(value: &str) -> Result { Ok(match value {\n", + ); + for variant in variants { + writeln!( + jc, + "{:?} => UnderlineStyle::{variant},", + variant.to_lowercase() + )?; + } + jc.push_str("value => return Err(internal(format!(\"terminal returned unknown underline style '{value}'\"))), }) }\n"); + } else { + writeln!(jc, "impl From<{target}> for tui_test::{path}::{model} {{ fn from(value: {target}) -> Self {{ match value {{")?; + for variant in variants { + writeln!(jc, "{target}::{variant} => Self::{variant},")?; + } + jc.push_str("} } }\n"); + if model == "SnapshotResult" { + writeln!(jc, "impl From for {target} {{ fn from(value: tui_test::{path}::{model}) -> Self {{ match value {{")?; + for variant in variants { + writeln!( + jc, + "tui_test::{path}::{model}::{variant} => Self::{variant}," + )?; + } + jc.push_str("} } }\n"); + } + } + } + Ok(()) +} + +/// Input defaults and flattening are boundary contracts, not new model definitions. +fn input_fields(models: &Models, model: &str, js: bool) -> Result> { + let mut fields = Vec::new(); + for mut f in models.fields(model)? { + match (model, f.name.as_str()) { + ("OpenOptions" | "RunOptions", "profile") => continue, + ("OpenOptions" | "RunOptions", "recording") if js => continue, + ("OpenOptions" | "RunOptions", "recording") => { + for mut f in models.fields("AutomaticRecording")? { + f.name = format!("recording_{}", f.name); + f.ty = "TuiString".into(); + fields.push(f); + } + continue; + } + ("OpenOptions" | "RunOptions", "timeouts") => { + for mut p in models.fields("Profile")? { + p.name = if js { + format!("profile_{}", p.name) + } else { + p.name + }; + p.ty = match p.ty.as_str() { + "usize" => { + if js { + "Option" + } else { + "TuiOptionalU64" + } + } + "Colors" => { + if js { + "Option>" + } else { + "*const TuiPair" + } + } + other => bail!("no profile mapping for {other}"), + } + .into(); + fields.push(p); + if !js && fields.last().unwrap().name == "colors" { + fields.push(Field { + name: "colors_len".into(), + ty: "usize".into(), + }); + } + } + } + ("AutomaticRecording", "mode") + | ("Operation::Snapshot", "name") + | ("Operation::WaitTitle", "text") => continue, + ("Operation::Screenshot" | "Operation::StartRecording", "background") => { + fields.push(Field { + name: f.name, + ty: if js { "Option" } else { "TuiString" }.into(), + }); + fields.push(Field { + name: "transparent".into(), + ty: if js { "Option" } else { "bool" }.into(), + }); + continue; + } + _ => {} + } + f.ty = if js { + match (model, f.name.as_str()) { + ("OpenOptions" | "RunOptions", "backend" | "shell") => format!( + "Option<{}>", + if f.name == "backend" { + "Backend" + } else { + "Shell" + } + ), + ("OpenOptions" | "RunOptions", "cols" | "rows") => "Option".into(), + ("OpenOptions" | "RunOptions", _) + if option(&f.ty).is_none() && f.name != "program" => + { + format!("Option<{}>", js_type(&f.ty)?) + } + ("TraceOptions", _) | ("FailureArtifactOptions", "mode" | "include_recording") => { + format!("Option<{}>", js_type(&f.ty)?) + } + ("Operation::StartRecording", "fps") => "Option".into(), + ("LocatorNode", "occurrence") => "Option".into(), + ("Operation::WaitTitle" | "Operation::Snapshot" | "Operation::Screenshot", _) + if f.ty == "bool" => + { + "Option".into() + } + _ => js_type(&f.ty)?, + } + } else { + match (model, f.name.as_str()) { + ("OpenOptions", "backend" | "shell") | ("Operation::StartRecording", "format") => { + "TuiString".into() + } + ("OpenOptions", "cols" | "rows") => "TuiOptionalU64".into(), + ("OpenOptions", "env") => "*const TuiPair".into(), + ("MouseOptions", "button") => "u32".into(), + _ => c_type(&f.ty)?, + } + }; + let env = !js && f.name == "env"; + let nth = js && model == "LocatorNode" && f.name == "occurrence"; + fields.push(f); + if env { + fields.push(Field { + name: "env_len".into(), + ty: "usize".into(), + }); + } + if nth { + fields.push(Field { + name: "nth".into(), + ty: "Option".into(), + }); + } + } + if js && matches!(model, "OpenOptions" | "RunOptions") { + for mut f in models.fields("DiagnosticRetentionOptions")? { + if f.ty != "u16" { + bail!("no retention number mapping for {}", f.ty); + } + f.ty = "Option".into(); + fields.push(f); + } + } + // C v3 field order is fixed. These fields differ from the owned operation order. + if !js && model == "Operation::WaitTitle" { + fields.sort_by_key(|f| match f.name.as_str() { + "timeout_ms" => 0, + "regex" => 1, + _ => 2, + }); + } + Ok(fields) +} + +fn input_models( + models: &Models, + c: &mut String, + js: &mut String, + jc: &mut String, + ci: &mut String, +) -> Result<()> { + for (model, target) in [ + ("Timeouts", "Timeouts"), + ("OpenOptions", "OpenOptions"), + ("MouseOptions", "MouseOptions"), + ("Operation::WaitTitle", "WaitOptions"), + ("Operation::StartRecording", "RecordingOptions"), + ] { + if model == "MouseOptions" { + c.push_str("/// button: 0 left, 1 middle, 2 right.\n"); + } + writeln!( + c, + "#[repr(C)] #[derive(Clone,Copy,Default)] pub struct Tui{target} {{" + )?; + for f in input_fields(models, model, false)? { + writeln!(c, "pub {}: {},", ident(&f.name), f.ty)?; + } + c.push_str("}\n"); + } + for (model, target) in [ + ("Timeouts", "Timeouts"), + ("AutomaticRecording", "AutomaticRecordingOptions"), + ("TraceOptions", "TraceOptions"), + ("FailureArtifactOptions", "FailureArtifactOptions"), + ("OpenOptions", "OpenOptions"), + ("RunOptions", "RunOptions"), + ("TextStyle", "LocatorStyle"), + ("LocatorNode", "LocatorNode"), + ("LocatorExpression", "LocatorExpression"), + ("Operation::WaitTitle", "TitleOptions"), + ("Operation::WaitTitle", "ClipboardWaitOptions"), + ("Operation::Snapshot", "SnapshotOptions"), + ("Operation::Screenshot", "ScreenshotOptions"), + ("Operation::StartRecording", "RecordingOptions"), + ] { + let strict = target.starts_with("Locator"); + if !matches!(target, "LocatorNode" | "LocatorExpression") { + writeln!( + js, + "#[derive(Default{})]", + if target == "LocatorStyle" { + ", serde::Serialize" + } else { + "" + } + )?; + } + writeln!( + js, + "#[napi(object{})] pub struct {target} {{", + if strict { + ", object_from_js = false" + } else { + "" + } + )?; + let mut fields = input_fields(models, model, true)?; + if target == "ClipboardWaitOptions" { + fields.retain(|f| f.name != "not"); + } + for f in &fields { + writeln!(js, "pub {}: {},", ident(&f.name), f.ty)?; + } + js.push_str("}\n"); + if strict { + writeln!(js, "strict_locator_input!({target} {{")?; + for f in fields { + writeln!(js, "{} => {:?},", ident(&f.name), js_key(&f.name))?; + } + js.push_str("});\n"); + } + } + // Both transports use the same source timeout fields, with different number validation. + jc.push_str("pub fn timeouts(value: Timeouts) -> Result { Ok(api::Timeouts {\n"); + ci.push_str("fn timeouts(value: TuiTimeouts) -> Timeouts { Timeouts {\n"); + for f in models.fields("Timeouts")? { + writeln!( + jc, + "{}: optional_integer(value.{}, \"timeouts.{}\", u64::MAX)?,", + f.name, f.name, f.name + )?; + writeln!(ci, "{}: value.{}.option(),", f.name, f.name)?; + } + jc.push_str("}) }\n"); + ci.push_str("} }\n"); + Ok(()) +} + +fn js_key(name: &str) -> String { + let mut words = name.split('_'); + let mut out = words.next().unwrap_or_default().to_owned(); + for word in words { + let mut chars = word.chars(); + if let Some(c) = chars.next() { + out.extend(c.to_uppercase()); + out.push_str(chars.as_str()); + } + } + out +} + +fn assignment(out: &mut String, name: &str, expr: &str) -> Result<()> { + if name == expr { + writeln!(out, "{name},")?; + } else { + writeln!(out, "{name}: {expr},")?; + } + Ok(()) +} + +fn session_input(models: &Models) -> Result { + let mut out = format!("// {NOTICE}\n#[derive(Clone, Default, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct SessionInput {{ pub name: String,\n"); + for (model, prefix) in [ + ("FailureArtifactOptions", "artifact_"), + ("TraceOptions", "trace_"), + ("DiagnosticRetentionOptions", ""), + ] { + for f in models.fields(model)? { + let ty = match f.ty.as_str() { + "bool" => { + out.push_str("#[serde(default)]\n"); + "bool" + } + "u16" => "Option", + "PathBuf" | "FailureArtifactMode" | "TraceMode" => "Option", + _ => bail!("no session input mapping for {}", f.ty), + }; + writeln!(out, "pub {prefix}{}: {ty},", f.name)?; + } + } + out.push_str( + "}\nimpl SessionInput { pub fn validate(self) -> Result<(String, ExecutionContext)> {\n", + ); + for (model, prefix) in [ + ("FailureArtifactOptions", "artifact"), + ("TraceOptions", "trace"), + ] { + writeln!(out, "let {prefix} = match self.{prefix}_mode {{ None => None, Some(mode) => {{ let mode = {prefix}_mode(&mode)?; let options = {model} {{")?; + for f in models.fields(model)? { + let expr = match f.ty.as_str() { + "FailureArtifactMode" | "TraceMode" => "mode".into(), + "PathBuf" if model == "FailureArtifactOptions" => { + format!("self.{prefix}_{}.unwrap_or_default().into()", f.name) + } + "PathBuf" => format!( + "self.{prefix}_{}.map(Into::into).unwrap_or_else(|| {model}::default().{})", + f.name, f.name + ), + _ => format!("self.{prefix}_{}", f.name), + }; + assignment(&mut out, &f.name, &expr)?; + } + out.push_str("}; options.validate().map_err(TuiTestError::usage)?; Some(options) } };\n"); + } + out.push_str("let defaults = DiagnosticRetentionOptions::default();\nlet retention = DiagnosticRetentionOptions {\n"); + for f in models.fields("DiagnosticRetentionOptions")? { + writeln!( + out, + "{}: self.{}.unwrap_or(defaults.{}),", + f.name, f.name, f.name + )?; + } + out.push_str("}; retention.validate().map_err(TuiTestError::usage)?;\nOk((self.name, ExecutionContext { artifact, trace, retention, ..ExecutionContext::default() })) } }\n"); + Ok(out) +} + +fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Result<()> { + jc.push_str("fn open_input(value: OpenOptions, profile: Profile, directory: Option) -> Result { Ok(api::OpenOptions {\n"); + ci.push_str("unsafe fn open_input(value: TuiOpenOptions, backend: tui_test::terminal::backend::Backend, shell: Option, profile: Profile, mode: AutomaticRecordingMode) -> Result { Ok(OpenOptions {\n"); + for f in models.fields("OpenOptions")? { + let name = &f.name; + let js = match name.as_str() { + "backend" => "value.backend.map(Into::into).unwrap_or_default()".into(), + "shell" => "value.shell.map(Into::into)".into(), + "profile" => "profile".into(), + "cols" => "u16_value(value.cols.unwrap_or(80.0), \"cols\")?".into(), + "rows" => "u16_value(value.rows.unwrap_or(30.0), \"rows\")?".into(), + "timeouts" => "timeouts(value.timeouts.unwrap_or_default())?".into(), + "recording" => "api::AutomaticRecording { mode: api::AutomaticRecordingMode::Disabled, directory: directory.map(Into::into) }".into(), + _ if option(&f.ty).is_some() => format!("value.{name}"), + _ => format!("value.{name}.unwrap_or_default()"), + }; + let c = match name.as_str() { + "backend" | "shell" | "profile" => name.clone(), + "cols" => "u16_option(value.cols, \"cols\")?.unwrap_or(80)".into(), + "rows" => "u16_option(value.rows, \"rows\")?.unwrap_or(30)".into(), + "env" => "unsafe { pairs(value.env, value.env_len)? }".into(), + "timeouts" => "timeouts(value.timeouts)".into(), + "recording" => "AutomaticRecording { mode, directory: unsafe { value.recording_directory.optional()? }.map(Into::into) }".into(), + _ if f.ty == "Option" => format!("unsafe {{ value.{name}.optional()? }}"), + _ if option(&f.ty).is_some() => format!("value.{name}.option()"), + _ => format!("value.{name}"), + }; + assignment(jc, name, &js)?; + assignment(ci, name, &c)?; + } + jc.push_str("}) }\n"); + ci.push_str("}) }\n"); + for (model, prefix, default_mode) in [ + ("FailureArtifactOptions", "artifact", "all"), + ("TraceOptions", "trace", "off"), + ] { + writeln!( + jc, + "pub fn apply_{prefix}(context: &mut crate::session::SessionInput, value: {model}) {{" + )?; + for f in models.fields(model)? { + let expr = match f.ty.as_str() { + "FailureArtifactMode" | "TraceMode" => format!( + "Some(value.{}.unwrap_or_else(|| {default_mode:?}.into()))", + f.name + ), + "PathBuf" if model == "FailureArtifactOptions" => format!("Some(value.{})", f.name), + "bool" => format!("value.{}.unwrap_or(false)", f.name), + _ => format!("value.{}", f.name), + }; + writeln!(jc, "context.{prefix}_{} = {expr};", f.name)?; + } + jc.push_str("}\n"); + } + for model in ["OpenOptions", "RunOptions"] { + writeln!(jc, "pub fn {}_context(context: &mut crate::session::SessionInput, value: &{model}) -> Result<()> {{", snake(model))?; + for f in models.fields("DiagnosticRetentionOptions")? { + writeln!( + jc, + "context.{} = optional_u16(value.{}, {:?})?;", + f.name, + f.name, + js_key(&f.name) + )?; + } + jc.push_str("Ok(()) }\n"); + } + jc.push_str("pub fn run_options(value: RunOptions, directory: Option) -> Result {\nlet open = open_options(OpenOptions {\n"); + let run = input_fields(models, "RunOptions", true)?; + for f in input_fields(models, "OpenOptions", true)? { + if run.iter().any(|r| r.name == f.name) { + writeln!(jc, "{}: value.{},", f.name, f.name)?; + } + } + jc.push_str("..Default::default() }, directory)?;\nif value.program.is_empty() { return Err(usage(\"program must not be empty\")); }\nOk(api::RunOptions {\n"); + for f in models.fields("RunOptions")? { + let expr = match f.name.as_str() { + "program" => "value.program".into(), + "args" => "value.args.unwrap_or_default()".into(), + _ => format!("open.{}", f.name), + }; + writeln!(jc, "{}: {expr},", f.name)?; + } + jc.push_str("}) }\n"); + + ci.push_str("fn mouse_input(value: TuiMouseOptions, button: MouseButton) -> MouseOptions { MouseOptions {\n"); + for f in models.fields("MouseOptions")? { + assignment( + ci, + &f.name, + &if f.name == "button" { + "button".into() + } else { + format!("value.{}", f.name) + }, + )?; + } + ci.push_str("} }\n"); + ci.push_str("unsafe fn recording_input(value: TuiRecordingOptions, format: Option, fps: Option) -> Result { Ok(Operation::StartRecording {\n"); + for f in models.fields("Operation::StartRecording")? { + let expr = match f.name.as_str() { + "format" | "fps" => f.name.clone(), + "background" => { + "unsafe { capture_background(value.background, value.transparent)? }".into() + } + _ if f.ty == "String" => format!("unsafe {{ value.{}.required()? }}", f.name), + _ => format!("value.{}.option()", f.name), + }; + assignment(ci, &f.name, &expr)?; + } + ci.push_str("}) }\n"); + for (variant, dto, args) in [ + ("Snapshot", "SnapshotOptions", ", name: String"), + ("Screenshot", "ScreenshotOptions", ""), + ("StartRecording", "RecordingOptions", ""), + ("WaitTitle", "TitleOptions", ", text: String"), + ("ExpectTitle", "TitleOptions", ", text: String"), + ] { + writeln!(jc, "pub fn {}_operation(value: {dto}{args}) -> Result {{ Ok(api::Operation::{variant} {{", snake(variant))?; + for f in models.fields(&format!("Operation::{variant}"))? { + let expr = match f.name.as_str() { + "name" | "text" => f.name.clone(), + "background" => "crate::session::capture_background(value.background, value.transparent.unwrap_or(false)).map_err(native_error)?".into(), + "format" => "value.format.map(Into::into)".into(), + "fps" => "value.fps.map(|fps| u8_value(fps, \"fps\")).transpose()?".into(), + "timeout_ms" => "optional_integer(value.timeout_ms, \"timeoutMs\", u64::MAX)?".into(), + _ if f.ty == "bool" => format!("value.{}.unwrap_or(false)", f.name), + _ => format!("value.{}", f.name), + }; + assignment(jc, &f.name, &expr)?; + } + jc.push_str("}) }\n"); + } + jc.push_str("fn locator_style(value: LocatorStyle) -> api::TextStyle { api::TextStyle {\n"); + for f in models.fields("TextStyle")? { + writeln!(jc, "{}: value.{},", f.name, f.name)?; + } + jc.push_str("} }\nfn locator_node(value: LocatorNode, index: usize) -> Result { Ok(tui_test::locator_query::LocatorNode {\n"); + for f in models.fields("LocatorNode")? { + let expr = match f.ty.as_str() { + "LocatorNodeKind" => format!("value.{}.into()", f.name), + "Option" => format!("value.{}.map(Into::into)", f.name), + "Option" => format!("value.{}.map(|s| serde_json::from_value(serde_json::Value::String(s)).map_err(|error| usage(format!(\"invalid locator expression: {{error}}\")))).transpose()?", f.name), + "Option" => format!("value.{}.map(locator_style)", f.name), + "Option" => format!("optional_integer(value.{}, \"locator reference\", 255)?.map(|v| v as usize)", f.name), + "MatchOccurrence" => "occurrence(value.occurrence, value.nth, index)?".into(), + _ => format!("value.{}", f.name), + }; + writeln!(jc, "{}: {expr},", f.name)?; + } + jc.push_str("}) }\npub fn query(value: LocatorExpression) -> Result { tui_test::locator_query::LocatorExpression {\n"); + for f in models.fields("LocatorExpression")? { + let expr = match f.ty.as_str() { + "Vec" => format!("value.{}.into_iter().enumerate().map(|(i,v)| locator_node(v,i)).collect::>()?", f.name), + "usize" => format!("integer(value.{}, \"locator root\", 255)? as usize", f.name), + _ => bail!("no locator expression conversion for {}", f.ty), + }; + writeln!(jc, "{}: {expr},", f.name)?; + } + jc.push_str("}.into_query().map_err(native_error) }\n"); + Ok(()) +} + +pub fn generate(root: &Path, check: bool) -> Result<()> { + let models = Models::load(root)?; + let notice = format!("// {NOTICE}\n"); + let mut c = notice.clone(); + c.push_str(&fs::read_to_string( + root.join("crates/tui-test-ffi/src/type_helpers.rs"), + )?); + let mut js = format!("{notice}include!(\"type_helpers.rs\");\n"); + let mut cc = notice.clone(); + let mut jc = notice.clone(); + let mut ci = notice; + output_models(&models, &mut c, &mut js, &mut cc, &mut jc)?; + enum_models(&models, &mut js, &mut jc)?; + input_models(&models, &mut c, &mut js, &mut jc, &mut ci)?; + input_conversions(&models, &mut jc, &mut ci)?; + for (path, content) in [ + ("types.rs", c), + ("napi/types.rs", js), + ("output_generated.rs", cc), + ("napi/convert_generated.rs", jc), + ("input_generated.rs", ci), + ("session_generated.rs", session_input(&models)?), + ] { + emit( + root, + &format!("crates/tui-test-ffi/src/{path}"), + &format_source("rustfmt", &["--edition", "2021"], &content)?, + check, + )?; + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + fn root() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) + } + + pub(crate) fn directory() -> tempfile::TempDir { + let parent = root().join("target/bindgen-tests"); + fs::create_dir_all(&parent).unwrap(); + tempfile::tempdir_in(parent).unwrap() + } + + #[test] + fn owned_field_propagates_to_both_declarations_and_conversions() { + let mut models = Models::load(root()).unwrap(); + let source = fs::read_to_string(root().join("crates/tui-test/src/api.rs")).unwrap(); + models + .parse(&source.replace( + "pub visible: bool,", + "pub visible: bool, pub generation_probe: bool,", + )) + .unwrap(); + let (mut c, mut js, mut cc, mut jc) = Default::default(); + output_models(&models, &mut c, &mut js, &mut cc, &mut jc).unwrap(); + assert!(c.contains("pub generation_probe: bool,")); + assert!(js.contains("pub generation_probe: bool,")); + assert!(cc.contains("generation_probe: v.generation_probe,")); + assert!(jc.contains("generation_probe: value.generation_probe,")); + } + + #[test] + fn input_field_propagates_to_both_number_contracts() { + let mut models = Models::load(root()).unwrap(); + models + .parse("pub struct Timeouts { pub new_wait: Option }") + .unwrap(); + let (mut c, mut js, mut jc, mut ci) = Default::default(); + input_models(&models, &mut c, &mut js, &mut jc, &mut ci).unwrap(); + assert!(c.contains("pub new_wait: TuiOptionalU64,")); + assert!(js.contains("pub new_wait: Option,")); + assert!(ci.contains("new_wait: value.new_wait.option(),")); + assert!(jc.contains("optional_integer(value.new_wait, \"timeouts.new_wait\", u64::MAX)?")); + } + + #[test] + fn transport_overrides_preserve_layout_and_javascript_contracts() { + let models = Models::load(root()).unwrap(); + let (mut c, mut js, mut cc, mut jc) = Default::default(); + output_models(&models, &mut c, &mut js, &mut cc, &mut jc).unwrap(); + assert!(c.contains("pub character: TuiString,")); + assert!(c.contains("pub spans: *const TuiSpan, pub spans_len: usize,")); + assert!(js.contains("#[napi(object, use_nullable = true)] pub struct State")); + assert!(js.contains("#[napi(js_name = \"bell_count\")]")); + assert!(js.contains("pub bell_count: f64,")); + assert!(js.contains("pub r#char: String,")); + assert!(js.contains("/// Whether the cursor is drawn (`DECTCEM`).")); + assert!(js.contains("pub fg: Color,")); + assert!(js.contains("#[napi(readonly, ts_type = \"Uint8Array\")]")); + assert!(cc.contains("let spans = s.spans(v.spans);")); + assert!(jc.contains("underline_style(&value.underline_style)?")); + let open = input_fields(&models, "OpenOptions", false).unwrap(); + let position = |name| open.iter().position(|f| f.name == name).unwrap(); + assert_eq!(position("env_len"), position("env") + 1); + assert_eq!(position("scrollback"), position("restart") + 1); + assert_eq!(position("timeouts"), position("colors_len") + 1); + } + + #[test] + fn strict_locator_keys_follow_owned_fields() { + let mut models = Models::load(root()).unwrap(); + models + .parse("pub struct TextStyle { pub new_predicate: Option }") + .unwrap(); + let (mut c, mut js, mut jc, mut ci) = Default::default(); + input_models(&models, &mut c, &mut js, &mut jc, &mut ci).unwrap(); + assert!(js.contains("pub new_predicate: Option,")); + assert!(js.contains("new_predicate => \"newPredicate\",")); + input_conversions(&models, &mut jc, &mut ci).unwrap(); + assert!(jc.contains("new_predicate: value.new_predicate,")); + } + + #[test] + fn session_context_fields_follow_diagnostics() { + let mut models = Models::load(root()).unwrap(); + models + .parse("pub struct DiagnosticRetentionOptions { pub new_limit: u16 }") + .unwrap(); + let session = session_input(&models).unwrap(); + assert!(session.contains("pub new_limit: Option,")); + assert!(session.contains("new_limit: self.new_limit.unwrap_or(defaults.new_limit),")); + let fields = input_fields(&models, "OpenOptions", true).unwrap(); + assert!(fields + .iter() + .any(|f| f.name == "new_limit" && f.ty == "Option")); + let (mut jc, mut ci) = Default::default(); + input_conversions(&models, &mut jc, &mut ci).unwrap(); + assert!(jc.contains("context.new_limit = optional_u16(value.new_limit, \"newLimit\")?;")); + } + + #[test] + fn enum_variants_follow_owned_source() { + let mut models = Models::load(root()).unwrap(); + models + .parse("pub enum RecordingFormat { Cast, Future }") + .unwrap(); + let (mut js, mut jc) = Default::default(); + enum_models(&models, &mut js, &mut jc).unwrap(); + assert!(js.contains("pub enum RecordingFormat { Cast,Future }")); + assert!(jc.contains("RecordingFormat::Future => Self::Future,")); + } + + #[test] + fn unknown_model_types_fail_instead_of_disappearing() { + let mut models = Models::load(root()).unwrap(); + models + .parse("pub struct Cursor { pub unknown: Unsupported }") + .unwrap(); + let (mut c, mut js, mut cc, mut jc) = Default::default(); + assert!(output_models(&models, &mut c, &mut js, &mut cc, &mut jc).is_err()); + } + + #[test] + fn regeneration_needs_no_generated_rust_and_check_detects_each_output() { + let directory = directory(); + let target = directory.path(); + for path in [ + "crates/tui-test/src/api.rs", + "crates/tui-test/src/locator_query.rs", + "crates/tui-test/src/diagnostics.rs", + "crates/tui-test/src/profile.rs", + "crates/tui-test/src/shell/mod.rs", + "crates/tui-test/src/terminal/backend.rs", + "crates/tui-test/src/terminal/cell.rs", + "crates/tui-test-ffi/src/type_helpers.rs", + ] { + fs::create_dir_all(target.join(path).parent().unwrap()).unwrap(); + fs::copy(root().join(path), target.join(path)).unwrap(); + } + generate(target, false).unwrap(); + generate(target, true).unwrap(); + for path in [ + "types.rs", + "napi/types.rs", + "output_generated.rs", + "napi/convert_generated.rs", + "input_generated.rs", + "session_generated.rs", + ] { + let output = target.join("crates/tui-test-ffi/src").join(path); + let original = fs::read_to_string(&output).unwrap(); + fs::write(&output, "// stale\n").unwrap(); + let error = generate(target, true).unwrap_err().to_string(); + assert!(error.contains(path), "{error}"); + assert!(error.contains("stale"), "{error}"); + fs::write(output, original).unwrap(); + } + } +} diff --git a/tools/bindgen/src/main.rs b/tools/bindgen/src/main.rs index 292a95a4..09c87c1c 100644 --- a/tools/bindgen/src/main.rs +++ b/tools/bindgen/src/main.rs @@ -9,6 +9,8 @@ use syn::{FnArg, Item, ItemFn, ItemStruct, ReturnType, Type}; const NOTICE: &str = "Generated by cargo run -p tui-test-bindgen. Do not edit."; +mod boundaries; + fn main() -> Result<()> { let args: Vec<_> = std::env::args().skip(1).collect(); let check = match args.as_slice() { @@ -17,6 +19,7 @@ fn main() -> Result<()> { _ => bail!("usage: cargo run -p tui-test-bindgen -- [--check]"), }; let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + boundaries::generate(&root, check)?; let source = root.join("crates/tui-test-ffi"); let types_source = fs::read_to_string(source.join("src/types.rs"))?; let types = syn::parse_file(&types_source)?; @@ -402,7 +405,7 @@ mod tests { #[test] fn check_accepts_windows_checkout_line_endings() { - let directory = tempfile::tempdir().unwrap(); + let directory = boundaries::tests::directory(); fs::write( directory.path().join("generated.txt"), "first\r\nsecond\r\n", From e2f87c264519d79f0611ba414a327d8fb4719700 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 14:31:43 -0400 Subject: [PATCH 05/15] fix: pass Go screenshot options through a pointer Signed-off-by: Ayman Bagabas --- bindings/go/CONTRIBUTING.md | 3 +++ bindings/go/integration_test.go | 20 +++++++++++++++++++ bindings/go/internal/native/abi.go | 8 ++++++++ bindings/go/internal/native/functions.go | 4 ++-- bindings/go/internal/native/session.go | 6 +++++- bindings/python/src/tui_test/_abi.py | 15 ++++++++++++++ crates/tui-test-ffi/include/tui_test.h | 18 +++++++++++++++++ crates/tui-test-ffi/src/lib.rs | 25 ++++++++++++++++++++++++ crates/tui-test-ffi/src/tests.rs | 9 +++++++++ crates/tui-test-ffi/src/types.rs | 9 +++++++++ tools/bindgen/src/boundaries.rs | 1 + tools/bindgen/src/main.rs | 23 ++++++++++++++++------ 12 files changed, 132 insertions(+), 9 deletions(-) diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index 4eaeb3ba..f5395bee 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -13,6 +13,9 @@ Go and Python use the same library. JavaScript uses the crate's optional N-API b Source builds select the Go/Python library path through `TUI_TEST_NATIVE_LIBRARY`. The library stays loaded for the process lifetime. +Go uses pointer entrypoints for open, run, and screenshot options. +The screenshot pointer avoids a PureGo aggregate-argument failure on Linux AMD64. + 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. diff --git a/bindings/go/integration_test.go b/bindings/go/integration_test.go index 4f8671c0..a1d8cac3 100644 --- a/bindings/go/integration_test.go +++ b/bindings/go/integration_test.go @@ -2,9 +2,11 @@ package tuitest_test import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" + "image/png" "os" "path/filepath" "slices" @@ -252,6 +254,24 @@ func TestScreenshotsAndSnapshots(t *testing.T) { if _, statErr := os.Stat(transparent); statErr != nil { t.Fatal(statErr) } + data, err := os.ReadFile(transparent) + requireNoError(t, err) + baseline, err := png.DecodeConfig(bytes.NewReader(data)) + requireNoError(t, err) + for index, options := range []tuitest.ScreenshotOptions{ + {Zoom: tuitest.Ptr(2.0), Transparent: true}, + {Zoom: tuitest.Ptr(2.0), Background: "#123456"}, + } { + path, err := terminal.Screenshot(filepath.Join(directory, fmt.Sprintf("scaled-%d.png", index)), options) + requireNoError(t, err) + data, err := os.ReadFile(path) + requireNoError(t, err) + scaled, err := png.DecodeConfig(bytes.NewReader(data)) + requireNoError(t, err) + if scaled.Width != 2*baseline.Width || scaled.Height != 2*baseline.Height { + t.Fatalf("scaled screenshot=%dx%d, baseline=%dx%d", scaled.Width, scaled.Height, baseline.Width, baseline.Height) + } + } _, 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}) diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go index a93b4220..1b9f1718 100644 --- a/bindings/go/internal/native/abi.go +++ b/bindings/go/internal/native/abi.go @@ -222,3 +222,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/functions.go b/bindings/go/internal/native/functions.go index 067bb3ba..326aa2a9 100644 --- a/bindings/go/internal/native/functions.go +++ b/bindings/go/internal/native/functions.go @@ -55,7 +55,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 } @@ -114,7 +114,7 @@ func nativeRegistrations(table *nativeFunctionTable) []nativeRegistration { {symbol: "tui_expect_output", target: &table.ExpectOutput}, {symbol: "tui_expect_bell_count", target: &table.ExpectBellCount}, {symbol: "tui_snapshot", target: &table.Snapshot}, - {symbol: "tui_screenshot", target: &table.Screenshot}, + {symbol: "tui_screenshot_ptr", target: &table.Screenshot}, {symbol: "tui_start_recording", target: &table.StartRecording}, } } diff --git a/bindings/go/internal/native/session.go b/bindings/go/internal/native/session.go index ed273fdd..896e0a23 100644 --- a/bindings/go/internal/native/session.go +++ b/bindings/go/internal/native/session.go @@ -423,7 +423,11 @@ 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) + return nativeFunctions.Screenshot(session, &abiScreenshotOptions{ + full: options.Full, path: memory.nonemptyText(path), + zoom: nativeFloat(options.Zoom), background: memory.nonemptyText(options.Background), + transparent: options.Transparent, + }) }) } diff --git a/bindings/python/src/tui_test/_abi.py b/bindings/python/src/tui_test/_abi.py index c2cc58a0..ccd4feb5 100644 --- a/bindings/python/src/tui_test/_abi.py +++ b/bindings/python/src/tui_test/_abi.py @@ -104,6 +104,10 @@ class TuiRecordingOptions(ctypes.Structure): pass +class TuiScreenshotOptions(ctypes.Structure): + pass + + TuiString._fields_ = [ ("data", ctypes.POINTER(ctypes.c_uint8)), ("len", ctypes.c_size_t), @@ -296,6 +300,15 @@ class TuiRecordingOptions(ctypes.Structure): ] +TuiScreenshotOptions._fields_ = [ + ("full", ctypes.c_bool), + ("path", TuiString), + ("zoom", TuiOptionalF64), + ("background", TuiString), + ("transparent", ctypes.c_bool), +] + + TuiState._fields_ = [ ("session_shell", TuiString), ("cols", ctypes.c_uint16), @@ -456,5 +469,7 @@ def configure(library): library.tui_snapshot.restype = ctypes.POINTER(TuiResult) library.tui_screenshot.argtypes = [TuiString, ctypes.c_bool, TuiString, TuiOptionalF64, TuiString, ctypes.c_bool] library.tui_screenshot.restype = ctypes.POINTER(TuiResult) + library.tui_screenshot_ptr.argtypes = [TuiString, ctypes.POINTER(TuiScreenshotOptions)] + library.tui_screenshot_ptr.restype = ctypes.POINTER(TuiResult) library.tui_start_recording.argtypes = [TuiString, TuiRecordingOptions] library.tui_start_recording.restype = ctypes.POINTER(TuiResult) diff --git a/crates/tui-test-ffi/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h index bfa7bd77..aebbd3f5 100644 --- a/crates/tui-test-ffi/include/tui_test.h +++ b/crates/tui-test-ffi/include/tui_test.h @@ -229,6 +229,14 @@ typedef struct TuiOptionalF64 { 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; @@ -687,6 +695,16 @@ struct TuiResult *tui_screenshot(struct TuiString session, struct TuiString background, bool transparent); +/** + * Pointer form for foreign callers with limited aggregate argument support. + * + * # Safety + * options must point to a valid TuiScreenshotOptions for this call. + * Its borrowed buffers follow the same contract as tui_screenshot. + */ +struct TuiResult *tui_screenshot_ptr(struct TuiString session, + const struct TuiScreenshotOptions *options); + /** * # Safety * Borrowed input buffers must be valid and readable for their stated lengths diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index 789d9ee6..e5076528 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -867,6 +867,31 @@ pub unsafe extern "C" fn tui_screenshot( } } #[no_mangle] +/// Pointer form for foreign callers with limited aggregate argument support. +/// +/// # Safety +/// options must point to a valid TuiScreenshotOptions for this call. +/// Its borrowed buffers follow the same contract as tui_screenshot. +pub unsafe extern "C" fn tui_screenshot_ptr( + session: TuiString, + options: *const TuiScreenshotOptions, +) -> *mut TuiResult { + if options.is_null() { + return boundary(|| Err(TuiTestError::usage("screenshot options pointer is null"))); + } + let options = unsafe { *options }; + unsafe { + tui_screenshot( + session, + options.full, + options.path, + options.zoom, + options.background, + options.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. diff --git a/crates/tui-test-ffi/src/tests.rs b/crates/tui-test-ffi/src/tests.rs index b890d0f4..dec6b848 100644 --- a/crates/tui-test-ffi/src/tests.rs +++ b/crates/tui-test-ffi/src/tests.rs @@ -37,6 +37,15 @@ fn pointer_options_reject_null_and_preserve_validation() { 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), + tui_screenshot_ptr(session, std::ptr::null()), + tui_screenshot_ptr( + session, + &TuiScreenshotOptions { + background: text("#000000"), + transparent: true, + ..Default::default() + }, + ), ] }; for result in results { diff --git a/crates/tui-test-ffi/src/types.rs b/crates/tui-test-ffi/src/types.rs index 8715395f..fae2b7b8 100644 --- a/crates/tui-test-ffi/src/types.rs +++ b/crates/tui-test-ffi/src/types.rs @@ -248,3 +248,12 @@ pub struct TuiRecordingOptions { pub background: TuiString, pub transparent: bool, } +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TuiScreenshotOptions { + pub full: bool, + pub path: TuiString, + pub zoom: TuiOptionalF64, + pub background: TuiString, + pub transparent: bool, +} diff --git a/tools/bindgen/src/boundaries.rs b/tools/bindgen/src/boundaries.rs index 6d72f53b..fd9ec55d 100644 --- a/tools/bindgen/src/boundaries.rs +++ b/tools/bindgen/src/boundaries.rs @@ -585,6 +585,7 @@ fn input_models( ("MouseOptions", "MouseOptions"), ("Operation::WaitTitle", "WaitOptions"), ("Operation::StartRecording", "RecordingOptions"), + ("Operation::Screenshot", "ScreenshotOptions"), ] { if model == "MouseOptions" { c.push_str("/// button: 0 left, 1 middle, 2 right.\n"); diff --git a/tools/bindgen/src/main.rs b/tools/bindgen/src/main.rs index 09c87c1c..1bd287e7 100644 --- a/tools/bindgen/src/main.rs +++ b/tools/bindgen/src/main.rs @@ -252,15 +252,11 @@ fn go_function_name(function: &ItemFn) -> String { } fn go_functions(functions: &[&ItemFn]) -> Result { + let names: BTreeSet<_> = functions.iter().map(|f| f.sig.ident.to_string()).collect(); let functions: Vec<_> = functions .iter() .copied() - .filter(|function| { - !matches!( - function.sig.ident.to_string().as_str(), - "tui_open" | "tui_run" - ) - }) + .filter(|function| !names.contains(&format!("{}_ptr", function.sig.ident))) .collect(); let mut out = format!("// {NOTICE}\npackage native\n\ntype nativeFunctionTable struct {{\n"); for function in &functions { @@ -375,6 +371,21 @@ fn python(structs: &[&ItemStruct], functions: &[&ItemFn], version: &str) -> Resu mod tests { use super::*; + #[test] + fn go_prefers_pointer_entrypoints() { + let value = syn::parse_str::( + "pub unsafe extern \"C\" fn tui_screenshot(options: TuiScreenshotOptions) {}", + ) + .unwrap(); + let pointer = syn::parse_str::( + "pub unsafe extern \"C\" fn tui_screenshot_ptr(options: *const TuiScreenshotOptions) {}", + ).unwrap(); + let output = go_functions(&[&value, &pointer]).unwrap(); + assert!(output.contains("Screenshot func(*abiScreenshotOptions)")); + assert!(output.contains("symbol: \"tui_screenshot_ptr\"")); + assert!(!output.contains("symbol: \"tui_screenshot\"")); + } + #[test] fn python_defines_embedded_structures_before_use() { let source = syn::parse_file("pub struct TuiOuter { pub value: TuiInner, pub next: *const TuiOuter } pub struct TuiInner { pub value: u64 }").unwrap(); From f532a3f37cc8433380b602aae941966be28e4e13 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Oct 2026 14:38:37 -0400 Subject: [PATCH 06/15] test: keep Go raster fixtures independent of host fonts Signed-off-by: Ayman Bagabas --- bindings/go/integration_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bindings/go/integration_test.go b/bindings/go/integration_test.go index a1d8cac3..72de1fc0 100644 --- a/bindings/go/integration_test.go +++ b/bindings/go/integration_test.go @@ -29,6 +29,8 @@ func TestTerminalProcess(t *testing.T) { os.Exit(0) case "events": fmt.Print("\x1b]2;Go title\x07\x1b]52;c;Z28tY2xpcGJvYXJk\x07\x07\r\nevents-done\r\n") + case "raster": + fmt.Print("\x1b[2J\x1b[Hgo-raster-ready\r\n") default: fmt.Printf("received:%s\r\n", scanner.Text()) } @@ -249,6 +251,9 @@ func TestScreenshotsAndSnapshots(t *testing.T) { if _, statErr := os.Stat(path); statErr != nil { t.Fatal(statErr) } + // Raster tests use bundled font coverage rather than host CJK fonts. + requireNoError(t, terminal.Submit(tuitest.Ptr("raster"))) + requireNoError(t, terminal.GetByText("go-raster-ready", tuitest.TextSelectorOptions{}).Expect(tuitest.LocatorExpectOptions{})) transparent, err := terminal.Screenshot(filepath.Join(directory, "transparent.png"), tuitest.ScreenshotOptions{Transparent: true}) requireNoError(t, err) if _, statErr := os.Stat(transparent); statErr != nil { From 1ed511273814e5865e24f2f962b9934a012aa5d3 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 09:47:31 -0400 Subject: [PATCH 07/15] refactor: generate all N-API exports from Rust operations Signed-off-by: Ayman Bagabas --- crates/tui-test-ffi/README.md | 33 +- .../src/napi/bindings_generated.rs | 773 +++++++++++++++++ crates/tui-test-ffi/src/napi/convert.rs | 43 - .../src/napi/convert_generated.rs | 93 ++ crates/tui-test-ffi/src/napi/mod.rs | 783 +---------------- crates/tui-test-ffi/src/napi/type_helpers.rs | 15 - crates/tui-test-ffi/src/napi/types.rs | 12 + tools/bindgen/src/boundaries.rs | 42 +- tools/bindgen/src/napi.rs | 812 ++++++++++++++++++ 9 files changed, 1783 insertions(+), 823 deletions(-) create mode 100644 crates/tui-test-ffi/src/napi/bindings_generated.rs create mode 100644 tools/bindgen/src/napi.rs diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index a85ae98d..a5d79696 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -62,12 +62,12 @@ The example prints the terminal size and text that contains `Ready 界🙂`. The owned Rust models define the shared fields. Do not edit generated boundary files. The existing `tools/bindgen` tool reads these models with `syn`. -It generates boundary structs and fieldwise conversions before it runs `cbindgen`. +It generates boundary structs, fieldwise conversions, and all N-API exports before it runs `cbindgen`. It rejects field types without a transport mapping. | Authoritative source in `crates/tui-test/src/` | Coverage | | --- | --- | -| `api.rs` | Open/run options, recording options, timeouts, mouse options, styles, results, and operation option fields | +| `api.rs` | Options, styles, results, and the operation, result, key action, and mouse action enums | | `locator_query.rs` | Locator nodes, expressions, and node kinds | | `profile.rs` | Profile fields flattened into open/run options | | `diagnostics.rs` | Artifact, trace, and retention options, including the shared session input | @@ -89,6 +89,7 @@ The generator writes these Rust files under `crates/tui-test-ffi/src/`: - `types.rs` and `napi/types.rs` - `input_generated.rs` and `output_generated.rs` - `napi/convert_generated.rs` +- `napi/bindings_generated.rs` - `session_generated.rs` It then generates `include/tui_test.h`, the Go ABI structs and function table, and Python `ctypes` declarations. @@ -101,7 +102,7 @@ The generator does not compile the FFI crate to discover its models. Keep `rustfmt` and `gofmt` on `PATH`. Run `npm run build:native:debug` from `bindings/js` after generation. -The N-API CLI updates `native/index.d.ts` and the loader from the generated Rust types. +The N-API CLI updates `native/index.d.ts` and the loader from the generated Rust exports. CI separately checks these N-API outputs after the native build. Release packages include generated Rust files. They do not need the generator during a package build. @@ -113,14 +114,32 @@ It maps JavaScript numbers, nullable properties, property names, color unions, m It also generates strict locator input keys from the source fields. The existing C ABI v3 layouts and JavaScript signatures remain unchanged. +`tools/bindgen/src/napi.rs` generates the `NativeSession` class, every method, and all global functions. +Its `METHODS` table maps JavaScript names to owned variants, result selectors, and trace names. +It derives parameter types, numeric conversions, and operation fields from `Operation` and `MouseAction`. +It derives result types and selection code from `OperationResult`. +Explicit mappings retain argument defaults, grouped options, fixed key actions, and the locator selection flag. +`MouseClickOptions` comes from `MouseAction::Click`; the generator also declares the `Color` transport alias. + +Lifecycle signatures come from the actual functions in `napi/mod.rs`, not a second declaration list. +Generated wrappers call those functions for constructor state, context overrides, registry access, and close semantics. +The clipboard helper chooses a wait operation and validates its regex. +All regular operation methods construct owned Rust operations directly in generated code. +No N-API method calls the C ABI. + These parts remain handwritten: - `src/type_helpers.rs`: C strings, option tags, map entries, color tags, and the result envelope. -- `src/napi/type_helpers.rs`: the color union, overloaded mouse-click options, and the strict-input macro. +- `src/napi/type_helpers.rs`: the strict-input macro and transport imports. - `input.rs`, `output.rs`, and `napi/convert.rs`: pointer ownership, numeric validation, profile validation, and semantic adapters. -- `lib.rs`, `napi/mod.rs`, and `session.rs`: entrypoints, operation dispatch, error presentation, and execution. +- `lib.rs` and `session.rs`: C entrypoints and shared execution. +- `napi/mod.rs`: worker execution, error envelopes, panic containment, lifecycle state, and clipboard overload semantics. - The public Go, Python, and TypeScript convenience APIs. +The generator rejects N-API exports or derive imports outside `napi/types.rs` and `napi/bindings_generated.rs`. +New operation, action, or result variants require an explicit export decision. +This coverage check prevents new core variants from silently missing a boundary mapping. + The generator copies C transport helpers into `types.rs`; only `type_helpers.rs` is handwritten. The result envelope is a transport container, not a mirror of the Rust result enum. Mouse codes, snapshot codes, capture backgrounds, and locator occurrence rules retain explicit transport logic. @@ -134,12 +153,16 @@ Run this example from the repository root: ```sh env -u CARGO_TARGET_DIR cargo test --locked -p tui-test-bindgen \ boundaries::tests::owned_field_propagates_to_both_declarations_and_conversions -- --exact +env -u CARGO_TARGET_DIR cargo test --locked -p tui-test-bindgen boundaries::napi::tests env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen -- --check ``` The test adds a cursor field to the source model in memory. It verifies that both boundary declarations and both conversions contain the new field. Other generator tests cover input numbers, strict locator keys, enum variants, missing outputs, and stale outputs. +The N-API tests change operation fields and result payloads in memory. +They verify generated signatures, numeric validation, dispatch, and mouse option declarations. +They also check lifecycle signature propagation, export coverage, trace names, defaults, and rejection of handwritten exports. Then run the JavaScript terminal example above or the Go smoke example. These examples exercise the generated result conversions with a real terminal. diff --git a/crates/tui-test-ffi/src/napi/bindings_generated.rs b/crates/tui-test-ffi/src/napi/bindings_generated.rs new file mode 100644 index 00000000..98b06eb2 --- /dev/null +++ b/crates/tui-test-ffi/src/napi/bindings_generated.rs @@ -0,0 +1,773 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +use super::*; +use napi_derive::napi; +use tui_test::api::{KeyAction, MouseAction}; +#[napi] +pub struct NativeSession { + inner: SessionRuntime, +} +#[napi] +impl NativeSession { + #[napi(constructor)] + pub fn new( + name: String, + recording: Option, + artifacts: Option, + trace: Option, + ) -> Result { + SessionRuntime::new(name, recording, artifacts, trace).map(|inner| Self { inner }) + } + #[napi] + pub fn name(&self) -> String { + self.inner.name() + } + #[napi] + pub async fn open(&self, options: Option) -> Result { + self.inner.open(options).await + } + #[napi] + pub async fn run(&self, options: RunOptions) -> Result { + self.inner.run(options).await + } + #[napi] + pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { + self.inner + .call( + "restart", + move || { + Ok(Operation::Restart { + graceful_timeout_ms: integer( + graceful_timeout_ms, + "gracefulTimeoutMs", + u64::MAX, + )?, + }) + }, + open_result, + ) + .await + } + #[napi] + pub async fn close(&self, failed: Option) -> Result<()> { + self.inner.close(failed).await + } + #[napi] + pub async fn state(&self) -> Result { + self.inner + .call("state", move || Ok(Operation::State), state_result) + .await + } + #[napi] + pub async fn text(&self, full: Option) -> Result { + self.inner + .call( + "text", + move || { + Ok(Operation::Text { + full: full.unwrap_or(false), + }) + }, + string_result, + ) + .await + } + #[napi] + pub async fn packed_screen(&self, full: Option) -> Result { + self.inner + .call( + "packedScreen", + move || { + Ok(Operation::PackedScreen { + full: full.unwrap_or(false), + }) + }, + packed_screen_result, + ) + .await + } + #[napi] + pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { + self.inner + .call( + "cells", + move || { + Ok(Operation::Cells { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + w: u16_value(w.unwrap_or(1.0), "w")?, + h: u16_value(h.unwrap_or(1.0), "h")?, + }) + }, + cells_result, + ) + .await + } + #[napi] + pub async fn find_locator( + &self, + expression: LocatorExpression, + require_one: Option, + ) -> Result> { + let require_one = require_one.unwrap_or(false); + self.inner + .call( + if require_one { + "locator.location" + } else { + "locator.locations" + }, + move || { + Ok(if require_one { + Operation::ResolveLocator { + query: query(expression)?, + } + } else { + Operation::FindLocator { + query: query(expression)?, + } + }) + }, + matches, + ) + .await + } + #[napi] + pub async fn wait_locator( + &self, + expression: LocatorExpression, + not: Option, + timeout_ms: Option, + ) -> Result<()> { + self.inner + .call( + "locator.wait", + move || { + Ok(Operation::WaitLocator { + query: query(expression)?, + not: not.unwrap_or(false), + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn expect_locator( + &self, + expression: LocatorExpression, + not: Option, + timeout_ms: Option, + ) -> Result<()> { + self.inner + .call( + "locator.expect", + move || { + Ok(Operation::WaitLocator { + query: query(expression)?, + not: not.unwrap_or(false), + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn click_locator( + &self, + expression: LocatorExpression, + button: Option, + clicks: Option, + timeout_ms: Option, + ) -> Result<()> { + self.inner + .call( + "locator.click", + move || { + Ok(Operation::ClickLocator { + query: query(expression)?, + options: mouse_options(button.unwrap_or(0.0))?, + clicks: u8_value(clicks.unwrap_or(1.0), "clicks")?, + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn highlight_locator( + &self, + expression: LocatorExpression, + timeout_ms: Option, + ) -> Result> { + self.inner + .call( + "locator.highlight", + move || { + Ok(Operation::HighlightLocator { + query: query(expression)?, + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + matches, + ) + .await + } + #[napi] + pub async fn panic_probe(&self) -> Result<()> { + blocking(|| panic!("intentional native panic probe")).await + } + #[napi] + pub async fn get_command(&self) -> Result> { + self.inner + .call( + "getCommand", + move || Ok(Operation::GetCommand), + optional_string_result, + ) + .await + } + #[napi] + pub async fn get_output(&self) -> Result> { + self.inner + .call( + "getOutput", + move || Ok(Operation::GetOutput), + optional_string_result, + ) + .await + } + #[napi] + pub async fn get_exit_code(&self) -> Result> { + self.inner + .call( + "getExitCode", + move || Ok(Operation::GetExitCode), + exit_code_result, + ) + .await + } + #[napi] + pub async fn get_cwd(&self) -> Result> { + self.inner + .call( + "getCwd", + move || Ok(Operation::GetCwd), + optional_string_result, + ) + .await + } + #[napi] + pub async fn get_title(&self) -> Result> { + self.inner + .call( + "getTitle", + move || Ok(Operation::GetTitle), + optional_string_result, + ) + .await + } + #[napi] + pub async fn get_clipboard(&self) -> Result { + self.inner + .call( + "getClipboard", + move || Ok(Operation::GetClipboard), + string_result, + ) + .await + } + #[napi] + pub async fn get_cursor(&self) -> Result { + self.inner + .call("getCursor", move || Ok(Operation::GetCursor), cursor_result) + .await + } + #[napi] + pub async fn get_size(&self) -> Result { + self.inner + .call("getSize", move || Ok(Operation::GetSize), size_result) + .await + } + #[napi] + pub async fn get_bell_count(&self) -> Result { + self.inner + .call( + "getBellCount", + move || Ok(Operation::GetBellCount), + bell_count_result, + ) + .await + } + #[napi] + pub async fn get_bell_events(&self) -> Result> { + self.inner + .call( + "getBellEvents", + move || Ok(Operation::GetBellEvents), + bell_events_result, + ) + .await + } + #[napi] + pub async fn write(&self, data: String) -> Result<()> { + self.inner + .call("write", move || Ok(Operation::Write { data }), unit) + .await + } + #[napi(js_name = "type")] + pub async fn type_text(&self, text: String) -> Result<()> { + self.inner + .call("type", move || Ok(Operation::Write { data: text }), unit) + .await + } + #[napi] + pub async fn submit(&self, data: Option) -> Result<()> { + self.inner + .call("submit", move || Ok(Operation::Submit { data }), unit) + .await + } + #[napi] + pub async fn press(&self, keys: Vec) -> Result<()> { + self.inner + .call( + "press", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Press, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn key_down(&self, keys: Vec) -> Result<()> { + self.inner + .call( + "keyDown", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Down, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn repeat(&self, keys: Vec) -> Result<()> { + self.inner + .call( + "repeat", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Repeat, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn key_up(&self, keys: Vec) -> Result<()> { + self.inner + .call( + "keyUp", + move || { + Ok(Operation::Key { + keys, + action: KeyAction::Up, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_click(&self, options: Option) -> Result<()> { + let options = options.unwrap_or_default(); + self.inner + .call( + "mouse.click", + move || { + Ok(Operation::Mouse { + action: MouseAction::Click { + x: options + .x + .map(|v| -> Result<_> { u16_value(v, "x") }) + .transpose()?, + y: options + .y + .map(|v| -> Result<_> { u16_value(v, "y") }) + .transpose()?, + on_text: options.on_text, + options: mouse_options(options.button.unwrap_or(0.0))?, + clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { + self.inner + .call( + "mouse.move", + move || { + Ok(Operation::Mouse { + action: MouseAction::Move { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { + self.inner + .call( + "mouse.down", + move || { + Ok(Operation::Mouse { + action: MouseAction::Down { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { + self.inner + .call( + "mouse.up", + move || { + Ok(Operation::Mouse { + action: MouseAction::Up { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_drag( + &self, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + button: Option, + ) -> Result<()> { + self.inner + .call( + "mouse.drag", + move || { + Ok(Operation::Mouse { + action: MouseAction::Drag { + x1: u16_value(x1, "x1")?, + y1: u16_value(y1, "y1")?, + x2: u16_value(x2, "x2")?, + y2: u16_value(y2, "y2")?, + options: mouse_options(button.unwrap_or(0.0))?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { + self.inner + .call( + "mouse.scroll", + move || { + Ok(Operation::Mouse { + action: MouseAction::Scroll { + direction, + amount: u16_value(amount.unwrap_or(3.0), "amount")?, + }, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { + self.inner + .call( + "resize", + move || { + Ok(Operation::Resize { + cols: u16_value(cols, "cols")?, + rows: u16_value(rows, "rows")?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn signal(&self, name: String) -> Result<()> { + self.inner + .call("signal", move || Ok(Operation::Signal { name }), unit) + .await + } + #[napi] + pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or_default(); + self.inner + .call( + "waitTitle", + move || wait_title_operation(options, text), + unit, + ) + .await + } + #[napi] + pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or_default(); + self.inner + .call( + "expectTitle", + move || expect_title_operation(options, text), + unit, + ) + .await + } + #[napi] + pub async fn wait_clipboard( + &self, + text: Option, + options: Option, + ) -> Result<()> { + let options = options.unwrap_or_default(); + self.inner + .call( + "waitClipboard", + move || wait_clipboard_operation(text, options), + unit, + ) + .await + } + #[napi] + pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "waitIdle", + move || { + Ok(Operation::WaitIdle { + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "waitCommand", + move || { + Ok(Operation::WaitCommand { + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "waitExit", + move || { + Ok(Operation::WaitExit { + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "waitReady", + move || { + Ok(Operation::WaitReady { + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "waitBell", + move || { + Ok(Operation::WaitBell { + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "expectExitCode", + move || { + Ok(Operation::ExpectExitCode { + code: i32_value(code, "code")?, + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { + self.inner + .call( + "expectOutput", + move || { + Ok(Operation::ExpectOutput { + text, + regex: regex.unwrap_or(false), + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { + self.inner + .call( + "expectBellCount", + move || { + Ok(Operation::ExpectBellCount { + count: integer(count, "count", u64::MAX)?, + timeout_ms: timeout_ms + .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) + .transpose()?, + }) + }, + unit, + ) + .await + } + #[napi] + pub async fn snapshot( + &self, + name: String, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + self.inner + .call( + "expectSnapshot", + move || snapshot_operation(options, name), + snapshot_result, + ) + .await + } + #[napi] + pub async fn screenshot(&self, options: Option) -> Result { + let options = options.unwrap_or_default(); + self.inner + .call( + "screenshot", + move || screenshot_operation(options), + string_result, + ) + .await + } + #[napi] + pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { + self.inner + .call( + "startRecording", + move || start_recording_operation(options), + unit, + ) + .await + } + #[napi] + pub async fn stop_recording(&self) -> Result { + self.inner + .call( + "stopRecording", + move || Ok(Operation::StopRecording), + string_result, + ) + .await + } +} +#[napi] +pub async fn sessions() -> Result> { + super::sessions().await +} +#[napi] +pub fn close_all_sync() -> Result<()> { + super::close_all_sync() +} +#[napi] +pub async fn recording(name: String) -> Result { + super::recording(name).await +} +#[napi] +pub async fn close_all() -> Result<()> { + blocking(super::close_all_sync).await +} diff --git a/crates/tui-test-ffi/src/napi/convert.rs b/crates/tui-test-ffi/src/napi/convert.rs index c5faf305..76eee950 100644 --- a/crates/tui-test-ffi/src/napi/convert.rs +++ b/crates/tui-test-ffi/src/napi/convert.rs @@ -105,42 +105,6 @@ fn occurrence( .map_err(|error| usage(format!("invalid locator expression: {error}"))) } -pub fn unit(value: OperationResult) -> Result<()> { - match value { - OperationResult::Unit => Ok(()), - _ => Err(internal("native operation returned an unexpected result")), - } -} - -pub fn string_result(value: OperationResult) -> Result { - match value { - OperationResult::Text(value) - | OperationResult::Clipboard(value) - | OperationResult::Recording(value) - | OperationResult::Screenshot( - api::ScreenshotResult::Path(value) | api::ScreenshotResult::Text(value), - ) => Ok(value), - _ => Err(internal("native operation returned an unexpected result")), - } -} - -pub fn optional_string_result(value: OperationResult) -> Result> { - match value { - OperationResult::Command(value) - | OperationResult::Output(value) - | OperationResult::Cwd(value) - | OperationResult::Title(value) => Ok(value), - _ => Err(internal("native operation returned an unexpected result")), - } -} - -pub fn open_result(value: OperationResult) -> Result { - let OperationResult::Open(value) = value else { - return Err(internal("native operation returned an unexpected result")); - }; - Ok(open_value(value)) -} - pub fn color(value: api::CellColor) -> Color { match value { api::CellColor::Default => Either::B("default".into()), @@ -150,10 +114,3 @@ pub fn color(value: api::CellColor) -> Color { } } } - -pub fn matches(value: OperationResult) -> Result> { - let OperationResult::Matches(value) = value else { - return Err(internal("native operation returned an unexpected result")); - }; - Ok(value.into_iter().map(text_match).collect()) -} diff --git a/crates/tui-test-ffi/src/napi/convert_generated.rs b/crates/tui-test-ffi/src/napi/convert_generated.rs index 21f9bc3e..9483e086 100644 --- a/crates/tui-test-ffi/src/napi/convert_generated.rs +++ b/crates/tui-test-ffi/src/napi/convert_generated.rs @@ -403,3 +403,96 @@ pub fn query(value: LocatorExpression) -> Result { .into_query() .map_err(native_error) } +pub fn unit(value: OperationResult) -> Result<()> { + match value { + OperationResult::Unit => Ok(()), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn open_result(value: OperationResult) -> Result { + match value { + OperationResult::Open(value) => Ok(open_value(value)), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn state_result(value: OperationResult) -> Result { + match value { + OperationResult::State(value) => Ok(state(*value)), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn string_result(value: OperationResult) -> Result { + match value { + OperationResult::Text(value) => Ok(value), + OperationResult::Clipboard(value) => Ok(value), + OperationResult::Recording(value) => Ok(value), + OperationResult::Screenshot(value) => match value { + api::ScreenshotResult::Path(value) => Ok(value), + api::ScreenshotResult::Text(value) => Ok(value), + }, + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn packed_screen_result(value: OperationResult) -> Result { + match value { + OperationResult::PackedScreen(value) => Ok(packed_screen(value)), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn cells_result(value: OperationResult) -> Result> { + match value { + OperationResult::Cells(value) => value.into_iter().map(cell).collect(), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn matches(value: OperationResult) -> Result> { + match value { + OperationResult::Matches(value) => Ok(value.into_iter().map(text_match).collect()), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn optional_string_result(value: OperationResult) -> Result> { + match value { + OperationResult::Command(value) => Ok(value), + OperationResult::Output(value) => Ok(value), + OperationResult::Cwd(value) => Ok(value), + OperationResult::Title(value) => Ok(value), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn exit_code_result(value: OperationResult) -> Result> { + match value { + OperationResult::ExitCode(value) => Ok(value), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn cursor_result(value: OperationResult) -> Result { + match value { + OperationResult::Cursor(value) => Ok(cursor(value)), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn size_result(value: OperationResult) -> Result { + match value { + OperationResult::Size(value) => Ok(size(value)), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn bell_count_result(value: OperationResult) -> Result { + match value { + OperationResult::BellCount(value) => Ok(value as f64), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn bell_events_result(value: OperationResult) -> Result> { + match value { + OperationResult::BellEvents(value) => Ok(value.into_iter().map(bell_event).collect()), + _ => Err(internal("native operation returned an unexpected result")), + } +} +pub fn snapshot_result(value: OperationResult) -> Result { + match value { + OperationResult::Snapshot(value) => Ok(value.into()), + _ => Err(internal("native operation returned an unexpected result")), + } +} diff --git a/crates/tui-test-ffi/src/napi/mod.rs b/crates/tui-test-ffi/src/napi/mod.rs index 0bfee191..8fe8933b 100644 --- a/crates/tui-test-ffi/src/napi/mod.rs +++ b/crates/tui-test-ffi/src/napi/mod.rs @@ -1,17 +1,17 @@ #![deny(clippy::all)] +mod bindings_generated; mod convert; mod types; use self::types::*; use crate::session::{self, SessionInput}; use convert::*; -use tui_test::api::{self, KeyAction, MouseAction, Operation, OperationResult}; +use tui_test::api::{self, Operation, OperationResult}; use tui_test::runtime::global_registry; use ::napi::bindgen_prelude::spawn_blocking; use ::napi::{Error, Result, Status}; -use napi_derive::napi; const ERROR_PREFIX: &str = "__tui_test_native_error__:"; const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; @@ -91,28 +91,14 @@ async fn execute( .await } -#[napi] -pub struct NativeSession { +struct SessionRuntime { name: String, context: SessionInput, recording_directory: Option, } -impl NativeSession { - async fn call( - &self, - operation_name: &'static str, - operation: impl FnOnce() -> Result + Send + 'static, - convert: impl FnOnce(OperationResult) -> Result + Send + 'static, - ) -> Result { - execute(self.context.clone(), operation_name, operation, convert).await - } -} - -#[napi] -impl NativeSession { - #[napi(constructor)] - pub fn new( +impl SessionRuntime { + fn new( name: String, recording: Option, artifacts: Option, @@ -135,13 +121,11 @@ impl NativeSession { }) } - #[napi] - pub fn name(&self) -> String { + fn name(&self) -> String { self.name.clone() } - #[napi] - pub async fn open(&self, options: Option) -> Result { + async fn open(&self, options: Option) -> Result { let options = options.unwrap_or_default(); let mut context = self.context.clone(); open_options_context(&mut context, &options)?; @@ -155,8 +139,7 @@ impl NativeSession { .await } - #[napi] - pub async fn run(&self, options: RunOptions) -> Result { + async fn run(&self, options: RunOptions) -> Result { let mut context = self.context.clone(); run_options_context(&mut context, &options)?; let directory = self.recording_directory.clone(); @@ -169,26 +152,7 @@ impl NativeSession { .await } - #[napi] - pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { - self.call( - "restart", - move || { - Ok(Operation::Restart { - graceful_timeout_ms: integer( - graceful_timeout_ms, - "gracefulTimeoutMs", - u64::MAX, - )?, - }) - }, - open_result, - ) - .await - } - - #[napi] - pub async fn close(&self, failed: Option) -> Result<()> { + async fn close(&self, failed: Option) -> Result<()> { let context = self.context.clone(); blocking(move || { let (name, context) = context.validate().map_err(native_error)?; @@ -197,718 +161,47 @@ impl NativeSession { .await } - #[napi] - pub async fn state(&self) -> Result { - self.call( - "state", - || Ok(Operation::State), - |value| match value { - OperationResult::State(value) => Ok(state(*value)), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - - #[napi] - pub async fn text(&self, full: Option) -> Result { - self.call( - "text", - move || { - Ok(Operation::Text { - full: full.unwrap_or(false), - }) - }, - string_result, - ) - .await - } - - #[napi] - pub async fn packed_screen(&self, full: Option) -> Result { - self.call( - "packedScreen", - move || { - Ok(Operation::PackedScreen { - full: full.unwrap_or(false), - }) - }, - |value| match value { - OperationResult::PackedScreen(value) => Ok(packed_screen(value)), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - - #[napi] - pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { - self.call( - "cells", - move || { - Ok(Operation::Cells { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - w: u16_value(w.unwrap_or(1.0), "w")?, - h: u16_value(h.unwrap_or(1.0), "h")?, - }) - }, - |value| match value { - OperationResult::Cells(value) => value.into_iter().map(cell).collect(), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - - #[napi] - pub async fn find_locator( - &self, - expression: LocatorExpression, - require_one: Option, - ) -> Result> { - let require_one = require_one.unwrap_or(false); - self.call( - if require_one { - "locator.location" - } else { - "locator.locations" - }, - move || { - let query = query(expression)?; - Ok(if require_one { - Operation::ResolveLocator { query } - } else { - Operation::FindLocator { query } - }) - }, - matches, - ) - .await - } - - #[napi] - pub async fn wait_locator( - &self, - expression: LocatorExpression, - not: Option, - timeout_ms: Option, - ) -> Result<()> { - self.call( - "locator.wait", - move || { - Ok(Operation::WaitLocator { - query: query(expression)?, - not: not.unwrap_or(false), - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - - #[napi] - pub async fn expect_locator( - &self, - expression: LocatorExpression, - not: Option, - timeout_ms: Option, - ) -> Result<()> { - self.call( - "locator.expect", - move || { - Ok(Operation::WaitLocator { - query: query(expression)?, - not: not.unwrap_or(false), - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - - #[napi] - pub async fn click_locator( - &self, - expression: LocatorExpression, - button: Option, - clicks: Option, - timeout_ms: Option, - ) -> Result<()> { - self.call( - "locator.click", - move || { - Ok(Operation::ClickLocator { - query: query(expression)?, - options: mouse_options(button.unwrap_or(0.0))?, - clicks: u8_value(clicks.unwrap_or(1.0), "clicks")?, - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - - #[napi] - pub async fn highlight_locator( - &self, - expression: LocatorExpression, - timeout_ms: Option, - ) -> Result> { - self.call( - "locator.highlight", - move || { - Ok(Operation::HighlightLocator { - query: query(expression)?, - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - matches, - ) - .await - } - - #[napi] - pub async fn panic_probe(&self) -> Result<()> { - blocking(|| panic!("intentional native panic probe")).await - } - - #[napi] - pub async fn get_command(&self) -> Result> { - self.call( - "getCommand", - || Ok(Operation::GetCommand), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_output(&self) -> Result> { - self.call( - "getOutput", - || Ok(Operation::GetOutput), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_exit_code(&self) -> Result> { - self.call( - "getExitCode", - || Ok(Operation::GetExitCode), - |value| match value { - OperationResult::ExitCode(value) => Ok(value), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - #[napi] - pub async fn get_cwd(&self) -> Result> { - self.call("getCwd", || Ok(Operation::GetCwd), optional_string_result) - .await - } - #[napi] - pub async fn get_title(&self) -> Result> { - self.call( - "getTitle", - || Ok(Operation::GetTitle), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_clipboard(&self) -> Result { - self.call( - "getClipboard", - || Ok(Operation::GetClipboard), - string_result, - ) - .await - } - #[napi] - pub async fn get_cursor(&self) -> Result { - self.call( - "getCursor", - || Ok(Operation::GetCursor), - |value| match value { - OperationResult::Cursor(value) => Ok(cursor(value)), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - #[napi] - pub async fn get_size(&self) -> Result { - self.call( - "getSize", - || Ok(Operation::GetSize), - |value| match value { - OperationResult::Size(value) => Ok(size(value)), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - #[napi] - pub async fn get_bell_count(&self) -> Result { - self.call( - "getBellCount", - || Ok(Operation::GetBellCount), - |value| match value { - OperationResult::BellCount(value) => Ok(value as f64), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - #[napi] - pub async fn get_bell_events(&self) -> Result> { - self.call( - "getBellEvents", - || Ok(Operation::GetBellEvents), - |value| match value { - OperationResult::BellEvents(value) => { - Ok(value.into_iter().map(bell_event).collect()) - } - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - - #[napi] - pub async fn write(&self, data: String) -> Result<()> { - self.call("write", move || Ok(Operation::Write { data }), unit) - .await - } - #[napi(js_name = "type")] - pub async fn type_text(&self, text: String) -> Result<()> { - self.call("type", move || Ok(Operation::Write { data: text }), unit) - .await - } - #[napi] - pub async fn submit(&self, data: Option) -> Result<()> { - self.call("submit", move || Ok(Operation::Submit { data }), unit) - .await - } - #[napi] - pub async fn press(&self, keys: Vec) -> Result<()> { - self.call( - "press", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Press, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn key_down(&self, keys: Vec) -> Result<()> { - self.call( - "keyDown", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Down, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn repeat(&self, keys: Vec) -> Result<()> { - self.call( - "repeat", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Repeat, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn key_up(&self, keys: Vec) -> Result<()> { - self.call( - "keyUp", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Up, - }) - }, - unit, - ) - .await - } - - #[napi] - pub async fn mouse_click(&self, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.call( - "mouse.click", - move || { - Ok(Operation::Mouse { - action: MouseAction::Click { - x: optional_u16(options.x, "x")?, - y: optional_u16(options.y, "y")?, - on_text: options.on_text, - options: mouse_options(options.button.unwrap_or(0.0))?, - clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { - self.call( - "mouse.move", - move || { - Ok(Operation::Mouse { - action: MouseAction::Move { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call( - "mouse.down", - move || { - Ok(Operation::Mouse { - action: MouseAction::Down { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.call( - "mouse.up", - move || { - Ok(Operation::Mouse { - action: MouseAction::Up { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_drag( - &self, - x1: f64, - y1: f64, - x2: f64, - y2: f64, - button: Option, - ) -> Result<()> { - self.call( - "mouse.drag", - move || { - Ok(Operation::Mouse { - action: MouseAction::Drag { - x1: u16_value(x1, "x1")?, - y1: u16_value(y1, "y1")?, - x2: u16_value(x2, "x2")?, - y2: u16_value(y2, "y2")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { - self.call( - "mouse.scroll", - move || { - Ok(Operation::Mouse { - action: MouseAction::Scroll { - direction, - amount: u16_value(amount.unwrap_or(3.0), "amount")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { - self.call( - "resize", - move || { - Ok(Operation::Resize { - cols: u16_value(cols, "cols")?, - rows: u16_value(rows, "rows")?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn signal(&self, name: String) -> Result<()> { - self.call("signal", move || Ok(Operation::Signal { name }), unit) - .await - } - - #[napi] - pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.call( - "waitTitle", - move || wait_title_operation(options, text), - unit, - ) - .await - } - #[napi] - pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.call( - "expectTitle", - move || expect_title_operation(options, text), - unit, - ) - .await - } - #[napi] - pub async fn wait_clipboard( - &self, - text: Option, - options: Option, - ) -> Result<()> { - let options = options.unwrap_or_default(); - self.call( - "waitClipboard", - move || { - let timeout_ms = optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?; - match text { - Some(text) => Ok(Operation::WaitClipboardMatch { - pattern: if options.regex.unwrap_or(false) { - api::ClipboardPattern::regex(&text) - .map_err(|error| usage(format!("invalid regex: {error}")))? - } else { - text.into() - }, - timeout_ms, - }), - None if options.regex.unwrap_or(false) => { - Err(usage("clipboard regex requires text")) - } - None => Ok(Operation::WaitClipboard { timeout_ms }), - } - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.call( - "waitIdle", - move || { - Ok(Operation::WaitIdle { - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.call( - "waitCommand", - move || { - Ok(Operation::WaitCommand { - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.call( - "waitExit", - move || { - Ok(Operation::WaitExit { - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.call( - "waitReady", - move || { - Ok(Operation::WaitReady { - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.call( - "waitBell", - move || { - Ok(Operation::WaitBell { - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { - self.call( - "expectExitCode", - move || { - Ok(Operation::ExpectExitCode { - code: i32_value(code, "code")?, - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.call( - "expectOutput", - move || { - Ok(Operation::ExpectOutput { - text, - regex: regex.unwrap_or(false), - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { - self.call( - "expectBellCount", - move || { - Ok(Operation::ExpectBellCount { - count: integer(count, "count", u64::MAX)?, - timeout_ms: optional_integer(timeout_ms, "timeoutMs", u64::MAX)?, - }) - }, - unit, - ) - .await - } - - #[napi] - pub async fn snapshot( + async fn call( &self, - name: String, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - self.call( - "expectSnapshot", - move || snapshot_operation(options, name), - |value| match value { - OperationResult::Snapshot(value) => Ok(value.into()), - _ => Err(internal("native operation returned an unexpected result")), - }, - ) - .await - } - #[napi] - pub async fn screenshot(&self, options: Option) -> Result { - let options = options.unwrap_or_default(); - self.call( - "screenshot", - move || screenshot_operation(options), - string_result, - ) - .await - } - #[napi] - pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { - self.call( - "startRecording", - move || start_recording_operation(options), - unit, - ) - .await - } - #[napi] - pub async fn stop_recording(&self) -> Result { - self.call( - "stopRecording", - || Ok(Operation::StopRecording), - string_result, - ) - .await + operation_name: &'static str, + operation: impl FnOnce() -> Result + Send + 'static, + convert: impl FnOnce(OperationResult) -> Result + Send + 'static, + ) -> Result { + execute(self.context.clone(), operation_name, operation, convert).await } } -#[napi] -pub async fn sessions() -> Result> { +async fn sessions() -> Result> { blocking(|| Ok(global_registry().sessions())).await } -#[napi] -pub async fn close_all() -> Result<()> { - blocking(close_all_sync).await -} - -#[napi] -pub fn close_all_sync() -> Result<()> { +fn close_all_sync() -> Result<()> { boundary(|| { global_registry().close_all(); Ok(()) }) } -#[napi] -pub async fn recording(name: String) -> Result { +async fn recording(name: String) -> Result { blocking(move || string_result(session::recording(&name).map_err(native_error)?)).await } + +fn wait_clipboard_operation( + text: Option, + options: ClipboardWaitOptions, +) -> Result { + let timeout_ms = optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?; + match text { + Some(text) => Ok(Operation::WaitClipboardMatch { + pattern: if options.regex.unwrap_or(false) { + api::ClipboardPattern::regex(&text) + .map_err(|error| usage(format!("invalid regex: {error}")))? + } else { + text.into() + }, + timeout_ms, + }), + None if options.regex.unwrap_or(false) => Err(usage("clipboard regex requires text")), + None => Ok(Operation::WaitClipboard { timeout_ms }), + } +} diff --git a/crates/tui-test-ffi/src/napi/type_helpers.rs b/crates/tui-test-ffi/src/napi/type_helpers.rs index 2687af33..94108312 100644 --- a/crates/tui-test-ffi/src/napi/type_helpers.rs +++ b/crates/tui-test-ffi/src/napi/type_helpers.rs @@ -2,23 +2,8 @@ use ::napi::bindgen_prelude::{ Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, }; use napi::{Error, Result, Status}; -use napi_derive::napi; use std::collections::HashMap; -#[napi] -pub type Color = Either; - -// This convenience operation selects either coordinates or text, unlike core MouseAction. -#[derive(Default)] -#[napi(object)] -pub struct MouseClickOptions { - pub x: Option, - pub y: Option, - pub on_text: Option, - pub button: Option, - pub clicks: Option, -} - // Reject unknown fields before conversion so misspelled predicates cannot // silently broaden a locator. The generator supplies the field names. macro_rules! strict_locator_input { diff --git a/crates/tui-test-ffi/src/napi/types.rs b/crates/tui-test-ffi/src/napi/types.rs index 65415dff..de4355f6 100644 --- a/crates/tui-test-ffi/src/napi/types.rs +++ b/crates/tui-test-ffi/src/napi/types.rs @@ -1,4 +1,5 @@ // Generated by cargo run -p tui-test-bindgen. Do not edit. +use napi_derive::napi; include!("type_helpers.rs"); #[napi(object, use_nullable = true)] pub struct OpenResult { @@ -426,3 +427,14 @@ pub struct RecordingOptions { pub background: Option, pub transparent: Option, } +#[napi] +pub type Color = Either; +#[derive(Default)] +#[napi(object)] +pub struct MouseClickOptions { + pub x: Option, + pub y: Option, + pub on_text: Option, + pub button: Option, + pub clicks: Option, +} diff --git a/tools/bindgen/src/boundaries.rs b/tools/bindgen/src/boundaries.rs index fd9ec55d..e476ce54 100644 --- a/tools/bindgen/src/boundaries.rs +++ b/tools/bindgen/src/boundaries.rs @@ -5,6 +5,9 @@ use quote::ToTokens; use std::{collections::BTreeMap, fmt::Write, fs, path::Path}; use syn::{Fields, Item}; +#[path = "napi.rs"] +mod napi; + #[derive(Clone)] struct Field { name: String, @@ -87,21 +90,24 @@ impl Models { } Item::Enum(item) => { let name = item.ident.to_string(); - if item - .variants - .iter() - .all(|v| matches!(v.fields, Fields::Unit)) - { - self.enums.insert( - name.clone(), - item.variants.iter().map(|v| v.ident.to_string()).collect(), - ); - } + self.enums.insert( + name.clone(), + item.variants.iter().map(|v| v.ident.to_string()).collect(), + ); for v in item.variants { - if matches!(v.fields, Fields::Named(_)) { - self.fields - .insert(format!("{name}::{}", v.ident), fields(&v.fields)?); - } + let values = match &v.fields { + Fields::Unnamed(values) => values + .unnamed + .iter() + .enumerate() + .map(|(i, f)| Field { + name: i.to_string(), + ty: f.ty.to_token_stream().to_string().replace(' ', ""), + }) + .collect(), + other => fields(other)?, + }; + self.fields.insert(format!("{name}::{}", v.ident), values); } } _ => {} @@ -911,12 +917,13 @@ fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Resul pub fn generate(root: &Path, check: bool) -> Result<()> { let models = Models::load(root)?; + napi::check_exports(root)?; let notice = format!("// {NOTICE}\n"); let mut c = notice.clone(); c.push_str(&fs::read_to_string( root.join("crates/tui-test-ffi/src/type_helpers.rs"), )?); - let mut js = format!("{notice}include!(\"type_helpers.rs\");\n"); + let mut js = format!("{notice}use napi_derive::napi;\ninclude!(\"type_helpers.rs\");\n"); let mut cc = notice.clone(); let mut jc = notice.clone(); let mut ci = notice; @@ -924,6 +931,8 @@ pub fn generate(root: &Path, check: bool) -> Result<()> { enum_models(&models, &mut js, &mut jc)?; input_models(&models, &mut c, &mut js, &mut jc, &mut ci)?; input_conversions(&models, &mut jc, &mut ci)?; + napi::types(&models, &mut js)?; + napi::results(&models, &mut jc)?; for (path, content) in [ ("types.rs", c), ("napi/types.rs", js), @@ -931,6 +940,7 @@ pub fn generate(root: &Path, check: bool) -> Result<()> { ("napi/convert_generated.rs", jc), ("input_generated.rs", ci), ("session_generated.rs", session_input(&models)?), + ("napi/bindings_generated.rs", napi::bindings(&models, root)?), ] { emit( root, @@ -1078,6 +1088,7 @@ pub(crate) mod tests { "crates/tui-test/src/terminal/backend.rs", "crates/tui-test/src/terminal/cell.rs", "crates/tui-test-ffi/src/type_helpers.rs", + "crates/tui-test-ffi/src/napi/mod.rs", ] { fs::create_dir_all(target.join(path).parent().unwrap()).unwrap(); fs::copy(root().join(path), target.join(path)).unwrap(); @@ -1091,6 +1102,7 @@ pub(crate) mod tests { "napi/convert_generated.rs", "input_generated.rs", "session_generated.rs", + "napi/bindings_generated.rs", ] { let output = target.join("crates/tui-test-ffi/src").join(path); let original = fs::read_to_string(&output).unwrap(); diff --git a/tools/bindgen/src/napi.rs b/tools/bindgen/src/napi.rs new file mode 100644 index 00000000..8ccef13b --- /dev/null +++ b/tools/bindgen/src/napi.rs @@ -0,0 +1,812 @@ +//! JavaScript method names, defaults, and result selection are transport metadata. +//! Field types and operation construction come from the owned Rust enums. +use super::{assignment, ident, js_key, js_output, js_type, option, snake, Field, Models}; +use crate::NOTICE; +use anyhow::{bail, Context, Result}; +use quote::ToTokens; +use std::{collections::BTreeSet, fmt::Write, fs, path::Path}; +use syn::{FnArg, ImplItem, Item, Pat}; + +// (JavaScript method in Rust spelling, owned variant, result selector, trace name). +// A dotted variant binds a nested MouseAction or a fixed KeyAction. +const METHODS: &[(&str, &str, &str, &str)] = &[ + ("restart", "Restart", "Open", "restart"), + ("state", "State", "State", "state"), + ("text", "Text", "Text", "text"), + ( + "packed_screen", + "PackedScreen", + "PackedScreen", + "packedScreen", + ), + ("cells", "Cells", "Cells", "cells"), + ( + "find_locator", + "FindLocator", + "Matches", + "locator.locations", + ), + ("wait_locator", "WaitLocator", "Unit", "locator.wait"), + ("expect_locator", "WaitLocator", "Unit", "locator.expect"), + ("click_locator", "ClickLocator", "Unit", "locator.click"), + ( + "highlight_locator", + "HighlightLocator", + "Matches", + "locator.highlight", + ), + ("get_command", "GetCommand", "Command", "getCommand"), + ("get_output", "GetOutput", "Command", "getOutput"), + ("get_exit_code", "GetExitCode", "ExitCode", "getExitCode"), + ("get_cwd", "GetCwd", "Command", "getCwd"), + ("get_title", "GetTitle", "Command", "getTitle"), + ("get_clipboard", "GetClipboard", "Text", "getClipboard"), + ("get_cursor", "GetCursor", "Cursor", "getCursor"), + ("get_size", "GetSize", "Size", "getSize"), + ( + "get_bell_count", + "GetBellCount", + "BellCount", + "getBellCount", + ), + ( + "get_bell_events", + "GetBellEvents", + "BellEvents", + "getBellEvents", + ), + ("write", "Write", "Unit", "write"), + ("type_text", "Write", "Unit", "type"), + ("submit", "Submit", "Unit", "submit"), + ("press", "Key.Press", "Unit", "press"), + ("key_down", "Key.Down", "Unit", "keyDown"), + ("repeat", "Key.Repeat", "Unit", "repeat"), + ("key_up", "Key.Up", "Unit", "keyUp"), + ("mouse_click", "Mouse.Click", "Unit", "mouse.click"), + ("mouse_move", "Mouse.Move", "Unit", "mouse.move"), + ("mouse_down", "Mouse.Down", "Unit", "mouse.down"), + ("mouse_up", "Mouse.Up", "Unit", "mouse.up"), + ("mouse_drag", "Mouse.Drag", "Unit", "mouse.drag"), + ("mouse_scroll", "Mouse.Scroll", "Unit", "mouse.scroll"), + ("resize", "Resize", "Unit", "resize"), + ("signal", "Signal", "Unit", "signal"), + ("wait_title", "WaitTitle", "Unit", "waitTitle"), + ("expect_title", "ExpectTitle", "Unit", "expectTitle"), + ("wait_clipboard", "WaitClipboard", "Unit", "waitClipboard"), + ("wait_idle", "WaitIdle", "Unit", "waitIdle"), + ("wait_command", "WaitCommand", "Unit", "waitCommand"), + ("wait_exit", "WaitExit", "Unit", "waitExit"), + ("wait_ready", "WaitReady", "Unit", "waitReady"), + ("wait_bell", "WaitBell", "Unit", "waitBell"), + ( + "expect_exit_code", + "ExpectExitCode", + "Unit", + "expectExitCode", + ), + ("expect_output", "ExpectOutput", "Unit", "expectOutput"), + ( + "expect_bell_count", + "ExpectBellCount", + "Unit", + "expectBellCount", + ), + ("snapshot", "Snapshot", "Snapshot", "expectSnapshot"), + ("screenshot", "Screenshot", "Text", "screenshot"), + ( + "start_recording", + "StartRecording", + "Unit", + "startRecording", + ), + ("stop_recording", "StopRecording", "Text", "stopRecording"), +]; + +// These variants have lifecycle/selection semantics rather than direct methods. +const SPECIAL_OPERATIONS: &[&str] = &[ + "Open", + "Run", + "Close", + "FinishTrace", + "ResolveLocator", + "WaitClipboardMatch", +]; +// These operations have no native JavaScript method. Additions must be classified explicitly. +const UNEXPORTED_OPERATIONS: &[&str] = &[ + "GetModes", + "GetColors", + "ExpectMode", + "ExpectColors", + "ExpectCursor", +]; +const LIFECYCLE: &[&str] = &["new", "name", "open", "run", "close"]; +const GLOBALS: &[&str] = &["sessions", "close_all_sync", "recording"]; + +fn coverage(models: &Models) -> Result<()> { + for (model, declared) in [ + ( + "Operation", + METHODS + .iter() + .map(|m| m.1.split('.').next().unwrap()) + .chain(SPECIAL_OPERATIONS.iter().copied()) + .chain(UNEXPORTED_OPERATIONS.iter().copied()) + .collect::>(), + ), + ( + "MouseAction", + METHODS + .iter() + .filter_map(|m| m.1.strip_prefix("Mouse.")) + .collect(), + ), + ( + "KeyAction", + METHODS + .iter() + .filter_map(|m| m.1.strip_prefix("Key.")) + .collect(), + ), + ] { + let owned: BTreeSet<_> = models + .enums + .get(model) + .context("missing operation enum")? + .iter() + .map(String::as_str) + .collect(); + if owned != declared { + bail!( + "{model} N-API coverage mismatch: {:?}", + owned.symmetric_difference(&declared).collect::>() + ); + } + } + Ok(()) +} + +/// All exported attributes must reside in outputs that generation replaces. +pub(super) fn check_exports(root: &Path) -> Result<()> { + fn scan(path: &Path, root: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + for entry in fs::read_dir(path)? { + let path = entry?.path(); + if path.is_dir() { + scan(&path, root)?; + } else if path.extension().is_some_and(|ext| ext == "rs") + && !["napi/types.rs", "napi/bindings_generated.rs"] + .iter() + .any(|output| path == root.join(output)) + { + let source = fs::read_to_string(&path)?; + // Normalized tokens include attributes inside impls, modules, and macros. + let tokens = syn::parse_file(&source)? + .to_token_stream() + .to_string() + .replace(' ', ""); + if tokens.contains("napi_derive") + || tokens.contains("#[napi") + || tokens.contains("#[::napi") + || (tokens.contains("#[cfg_attr(") && tokens.contains(",napi")) + { + bail!("handwritten N-API export in {}", path.display()); + } + } + } + Ok(()) + } + let source = root.join("crates/tui-test-ffi/src"); + scan(&source, &source) +} + +fn parameter(method: &str, field: &Field, prefix: &str) -> Result<(String, String, String)> { + let name = match field.name.as_str() { + "query" => "expression", + "options" if field.ty == "MouseOptions" => "button", + "data" if method == "type_text" => "text", + other => other, + } + .to_owned(); + let ty = input_type(&field.ty)?; + let default = match field.name.as_str() { + "w" | "h" | "clicks" => Some("1.0"), + "amount" => Some("3.0"), + "options" if field.ty == "MouseOptions" => Some("0.0"), + _ if field.ty == "bool" => Some("false"), + _ => None, + }; + let ty = if default.is_some() { + format!("Option<{ty}>") + } else { + ty + }; + let value = if let Some(default) = default { + format!("{prefix}{name}.unwrap_or({default})") + } else { + format!("{prefix}{name}") + }; + Ok(( + name.clone(), + ty, + input_value(&field.ty, &value, &js_key(&name))?, + )) +} + +fn input_type(ty: &str) -> Result { + if let Some(inner) = option(ty) { + return Ok(format!("Option<{}>", input_type(inner)?)); + } + Ok(match ty { + "u8" | "u16" | "u32" | "u64" | "usize" | "i32" | "MouseOptions" => "f64".into(), + "LocatorQuery" => "LocatorExpression".into(), + _ => js_type(ty)?, + }) +} + +fn input_value(ty: &str, value: &str, label: &str) -> Result { + if let Some(inner) = option(ty) { + if input_type(inner)? == inner { + return Ok(value.into()); + } + let conversion = input_value(inner, "v", label)?; + let conversion = conversion + .strip_suffix('?') + .map(str::to_owned) + .unwrap_or_else(|| format!("Ok({conversion})")); + return Ok(format!( + "{value}.map(|v| -> Result<_> {{ {conversion} }}).transpose()?" + )); + } + Ok(match ty { + "u16" => format!("u16_value({value}, {label:?})?"), + "u8" => format!("u8_value({value}, {label:?})?"), + "i32" => format!("i32_value({value}, {label:?})?"), + "u64" => format!("integer({value}, {label:?}, u64::MAX)?"), + "u32" | "usize" => format!("integer({value}, {label:?}, {ty}::MAX as u64)? as {ty}"), + "LocatorQuery" => format!("query({value})?"), + "MouseOptions" => format!("mouse_options({value})?"), + _ => { + input_type(ty)?; + value.into() + } + }) +} + +pub(super) fn types(models: &Models, js: &mut String) -> Result<()> { + js.push_str("#[napi] pub type Color = Either;\n#[derive(Default)] #[napi(object)] pub struct MouseClickOptions {\n"); + for field in models.fields("MouseAction::Click")? { + let (name, ty, _) = parameter("mouse_click", &field, "")?; + writeln!(js, "pub {}: {},", ident(&name), ty)?; + } + js.push_str("}\n"); + Ok(()) +} + +// Result unions preserve the existing adapter contract. The first variant supplies the type. +const RESULTS: &[(&str, &[&str])] = &[ + ("Unit", &["Unit"]), + ("Open", &["Open"]), + ("State", &["State"]), + ("Text", &["Text", "Clipboard", "Recording", "Screenshot"]), + ("PackedScreen", &["PackedScreen"]), + ("Cells", &["Cells"]), + ("Matches", &["Matches"]), + ("Command", &["Command", "Output", "Cwd", "Title"]), + ("ExitCode", &["ExitCode"]), + ("Cursor", &["Cursor"]), + ("Size", &["Size"]), + ("BellCount", &["BellCount"]), + ("BellEvents", &["BellEvents"]), + ("Snapshot", &["Snapshot"]), +]; + +fn result_type(models: &Models, variant: &str) -> Result { + let fields = models.fields(&format!("OperationResult::{variant}"))?; + match fields.as_slice() { + [] => Ok("()".into()), + [field] => js_type( + field + .ty + .strip_prefix("Box<") + .and_then(|t| t.strip_suffix('>')) + .unwrap_or(&field.ty), + ) + .or_else(|error| { + if field.ty == "SnapshotResult" { + Ok(field.ty.clone()) + } else { + Err(error) + } + }), + _ => bail!("unsupported result shape: {variant}"), + } +} + +fn result_name(variant: &str) -> String { + match variant { + "Unit" => "unit".into(), + "Text" => "string_result".into(), + "Command" => "optional_string_result".into(), + "Matches" => "matches".into(), + _ => format!("{}_result", snake(variant)), + } +} + +pub(super) fn results(models: &Models, out: &mut String) -> Result<()> { + let covered: BTreeSet<_> = RESULTS + .iter() + .flat_map(|r| r.1.iter().copied()) + .chain(["Modes", "Colors"]) + .collect(); + let owned: BTreeSet<_> = models.enums["OperationResult"] + .iter() + .map(String::as_str) + .collect(); + if covered != owned { + bail!("OperationResult N-API coverage mismatch"); + } + for (selector, variants) in RESULTS { + writeln!( + out, + "pub fn {}(value: OperationResult) -> Result<{}> {{ match value {{", + result_name(selector), + result_type(models, selector)? + )?; + for variant in *variants { + let fields = models.fields(&format!("OperationResult::{variant}"))?; + let (pattern, conversion) = match fields.as_slice() { + [] => (format!("OperationResult::{variant}"), "Ok(())".into()), + [field] => { + let (ty, value) = if let Some(inner) = field + .ty + .strip_prefix("Box<") + .and_then(|t| t.strip_suffix('>')) + { + (inner, "*value") + } else { + (field.ty.as_str(), "value") + }; + let conversion = match ty { + "Vec" => "value.into_iter().map(cell).collect()".into(), + "SnapshotResult" => "Ok(value.into())".into(), + "ScreenshotResult" => { + let mut arms = String::new(); + for variant in &models.enums["ScreenshotResult"] { + let fields = + models.fields(&format!("ScreenshotResult::{variant}"))?; + if fields.len() != 1 || fields[0].ty != "String" { + bail!("unsupported screenshot result"); + } + write!( + arms, + "api::ScreenshotResult::{variant}(value) => Ok(value)," + )?; + } + format!("match value {{ {arms} }}") + } + _ => format!("Ok({})", js_output(ty, value)?), + }; + (format!("OperationResult::{variant}(value)"), conversion) + } + _ => bail!("unsupported result shape: {variant}"), + }; + writeln!(out, "{pattern} => {conversion},")?; + } + out.push_str( + "_ => Err(internal(\"native operation returned an unexpected result\")), } }\n", + ); + } + Ok(()) +} + +fn operation_method( + models: &Models, + method: &str, + source: &str, + result: &str, + trace: &str, +) -> Result { + let (operation, nested) = source + .split_once('.') + .map_or((source, None), |(a, b)| (a, Some(b))); + let model = if operation == "Mouse" { + format!("MouseAction::{}", nested.unwrap()) + } else { + format!("Operation::{operation}") + }; + let mut params = Vec::new(); + let mut assignments = String::new(); + let mut setup = String::new(); + let mut expression = String::new(); + let options = match operation { + "WaitTitle" | "ExpectTitle" => Some(("TitleOptions", Some("text"))), + "Snapshot" => Some(("SnapshotOptions", Some("name"))), + "Screenshot" => Some(("ScreenshotOptions", None)), + "StartRecording" => Some(("RecordingOptions", None)), + _ => None, + }; + if let Some((dto, separate)) = options { + if let Some(name) = separate { + let field = models + .fields(&model)? + .into_iter() + .find(|f| f.name == name) + .context("separate option argument")?; + params.push(format!("{name}: {}", input_type(&field.ty)?)); + } + let optional = operation != "StartRecording"; + params.push(format!( + "options: {}", + if optional { + format!("Option<{dto}>") + } else { + dto.into() + } + )); + if optional { + setup.push_str("let options = options.unwrap_or_default();"); + } + expression = format!( + "{}_operation(options{})", + snake(operation), + separate.map(|s| format!(", {s}")).unwrap_or_default() + ); + } else if operation == "WaitClipboard" { + // This JS overload chooses between two operations and validates a regex. + let text = models + .fields("ClipboardPattern::Text")? + .into_iter() + .next() + .context("clipboard text")?; + params.push(format!("text: Option<{}>", input_type(&text.ty)?)); + params.push("options: Option".into()); + setup.push_str("let options = options.unwrap_or_default();"); + expression = "wait_clipboard_operation(text, options)".into(); + } else { + let grouped = source == "Mouse.Click"; + if grouped { + params.push("options: Option".into()); + setup.push_str("let options = options.unwrap_or_default();"); + } + for field in models.fields(&model)? { + let value = if operation == "Key" && field.name == "action" { + format!("KeyAction::{}", nested.context("key action")?) + } else { + let (name, ty, value) = + parameter(method, &field, if grouped { "options." } else { "" })?; + if !grouped { + params.push(format!("{name}: {ty}")); + } + value + }; + assignment(&mut assignments, ident(&field.name), &value)?; + } + let value = if assignments.is_empty() { + model.clone() + } else { + format!("{model} {{ {assignments} }}") + }; + let value = if operation == "Mouse" { + format!("Operation::Mouse {{ action: {value} }}") + } else { + value + }; + write!(expression, "Ok({value})")?; + } + let trace = if method == "find_locator" { + params.push("require_one: Option".into()); + setup.push_str("let require_one = require_one.unwrap_or(false);"); + expression = format!("Ok(if require_one {{ Operation::ResolveLocator {{ {assignments} }} }} else {{ Operation::FindLocator {{ {assignments} }} }})"); + "if require_one { \"locator.location\" } else { \"locator.locations\" }".into() + } else { + format!("{trace:?}") + }; + Ok(format!( + "#[napi{}] pub async fn {method}(&self{}) -> Result<{}> {{ {setup} self.inner.call({trace}, move || {{ {expression} }}, {}).await }}\n", + if method == "type_text" { "(js_name = \"type\")" } else { "" }, + params.iter().map(|p| format!(", {p}")).collect::(), + result_type(models, result)?, result_name(result), + )) +} + +/// Lifecycle signatures come from the real runtime functions, not a parallel declaration. +fn forward(sig: &syn::Signature, receiver: bool) -> Result { + let name = sig.ident.to_string(); + let args = sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Typed(arg) => Some(match arg.pat.as_ref() { + Pat::Ident(pat) => Ok(pat.ident.to_string()), + _ => Err(anyhow::anyhow!("unsupported runtime argument")), + }), + FnArg::Receiver(_) => None, + }) + .collect::>>()? + .join(","); + let target = if receiver { + if name == "new" { + "SessionRuntime::" + } else { + "self.inner." + } + } else { + "super::" + }; + Ok(format!( + "#[napi{}] pub {} {{ {target}{name}({args}){}{} }}\n", + if name == "new" { "(constructor)" } else { "" }, + sig.to_token_stream(), + if sig.asyncness.is_some() { + ".await" + } else { + "" + }, + if name == "new" { + ".map(|inner| Self { inner })" + } else { + "" + }, + )) +} + +pub(super) fn bindings(models: &Models, root: &Path) -> Result { + coverage(models)?; + let runtime = syn::parse_file(&fs::read_to_string( + root.join("crates/tui-test-ffi/src/napi/mod.rs"), + )?)?; + let mut lifecycle = std::collections::BTreeMap::new(); + let mut globals = std::collections::BTreeMap::new(); + for item in runtime.items { + match item { + Item::Impl(item) if item.self_ty.to_token_stream().to_string() == "SessionRuntime" => { + for item in item.items { + if let ImplItem::Fn(item) = item { + if LIFECYCLE.contains(&item.sig.ident.to_string().as_str()) { + lifecycle.insert(item.sig.ident.to_string(), item.sig); + } + } + } + } + Item::Fn(item) if GLOBALS.contains(&item.sig.ident.to_string().as_str()) => { + globals.insert(item.sig.ident.to_string(), item.sig); + } + _ => {} + } + } + let mut out = format!("// {NOTICE}\nuse super::*;\nuse napi_derive::napi;\nuse tui_test::api::{{KeyAction, MouseAction}};\n#[napi] pub struct NativeSession {{ inner: SessionRuntime }}\n#[napi] impl NativeSession {{\n"); + for name in LIFECYCLE.iter().filter(|name| **name != "close") { + out.push_str(&forward( + lifecycle + .get(*name) + .with_context(|| format!("missing lifecycle method {name}"))?, + true, + )?); + } + for &(method, source, result, trace) in METHODS { + out.push_str(&operation_method(models, method, source, result, trace)?); + // Keep the published declaration order stable. + if method == "restart" { + out.push_str(&forward( + lifecycle + .get("close") + .context("missing lifecycle method close")?, + true, + )?); + } + if method == "highlight_locator" { + out.push_str("#[napi] pub async fn panic_probe(&self) -> Result<()> { blocking(|| panic!(\"intentional native panic probe\")).await }\n"); + } + } + out.push_str("}\n"); + for name in GLOBALS { + out.push_str(&forward( + globals + .get(*name) + .with_context(|| format!("missing global {name}"))?, + false, + )?); + } + out.push_str("#[napi] pub async fn close_all() -> Result<()> { blocking(super::close_all_sync).await }\n"); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) + } + + #[test] + fn operation_fields_drive_parameters_validation_and_dispatch() { + let mut models = Models::load(root()).unwrap(); + models + .parse("enum Operation { Resize { cols: u16, rows: String, new_limit: Option } }") + .unwrap(); + let output = operation_method(&models, "resize", "Resize", "Unit", "resize").unwrap(); + assert!(output.contains("cols: f64, rows: String, new_limit: Option")); + assert!(output.contains("cols: u16_value(cols, \"cols\")?")); + assert!(output.contains("rows,")); + assert!(output.contains("new_limit: new_limit.map")); + assert!(output.contains("integer(v, \"newLimit\", u64::MAX)")); + assert!(output.contains("Operation::Resize")); + models + .parse("enum Operation { Resize { unknown: Unsupported } }") + .unwrap(); + assert!(operation_method(&models, "resize", "Resize", "Unit", "resize").is_err()); + } + + #[test] + fn mouse_fields_drive_both_options_and_method_conversion() { + let mut models = Models::load(root()).unwrap(); + models + .parse("enum MouseAction { Click { value: Option } }") + .unwrap(); + let mut declarations = String::new(); + types(&models, &mut declarations).unwrap(); + let method = + operation_method(&models, "mouse_click", "Mouse.Click", "Unit", "mouse.click").unwrap(); + assert!(declarations.contains("pub value: Option")); + assert!(method.contains("value: options.value.map")); + assert!(method.contains("u16_value(v, \"value\")")); + } + + #[test] + fn result_payload_drives_method_signature_and_selection() { + let mut models = Models::load(root()).unwrap(); + let source = fs::read_to_string(root().join("crates/tui-test/src/api.rs")).unwrap(); + models + .parse(&source.replace("ExitCode(Option)", "ExitCode(Option)")) + .unwrap(); + let method = operation_method( + &models, + "get_exit_code", + "GetExitCode", + "ExitCode", + "getExitCode", + ) + .unwrap(); + assert!(method.contains("Result>")); + let mut conversions = String::new(); + results(&models, &mut conversions).unwrap(); + assert!(conversions + .contains("fn exit_code_result(value: OperationResult) -> Result>")); + assert!(conversions.contains("OperationResult::ExitCode(value) => Ok(value)")); + } + + #[test] + fn new_variants_require_an_explicit_export_decision() { + for model in ["Operation", "MouseAction", "KeyAction", "OperationResult"] { + let mut models = Models::load(root()).unwrap(); + models + .enums + .get_mut(model) + .unwrap() + .push("NewVariant".into()); + let result = if model == "OperationResult" { + results(&models, &mut String::new()) + } else { + coverage(&models) + }; + assert!( + result + .unwrap_err() + .to_string() + .contains("coverage mismatch"), + "{model}" + ); + } + } + + #[test] + fn lifecycle_wrappers_follow_runtime_signatures() { + let item: syn::ItemFn = syn::parse_str("async fn open(&self, options: Option, probe: bool) -> Result {}").unwrap(); + let wrapper = forward(&item.sig, true).unwrap(); + assert!(wrapper.contains("probe : bool")); + assert!(wrapper.contains("self.inner.open(options,probe).await")); + let item: syn::ItemFn = syn::parse_str("fn new(name: String) -> Result {}").unwrap(); + let wrapper = forward(&item.sig, true).unwrap(); + assert!(wrapper.contains("#[napi(constructor)]")); + assert!(wrapper.contains("SessionRuntime::new(name).map(|inner| Self { inner })")); + } + + #[test] + fn every_native_export_is_generated_and_matches_the_published_surface() { + check_exports(root()).unwrap(); + let models = Models::load(root()).unwrap(); + let output = bindings(&models, root()).unwrap(); + let mut methods = BTreeSet::new(); + let parsed = syn::parse_file(&output).unwrap(); + for item in parsed.items { + if let Item::Impl(item) = item { + for item in item.items { + if let ImplItem::Fn(item) = item { + assert!(item.attrs.iter().any(|a| a.path().is_ident("napi"))); + let name = item.sig.ident.to_string(); + methods.insert(match name.as_str() { + "new" => "constructor".into(), + "type_text" => "type".into(), + _ => js_key(&name), + }); + } + } + } + } + let declarations = + fs::read_to_string(root().join("bindings/js/native/index.d.ts")).unwrap(); + let published = declarations + .split("export declare class NativeSession {") + .nth(1) + .unwrap() + .split('}') + .next() + .unwrap() + .lines() + .filter_map(|line| line.trim().split_once('(').map(|(name, _)| name.to_owned())) + .collect::>(); + assert_eq!(methods, published); + for name in GLOBALS { + assert!(output.contains(&format!("fn {name}"))); + } + assert!(output.contains("fn close_all()")); + } + + #[test] + fn manual_exports_cannot_hide_in_helpers_or_nested_modules() { + let directory = super::super::tests::directory(); + let source = directory.path().join("crates/tui-test-ffi/src"); + fs::create_dir_all(source.join("napi/nested")).unwrap(); + for (path, text) in [ + ("helper.rs", "#[napi] pub fn extra() {}"), + ( + "napi/nested/types.rs", + "mod nested { #[napi(object)] pub struct Extra {} }", + ), + ( + "napi/nested/types.rs", + "macro_rules! exports { () => { # [ napi ] pub fn extra() {} } }", + ), + ( + "napi/nested/types.rs", + "#[cfg_attr(feature = \"napi\", napi)] pub fn extra() {}", + ), + ( + "napi/nested/types.rs", + "use napi_derive::napi as export; #[export] pub fn extra() {}", + ), + ] { + let path = source.join(path); + fs::write(&path, text).unwrap(); + let error = check_exports(directory.path()).unwrap_err().to_string(); + assert!(error.contains("handwritten N-API export"), "{error}"); + fs::remove_file(path).unwrap(); + } + } + + #[test] + fn trace_names_defaults_and_selectors_remain_explicit() { + let models = Models::load(root()).unwrap(); + let output = bindings(&models, root()).unwrap(); + for (_, _, _, trace) in METHODS { + assert!(output.contains(&format!("{trace:?}")), "{trace}"); + } + for expression in [ + "if require_one { \"locator.location\" } else { \"locator.locations\" }", + "Operation::ResolveLocator", + "Operation::FindLocator", + "full.unwrap_or(false)", + "w.unwrap_or(1.0)", + "h.unwrap_or(1.0)", + "options.clicks.unwrap_or(1.0)", + "button.unwrap_or(0.0)", + "amount.unwrap_or(3.0)", + "blocking(|| panic!(\"intentional native panic probe\"))", + ] { + assert!(output.contains(expression), "{expression}"); + } + } +} From 8b837033dcb503aff0f6c30078085fd419263e85 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 11:06:43 -0400 Subject: [PATCH 08/15] refactor: use Koffi for the shared JavaScript binding Signed-off-by: Ayman Bagabas --- .github/scripts/release/package-node.mjs | 62 +- .github/scripts/release/package-node.test.mjs | 36 +- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 21 +- .gitignore | 6 + Cargo.lock | 171 -- README.md | 4 +- bindings/go/CONTRIBUTING.md | 8 +- bindings/js/README.md | 17 +- bindings/js/native/abi.js | 516 +++++ bindings/js/native/adapter.js | 366 ++++ bindings/js/native/index.d.ts | 574 +++--- bindings/js/native/index.js | 635 +----- bindings/js/native/models.js | 327 +++ bindings/js/package-lock.json | 1788 ++--------------- bindings/js/package.json | 31 +- bindings/js/scripts/build-native.mjs | 56 +- bindings/js/src/native.ts | 2 +- bindings/js/test/integration.test.mjs | 4 + bindings/js/test/native.test.mjs | 233 ++- bindings/js/test/options.test.mjs | 2 +- crates/tui-test-ffi/Cargo.toml | 8 - crates/tui-test-ffi/README.md | 169 +- crates/tui-test-ffi/build.rs | 4 - crates/tui-test-ffi/include/tui_test.h | 460 +++++ crates/tui-test-ffi/src/lib.rs | 42 +- .../src/napi/bindings_generated.rs | 773 ------- crates/tui-test-ffi/src/napi/convert.rs | 116 -- .../src/napi/convert_generated.rs | 498 ----- crates/tui-test-ffi/src/napi/mod.rs | 207 -- crates/tui-test-ffi/src/napi/type_helpers.rs | 25 - crates/tui-test-ffi/src/napi/types.rs | 440 ---- crates/tui-test-ffi/src/tasks.rs | 108 + crates/tui-test-ffi/src/tasks_generated.rs | 511 +++++ crates/tui-test-ffi/src/tests.rs | 57 + tools/bindgen/src/boundaries.rs | 720 ++----- tools/bindgen/src/javascript.rs | 466 +++++ tools/bindgen/src/koffi.rs | 178 ++ tools/bindgen/src/main.rs | 2 + tools/bindgen/src/napi.rs | 812 -------- 40 files changed, 4014 insertions(+), 6445 deletions(-) create mode 100644 bindings/js/native/abi.js create mode 100644 bindings/js/native/adapter.js create mode 100644 bindings/js/native/models.js delete mode 100644 crates/tui-test-ffi/build.rs delete mode 100644 crates/tui-test-ffi/src/napi/bindings_generated.rs delete mode 100644 crates/tui-test-ffi/src/napi/convert.rs delete mode 100644 crates/tui-test-ffi/src/napi/convert_generated.rs delete mode 100644 crates/tui-test-ffi/src/napi/mod.rs delete mode 100644 crates/tui-test-ffi/src/napi/type_helpers.rs delete mode 100644 crates/tui-test-ffi/src/napi/types.rs create mode 100644 crates/tui-test-ffi/src/tasks.rs create mode 100644 crates/tui-test-ffi/src/tasks_generated.rs create mode 100644 tools/bindgen/src/javascript.rs create mode 100644 tools/bindgen/src/koffi.rs delete mode 100644 tools/bindgen/src/napi.rs diff --git a/.github/scripts/release/package-node.mjs b/.github/scripts/release/package-node.mjs index 6762e385..1a667505 100644 --- a/.github/scripts/release/package-node.mjs +++ b/.github/scripts/release/package-node.mjs @@ -6,48 +6,60 @@ const bindingsDirectory = path.resolve("bindings/js"); const nativePackagesDirectory = path.join(bindingsDirectory, "npm"); const outputDirectory = path.resolve("package-artifacts/npm"); const packagePath = path.join(bindingsDirectory, "package.json"); +const originalPackage = fs.readFileSync(packagePath, "utf8"); +const rootPackage = JSON.parse(originalPackage); +rootPackage.optionalDependencies = {}; const runNpm = (args) => execFileSync("npm", args, { stdio: "inherit" }); -const nativePackages = fs - .readdirSync(nativePackagesDirectory, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(nativePackagesDirectory, entry.name)) - .sort(); +const targets = Object.values(rootPackage.nativeTargets).sort(); -if (nativePackages.length === 0) { - throw new Error(`No native packages found in ${nativePackagesDirectory}`); +if (targets.length === 0) { + throw new Error(`No native targets configured in ${packagePath}`); } fs.mkdirSync(outputDirectory, { recursive: true }); -for (const nativePackage of nativePackages) { - const hasNativeAddon = fs - .readdirSync(nativePackage, { withFileTypes: true }) - .some((entry) => entry.isFile() && entry.name.endsWith(".node")); - if (!hasNativeAddon) { - throw new Error(`Missing native addon in ${nativePackage}`); +for (const tag of targets) { + const [platform, arch, abi] = tag.split("-"); + const extension = { darwin: "dylib", linux: "so", win32: "dll" }[platform]; + if (!extension) throw new Error(`Unsupported native platform: ${platform}`); + const filename = `tui-test.${tag}.${extension}`; + const nativePackage = path.join(nativePackagesDirectory, tag); + const manifestPath = path.join(nativePackage, "package.json"); + const manifest = { + name: `${rootPackage.name}-${tag}`, + version: rootPackage.version, + description: rootPackage.description, + main: filename, + files: [filename], + os: [platform], + cpu: [arch], + ...(platform === "linux" ? { libc: [abi === "gnu" ? "glibc" : abi] } : {}), + license: rootPackage.license, + author: rootPackage.author, + repository: rootPackage.repository, + engines: rootPackage.engines, + publishConfig: rootPackage.publishConfig, + }; + const artifact = path.join(bindingsDirectory, "artifacts", filename); + if (!fs.existsSync(artifact)) { + throw new Error(`Missing shared library for ${manifest.name}: ${artifact}`); } - + fs.mkdirSync(nativePackage, { recursive: true }); + fs.copyFileSync(artifact, path.join(nativePackage, filename)); + rootPackage.optionalDependencies[manifest.name] = rootPackage.version; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + fs.writeFileSync(path.join(nativePackage, "README.md"), `# ${manifest.name}\n\nThis package contains the tui-test shared library for ${tag}.\n`); runNpm([ "pack", nativePackage, + "--ignore-scripts", "--pack-destination", outputDirectory, ]); } -const originalPackage = fs.readFileSync(packagePath, "utf8"); -const rootPackage = JSON.parse(originalPackage); -rootPackage.optionalDependencies = {}; - -for (const nativePackage of nativePackages) { - const manifest = JSON.parse( - fs.readFileSync(path.join(nativePackage, "package.json"), "utf8"), - ); - rootPackage.optionalDependencies[manifest.name] = manifest.version; -} - try { fs.writeFileSync( packagePath, diff --git a/.github/scripts/release/package-node.test.mjs b/.github/scripts/release/package-node.test.mjs index 0bc3002e..38d5c093 100644 --- a/.github/scripts/release/package-node.test.mjs +++ b/.github/scripts/release/package-node.test.mjs @@ -6,7 +6,9 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; const script = fileURLToPath(new URL("./package-node.mjs", import.meta.url)); -const targets = ["darwin-arm64", "linux-x64-gnu", "win32-x64-msvc"]; +const { nativeTargets } = JSON.parse(fs.readFileSync(new URL("../../../bindings/js/package.json", import.meta.url), "utf8")); +const targets = Object.values(nativeTargets); +const library = (platform) => `tui-test.${platform}.${platform.startsWith("darwin") ? "dylib" : platform.startsWith("win32") ? "dll" : "so"}`; function fixture(t) { const target = path.resolve("target"); @@ -19,22 +21,17 @@ function fixture(t) { name: "@fixture/tui-test", version: "1.0.0", files: ["index.js"], + dependencies: { koffi: "^3.3.3" }, + nativeTargets, })); fs.writeFileSync(path.join(bindings, "index.js"), "export {};\n"); for (const platform of targets) { - const pkg = path.join(bindings, "npm", platform); - fs.mkdirSync(pkg, { recursive: true }); - fs.writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ - name: `@fixture/tui-test-${platform}`, - version: "1.0.0", - files: platform === "linux-x64-gnu" ? undefined : ["*.node"], - })); - fs.writeFileSync(path.join(pkg, `tui-test.${platform}.node`), "addon fixture"); + fs.writeFileSync(path.join(bindings, "artifacts", library(platform)), "shared library fixture"); } return directory; } -test("platform packages need only the self-contained addon", (t) => { +test("platform packages include the shared library at the release version", (t) => { const directory = fixture(t); const packagePath = path.join(directory, "bindings/js/package.json"); const original = fs.readFileSync(packagePath, "utf8"); @@ -43,16 +40,25 @@ test("platform packages need only the self-contained addon", (t) => { for (const platform of targets) { const archive = path.join(directory, "package-artifacts/npm", `fixture-tui-test-${platform}-1.0.0.tgz`); const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" }).split("\n"); - assert.ok(entries.includes(`package/tui-test.${platform}.node`)); - assert.ok(!entries.some((entry) => /\.(so|dylib|dll)$/.test(entry))); + assert.ok(entries.includes(`package/${library(platform)}`)); + assert.ok(!entries.some((entry) => entry.endsWith(".node"))); + const manifest = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/package.json"], { encoding: "utf8" })); + assert.equal(manifest.version, "1.0.0"); + assert.deepEqual(manifest.os, [platform.split("-")[0]]); + assert.deepEqual(manifest.cpu, [platform.split("-")[1]]); + if (platform.startsWith("linux")) assert.deepEqual(manifest.libc, [platform.endsWith("gnu") ? "glibc" : "musl"]); } + const archive = path.join(directory, "package-artifacts/npm/fixture-tui-test-1.0.0.tgz"); + const manifest = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/package.json"], { encoding: "utf8" })); + assert.equal(manifest.dependencies.koffi, "^3.3.3"); + assert.deepEqual(manifest.optionalDependencies, Object.fromEntries(targets.map((platform) => [`@fixture/tui-test-${platform}`, "1.0.0"]))); }); -test("packaging rejects a platform package without its addon", (t) => { +test("packaging rejects a missing shared library artifact", (t) => { const directory = fixture(t); - fs.unlinkSync(path.join(directory, "bindings/js/npm/darwin-arm64/tui-test.darwin-arm64.node")); + fs.unlinkSync(path.join(directory, "bindings/js/artifacts", library("darwin-arm64"))); assert.throws( () => execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }), - /Missing native addon/, + /Missing shared library/, ); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 994b330f..ecc40931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,10 +111,10 @@ jobs: - name: Test Node binding run: npm run test:node --prefix bindings/js - - name: Verify generated Node loader + - name: Verify generated Node declarations shell: bash run: | - git diff --exit-code -- bindings/js/native/index.js bindings/js/native/index.d.ts + cargo run --locked -p tui-test-bindgen -- --check ! grep -Eq 'JsonValue|Promise|\brequest\(|\bBuffer\b' bindings/js/native/index.d.ts - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59a61ffd..50fbf13d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,6 @@ jobs: bindings/js/src/version.ts bindings/python/pyproject.toml bindings/python/src/tui_test/_config.py - bindings/js/native/index.js ) diff -u \ <(printf '%s\n' "${version_files[@]}" | sort) \ @@ -144,8 +143,8 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - args: --target x86_64-unknown-linux-gnu --use-napi-cross + os: ubuntu-22.04 + args: --target x86_64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu os: ubuntu-latest args: --target aarch64-unknown-linux-gnu -x @@ -197,7 +196,7 @@ jobs: - run: npm ci working-directory: bindings/js - - name: Build Node addon + - name: Build Node shared library working-directory: bindings/js shell: bash run: npm run build:native -- ${{ matrix.args }} @@ -205,7 +204,10 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: node-${{ matrix.target }} - path: bindings/js/native/*.node + path: | + bindings/js/native/*.so + bindings/js/native/*.dylib + bindings/js/native/*.dll if-no-files-found: error package-node: @@ -225,9 +227,7 @@ jobs: - run: npm ci working-directory: bindings/js - - run: | - npm run build:ts - npm run create:npm-dirs + - run: npm run build:ts working-directory: bindings/js - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -236,9 +236,6 @@ jobs: path: bindings/js/artifacts merge-multiple: true - - run: npm run artifacts - working-directory: bindings/js - - name: Pack Node packages run: node .github/scripts/release/package-node.mjs @@ -256,7 +253,7 @@ jobs: npm init -y >/dev/null npm install --ignore-scripts \ "$GITHUB_WORKSPACE/package-artifacts/npm/microsoft-tui-test-linux-x64-gnu-${version}.tgz" - npm install --ignore-scripts --omit=optional \ + npm install --ignore-scripts \ "$GITHUB_WORKSPACE/package-artifacts/npm/microsoft-tui-test-${version}.tgz" cd "$GITHUB_WORKSPACE" PATH="/usr/bin:/bin" \ diff --git a/.gitignore b/.gitignore index b5864dd8..010170a4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ *.trace __pycache__/ *.node +bindings/js/native/*.so +bindings/js/native/*.dylib +bindings/js/native/*.dll +bindings/js/npm/*/*.so +bindings/js/npm/*/*.dylib +bindings/js/npm/*/*.dll .tui-test/ bindings/js/target bindings/js/native/target diff --git a/Cargo.lock b/Cargo.lock index 2d149a70..63677ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,15 +321,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "convert_case" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "corcovado" version = "0.5.5" @@ -417,12 +408,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctor" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" - [[package]] name = "cursor-icon" version = "1.2.0" @@ -601,71 +586,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - [[package]] name = "futures-task" version = "0.3.33" @@ -678,13 +610,8 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -961,16 +888,6 @@ name = "libghostty-vt-sys" version = "0.2.1" source = "git+https://github.com/Uzaaft/libghostty-rs.git?rev=a28e4ad00d6ba79b98b8cac651ed8976d8500903#a28e4ad00d6ba79b98b8cac651ed8976d8500903" -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if 1.0.4", - "windows-link", -] - [[package]] name = "libm" version = "0.2.16" @@ -1065,64 +982,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "napi" -version = "3.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f71d6bc097c4a6eb853c3f24991ab8c9f50f57d1f719e305175541482217e36" -dependencies = [ - "bitflags 2.13.1", - "ctor", - "futures", - "napi-build", - "napi-sys", - "nohash-hasher", - "rustc-hash", - "tokio", -] - -[[package]] -name = "napi-build" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c" - -[[package]] -name = "napi-derive" -version = "3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9002b2940f0184444754546e0fcd15182f56948e6f381968b019d549387c42" -dependencies = [ - "convert_case", - "ctor", - "napi-derive-backend", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "napi-derive-backend" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "semver", - "syn 2.0.118", -] - -[[package]] -name = "napi-sys" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" -dependencies = [ - "libloading", -] - [[package]] name = "nix" version = "0.28.0" @@ -1135,12 +994,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - [[package]] name = "once_cell" version = "1.21.4" @@ -1517,12 +1370,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -1890,15 +1737,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2012,9 +1850,6 @@ dependencies = [ name = "tui-test-ffi" version = "0.1.0" dependencies = [ - "napi", - "napi-build", - "napi-derive", "serde", "serde_json", "tui-test-rs", @@ -2068,12 +1903,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - [[package]] name = "unicode-width" version = "0.2.2" diff --git a/README.md b/README.md index 361fdabc..8f7f2a33 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ You can also download a binary from [GitHub Releases](https://github.com/microso Add the Rust `recording-raster` feature for APNG, GIF, and MP4 output. It uses installed fonts; `recording-font-jetbrains-mono*` bundles a font. Go, Python, and JavaScript use one [native adapter crate](crates/tui-test-ffi/README.md). -Go and Python use its generated C ABI declarations. JavaScript uses its optional N-API feature in a self-contained addon. -Set `TUI_TEST_NATIVE_LIBRARY` to select a matching library explicitly for Go or Python. +All three use its generated C ABI declarations through purego, ctypes, and Koffi, respectively. +Set `TUI_TEST_NATIVE_LIBRARY` to select a matching library explicitly. ## Quick start diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index f5395bee..370f6eb3 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -9,8 +9,8 @@ The public `tuitest` package at the module root owns clients, options, results, 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 shared Rust adapter lives in `../../crates/tui-test-ffi/` and delegates to the existing session registry and operations. -Go and Python use the same library. JavaScript uses the crate's optional N-API build. -Source builds select the Go/Python library path through `TUI_TEST_NATIVE_LIBRARY`. +Go, Python, and JavaScript use the same library through purego, ctypes, and Koffi. +Source builds select the library path through `TUI_TEST_NATIVE_LIBRARY`. The library stays loaded for the process lifetime. Go uses pointer entrypoints for open, run, and screenshot options. @@ -19,7 +19,7 @@ The screenshot pointer avoids a PureGo aggregate-argument failure on Linux AMD64 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 an internal contract shared by Go and Python, not a stable public C SDK. +The C ABI is an internal contract shared by all three bindings, not a stable public C SDK. Native allocations must be released by their matching Rust functions, and native code must not retain Go pointers. `internal/native/abi.go` and `internal/native/functions.go` are generated from the Rust ABI declarations. @@ -31,7 +31,7 @@ Do not edit these files directly. From the repository root, regenerate them with cargo run --locked -p tui-test-bindgen ``` -The same command updates the C header and Python declarations. +The same command updates the C header, Python declarations, and JavaScript declarations. Use `cargo run --locked -p tui-test-bindgen -- --check` to check for stale output. ## Build from source diff --git a/bindings/js/README.md b/bindings/js/README.md index a3d61d0a..5d46cc92 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -10,17 +10,18 @@ npm install @microsoft/tui-test Node 20+ is supported. The package is ESM only. Bun and Deno support is best effort. Deno 2 needs local `node_modules` and `--allow-ffi`. -Each platform package contains one self-contained `.node` addon. -The addon uses the optional `napi` feature in `tui-test-ffi` and calls Rust operations directly. -It shares safe execution, context validation, and close logic with the C ABI adapter. -It converts owned Rust results into JavaScript values without C result pointers. -It needs no companion shared library or CLI. -`TUI_TEST_NATIVE_LIBRARY` applies only to Go and Python; JavaScript ignores it. +Each platform package contains the `tui-test-ffi` shared library. +The JavaScript adapter uses Koffi to call the same typed C ABI as Go and Python. +It copies native results before it frees their Rust allocations. +Koffi supplies its own native addon; this package no longer builds a separate N-API adapter. +Keep optional dependencies enabled during installation; they contain the platform binaries for both packages. +The package needs no CLI. +Set `TUI_TEST_NATIVE_LIBRARY` to an absolute path to select a library from the same source revision. For a local build, run `npm ci`, then `npm run build`. Use `npm run build:native:debug` for a debug build. -These commands build `tui-test-ffi` with `--features napi` and put the addon in `native/`. -They also regenerate the N-API loader and declarations. +These commands build `tui-test-ffi` and put the shared library in `native/`. +Run `cargo run --locked -p tui-test-bindgen` from the repository root to regenerate the native declarations. ## Quick start diff --git a/bindings/js/native/abi.js b/bindings/js/native/abi.js new file mode 100644 index 00000000..5c5d469e --- /dev/null +++ b/bindings/js/native/abi.js @@ -0,0 +1,516 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +import koffi from 'koffi'; +export const abiVersion = 3; +export const types = {}; +types.TuiString = koffi.struct("TuiString", { +data: "void *", +len: "size_t", +}); +types.TuiOptionalU64 = koffi.struct("TuiOptionalU64", { +present: "bool", +value: "uint64_t", +}); +types.TuiOptionalI32 = koffi.struct("TuiOptionalI32", { +present: "bool", +value: "int32_t", +}); +types.TuiOptionalBool = koffi.struct("TuiOptionalBool", { +present: "bool", +value: "bool", +}); +types.TuiOptionalF64 = koffi.struct("TuiOptionalF64", { +present: "bool", +value: "double", +}); +types.TuiPair = koffi.struct("TuiPair", { +key: "TuiString", +value: "TuiString", +}); +types.TuiQuery = koffi.struct("TuiQuery", { +json: "TuiString", +}); +types.TuiMode = koffi.struct("TuiMode", { +name: "TuiString", +enabled: "bool", +}); +types.TuiColor = koffi.struct("TuiColor", { +kind: "uint32_t", +index: "uint8_t", +red: "uint8_t", +green: "uint8_t", +blue: "uint8_t", +}); +types.TuiOpenResult = koffi.struct("TuiOpenResult", { +shell_pid: "TuiOptionalU64", +session: "TuiString", +ready: "bool", +recording: "TuiString", +}); +types.TuiCursor = koffi.struct("TuiCursor", { +x: "uint16_t", +y: "uint16_t", +visible: "bool", +shape: "TuiString", +color: "TuiString", +}); +types.TuiSize = koffi.struct("TuiSize", { +cols: "uint16_t", +rows: "uint16_t", +}); +types.TuiBellEvent = koffi.struct("TuiBellEvent", { +sequence: "uint64_t", +elapsed_ms: "uint64_t", +}); +types.TuiTerminalColors = koffi.struct("TuiTerminalColors", { +foreground: "TuiString", +background: "TuiString", +cursor: "TuiString", +palette: "void *", +palette_len: "size_t", +}); +types.TuiCell = koffi.struct("TuiCell", { +x: "uint16_t", +y: "uint16_t", +character: "TuiString", +fg: "TuiColor", +bg: "TuiColor", +bold: "bool", +dim: "bool", +italic: "bool", +inverse: "bool", +invisible: "bool", +strike: "bool", +blink: "bool", +underline: "bool", +underline_style: "TuiString", +underline_color: "TuiColor", +link: "TuiString", +link_id: "TuiString", +}); +types.TuiPosition = koffi.struct("TuiPosition", { +row: "uint32_t", +column: "uint16_t", +}); +types.TuiSpan = koffi.struct("TuiSpan", { +row: "uint32_t", +start: "uint16_t", +end: "uint16_t", +}); +types.TuiMatch = koffi.struct("TuiMatch", { +text: "TuiString", +start: "TuiPosition", +end: "TuiPosition", +spans: "void *", +spans_len: "size_t", +}); +types.TuiTimeouts = koffi.struct("TuiTimeouts", { +text: "TuiOptionalU64", +idle: "TuiOptionalU64", +command: "TuiOptionalU64", +exit: "TuiOptionalU64", +ready: "TuiOptionalU64", +}); +types.TuiOpenOptions = koffi.struct("TuiOpenOptions", { +backend: "TuiString", +shell: "TuiString", +cols: "TuiOptionalU64", +rows: "TuiOptionalU64", +cwd: "TuiString", +env: "void *", +env_len: "size_t", +wait_ready: "TuiOptionalBool", +restart: "bool", +scrollback: "TuiOptionalU64", +colors: "void *", +colors_len: "size_t", +timeouts: "TuiTimeouts", +recording_mode: "TuiString", +recording_directory: "TuiString", +}); +types.TuiMouseOptions = koffi.struct("TuiMouseOptions", { +button: "uint32_t", +alt: "bool", +ctrl: "bool", +shift: "bool", +}); +types.TuiWaitOptions = koffi.struct("TuiWaitOptions", { +timeout_ms: "TuiOptionalU64", +regex: "bool", +not: "bool", +}); +types.TuiRecordingOptions = koffi.struct("TuiRecordingOptions", { +path: "TuiString", +format: "TuiString", +fps: "TuiOptionalU64", +speed: "TuiOptionalF64", +idle_time_limit: "TuiOptionalF64", +zoom: "TuiOptionalF64", +background: "TuiString", +transparent: "bool", +}); +types.TuiScreenshotOptions = koffi.struct("TuiScreenshotOptions", { +full: "bool", +path: "TuiString", +zoom: "TuiOptionalF64", +background: "TuiString", +transparent: "bool", +}); +types.TuiState = koffi.struct("TuiState", { +session_shell: "TuiString", +cols: "uint16_t", +rows: "uint16_t", +cursor: "TuiCursor", +title: "TuiString", +cwd: "TuiString", +last_command: "TuiString", +last_exit: "TuiOptionalI32", +exit_signal: "TuiString", +exited: "TuiOptionalI32", +ready: "bool", +bell_count: "uint64_t", +modes: "void *", +modes_len: "size_t", +mouse_mode: "TuiString", +colors: "TuiTerminalColors", +timeouts: "TuiTimeouts", +text: "TuiString", +}); +types.TuiResult = koffi.struct("TuiResult", { +error_kind: "uint32_t", +error_message: "TuiString", +error_details: "TuiString", +error_artifact: "TuiString", +text: "TuiString", +number: "uint64_t", +exit_code: "TuiOptionalI32", +open: "TuiOpenResult", +state: "TuiState", +cursor: "TuiCursor", +size: "TuiSize", +cells: "void *", +cells_len: "size_t", +matches: "void *", +matches_len: "size_t", +bells: "void *", +bells_len: "size_t", +strings: "void *", +strings_len: "size_t", +snapshot: "uint32_t", +private_data: "void *", +}); +export const layouts = { +TuiString: { +data: "*u8", +len: "usize", +}, +TuiOptionalU64: { +present: "bool", +value: "u64", +}, +TuiOptionalI32: { +present: "bool", +value: "i32", +}, +TuiOptionalBool: { +present: "bool", +value: "bool", +}, +TuiOptionalF64: { +present: "bool", +value: "f64", +}, +TuiPair: { +key: "TuiString", +value: "TuiString", +}, +TuiQuery: { +json: "TuiString", +}, +TuiMode: { +name: "TuiString", +enabled: "bool", +}, +TuiColor: { +kind: "u32", +index: "u8", +red: "u8", +green: "u8", +blue: "u8", +}, +TuiOpenResult: { +shell_pid: "TuiOptionalU64", +session: "TuiString", +ready: "bool", +recording: "TuiString", +}, +TuiCursor: { +x: "u16", +y: "u16", +visible: "bool", +shape: "TuiString", +color: "TuiString", +}, +TuiSize: { +cols: "u16", +rows: "u16", +}, +TuiBellEvent: { +sequence: "u64", +elapsed_ms: "u64", +}, +TuiTerminalColors: { +foreground: "TuiString", +background: "TuiString", +cursor: "TuiString", +palette: "*TuiPair", +palette_len: "usize", +}, +TuiCell: { +x: "u16", +y: "u16", +character: "TuiString", +fg: "TuiColor", +bg: "TuiColor", +bold: "bool", +dim: "bool", +italic: "bool", +inverse: "bool", +invisible: "bool", +strike: "bool", +blink: "bool", +underline: "bool", +underline_style: "TuiString", +underline_color: "TuiColor", +link: "TuiString", +link_id: "TuiString", +}, +TuiPosition: { +row: "u32", +column: "u16", +}, +TuiSpan: { +row: "u32", +start: "u16", +end: "u16", +}, +TuiMatch: { +text: "TuiString", +start: "TuiPosition", +end: "TuiPosition", +spans: "*TuiSpan", +spans_len: "usize", +}, +TuiTimeouts: { +text: "TuiOptionalU64", +idle: "TuiOptionalU64", +command: "TuiOptionalU64", +exit: "TuiOptionalU64", +ready: "TuiOptionalU64", +}, +TuiOpenOptions: { +backend: "TuiString", +shell: "TuiString", +cols: "TuiOptionalU64", +rows: "TuiOptionalU64", +cwd: "TuiString", +env: "*TuiPair", +env_len: "usize", +wait_ready: "TuiOptionalBool", +restart: "bool", +scrollback: "TuiOptionalU64", +colors: "*TuiPair", +colors_len: "usize", +timeouts: "TuiTimeouts", +recording_mode: "TuiString", +recording_directory: "TuiString", +}, +TuiMouseOptions: { +button: "u32", +alt: "bool", +ctrl: "bool", +shift: "bool", +}, +TuiWaitOptions: { +timeout_ms: "TuiOptionalU64", +regex: "bool", +not: "bool", +}, +TuiRecordingOptions: { +path: "TuiString", +format: "TuiString", +fps: "TuiOptionalU64", +speed: "TuiOptionalF64", +idle_time_limit: "TuiOptionalF64", +zoom: "TuiOptionalF64", +background: "TuiString", +transparent: "bool", +}, +TuiScreenshotOptions: { +full: "bool", +path: "TuiString", +zoom: "TuiOptionalF64", +background: "TuiString", +transparent: "bool", +}, +TuiState: { +session_shell: "TuiString", +cols: "u16", +rows: "u16", +cursor: "TuiCursor", +title: "TuiString", +cwd: "TuiString", +last_command: "TuiString", +last_exit: "TuiOptionalI32", +exit_signal: "TuiString", +exited: "TuiOptionalI32", +ready: "bool", +bell_count: "u64", +modes: "*TuiMode", +modes_len: "usize", +mouse_mode: "TuiString", +colors: "TuiTerminalColors", +timeouts: "TuiTimeouts", +text: "TuiString", +}, +TuiResult: { +error_kind: "u32", +error_message: "TuiString", +error_details: "TuiString", +error_artifact: "TuiString", +text: "TuiString", +number: "u64", +exit_code: "TuiOptionalI32", +open: "TuiOpenResult", +state: "TuiState", +cursor: "TuiCursor", +size: "TuiSize", +cells: "*TuiCell", +cells_len: "usize", +matches: "*TuiMatch", +matches_len: "usize", +bells: "*TuiBellEvent", +bells_len: "usize", +strings: "*TuiString", +strings_len: "usize", +snapshot: "u32", +private_data: "*c_void", +}, +}; +export function bind(library) { +const functions = {}; +functions.tui_abi_version = library.func("tui_abi_version", "uint32_t", []); +functions.tui_result_free = library.func("tui_result_free", "void", ["void *"]); +functions.tui_open = library.func("tui_open", "void *", ["TuiString", "TuiOpenOptions"]); +functions.tui_open_start = library.func("tui_open_start", 'void *', ["TuiString", "TuiOpenOptions"]); +functions.tui_open_ptr = library.func("tui_open_ptr", "void *", ["TuiString", "void *"]); +functions.tui_run = library.func("tui_run", "void *", ["TuiString", "TuiOpenOptions", "TuiString", "void *", "size_t"]); +functions.tui_run_start = library.func("tui_run_start", 'void *', ["TuiString", "TuiOpenOptions", "TuiString", "void *", "size_t"]); +functions.tui_run_ptr = library.func("tui_run_ptr", "void *", ["TuiString", "void *", "TuiString", "void *", "size_t"]); +functions.tui_sessions = library.func("tui_sessions", "void *", []); +functions.tui_sessions_start = library.func("tui_sessions_start", 'void *', []); +functions.tui_close_all = library.func("tui_close_all", "void *", []); +functions.tui_close_all_start = library.func("tui_close_all_start", 'void *', []); +functions.tui_recording = library.func("tui_recording", "void *", ["TuiString"]); +functions.tui_recording_start = library.func("tui_recording_start", 'void *', ["TuiString"]); +functions.tui_close = library.func("tui_close", "void *", ["TuiString", "TuiOptionalBool"]); +functions.tui_close_start = library.func("tui_close_start", 'void *', ["TuiString", "TuiOptionalBool"]); +functions.tui_restart = library.func("tui_restart", "void *", ["TuiString", "uint64_t"]); +functions.tui_restart_start = library.func("tui_restart_start", 'void *', ["TuiString", "uint64_t"]); +functions.tui_state = library.func("tui_state", "void *", ["TuiString"]); +functions.tui_state_start = library.func("tui_state_start", 'void *', ["TuiString"]); +functions.tui_get_command = library.func("tui_get_command", "void *", ["TuiString"]); +functions.tui_get_command_start = library.func("tui_get_command_start", 'void *', ["TuiString"]); +functions.tui_get_output = library.func("tui_get_output", "void *", ["TuiString"]); +functions.tui_get_output_start = library.func("tui_get_output_start", 'void *', ["TuiString"]); +functions.tui_get_exit_code = library.func("tui_get_exit_code", "void *", ["TuiString"]); +functions.tui_get_exit_code_start = library.func("tui_get_exit_code_start", 'void *', ["TuiString"]); +functions.tui_get_cwd = library.func("tui_get_cwd", "void *", ["TuiString"]); +functions.tui_get_cwd_start = library.func("tui_get_cwd_start", 'void *', ["TuiString"]); +functions.tui_get_cursor = library.func("tui_get_cursor", "void *", ["TuiString"]); +functions.tui_get_cursor_start = library.func("tui_get_cursor_start", 'void *', ["TuiString"]); +functions.tui_get_size = library.func("tui_get_size", "void *", ["TuiString"]); +functions.tui_get_size_start = library.func("tui_get_size_start", 'void *', ["TuiString"]); +functions.tui_get_title = library.func("tui_get_title", "void *", ["TuiString"]); +functions.tui_get_title_start = library.func("tui_get_title_start", 'void *', ["TuiString"]); +functions.tui_get_clipboard = library.func("tui_get_clipboard", "void *", ["TuiString"]); +functions.tui_get_clipboard_start = library.func("tui_get_clipboard_start", 'void *', ["TuiString"]); +functions.tui_get_bell_count = library.func("tui_get_bell_count", "void *", ["TuiString"]); +functions.tui_get_bell_count_start = library.func("tui_get_bell_count_start", 'void *', ["TuiString"]); +functions.tui_get_bell_events = library.func("tui_get_bell_events", "void *", ["TuiString"]); +functions.tui_get_bell_events_start = library.func("tui_get_bell_events_start", 'void *', ["TuiString"]); +functions.tui_stop_recording = library.func("tui_stop_recording", "void *", ["TuiString"]); +functions.tui_stop_recording_start = library.func("tui_stop_recording_start", 'void *', ["TuiString"]); +functions.tui_text = library.func("tui_text", "void *", ["TuiString", "bool"]); +functions.tui_text_start = library.func("tui_text_start", 'void *', ["TuiString", "bool"]); +functions.tui_packed_screen = library.func("tui_packed_screen", "void *", ["TuiString", "bool"]); +functions.tui_packed_screen_start = library.func("tui_packed_screen_start", 'void *', ["TuiString", "bool"]); +functions.tui_cells = library.func("tui_cells", "void *", ["TuiString", "uint16_t", "uint16_t", "uint16_t", "uint16_t"]); +functions.tui_cells_start = library.func("tui_cells_start", 'void *', ["TuiString", "uint16_t", "uint16_t", "uint16_t", "uint16_t"]); +functions.tui_write = library.func("tui_write", "void *", ["TuiString", "TuiString"]); +functions.tui_write_start = library.func("tui_write_start", 'void *', ["TuiString", "TuiString"]); +functions.tui_type = library.func("tui_type", "void *", ["TuiString", "TuiString"]); +functions.tui_type_start = library.func("tui_type_start", 'void *', ["TuiString", "TuiString"]); +functions.tui_submit = library.func("tui_submit", "void *", ["TuiString", "TuiString"]); +functions.tui_submit_start = library.func("tui_submit_start", 'void *', ["TuiString", "TuiString"]); +functions.tui_signal = library.func("tui_signal", "void *", ["TuiString", "TuiString"]); +functions.tui_signal_start = library.func("tui_signal_start", 'void *', ["TuiString", "TuiString"]); +functions.tui_key = library.func("tui_key", "void *", ["TuiString", "void *", "size_t", "uint32_t"]); +functions.tui_key_start = library.func("tui_key_start", 'void *', ["TuiString", "void *", "size_t", "uint32_t"]); +functions.tui_resize = library.func("tui_resize", "void *", ["TuiString", "uint16_t", "uint16_t"]); +functions.tui_resize_start = library.func("tui_resize_start", 'void *', ["TuiString", "uint16_t", "uint16_t"]); +functions.tui_mouse_click = library.func("tui_mouse_click", "void *", ["TuiString", "TuiOptionalU64", "TuiOptionalU64", "TuiString", "TuiMouseOptions", "uint8_t"]); +functions.tui_mouse_click_start = library.func("tui_mouse_click_start", 'void *', ["TuiString", "TuiOptionalU64", "TuiOptionalU64", "TuiString", "TuiMouseOptions", "uint8_t"]); +functions.tui_mouse_move = library.func("tui_mouse_move", "void *", ["TuiString", "uint16_t", "uint16_t"]); +functions.tui_mouse_move_start = library.func("tui_mouse_move_start", 'void *', ["TuiString", "uint16_t", "uint16_t"]); +functions.tui_mouse_down = library.func("tui_mouse_down", "void *", ["TuiString", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_down_start = library.func("tui_mouse_down_start", 'void *', ["TuiString", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_up = library.func("tui_mouse_up", "void *", ["TuiString", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_up_start = library.func("tui_mouse_up_start", 'void *', ["TuiString", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_drag = library.func("tui_mouse_drag", "void *", ["TuiString", "uint16_t", "uint16_t", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_drag_start = library.func("tui_mouse_drag_start", 'void *', ["TuiString", "uint16_t", "uint16_t", "uint16_t", "uint16_t", "TuiMouseOptions"]); +functions.tui_mouse_scroll = library.func("tui_mouse_scroll", "void *", ["TuiString", "TuiString", "uint16_t"]); +functions.tui_mouse_scroll_start = library.func("tui_mouse_scroll_start", 'void *', ["TuiString", "TuiString", "uint16_t"]); +functions.tui_wait_title = library.func("tui_wait_title", "void *", ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_wait_title_start = library.func("tui_wait_title_start", 'void *', ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_expect_title = library.func("tui_expect_title", "void *", ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_expect_title_start = library.func("tui_expect_title_start", 'void *', ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_wait_clipboard = library.func("tui_wait_clipboard", "void *", ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_wait_clipboard_start = library.func("tui_wait_clipboard_start", 'void *', ["TuiString", "TuiString", "TuiWaitOptions"]); +functions.tui_wait_idle = library.func("tui_wait_idle", "void *", ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_idle_start = library.func("tui_wait_idle_start", 'void *', ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_command = library.func("tui_wait_command", "void *", ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_command_start = library.func("tui_wait_command_start", 'void *', ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_exit = library.func("tui_wait_exit", "void *", ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_exit_start = library.func("tui_wait_exit_start", 'void *', ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_ready = library.func("tui_wait_ready", "void *", ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_ready_start = library.func("tui_wait_ready_start", 'void *', ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_bell = library.func("tui_wait_bell", "void *", ["TuiString", "TuiOptionalU64"]); +functions.tui_wait_bell_start = library.func("tui_wait_bell_start", 'void *', ["TuiString", "TuiOptionalU64"]); +functions.tui_find_locator = library.func("tui_find_locator", "void *", ["TuiString", "TuiQuery", "bool"]); +functions.tui_find_locator_start = library.func("tui_find_locator_start", 'void *', ["TuiString", "TuiQuery", "bool"]); +functions.tui_wait_locator = library.func("tui_wait_locator", "void *", ["TuiString", "TuiQuery", "TuiWaitOptions"]); +functions.tui_wait_locator_start = library.func("tui_wait_locator_start", 'void *', ["TuiString", "TuiQuery", "TuiWaitOptions"]); +functions.tui_expect_locator = library.func("tui_expect_locator", "void *", ["TuiString", "TuiQuery", "TuiWaitOptions"]); +functions.tui_expect_locator_start = library.func("tui_expect_locator_start", 'void *', ["TuiString", "TuiQuery", "TuiWaitOptions"]); +functions.tui_click_locator = library.func("tui_click_locator", "void *", ["TuiString", "TuiQuery", "TuiMouseOptions", "uint8_t", "TuiOptionalU64"]); +functions.tui_click_locator_start = library.func("tui_click_locator_start", 'void *', ["TuiString", "TuiQuery", "TuiMouseOptions", "uint8_t", "TuiOptionalU64"]); +functions.tui_highlight_locator = library.func("tui_highlight_locator", "void *", ["TuiString", "TuiQuery", "TuiOptionalU64"]); +functions.tui_highlight_locator_start = library.func("tui_highlight_locator_start", 'void *', ["TuiString", "TuiQuery", "TuiOptionalU64"]); +functions.tui_expect_exit_code = library.func("tui_expect_exit_code", "void *", ["TuiString", "int32_t", "TuiOptionalU64"]); +functions.tui_expect_exit_code_start = library.func("tui_expect_exit_code_start", 'void *', ["TuiString", "int32_t", "TuiOptionalU64"]); +functions.tui_expect_output = library.func("tui_expect_output", "void *", ["TuiString", "TuiString", "bool"]); +functions.tui_expect_output_start = library.func("tui_expect_output_start", 'void *', ["TuiString", "TuiString", "bool"]); +functions.tui_expect_bell_count = library.func("tui_expect_bell_count", "void *", ["TuiString", "uint64_t", "TuiOptionalU64"]); +functions.tui_expect_bell_count_start = library.func("tui_expect_bell_count_start", 'void *', ["TuiString", "uint64_t", "TuiOptionalU64"]); +functions.tui_snapshot = library.func("tui_snapshot", "void *", ["TuiString", "TuiString", "bool", "bool", "bool", "TuiString"]); +functions.tui_snapshot_start = library.func("tui_snapshot_start", 'void *', ["TuiString", "TuiString", "bool", "bool", "bool", "TuiString"]); +functions.tui_screenshot = library.func("tui_screenshot", "void *", ["TuiString", "bool", "TuiString", "TuiOptionalF64", "TuiString", "bool"]); +functions.tui_screenshot_start = library.func("tui_screenshot_start", 'void *', ["TuiString", "bool", "TuiString", "TuiOptionalF64", "TuiString", "bool"]); +functions.tui_screenshot_ptr = library.func("tui_screenshot_ptr", "void *", ["TuiString", "void *"]); +functions.tui_start_recording = library.func("tui_start_recording", "void *", ["TuiString", "TuiRecordingOptions"]); +functions.tui_start_recording_start = library.func("tui_start_recording_start", 'void *', ["TuiString", "TuiRecordingOptions"]); +functions.tui_task_poll = library.func("tui_task_poll", "void *", ["void *"]); +functions.tui_task_free = library.func("tui_task_free", "void", ["void *"]); +functions.tui_panic_probe_start = library.func("tui_panic_probe_start", "void *", []); +return functions; +} diff --git a/bindings/js/native/adapter.js b/bindings/js/native/adapter.js new file mode 100644 index 00000000..44c3db0f --- /dev/null +++ b/bindings/js/native/adapter.js @@ -0,0 +1,366 @@ +import koffi from "koffi"; +import { layouts, types } from "./abi.js"; +import { enums, methods, models } from "./models.js"; + +const PREFIX = "__tui_test_native_error__:"; +const aliases = { TextStyle: "LocatorStyle", LocatorQuery: "LocatorExpression", LocatorDirection: "LocatorStageDirection" }; +const snake = (name) => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +const optional = (type) => type.startsWith("Option<") ? type.slice(7, -1) : undefined; +const absent = (value) => value === undefined || value === null; +const error = (kind, message, extra = {}) => new Error(PREFIX + JSON.stringify({ kind, message, ...extra })); + +function integer(value, label, max = Number.MAX_SAFE_INTEGER, min = 0) { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw error("usage", `${label} must be an integer between ${min} and ${max}`); + } + return value; +} + +function validate(type, value, label) { + const inner = optional(type); + if (inner) return absent(value) ? undefined : validate(inner, value, label); + if (type.startsWith("Vec<")) { + if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`); + return value.map((item, i) => validate(type.slice(4, -1), item, `${label}[${i}]`)); + } + if (type === "(String,String)") { + if (!Array.isArray(value) || value.length !== 2) throw new TypeError(`${label} must be a string pair`); + return value.map((item, i) => validate("String", item, `${label}[${i}]`)); + } + if (/^(u8|u16|u32|u64|usize|i32)$/.test(type)) { + const max = { u8: 255, u16: 65535, u32: 4294967295, i32: 2147483647 }[type]; + return integer(value, label, max, type === "i32" ? -2147483648 : 0); + } + if (type === "MouseOptions") { + const code = integer(value, "button", 255); + if ((code & ~31) !== 0 || (code & 3) === 3) throw error("usage", `invalid mouse button code ${code}`); + return { button: code & 3, shift: !!(code & 4), alt: !!(code & 8), ctrl: !!(code & 16) }; + } + const primitive = { String: "string", PathBuf: "string", WhitespaceMode: "string", TraceMode: "string", FailureArtifactMode: "string", bool: "boolean", f64: "number" }[type]; + if (primitive) { + if (typeof value !== primitive) throw new TypeError(`${label} must be a ${primitive}`); + return value; + } + type = aliases[type] ?? type; + if (enums[type]) { + if (!Object.values(enums[type]).includes(value)) throw new TypeError(`invalid ${label}: ${String(value)}`); + return value; + } + const fields = models[type]; + if (!fields) throw error("internal", `unknown native model ${type}`); + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${label} must be an object`); + if (type.startsWith("Locator")) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(fields, key)) throw new TypeError(`unknown locator field ${JSON.stringify(key)}`); + } + } + return Object.fromEntries(Object.entries(fields).map(([key, [field, fieldType]]) => [ + field, validate(fieldType, value[key], label ? `${label}.${key}` : key), + ])); +} + +// Encoding stores raw pointers in native structs. Keep every backing allocation +// reachable until the start call returns and Rust finishes its input copy. +class Inputs { + buffers = []; + release() { + this.buffers.length = 0; + } + string(value) { + if (absent(value)) return { data: null, len: 0 }; + const bytes = Buffer.from(value, "utf8"); + const data = bytes.length ? bytes : Buffer.alloc(1); + this.buffers.push(data); + return { data, len: bytes.length }; + } + array(type, values = []) { + if (!values.length) return null; + const buffer = Buffer.alloc(koffi.sizeof(types[type]) * values.length); + this.buffers.push(buffer); + koffi.encode(buffer, types[type], values.map((v) => this.struct(type, v)), values.length); + return buffer; + } + struct(type, value) { + if (type === "TuiString") return this.string(value); + if (type.startsWith("TuiOptional")) { + return { present: !absent(value), value: value ?? (type === "TuiOptionalBool" ? false : 0) }; + } + const result = {}; + value ??= {}; + for (const [field, fieldType] of Object.entries(layouts[type])) { + if (field.endsWith("_len")) { + result[field] = value[field.slice(0, -4)]?.length ?? 0; + } else if (fieldType.startsWith("*")) { + const items = value[field] ?? []; + result[field] = this.array(fieldType.slice(1), items); + } else if (fieldType.startsWith("Tui")) { + result[field] = this.struct(fieldType, value[field]); + } else { + result[field] = value[field] ?? (fieldType === "bool" ? false : 0); + } + } + return result; + } +} + +function bytes(value) { + if (value.data === null) return null; + return Buffer.from(koffi.decode(value.data, "uint8_t", Number(value.len))); +} +function string(value) { return bytes(value)?.toString("utf8") ?? null; } +function array(type, pointer, length) { + return length ? koffi.decode(pointer, types[type], Number(length)).map((value) => decode(type, value)) : []; +} +function decode(type, value) { + if (type === "TuiString") return string(value); + if (type.startsWith("TuiOptional")) return value.present ? Number(value.value) : null; + if (type === "TuiColor") { + if (value.kind === 0) return "default"; + if (value.kind === 1) return value.index; + if (value.kind === 2) return "#" + [value.red, value.green, value.blue].map((v) => v.toString(16).padStart(2, "0")).join(""); + throw error("internal", `unknown native color kind ${value.kind}`); + } + const result = {}; + for (const [field, fieldType] of Object.entries(layouts[type])) { + if (field.endsWith("_len")) continue; + let decoded; + if (fieldType.startsWith("*")) { + decoded = array(fieldType.slice(1), value[field], value[`${field}_len`]); + if (field === "modes") decoded = Object.fromEntries(decoded.map((m) => [m.name, m.enabled])); + if (field === "palette") decoded = Object.fromEntries(decoded.map((p) => [p.key, p.value])); + } else if (fieldType.startsWith("Tui")) decoded = decode(fieldType, value[field]); + else decoded = typeof value[field] === "bigint" ? Number(value[field]) : value[field]; + result[field === "character" ? "char" : field] = decoded; + } + return result; +} + +function resultValue(result, selector) { + switch (selector) { + case "Unit": return undefined; + case "Open": return decode("TuiOpenResult", result.open); + case "State": return decode("TuiState", result.state); + case "Cursor": return decode("TuiCursor", result.cursor); + case "Size": return decode("TuiSize", result.size); + case "Cells": return array("TuiCell", result.cells, result.cells_len); + case "Matches": return array("TuiMatch", result.matches, result.matches_len); + case "BellEvents": return array("TuiBellEvent", result.bells, result.bells_len); + case "Sessions": return array("TuiString", result.strings, result.strings_len); + case "BellCount": return Number(result.number); + case "ExitCode": return decode("TuiOptionalI32", result.exit_code); + case "Snapshot": { + const value = Object.values(enums.SnapshotResult)[result.snapshot]; + if (value === undefined) throw error("internal", `unknown snapshot result ${result.snapshot}`); + return value; + } + case "PackedScreen": return { ...decode("TuiSize", result.size), utf8: bytes(result.text) }; + case "Text": case "Command": case "Output": case "Cwd": case "Title": + case "Clipboard": case "Recording": case "Screenshot": return string(result.text); + default: throw error("internal", `unknown native result ${selector}`); + } +} + +function locator(expression) { + for (const [index, node] of expression.nodes.entries()) { + for (const key of ["within", "left", "right", "input", "has", "has_not"]) { + if (!absent(node[key])) integer(node[key], "locator reference", 255); + } + if (!absent(node.nth)) { + if (!absent(node.occurrence) && node.occurrence !== "nth") { + throw error("usage", `nodes[${index}].nth cannot be used with occurrence '${node.occurrence}'`); + } + node.occurrence = { nth: node.nth }; + } else { + node.occurrence ??= "any"; + if (node.occurrence === "nth") throw error("usage", `nodes[${index}].nth requires an nth index`); + if (!["any", "unique", "first", "last"].includes(node.occurrence)) throw error("usage", `invalid occurrence '${node.occurrence}'`); + } + delete node.nth; + } + integer(expression.root, "locator root", 255); + return { json: JSON.stringify(expression) }; +} + +const mouseDefault = () => ({ button: 0, shift: false, alt: false, ctrl: false }); +const pairs = (values) => values?.map(([key, value]) => ({ key, value })); + +export function createAdapter(functions) { + const reads = []; + let activeReads = 0; + + function read(name, args, selector, input) { + return new Promise((resolve, reject) => { + const run = () => { + activeReads++; + const finish = (failure, pointer) => { + try { + if (failure) reject(failure); + else resolve(consume(pointer, selector)); + } catch (failure) { + reject(failure); + } finally { + input?.release(); + activeReads--; + reads.shift()?.(); + } + }; + try { + functions[name].async(...args, finish); + } catch (failure) { + finish(failure); + } + }; + // Bound submissions, not the number of callers. Koffi rejects excess + // in-flight calls; the queue preserves promises without changing its limits. + if (activeReads < 4) run(); + else reads.push(run); + }); + } + + function consume(pointer, selector) { + if (pointer === null) throw error("internal", "native call returned a null result"); + try { + const result = koffi.decode(pointer, types.TuiResult); + if (result.error_kind) { + const kinds = { 1: "assertion", 2: "usage", 3: "no_session", 5: "internal" }; + const kind = kinds[result.error_kind]; + if (!kind) throw error("internal", `unknown native error kind ${result.error_kind}`); + const details = string(result.error_details); + const artifact = string(result.error_artifact); + throw error(kind, string(result.error_message), { + ...(details === null ? {} : { details: JSON.parse(details) }), + ...(artifact === null ? {} : { artifact: JSON.parse(artifact) }), + }); + } + return resultValue(result, selector); + } finally { + functions.tui_result_free(pointer); + } + } + + function call(name, args, selector, input, short = false) { + if (short) return read(name, args, selector, input); + let task; + try { + task = functions[`${name}_start`](...args); + } finally { + // This post-call access retains nested buffers through native marshaling. + // Rust owns its input copy when the synchronous start function returns. + input?.release(); + } + if (task === null) return Promise.reject(error("internal", "native call returned a null task")); + return new Promise((resolve, reject) => { + const poll = () => { + let pointer; + try { + pointer = functions.tui_task_poll(task); + if (pointer === null) { setTimeout(poll, 1); return; } + resolve(consume(pointer, selector)); + } catch (failure) { reject(failure); } + finally { + if (pointer !== null) functions.tui_task_free(task); + } + }; + poll(); + }); + } + + class NativeSession { + #name; + #context; + #directory; + constructor(name, recording, artifacts, trace) { + this.#name = validate("String", name, "name"); + recording = validate("Option", recording, ""); + artifacts = validate("Option", artifacts, ""); + trace = validate("Option", trace, ""); + this.#directory = recording?.directory; + this.#context = { name }; + if (artifacts) Object.assign(this.#context, { + artifact_directory: artifacts.directory, + artifact_mode: artifacts.mode ?? "all", + artifact_include_recording: artifacts.include_recording ?? false, + }); + if (trace) Object.assign(this.#context, { trace_directory: trace.directory, trace_mode: trace.mode ?? "off" }); + } + name() { return this.#name; } + async open(options) { return this.#open("open", validate("OpenOptions", options ?? {}, "")); } + async run(options) { return this.#open("run", validate("RunOptions", options, "")); } + #open(method, options) { + const input = new Inputs(); + const context = input.string(JSON.stringify({ ...this.#context, screen_history_limit: options.screen_history_limit })); + const native = input.struct("TuiOpenOptions", { + ...options, env: pairs(options.env), colors: pairs(options.profile_colors), + scrollback: options.profile_scrollback, recording_directory: this.#directory, + }); + const args = [context, native]; + if (method === "run") args.push(input.string(options.program), input.array("TuiString", options.args), options.args?.length ?? 0); + return call(`tui_${method}`, args, "Open", input); + } + async close(failed) { + failed = validate("Option", failed, "failed"); + const input = new Inputs(); + return call("tui_close", [input.string(JSON.stringify(this.#context)), input.struct("TuiOptionalBool", failed)], "Unit", input); + } + async panicProbe() { return call("tui_panic_probe", [], "Unit"); } + async #invoke(method, args) { + const spec = methods[method]; + const values = Object.fromEntries(spec.args.map(([name, type], i) => [name, validate(type, args[i], name === "options" ? "" : name)])); + const input = new Inputs(); + const s = (v) => input.string(v); + const u = (v) => input.struct("TuiOptionalU64", v); + const wait = (v) => input.struct("TuiWaitOptions", v); + const options = values.options ?? {}; + const v = values; + let name = `tui_${snake(method)}`; + let native; + if (["press", "keyDown", "repeat", "keyUp"].includes(method)) { + name = "tui_key"; + native = [input.array("TuiString", v.keys), v.keys.length, ["press", "keyDown", "repeat", "keyUp"].indexOf(method)]; + } else switch (method) { + case "text": case "packedScreen": native = [v.full ?? false]; break; + case "cells": native = [v.x, v.y, v.w ?? 1, v.h ?? 1]; break; + case "write": native = [s(v.data)]; break; + case "type": native = [s(v.text)]; break; + case "submit": native = [s(v.data)]; break; + case "signal": native = [s(v.name)]; break; + case "mouseClick": native = [u(options.x), u(options.y), s(options.on_text), options.button ?? mouseDefault(), options.clicks ?? 1]; break; + case "mouseDown": case "mouseUp": native = [v.x, v.y, v.button ?? mouseDefault()]; break; + case "mouseDrag": native = [v.x1, v.y1, v.x2, v.y2, v.button ?? mouseDefault()]; break; + case "mouseScroll": native = [s(v.direction), v.amount ?? 3]; break; + case "waitTitle": case "expectTitle": case "waitClipboard": native = [s(v.text), wait(options)]; break; + case "waitIdle": case "waitCommand": case "waitExit": case "waitReady": case "waitBell": native = [u(v.timeoutMs)]; break; + case "findLocator": native = [input.struct("TuiQuery", locator(v.expression)), v.requireOne ?? false]; break; + case "waitLocator": case "expectLocator": native = [input.struct("TuiQuery", locator(v.expression)), wait({ not: v.not, timeout_ms: v.timeoutMs })]; break; + case "clickLocator": native = [input.struct("TuiQuery", locator(v.expression)), v.button ?? mouseDefault(), v.clicks ?? 1, u(v.timeoutMs)]; break; + case "highlightLocator": native = [input.struct("TuiQuery", locator(v.expression)), u(v.timeoutMs)]; break; + case "expectExitCode": native = [v.code, u(v.timeoutMs)]; break; + case "expectBellCount": native = [v.count, u(v.timeoutMs)]; break; + case "expectOutput": native = [s(v.text), v.regex ?? false]; break; + case "snapshot": native = [s(v.name), options.update ?? false, options.include_style ?? false, options.include_title ?? false, s(options.cwd)]; break; + case "screenshot": native = [options.full ?? false, s(options.path), input.struct("TuiOptionalF64", options.zoom), s(options.background), options.transparent ?? false]; break; + case "startRecording": native = [input.struct("TuiRecordingOptions", options)]; break; + default: native = Object.values(v); + } + return call(name, [s(JSON.stringify(this.#context)), ...native], spec.result, input, spec.short); + } + static { + for (const method of Object.keys(methods)) { + Object.defineProperty(this.prototype, method, { + configurable: true, writable: true, + value: function (...args) { return this.#invoke(method, args); }, + }); + } + } + } + return { + NativeSession, + sessions: async () => call("tui_sessions", [], "Sessions"), + recording: async (name) => { + const input = new Inputs(); + return call("tui_recording", [input.string(validate("String", name, "name"))], "Recording", input); + }, + closeAll: async () => call("tui_close_all", [], "Unit"), + closeAllSync: () => consume(functions.tui_close_all(), "Unit"), + }; +} diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index e746049f..6f1d2386 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -1,73 +1,32 @@ -/* auto-generated by NAPI-RS */ -/* eslint-disable */ -export declare class NativeSession { - constructor(name: string, recording?: AutomaticRecordingOptions | undefined | null, artifacts?: FailureArtifactOptions | undefined | null, trace?: TraceOptions | undefined | null) - name(): string - open(options?: OpenOptions | undefined | null): Promise - run(options: RunOptions): Promise - restart(gracefulTimeoutMs: number): Promise - close(failed?: boolean | undefined | null): Promise - state(): Promise - text(full?: boolean | undefined | null): Promise - packedScreen(full?: boolean | undefined | null): Promise - cells(x: number, y: number, w?: number | undefined | null, h?: number | undefined | null): Promise> - findLocator(expression: LocatorExpression, requireOne?: boolean | undefined | null): Promise> - waitLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise - expectLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise - clickLocator(expression: LocatorExpression, button?: number | undefined | null, clicks?: number | undefined | null, timeoutMs?: number | undefined | null): Promise - highlightLocator(expression: LocatorExpression, timeoutMs?: number | undefined | null): Promise> - panicProbe(): Promise - getCommand(): Promise - getOutput(): Promise - getExitCode(): Promise - getCwd(): Promise - getTitle(): Promise - getClipboard(): Promise - getCursor(): Promise - getSize(): Promise - getBellCount(): Promise - getBellEvents(): Promise> - write(data: string): Promise - type(text: string): Promise - submit(data?: string | undefined | null): Promise - press(keys: Array): Promise - keyDown(keys: Array): Promise - repeat(keys: Array): Promise - keyUp(keys: Array): Promise - mouseClick(options?: MouseClickOptions | undefined | null): Promise - mouseMove(x: number, y: number): Promise - mouseDown(x: number, y: number, button?: number | undefined | null): Promise - mouseUp(x: number, y: number, button?: number | undefined | null): Promise - mouseDrag(x1: number, y1: number, x2: number, y2: number, button?: number | undefined | null): Promise - mouseScroll(direction: string, amount?: number | undefined | null): Promise - resize(cols: number, rows: number): Promise - signal(name: string): Promise - waitTitle(text: string, options?: TitleOptions | undefined | null): Promise - expectTitle(text: string, options?: TitleOptions | undefined | null): Promise - waitClipboard(text?: string | undefined | null, options?: ClipboardWaitOptions | undefined | null): Promise - waitIdle(timeoutMs?: number | undefined | null): Promise - waitCommand(timeoutMs?: number | undefined | null): Promise - waitExit(timeoutMs?: number | undefined | null): Promise - waitReady(timeoutMs?: number | undefined | null): Promise - waitBell(timeoutMs?: number | undefined | null): Promise - expectExitCode(code: number, timeoutMs?: number | undefined | null): Promise - expectOutput(text: string, regex?: boolean | undefined | null): Promise - expectBellCount(count: number, timeoutMs?: number | undefined | null): Promise - snapshot(name: string, options?: SnapshotOptions | undefined | null): Promise - screenshot(options?: ScreenshotOptions | undefined | null): Promise - startRecording(options: RecordingOptions): Promise - stopRecording(): Promise +// Generated by cargo run -p tui-test-bindgen. Do not edit. +export type Color = number | string; + +export interface OpenResult { + shell_pid: number | null + session: string + ready: boolean + recording: string } -export interface AutomaticRecordingOptions { - directory?: string +export interface Cursor { + x: number + y: number + visible: boolean + shape: string + color: string } -export declare const enum Backend { - Alacritty = 'alacritty', - Ghostty = 'ghostty', - Rio = 'rio', - Xtermjs = 'xtermjs' +export interface Size { + cols: number + rows: number +} + +export interface EffectiveTimeouts { + text: number + idle: number + command: number + exit: number + ready: number } export interface BellEvent { @@ -75,6 +34,33 @@ export interface BellEvent { elapsed_ms: number } +export interface State { + session_shell: string | null + cols: number + rows: number + cursor: Cursor + title: string | null + cwd: string | null + last_command: string | null + last_exit: number | null + exit_signal: string | null + exited: number | null + ready: boolean + bell_count: number + modes: Record + mouse_mode: string + colors: TerminalColors + timeouts: EffectiveTimeouts + text: string +} + +export interface TerminalColors { + foreground: string + background: string + cursor: string + palette: Record +} + export interface Cell { x: number y: number @@ -91,121 +77,55 @@ export interface Cell { underline: boolean underline_style: UnderlineStyle underline_color: Color - /** The OSC 8 URI this cell links to, empty when it links nowhere. */ link: string - /** - * The link's `id=` parameter, empty when the sequence carried none. - * - * Separate from `link` because it identifies a link across a wrap rather - * than describing where the link points: a program that wraps its own - * links tags each run with a shared `id=` so a terminal can treat them as - * one. - * - * Backend-dependent, unlike everything else on a cell. Ghostty's FFI - * exposes a link's URI and nothing else, so this is always empty there - * and "no `id=` was sent" cannot be told apart from "this backend cannot - * see it". An assertion built on it will not mean the same thing on every - * backend, which is why no locator matches on it. - */ link_id: string } -export interface ClipboardWaitOptions { - regex?: boolean - timeoutMs?: number -} - -export declare function closeAll(): Promise - -export declare function closeAllSync(): void - -export type Color = - number | string - -export interface Cursor { - x: number - y: number - /** Whether the cursor is drawn (`DECTCEM`). */ - visible: boolean - /** `block`, `underline`, or `bar` (`DECSCUSR`). */ - shape: string - /** The cursor color as `#rrggbb`, after any `OSC 12` a program sent. */ - color: string -} - -export interface EffectiveTimeouts { - text: number - idle: number - command: number - exit: number - ready: number +export interface TextPosition { + row: number + column: number } -export interface FailureArtifactOptions { - directory: string - mode?: string - includeRecording?: boolean +export interface TextSpan { + row: number + start: number + end: number } -export interface LocatorExpression { - nodes: Array - root: number +export interface TextMatch { + text: string + start: TextPosition + end: TextPosition + spans: Array } -export interface LocatorNode { - kind: LocatorNodeKind - text?: string - regex?: boolean - whitespace?: string - style?: LocatorStyle - link?: string - full?: boolean - direction?: LocatorStageDirection - within?: number - left?: number - right?: number - input?: number - has?: number - hasNot?: number - occurrence?: string - nth?: number +export interface PackedScreen { + readonly cols: number + readonly rows: number + readonly utf8: Uint8Array } -export declare const enum LocatorNodeKind { - Text = 'text', - Style = 'style', - Link = 'link', - And = 'and', - Or = 'or', - Filter = 'filter' +export interface Timeouts { + text?: number + idle?: number + command?: number + exit?: number + ready?: number } -export declare const enum LocatorStageDirection { - Within = 'within', - After = 'after', - Before = 'before' +export interface AutomaticRecordingOptions { + directory?: string } -export interface LocatorStyle { - foreground?: string - background?: string - bold?: boolean - dim?: boolean - italic?: boolean - underlineStyle?: string - underlineColor?: string - inverse?: boolean - hidden?: boolean - strikethrough?: boolean - blink?: boolean +export interface TraceOptions { + mode?: string + directory?: string } -export interface MouseClickOptions { - x?: number - y?: number - onText?: string - button?: number - clicks?: number +export interface FailureArtifactOptions { + directory: string + mode?: string + includeRecording?: boolean } export interface OpenOptions { @@ -223,44 +143,6 @@ export interface OpenOptions { screenHistoryLimit?: number } -export interface OpenResult { - shell_pid: number | null - session: string - ready: boolean - recording: string -} - -export interface PackedScreen { - /** - * Logical terminal dimensions for the newline-delimited UTF-8 snapshot. - * Rows retain trailing spaces and blank lines; byte offsets are not cell - * offsets because Unicode graphemes may occupy multiple bytes. - */ - readonly cols: number - readonly rows: number - readonly utf8: Uint8Array -} - -export declare function recording(name: string): Promise - -export declare const enum RecordingFormat { - Apng = 'apng', - Gif = 'gif', - Mp4 = 'mp4', - Cast = 'cast' -} - -export interface RecordingOptions { - path: string - format?: RecordingFormat - fps?: number - speed?: number - idleTimeLimit?: number - zoom?: number - background?: string - transparent?: boolean -} - export interface RunOptions { backend?: Backend program: string @@ -277,31 +159,53 @@ export interface RunOptions { screenHistoryLimit?: number } -export interface ScreenshotOptions { - full?: boolean - path?: string - zoom?: number +export interface LocatorStyle { + foreground?: string background?: string - transparent?: boolean + bold?: boolean + dim?: boolean + italic?: boolean + underlineStyle?: string + underlineColor?: string + inverse?: boolean + hidden?: boolean + strikethrough?: boolean + blink?: boolean +} + +export interface LocatorNode { + kind: LocatorNodeKind + text?: string + regex?: boolean + whitespace?: string + style?: LocatorStyle + link?: string + full?: boolean + direction?: LocatorStageDirection + within?: number + left?: number + right?: number + input?: number + has?: number + hasNot?: number + occurrence?: string + nth?: number } -export declare function sessions(): Promise> +export interface LocatorExpression { + nodes: Array + root: number +} -export declare const enum Shell { - Bash = 'bash', - Powershell = 'powershell', - Pwsh = 'pwsh', - Cmd = 'cmd', - Fish = 'fish', - Zsh = 'zsh', - Xonsh = 'xonsh', - Elvish = 'elvish', - Nushell = 'nushell' +export interface TitleOptions { + regex?: boolean + timeoutMs?: number + not?: boolean } -export interface Size { - cols: number - rows: number +export interface ClipboardWaitOptions { + regex?: boolean + timeoutMs?: number } export interface SnapshotOptions { @@ -311,125 +215,149 @@ export interface SnapshotOptions { cwd?: string } -export declare const enum SnapshotResult { - Passed = 'passed', - Written = 'written', - Updated = 'updated' +export interface ScreenshotOptions { + full?: boolean + path?: string + zoom?: number + background?: string + transparent?: boolean } -export interface State { - session_shell: string | null - cols: number - rows: number - cursor: Cursor - title: string | null - cwd: string | null - last_command: string | null - last_exit: number | null - /** Signal reported when the process was terminated by one. */ - exit_signal: string | null - exited: number | null - ready: boolean - bell_count: number - /** - * Terminal modes the child has turned on, by name. - * - * A map rather than a list, so a reader can tell "off" from "this build - * does not know that mode" and every key is always present. - */ - modes: Record - /** - * Mouse tracking level: `none`, `click`, `drag`, or `motion`. - * - * Separate from `modes` because mouse tracking is not a set of - * independent switches: `CSI ?1002 h` replaces `CSI ?1000 h` rather than - * joining it, so reporting it as booleans would say two are on when the - * terminal only honors the last. - * - * Independent of how the child asked for the reports to be encoded. - * `CSI ?1000 h` on its own is `click`, whether or not `CSI ?1006 h` - * followed it to ask for SGR coordinates. - */ - mouse_mode: string - /** - * The terminal's colors, as `#rrggbb`: the three defaults and any - * palette entry a program overrode. - */ - colors: TerminalColors - timeouts: EffectiveTimeouts - text: string +export interface RecordingOptions { + path: string + format?: RecordingFormat + fps?: number + speed?: number + idleTimeLimit?: number + zoom?: number + background?: string + transparent?: boolean } -/** - * The colors the terminal paints with. - * - * Grouped because they are one concept — the colors a program chooses - * rather than the ones a cell names — set by sibling sequences and reset by - * `OSC 104` and `OSC 110/111/112`. A slot nothing has overridden reports the - * color the session's profile gives it, so every field always has an - * answer. - */ -export interface TerminalColors { - /** The default foreground (`OSC 10`). */ - foreground: string - /** The default background (`OSC 11`). */ - background: string - /** The cursor color (`OSC 12`). */ - cursor: string - /** - * Palette entries a program moved with `OSC 4`, keyed by index. - * - * Only the entries that differ from the profile are listed: a program - * that recolors slot 1 is interesting, and the 255 it left alone are - * not. - */ - palette: Record +export interface MouseClickOptions { + x?: number + y?: number + onText?: string + button?: number + clicks?: number } -export interface TextMatch { - text: string - start: TextPosition - /** Exclusive end position. */ - end: TextPosition - /** Per-row column ranges with exclusive ends. */ - spans: Array +export declare const enum Backend { + Alacritty = "alacritty", + Ghostty = "ghostty", + Rio = "rio", + Xtermjs = "xtermjs", } -export interface TextPosition { - row: number - column: number +export declare const enum Shell { + Bash = "bash", + Powershell = "powershell", + Pwsh = "pwsh", + Cmd = "cmd", + Fish = "fish", + Zsh = "zsh", + Xonsh = "xonsh", + Elvish = "elvish", + Nushell = "nushell", } -export interface TextSpan { - row: number - start: number - end: number +export declare const enum RecordingFormat { + Apng = "apng", + Gif = "gif", + Mp4 = "mp4", + Cast = "cast", } -export interface Timeouts { - text?: number - idle?: number - command?: number - exit?: number - ready?: number +export declare const enum SnapshotResult { + Passed = "passed", + Written = "written", + Updated = "updated", } -export interface TitleOptions { - regex?: boolean - timeoutMs?: number - not?: boolean +export declare const enum UnderlineStyle { + None = "none", + Single = "single", + Double = "double", + Curly = "curly", + Dotted = "dotted", + Dashed = "dashed", } -export interface TraceOptions { - mode?: string - directory?: string +export declare const enum LocatorNodeKind { + Text = "text", + Style = "style", + Link = "link", + And = "and", + Or = "or", + Filter = "filter", } -export declare const enum UnderlineStyle { - None = 'none', - Single = 'single', - Double = 'double', - Curly = 'curly', - Dotted = 'dotted', - Dashed = 'dashed' +export declare const enum LocatorStageDirection { + Within = "within", + After = "after", + Before = "before", +} + +export declare class NativeSession { + constructor(name: string, recording?: AutomaticRecordingOptions | null, artifacts?: FailureArtifactOptions | null, trace?: TraceOptions | null) + name(): string + open(options?: OpenOptions | null): Promise + run(options: RunOptions): Promise + close(failed?: boolean | null): Promise + panicProbe(): Promise + restart(gracefulTimeoutMs: number): Promise + state(): Promise + text(full?: boolean | undefined | null): Promise + packedScreen(full?: boolean | undefined | null): Promise + cells(x: number, y: number, w?: number | undefined | null, h?: number | undefined | null): Promise> + findLocator(expression: LocatorExpression, requireOne?: boolean | undefined | null): Promise> + waitLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise + expectLocator(expression: LocatorExpression, not?: boolean | undefined | null, timeoutMs?: number | undefined | null): Promise + clickLocator(expression: LocatorExpression, button?: number | undefined | null, clicks?: number | undefined | null, timeoutMs?: number | undefined | null): Promise + highlightLocator(expression: LocatorExpression, timeoutMs?: number | undefined | null): Promise> + getCommand(): Promise + getOutput(): Promise + getExitCode(): Promise + getCwd(): Promise + getTitle(): Promise + getClipboard(): Promise + getCursor(): Promise + getSize(): Promise + getBellCount(): Promise + getBellEvents(): Promise> + write(data: string): Promise + type(text: string): Promise + submit(data?: string | undefined | null): Promise + press(keys: Array): Promise + keyDown(keys: Array): Promise + repeat(keys: Array): Promise + keyUp(keys: Array): Promise + mouseClick(options?: MouseClickOptions | undefined | null): Promise + mouseMove(x: number, y: number): Promise + mouseDown(x: number, y: number, button?: number | undefined | null): Promise + mouseUp(x: number, y: number, button?: number | undefined | null): Promise + mouseDrag(x1: number, y1: number, x2: number, y2: number, button?: number | undefined | null): Promise + mouseScroll(direction: string, amount?: number | undefined | null): Promise + resize(cols: number, rows: number): Promise + signal(name: string): Promise + waitTitle(text: string, options?: TitleOptions | undefined | null): Promise + expectTitle(text: string, options?: TitleOptions | undefined | null): Promise + waitClipboard(text?: string | undefined | null, options?: ClipboardWaitOptions | undefined | null): Promise + waitIdle(timeoutMs?: number | undefined | null): Promise + waitCommand(timeoutMs?: number | undefined | null): Promise + waitExit(timeoutMs?: number | undefined | null): Promise + waitReady(timeoutMs?: number | undefined | null): Promise + waitBell(timeoutMs?: number | undefined | null): Promise + expectExitCode(code: number, timeoutMs?: number | undefined | null): Promise + expectOutput(text: string, regex?: boolean | undefined | null): Promise + expectBellCount(count: number, timeoutMs?: number | undefined | null): Promise + snapshot(name: string, options?: SnapshotOptions | undefined | null): Promise + screenshot(options?: ScreenshotOptions | undefined | null): Promise + startRecording(options: RecordingOptions): Promise + stopRecording(): Promise } + +export declare function sessions(): Promise>; +export declare function recording(name: string): Promise; +export declare function closeAll(): Promise; +export declare function closeAllSync(): void; diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index c5ac3869..07a881e6 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -1,605 +1,36 @@ -// prettier-ignore -/* eslint-disable */ -// @ts-nocheck -/* auto-generated by NAPI-RS */ - -import { createRequire } from 'module' -const require = createRequire(import.meta.url) -const __dirname = new URL('.', import.meta.url).pathname - -const { readFileSync } = require('fs') -let nativeBinding = null -const loadErrors = [] - -const isMusl = () => { - let musl = false - if (process.platform === 'linux') { - musl = isMuslFromFilesystem() - if (musl === null) { - musl = isMuslFromReport() - } - if (musl === null) { - musl = isMuslFromChildProcess() - } - } - return musl +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import koffi from "koffi"; +import { abiVersion, bind } from "./abi.js"; +import { createAdapter } from "./adapter.js"; + +export { + Backend, Shell, RecordingFormat, SnapshotResult, UnderlineStyle, + LocatorNodeKind, LocatorStageDirection, +} from "./models.js"; + +function libraryPath() { + if (process.env.TUI_TEST_NATIVE_LIBRARY) return process.env.TUI_TEST_NATIVE_LIBRARY; + const platform = process.platform; + const arch = process.arch; + const libc = platform === "linux" + ? (process.report.getReport().header.glibcVersionRuntime ? "gnu" : "musl") + : undefined; + const tag = [platform, arch, platform === "win32" ? "msvc" : libc].filter(Boolean).join("-"); + const extension = { darwin: "dylib", linux: "so", win32: "dll" }[platform]; + if (!extension) throw new Error(`unsupported native platform: ${platform}`); + const local = fileURLToPath(new URL(`tui-test.${tag}.${extension}`, import.meta.url)); + if (existsSync(local)) return local; + return createRequire(import.meta.url).resolve(`@microsoft/tui-test-${tag}`); } -const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') - -const isMuslFromFilesystem = () => { - try { - return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') - } catch { - return null - } +const library = koffi.load(libraryPath()); +// Keep the library handle alive with its functions. Bun cannot safely finalize +// Koffi's handle during garbage collection, even while native functions remain. +const functions = { ...bind(library), library }; +const version = functions.tui_abi_version(); +if (version !== abiVersion) { + throw new Error(`tui-test native ABI mismatch: expected ${abiVersion}, got ${version}`); } - -const isMuslFromReport = () => { - let report = null - if (process.report && typeof process.report.getReport === 'function') { - process.report.excludeNetwork = true - report = process.report.getReport() - } - if (!report) { - return null - } - if (report.header && report.header.glibcVersionRuntime) { - return false - } - if (Array.isArray(report.sharedObjects)) { - if (report.sharedObjects.some(isFileMusl)) { - return true - } - } - return false -} - -const isMuslFromChildProcess = () => { - try { - return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') - } catch (e) { - // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false - return false - } -} - -function requireNative() { - if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { - try { - return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); - } catch (err) { - loadErrors.push(err) - } - } else if (process.platform === 'android') { - if (process.arch === 'arm64') { - try { - return require('./tui-test.android-arm64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-android-arm64') - const bindingPackageVersion = require('@microsoft/tui-test-android-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm') { - try { - return require('./tui-test.android-arm-eabi.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-android-arm-eabi') - const bindingPackageVersion = require('@microsoft/tui-test-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) - } - } else if (process.platform === 'win32') { - if (process.arch === 'x64') { - if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { - try { - return require('./tui-test.win32-x64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-win32-x64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.win32-x64-msvc.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-win32-x64-msvc') - const bindingPackageVersion = require('@microsoft/tui-test-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'ia32') { - try { - return require('./tui-test.win32-ia32-msvc.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-win32-ia32-msvc') - const bindingPackageVersion = require('@microsoft/tui-test-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - return require('./tui-test.win32-arm64-msvc.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-win32-arm64-msvc') - const bindingPackageVersion = require('@microsoft/tui-test-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) - } - } else if (process.platform === 'darwin') { - try { - return require('./tui-test.darwin-universal.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-darwin-universal') - const bindingPackageVersion = require('@microsoft/tui-test-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - if (process.arch === 'x64') { - try { - return require('./tui-test.darwin-x64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-darwin-x64') - const bindingPackageVersion = require('@microsoft/tui-test-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - return require('./tui-test.darwin-arm64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-darwin-arm64') - const bindingPackageVersion = require('@microsoft/tui-test-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) - } - } else if (process.platform === 'freebsd') { - if (process.arch === 'x64') { - try { - return require('./tui-test.freebsd-x64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-freebsd-x64') - const bindingPackageVersion = require('@microsoft/tui-test-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - return require('./tui-test.freebsd-arm64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-freebsd-arm64') - const bindingPackageVersion = require('@microsoft/tui-test-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) - } - } else if (process.platform === 'linux') { - if (process.arch === 'x64') { - if (isMusl()) { - try { - return require('./tui-test.linux-x64-musl.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-x64-musl') - const bindingPackageVersion = require('@microsoft/tui-test-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.linux-x64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-x64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'arm64') { - if (isMusl()) { - try { - return require('./tui-test.linux-arm64-musl.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-arm64-musl') - const bindingPackageVersion = require('@microsoft/tui-test-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.linux-arm64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-arm64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'arm') { - if (isMusl()) { - try { - return require('./tui-test.linux-arm-musleabihf.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-arm-musleabihf') - const bindingPackageVersion = require('@microsoft/tui-test-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.linux-arm-gnueabihf.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-arm-gnueabihf') - const bindingPackageVersion = require('@microsoft/tui-test-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'loong64') { - if (isMusl()) { - try { - return require('./tui-test.linux-loong64-musl.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-loong64-musl') - const bindingPackageVersion = require('@microsoft/tui-test-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.linux-loong64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-loong64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'riscv64') { - if (isMusl()) { - try { - return require('./tui-test.linux-riscv64-musl.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-riscv64-musl') - const bindingPackageVersion = require('@microsoft/tui-test-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - try { - return require('./tui-test.linux-riscv64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-riscv64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'ppc64') { - try { - return require('./tui-test.linux-ppc64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-ppc64-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 's390x') { - try { - return require('./tui-test.linux-s390x-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-linux-s390x-gnu') - const bindingPackageVersion = require('@microsoft/tui-test-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) - } - } else if (process.platform === 'openharmony') { - if (process.arch === 'arm64') { - try { - return require('./tui-test.openharmony-arm64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-openharmony-arm64') - const bindingPackageVersion = require('@microsoft/tui-test-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'x64') { - try { - return require('./tui-test.openharmony-x64.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-openharmony-x64') - const bindingPackageVersion = require('@microsoft/tui-test-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm') { - try { - return require('./tui-test.openharmony-arm.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@microsoft/tui-test-openharmony-arm') - const bindingPackageVersion = require('@microsoft/tui-test-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return binding - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) - } - } else { - loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) - } -} - -nativeBinding = requireNative() - -// NAPI_RS_FORCE_WASI is a tri-state flag: -// unset / any other value → native binding preferred, WASI is only a fallback -// 'true' → force WASI fallback even if native loaded -// 'error' → force WASI and throw if no WASI binding is found -// Treating any non-empty string as truthy (the historical behavior) meant -// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered -// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. -const forceWasi = - process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error' - -if (!nativeBinding || forceWasi) { - let wasiBinding = null - let wasiBindingError = null - try { - wasiBinding = require('./tui-test.wasi.cjs') - nativeBinding = wasiBinding - } catch (err) { - if (forceWasi) { - wasiBindingError = err - } - } - if (!nativeBinding || forceWasi) { - try { - wasiBinding = require('@microsoft/tui-test-wasm32-wasi') - nativeBinding = wasiBinding - } catch (err) { - if (forceWasi) { - if (!wasiBindingError) { - wasiBindingError = err - } else { - wasiBindingError.cause = err - } - loadErrors.push(err) - } - } - } - if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { - const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') - error.cause = wasiBindingError - throw error - } -} - -if (!nativeBinding) { - if (loadErrors.length > 0) { - const error = new Error( - `Cannot find native binding. ` + - `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + - 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', - ) - // assign instead of the `new Error(message, { cause })` options form, - // which Node < 16.9 silently ignores - error.cause = loadErrors.reduce((err, cur) => { - cur.cause = err - return cur - }) - throw error - } - throw new Error(`Failed to load native binding`) -} - -const { NativeSession, Backend, closeAll, closeAllSync, LocatorNodeKind, LocatorStageDirection, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding -export { NativeSession } -export { Backend } -export { closeAll } -export { closeAllSync } -export { LocatorNodeKind } -export { LocatorStageDirection } -export { recording } -export { RecordingFormat } -export { sessions } -export { Shell } -export { SnapshotResult } -export { UnderlineStyle } +export const { NativeSession, sessions, recording, closeAll, closeAllSync } = createAdapter(functions); diff --git a/bindings/js/native/models.js b/bindings/js/native/models.js new file mode 100644 index 00000000..38462671 --- /dev/null +++ b/bindings/js/native/models.js @@ -0,0 +1,327 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +export const models = { +OpenResult: { +"shell_pid": ["shell_pid", "Option"], +"session": ["session", "String"], +"ready": ["ready", "bool"], +"recording": ["recording", "String"], +}, +Cursor: { +"x": ["x", "u16"], +"y": ["y", "u16"], +"visible": ["visible", "bool"], +"shape": ["shape", "String"], +"color": ["color", "String"], +}, +Size: { +"cols": ["cols", "u16"], +"rows": ["rows", "u16"], +}, +EffectiveTimeouts: { +"text": ["text", "u64"], +"idle": ["idle", "u64"], +"command": ["command", "u64"], +"exit": ["exit", "u64"], +"ready": ["ready", "u64"], +}, +BellEvent: { +"sequence": ["sequence", "u64"], +"elapsed_ms": ["elapsed_ms", "u64"], +}, +State: { +"session_shell": ["session_shell", "Option"], +"cols": ["cols", "u16"], +"rows": ["rows", "u16"], +"cursor": ["cursor", "Cursor"], +"title": ["title", "Option"], +"cwd": ["cwd", "Option"], +"last_command": ["last_command", "Option"], +"last_exit": ["last_exit", "Option"], +"exit_signal": ["exit_signal", "Option"], +"exited": ["exited", "Option"], +"ready": ["ready", "bool"], +"bell_count": ["bell_count", "u64"], +"modes": ["modes", "BTreeMap"], +"mouse_mode": ["mouse_mode", "String"], +"colors": ["colors", "TerminalColors"], +"timeouts": ["timeouts", "EffectiveTimeouts"], +"text": ["text", "String"], +}, +TerminalColors: { +"foreground": ["foreground", "String"], +"background": ["background", "String"], +"cursor": ["cursor", "String"], +"palette": ["palette", "std::collections::BTreeMap"], +}, +Cell: { +"x": ["x", "u16"], +"y": ["y", "u16"], +"char": ["char", "String"], +"fg": ["fg", "CellColor"], +"bg": ["bg", "CellColor"], +"bold": ["bold", "bool"], +"dim": ["dim", "bool"], +"italic": ["italic", "bool"], +"inverse": ["inverse", "bool"], +"invisible": ["invisible", "bool"], +"strike": ["strike", "bool"], +"blink": ["blink", "bool"], +"underline": ["underline", "bool"], +"underline_style": ["underline_style", "String"], +"underline_color": ["underline_color", "CellColor"], +"link": ["link", "String"], +"link_id": ["link_id", "String"], +}, +TextPosition: { +"row": ["row", "u32"], +"column": ["column", "u16"], +}, +TextSpan: { +"row": ["row", "u32"], +"start": ["start", "u16"], +"end": ["end", "u16"], +}, +TextMatch: { +"text": ["text", "String"], +"start": ["start", "TextPosition"], +"end": ["end", "TextPosition"], +"spans": ["spans", "Vec"], +}, +PackedScreen: { +"cols": ["cols", "u16"], +"rows": ["rows", "u16"], +"utf8": ["utf8", "Vec"], +}, +Timeouts: { +"text": ["text", "Option"], +"idle": ["idle", "Option"], +"command": ["command", "Option"], +"exit": ["exit", "Option"], +"ready": ["ready", "Option"], +}, +AutomaticRecordingOptions: { +"directory": ["directory", "Option"], +}, +TraceOptions: { +"mode": ["mode", "Option"], +"directory": ["directory", "Option"], +}, +FailureArtifactOptions: { +"directory": ["directory", "PathBuf"], +"mode": ["mode", "Option"], +"includeRecording": ["include_recording", "Option"], +}, +OpenOptions: { +"backend": ["backend", "Option"], +"shell": ["shell", "Option"], +"cols": ["cols", "Option"], +"rows": ["rows", "Option"], +"cwd": ["cwd", "Option"], +"env": ["env", "Option>"], +"waitReady": ["wait_ready", "Option"], +"restart": ["restart", "Option"], +"profileScrollback": ["profile_scrollback", "Option"], +"profileColors": ["profile_colors", "Option>"], +"timeouts": ["timeouts", "Option"], +"screenHistoryLimit": ["screen_history_limit", "Option"], +}, +RunOptions: { +"backend": ["backend", "Option"], +"program": ["program", "String"], +"args": ["args", "Option>"], +"cols": ["cols", "Option"], +"rows": ["rows", "Option"], +"cwd": ["cwd", "Option"], +"env": ["env", "Option>"], +"waitReady": ["wait_ready", "Option"], +"restart": ["restart", "Option"], +"profileScrollback": ["profile_scrollback", "Option"], +"profileColors": ["profile_colors", "Option>"], +"timeouts": ["timeouts", "Option"], +"screenHistoryLimit": ["screen_history_limit", "Option"], +}, +LocatorStyle: { +"foreground": ["foreground", "Option"], +"background": ["background", "Option"], +"bold": ["bold", "Option"], +"dim": ["dim", "Option"], +"italic": ["italic", "Option"], +"underlineStyle": ["underline_style", "Option"], +"underlineColor": ["underline_color", "Option"], +"inverse": ["inverse", "Option"], +"hidden": ["hidden", "Option"], +"strikethrough": ["strikethrough", "Option"], +"blink": ["blink", "Option"], +}, +LocatorNode: { +"kind": ["kind", "LocatorNodeKind"], +"text": ["text", "Option"], +"regex": ["regex", "Option"], +"whitespace": ["whitespace", "Option"], +"style": ["style", "Option"], +"link": ["link", "Option"], +"full": ["full", "Option"], +"direction": ["direction", "Option"], +"within": ["within", "Option"], +"left": ["left", "Option"], +"right": ["right", "Option"], +"input": ["input", "Option"], +"has": ["has", "Option"], +"hasNot": ["has_not", "Option"], +"occurrence": ["occurrence", "Option"], +"nth": ["nth", "Option"], +}, +LocatorExpression: { +"nodes": ["nodes", "Vec"], +"root": ["root", "usize"], +}, +TitleOptions: { +"regex": ["regex", "Option"], +"timeoutMs": ["timeout_ms", "Option"], +"not": ["not", "Option"], +}, +ClipboardWaitOptions: { +"regex": ["regex", "Option"], +"timeoutMs": ["timeout_ms", "Option"], +}, +SnapshotOptions: { +"update": ["update", "Option"], +"includeStyle": ["include_style", "Option"], +"includeTitle": ["include_title", "Option"], +"cwd": ["cwd", "Option"], +}, +ScreenshotOptions: { +"full": ["full", "Option"], +"path": ["path", "Option"], +"zoom": ["zoom", "Option"], +"background": ["background", "Option"], +"transparent": ["transparent", "Option"], +}, +RecordingOptions: { +"path": ["path", "String"], +"format": ["format", "Option"], +"fps": ["fps", "Option"], +"speed": ["speed", "Option"], +"idleTimeLimit": ["idle_time_limit", "Option"], +"zoom": ["zoom", "Option"], +"background": ["background", "Option"], +"transparent": ["transparent", "Option"], +}, +MouseClickOptions: { +"x": ["x", "Option"], +"y": ["y", "Option"], +"onText": ["on_text", "Option"], +"button": ["button", "Option"], +"clicks": ["clicks", "Option"], +}, +}; +export const enums = { +Backend: { +Alacritty: "alacritty", +Ghostty: "ghostty", +Rio: "rio", +Xtermjs: "xtermjs", +}, +Shell: { +Bash: "bash", +Powershell: "powershell", +Pwsh: "pwsh", +Cmd: "cmd", +Fish: "fish", +Zsh: "zsh", +Xonsh: "xonsh", +Elvish: "elvish", +Nushell: "nushell", +}, +RecordingFormat: { +Apng: "apng", +Gif: "gif", +Mp4: "mp4", +Cast: "cast", +}, +SnapshotResult: { +Passed: "passed", +Written: "written", +Updated: "updated", +}, +UnderlineStyle: { +None: "none", +Single: "single", +Double: "double", +Curly: "curly", +Dotted: "dotted", +Dashed: "dashed", +}, +LocatorNodeKind: { +Text: "text", +Style: "style", +Link: "link", +And: "and", +Or: "or", +Filter: "filter", +}, +LocatorStageDirection: { +Within: "within", +After: "after", +Before: "before", +}, +}; +export const Backend = enums.Backend; +export const Shell = enums.Shell; +export const RecordingFormat = enums.RecordingFormat; +export const SnapshotResult = enums.SnapshotResult; +export const UnderlineStyle = enums.UnderlineStyle; +export const LocatorNodeKind = enums.LocatorNodeKind; +export const LocatorStageDirection = enums.LocatorStageDirection; +export const methods = { +restart: { result: "Open", args: [["gracefulTimeoutMs", "u64"],] }, +state: { result: "State", args: [] }, +text: { result: "Text", args: [["full", "Option"],] }, +packedScreen: { result: "PackedScreen", args: [["full", "Option"],] }, +cells: { result: "Cells", args: [["x", "u16"],["y", "u16"],["w", "Option"],["h", "Option"],] }, +findLocator: { result: "Matches", args: [["expression", "LocatorQuery"],["requireOne", "Option"],] }, +waitLocator: { result: "Unit", args: [["expression", "LocatorQuery"],["not", "Option"],["timeoutMs", "Option"],] }, +expectLocator: { result: "Unit", args: [["expression", "LocatorQuery"],["not", "Option"],["timeoutMs", "Option"],] }, +clickLocator: { result: "Unit", args: [["expression", "LocatorQuery"],["button", "Option"],["clicks", "Option"],["timeoutMs", "Option"],] }, +highlightLocator: { result: "Matches", args: [["expression", "LocatorQuery"],["timeoutMs", "Option"],] }, +getCommand: { result: "Command", short: true, args: [] }, +getOutput: { result: "Output", short: true, args: [] }, +getExitCode: { result: "ExitCode", short: true, args: [] }, +getCwd: { result: "Cwd", short: true, args: [] }, +getTitle: { result: "Title", short: true, args: [] }, +getClipboard: { result: "Clipboard", short: true, args: [] }, +getCursor: { result: "Cursor", short: true, args: [] }, +getSize: { result: "Size", short: true, args: [] }, +getBellCount: { result: "BellCount", short: true, args: [] }, +getBellEvents: { result: "BellEvents", short: true, args: [] }, +write: { result: "Unit", args: [["data", "String"],] }, +type: { result: "Unit", args: [["text", "String"],] }, +submit: { result: "Unit", args: [["data", "Option"],] }, +press: { result: "Unit", args: [["keys", "Vec"],] }, +keyDown: { result: "Unit", args: [["keys", "Vec"],] }, +repeat: { result: "Unit", args: [["keys", "Vec"],] }, +keyUp: { result: "Unit", args: [["keys", "Vec"],] }, +mouseClick: { result: "Unit", args: [["options", "Option"],] }, +mouseMove: { result: "Unit", args: [["x", "u16"],["y", "u16"],] }, +mouseDown: { result: "Unit", args: [["x", "u16"],["y", "u16"],["button", "Option"],] }, +mouseUp: { result: "Unit", args: [["x", "u16"],["y", "u16"],["button", "Option"],] }, +mouseDrag: { result: "Unit", args: [["x1", "u16"],["y1", "u16"],["x2", "u16"],["y2", "u16"],["button", "Option"],] }, +mouseScroll: { result: "Unit", args: [["direction", "String"],["amount", "Option"],] }, +resize: { result: "Unit", args: [["cols", "u16"],["rows", "u16"],] }, +signal: { result: "Unit", args: [["name", "String"],] }, +waitTitle: { result: "Unit", args: [["text", "String"],["options", "Option"],] }, +expectTitle: { result: "Unit", args: [["text", "String"],["options", "Option"],] }, +waitClipboard: { result: "Unit", args: [["text", "Option"],["options", "Option"],] }, +waitIdle: { result: "Unit", args: [["timeoutMs", "Option"],] }, +waitCommand: { result: "Unit", args: [["timeoutMs", "Option"],] }, +waitExit: { result: "Unit", args: [["timeoutMs", "Option"],] }, +waitReady: { result: "Unit", args: [["timeoutMs", "Option"],] }, +waitBell: { result: "Unit", args: [["timeoutMs", "Option"],] }, +expectExitCode: { result: "Unit", args: [["code", "i32"],["timeoutMs", "Option"],] }, +expectOutput: { result: "Unit", args: [["text", "String"],["regex", "Option"],] }, +expectBellCount: { result: "Unit", args: [["count", "u64"],["timeoutMs", "Option"],] }, +snapshot: { result: "Snapshot", args: [["name", "String"],["options", "Option"],] }, +screenshot: { result: "Screenshot", args: [["options", "Option"],] }, +startRecording: { result: "Unit", args: [["options", "RecordingOptions"],] }, +stopRecording: { result: "Recording", args: [] }, +}; diff --git a/bindings/js/package-lock.json b/bindings/js/package-lock.json index 3c5b560a..2d54b663 100644 --- a/bindings/js/package-lock.json +++ b/bindings/js/package-lock.json @@ -8,8 +8,10 @@ "name": "@microsoft/tui-test", "version": "0.1.0", "license": "MIT", + "dependencies": { + "koffi": "^3.3.3" + }, "devDependencies": { - "@napi-rs/cli": "^3.7.4", "@types/node": "^20.14.0", "typescript": "^5.5.0" }, @@ -17,1562 +19,324 @@ "node": ">=20" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "integrity": "sha1-BJrOZx8wJ01qTRYdO3cROLhi9wQ=", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "integrity": "sha1-6yLwTXb+v99Ph/2v9UyKU/a/Db0=", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "integrity": "sha1-HJKRkyi+H2q3n7YKZ8zz0uYs1Is=", - "dev": true, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.3.tgz", + "integrity": "sha512-c8iDq/mPXkgQmrmZ6SpqWIVF3ZnJEpjVQswzKzbrCSdr68pmSY/cNj1t4qvv32T8nNjW71/hB+E8Rq1+vVpmQg==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.2.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/external-editor": "^3.0.3", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.5.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.2.1", - "@inquirer/confirm": "^6.1.1", - "@inquirer/editor": "^5.2.2", - "@inquirer/expand": "^5.1.1", - "@inquirer/input": "^5.1.2", - "@inquirer/number": "^4.1.1", - "@inquirer/password": "^5.1.1", - "@inquirer/rawlist": "^5.3.1", - "@inquirer/search": "^4.2.1", - "@inquirer/select": "^5.2.1" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@napi-rs/cli": { - "version": "3.7.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/prompts": "^8.5.2", - "@napi-rs/cross-toolchain": "^1.0.3", - "@napi-rs/wasm-tools": "^1.0.1", - "@octokit/rest": "^22.0.1", - "clipanion": "^4.0.0-rc.4", - "colorette": "^2.0.20", - "emnapi": "^1.11.1", - "es-toolkit": "^1.47.0", - "js-yaml": "^4.2.0", - "obug": "^2.1.2", - "semver": "^7.8.2", - "typanion": "^3.14.0" - }, - "bin": { - "napi": "dist/cli.js", - "napi-raw": "cli.mjs" - }, - "engines": { - "node": ">= 16" - }, + "os": [ + "android" + ], "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/runtime": "^1.7.1" - }, - "peerDependenciesMeta": { - "@emnapi/runtime": { - "optional": true - } + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/cross-toolchain": { - "version": "1.0.3", - "dev": true, - "license": "MIT", - "workspaces": [ - ".", - "arm64/*", - "x64/*" + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.3.tgz", + "integrity": "sha512-yAFu4oX6rpBCqr4HiGo3FpywmRN11LOSde4+GJZvfkFymqV3wZtx6TgVe8rZ4ogJZz9B8o1tDeKsZ6pqeoH40g==", + "cpu": [ + "x64" ], - "dependencies": { - "@napi-rs/lzma": "^1.4.5", - "@napi-rs/tar": "^1.1.0", - "debug": "^4.4.1" - }, - "peerDependencies": { - "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" - }, - "peerDependenciesMeta": { - "@napi-rs/cross-toolchain-arm64-target-aarch64": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-armv7": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-ppc64le": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-s390x": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-x86_64": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-aarch64": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-armv7": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-ppc64le": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-s390x": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-x86_64": { - "optional": true - } - } - }, - "node_modules/@napi-rs/lzma": { - "version": "1.5.1", - "dev": true, "license": "MIT", - "engines": { - "node": "^22.20 || ^24.12 || >=25" - }, + "optional": true, + "os": [ + "android" + ], "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/lzma-android-arm-eabi": "1.5.1", - "@napi-rs/lzma-android-arm64": "1.5.1", - "@napi-rs/lzma-darwin-arm64": "1.5.1", - "@napi-rs/lzma-darwin-x64": "1.5.1", - "@napi-rs/lzma-freebsd-x64": "1.5.1", - "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", - "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", - "@napi-rs/lzma-linux-arm64-musl": "1.5.1", - "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", - "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", - "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@napi-rs/lzma-linux-x64-musl": "1.5.1", - "@napi-rs/lzma-wasm32-wasi": "1.5.1", - "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", - "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", - "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-android-arm-eabi": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.5.1.tgz", - "integrity": "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==", + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.3.tgz", + "integrity": "sha512-aFYI3YMdQWOAmHlVA+GfBlJZTpvfbUv/NoeBBkqblWN7BxuY02bkWMyoksUEl+mnx7ScoMonnjzXKJkNQXmR3w==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-android-arm64": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.5.1.tgz", - "integrity": "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==", + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.3.tgz", + "integrity": "sha512-6Q8/DDhVADPMm3lFnZFJRETJtf485jZKzn1KogXeA9Gfztv/XMFiCOK4aEouFc256h9uWzC69mGimtcVcwK+Ww==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-darwin-arm64": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.5.1.tgz", - "integrity": "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==", + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.3.tgz", + "integrity": "sha512-IynEDMMU9pp7EVVqQwXVU9x0JH7Opj8KB0wbcKgYNEhRM9eGzJ4ai7iwG8y8tdD/Y8niSVrVTj5WLNCCvUWXIw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-darwin-x64": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.5.1.tgz", - "integrity": "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==", + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.3.tgz", + "integrity": "sha512-5b4W9jX+wmfWn9vG7DvKIrNhhRpPDAx7YQV5i9epUR0Ogxz4s4a3APz/beLCvgHt4WhaSRrazxGYQz1oFOlfow==", "cpu": [ - "x64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-freebsd-x64": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.5.1.tgz", - "integrity": "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==", + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.3.tgz", + "integrity": "sha512-+NmMrp3ji1NN4PTaLaXDCKMaJ5Fl+0+nrClmtsu/7wi+fjCrCSGMrJZUmTIJPm1nd8Z5fMRjWesiVvQiy/ETDg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.5.1.tgz", - "integrity": "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==", + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.3.tgz", + "integrity": "sha512-K+NFO4/SiF4ANCS1ykem36wbEjfBJKsaakLKZ+6LbaLqyLjHHGky4fEIgipac/+XJtD4ZXjhexPIVjwzkO+rMQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-arm64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.5.1.tgz", - "integrity": "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==", + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.3.tgz", + "integrity": "sha512-y7UHjwOKbj2IjIpWtHmI8LEKXchidt47k605DCv5+YQMIDTbzhz/i3zTI1kRp6EYBLTBwRkR1X1PGt3uUi+rKQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-arm64-musl": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.5.1.tgz", - "integrity": "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==", + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.3.tgz", + "integrity": "sha512-dbcpe7IrWr18AAbIKa/RvJ8pM1lEpSELWYW6wsEBIYw/RTLfXqWJ6d9Ya0NWJgHTW+N7783KVQaV5c1Sr3ONDQ==", "cpu": [ - "arm64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.5.1.tgz", - "integrity": "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==", + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.3.tgz", + "integrity": "sha512-kbJFmWSoDHGZgxgDhInuvDi8MzrHafBfQ7iWN5P+nHv297beQXrHecFfCNBApnG9d2wrhEoY+EH/TdITuvaSwQ==", "cpu": [ - "ppc64" + "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.5.1.tgz", - "integrity": "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==", + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.3.tgz", + "integrity": "sha512-YwGxjudYHsiDZ6qH4WmEawHXmTaZzjiZ4xdjcbzsVHqNvgZ3Nz7WG03PybecJ+BVH2vqBOntGZ2VsG/sL0WY1g==", "cpu": [ - "riscv64" + "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-s390x-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.5.1.tgz", - "integrity": "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==", + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.3.tgz", + "integrity": "sha512-k/txpUsOO/L4TexahQHxqdFRmBEGE+g++np9M5eNcsHWNXmazeWBrrfB9sjl6cZAP69KZFO0toP+ZMfDUDHmnQ==", "cpu": [ - "s390x" + "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "integrity": "sha1-5X1DBpZgeGYgOAlPs465FG3Drqk=", + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.3.tgz", + "integrity": "sha512-AI4kcNNWeGmzQzsNb0S+ZL/ht4HMtDcQl5nYFkkuVUO2g1/2KdvNekkwNWhpp805mDbLSDoRZtlioHyH/v8poQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-linux-x64-musl": { - "version": "1.5.1", - "integrity": "sha1-Ga78wscV9EImbwasOzNLkq6YfB0=", + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.3.tgz", + "integrity": "sha512-NEbQMrixbAWSmWCh4XyHWuhgEqgO4tegWyHoJej7Q3PmyIlgxKHTcRZsjAJ9C3sfgBDNIqPWTjMy3KO2mqEp1g==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-wasm32-wasi": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.5.1.tgz", - "integrity": "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==", + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.3.tgz", + "integrity": "sha512-zGFX0KwZGmrV7AZY0+oozBpMuQZT7yQEY3NCDSb0rEqKyOrYLBNiXiXA5T8NSY3ipaW0b71L5Cu0KLKabdLY0A==", "cpu": [ - "wasm32" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/lzma-win32-arm64-msvc": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.5.1.tgz", - "integrity": "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@napi-rs/lzma-win32-ia32-msvc": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.5.1.tgz", - "integrity": "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@napi-rs/lzma-win32-x64-msvc": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.5.1.tgz", - "integrity": "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@napi-rs/tar": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/tar-android-arm-eabi": "1.1.1", - "@napi-rs/tar-android-arm64": "1.1.1", - "@napi-rs/tar-darwin-arm64": "1.1.1", - "@napi-rs/tar-darwin-x64": "1.1.1", - "@napi-rs/tar-freebsd-x64": "1.1.1", - "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", - "@napi-rs/tar-linux-arm64-gnu": "1.1.1", - "@napi-rs/tar-linux-arm64-musl": "1.1.1", - "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", - "@napi-rs/tar-linux-s390x-gnu": "1.1.1", - "@napi-rs/tar-linux-x64-gnu": "1.1.1", - "@napi-rs/tar-linux-x64-musl": "1.1.1", - "@napi-rs/tar-wasm32-wasi": "1.1.1", - "@napi-rs/tar-win32-arm64-msvc": "1.1.1", - "@napi-rs/tar-win32-ia32-msvc": "1.1.1", - "@napi-rs/tar-win32-x64-msvc": "1.1.1" - } - }, - "node_modules/@napi-rs/tar-android-arm-eabi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.1.tgz", - "integrity": "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-android-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.1.tgz", - "integrity": "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-darwin-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.1.tgz", - "integrity": "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-darwin-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.1.tgz", - "integrity": "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-freebsd-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.1.tgz", - "integrity": "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.1.tgz", - "integrity": "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.1.tgz", - "integrity": "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.1.tgz", - "integrity": "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-ppc64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.1.tgz", - "integrity": "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-s390x-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.1.tgz", - "integrity": "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-x64-gnu": { - "version": "1.1.1", - "integrity": "sha1-6vqLauqYHJnvA1v7jIzkYNIaCy4=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-x64-musl": { - "version": "1.1.1", - "integrity": "sha1-DDsSTj3aUeEhhUG3ntT6xHznXxI=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-wasm32-wasi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.1.tgz", - "integrity": "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/tar-win32-arm64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.1.tgz", - "integrity": "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-win32-ia32-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.1.tgz", - "integrity": "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-win32-x64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.1.tgz", - "integrity": "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "integrity": "sha1-16o5iEXjqxycj4HqUPUn7TfRZxU=", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" - } - }, - "node_modules/@napi-rs/wasm-tools": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.22.0" - }, - "optionalDependencies": { - "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", - "@napi-rs/wasm-tools-android-arm64": "1.1.0", - "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", - "@napi-rs/wasm-tools-darwin-x64": "1.1.0", - "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", - "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", - "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", - "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", - "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", - "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", - "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", - "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", - "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" - } - }, - "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz", - "integrity": "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-android-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.1.0.tgz", - "integrity": "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.1.0.tgz", - "integrity": "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-freebsd-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.1.0.tgz", - "integrity": "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.1.0.tgz", - "integrity": "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.1.0.tgz", - "integrity": "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { - "version": "1.1.0", - "integrity": "sha1-tK8NO9TT0keOQm3Z18Q0MSNApKw=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { - "version": "1.1.0", - "integrity": "sha1-QUE6XqeB1miVLNY4qayD3+mH2FI=", + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.3.tgz", + "integrity": "sha512-c1HMWkZPAZKQesKj2HqA6Zk1YEcv7Doc6JU9YJepXG8KpK4XZmI0L5vESSfySmidmasm05QDyLAcwDnNSvtfRA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.1.0.tgz", - "integrity": "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==", - "cpu": [ - "wasm32" + "openbsd" ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.1.0.tgz", - "integrity": "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==", + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.3.tgz", + "integrity": "sha512-eL7gW4D22OBb3fi04s5ElzzNk+gkGEahaTNMB29UDLDeORe+0qIkhccBPovXW3/IRpdt8W7o8X2myUDXlTSYDQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 12.22.0" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.1.0.tgz", - "integrity": "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==", + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.3.tgz", + "integrity": "sha512-3sw9vvQqro4yKsOWfKebzfTcdPKRwMrodGXWbfwkTsL61EQKdwsMNKA/0dDe0/ubAAZ0yPgstX8/gs94Svzfgw==", "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 12.22.0" + "funding": { + "url": "https://liberapay.com/Koromix" } }, - "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.1.0.tgz", - "integrity": "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==", + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.3.tgz", + "integrity": "sha512-tLTF17wl4CCwOlo1Tt0NbduwUduDvM6c8JSyearcLu6hpNHQo44RF3rKN2i7UkxwCqXyiudZK0lS421CC/TPZA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 12.22.0" - } - }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/core": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "17.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/request": { - "version": "10.0.11", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "content-type": "^2.0.0", - "json-with-bigint": "^3.5.3", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/request-error": { - "version": "7.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/rest": { - "version": "22.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/core": "^7.0.6", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/plugin-request-log": "^6.0.0", - "@octokit/plugin-rest-endpoint-methods": "^17.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "integrity": "sha1-AVy6np3UfOFNA9KoxdVHv7FpZl0=", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "funding": { + "url": "https://liberapay.com/Koromix" } }, "node_modules/@types/node": { @@ -1583,229 +347,38 @@ "undici-types": "~6.21.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/chardet": { - "version": "2.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-width": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/clipanion": { - "version": "4.0.0-rc.4", - "dev": true, - "license": "MIT", - "workspaces": [ - "website" - ], - "dependencies": { - "typanion": "^3.8.0" - }, - "peerDependencies": { - "typanion": "*" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "dev": true, - "license": "MIT" - }, - "node_modules/content-type": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/emnapi": { - "version": "1.11.3", - "dev": true, - "license": "MIT", - "peerDependencies": { - "node-addon-api": ">= 6.1.0" - }, - "peerDependenciesMeta": { - "node-addon-api": { - "optional": true - } - } - }, - "node_modules/es-toolkit": { - "version": "1.50.0", - "dev": true, - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks", - "tests/types" - ] - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "dev": true, + "node_modules/koffi": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.3.tgz", + "integrity": "sha512-XWNI4eyxJJzR2MakPZzR3QeZ06JOklLbxglin0dHhx/qIQCby8lqHs9Acq4Q7CpCKnOrBSj+6b9J5y69wfr2ZA==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-with-bigint": { - "version": "3.5.10", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" + "url": "https://liberapay.com/Koromix" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.3", + "@koromix/koffi-android-x64": "3.3.3", + "@koromix/koffi-darwin-arm64": "3.3.3", + "@koromix/koffi-darwin-x64": "3.3.3", + "@koromix/koffi-freebsd-arm64": "3.3.3", + "@koromix/koffi-freebsd-ia32": "3.3.3", + "@koromix/koffi-freebsd-x64": "3.3.3", + "@koromix/koffi-linux-arm": "3.3.3", + "@koromix/koffi-linux-arm64": "3.3.3", + "@koromix/koffi-linux-ia32": "3.3.3", + "@koromix/koffi-linux-loong64": "3.3.3", + "@koromix/koffi-linux-ppc64": "3.3.3", + "@koromix/koffi-linux-riscv64": "3.3.3", + "@koromix/koffi-linux-x64": "3.3.3", + "@koromix/koffi-openbsd-arm64": "3.3.3", + "@koromix/koffi-openbsd-ia32": "3.3.3", + "@koromix/koffi-openbsd-x64": "3.3.3", + "@koromix/koffi-win32-arm64": "3.3.3", + "@koromix/koffi-win32-ia32": "3.3.3", + "@koromix/koffi-win32-x64": "3.3.3" } }, - "node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typanion": { - "version": "3.14.0", - "dev": true, - "license": "MIT", - "workspaces": [ - "website" - ] - }, "node_modules/typescript": { "version": "5.9.3", "dev": true, @@ -1822,11 +395,6 @@ "version": "6.21.0", "dev": true, "license": "MIT" - }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "dev": true, - "license": "ISC" } } } diff --git a/bindings/js/package.json b/bindings/js/package.json index 77018942..ab283046 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -16,35 +16,28 @@ "types": "./dist/index.d.ts", "files": [ "dist", - "native/index.js", - "native/index.d.ts", - "native/*.node", + "native/*.js", + "native/*.d.ts", "README.md" ], "engines": { "node": ">=20" }, "sideEffects": false, - "napi": { - "binaryName": "tui-test", - "packageName": "@microsoft/tui-test", - "targets": [ - "x86_64-pc-windows-msvc", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-musl" - ] + "nativeTargets": { + "x86_64-pc-windows-msvc": "win32-x64-msvc", + "x86_64-apple-darwin": "darwin-x64", + "aarch64-apple-darwin": "darwin-arm64", + "x86_64-unknown-linux-gnu": "linux-x64-gnu", + "aarch64-unknown-linux-gnu": "linux-arm64-gnu", + "x86_64-unknown-linux-musl": "linux-x64-musl", + "aarch64-unknown-linux-musl": "linux-arm64-musl" }, "scripts": { "build:native": "node scripts/build-native.mjs", "build:native:debug": "node scripts/build-native.mjs --debug", "build:ts": "npm run clean && tsc -p tsconfig.json", "build": "npm run build:native && npm run build:ts", - "create:npm-dirs": "napi create-npm-dirs", - "artifacts": "napi artifacts --output-dir artifacts --npm-dir npm", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "test": "npm run test:node", "test:node": "npm run build && node --test --test-concurrency=1 ./test/conformance.test.mjs ./test/integration.test.mjs ./test/options.test.mjs ./test/native.test.mjs ./test/helpers.test.mjs", @@ -69,8 +62,10 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "koffi": "^3.3.3" + }, "devDependencies": { - "@napi-rs/cli": "^3.7.4", "@types/node": "^20.14.0", "typescript": "^5.5.0" } diff --git a/bindings/js/scripts/build-native.mjs b/bindings/js/scripts/build-native.mjs index 030062a4..67915862 100644 --- a/bindings/js/scripts/build-native.mjs +++ b/bindings/js/scripts/build-native.mjs @@ -1,27 +1,30 @@ import { execFileSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const directory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(directory, "../.."); const args = process.argv.slice(2); -const debug = args.includes("--debug"); -const napiArgs = args.filter((arg) => arg !== "--debug"); -const targetIndex = args.findIndex((arg) => arg === "--target" || arg === "-t"); -const explicitTarget = targetIndex >= 0 - ? args[targetIndex + 1] - : args.find((arg) => arg.startsWith("--target="))?.slice("--target=".length); +let debug = false; +let zigbuild = false; +let explicitTarget; +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--debug") debug = true; + else if (arg === "--zig" || arg === "-x") zigbuild = true; + else if (arg === "--target" || arg === "-t") { + explicitTarget = args[++i]; + if (!explicitTarget || explicitTarget.startsWith("-")) throw new Error("Missing --target value"); + } else if (arg.startsWith("--target=") && arg.length > "--target=".length) { + explicitTarget = arg.slice("--target=".length); + } else { + throw new Error(`Unknown build option: ${arg}`); + } +} const target = explicitTarget ?? execFileSync("rustc", ["-vV"], { encoding: "utf8" }) .match(/^host: (.+)$/m)?.[1]; -const tags = { - "x86_64-pc-windows-msvc": "win32-x64-msvc", - "x86_64-apple-darwin": "darwin-x64", - "aarch64-apple-darwin": "darwin-arm64", - "x86_64-unknown-linux-gnu": "linux-x64-gnu", - "aarch64-unknown-linux-gnu": "linux-arm64-gnu", - "x86_64-unknown-linux-musl": "linux-x64-musl", - "aarch64-unknown-linux-musl": "linux-arm64-musl", -}; +const tags = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).nativeTargets; const tag = tags[target]; if (!tag) throw new Error(`Unsupported native target: ${target}`); const env = { ...process.env }; @@ -29,15 +32,14 @@ delete env.CARGO_TARGET_DIR; if (target.endsWith("-musl")) { env.RUSTFLAGS = `${env.RUSTFLAGS ?? ""} -C target-feature=-crt-static`.trim(); } -const run = (command, commandArgs, cwd = directory) => - execFileSync(command, commandArgs, { cwd, env, stdio: "inherit" }); +execFileSync("cargo", [ + zigbuild ? "zigbuild" : "build", "--locked", "-p", "tui-test-ffi", + ...(debug ? [] : ["--release"]), + ...(explicitTarget ? ["--target", target] : []), +], { cwd: root, env, stdio: "inherit" }); -run(process.execPath, [ - resolve(directory, "node_modules/@napi-rs/cli/dist/cli.js"), - "build", "--platform", "--esm", - "--manifest-path", resolve(root, "crates/tui-test-ffi/Cargo.toml"), - "-p", "tui-test-ffi", "--features", "napi", - ...(debug ? [] : ["--release", "--strip"]), - "--output-dir", "native", "--js", "index.js", "--dts", "index.d.ts", - ...napiArgs, -]); +const extension = target.includes("windows") ? "dll" : target.includes("apple") ? "dylib" : "so"; +const library = extension === "dll" ? "tui_test_ffi.dll" : `libtui_test_ffi.${extension}`; +const output = join(root, "target", ...(explicitTarget ? [target] : []), debug ? "debug" : "release", library); +mkdirSync(join(directory, "native"), { recursive: true }); +copyFileSync(output, join(directory, "native", `tui-test.${tag}.${extension}`)); diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index ef81d56e..bf12cbf0 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -88,7 +88,7 @@ async function importBinding(): Promise { } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( - "failed to load the @microsoft/tui-test native addon: " + + "failed to load the @microsoft/tui-test native library: " + `${message}. Build it with \`npm run build:native\` (requires a Rust ` + "toolchain), or install a matching prebuilt platform package.", { cause: error }, diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index e10e879c..89ab3bd2 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -138,7 +138,11 @@ test("worker exit leaves terminals owned by the main thread open", { (async () => { const sdk = await import(workerData.url); assert.ok((await sdk.sessions()).includes(workerData.session)); + const terminal = new sdk.TuiTest(workerData.session); + assert.deepEqual(await terminal.getSize(), { cols: 80, rows: 30 }); + const reads = Array.from({ length: 300 }, () => terminal.getSize()); if (workerData.explicitExit) process.exit(0); + await Promise.all(reads); })().catch((error) => { throw error; }); `, { eval: true, diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index ad44e6b2..31463b61 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -2,9 +2,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { test } from "node:test"; import { TuiTest } from "../dist/index.js"; @@ -18,17 +17,81 @@ import { NativeRuntime, mapNativeError } from "../dist/native.js"; const ERROR_PREFIX = "__tui_test_native_error__:"; -test("standalone addon works without a companion library and ignores the library override", (t) => { - const directory = mkdtempSync(join(tmpdir(), "tui-test-node-standalone-")); +function runWithGc(script) { + const args = typeof globalThis.Deno !== "undefined" + ? ["eval", "--v8-flags=--expose-gc", script] + : typeof globalThis.Bun !== "undefined" + ? ["-e", script] + : ["--expose-gc", "--input-type=module", "-e", script]; + const child = spawnSync(process.execPath, args, { encoding: "utf8", env: process.env }); + assert.equal(child.status, 0, child.stderr); +} + +test("the native function table retains its library through garbage collection", () => { + runWithGc(` + import assert from "node:assert/strict"; + const binding = await import(${JSON.stringify(new URL("../native/index.js", import.meta.url).href)}); + for (let i = 0; i < 10; i++) { + if (typeof globalThis.Bun !== "undefined") globalThis.Bun.gc(true); + else globalThis.gc(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.deepEqual(await binding.sessions(), []); + `); +}); + +test("nested input buffers remain live during native start and marshaling", () => { + runWithGc(` + import assert from "node:assert/strict"; + import koffi from "koffi"; + import { types } from ${JSON.stringify(new URL("../native/abi.js", import.meta.url).href)}; + import { createAdapter } from ${JSON.stringify(new URL("../native/adapter.js", import.meta.url).href)}; + const expected = "nested-owned-buffer-".repeat(16000); + const read = value => Buffer.from(koffi.decode(value.data, "uint8_t", value.len)).toString(); + let calls = 0; + const { NativeSession: TestSession } = createAdapter({ + tui_run_start(context, options, program, argv, argc) { + for (let i = 0; i < 4; i++) { + if (typeof globalThis.Bun !== "undefined") globalThis.Bun.gc(true); + else globalThis.gc(); + Buffer.alloc(400000, 0x58); + } + const arg = koffi.decode(argv, types.TuiString, argc)[0]; + const pair = koffi.decode(options.env, types.TuiPair, options.env_len)[0]; + assert.ok(read(arg) === expected, "argument buffer changed during native start"); + assert.ok(read(pair.value) === expected, "environment buffer changed during native start"); + calls++; + return null; + }, + }); + const session = new TestSession("marshal-gc-regression"); + for (let i = 0; i < 100; i++) { + await assert.rejects( + session.run({ program: "unused", args: [expected], env: [["TEST", expected]] }), + /native call returned a null task/, + ); + } + assert.equal(calls, 100); + `); +}); + +test("Koffi adapter loads the explicit shared library and honors invalid overrides", (t) => { + const directory = mkdtempSync(fileURLToPath(new URL(".native-loader-", import.meta.url))); t.after(() => rmSync(directory, { recursive: true, force: true })); const native = new URL("../native/", import.meta.url); - for (const file of readdirSync(native).filter((file) => file.endsWith(".node"))) { + for (const file of readdirSync(native).filter((file) => file.endsWith(".js"))) { copyFileSync(new URL(file, native), join(directory, file)); } - const loader = join(directory, "index.mjs"); - copyFileSync(new URL("index.js", native), loader); - assert.ok(readdirSync(directory).some((file) => file.endsWith(".node"))); - assert.ok(readdirSync(directory).every((file) => file.endsWith(".node") || file === "index.mjs")); + const extension = { darwin: "dylib", linux: "so", win32: "dll" }[process.platform]; + const variant = process.platform === "linux" + ? (process.report.getReport().header.glibcVersionRuntime ? "gnu" : "musl") + : process.platform === "win32" ? "msvc" : undefined; + const tag = [process.platform, process.arch, variant].filter(Boolean).join("-"); + const binary = process.env.TUI_TEST_NATIVE_LIBRARY + || new URL(`tui-test.${tag}.${extension}`, native); + const library = join(directory, `chosen.${extension}`); + copyFileSync(binary, library); + const loader = join(directory, "index.js"); const script = ` (async () => { const assert = (await import("node:assert/strict")).default; @@ -52,10 +115,160 @@ test("standalone addon works without a companion library and ignores the library encoding: "utf8", env: { ...process.env, - TUI_TEST_NATIVE_LIBRARY: join(directory, "does-not-exist"), + TUI_TEST_NATIVE_LIBRARY: library, }, }); assert.equal(child.status, 0, child.stderr); + const invalid = spawnSync(process.execPath, args, { + encoding: "utf8", + env: { ...process.env, TUI_TEST_NATIVE_LIBRARY: join(directory, "does-not-exist") }, + }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /Failed to load shared library/); +}); + +test("concurrent waits cannot starve input or close", async () => { + const session = new NativeSession(`koffi-concurrency-${process.pid}`); + try { + await session.run({ + program: process.execPath, + args: [typeof globalThis.Deno === "undefined" ? "-e" : "eval", + 'process.stdin.once("data", () => process.stdout.write("\\x1b]0;koffi-completed\\x07")); setInterval(() => {}, 1000);'], + }); + let completedWaits = 0; + const waits = Array.from({ length: 32 }, () => session.waitTitle("koffi-completed", { timeoutMs: 5000 }) + .then(() => { completedWaits++; })); + const sizes = await Promise.all(Array.from({ length: 300 }, () => session.getSize())); + assert.ok(sizes.every((size) => size.cols === 80 && size.rows === 30)); + assert.equal(completedWaits, 0, "short reads waited for long waits to complete"); + const start = performance.now(); + await session.write("koffi-completed\n"); + await Promise.all(waits); + assert.ok(performance.now() - start < 2000, "waits blocked the input call"); + const pending = Array.from({ length: 32 }, () => session.waitTitle("never-set", { timeoutMs: 5000 }).catch(mapNativeError)); + const closeStart = performance.now(); + await session.close(); + const failures = await Promise.all(pending); + assert.ok(performance.now() - closeStart < 2000, "waits blocked the close call"); + assert.ok(failures.every((failure) => failure instanceof NoSessionError || failure instanceof ExpectationError)); + } finally { + await session.close(); + } +}); + +test("short reads use bounded asynchronous calls without task polling", async () => { + const { default: koffi } = await import("koffi"); + const { types } = await import("../native/abi.js"); + const { createAdapter } = await import("../native/adapter.js"); + const result = Buffer.alloc(koffi.sizeof(types.TuiResult)); + koffi.encode(result, koffi.offsetof(types.TuiResult, "size"), types.TuiSize, { cols: 80, rows: 30 }); + let active = 0; + let maximum = 0; + let completed = 0; + let freed = 0; + const { NativeSession: TestSession } = createAdapter({ + tui_get_size: { + async(context, callback) { + active++; + maximum = Math.max(maximum, active); + setImmediate(() => { + active--; + completed++; + callback(null, result); + }); + }, + }, + tui_result_free() { freed++; }, + }); + const session = new TestSession("short-read-queue"); + const sizes = await Promise.all(Array.from({ length: 300 }, () => session.getSize())); + assert.ok(sizes.every((size) => size.cols === 80 && size.rows === 30)); + assert.equal(maximum, 4); + assert.equal(completed, 300); + assert.equal(freed, 300); +}); + +test("native output copies survive result release and later allocations", async () => { + const session = new NativeSession(`koffi-copy-${process.pid}`); + try { + await session.run({ + program: process.execPath, + args: [typeof globalThis.Deno === "undefined" ? "-e" : "eval", + 'process.stdout.write("copy-αβγ"); setInterval(() => {}, 1000);'], + }); + + await session.waitLocator({ nodes: [{ kind: "text", text: "copy-αβγ" }], root: 0 }, false, 5000); + const screen = await session.packedScreen(); + const original = Buffer.from(screen.utf8); + const state = await session.state(); + const cells = await session.cells(0, 0, 8, 1); + for (let i = 0; i < 30; i++) { + await session.packedScreen(); + await session.state(); + Buffer.alloc(64 * 1024, i); + } + assert.deepEqual(screen.utf8, original); + assert.match(state.text, /copy-αβγ/); + assert.equal(cells.map((cell) => cell.char).join(""), "copy-αβγ"); + } finally { + await session.close(); + } +}); + +test("typed input buffers survive garbage collection after start", () => { + const script = ` + import assert from "node:assert/strict"; + import { NativeSession } from ${JSON.stringify(new URL("../native/index.js", import.meta.url).href)}; + const session = new NativeSession("koffi-gc-" + process.pid); + const expected = "input-copy-" + "αβγ".repeat(1500) + "-input-end"; + const env = [["TUI_COPY_VALUE", expected]]; + const code = 'process.stdout.write(process.env.TUI_COPY_VALUE); setInterval(() => {}, 1000);'; + const args = [typeof globalThis.Deno === "undefined" ? "-e" : "eval", code]; + try { + const started = session.run({ program: process.execPath, args, env, cols: 120, rows: 60 }); + args[1] = "throw new Error('mutated')"; + env[0][1] = "mutated"; + for (let i = 0; i < 20; i++) { + Buffer.alloc(1024 * 1024, i); + if (typeof globalThis.gc === "function") globalThis.gc(); + else globalThis.Bun.gc(true); + } + await started; + await session.waitLocator({ nodes: [{ kind: "text", text: "-input-end" }], root: 0 }, false, 5000); + const screen = await session.packedScreen(); + assert.ok(Buffer.from(screen.utf8).toString("utf8").replace(/\\s/g, "").includes(expected)); + } finally { await session.close(); } + `; + runWithGc(script); +}); + +test("error and conversion failures free native results and tasks exactly once", async () => { + const { default: koffi } = await import("koffi"); + const { types } = await import("../native/abi.js"); + const { createAdapter } = await import("../native/adapter.js"); + for (const [method, malformed] of [ + ["state", false], ["state", true], ["getSize", false], ["getSize", true], + ]) { + const result = Buffer.alloc(koffi.sizeof(types.TuiResult)); + koffi.encode(result, koffi.offsetof(types.TuiResult, "error_kind"), "uint32_t", 2); + const message = Buffer.from("test native failure"); + const details = Buffer.from(malformed ? "{invalid-json" : '{"reason":"test"}'); + for (const [field, data] of [["error_message", message], ["error_details", details]]) { + koffi.encode(result, koffi.offsetof(types.TuiResult, field), types.TuiString, { data, len: data.length }); + } + let resultsFreed = 0; + let tasksFreed = 0; + const { NativeSession: TestSession } = createAdapter({ + tui_state_start: () => result, + tui_get_size: { async(context, callback) { callback(null, result); } }, + tui_task_poll: () => result, + tui_task_free: () => { tasksFreed++; }, + tui_result_free: () => { resultsFreed++; }, + }); + await assert.rejects(new TestSession("test")[method]()); + assert.equal(resultsFreed, 1); + assert.equal(tasksFreed, method === "state" ? 1 : 0); + } }); test("native JSON error envelopes expose structured diagnostics", () => { diff --git a/bindings/js/test/options.test.mjs b/bindings/js/test/options.test.mjs index 6dd311fc..633dab51 100644 --- a/bindings/js/test/options.test.mjs +++ b/bindings/js/test/options.test.mjs @@ -702,7 +702,7 @@ test("expectExitCode rejects unsafe JavaScript numbers as UsageError", async () } }); -test("N-API argument conversion errors map to UsageError", async () => { +test("native argument conversion errors map to UsageError", async () => { const runtime = new NativeRuntime(uniqueSession("native-argument-errors")); const invalidCalls = [ () => runtime.run(null), diff --git a/crates/tui-test-ffi/Cargo.toml b/crates/tui-test-ffi/Cargo.toml index 7de6ca09..9a5db00a 100644 --- a/crates/tui-test-ffi/Cargo.toml +++ b/crates/tui-test-ffi/Cargo.toml @@ -11,15 +11,7 @@ publish = false name = "tui_test_ffi" crate-type = ["cdylib", "rlib"] -[features] -napi = ["dep:napi", "dep:napi-derive", "dep:napi-build"] - [dependencies] -napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"], optional = true } -napi-derive = { version = "3", optional = true } serde.workspace = true serde_json.workspace = true tui-test = { workspace = true, features = ["ghostty", "rio", "xtermjs", "recording-font-jetbrains-mono-styles"] } - -[build-dependencies] -napi-build = { version = "2", optional = true } diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index a5d79696..c467aa1c 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -1,15 +1,13 @@ # Shared native library -`tui-test-ffi` exposes the Rust engine through a typed C ABI. -Go uses `purego`, and Python uses `ctypes`, to load the default shared library. -The optional `napi` feature adds the JavaScript adapter to this crate. -That adapter constructs Rust `Operation` values and converts owned `OperationResult` values directly. -Both adapters share safe Rust execution, context validation, and close logic. -The JavaScript build produces one self-contained `.node` addon without C ABI calls or result pointers. -The default build has no N-API dependencies. +`tui-test-ffi` exposes the Rust engine through one typed C ABI. +Go uses `purego`, Python uses `ctypes`, and JavaScript uses Koffi to load this library. +There is no separate Rust N-API adapter or JavaScript-specific native build. +Koffi supplies its own native addon. The library and language package must come from the same source revision. This ABI is an internal contract, not a stable public C SDK. +The language adapters preserve their public convenience APIs. ## Build and use @@ -19,9 +17,9 @@ From the repository root: env -u CARGO_TARGET_DIR cargo build --locked -p tui-test-ffi ``` -The library is `target/debug/libtui_test_ffi.so` on Linux, `libtui_test_ffi.dylib` on macOS, or `tui_test_ffi.dll` on Windows. -Set `TUI_TEST_NATIVE_LIBRARY` to its absolute path to select it explicitly in Go or Python. -Python wheels also include the library. JavaScript ignores this variable and needs no companion library. +The output is `target/debug/libtui_test_ffi.so` on Linux, `libtui_test_ffi.dylib` on macOS, or `tui_test_ffi.dll` on Windows. +All three bindings accept `TUI_TEST_NATIVE_LIBRARY` as an explicit library path. +Python wheels and JavaScript platform packages include the library. For example, run the Go smoke example on macOS: @@ -33,10 +31,11 @@ CGO_ENABLED=0 go run ./examples/smoke The example starts a child terminal, submits input, checks its output, and prints `Go binding smoke passed`. -Build the JavaScript addon and declarations from the same crate: +Build and run the JavaScript example from the repository root: ```sh cd bindings/js +npm ci npm run build:native:debug npm run build:ts node --input-type=module -e ' @@ -55,133 +54,79 @@ node --input-type=module -e ' ' ``` -The build enables `--features napi` and writes the addon, loader, and declarations to `bindings/js/native/`. -The example prints the terminal size and text that contains `Ready 界🙂`. +The build copies the library to `native/tui-test..`. +The example prints `Terminal size: 80x30` and text that contains `Ready 界🙂`. +Set `TUI_TEST_NATIVE_LIBRARY` to the absolute library path to exercise the explicit override. +The build does not regenerate declarations. ## Generate boundaries The owned Rust models define the shared fields. Do not edit generated boundary files. The existing `tools/bindgen` tool reads these models with `syn`. -It generates boundary structs, fieldwise conversions, and all N-API exports before it runs `cbindgen`. -It rejects field types without a transport mapping. +It generates C boundary structs and fieldwise conversions, then uses `cbindgen` for the C header. +The C function signatures in `src/lib.rs` define the entrypoints. +The same tool generates the Go, Python, and JavaScript boundary declarations. +JavaScript uses generated `native/abi.js`, `native/models.js`, and `native/index.d.ts`. +The handwritten loader and adapter consume these declarations. | Authoritative source in `crates/tui-test/src/` | Coverage | | --- | --- | -| `api.rs` | Options, styles, results, and the operation, result, key action, and mouse action enums | +| `api.rs` | Options, styles, results, operations, and input actions | | `locator_query.rs` | Locator nodes, expressions, and node kinds | -| `profile.rs` | Profile fields flattened into open/run options | -| `diagnostics.rs` | Artifact, trace, and retention options, including the shared session input | -| `shell/mod.rs`, `terminal/backend.rs` | Shell and backend enum variants | -| `terminal/cell.rs` | Underline variants for the JavaScript string enum | +| `profile.rs` | Profile fields | +| `diagnostics.rs` | Artifact, trace, retention, and session options | +| `shell/mod.rs`, `terminal/backend.rs` | Shell and backend variants | +| `terminal/cell.rs` | Underline variants | -The result models include `OpenResult`, `Cursor`, `Size`, `BellEvent`, `State`, `TerminalColors`, and `Cell`. -They also include `TextPosition`, `TextSpan`, `TextMatch`, `EffectiveTimeouts`, and `PackedScreen`. -The operation variants supply title, clipboard, snapshot, screenshot, and recording option fields. -The clipboard options select the regex and timeout fields from the title operation. +From the repository root: ```sh env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen -- --check ``` -The generator writes these Rust files under `crates/tui-test-ffi/src/`: - -- `types.rs` and `napi/types.rs` -- `input_generated.rs` and `output_generated.rs` -- `napi/convert_generated.rs` -- `napi/bindings_generated.rs` -- `session_generated.rs` - -It then generates `include/tui_test.h`, the Go ABI structs and function table, and Python `ctypes` declarations. -The C function signatures in `src/lib.rs` remain authoritative for the C entrypoints. -The JavaScript adapter still calls owned Rust operations directly. It does not call the C ABI. - -`--check` checks all these outputs without changing files. -Normal generation can replace missing or stale generated Rust files. +`--check` checks generated files without changes. +Normal generation can replace missing or stale output. The generator does not compile the FFI crate to discover its models. Keep `rustfmt` and `gofmt` on `PATH`. +Release packages include generated files and do not need the generator. -Run `npm run build:native:debug` from `bindings/js` after generation. -The N-API CLI updates `native/index.d.ts` and the loader from the generated Rust exports. -CI separately checks these N-API outputs after the native build. -Release packages include generated Rust files. They do not need the generator during a package build. - -### Transport mappings and handwritten code - -`tools/bindgen/src/boundaries.rs` contains transport mappings, not another list of shared fields. -It maps C strings, pointer/length pairs, option tags, and fixed ABI field order. -It maps JavaScript numbers, nullable properties, property names, color unions, maps, and owned buffers. -It also generates strict locator input keys from the source fields. -The existing C ABI v3 layouts and JavaScript signatures remain unchanged. - -`tools/bindgen/src/napi.rs` generates the `NativeSession` class, every method, and all global functions. -Its `METHODS` table maps JavaScript names to owned variants, result selectors, and trace names. -It derives parameter types, numeric conversions, and operation fields from `Operation` and `MouseAction`. -It derives result types and selection code from `OperationResult`. -Explicit mappings retain argument defaults, grouped options, fixed key actions, and the locator selection flag. -`MouseClickOptions` comes from `MouseAction::Click`; the generator also declares the `Color` transport alias. - -Lifecycle signatures come from the actual functions in `napi/mod.rs`, not a second declaration list. -Generated wrappers call those functions for constructor state, context overrides, registry access, and close semantics. -The clipboard helper chooses a wait operation and validates its regex. -All regular operation methods construct owned Rust operations directly in generated code. -No N-API method calls the C ABI. - -These parts remain handwritten: - -- `src/type_helpers.rs`: C strings, option tags, map entries, color tags, and the result envelope. -- `src/napi/type_helpers.rs`: the strict-input macro and transport imports. -- `input.rs`, `output.rs`, and `napi/convert.rs`: pointer ownership, numeric validation, profile validation, and semantic adapters. -- `lib.rs` and `session.rs`: C entrypoints and shared execution. -- `napi/mod.rs`: worker execution, error envelopes, panic containment, lifecycle state, and clipboard overload semantics. -- The public Go, Python, and TypeScript convenience APIs. - -The generator rejects N-API exports or derive imports outside `napi/types.rs` and `napi/bindings_generated.rs`. -New operation, action, or result variants require an explicit export decision. -This coverage check prevents new core variants from silently missing a boundary mapping. +Transport mappings handle strings, pointer/length pairs, optional values, property names, colors, maps, and numeric conversions. +They are not another list of shared model fields. +Pointer ownership, result cleanup, lifecycle behavior, and public convenience APIs remain handwritten. +The core retains separate input and effective timeout models. -The generator copies C transport helpers into `types.rs`; only `type_helpers.rs` is handwritten. -The result envelope is a transport container, not a mirror of the Rust result enum. -Mouse codes, snapshot codes, capture backgrounds, and locator occurrence rules retain explicit transport logic. -The core still has separate input and effective timeout models. -Generation does not merge different owned API concepts or remove handwritten language convenience APIs. +## Ownership -### Source propagation example - -Run this example from the repository root: - -```sh -env -u CARGO_TARGET_DIR cargo test --locked -p tui-test-bindgen \ - boundaries::tests::owned_field_propagates_to_both_declarations_and_conversions -- --exact -env -u CARGO_TARGET_DIR cargo test --locked -p tui-test-bindgen boundaries::napi::tests -env -u CARGO_TARGET_DIR cargo run --locked -p tui-test-bindgen -- --check -``` - -The test adds a cursor field to the source model in memory. -It verifies that both boundary declarations and both conversions contain the new field. -Other generator tests cover input numbers, strict locator keys, enum variants, missing outputs, and stale outputs. -The N-API tests change operation fields and result payloads in memory. -They verify generated signatures, numeric validation, dispatch, and mouse option declarations. -They also check lifecycle signature propagation, export coverage, trace names, defaults, and rejection of handwritten exports. -Then run the JavaScript terminal example above or the Go smoke example. -These examples exercise the generated result conversions with a real terminal. - -## C ABI ownership - -Input buffers must remain valid until a call returns. The engine copies inputs before it executes an operation. -A null string pointer means absent. A non-null pointer with zero length means an explicit empty string. +Input buffers must remain valid until a call returns. +The engine copies inputs before it executes an operation. +A null string pointer means absent. +A non-null pointer with zero length means an explicit empty string. Every result belongs to this library until `tui_result_free` releases it. -Copy strings, arrays, and packed screen bytes before that call. Do not free individual fields. -The function called determines which result field contains the successful output. +Copy strings, arrays, and packed screen bytes before that call. +Do not free individual fields. +The called function determines which result field contains successful output. Check `error_kind` before reading a successful result. +The JavaScript adapter follows these same rules. +JavaScript values remain valid after the adapter frees the native result. Keep the library loaded while any call or allocation remains active. Increase `ABI_VERSION` when a change makes existing declarations incompatible. -## JavaScript ownership +## Asynchronous calls + +Immediate getters use Koffi's asynchronous calls without a polling timer. +The adapter retains their input buffers until the callback completes. +It limits concurrent reads without limiting the number of callers. + +Other JavaScript operations use generated typed `tui_*_start` entrypoints. +Each start call copies its inputs before it returns an owned `TuiTask`. +A native worker executes the owned operation. +JavaScript polls `tui_task_poll` until it receives the result, then frees the result and task. +The existing synchronous functions remain available to Go and Python. -The N-API adapter moves owned strings, arrays, and screen bytes from Rust results into JavaScript values. -It does not allocate or free C ABI results. -Blocking workers execute operations with a typed session context; they do not serialize that context for each call. -The adapter retains JavaScript numeric validation and the structured error envelope. +This avoids Koffi worker exhaustion: pending terminal waits cannot prevent input or close calls. +The current implementation uses one native thread per active operation and a one-millisecond poll interval. +Freeing a pending task abandons its result; it does not cancel the operation. +Close the session to interrupt its pending waits. diff --git a/crates/tui-test-ffi/build.rs b/crates/tui-test-ffi/build.rs deleted file mode 100644 index f2be9938..00000000 --- a/crates/tui-test-ffi/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(feature = "napi")] - napi_build::setup(); -} diff --git a/crates/tui-test-ffi/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h index aebbd3f5..1c49443d 100644 --- a/crates/tui-test-ffi/include/tui_test.h +++ b/crates/tui-test-ffi/include/tui_test.h @@ -10,6 +10,12 @@ #define ABI_VERSION 3 +/** + * An owned asynchronous call. Poll until a result arrives, then free the task. + * Freeing a pending task discards its eventual result without canceling the call. + */ +typedef struct TuiTask TuiTask; + /** * A borrowed UTF-8 byte string. NULL denotes absence; a non-NULL pointer * with zero length denotes an explicitly empty string. Inputs live through @@ -712,6 +718,460 @@ struct TuiResult *tui_screenshot_ptr(struct TuiString session, */ struct TuiResult *tui_start_recording(struct TuiString session, struct TuiRecordingOptions options); +/** + * Returns NULL while pending. Transfers ownership of a completed result once. + * + * # Safety + * task must be a live task returned by a start function, used by one thread. + */ +struct TuiResult *tui_task_poll(struct TuiTask *task); + +/** + * # Safety + * task must be NULL or a live task, freed exactly once. + */ +void tui_task_free(struct TuiTask *task); + +/** + * Exercise panic containment without terminating the host process. + */ +struct TuiTask *tui_panic_probe_start(void); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_open_start(struct TuiString session, struct TuiOpenOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_run_start(struct TuiString session, + struct TuiOpenOptions options, + struct TuiString program, + const struct TuiString *args, + uintptr_t args_len); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_sessions_start(void); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_close_all_start(void); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_recording_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_close_start(struct TuiString session, struct TuiOptionalBool failed); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_restart_start(struct TuiString session, uint64_t graceful_timeout_ms); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_state_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_command_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_output_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_exit_code_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_cwd_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_cursor_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_size_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_title_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_clipboard_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_bell_count_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_get_bell_events_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_stop_recording_start(struct TuiString session); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_text_start(struct TuiString session, bool full); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_packed_screen_start(struct TuiString session, bool full); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_cells_start(struct TuiString session, + uint16_t x, + uint16_t y, + uint16_t w, + uint16_t h); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_write_start(struct TuiString session, struct TuiString text); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_type_start(struct TuiString session, struct TuiString text); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_submit_start(struct TuiString session, struct TuiString text); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_signal_start(struct TuiString session, struct TuiString text); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_key_start(struct TuiString session, + const struct TuiString *keys, + uintptr_t len, + uint32_t action); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_resize_start(struct TuiString session, uint16_t cols, uint16_t rows); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_click_start(struct TuiString session, + struct TuiOptionalU64 x, + struct TuiOptionalU64 y, + struct TuiString on_text, + struct TuiMouseOptions options, + uint8_t clicks); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_move_start(struct TuiString session, uint16_t x, uint16_t y); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_down_start(struct TuiString session, + uint16_t x, + uint16_t y, + struct TuiMouseOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_up_start(struct TuiString session, + uint16_t x, + uint16_t y, + struct TuiMouseOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_drag_start(struct TuiString session, + uint16_t x1, + uint16_t y1, + uint16_t x2, + uint16_t y2, + struct TuiMouseOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_mouse_scroll_start(struct TuiString session, + struct TuiString direction, + uint16_t amount); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_title_start(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_expect_title_start(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_clipboard_start(struct TuiString session, + struct TuiString text, + struct TuiWaitOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_idle_start(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_command_start(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_exit_start(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_ready_start(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_bell_start(struct TuiString session, struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_find_locator_start(struct TuiString session, + struct TuiQuery query, + bool require_one); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_wait_locator_start(struct TuiString session, + struct TuiQuery query, + struct TuiWaitOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_expect_locator_start(struct TuiString session, + struct TuiQuery query, + struct TuiWaitOptions options); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_click_locator_start(struct TuiString session, + struct TuiQuery query, + struct TuiMouseOptions options, + uint8_t clicks, + struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_highlight_locator_start(struct TuiString session, + struct TuiQuery query, + struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_expect_exit_code_start(struct TuiString session, + int32_t code, + struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_expect_output_start(struct TuiString session, + struct TuiString text, + bool regex); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_expect_bell_count_start(struct TuiString session, + uint64_t count, + struct TuiOptionalU64 timeout); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_snapshot_start(struct TuiString session, + struct TuiString name, + bool update, + bool include_style, + bool include_title, + struct TuiString cwd); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_screenshot_start(struct TuiString session, + bool full, + struct TuiString path, + struct TuiOptionalF64 zoom, + struct TuiString background, + bool transparent); + +/** + * Start a typed call after copying all borrowed inputs. + * # Safety + * Inputs follow the synchronous function contract through this call only. + */ +struct TuiTask *tui_start_recording_start(struct TuiString session, + struct TuiRecordingOptions options); + #ifdef __cplusplus } // extern "C" #endif // __cplusplus diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index e5076528..c8781b4d 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -1,17 +1,20 @@ -//! Native language adapters with shared Rust execution and an optional N-API adapter. +//! Typed C boundary shared by all language adapters. //! C ABI input buffers must remain valid for the call. The adapter copies inputs //! before execution; C output storage belongs to Rust until tui_result_free. mod input; -#[cfg(all(feature = "napi", not(test)))] -mod napi; mod output; mod session; +mod tasks; mod types; use std::panic::{catch_unwind, AssertUnwindSafe}; +pub use tasks::TuiTask; use tui_test::api::*; use tui_test::runtime::global_registry; pub use types::*; +mod tasks_generated; +pub use tasks_generated::*; + pub const ABI_VERSION: u32 = 3; type Result = std::result::Result; @@ -27,10 +30,17 @@ unsafe fn execute( operation_name: &'static str, operation: impl FnOnce() -> Result, ) -> *mut TuiResult { - boundary(|| { + let prepared = catch_unwind(AssertUnwindSafe(|| { let (session, context) = unsafe { input::session(session)? }; - session::execute(session, context, operation_name, operation()?) - }) + Ok((session, context, operation()?)) + })); + match prepared { + Ok(Ok((session, context, operation))) => { + tasks::execute(move || session::execute(session, context, operation_name, operation)) + } + Ok(Err(error)) => output::error(error), + Err(_) => output::error(TuiTestError::internal("native engine adapter panicked")), + } } fn finish_trace_and_close( @@ -146,7 +156,7 @@ pub extern "C" fn tui_sessions() -> *mut TuiResult { } #[no_mangle] pub extern "C" fn tui_close_all() -> *mut TuiResult { - boundary(|| { + tasks::execute(|| { global_registry().close_all(); Ok(OperationResult::Unit) }) @@ -156,20 +166,22 @@ pub extern "C" fn tui_close_all() -> *mut TuiResult { /// 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()? }; - session::recording(&session) - }) + match unsafe { session.required() } { + Ok(session) => tasks::execute(move || session::recording(&session)), + Err(error) => output::error(error), + } } #[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)? }; - session::close(name, context, failed.option()) - }) + match unsafe { input::session(session) } { + Ok((name, context)) => { + tasks::execute(move || session::close(name, context, failed.option())) + } + Err(error) => output::error(error), + } } #[no_mangle] /// # Safety diff --git a/crates/tui-test-ffi/src/napi/bindings_generated.rs b/crates/tui-test-ffi/src/napi/bindings_generated.rs deleted file mode 100644 index 98b06eb2..00000000 --- a/crates/tui-test-ffi/src/napi/bindings_generated.rs +++ /dev/null @@ -1,773 +0,0 @@ -// Generated by cargo run -p tui-test-bindgen. Do not edit. -use super::*; -use napi_derive::napi; -use tui_test::api::{KeyAction, MouseAction}; -#[napi] -pub struct NativeSession { - inner: SessionRuntime, -} -#[napi] -impl NativeSession { - #[napi(constructor)] - pub fn new( - name: String, - recording: Option, - artifacts: Option, - trace: Option, - ) -> Result { - SessionRuntime::new(name, recording, artifacts, trace).map(|inner| Self { inner }) - } - #[napi] - pub fn name(&self) -> String { - self.inner.name() - } - #[napi] - pub async fn open(&self, options: Option) -> Result { - self.inner.open(options).await - } - #[napi] - pub async fn run(&self, options: RunOptions) -> Result { - self.inner.run(options).await - } - #[napi] - pub async fn restart(&self, graceful_timeout_ms: f64) -> Result { - self.inner - .call( - "restart", - move || { - Ok(Operation::Restart { - graceful_timeout_ms: integer( - graceful_timeout_ms, - "gracefulTimeoutMs", - u64::MAX, - )?, - }) - }, - open_result, - ) - .await - } - #[napi] - pub async fn close(&self, failed: Option) -> Result<()> { - self.inner.close(failed).await - } - #[napi] - pub async fn state(&self) -> Result { - self.inner - .call("state", move || Ok(Operation::State), state_result) - .await - } - #[napi] - pub async fn text(&self, full: Option) -> Result { - self.inner - .call( - "text", - move || { - Ok(Operation::Text { - full: full.unwrap_or(false), - }) - }, - string_result, - ) - .await - } - #[napi] - pub async fn packed_screen(&self, full: Option) -> Result { - self.inner - .call( - "packedScreen", - move || { - Ok(Operation::PackedScreen { - full: full.unwrap_or(false), - }) - }, - packed_screen_result, - ) - .await - } - #[napi] - pub async fn cells(&self, x: f64, y: f64, w: Option, h: Option) -> Result> { - self.inner - .call( - "cells", - move || { - Ok(Operation::Cells { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - w: u16_value(w.unwrap_or(1.0), "w")?, - h: u16_value(h.unwrap_or(1.0), "h")?, - }) - }, - cells_result, - ) - .await - } - #[napi] - pub async fn find_locator( - &self, - expression: LocatorExpression, - require_one: Option, - ) -> Result> { - let require_one = require_one.unwrap_or(false); - self.inner - .call( - if require_one { - "locator.location" - } else { - "locator.locations" - }, - move || { - Ok(if require_one { - Operation::ResolveLocator { - query: query(expression)?, - } - } else { - Operation::FindLocator { - query: query(expression)?, - } - }) - }, - matches, - ) - .await - } - #[napi] - pub async fn wait_locator( - &self, - expression: LocatorExpression, - not: Option, - timeout_ms: Option, - ) -> Result<()> { - self.inner - .call( - "locator.wait", - move || { - Ok(Operation::WaitLocator { - query: query(expression)?, - not: not.unwrap_or(false), - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_locator( - &self, - expression: LocatorExpression, - not: Option, - timeout_ms: Option, - ) -> Result<()> { - self.inner - .call( - "locator.expect", - move || { - Ok(Operation::WaitLocator { - query: query(expression)?, - not: not.unwrap_or(false), - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn click_locator( - &self, - expression: LocatorExpression, - button: Option, - clicks: Option, - timeout_ms: Option, - ) -> Result<()> { - self.inner - .call( - "locator.click", - move || { - Ok(Operation::ClickLocator { - query: query(expression)?, - options: mouse_options(button.unwrap_or(0.0))?, - clicks: u8_value(clicks.unwrap_or(1.0), "clicks")?, - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn highlight_locator( - &self, - expression: LocatorExpression, - timeout_ms: Option, - ) -> Result> { - self.inner - .call( - "locator.highlight", - move || { - Ok(Operation::HighlightLocator { - query: query(expression)?, - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - matches, - ) - .await - } - #[napi] - pub async fn panic_probe(&self) -> Result<()> { - blocking(|| panic!("intentional native panic probe")).await - } - #[napi] - pub async fn get_command(&self) -> Result> { - self.inner - .call( - "getCommand", - move || Ok(Operation::GetCommand), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_output(&self) -> Result> { - self.inner - .call( - "getOutput", - move || Ok(Operation::GetOutput), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_exit_code(&self) -> Result> { - self.inner - .call( - "getExitCode", - move || Ok(Operation::GetExitCode), - exit_code_result, - ) - .await - } - #[napi] - pub async fn get_cwd(&self) -> Result> { - self.inner - .call( - "getCwd", - move || Ok(Operation::GetCwd), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_title(&self) -> Result> { - self.inner - .call( - "getTitle", - move || Ok(Operation::GetTitle), - optional_string_result, - ) - .await - } - #[napi] - pub async fn get_clipboard(&self) -> Result { - self.inner - .call( - "getClipboard", - move || Ok(Operation::GetClipboard), - string_result, - ) - .await - } - #[napi] - pub async fn get_cursor(&self) -> Result { - self.inner - .call("getCursor", move || Ok(Operation::GetCursor), cursor_result) - .await - } - #[napi] - pub async fn get_size(&self) -> Result { - self.inner - .call("getSize", move || Ok(Operation::GetSize), size_result) - .await - } - #[napi] - pub async fn get_bell_count(&self) -> Result { - self.inner - .call( - "getBellCount", - move || Ok(Operation::GetBellCount), - bell_count_result, - ) - .await - } - #[napi] - pub async fn get_bell_events(&self) -> Result> { - self.inner - .call( - "getBellEvents", - move || Ok(Operation::GetBellEvents), - bell_events_result, - ) - .await - } - #[napi] - pub async fn write(&self, data: String) -> Result<()> { - self.inner - .call("write", move || Ok(Operation::Write { data }), unit) - .await - } - #[napi(js_name = "type")] - pub async fn type_text(&self, text: String) -> Result<()> { - self.inner - .call("type", move || Ok(Operation::Write { data: text }), unit) - .await - } - #[napi] - pub async fn submit(&self, data: Option) -> Result<()> { - self.inner - .call("submit", move || Ok(Operation::Submit { data }), unit) - .await - } - #[napi] - pub async fn press(&self, keys: Vec) -> Result<()> { - self.inner - .call( - "press", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Press, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn key_down(&self, keys: Vec) -> Result<()> { - self.inner - .call( - "keyDown", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Down, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn repeat(&self, keys: Vec) -> Result<()> { - self.inner - .call( - "repeat", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Repeat, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn key_up(&self, keys: Vec) -> Result<()> { - self.inner - .call( - "keyUp", - move || { - Ok(Operation::Key { - keys, - action: KeyAction::Up, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_click(&self, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.inner - .call( - "mouse.click", - move || { - Ok(Operation::Mouse { - action: MouseAction::Click { - x: options - .x - .map(|v| -> Result<_> { u16_value(v, "x") }) - .transpose()?, - y: options - .y - .map(|v| -> Result<_> { u16_value(v, "y") }) - .transpose()?, - on_text: options.on_text, - options: mouse_options(options.button.unwrap_or(0.0))?, - clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> { - self.inner - .call( - "mouse.move", - move || { - Ok(Operation::Mouse { - action: MouseAction::Move { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_down(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.inner - .call( - "mouse.down", - move || { - Ok(Operation::Mouse { - action: MouseAction::Down { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { - self.inner - .call( - "mouse.up", - move || { - Ok(Operation::Mouse { - action: MouseAction::Up { - x: u16_value(x, "x")?, - y: u16_value(y, "y")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_drag( - &self, - x1: f64, - y1: f64, - x2: f64, - y2: f64, - button: Option, - ) -> Result<()> { - self.inner - .call( - "mouse.drag", - move || { - Ok(Operation::Mouse { - action: MouseAction::Drag { - x1: u16_value(x1, "x1")?, - y1: u16_value(y1, "y1")?, - x2: u16_value(x2, "x2")?, - y2: u16_value(y2, "y2")?, - options: mouse_options(button.unwrap_or(0.0))?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn mouse_scroll(&self, direction: String, amount: Option) -> Result<()> { - self.inner - .call( - "mouse.scroll", - move || { - Ok(Operation::Mouse { - action: MouseAction::Scroll { - direction, - amount: u16_value(amount.unwrap_or(3.0), "amount")?, - }, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn resize(&self, cols: f64, rows: f64) -> Result<()> { - self.inner - .call( - "resize", - move || { - Ok(Operation::Resize { - cols: u16_value(cols, "cols")?, - rows: u16_value(rows, "rows")?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn signal(&self, name: String) -> Result<()> { - self.inner - .call("signal", move || Ok(Operation::Signal { name }), unit) - .await - } - #[napi] - pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.inner - .call( - "waitTitle", - move || wait_title_operation(options, text), - unit, - ) - .await - } - #[napi] - pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { - let options = options.unwrap_or_default(); - self.inner - .call( - "expectTitle", - move || expect_title_operation(options, text), - unit, - ) - .await - } - #[napi] - pub async fn wait_clipboard( - &self, - text: Option, - options: Option, - ) -> Result<()> { - let options = options.unwrap_or_default(); - self.inner - .call( - "waitClipboard", - move || wait_clipboard_operation(text, options), - unit, - ) - .await - } - #[napi] - pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "waitIdle", - move || { - Ok(Operation::WaitIdle { - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "waitCommand", - move || { - Ok(Operation::WaitCommand { - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "waitExit", - move || { - Ok(Operation::WaitExit { - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "waitReady", - move || { - Ok(Operation::WaitReady { - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "waitBell", - move || { - Ok(Operation::WaitBell { - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "expectExitCode", - move || { - Ok(Operation::ExpectExitCode { - code: i32_value(code, "code")?, - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.inner - .call( - "expectOutput", - move || { - Ok(Operation::ExpectOutput { - text, - regex: regex.unwrap_or(false), - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { - self.inner - .call( - "expectBellCount", - move || { - Ok(Operation::ExpectBellCount { - count: integer(count, "count", u64::MAX)?, - timeout_ms: timeout_ms - .map(|v| -> Result<_> { integer(v, "timeoutMs", u64::MAX) }) - .transpose()?, - }) - }, - unit, - ) - .await - } - #[napi] - pub async fn snapshot( - &self, - name: String, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - self.inner - .call( - "expectSnapshot", - move || snapshot_operation(options, name), - snapshot_result, - ) - .await - } - #[napi] - pub async fn screenshot(&self, options: Option) -> Result { - let options = options.unwrap_or_default(); - self.inner - .call( - "screenshot", - move || screenshot_operation(options), - string_result, - ) - .await - } - #[napi] - pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { - self.inner - .call( - "startRecording", - move || start_recording_operation(options), - unit, - ) - .await - } - #[napi] - pub async fn stop_recording(&self) -> Result { - self.inner - .call( - "stopRecording", - move || Ok(Operation::StopRecording), - string_result, - ) - .await - } -} -#[napi] -pub async fn sessions() -> Result> { - super::sessions().await -} -#[napi] -pub fn close_all_sync() -> Result<()> { - super::close_all_sync() -} -#[napi] -pub async fn recording(name: String) -> Result { - super::recording(name).await -} -#[napi] -pub async fn close_all() -> Result<()> { - blocking(super::close_all_sync).await -} diff --git a/crates/tui-test-ffi/src/napi/convert.rs b/crates/tui-test-ffi/src/napi/convert.rs deleted file mode 100644 index 76eee950..00000000 --- a/crates/tui-test-ffi/src/napi/convert.rs +++ /dev/null @@ -1,116 +0,0 @@ -use super::{internal, native_error, types::*, usage, MAX_SAFE_INTEGER}; -use napi::bindgen_prelude::Either; -use napi::Result; -use tui_test::api::{self, OperationResult}; -use tui_test::profile::{Profile, Rgb}; - -include!("convert_generated.rs"); - -pub fn integer(value: f64, name: &str, max: u64) -> Result { - let max = max.min(MAX_SAFE_INTEGER); - if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > max as f64 { - return Err(usage(format!( - "{name} must be an integer between 0 and {max}" - ))); - } - Ok(value as u64) -} - -pub fn optional_integer(value: Option, name: &str, max: u64) -> Result> { - value.map(|value| integer(value, name, max)).transpose() -} - -pub fn u16_value(value: f64, name: &str) -> Result { - Ok(integer(value, name, u16::MAX.into())? as u16) -} - -pub fn optional_u16(value: Option, name: &str) -> Result> { - value.map(|value| u16_value(value, name)).transpose() -} - -pub fn u8_value(value: f64, name: &str) -> Result { - Ok(integer(value, name, u8::MAX.into())? as u8) -} - -pub fn i32_value(value: f64, name: &str) -> Result { - if !value.is_finite() - || value.fract() != 0.0 - || value < i32::MIN as f64 - || value > i32::MAX as f64 - { - return Err(usage(format!( - "{name} must be an integer between {} and {}", - i32::MIN, - i32::MAX - ))); - } - Ok(value as i32) -} - -pub fn mouse_options(value: f64) -> Result { - let code = u8_value(value, "button")?; - if code & !31 != 0 || code & 3 == 3 { - return Err(usage(format!("invalid mouse button code {code}"))); - } - Ok(api::MouseOptions { - button: match code & 3 { - 0 => api::MouseButton::Left, - 1 => api::MouseButton::Middle, - _ => api::MouseButton::Right, - }, - shift: code & 4 != 0, - alt: code & 8 != 0, - ctrl: code & 16 != 0, - }) -} - -pub fn open_options(value: OpenOptions, directory: Option) -> Result { - let mut profile = Profile::default(); - if let Some(scrollback) = optional_integer( - value.profile_scrollback, - "profile.scrollback", - usize::MAX as u64, - )? { - profile.scrollback = scrollback as usize; - } - for (name, color) in value.profile_colors.as_deref().unwrap_or_default() { - let color = Rgb::parse(color).map_err(usage)?; - if !profile.colors.set_named(name, color) { - return Err(usage(format!("unknown profile color {name:?}"))); - } - } - open_input(value, profile, directory) -} - -fn occurrence( - value: Option, - nth: Option, - index: usize, -) -> Result { - let occurrence = if let Some(nth) = nth { - if let Some(value) = value.as_deref().filter(|value| *value != "nth") { - return Err(usage(format!( - "nodes[{index}].nth cannot be used with occurrence '{value}'" - ))); - } - serde_json::json!({"nth": integer(nth, &format!("nodes[{index}].nth"), usize::MAX as u64)?}) - } else { - match value.as_deref().unwrap_or("any") { - "any" | "unique" | "first" | "last" => value.unwrap_or_else(|| "any".into()).into(), - "nth" => return Err(usage(format!("nodes[{index}].nth requires an nth index"))), - value => return Err(usage(format!("invalid occurrence '{value}'"))), - } - }; - serde_json::from_value(occurrence) - .map_err(|error| usage(format!("invalid locator expression: {error}"))) -} - -pub fn color(value: api::CellColor) -> Color { - match value { - api::CellColor::Default => Either::B("default".into()), - api::CellColor::Indexed(index) => Either::A(index.into()), - api::CellColor::Rgb(red, green, blue) => { - Either::B(format!("#{red:02x}{green:02x}{blue:02x}")) - } - } -} diff --git a/crates/tui-test-ffi/src/napi/convert_generated.rs b/crates/tui-test-ffi/src/napi/convert_generated.rs deleted file mode 100644 index 9483e086..00000000 --- a/crates/tui-test-ffi/src/napi/convert_generated.rs +++ /dev/null @@ -1,498 +0,0 @@ -// Generated by cargo run -p tui-test-bindgen. Do not edit. -pub fn open_value(value: api::OpenResult) -> OpenResult { - OpenResult { - shell_pid: value.shell_pid, - session: value.session, - ready: value.ready, - recording: value.recording, - } -} -pub fn cursor(value: api::Cursor) -> Cursor { - Cursor { - x: value.x, - y: value.y, - visible: value.visible, - shape: value.shape, - color: value.color, - } -} -pub fn size(value: api::Size) -> Size { - Size { - cols: value.cols, - rows: value.rows, - } -} -pub fn effective_timeouts(value: api::EffectiveTimeouts) -> EffectiveTimeouts { - EffectiveTimeouts { - text: value.text as f64, - idle: value.idle as f64, - command: value.command as f64, - exit: value.exit as f64, - ready: value.ready as f64, - } -} -pub fn bell_event(value: api::BellEvent) -> BellEvent { - BellEvent { - sequence: value.sequence as f64, - elapsed_ms: value.elapsed_ms as f64, - } -} -pub fn state(value: api::State) -> State { - State { - session_shell: value.session_shell, - cols: value.cols, - rows: value.rows, - cursor: cursor(value.cursor), - title: value.title, - cwd: value.cwd, - last_command: value.last_command, - last_exit: value.last_exit, - exit_signal: value.exit_signal, - exited: value.exited, - ready: value.ready, - bell_count: value.bell_count as f64, - modes: value.modes.into_iter().collect(), - mouse_mode: value.mouse_mode, - colors: terminal_colors(value.colors), - timeouts: effective_timeouts(value.timeouts), - text: value.text, - } -} -pub fn terminal_colors(value: api::TerminalColors) -> TerminalColors { - TerminalColors { - foreground: value.foreground, - background: value.background, - cursor: value.cursor, - palette: value - .palette - .into_iter() - .map(|(index, color)| (index.to_string(), color)) - .collect(), - } -} -pub fn cell(value: api::Cell) -> Result { - Ok(Cell { - x: value.x, - y: value.y, - r#char: value.r#char, - fg: color(value.fg), - bg: color(value.bg), - bold: value.bold, - dim: value.dim, - italic: value.italic, - inverse: value.inverse, - invisible: value.invisible, - strike: value.strike, - blink: value.blink, - underline: value.underline, - underline_style: underline_style(&value.underline_style)?, - underline_color: color(value.underline_color), - link: value.link, - link_id: value.link_id, - }) -} -pub fn text_position(value: api::TextPosition) -> TextPosition { - TextPosition { - row: value.row, - column: value.column, - } -} -pub fn text_span(value: api::TextSpan) -> TextSpan { - TextSpan { - row: value.row, - start: value.start, - end: value.end, - } -} -pub fn text_match(value: api::TextMatch) -> TextMatch { - TextMatch { - text: value.text, - start: text_position(value.start), - end: text_position(value.end), - spans: value.spans.into_iter().map(text_span).collect(), - } -} -pub fn packed_screen(value: api::PackedScreen) -> PackedScreen { - PackedScreen { - cols: value.cols, - rows: value.rows, - utf8: value.utf8.into(), - } -} -impl From for tui_test::terminal::backend::Backend { - fn from(value: Backend) -> Self { - match value { - Backend::Alacritty => Self::Alacritty, - Backend::Ghostty => Self::Ghostty, - Backend::Rio => Self::Rio, - Backend::Xtermjs => Self::Xtermjs, - } - } -} -impl From for tui_test::shell::Shell { - fn from(value: Shell) -> Self { - match value { - Shell::Bash => Self::Bash, - Shell::Powershell => Self::Powershell, - Shell::Pwsh => Self::Pwsh, - Shell::Cmd => Self::Cmd, - Shell::Fish => Self::Fish, - Shell::Zsh => Self::Zsh, - Shell::Xonsh => Self::Xonsh, - Shell::Elvish => Self::Elvish, - Shell::Nushell => Self::Nushell, - } - } -} -impl From for tui_test::api::RecordingFormat { - fn from(value: RecordingFormat) -> Self { - match value { - RecordingFormat::Apng => Self::Apng, - RecordingFormat::Gif => Self::Gif, - RecordingFormat::Mp4 => Self::Mp4, - RecordingFormat::Cast => Self::Cast, - } - } -} -impl From for tui_test::api::SnapshotResult { - fn from(value: SnapshotResult) -> Self { - match value { - SnapshotResult::Passed => Self::Passed, - SnapshotResult::Written => Self::Written, - SnapshotResult::Updated => Self::Updated, - } - } -} -impl From for SnapshotResult { - fn from(value: tui_test::api::SnapshotResult) -> Self { - match value { - tui_test::api::SnapshotResult::Passed => Self::Passed, - tui_test::api::SnapshotResult::Written => Self::Written, - tui_test::api::SnapshotResult::Updated => Self::Updated, - } - } -} -fn underline_style(value: &str) -> Result { - Ok(match value { - "none" => UnderlineStyle::None, - "single" => UnderlineStyle::Single, - "double" => UnderlineStyle::Double, - "curly" => UnderlineStyle::Curly, - "dotted" => UnderlineStyle::Dotted, - "dashed" => UnderlineStyle::Dashed, - value => { - return Err(internal(format!( - "terminal returned unknown underline style '{value}'" - ))) - } - }) -} -impl From for tui_test::locator_query::LocatorNodeKind { - fn from(value: LocatorNodeKind) -> Self { - match value { - LocatorNodeKind::Text => Self::Text, - LocatorNodeKind::Style => Self::Style, - LocatorNodeKind::Link => Self::Link, - LocatorNodeKind::And => Self::And, - LocatorNodeKind::Or => Self::Or, - LocatorNodeKind::Filter => Self::Filter, - } - } -} -impl From for tui_test::api::LocatorDirection { - fn from(value: LocatorStageDirection) -> Self { - match value { - LocatorStageDirection::Within => Self::Within, - LocatorStageDirection::After => Self::After, - LocatorStageDirection::Before => Self::Before, - } - } -} -pub fn timeouts(value: Timeouts) -> Result { - Ok(api::Timeouts { - text: optional_integer(value.text, "timeouts.text", u64::MAX)?, - idle: optional_integer(value.idle, "timeouts.idle", u64::MAX)?, - command: optional_integer(value.command, "timeouts.command", u64::MAX)?, - exit: optional_integer(value.exit, "timeouts.exit", u64::MAX)?, - ready: optional_integer(value.ready, "timeouts.ready", u64::MAX)?, - }) -} -fn open_input( - value: OpenOptions, - profile: Profile, - directory: Option, -) -> Result { - Ok(api::OpenOptions { - backend: value.backend.map(Into::into).unwrap_or_default(), - shell: value.shell.map(Into::into), - profile, - cols: u16_value(value.cols.unwrap_or(80.0), "cols")?, - rows: u16_value(value.rows.unwrap_or(30.0), "rows")?, - cwd: value.cwd, - env: value.env.unwrap_or_default(), - wait_ready: value.wait_ready, - restart: value.restart.unwrap_or_default(), - timeouts: timeouts(value.timeouts.unwrap_or_default())?, - recording: api::AutomaticRecording { - mode: api::AutomaticRecordingMode::Disabled, - directory: directory.map(Into::into), - }, - }) -} -pub fn apply_artifact(context: &mut crate::session::SessionInput, value: FailureArtifactOptions) { - context.artifact_directory = Some(value.directory); - context.artifact_mode = Some(value.mode.unwrap_or_else(|| "all".into())); - context.artifact_include_recording = value.include_recording.unwrap_or(false); -} -pub fn apply_trace(context: &mut crate::session::SessionInput, value: TraceOptions) { - context.trace_mode = Some(value.mode.unwrap_or_else(|| "off".into())); - context.trace_directory = value.directory; -} -pub fn open_options_context( - context: &mut crate::session::SessionInput, - value: &OpenOptions, -) -> Result<()> { - context.screen_history_limit = optional_u16(value.screen_history_limit, "screenHistoryLimit")?; - Ok(()) -} -pub fn run_options_context( - context: &mut crate::session::SessionInput, - value: &RunOptions, -) -> Result<()> { - context.screen_history_limit = optional_u16(value.screen_history_limit, "screenHistoryLimit")?; - Ok(()) -} -pub fn run_options(value: RunOptions, directory: Option) -> Result { - let open = open_options( - OpenOptions { - backend: value.backend, - cols: value.cols, - rows: value.rows, - cwd: value.cwd, - env: value.env, - wait_ready: value.wait_ready, - restart: value.restart, - profile_scrollback: value.profile_scrollback, - profile_colors: value.profile_colors, - timeouts: value.timeouts, - screen_history_limit: value.screen_history_limit, - ..Default::default() - }, - directory, - )?; - if value.program.is_empty() { - return Err(usage("program must not be empty")); - } - Ok(api::RunOptions { - backend: open.backend, - program: value.program, - args: value.args.unwrap_or_default(), - profile: open.profile, - cols: open.cols, - rows: open.rows, - cwd: open.cwd, - env: open.env, - wait_ready: open.wait_ready, - restart: open.restart, - timeouts: open.timeouts, - recording: open.recording, - }) -} -pub fn snapshot_operation(value: SnapshotOptions, name: String) -> Result { - Ok(api::Operation::Snapshot { - name, - update: value.update.unwrap_or(false), - include_style: value.include_style.unwrap_or(false), - include_title: value.include_title.unwrap_or(false), - cwd: value.cwd, - }) -} -pub fn screenshot_operation(value: ScreenshotOptions) -> Result { - Ok(api::Operation::Screenshot { - full: value.full.unwrap_or(false), - path: value.path, - zoom: value.zoom, - background: crate::session::capture_background( - value.background, - value.transparent.unwrap_or(false), - ) - .map_err(native_error)?, - }) -} -pub fn start_recording_operation(value: RecordingOptions) -> Result { - Ok(api::Operation::StartRecording { - path: value.path, - format: value.format.map(Into::into), - fps: value.fps.map(|fps| u8_value(fps, "fps")).transpose()?, - speed: value.speed, - idle_time_limit: value.idle_time_limit, - zoom: value.zoom, - background: crate::session::capture_background( - value.background, - value.transparent.unwrap_or(false), - ) - .map_err(native_error)?, - }) -} -pub fn wait_title_operation(value: TitleOptions, text: String) -> Result { - Ok(api::Operation::WaitTitle { - text, - regex: value.regex.unwrap_or(false), - timeout_ms: optional_integer(value.timeout_ms, "timeoutMs", u64::MAX)?, - not: value.not.unwrap_or(false), - }) -} -pub fn expect_title_operation(value: TitleOptions, text: String) -> Result { - Ok(api::Operation::ExpectTitle { - text, - regex: value.regex.unwrap_or(false), - not: value.not.unwrap_or(false), - timeout_ms: optional_integer(value.timeout_ms, "timeoutMs", u64::MAX)?, - }) -} -fn locator_style(value: LocatorStyle) -> api::TextStyle { - api::TextStyle { - foreground: value.foreground, - background: value.background, - bold: value.bold, - dim: value.dim, - italic: value.italic, - underline_style: value.underline_style, - underline_color: value.underline_color, - inverse: value.inverse, - hidden: value.hidden, - strikethrough: value.strikethrough, - blink: value.blink, - } -} -fn locator_node(value: LocatorNode, index: usize) -> Result { - Ok(tui_test::locator_query::LocatorNode { - kind: value.kind.into(), - text: value.text, - regex: value.regex, - whitespace: value - .whitespace - .map(|s| { - serde_json::from_value(serde_json::Value::String(s)) - .map_err(|error| usage(format!("invalid locator expression: {error}"))) - }) - .transpose()?, - style: value.style.map(locator_style), - link: value.link, - full: value.full, - direction: value.direction.map(Into::into), - within: optional_integer(value.within, "locator reference", 255)?.map(|v| v as usize), - left: optional_integer(value.left, "locator reference", 255)?.map(|v| v as usize), - right: optional_integer(value.right, "locator reference", 255)?.map(|v| v as usize), - input: optional_integer(value.input, "locator reference", 255)?.map(|v| v as usize), - has: optional_integer(value.has, "locator reference", 255)?.map(|v| v as usize), - has_not: optional_integer(value.has_not, "locator reference", 255)?.map(|v| v as usize), - occurrence: occurrence(value.occurrence, value.nth, index)?, - }) -} -pub fn query(value: LocatorExpression) -> Result { - tui_test::locator_query::LocatorExpression { - nodes: value - .nodes - .into_iter() - .enumerate() - .map(|(i, v)| locator_node(v, i)) - .collect::>()?, - root: integer(value.root, "locator root", 255)? as usize, - } - .into_query() - .map_err(native_error) -} -pub fn unit(value: OperationResult) -> Result<()> { - match value { - OperationResult::Unit => Ok(()), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn open_result(value: OperationResult) -> Result { - match value { - OperationResult::Open(value) => Ok(open_value(value)), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn state_result(value: OperationResult) -> Result { - match value { - OperationResult::State(value) => Ok(state(*value)), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn string_result(value: OperationResult) -> Result { - match value { - OperationResult::Text(value) => Ok(value), - OperationResult::Clipboard(value) => Ok(value), - OperationResult::Recording(value) => Ok(value), - OperationResult::Screenshot(value) => match value { - api::ScreenshotResult::Path(value) => Ok(value), - api::ScreenshotResult::Text(value) => Ok(value), - }, - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn packed_screen_result(value: OperationResult) -> Result { - match value { - OperationResult::PackedScreen(value) => Ok(packed_screen(value)), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn cells_result(value: OperationResult) -> Result> { - match value { - OperationResult::Cells(value) => value.into_iter().map(cell).collect(), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn matches(value: OperationResult) -> Result> { - match value { - OperationResult::Matches(value) => Ok(value.into_iter().map(text_match).collect()), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn optional_string_result(value: OperationResult) -> Result> { - match value { - OperationResult::Command(value) => Ok(value), - OperationResult::Output(value) => Ok(value), - OperationResult::Cwd(value) => Ok(value), - OperationResult::Title(value) => Ok(value), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn exit_code_result(value: OperationResult) -> Result> { - match value { - OperationResult::ExitCode(value) => Ok(value), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn cursor_result(value: OperationResult) -> Result { - match value { - OperationResult::Cursor(value) => Ok(cursor(value)), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn size_result(value: OperationResult) -> Result { - match value { - OperationResult::Size(value) => Ok(size(value)), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn bell_count_result(value: OperationResult) -> Result { - match value { - OperationResult::BellCount(value) => Ok(value as f64), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn bell_events_result(value: OperationResult) -> Result> { - match value { - OperationResult::BellEvents(value) => Ok(value.into_iter().map(bell_event).collect()), - _ => Err(internal("native operation returned an unexpected result")), - } -} -pub fn snapshot_result(value: OperationResult) -> Result { - match value { - OperationResult::Snapshot(value) => Ok(value.into()), - _ => Err(internal("native operation returned an unexpected result")), - } -} diff --git a/crates/tui-test-ffi/src/napi/mod.rs b/crates/tui-test-ffi/src/napi/mod.rs deleted file mode 100644 index 8fe8933b..00000000 --- a/crates/tui-test-ffi/src/napi/mod.rs +++ /dev/null @@ -1,207 +0,0 @@ -#![deny(clippy::all)] - -mod bindings_generated; -mod convert; -mod types; - -use self::types::*; -use crate::session::{self, SessionInput}; -use convert::*; -use tui_test::api::{self, Operation, OperationResult}; -use tui_test::runtime::global_registry; - -use ::napi::bindgen_prelude::spawn_blocking; -use ::napi::{Error, Result, Status}; - -const ERROR_PREFIX: &str = "__tui_test_native_error__:"; -const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; - -fn error(kind: &str, message: impl Into) -> Error { - Error::new( - Status::GenericFailure, - format!( - "{ERROR_PREFIX}{}", - serde_json::json!({ - "kind": kind, "message": message.into(), - }) - ), - ) -} - -fn internal(message: impl Into) -> Error { - error("internal", message) -} -fn usage(message: impl Into) -> Error { - error("usage", message) -} - -fn native_error(error: api::TuiTestError) -> Error { - let mut envelope = serde_json::json!({ - "kind": error.kind.as_str(), "message": error.message, - }); - if let Some(details) = error.details { - envelope["details"] = match serde_json::to_value(details) { - Ok(value) => value, - Err(error) => return internal(format!("failed to serialize native details: {error}")), - }; - } - if let Some(artifact) = error.artifact { - envelope["artifact"] = match serde_json::to_value(artifact) { - Ok(value) => value, - Err(error) => return internal(format!("failed to serialize native artifact: {error}")), - }; - } - Error::new(Status::GenericFailure, format!("{ERROR_PREFIX}{envelope}")) -} - -fn boundary(work: impl FnOnce() -> Result) -> Result { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) { - Ok(result) => result, - Err(payload) => { - let message = payload - .downcast_ref::<&str>() - .copied() - .or_else(|| payload.downcast_ref::().map(String::as_str)) - .unwrap_or("non-string panic payload"); - Err(internal(format!("native binding panicked: {message}"))) - } - } -} - -async fn blocking( - work: impl FnOnce() -> Result + Send + 'static, -) -> Result { - spawn_blocking(move || boundary(work)) - .await - .map_err(|error| internal(format!("native worker failed: {error}")))? -} - -async fn execute( - context: SessionInput, - operation_name: &'static str, - operation: impl FnOnce() -> Result + Send + 'static, - convert: impl FnOnce(OperationResult) -> Result + Send + 'static, -) -> Result { - blocking(move || { - let (name, context) = context.validate().map_err(native_error)?; - convert( - session::execute(name, context, operation_name, operation()?).map_err(native_error)?, - ) - }) - .await -} - -struct SessionRuntime { - name: String, - context: SessionInput, - recording_directory: Option, -} - -impl SessionRuntime { - fn new( - name: String, - recording: Option, - artifacts: Option, - trace: Option, - ) -> Result { - let mut context = SessionInput { - name: name.clone(), - ..Default::default() - }; - if let Some(options) = artifacts { - apply_artifact(&mut context, options); - } - if let Some(options) = trace { - apply_trace(&mut context, options); - } - Ok(Self { - name, - context, - recording_directory: recording.and_then(|options| options.directory), - }) - } - - fn name(&self) -> String { - self.name.clone() - } - - async fn open(&self, options: Option) -> Result { - let options = options.unwrap_or_default(); - let mut context = self.context.clone(); - open_options_context(&mut context, &options)?; - let directory = self.recording_directory.clone(); - execute( - context, - "open", - move || Ok(Operation::Open(open_options(options, directory)?)), - open_result, - ) - .await - } - - async fn run(&self, options: RunOptions) -> Result { - let mut context = self.context.clone(); - run_options_context(&mut context, &options)?; - let directory = self.recording_directory.clone(); - execute( - context, - "run", - move || Ok(Operation::Run(run_options(options, directory)?)), - open_result, - ) - .await - } - - async fn close(&self, failed: Option) -> Result<()> { - let context = self.context.clone(); - blocking(move || { - let (name, context) = context.validate().map_err(native_error)?; - unit(session::close(name, context, failed).map_err(native_error)?) - }) - .await - } - - async fn call( - &self, - operation_name: &'static str, - operation: impl FnOnce() -> Result + Send + 'static, - convert: impl FnOnce(OperationResult) -> Result + Send + 'static, - ) -> Result { - execute(self.context.clone(), operation_name, operation, convert).await - } -} - -async fn sessions() -> Result> { - blocking(|| Ok(global_registry().sessions())).await -} - -fn close_all_sync() -> Result<()> { - boundary(|| { - global_registry().close_all(); - Ok(()) - }) -} - -async fn recording(name: String) -> Result { - blocking(move || string_result(session::recording(&name).map_err(native_error)?)).await -} - -fn wait_clipboard_operation( - text: Option, - options: ClipboardWaitOptions, -) -> Result { - let timeout_ms = optional_integer(options.timeout_ms, "timeoutMs", u64::MAX)?; - match text { - Some(text) => Ok(Operation::WaitClipboardMatch { - pattern: if options.regex.unwrap_or(false) { - api::ClipboardPattern::regex(&text) - .map_err(|error| usage(format!("invalid regex: {error}")))? - } else { - text.into() - }, - timeout_ms, - }), - None if options.regex.unwrap_or(false) => Err(usage("clipboard regex requires text")), - None => Ok(Operation::WaitClipboard { timeout_ms }), - } -} diff --git a/crates/tui-test-ffi/src/napi/type_helpers.rs b/crates/tui-test-ffi/src/napi/type_helpers.rs deleted file mode 100644 index 94108312..00000000 --- a/crates/tui-test-ffi/src/napi/type_helpers.rs +++ /dev/null @@ -1,25 +0,0 @@ -use ::napi::bindgen_prelude::{ - Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, -}; -use napi::{Error, Result, Status}; -use std::collections::HashMap; - -// Reject unknown fields before conversion so misspelled predicates cannot -// silently broaden a locator. The generator supplies the field names. -macro_rules! strict_locator_input { - ($name:ident { $($field:ident => $key:literal),* $(,)? }) => { - impl ValidateNapiValue for $name {} - impl FromNapiValue for $name { - unsafe fn from_napi_value(env: napi::sys::napi_env, value: napi::sys::napi_value) -> Result { - unsafe { Object::validate(env, value)?; } - let object = unsafe { Object::from_napi_value(env, value)? }; - for key in Object::keys(&object)? { - if ![$($key),*].contains(&key.as_str()) { - return Err(Error::new(Status::InvalidArg, format!("unknown locator field {key:?}"))); - } - } - Ok(Self { $($field: object.get_named_property($key)?),* }) - } - } - }; -} diff --git a/crates/tui-test-ffi/src/napi/types.rs b/crates/tui-test-ffi/src/napi/types.rs deleted file mode 100644 index de4355f6..00000000 --- a/crates/tui-test-ffi/src/napi/types.rs +++ /dev/null @@ -1,440 +0,0 @@ -// Generated by cargo run -p tui-test-bindgen. Do not edit. -use napi_derive::napi; -include!("type_helpers.rs"); -#[napi(object, use_nullable = true)] -pub struct OpenResult { - #[napi(js_name = "shell_pid")] - pub shell_pid: Option, - pub session: String, - pub ready: bool, - pub recording: String, -} -#[derive(Default)] -#[napi(object)] -pub struct Cursor { - pub x: u16, - pub y: u16, - /// Whether the cursor is drawn (`DECTCEM`). - pub visible: bool, - /// `block`, `underline`, or `bar` (`DECSCUSR`). - pub shape: String, - /// The cursor color as `#rrggbb`, after any `OSC 12` a program sent. - pub color: String, -} -#[derive(Default)] -#[napi(object)] -pub struct Size { - pub cols: u16, - pub rows: u16, -} -#[derive(Default)] -#[napi(object)] -pub struct EffectiveTimeouts { - pub text: f64, - pub idle: f64, - pub command: f64, - pub exit: f64, - pub ready: f64, -} -#[derive(Default)] -#[napi(object)] -pub struct BellEvent { - pub sequence: f64, - #[napi(js_name = "elapsed_ms")] - pub elapsed_ms: f64, -} -#[napi(object, use_nullable = true)] -pub struct State { - #[napi(js_name = "session_shell")] - pub session_shell: Option, - pub cols: u16, - pub rows: u16, - pub cursor: Cursor, - pub title: Option, - pub cwd: Option, - #[napi(js_name = "last_command")] - pub last_command: Option, - #[napi(js_name = "last_exit")] - pub last_exit: Option, - #[napi(js_name = "exit_signal")] - /// Signal reported when the process was terminated by one. - pub exit_signal: Option, - pub exited: Option, - pub ready: bool, - #[napi(js_name = "bell_count")] - pub bell_count: f64, - /// Terminal modes the child has turned on, by name. - /// - /// A map rather than a list, so a reader can tell "off" from "this build - /// does not know that mode" and every key is always present. - pub modes: HashMap, - #[napi(js_name = "mouse_mode")] - /// Mouse tracking level: `none`, `click`, `drag`, or `motion`. - /// - /// Separate from `modes` because mouse tracking is not a set of - /// independent switches: `CSI ?1002 h` replaces `CSI ?1000 h` rather than - /// joining it, so reporting it as booleans would say two are on when the - /// terminal only honors the last. - /// - /// Independent of how the child asked for the reports to be encoded. - /// `CSI ?1000 h` on its own is `click`, whether or not `CSI ?1006 h` - /// followed it to ask for SGR coordinates. - pub mouse_mode: String, - /// The terminal's colors, as `#rrggbb`: the three defaults and any - /// palette entry a program overrode. - pub colors: TerminalColors, - pub timeouts: EffectiveTimeouts, - pub text: String, -} -/// The colors the terminal paints with. -/// -/// Grouped because they are one concept — the colors a program chooses -/// rather than the ones a cell names — set by sibling sequences and reset by -/// `OSC 104` and `OSC 110/111/112`. A slot nothing has overridden reports the -/// color the session's profile gives it, so every field always has an -/// answer. -#[derive(Default)] -#[napi(object)] -pub struct TerminalColors { - /// The default foreground (`OSC 10`). - pub foreground: String, - /// The default background (`OSC 11`). - pub background: String, - /// The cursor color (`OSC 12`). - pub cursor: String, - /// Palette entries a program moved with `OSC 4`, keyed by index. - /// - /// Only the entries that differ from the profile are listed: a program - /// that recolors slot 1 is interesting, and the 255 it left alone are - /// not. - pub palette: HashMap, -} -#[napi(object)] -pub struct Cell { - pub x: u16, - pub y: u16, - pub r#char: String, - pub fg: Color, - pub bg: Color, - pub bold: bool, - pub dim: bool, - pub italic: bool, - pub inverse: bool, - pub invisible: bool, - pub strike: bool, - pub blink: bool, - pub underline: bool, - #[napi(js_name = "underline_style")] - pub underline_style: UnderlineStyle, - #[napi(js_name = "underline_color")] - pub underline_color: Color, - /// The OSC 8 URI this cell links to, empty when it links nowhere. - pub link: String, - #[napi(js_name = "link_id")] - /// The link's `id=` parameter, empty when the sequence carried none. - /// - /// Separate from `link` because it identifies a link across a wrap rather - /// than describing where the link points: a program that wraps its own - /// links tags each run with a shared `id=` so a terminal can treat them as - /// one. - /// - /// Backend-dependent, unlike everything else on a cell. Ghostty's FFI - /// exposes a link's URI and nothing else, so this is always empty there - /// and "no `id=` was sent" cannot be told apart from "this backend cannot - /// see it". An assertion built on it will not mean the same thing on every - /// backend, which is why no locator matches on it. - pub link_id: String, -} -#[derive(Default)] -#[napi(object)] -pub struct TextPosition { - pub row: u32, - pub column: u16, -} -#[derive(Default)] -#[napi(object)] -pub struct TextSpan { - pub row: u32, - pub start: u16, - pub end: u16, -} -#[derive(Default)] -#[napi(object)] -pub struct TextMatch { - pub text: String, - pub start: TextPosition, - /// Exclusive end position. - pub end: TextPosition, - /// Per-row column ranges with exclusive ends. - pub spans: Vec, -} -#[derive(Default)] -#[napi(object)] -pub struct PackedScreen { - /// Logical terminal dimensions for the newline-delimited UTF-8 snapshot. - /// Rows retain trailing spaces and blank lines; byte offsets are not cell - /// offsets because Unicode graphemes may occupy multiple bytes. - #[napi(readonly)] - pub cols: u16, - #[napi(readonly)] - pub rows: u16, - #[napi(readonly, ts_type = "Uint8Array")] - pub utf8: Buffer, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum Backend { - Alacritty, - Ghostty, - Rio, - Xtermjs, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum Shell { - Bash, - Powershell, - Pwsh, - Cmd, - Fish, - Zsh, - Xonsh, - Elvish, - Nushell, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum RecordingFormat { - Apng, - Gif, - Mp4, - Cast, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum SnapshotResult { - Passed, - Written, - Updated, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum UnderlineStyle { - None, - Single, - Double, - Curly, - Dotted, - Dashed, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum LocatorNodeKind { - Text, - Style, - Link, - And, - Or, - Filter, -} -#[derive(serde::Serialize)] -#[serde(rename_all = "lowercase")] -#[napi(string_enum = "lowercase")] -pub enum LocatorStageDirection { - Within, - After, - Before, -} -#[derive(Default)] -#[napi(object)] -pub struct Timeouts { - pub text: Option, - pub idle: Option, - pub command: Option, - pub exit: Option, - pub ready: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct AutomaticRecordingOptions { - pub directory: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct TraceOptions { - pub mode: Option, - pub directory: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct FailureArtifactOptions { - pub directory: String, - pub mode: Option, - pub include_recording: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct OpenOptions { - pub backend: Option, - pub shell: Option, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct RunOptions { - pub backend: Option, - pub program: String, - pub args: Option>, - pub cols: Option, - pub rows: Option, - pub cwd: Option, - pub env: Option>, - pub wait_ready: Option, - pub restart: Option, - pub profile_scrollback: Option, - pub profile_colors: Option>, - pub timeouts: Option, - pub screen_history_limit: Option, -} -#[derive(Default, serde::Serialize)] -#[napi(object, object_from_js = false)] -pub struct LocatorStyle { - pub foreground: Option, - pub background: Option, - pub bold: Option, - pub dim: Option, - pub italic: Option, - pub underline_style: Option, - pub underline_color: Option, - pub inverse: Option, - pub hidden: Option, - pub strikethrough: Option, - pub blink: Option, -} -strict_locator_input!(LocatorStyle { -foreground => "foreground", -background => "background", -bold => "bold", -dim => "dim", -italic => "italic", -underline_style => "underlineStyle", -underline_color => "underlineColor", -inverse => "inverse", -hidden => "hidden", -strikethrough => "strikethrough", -blink => "blink", -}); -#[napi(object, object_from_js = false)] -pub struct LocatorNode { - pub kind: LocatorNodeKind, - pub text: Option, - pub regex: Option, - pub whitespace: Option, - pub style: Option, - pub link: Option, - pub full: Option, - pub direction: Option, - pub within: Option, - pub left: Option, - pub right: Option, - pub input: Option, - pub has: Option, - pub has_not: Option, - pub occurrence: Option, - pub nth: Option, -} -strict_locator_input!(LocatorNode { -kind => "kind", -text => "text", -regex => "regex", -whitespace => "whitespace", -style => "style", -link => "link", -full => "full", -direction => "direction", -within => "within", -left => "left", -right => "right", -input => "input", -has => "has", -has_not => "hasNot", -occurrence => "occurrence", -nth => "nth", -}); -#[napi(object, object_from_js = false)] -pub struct LocatorExpression { - pub nodes: Vec, - pub root: f64, -} -strict_locator_input!(LocatorExpression { -nodes => "nodes", -root => "root", -}); -#[derive(Default)] -#[napi(object)] -pub struct TitleOptions { - pub regex: Option, - pub timeout_ms: Option, - pub not: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct ClipboardWaitOptions { - pub regex: Option, - pub timeout_ms: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct SnapshotOptions { - pub update: Option, - pub include_style: Option, - pub include_title: Option, - pub cwd: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct ScreenshotOptions { - pub full: Option, - pub path: Option, - pub zoom: Option, - pub background: Option, - pub transparent: Option, -} -#[derive(Default)] -#[napi(object)] -pub struct RecordingOptions { - pub path: String, - pub format: Option, - pub fps: Option, - pub speed: Option, - pub idle_time_limit: Option, - pub zoom: Option, - pub background: Option, - pub transparent: Option, -} -#[napi] -pub type Color = Either; -#[derive(Default)] -#[napi(object)] -pub struct MouseClickOptions { - pub x: Option, - pub y: Option, - pub on_text: Option, - pub button: Option, - pub clicks: Option, -} diff --git a/crates/tui-test-ffi/src/tasks.rs b/crates/tui-test-ffi/src/tasks.rs new file mode 100644 index 00000000..6a04a99b --- /dev/null +++ b/crates/tui-test-ffi/src/tasks.rs @@ -0,0 +1,108 @@ +//! Typed start functions copy borrowed inputs on the caller thread. Only owned +//! operations reach worker threads, so waits cannot exhaust a foreign worker pool. +use super::*; +use std::cell::RefCell; +use std::sync::mpsc::{self, Receiver, TryRecvError}; + +type Completion = Result; + +thread_local! { + static START: RefCell>>> = const { RefCell::new(None) }; +} + +/// An owned asynchronous call. Poll until a result arrives, then free the task. +/// Freeing a pending task discards its eventual result without canceling the call. +pub struct TuiTask { + receiver: Option>, + immediate: *mut TuiResult, +} + +impl Drop for TuiTask { + fn drop(&mut self) { + unsafe { output::free(self.immediate) }; + } +} + +pub(super) fn execute(work: impl FnOnce() -> Completion + Send + 'static) -> *mut TuiResult { + if !START.with(|slot| slot.borrow().is_some()) { + return boundary(work); + } + let (sender, receiver) = mpsc::channel(); + START.with(|slot| *slot.borrow_mut() = Some(Some(receiver))); + match std::thread::Builder::new() + .name("tui-test-call".into()) + .spawn(move || { + let result = catch_unwind(AssertUnwindSafe(work)).unwrap_or_else(|payload| { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload"); + Err(TuiTestError::internal(format!( + "native engine adapter panicked: {message}" + ))) + }); + // Dropping the receiver abandons the result, which remains fully owned. + drop(sender.send(result)); + }) { + Ok(_) => std::ptr::null_mut(), + Err(error) => { + START.with(|slot| *slot.borrow_mut() = Some(None)); + output::error(TuiTestError::internal(format!( + "native worker failed: {error}" + ))) + } + } +} + +pub(super) fn start(call: impl FnOnce() -> *mut TuiResult) -> *mut TuiTask { + START.with(|slot| *slot.borrow_mut() = Some(None)); + let immediate = call(); + let receiver = START.with(|slot| slot.borrow_mut().take().flatten()); + Box::into_raw(Box::new(TuiTask { + receiver, + immediate, + })) +} + +#[no_mangle] +/// Returns NULL while pending. Transfers ownership of a completed result once. +/// +/// # Safety +/// task must be a live task returned by a start function, used by one thread. +pub unsafe extern "C" fn tui_task_poll(task: *mut TuiTask) -> *mut TuiResult { + let task = unsafe { &mut *task }; + if !task.immediate.is_null() { + return std::mem::replace(&mut task.immediate, std::ptr::null_mut()); + } + let Some(receiver) = &task.receiver else { + return std::ptr::null_mut(); + }; + let completion = match receiver.try_recv() { + Ok(value) => value, + Err(TryRecvError::Empty) => return std::ptr::null_mut(), + Err(TryRecvError::Disconnected) => { + Err(TuiTestError::internal("native worker disconnected")) + } + }; + task.receiver = None; + match completion { + Ok(value) => output::operation(value), + Err(error) => output::error(error), + } +} + +#[no_mangle] +/// # Safety +/// task must be NULL or a live task, freed exactly once. +pub unsafe extern "C" fn tui_task_free(task: *mut TuiTask) { + if !task.is_null() { + drop(unsafe { Box::from_raw(task) }); + } +} + +#[no_mangle] +/// Exercise panic containment without terminating the host process. +pub extern "C" fn tui_panic_probe_start() -> *mut TuiTask { + start(|| execute(|| panic!("intentional native panic probe"))) +} diff --git a/crates/tui-test-ffi/src/tasks_generated.rs b/crates/tui-test-ffi/src/tasks_generated.rs new file mode 100644 index 00000000..abccc9e0 --- /dev/null +++ b/crates/tui-test-ffi/src/tasks_generated.rs @@ -0,0 +1,511 @@ +// Generated by cargo run -p tui-test-bindgen. Do not edit. +use super::*; +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_open_start( + session: TuiString, + options: TuiOpenOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_open(session, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_run_start( + session: TuiString, + options: TuiOpenOptions, + program: TuiString, + args: *const TuiString, + args_len: usize, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_run(session, options, program, args, args_len) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_sessions_start() -> *mut TuiTask { + tasks::start(|| tui_sessions()) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_close_all_start() -> *mut TuiTask { + tasks::start(|| tui_close_all()) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_recording_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_recording(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_close_start( + session: TuiString, + failed: TuiOptionalBool, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_close(session, failed) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_restart_start( + session: TuiString, + graceful_timeout_ms: u64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_restart(session, graceful_timeout_ms) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_state_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_state(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_command_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_command(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_output_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_output(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_exit_code_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_exit_code(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_cwd_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_cwd(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_cursor_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_cursor(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_size_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_size(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_title_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_title(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_clipboard_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_clipboard(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_bell_count_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_bell_count(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_get_bell_events_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_get_bell_events(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_stop_recording_start(session: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_stop_recording(session) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_text_start(session: TuiString, full: bool) -> *mut TuiTask { + tasks::start(|| unsafe { tui_text(session, full) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_packed_screen_start(session: TuiString, full: bool) -> *mut TuiTask { + tasks::start(|| unsafe { tui_packed_screen(session, full) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_cells_start( + session: TuiString, + x: u16, + y: u16, + w: u16, + h: u16, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_cells(session, x, y, w, h) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_write_start(session: TuiString, text: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_write(session, text) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_type_start(session: TuiString, text: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_type(session, text) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_submit_start(session: TuiString, text: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_submit(session, text) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_signal_start(session: TuiString, text: TuiString) -> *mut TuiTask { + tasks::start(|| unsafe { tui_signal(session, text) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_key_start( + session: TuiString, + keys: *const TuiString, + len: usize, + action: u32, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_key(session, keys, len, action) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_resize_start( + session: TuiString, + cols: u16, + rows: u16, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_resize(session, cols, rows) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_click_start( + session: TuiString, + x: TuiOptionalU64, + y: TuiOptionalU64, + on_text: TuiString, + options: TuiMouseOptions, + clicks: u8, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_click(session, x, y, on_text, options, clicks) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_move_start(session: TuiString, x: u16, y: u16) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_move(session, x, y) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_down_start( + session: TuiString, + x: u16, + y: u16, + options: TuiMouseOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_down(session, x, y, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_up_start( + session: TuiString, + x: u16, + y: u16, + options: TuiMouseOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_up(session, x, y, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_drag_start( + session: TuiString, + x1: u16, + y1: u16, + x2: u16, + y2: u16, + options: TuiMouseOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_drag(session, x1, y1, x2, y2, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_mouse_scroll_start( + session: TuiString, + direction: TuiString, + amount: u16, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_mouse_scroll(session, direction, amount) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_title_start( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_title(session, text, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_expect_title_start( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_expect_title(session, text, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_clipboard_start( + session: TuiString, + text: TuiString, + options: TuiWaitOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_clipboard(session, text, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_idle_start( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_idle(session, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_command_start( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_command(session, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_exit_start( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_exit(session, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_ready_start( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_ready(session, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_bell_start( + session: TuiString, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_bell(session, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_find_locator_start( + session: TuiString, + query: TuiQuery, + require_one: bool, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_find_locator(session, query, require_one) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_wait_locator_start( + session: TuiString, + query: TuiQuery, + options: TuiWaitOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_wait_locator(session, query, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_expect_locator_start( + session: TuiString, + query: TuiQuery, + options: TuiWaitOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_expect_locator(session, query, options) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_click_locator_start( + session: TuiString, + query: TuiQuery, + options: TuiMouseOptions, + clicks: u8, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_click_locator(session, query, options, clicks, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_highlight_locator_start( + session: TuiString, + query: TuiQuery, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_highlight_locator(session, query, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_expect_exit_code_start( + session: TuiString, + code: i32, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_expect_exit_code(session, code, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_expect_output_start( + session: TuiString, + text: TuiString, + regex: bool, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_expect_output(session, text, regex) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_expect_bell_count_start( + session: TuiString, + count: u64, + timeout: TuiOptionalU64, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_expect_bell_count(session, count, timeout) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_snapshot_start( + session: TuiString, + name: TuiString, + update: bool, + include_style: bool, + include_title: bool, + cwd: TuiString, +) -> *mut TuiTask { + tasks::start(|| unsafe { + tui_snapshot(session, name, update, include_style, include_title, cwd) + }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_screenshot_start( + session: TuiString, + full: bool, + path: TuiString, + zoom: TuiOptionalF64, + background: TuiString, + transparent: bool, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_screenshot(session, full, path, zoom, background, transparent) }) +} +#[no_mangle] +/// Start a typed call after copying all borrowed inputs. +/// # Safety +/// Inputs follow the synchronous function contract through this call only. +pub unsafe extern "C" fn tui_start_recording_start( + session: TuiString, + options: TuiRecordingOptions, +) -> *mut TuiTask { + tasks::start(|| unsafe { tui_start_recording(session, options) }) +} diff --git a/crates/tui-test-ffi/src/tests.rs b/crates/tui-test-ffi/src/tests.rs index dec6b848..8212ba9c 100644 --- a/crates/tui-test-ffi/src/tests.rs +++ b/crates/tui-test-ffi/src/tests.rs @@ -1,4 +1,61 @@ use super::*; +use std::time::{Duration, Instant}; + +unsafe fn completed(task: *mut TuiTask) -> *mut TuiResult { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let result = unsafe { tasks::tui_task_poll(task) }; + if !result.is_null() { + assert!(unsafe { tasks::tui_task_poll(task) }.is_null()); + unsafe { tasks::tui_task_free(task) }; + return result; + } + assert!( + Instant::now() < deadline, + "asynchronous task did not complete" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn typed_start_copies_inputs_and_transfers_result_once() { + let task = { + let context = String::from(r#"{"name":"async-owned-input"}"#); + unsafe { tui_state_start(text(&context)) } + }; + let result = unsafe { completed(task) }; + assert_eq!(unsafe { (*result).error_kind }, 3); + assert!(unsafe { message(result) }.contains("no active session")); + unsafe { tui_result_free(result) }; + let invalid = unsafe { tui_state_start(TuiString::default()) }; + let result = unsafe { completed(invalid) }; + assert_eq!(unsafe { (*result).error_kind }, 2); + unsafe { tui_result_free(result) }; +} + +#[test] +fn asynchronous_panics_are_contained() { + let task = tasks::tui_panic_probe_start(); + let result = unsafe { completed(task) }; + assert_eq!(unsafe { (*result).error_kind }, 5); + assert!(unsafe { message(result) }.contains("intentional native panic probe")); + unsafe { tui_result_free(result) }; +} + +#[test] +fn pending_tasks_can_be_abandoned_without_borrowing_foreign_storage() { + let (sender, receiver) = std::sync::mpsc::channel(); + let task = tasks::start(|| { + tasks::execute(move || { + receiver.recv().unwrap(); + Ok(OperationResult::Text("owned discarded result".into())) + }) + }); + unsafe { tasks::tui_task_free(task) }; + sender.send(()).unwrap(); + unsafe { tasks::tui_task_free(std::ptr::null_mut()) }; +} fn text(value: &str) -> TuiString { TuiString { diff --git a/tools/bindgen/src/boundaries.rs b/tools/bindgen/src/boundaries.rs index e476ce54..d4c41934 100644 --- a/tools/bindgen/src/boundaries.rs +++ b/tools/bindgen/src/boundaries.rs @@ -1,12 +1,12 @@ -//! Boundary fields come from owned Rust models. Overrides describe transport differences only. +//! Fields and enums come from owned Rust models. Overrides describe transport contracts only. use crate::{emit, format_source, NOTICE}; use anyhow::{bail, Context, Result}; use quote::ToTokens; use std::{collections::BTreeMap, fmt::Write, fs, path::Path}; use syn::{Fields, Item}; -#[path = "napi.rs"] -mod napi; +#[path = "javascript.rs"] +mod javascript; #[derive(Clone)] struct Field { @@ -17,7 +17,6 @@ struct Field { struct Models { fields: BTreeMap>, enums: BTreeMap>, - docs: BTreeMap, } impl Models { @@ -25,7 +24,6 @@ impl Models { let mut models = Self { fields: BTreeMap::new(), enums: BTreeMap::new(), - docs: BTreeMap::new(), }; for module in [ "api", @@ -36,57 +34,33 @@ impl Models { "terminal/backend", "terminal/cell", ] { - let source = fs::read_to_string(root.join(format!("crates/tui-test/src/{module}.rs")))?; - models.parse(&source)?; + models.parse(&fs::read_to_string( + root.join(format!("crates/tui-test/src/{module}.rs")), + )?)?; } Ok(models) } fn parse(&mut self, source: &str) -> Result<()> { - let fields = |fields: &Fields| -> Result> { - fields + fn fields(values: &Fields) -> Vec { + values .iter() - .map(|f| { - Ok(Field { - name: f.ident.as_ref().context("named model field")?.to_string(), - ty: f.ty.to_token_stream().to_string().replace(' ', ""), - }) + .enumerate() + .map(|(i, f)| Field { + name: f + .ident + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| i.to_string()), + ty: f.ty.to_token_stream().to_string().replace(' ', ""), }) .collect() - }; + } for item in syn::parse_file(source)?.items { match item { Item::Struct(item) if matches!(item.fields, Fields::Named(_)) => { - let docs = |attrs: &[syn::Attribute]| { - attrs - .iter() - .filter(|a| a.path().is_ident("doc")) - .filter_map(|a| match &a.meta { - syn::Meta::NameValue(meta) => match &meta.value { - syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(text), - .. - }) => Some( - text.value() - .split('\n') - .map(|line| format!("///{line}\n")) - .collect::(), - ), - _ => None, - }, - _ => None, - }) - .collect() - }; - self.docs.insert(item.ident.to_string(), docs(&item.attrs)); - for f in &item.fields { - self.docs.insert( - format!("{}.{}", item.ident, f.ident.as_ref().unwrap()), - docs(&f.attrs), - ); - } self.fields - .insert(item.ident.to_string(), fields(&item.fields)?); + .insert(item.ident.to_string(), fields(&item.fields)); } Item::Enum(item) => { let name = item.ident.to_string(); @@ -94,20 +68,11 @@ impl Models { name.clone(), item.variants.iter().map(|v| v.ident.to_string()).collect(), ); - for v in item.variants { - let values = match &v.fields { - Fields::Unnamed(values) => values - .unnamed - .iter() - .enumerate() - .map(|(i, f)| Field { - name: i.to_string(), - ty: f.ty.to_token_stream().to_string().replace(' ', ""), - }) - .collect(), - other => fields(other)?, - }; - self.fields.insert(format!("{name}::{}", v.ident), values); + for variant in item.variants { + self.fields.insert( + format!("{name}::{}", variant.ident), + fields(&variant.fields), + ); } } _ => {} @@ -151,7 +116,6 @@ fn c_name(name: &str) -> String { } ) } - fn ident(name: &str) -> &str { if name == "char" { "r#char" @@ -159,7 +123,6 @@ fn ident(name: &str) -> &str { name } } - fn snake(name: &str) -> String { if name == "OpenResult" { return "open_value".into(); @@ -173,11 +136,21 @@ fn snake(name: &str) -> String { } out } - +fn js_key(name: &str) -> String { + let mut words = name.split('_'); + let mut out = words.next().unwrap_or_default().to_owned(); + for word in words { + let mut chars = word.chars(); + if let Some(c) = chars.next() { + out.extend(c.to_uppercase()); + out.push_str(chars.as_str()); + } + } + out +} fn option(ty: &str) -> Option<&str> { ty.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) } - fn c_type(ty: &str) -> Result { Ok(match ty { "String" | "Option" | "PathBuf" | "Option" => "TuiString".into(), @@ -191,63 +164,6 @@ fn c_type(ty: &str) -> Result { _ => bail!("no C mapping for {ty}"), }) } - -fn js_type(ty: &str) -> Result { - if let Some(inner) = option(ty) { - return Ok(format!("Option<{}>", js_type(inner)?)); - } - if let Some(inner) = ty.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) { - return Ok(format!("Vec<{}>", js_type(inner)?)); - } - Ok(match ty { - "u64" | "usize" => "f64".into(), - "PathBuf" | "WhitespaceMode" | "TraceMode" | "FailureArtifactMode" => "String".into(), - "CellColor" => "Color".into(), - "TextStyle" => "LocatorStyle".into(), - "LocatorDirection" => "LocatorStageDirection".into(), - "BTreeMap" => "HashMap".into(), - "std::collections::BTreeMap" => "HashMap".into(), - "(String,String)" => ty.into(), - t if t.contains("::") => js_type(t.rsplit("::").next().unwrap())?, - t if matches!(t, "String" | "bool" | "f64" | "u8" | "u16" | "u32" | "i32") - || OUTPUTS.contains(&t) - || matches!( - t, - "Backend" - | "Shell" - | "RecordingFormat" - | "Timeouts" - | "LocatorNode" - | "LocatorNodeKind" - ) => - { - t.into() - } - _ => bail!("no N-API mapping for {ty}"), - }) -} - -fn js_output(ty: &str, value: &str) -> Result { - Ok(match ty { - "u64" | "usize" => format!("{value} as f64"), - "CellColor" => format!("color({value})"), - "BTreeMap" => format!("{value}.into_iter().collect()"), - "std::collections::BTreeMap" => { - format!("{value}.into_iter().map(|(index,color)| (index.to_string(),color)).collect()") - } - "Vec" => format!("{value}.into()"), - t if t.starts_with("Vec<") => { - let inner = &t[4..t.len() - 1]; - format!("{value}.into_iter().map({}).collect()", snake(inner)) - } - t if OUTPUTS.contains(&t) => format!("{}({value})", snake(t)), - _ => { - js_type(ty)?; - value.into() - } - }) -} - fn c_output(ty: &str, value: &str) -> Result { Ok(match ty { "String" => format!("s.string({value})"), @@ -260,16 +176,12 @@ fn c_output(ty: &str, value: &str) -> Result { }) } -fn output_models( - models: &Models, - c: &mut String, - js: &mut String, - cc: &mut String, - jc: &mut String, -) -> Result<()> { +fn output_models(models: &Models, c: &mut String, cc: &mut String) -> Result<()> { for &model in OUTPUTS { - let fields = models.fields(model)?; - let c_enabled = !matches!(model, "PackedScreen" | "EffectiveTimeouts"); + if model == "PackedScreen" { + continue; + } + let c_enabled = model != "EffectiveTimeouts"; if c_enabled { writeln!( c, @@ -277,39 +189,14 @@ fn output_models( c_name(model) )?; } - let nullable = matches!(model, "State" | "OpenResult"); - js.push_str( - models - .docs - .get(model) - .map(String::as_str) - .unwrap_or_default(), - ); - if !nullable && model != "Cell" { - js.push_str("#[derive(Default)]\n"); - } - writeln!( - js, - "#[napi(object{})] pub struct {model} {{", - if nullable { - ", use_nullable = true" - } else { - "" - } - )?; - if model != "PackedScreen" { - writeln!(cc, "#[allow(unused_variables)] fn {}(s: &mut Storage, v: tui_test::api::{model}) -> {} {{", snake(model), c_name(model))?; - } writeln!( - jc, - "pub fn {}(value: api::{model}) -> {}{model}{} {{", + cc, + "#[allow(unused_variables)] fn {}(s: &mut Storage, v: tui_test::api::{model}) -> {} {{", snake(model), - if model == "Cell" { "Result<" } else { "" }, - if model == "Cell" { ">" } else { "" } + c_name(model) )?; - writeln!(jc, "{}{model} {{", if model == "Cell" { "Ok(" } else { "" })?; let mut init = String::new(); - for f in fields { + for f in models.fields(model)? { let field = ident(&f.name); let cfield = if f.name == "char" { "character" } else { field }; let value = format!("v.{field}"); @@ -326,119 +213,27 @@ fn output_models( writeln!(c, "pub {cfield}: {},", c_type(&f.ty)?)?; } } - if model != "PackedScreen" { - if let Some((_, helper)) = array { - writeln!(cc, "let {cfield} = s.{helper}({value});")?; - writeln!(init, "{cfield}: {cfield}.0, {cfield}_len: {cfield}.1,")?; + if let Some((_, helper)) = array { + writeln!(cc, "let {cfield} = s.{helper}({value});")?; + writeln!(init, "{cfield}: {cfield}.0, {cfield}_len: {cfield}.1,")?; + } else { + let expr = if model == "EffectiveTimeouts" { + format!("unsigned({value})") } else { - let expr = if model == "EffectiveTimeouts" { - format!("unsigned({value})") - } else { - c_output(&f.ty, &value)? - }; - writeln!(init, "{cfield}: {expr},")?; - } + c_output(&f.ty, &value)? + }; + writeln!(init, "{cfield}: {expr},")?; } - if matches!(model, "OpenResult" | "State" | "Cell" | "BellEvent") - && f.name.contains('_') - { - writeln!(js, "#[napi(js_name = {:?})]", f.name)?; - } - js.push_str( - models - .docs - .get(&format!("{model}.{}", f.name)) - .map(String::as_str) - .unwrap_or_default(), - ); - if model == "PackedScreen" { - writeln!( - js, - "#[napi(readonly{})]", - if f.name == "utf8" { - ", ts_type = \"Uint8Array\"" - } else { - "" - } - )?; - } - let ty = if model == "Cell" && f.name == "underline_style" { - "UnderlineStyle".into() - } else if model == "PackedScreen" && f.name == "utf8" { - "Buffer".into() - } else { - js_type(&f.ty)? - }; - writeln!(js, "pub {field}: {ty},")?; - let expr = if model == "Cell" && f.name == "underline_style" { - "underline_style(&value.underline_style)?".into() - } else { - js_output(&f.ty, &format!("value.{field}"))? - }; - writeln!(jc, "{field}: {expr},")?; } if c_enabled { c.push_str("}\n"); } - if model != "PackedScreen" { - writeln!(cc, "{} {{ {init} }} }}", c_name(model))?; - } - writeln!(jc, "}}{} }}", if model == "Cell" { ")" } else { "" })?; - js.push_str("}\n"); + writeln!(cc, "{} {{ {init} }} }}", c_name(model))?; } Ok(()) } -fn enum_models(models: &Models, js: &mut String, jc: &mut String) -> Result<()> { - for (model, target, path) in [ - ("Backend", "Backend", "terminal::backend"), - ("Shell", "Shell", "shell"), - ("RecordingFormat", "RecordingFormat", "api"), - ("SnapshotResult", "SnapshotResult", "api"), - ("UnderlineStyle", "UnderlineStyle", "terminal::cell"), - ("LocatorNodeKind", "LocatorNodeKind", "locator_query"), - ("LocatorDirection", "LocatorStageDirection", "api"), - ] { - let variants = models - .enums - .get(model) - .with_context(|| format!("missing enum {model}"))?; - writeln!(js, "#[derive(serde::Serialize)] #[serde(rename_all = \"lowercase\")] #[napi(string_enum = \"lowercase\")] pub enum {target} {{ {} }}", variants.join(","))?; - // UnderlineStyle's owned API field is a string, not the terminal enum. - if model == "UnderlineStyle" { - jc.push_str( - "fn underline_style(value: &str) -> Result { Ok(match value {\n", - ); - for variant in variants { - writeln!( - jc, - "{:?} => UnderlineStyle::{variant},", - variant.to_lowercase() - )?; - } - jc.push_str("value => return Err(internal(format!(\"terminal returned unknown underline style '{value}'\"))), }) }\n"); - } else { - writeln!(jc, "impl From<{target}> for tui_test::{path}::{model} {{ fn from(value: {target}) -> Self {{ match value {{")?; - for variant in variants { - writeln!(jc, "{target}::{variant} => Self::{variant},")?; - } - jc.push_str("} } }\n"); - if model == "SnapshotResult" { - writeln!(jc, "impl From for {target} {{ fn from(value: tui_test::{path}::{model}) -> Self {{ match value {{")?; - for variant in variants { - writeln!( - jc, - "tui_test::{path}::{model}::{variant} => Self::{variant}," - )?; - } - jc.push_str("} } }\n"); - } - } - } - Ok(()) -} - -/// Input defaults and flattening are boundary contracts, not new model definitions. +/// Flattened options preserve the C v3 field order and the public JavaScript API. fn input_fields(models: &Models, model: &str, js: bool) -> Result> { let mut fields = Vec::new(); for mut f in models.fields(model)? { @@ -463,7 +258,7 @@ fn input_fields(models: &Models, model: &str, js: bool) -> Result> { p.ty = match p.ty.as_str() { "usize" => { if js { - "Option" + "Option" } else { "TuiOptionalU64" } @@ -513,23 +308,21 @@ fn input_fields(models: &Models, model: &str, js: bool) -> Result> { "Shell" } ), - ("OpenOptions" | "RunOptions", "cols" | "rows") => "Option".into(), ("OpenOptions" | "RunOptions", _) if option(&f.ty).is_none() && f.name != "program" => { - format!("Option<{}>", js_type(&f.ty)?) + format!("Option<{}>", f.ty) } ("TraceOptions", _) | ("FailureArtifactOptions", "mode" | "include_recording") => { - format!("Option<{}>", js_type(&f.ty)?) + format!("Option<{}>", f.ty) } - ("Operation::StartRecording", "fps") => "Option".into(), ("LocatorNode", "occurrence") => "Option".into(), ("Operation::WaitTitle" | "Operation::Snapshot" | "Operation::Screenshot", _) if f.ty == "bool" => { "Option".into() } - _ => js_type(&f.ty)?, + _ => f.ty, } } else { match (model, f.name.as_str()) { @@ -554,20 +347,16 @@ fn input_fields(models: &Models, model: &str, js: bool) -> Result> { if nth { fields.push(Field { name: "nth".into(), - ty: "Option".into(), + ty: "Option".into(), }); } } if js && matches!(model, "OpenOptions" | "RunOptions") { for mut f in models.fields("DiagnosticRetentionOptions")? { - if f.ty != "u16" { - bail!("no retention number mapping for {}", f.ty); - } - f.ty = "Option".into(); + f.ty = format!("Option<{}>", f.ty); fields.push(f); } } - // C v3 field order is fixed. These fields differ from the owned operation order. if !js && model == "Operation::WaitTitle" { fields.sort_by_key(|f| match f.name.as_str() { "timeout_ms" => 0, @@ -578,13 +367,7 @@ fn input_fields(models: &Models, model: &str, js: bool) -> Result> { Ok(fields) } -fn input_models( - models: &Models, - c: &mut String, - js: &mut String, - jc: &mut String, - ci: &mut String, -) -> Result<()> { +fn input_models(models: &Models, c: &mut String, ci: &mut String) -> Result<()> { for (model, target) in [ ("Timeouts", "Timeouts"), ("OpenOptions", "OpenOptions"), @@ -605,88 +388,14 @@ fn input_models( } c.push_str("}\n"); } - for (model, target) in [ - ("Timeouts", "Timeouts"), - ("AutomaticRecording", "AutomaticRecordingOptions"), - ("TraceOptions", "TraceOptions"), - ("FailureArtifactOptions", "FailureArtifactOptions"), - ("OpenOptions", "OpenOptions"), - ("RunOptions", "RunOptions"), - ("TextStyle", "LocatorStyle"), - ("LocatorNode", "LocatorNode"), - ("LocatorExpression", "LocatorExpression"), - ("Operation::WaitTitle", "TitleOptions"), - ("Operation::WaitTitle", "ClipboardWaitOptions"), - ("Operation::Snapshot", "SnapshotOptions"), - ("Operation::Screenshot", "ScreenshotOptions"), - ("Operation::StartRecording", "RecordingOptions"), - ] { - let strict = target.starts_with("Locator"); - if !matches!(target, "LocatorNode" | "LocatorExpression") { - writeln!( - js, - "#[derive(Default{})]", - if target == "LocatorStyle" { - ", serde::Serialize" - } else { - "" - } - )?; - } - writeln!( - js, - "#[napi(object{})] pub struct {target} {{", - if strict { - ", object_from_js = false" - } else { - "" - } - )?; - let mut fields = input_fields(models, model, true)?; - if target == "ClipboardWaitOptions" { - fields.retain(|f| f.name != "not"); - } - for f in &fields { - writeln!(js, "pub {}: {},", ident(&f.name), f.ty)?; - } - js.push_str("}\n"); - if strict { - writeln!(js, "strict_locator_input!({target} {{")?; - for f in fields { - writeln!(js, "{} => {:?},", ident(&f.name), js_key(&f.name))?; - } - js.push_str("});\n"); - } - } - // Both transports use the same source timeout fields, with different number validation. - jc.push_str("pub fn timeouts(value: Timeouts) -> Result { Ok(api::Timeouts {\n"); ci.push_str("fn timeouts(value: TuiTimeouts) -> Timeouts { Timeouts {\n"); for f in models.fields("Timeouts")? { - writeln!( - jc, - "{}: optional_integer(value.{}, \"timeouts.{}\", u64::MAX)?,", - f.name, f.name, f.name - )?; writeln!(ci, "{}: value.{}.option(),", f.name, f.name)?; } - jc.push_str("}) }\n"); ci.push_str("} }\n"); Ok(()) } -fn js_key(name: &str) -> String { - let mut words = name.split('_'); - let mut out = words.next().unwrap_or_default().to_owned(); - for word in words { - let mut chars = word.chars(); - if let Some(c) = chars.next() { - out.extend(c.to_uppercase()); - out.push_str(chars.as_str()); - } - } - out -} - fn assignment(out: &mut String, name: &str, expr: &str) -> Result<()> { if name == expr { writeln!(out, "{name},")?; @@ -752,23 +461,11 @@ fn session_input(models: &Models) -> Result { Ok(out) } -fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Result<()> { - jc.push_str("fn open_input(value: OpenOptions, profile: Profile, directory: Option) -> Result { Ok(api::OpenOptions {\n"); +fn input_conversions(models: &Models, ci: &mut String) -> Result<()> { ci.push_str("unsafe fn open_input(value: TuiOpenOptions, backend: tui_test::terminal::backend::Backend, shell: Option, profile: Profile, mode: AutomaticRecordingMode) -> Result { Ok(OpenOptions {\n"); for f in models.fields("OpenOptions")? { let name = &f.name; - let js = match name.as_str() { - "backend" => "value.backend.map(Into::into).unwrap_or_default()".into(), - "shell" => "value.shell.map(Into::into)".into(), - "profile" => "profile".into(), - "cols" => "u16_value(value.cols.unwrap_or(80.0), \"cols\")?".into(), - "rows" => "u16_value(value.rows.unwrap_or(30.0), \"rows\")?".into(), - "timeouts" => "timeouts(value.timeouts.unwrap_or_default())?".into(), - "recording" => "api::AutomaticRecording { mode: api::AutomaticRecordingMode::Disabled, directory: directory.map(Into::into) }".into(), - _ if option(&f.ty).is_some() => format!("value.{name}"), - _ => format!("value.{name}.unwrap_or_default()"), - }; - let c = match name.as_str() { + let value = match name.as_str() { "backend" | "shell" | "profile" => name.clone(), "cols" => "u16_option(value.cols, \"cols\")?.unwrap_or(80)".into(), "rows" => "u16_option(value.rows, \"rows\")?.unwrap_or(30)".into(), @@ -779,65 +476,9 @@ fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Resul _ if option(&f.ty).is_some() => format!("value.{name}.option()"), _ => format!("value.{name}"), }; - assignment(jc, name, &js)?; - assignment(ci, name, &c)?; - } - jc.push_str("}) }\n"); - ci.push_str("}) }\n"); - for (model, prefix, default_mode) in [ - ("FailureArtifactOptions", "artifact", "all"), - ("TraceOptions", "trace", "off"), - ] { - writeln!( - jc, - "pub fn apply_{prefix}(context: &mut crate::session::SessionInput, value: {model}) {{" - )?; - for f in models.fields(model)? { - let expr = match f.ty.as_str() { - "FailureArtifactMode" | "TraceMode" => format!( - "Some(value.{}.unwrap_or_else(|| {default_mode:?}.into()))", - f.name - ), - "PathBuf" if model == "FailureArtifactOptions" => format!("Some(value.{})", f.name), - "bool" => format!("value.{}.unwrap_or(false)", f.name), - _ => format!("value.{}", f.name), - }; - writeln!(jc, "context.{prefix}_{} = {expr};", f.name)?; - } - jc.push_str("}\n"); + assignment(ci, name, &value)?; } - for model in ["OpenOptions", "RunOptions"] { - writeln!(jc, "pub fn {}_context(context: &mut crate::session::SessionInput, value: &{model}) -> Result<()> {{", snake(model))?; - for f in models.fields("DiagnosticRetentionOptions")? { - writeln!( - jc, - "context.{} = optional_u16(value.{}, {:?})?;", - f.name, - f.name, - js_key(&f.name) - )?; - } - jc.push_str("Ok(()) }\n"); - } - jc.push_str("pub fn run_options(value: RunOptions, directory: Option) -> Result {\nlet open = open_options(OpenOptions {\n"); - let run = input_fields(models, "RunOptions", true)?; - for f in input_fields(models, "OpenOptions", true)? { - if run.iter().any(|r| r.name == f.name) { - writeln!(jc, "{}: value.{},", f.name, f.name)?; - } - } - jc.push_str("..Default::default() }, directory)?;\nif value.program.is_empty() { return Err(usage(\"program must not be empty\")); }\nOk(api::RunOptions {\n"); - for f in models.fields("RunOptions")? { - let expr = match f.name.as_str() { - "program" => "value.program".into(), - "args" => "value.args.unwrap_or_default()".into(), - _ => format!("open.{}", f.name), - }; - writeln!(jc, "{}: {expr},", f.name)?; - } - jc.push_str("}) }\n"); - - ci.push_str("fn mouse_input(value: TuiMouseOptions, button: MouseButton) -> MouseOptions { MouseOptions {\n"); + ci.push_str("}) }\nfn mouse_input(value: TuiMouseOptions, button: MouseButton) -> MouseOptions { MouseOptions {\n"); for f in models.fields("MouseOptions")? { assignment( ci, @@ -849,8 +490,7 @@ fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Resul }, )?; } - ci.push_str("} }\n"); - ci.push_str("unsafe fn recording_input(value: TuiRecordingOptions, format: Option, fps: Option) -> Result { Ok(Operation::StartRecording {\n"); + ci.push_str("} }\nunsafe fn recording_input(value: TuiRecordingOptions, format: Option, fps: Option) -> Result { Ok(Operation::StartRecording {\n"); for f in models.fields("Operation::StartRecording")? { let expr = match f.name.as_str() { "format" | "fps" => f.name.clone(), @@ -863,84 +503,26 @@ fn input_conversions(models: &Models, jc: &mut String, ci: &mut String) -> Resul assignment(ci, &f.name, &expr)?; } ci.push_str("}) }\n"); - for (variant, dto, args) in [ - ("Snapshot", "SnapshotOptions", ", name: String"), - ("Screenshot", "ScreenshotOptions", ""), - ("StartRecording", "RecordingOptions", ""), - ("WaitTitle", "TitleOptions", ", text: String"), - ("ExpectTitle", "TitleOptions", ", text: String"), - ] { - writeln!(jc, "pub fn {}_operation(value: {dto}{args}) -> Result {{ Ok(api::Operation::{variant} {{", snake(variant))?; - for f in models.fields(&format!("Operation::{variant}"))? { - let expr = match f.name.as_str() { - "name" | "text" => f.name.clone(), - "background" => "crate::session::capture_background(value.background, value.transparent.unwrap_or(false)).map_err(native_error)?".into(), - "format" => "value.format.map(Into::into)".into(), - "fps" => "value.fps.map(|fps| u8_value(fps, \"fps\")).transpose()?".into(), - "timeout_ms" => "optional_integer(value.timeout_ms, \"timeoutMs\", u64::MAX)?".into(), - _ if f.ty == "bool" => format!("value.{}.unwrap_or(false)", f.name), - _ => format!("value.{}", f.name), - }; - assignment(jc, &f.name, &expr)?; - } - jc.push_str("}) }\n"); - } - jc.push_str("fn locator_style(value: LocatorStyle) -> api::TextStyle { api::TextStyle {\n"); - for f in models.fields("TextStyle")? { - writeln!(jc, "{}: value.{},", f.name, f.name)?; - } - jc.push_str("} }\nfn locator_node(value: LocatorNode, index: usize) -> Result { Ok(tui_test::locator_query::LocatorNode {\n"); - for f in models.fields("LocatorNode")? { - let expr = match f.ty.as_str() { - "LocatorNodeKind" => format!("value.{}.into()", f.name), - "Option" => format!("value.{}.map(Into::into)", f.name), - "Option" => format!("value.{}.map(|s| serde_json::from_value(serde_json::Value::String(s)).map_err(|error| usage(format!(\"invalid locator expression: {{error}}\")))).transpose()?", f.name), - "Option" => format!("value.{}.map(locator_style)", f.name), - "Option" => format!("optional_integer(value.{}, \"locator reference\", 255)?.map(|v| v as usize)", f.name), - "MatchOccurrence" => "occurrence(value.occurrence, value.nth, index)?".into(), - _ => format!("value.{}", f.name), - }; - writeln!(jc, "{}: {expr},", f.name)?; - } - jc.push_str("}) }\npub fn query(value: LocatorExpression) -> Result { tui_test::locator_query::LocatorExpression {\n"); - for f in models.fields("LocatorExpression")? { - let expr = match f.ty.as_str() { - "Vec" => format!("value.{}.into_iter().enumerate().map(|(i,v)| locator_node(v,i)).collect::>()?", f.name), - "usize" => format!("integer(value.{}, \"locator root\", 255)? as usize", f.name), - _ => bail!("no locator expression conversion for {}", f.ty), - }; - writeln!(jc, "{}: {expr},", f.name)?; - } - jc.push_str("}.into_query().map_err(native_error) }\n"); Ok(()) } pub fn generate(root: &Path, check: bool) -> Result<()> { let models = Models::load(root)?; - napi::check_exports(root)?; let notice = format!("// {NOTICE}\n"); let mut c = notice.clone(); c.push_str(&fs::read_to_string( root.join("crates/tui-test-ffi/src/type_helpers.rs"), )?); - let mut js = format!("{notice}use napi_derive::napi;\ninclude!(\"type_helpers.rs\");\n"); let mut cc = notice.clone(); - let mut jc = notice.clone(); let mut ci = notice; - output_models(&models, &mut c, &mut js, &mut cc, &mut jc)?; - enum_models(&models, &mut js, &mut jc)?; - input_models(&models, &mut c, &mut js, &mut jc, &mut ci)?; - input_conversions(&models, &mut jc, &mut ci)?; - napi::types(&models, &mut js)?; - napi::results(&models, &mut jc)?; + output_models(&models, &mut c, &mut cc)?; + input_models(&models, &mut c, &mut ci)?; + input_conversions(&models, &mut ci)?; for (path, content) in [ ("types.rs", c), - ("napi/types.rs", js), ("output_generated.rs", cc), - ("napi/convert_generated.rs", jc), ("input_generated.rs", ci), ("session_generated.rs", session_input(&models)?), - ("napi/bindings_generated.rs", napi::bindings(&models, root)?), ] { emit( root, @@ -949,134 +531,65 @@ pub fn generate(root: &Path, check: bool) -> Result<()> { check, )?; } + javascript::generate(&models, root, check)?; Ok(()) } #[cfg(test)] pub(crate) mod tests { use super::*; - - fn root() -> &'static Path { - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) - } - pub(crate) fn directory() -> tempfile::TempDir { - let parent = root().join("target/bindgen-tests"); - fs::create_dir_all(&parent).unwrap(); + let parent = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../target/bindgen-tests" + )); + fs::create_dir_all(parent).unwrap(); tempfile::tempdir_in(parent).unwrap() } - #[test] - fn owned_field_propagates_to_both_declarations_and_conversions() { - let mut models = Models::load(root()).unwrap(); - let source = fs::read_to_string(root().join("crates/tui-test/src/api.rs")).unwrap(); + fn owned_fields_drive_c_types_and_conversions() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + let mut models = Models::load(root).unwrap(); models - .parse(&source.replace( - "pub visible: bool,", - "pub visible: bool, pub generation_probe: bool,", - )) + .parse("struct Cursor { generation_probe: bool }") .unwrap(); - let (mut c, mut js, mut cc, mut jc) = Default::default(); - output_models(&models, &mut c, &mut js, &mut cc, &mut jc).unwrap(); - assert!(c.contains("pub generation_probe: bool,")); - assert!(js.contains("pub generation_probe: bool,")); - assert!(cc.contains("generation_probe: v.generation_probe,")); - assert!(jc.contains("generation_probe: value.generation_probe,")); - } - - #[test] - fn input_field_propagates_to_both_number_contracts() { - let mut models = Models::load(root()).unwrap(); + let (mut c, mut cc) = Default::default(); + output_models(&models, &mut c, &mut cc).unwrap(); + assert!(c.contains("pub generation_probe: bool")); + assert!(cc.contains("generation_probe: v.generation_probe")); models - .parse("pub struct Timeouts { pub new_wait: Option }") + .parse("struct Cursor { unsupported: Unknown }") .unwrap(); - let (mut c, mut js, mut jc, mut ci) = Default::default(); - input_models(&models, &mut c, &mut js, &mut jc, &mut ci).unwrap(); - assert!(c.contains("pub new_wait: TuiOptionalU64,")); - assert!(js.contains("pub new_wait: Option,")); - assert!(ci.contains("new_wait: value.new_wait.option(),")); - assert!(jc.contains("optional_integer(value.new_wait, \"timeouts.new_wait\", u64::MAX)?")); + assert!(output_models(&models, &mut c, &mut cc).is_err()); } - #[test] - fn transport_overrides_preserve_layout_and_javascript_contracts() { - let models = Models::load(root()).unwrap(); - let (mut c, mut js, mut cc, mut jc) = Default::default(); - output_models(&models, &mut c, &mut js, &mut cc, &mut jc).unwrap(); - assert!(c.contains("pub character: TuiString,")); - assert!(c.contains("pub spans: *const TuiSpan, pub spans_len: usize,")); - assert!(js.contains("#[napi(object, use_nullable = true)] pub struct State")); - assert!(js.contains("#[napi(js_name = \"bell_count\")]")); - assert!(js.contains("pub bell_count: f64,")); - assert!(js.contains("pub r#char: String,")); - assert!(js.contains("/// Whether the cursor is drawn (`DECTCEM`).")); - assert!(js.contains("pub fg: Color,")); - assert!(js.contains("#[napi(readonly, ts_type = \"Uint8Array\")]")); - assert!(cc.contains("let spans = s.spans(v.spans);")); - assert!(jc.contains("underline_style(&value.underline_style)?")); - let open = input_fields(&models, "OpenOptions", false).unwrap(); - let position = |name| open.iter().position(|f| f.name == name).unwrap(); + fn c_v3_input_field_order_remains_fixed() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + let models = Models::load(root).unwrap(); + let fields = input_fields(&models, "OpenOptions", false).unwrap(); + let position = |name| fields.iter().position(|f| f.name == name).unwrap(); assert_eq!(position("env_len"), position("env") + 1); assert_eq!(position("scrollback"), position("restart") + 1); assert_eq!(position("timeouts"), position("colors_len") + 1); } #[test] - fn strict_locator_keys_follow_owned_fields() { - let mut models = Models::load(root()).unwrap(); - models - .parse("pub struct TextStyle { pub new_predicate: Option }") - .unwrap(); - let (mut c, mut js, mut jc, mut ci) = Default::default(); - input_models(&models, &mut c, &mut js, &mut jc, &mut ci).unwrap(); - assert!(js.contains("pub new_predicate: Option,")); - assert!(js.contains("new_predicate => \"newPredicate\",")); - input_conversions(&models, &mut jc, &mut ci).unwrap(); - assert!(jc.contains("new_predicate: value.new_predicate,")); - } - - #[test] - fn session_context_fields_follow_diagnostics() { - let mut models = Models::load(root()).unwrap(); - models - .parse("pub struct DiagnosticRetentionOptions { pub new_limit: u16 }") - .unwrap(); - let session = session_input(&models).unwrap(); - assert!(session.contains("pub new_limit: Option,")); - assert!(session.contains("new_limit: self.new_limit.unwrap_or(defaults.new_limit),")); - let fields = input_fields(&models, "OpenOptions", true).unwrap(); - assert!(fields - .iter() - .any(|f| f.name == "new_limit" && f.ty == "Option")); - let (mut jc, mut ci) = Default::default(); - input_conversions(&models, &mut jc, &mut ci).unwrap(); - assert!(jc.contains("context.new_limit = optional_u16(value.new_limit, \"newLimit\")?;")); - } - - #[test] - fn enum_variants_follow_owned_source() { - let mut models = Models::load(root()).unwrap(); - models - .parse("pub enum RecordingFormat { Cast, Future }") - .unwrap(); - let (mut js, mut jc) = Default::default(); - enum_models(&models, &mut js, &mut jc).unwrap(); - assert!(js.contains("pub enum RecordingFormat { Cast,Future }")); - assert!(jc.contains("RecordingFormat::Future => Self::Future,")); - } - - #[test] - fn unknown_model_types_fail_instead_of_disappearing() { - let mut models = Models::load(root()).unwrap(); - models - .parse("pub struct Cursor { pub unknown: Unsupported }") - .unwrap(); - let (mut c, mut js, mut cc, mut jc) = Default::default(); - assert!(output_models(&models, &mut c, &mut js, &mut cc, &mut jc).is_err()); + fn owned_input_and_context_fields_drive_c_conversions() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + let mut models = Models::load(root).unwrap(); + models.parse("struct Timeouts { new_wait: Option } struct DiagnosticRetentionOptions { new_limit: u16 }").unwrap(); + let (mut c, mut ci) = Default::default(); + input_models(&models, &mut c, &mut ci).unwrap(); + assert!(c.contains("pub new_wait: TuiOptionalU64")); + assert!(ci.contains("new_wait: value.new_wait.option()")); + let context = session_input(&models).unwrap(); + assert!(context.contains("pub new_limit: Option")); + assert!(context.contains("new_limit: self.new_limit.unwrap_or(defaults.new_limit)")); } #[test] - fn regeneration_needs_no_generated_rust_and_check_detects_each_output() { + fn regeneration_bootstraps_and_checks_every_owned_boundary() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); let directory = directory(); let target = directory.path(); for path in [ @@ -1088,28 +601,27 @@ pub(crate) mod tests { "crates/tui-test/src/terminal/backend.rs", "crates/tui-test/src/terminal/cell.rs", "crates/tui-test-ffi/src/type_helpers.rs", - "crates/tui-test-ffi/src/napi/mod.rs", ] { fs::create_dir_all(target.join(path).parent().unwrap()).unwrap(); - fs::copy(root().join(path), target.join(path)).unwrap(); + fs::copy(root.join(path), target.join(path)).unwrap(); } generate(target, false).unwrap(); generate(target, true).unwrap(); for path in [ - "types.rs", - "napi/types.rs", - "output_generated.rs", - "napi/convert_generated.rs", - "input_generated.rs", - "session_generated.rs", - "napi/bindings_generated.rs", + "crates/tui-test-ffi/src/types.rs", + "crates/tui-test-ffi/src/input_generated.rs", + "crates/tui-test-ffi/src/output_generated.rs", + "crates/tui-test-ffi/src/session_generated.rs", + "bindings/js/native/index.d.ts", + "bindings/js/native/models.js", ] { - let output = target.join("crates/tui-test-ffi/src").join(path); + let output = target.join(path); let original = fs::read_to_string(&output).unwrap(); fs::write(&output, "// stale\n").unwrap(); - let error = generate(target, true).unwrap_err().to_string(); - assert!(error.contains(path), "{error}"); - assert!(error.contains("stale"), "{error}"); + assert!(generate(target, true) + .unwrap_err() + .to_string() + .contains(path)); fs::write(output, original).unwrap(); } } diff --git a/tools/bindgen/src/javascript.rs b/tools/bindgen/src/javascript.rs new file mode 100644 index 00000000..944dec63 --- /dev/null +++ b/tools/bindgen/src/javascript.rs @@ -0,0 +1,466 @@ +//! JavaScript names and defaults are transport metadata, not duplicate Rust models. +use super::*; +use std::collections::BTreeSet; + +// JavaScript method, owned operation, result variant. +const METHODS: &[(&str, &str, &str)] = &[ + ("restart", "Restart", "Open"), + ("state", "State", "State"), + ("text", "Text", "Text"), + ("packedScreen", "PackedScreen", "PackedScreen"), + ("cells", "Cells", "Cells"), + ("findLocator", "FindLocator", "Matches"), + ("waitLocator", "WaitLocator", "Unit"), + ("expectLocator", "WaitLocator", "Unit"), + ("clickLocator", "ClickLocator", "Unit"), + ("highlightLocator", "HighlightLocator", "Matches"), + ("getCommand", "GetCommand", "Command"), + ("getOutput", "GetOutput", "Output"), + ("getExitCode", "GetExitCode", "ExitCode"), + ("getCwd", "GetCwd", "Cwd"), + ("getTitle", "GetTitle", "Title"), + ("getClipboard", "GetClipboard", "Clipboard"), + ("getCursor", "GetCursor", "Cursor"), + ("getSize", "GetSize", "Size"), + ("getBellCount", "GetBellCount", "BellCount"), + ("getBellEvents", "GetBellEvents", "BellEvents"), + ("write", "Write", "Unit"), + ("type", "Write", "Unit"), + ("submit", "Submit", "Unit"), + ("press", "Key.Press", "Unit"), + ("keyDown", "Key.Down", "Unit"), + ("repeat", "Key.Repeat", "Unit"), + ("keyUp", "Key.Up", "Unit"), + ("mouseClick", "Mouse.Click", "Unit"), + ("mouseMove", "Mouse.Move", "Unit"), + ("mouseDown", "Mouse.Down", "Unit"), + ("mouseUp", "Mouse.Up", "Unit"), + ("mouseDrag", "Mouse.Drag", "Unit"), + ("mouseScroll", "Mouse.Scroll", "Unit"), + ("resize", "Resize", "Unit"), + ("signal", "Signal", "Unit"), + ("waitTitle", "WaitTitle", "Unit"), + ("expectTitle", "ExpectTitle", "Unit"), + ("waitClipboard", "WaitClipboard", "Unit"), + ("waitIdle", "WaitIdle", "Unit"), + ("waitCommand", "WaitCommand", "Unit"), + ("waitExit", "WaitExit", "Unit"), + ("waitReady", "WaitReady", "Unit"), + ("waitBell", "WaitBell", "Unit"), + ("expectExitCode", "ExpectExitCode", "Unit"), + ("expectOutput", "ExpectOutput", "Unit"), + ("expectBellCount", "ExpectBellCount", "Unit"), + ("snapshot", "Snapshot", "Snapshot"), + ("screenshot", "Screenshot", "Screenshot"), + ("startRecording", "StartRecording", "Unit"), + ("stopRecording", "StopRecording", "Recording"), +]; + +const INPUTS: &[(&str, &str)] = &[ + ("Timeouts", "Timeouts"), + ("AutomaticRecording", "AutomaticRecordingOptions"), + ("TraceOptions", "TraceOptions"), + ("FailureArtifactOptions", "FailureArtifactOptions"), + ("OpenOptions", "OpenOptions"), + ("RunOptions", "RunOptions"), + ("TextStyle", "LocatorStyle"), + ("LocatorNode", "LocatorNode"), + ("LocatorExpression", "LocatorExpression"), + ("Operation::WaitTitle", "TitleOptions"), + ("Operation::WaitTitle", "ClipboardWaitOptions"), + ("Operation::Snapshot", "SnapshotOptions"), + ("Operation::Screenshot", "ScreenshotOptions"), + ("Operation::StartRecording", "RecordingOptions"), +]; + +// These getters read current values without an intentional wait. Other operations +// retain Rust tasks so waits, input, and lifecycle work cannot exhaust Koffi's pool. +const SHORT_READS: &[&str] = &[ + "GetCommand", + "GetOutput", + "GetExitCode", + "GetCwd", + "GetTitle", + "GetClipboard", + "GetCursor", + "GetSize", + "GetBellCount", + "GetBellEvents", +]; +const ENUMS: &[(&str, &str)] = &[ + ("Backend", "Backend"), + ("Shell", "Shell"), + ("RecordingFormat", "RecordingFormat"), + ("SnapshotResult", "SnapshotResult"), + ("UnderlineStyle", "UnderlineStyle"), + ("LocatorNodeKind", "LocatorNodeKind"), + ("LocatorDirection", "LocatorStageDirection"), +]; + +fn ts_type(ty: &str) -> Result { + if let Some(inner) = option(ty) { + return Ok(format!("{} | null", ts_type(inner)?)); + } + if let Some(inner) = ty.strip_prefix("Box<").and_then(|s| s.strip_suffix('>')) { + return ts_type(inner); + } + if let Some(inner) = ty.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) { + return Ok(format!("Array<{}>", ts_type(inner)?)); + } + Ok(match ty { + "u8" | "u16" | "u32" | "u64" | "usize" | "i32" | "f64" | "MouseOptions" => "number".into(), + "String" + | "PathBuf" + | "WhitespaceMode" + | "TraceMode" + | "FailureArtifactMode" + | "ScreenshotResult" => "string".into(), + "bool" => "boolean".into(), + "()" => "void".into(), + "(String,String)" => "[string, string]".into(), + "BTreeMap" => "Record".into(), + "std::collections::BTreeMap" => "Record".into(), + "CellColor" => "Color".into(), + "TextStyle" => "LocatorStyle".into(), + "LocatorDirection" => "LocatorStageDirection".into(), + "LocatorQuery" => "LocatorExpression".into(), + t if OUTPUTS.contains(&t) + || INPUTS.iter().any(|(_, target)| *target == t) + || ENUMS.iter().any(|(_, target)| *target == t) + || t == "MouseClickOptions" => + { + t.into() + } + _ => bail!("no JavaScript mapping for {ty}"), + }) +} + +fn parameter(mut f: Field) -> Field { + f.name = match f.name.as_str() { + "query" => "expression", + "options" if f.ty == "MouseOptions" => "button", + other => other, + } + .into(); + if f.ty == "bool" || matches!(f.name.as_str(), "w" | "h" | "clicks" | "amount" | "button") { + f.ty = format!("Option<{}>", f.ty); + } + f +} + +fn parameters(models: &Models, method: &str, variant: &str) -> Result> { + let field = |name: &str, ty: &str| Field { + name: name.into(), + ty: ty.into(), + }; + Ok(match method { + "mouseClick" => vec![field("options", "Option")], + "waitTitle" | "expectTitle" => vec![ + field("text", "String"), + field("options", "Option"), + ], + "waitClipboard" => vec![ + field("text", "Option"), + field("options", "Option"), + ], + "snapshot" => vec![ + field("name", "String"), + field("options", "Option"), + ], + "screenshot" => vec![field("options", "Option")], + "startRecording" => vec![field("options", "RecordingOptions")], + _ => { + let mut fields = if variant.starts_with("Key.") { + models + .fields("Operation::Key")? + .into_iter() + .filter(|f| f.name != "action") + .collect() + } else if let Some(variant) = variant.strip_prefix("Mouse.") { + models.fields(&format!("MouseAction::{variant}"))? + } else { + models.fields(&format!("Operation::{variant}"))? + }; + if method == "findLocator" { + fields.push(field("require_one", "bool")); + } + if method == "type" { + fields[0].name = "text".into(); + } + fields.into_iter().map(parameter).collect() + } + }) +} + +fn declaration(fields: &[Field]) -> Result { + fields + .iter() + .map(|f| { + let (ty, suffix) = if let Some(inner) = option(&f.ty) { + (inner, "?") + } else { + (f.ty.as_str(), "") + }; + Ok(format!( + "{}{suffix}: {}{}", + js_key(&f.name), + ts_type(ty)?, + if suffix.is_empty() { + "" + } else { + " | undefined | null" + } + )) + }) + .collect::>>() + .map(|fields| fields.join(", ")) +} + +fn model( + models: &Models, + model: &str, + target: &str, + fields: Vec, + output: bool, + dts: &mut String, + schema: &mut String, +) -> Result<()> { + writeln!(dts, "export interface {target} {{")?; + writeln!(schema, "{target}: {{")?; + for f in fields { + let key = if output && matches!(model, "OpenResult" | "State" | "Cell" | "BellEvent") { + f.name.clone() + } else { + js_key(&f.name) + }; + let optional = !output && option(&f.ty).is_some(); + let ty = if optional { + option(&f.ty).unwrap() + } else { + &f.ty + }; + let ts = if model == "PackedScreen" && f.name == "utf8" { + "Uint8Array".into() + } else if model == "Cell" && f.name == "underline_style" { + "UnderlineStyle".into() + } else { + ts_type(ty)? + }; + writeln!( + dts, + " {}{key}{}: {ts}", + if model == "PackedScreen" { + "readonly " + } else { + "" + }, + if optional { "?" } else { "" } + )?; + writeln!(schema, "{key:?}: [{:?}, {:?}],", f.name, f.ty)?; + } + dts.push_str("}\n\n"); + schema.push_str("},\n"); + let _ = models; + Ok(()) +} + +fn coverage(models: &Models) -> Result<()> { + let results: BTreeSet<_> = METHODS + .iter() + .map(|(_, _, result)| *result) + .chain(["Open", "Modes", "Colors"]) + .collect(); + let owned_results: BTreeSet<_> = models.enums["OperationResult"] + .iter() + .map(String::as_str) + .collect(); + if results != owned_results { + bail!("OperationResult JavaScript coverage mismatch"); + } + let declared: BTreeSet<_> = METHODS + .iter() + .map(|(_, op, _)| op.split('.').next().unwrap()) + .chain([ + "Open", + "Run", + "Close", + "FinishTrace", + "ResolveLocator", + "WaitClipboardMatch", + "GetModes", + "GetColors", + "ExpectMode", + "ExpectColors", + "ExpectCursor", + ]) + .collect(); + let owned: BTreeSet<_> = models.enums["Operation"] + .iter() + .map(String::as_str) + .collect(); + if declared != owned { + bail!("Operation JavaScript coverage mismatch"); + } + for kind in ["Mouse", "Key"] { + let declared: BTreeSet<_> = METHODS + .iter() + .filter_map(|(_, op, _)| op.strip_prefix(&format!("{kind}."))) + .collect(); + let owned: BTreeSet<_> = models.enums[&format!("{kind}Action")] + .iter() + .map(String::as_str) + .collect(); + if declared != owned { + bail!("{kind}Action JavaScript coverage mismatch"); + } + } + Ok(()) +} + +pub(super) fn generate(models: &Models, root: &Path, check: bool) -> Result<()> { + coverage(models)?; + let mut dts = format!("// {NOTICE}\nexport type Color = number | string;\n\n"); + let mut schema = format!("// {NOTICE}\nexport const models = {{\n"); + for &name in OUTPUTS { + model( + models, + name, + name, + models.fields(name)?, + true, + &mut dts, + &mut schema, + )?; + } + for &(name, target) in INPUTS { + let mut fields = input_fields(models, name, true)?; + if target == "ClipboardWaitOptions" { + fields.retain(|f| f.name != "not"); + } + model(models, name, target, fields, false, &mut dts, &mut schema)?; + } + model( + models, + "MouseClickOptions", + "MouseClickOptions", + models + .fields("MouseAction::Click")? + .into_iter() + .map(parameter) + .collect(), + false, + &mut dts, + &mut schema, + )?; + schema.push_str("};\nexport const enums = {\n"); + let mut exports = String::new(); + for &(name, target) in ENUMS { + writeln!(dts, "export declare const enum {target} {{")?; + writeln!(schema, "{target}: {{")?; + for variant in &models.enums[name] { + writeln!(dts, " {variant} = {:?},", variant.to_lowercase())?; + writeln!(schema, "{variant}: {:?},", variant.to_lowercase())?; + } + dts.push_str("}\n\n"); + schema.push_str("},\n"); + writeln!(exports, "export const {target} = enums.{target};")?; + } + schema.push_str("};\n"); + schema.push_str(&exports); + schema.push_str("export const methods = {\n"); + dts.push_str("export declare class NativeSession {\n constructor(name: string, recording?: AutomaticRecordingOptions | null, artifacts?: FailureArtifactOptions | null, trace?: TraceOptions | null)\n name(): string\n open(options?: OpenOptions | null): Promise\n run(options: RunOptions): Promise\n close(failed?: boolean | null): Promise\n panicProbe(): Promise\n"); + for &(method, variant, result) in METHODS { + let fields = parameters(models, method, variant)?; + let result_fields = models.fields(&format!("OperationResult::{result}"))?; + let ty = match result_fields.as_slice() { + [] => "()", + [field] => &field.ty, + _ => bail!("unsupported result shape: {result}"), + }; + writeln!( + dts, + " {method}({}): Promise<{}>", + declaration(&fields)?, + ts_type(ty)? + )?; + write!(schema, "{method}: {{ result: {result:?}, ")?; + if SHORT_READS.contains(&variant) { + schema.push_str("short: true, "); + } + schema.push_str("args: ["); + for f in fields { + write!(schema, "[{:?}, {:?}],", js_key(&f.name), f.ty)?; + } + schema.push_str("] },\n"); + } + dts.push_str("}\n\nexport declare function sessions(): Promise>;\nexport declare function recording(name: string): Promise;\nexport declare function closeAll(): Promise;\nexport declare function closeAllSync(): void;\n"); + schema.push_str("};\n"); + emit(root, "bindings/js/native/index.d.ts", &dts, check)?; + emit(root, "bindings/js/native/models.js", &schema, check)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn owned_fields_drive_declarations_and_validation() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + let mut models = Models::load(root).unwrap(); + models + .parse("struct Cursor { generation_probe: bool }") + .unwrap(); + let (mut dts, mut schema) = Default::default(); + model( + &models, + "Cursor", + "Cursor", + models.fields("Cursor").unwrap(), + true, + &mut dts, + &mut schema, + ) + .unwrap(); + assert!(dts.contains("generationProbe: boolean")); + assert!(schema.contains("[\"generation_probe\", \"bool\"]")); + models + .parse("enum Operation { Resize { cols: String, rows: u16 } }") + .unwrap(); + let fields = parameters(&models, "resize", "Resize").unwrap(); + assert_eq!(declaration(&fields).unwrap(), "cols: string, rows: number"); + } + #[test] + fn new_operations_require_an_export_decision() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + for name in ["Operation", "OperationResult", "MouseAction", "KeyAction"] { + let mut models = Models::load(root).unwrap(); + models.enums.get_mut(name).unwrap().push("Future".into()); + assert!(coverage(&models).is_err()); + } + } + + #[test] + fn owned_inputs_enums_and_result_types_drive_javascript() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")); + let mut models = Models::load(root).unwrap(); + let source = fs::read_to_string(root.join("crates/tui-test/src/api.rs")).unwrap(); + models + .parse(&source.replace("ExitCode(Option)", "ExitCode(Option)")) + .unwrap(); + models.parse("struct Timeouts { new_wait: Option } struct TextStyle { new_predicate: Option } enum RecordingFormat { Cast, Future }").unwrap(); + let output = super::super::tests::directory(); + generate(&models, output.path(), false).unwrap(); + let dts = fs::read_to_string(output.path().join("bindings/js/native/index.d.ts")).unwrap(); + let schema = + fs::read_to_string(output.path().join("bindings/js/native/models.js")).unwrap(); + assert!(dts.contains("newWait?: number")); + assert!(dts.contains("newPredicate?: boolean")); + assert!(dts.contains("Future = \"future\"")); + assert!(dts.contains("getExitCode(): Promise")); + assert!(schema.contains("[\"new_wait\", \"Option\"]")); + assert!(schema.contains("[\"new_predicate\", \"Option\"]")); + assert!(schema.contains("getSize: { result: \"Size\", short: true,")); + assert!(schema.contains("waitIdle: { result: \"Unit\", args:")); + assert!(schema.contains("write: { result: \"Unit\", args:")); + } +} diff --git a/tools/bindgen/src/koffi.rs b/tools/bindgen/src/koffi.rs new file mode 100644 index 00000000..e3acd6f6 --- /dev/null +++ b/tools/bindgen/src/koffi.rs @@ -0,0 +1,178 @@ +//! Koffi layouts and typed asynchronous wrappers come from the C function signatures. +use super::*; + +fn ffi_type(ty: &Type) -> Result { + if matches!(ty, Type::Ptr(_)) { + return Ok("void *".into()); + } + Ok(match name(ty)?.as_str() { + "u8" => "uint8_t", + "u16" => "uint16_t", + "u32" => "uint32_t", + "u64" => "uint64_t", + "i32" => "int32_t", + "usize" => "size_t", + "f64" => "double", + "bool" => "bool", + other if other.starts_with("Tui") => return Ok(other.into()), + other => bail!("unsupported Koffi type: {other}"), + } + .into()) +} +fn descriptor(ty: &Type) -> Result { + if let Type::Ptr(pointer) = ty { + return Ok(format!("*{}", name(&pointer.elem)?)); + } + name(ty) +} +fn asynchronous(function: &ItemFn) -> bool { + matches!(&function.sig.output, ReturnType::Type(_, ty) if matches!(ty.as_ref(), Type::Ptr(_))) + && !function.sig.ident.to_string().ends_with("_ptr") +} + +pub fn generate( + root: &Path, + structs: &[&ItemStruct], + functions: &[&ItemFn], + version: &str, + check: bool, +) -> Result<()> { + let mut js = format!("// {NOTICE}\nimport koffi from 'koffi';\nexport const abiVersion = {version};\nexport const types = {{}};\n"); + let mut layouts = String::from("export const layouts = {\n"); + let mut emitted = BTreeSet::new(); + while emitted.len() < structs.len() { + let before = emitted.len(); + for item in structs { + let id = item.ident.to_string(); + if emitted.contains(&id) { + continue; + } + let ready = item.fields.iter().all(|f| { + !matches!(&f.ty, Type::Path(_)) + || name(&f.ty).is_ok_and(|n| !n.starts_with("Tui") || emitted.contains(&n)) + }); + if !ready { + continue; + } + writeln!(js, "types.{id} = koffi.struct({id:?}, {{")?; + writeln!(layouts, "{id}: {{")?; + for field in &item.fields { + let field_name = field.ident.as_ref().context("named C field")?.to_string(); + writeln!(js, "{field_name}: {:?},", ffi_type(&field.ty)?)?; + writeln!(layouts, "{field_name}: {:?},", descriptor(&field.ty)?)?; + } + js.push_str("});\n"); + layouts.push_str("},\n"); + emitted.insert(id); + } + if emitted.len() == before { + bail!("cyclic Koffi structure"); + } + } + layouts.push_str("};\n"); + js.push_str(&layouts); + js.push_str("export function bind(library) {\nconst functions = {};\n"); + let mut wrappers = format!("// {NOTICE}\nuse super::*;\n"); + for function in functions { + let id = function.sig.ident.to_string(); + let args = arguments(function)? + .into_iter() + .map(ffi_type) + .collect::>>()?; + let args = args + .iter() + .map(|s| format!("{s:?}")) + .collect::>() + .join(", "); + let result = match &function.sig.output { + ReturnType::Default => "void".into(), + ReturnType::Type(_, ty) => ffi_type(ty)?, + }; + writeln!( + js, + "functions.{id} = library.func({id:?}, {result:?}, [{args}]);" + )?; + if asynchronous(function) { + writeln!( + js, + "functions.{id}_start = library.func({:?}, 'void *', [{args}]);", + format!("{id}_start") + )?; + let params = function.sig.inputs.to_token_stream().to_string(); + let names = function + .sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Typed(arg) => Ok(arg.pat.to_token_stream().to_string()), + _ => bail!("C receiver"), + }) + .collect::>>()? + .join(", "); + let call = if function.sig.unsafety.is_some() { + format!("unsafe {{ {id}({names}) }}") + } else { + format!("{id}({names})") + }; + writeln!(wrappers, "#[no_mangle]\n/// Start a typed call after copying all borrowed inputs.\n/// # Safety\n/// Inputs follow the synchronous function contract through this call only.\npub unsafe extern \"C\" fn {id}_start({params}) -> *mut TuiTask {{ tasks::start(|| {call}) }}")?; + } + } + let tasks = syn::parse_file(&fs::read_to_string( + root.join("crates/tui-test-ffi/src/tasks.rs"), + )?)?; + for item in tasks.items { + if let Item::Fn(function) = item { + if !function + .attrs + .iter() + .any(|a| a.path().is_ident("no_mangle")) + { + continue; + } + let id = function.sig.ident.to_string(); + let args = arguments(&function)? + .into_iter() + .map(ffi_type) + .collect::>>()?; + let args = args + .iter() + .map(|s| format!("{s:?}")) + .collect::>() + .join(", "); + let result = match &function.sig.output { + ReturnType::Default => "void".into(), + ReturnType::Type(_, ty) => ffi_type(ty)?, + }; + writeln!( + js, + "functions.{id} = library.func({id:?}, {result:?}, [{args}]);" + )?; + } + } + js.push_str("return functions;\n}\n"); + emit(root, "bindings/js/native/abi.js", &js, check)?; + emit( + root, + "crates/tui-test-ffi/src/tasks_generated.rs", + &format_source("rustfmt", &["--edition", "2021"], &wrappers)?, + check, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn c_types_drive_koffi_layouts() { + assert_eq!( + ffi_type(&syn::parse_str("u64").unwrap()).unwrap(), + "uint64_t" + ); + assert_eq!( + descriptor(&syn::parse_str("*const TuiCell").unwrap()).unwrap(), + "*TuiCell" + ); + assert!(ffi_type(&syn::parse_str("String").unwrap()).is_err()); + } +} diff --git a/tools/bindgen/src/main.rs b/tools/bindgen/src/main.rs index 1bd287e7..83dcb114 100644 --- a/tools/bindgen/src/main.rs +++ b/tools/bindgen/src/main.rs @@ -10,6 +10,7 @@ use syn::{FnArg, Item, ItemFn, ItemStruct, ReturnType, Type}; const NOTICE: &str = "Generated by cargo run -p tui-test-bindgen. Do not edit."; mod boundaries; +mod koffi; fn main() -> Result<()> { let args: Vec<_> = std::env::args().skip(1).collect(); @@ -70,6 +71,7 @@ fn main() -> Result<()> { } } + koffi::generate(&root, &structs, &functions, &version, check)?; let mut header = Vec::new(); cbindgen::Builder::new() .with_crate(&source) diff --git a/tools/bindgen/src/napi.rs b/tools/bindgen/src/napi.rs deleted file mode 100644 index 8ccef13b..00000000 --- a/tools/bindgen/src/napi.rs +++ /dev/null @@ -1,812 +0,0 @@ -//! JavaScript method names, defaults, and result selection are transport metadata. -//! Field types and operation construction come from the owned Rust enums. -use super::{assignment, ident, js_key, js_output, js_type, option, snake, Field, Models}; -use crate::NOTICE; -use anyhow::{bail, Context, Result}; -use quote::ToTokens; -use std::{collections::BTreeSet, fmt::Write, fs, path::Path}; -use syn::{FnArg, ImplItem, Item, Pat}; - -// (JavaScript method in Rust spelling, owned variant, result selector, trace name). -// A dotted variant binds a nested MouseAction or a fixed KeyAction. -const METHODS: &[(&str, &str, &str, &str)] = &[ - ("restart", "Restart", "Open", "restart"), - ("state", "State", "State", "state"), - ("text", "Text", "Text", "text"), - ( - "packed_screen", - "PackedScreen", - "PackedScreen", - "packedScreen", - ), - ("cells", "Cells", "Cells", "cells"), - ( - "find_locator", - "FindLocator", - "Matches", - "locator.locations", - ), - ("wait_locator", "WaitLocator", "Unit", "locator.wait"), - ("expect_locator", "WaitLocator", "Unit", "locator.expect"), - ("click_locator", "ClickLocator", "Unit", "locator.click"), - ( - "highlight_locator", - "HighlightLocator", - "Matches", - "locator.highlight", - ), - ("get_command", "GetCommand", "Command", "getCommand"), - ("get_output", "GetOutput", "Command", "getOutput"), - ("get_exit_code", "GetExitCode", "ExitCode", "getExitCode"), - ("get_cwd", "GetCwd", "Command", "getCwd"), - ("get_title", "GetTitle", "Command", "getTitle"), - ("get_clipboard", "GetClipboard", "Text", "getClipboard"), - ("get_cursor", "GetCursor", "Cursor", "getCursor"), - ("get_size", "GetSize", "Size", "getSize"), - ( - "get_bell_count", - "GetBellCount", - "BellCount", - "getBellCount", - ), - ( - "get_bell_events", - "GetBellEvents", - "BellEvents", - "getBellEvents", - ), - ("write", "Write", "Unit", "write"), - ("type_text", "Write", "Unit", "type"), - ("submit", "Submit", "Unit", "submit"), - ("press", "Key.Press", "Unit", "press"), - ("key_down", "Key.Down", "Unit", "keyDown"), - ("repeat", "Key.Repeat", "Unit", "repeat"), - ("key_up", "Key.Up", "Unit", "keyUp"), - ("mouse_click", "Mouse.Click", "Unit", "mouse.click"), - ("mouse_move", "Mouse.Move", "Unit", "mouse.move"), - ("mouse_down", "Mouse.Down", "Unit", "mouse.down"), - ("mouse_up", "Mouse.Up", "Unit", "mouse.up"), - ("mouse_drag", "Mouse.Drag", "Unit", "mouse.drag"), - ("mouse_scroll", "Mouse.Scroll", "Unit", "mouse.scroll"), - ("resize", "Resize", "Unit", "resize"), - ("signal", "Signal", "Unit", "signal"), - ("wait_title", "WaitTitle", "Unit", "waitTitle"), - ("expect_title", "ExpectTitle", "Unit", "expectTitle"), - ("wait_clipboard", "WaitClipboard", "Unit", "waitClipboard"), - ("wait_idle", "WaitIdle", "Unit", "waitIdle"), - ("wait_command", "WaitCommand", "Unit", "waitCommand"), - ("wait_exit", "WaitExit", "Unit", "waitExit"), - ("wait_ready", "WaitReady", "Unit", "waitReady"), - ("wait_bell", "WaitBell", "Unit", "waitBell"), - ( - "expect_exit_code", - "ExpectExitCode", - "Unit", - "expectExitCode", - ), - ("expect_output", "ExpectOutput", "Unit", "expectOutput"), - ( - "expect_bell_count", - "ExpectBellCount", - "Unit", - "expectBellCount", - ), - ("snapshot", "Snapshot", "Snapshot", "expectSnapshot"), - ("screenshot", "Screenshot", "Text", "screenshot"), - ( - "start_recording", - "StartRecording", - "Unit", - "startRecording", - ), - ("stop_recording", "StopRecording", "Text", "stopRecording"), -]; - -// These variants have lifecycle/selection semantics rather than direct methods. -const SPECIAL_OPERATIONS: &[&str] = &[ - "Open", - "Run", - "Close", - "FinishTrace", - "ResolveLocator", - "WaitClipboardMatch", -]; -// These operations have no native JavaScript method. Additions must be classified explicitly. -const UNEXPORTED_OPERATIONS: &[&str] = &[ - "GetModes", - "GetColors", - "ExpectMode", - "ExpectColors", - "ExpectCursor", -]; -const LIFECYCLE: &[&str] = &["new", "name", "open", "run", "close"]; -const GLOBALS: &[&str] = &["sessions", "close_all_sync", "recording"]; - -fn coverage(models: &Models) -> Result<()> { - for (model, declared) in [ - ( - "Operation", - METHODS - .iter() - .map(|m| m.1.split('.').next().unwrap()) - .chain(SPECIAL_OPERATIONS.iter().copied()) - .chain(UNEXPORTED_OPERATIONS.iter().copied()) - .collect::>(), - ), - ( - "MouseAction", - METHODS - .iter() - .filter_map(|m| m.1.strip_prefix("Mouse.")) - .collect(), - ), - ( - "KeyAction", - METHODS - .iter() - .filter_map(|m| m.1.strip_prefix("Key.")) - .collect(), - ), - ] { - let owned: BTreeSet<_> = models - .enums - .get(model) - .context("missing operation enum")? - .iter() - .map(String::as_str) - .collect(); - if owned != declared { - bail!( - "{model} N-API coverage mismatch: {:?}", - owned.symmetric_difference(&declared).collect::>() - ); - } - } - Ok(()) -} - -/// All exported attributes must reside in outputs that generation replaces. -pub(super) fn check_exports(root: &Path) -> Result<()> { - fn scan(path: &Path, root: &Path) -> Result<()> { - if !path.exists() { - return Ok(()); - } - for entry in fs::read_dir(path)? { - let path = entry?.path(); - if path.is_dir() { - scan(&path, root)?; - } else if path.extension().is_some_and(|ext| ext == "rs") - && !["napi/types.rs", "napi/bindings_generated.rs"] - .iter() - .any(|output| path == root.join(output)) - { - let source = fs::read_to_string(&path)?; - // Normalized tokens include attributes inside impls, modules, and macros. - let tokens = syn::parse_file(&source)? - .to_token_stream() - .to_string() - .replace(' ', ""); - if tokens.contains("napi_derive") - || tokens.contains("#[napi") - || tokens.contains("#[::napi") - || (tokens.contains("#[cfg_attr(") && tokens.contains(",napi")) - { - bail!("handwritten N-API export in {}", path.display()); - } - } - } - Ok(()) - } - let source = root.join("crates/tui-test-ffi/src"); - scan(&source, &source) -} - -fn parameter(method: &str, field: &Field, prefix: &str) -> Result<(String, String, String)> { - let name = match field.name.as_str() { - "query" => "expression", - "options" if field.ty == "MouseOptions" => "button", - "data" if method == "type_text" => "text", - other => other, - } - .to_owned(); - let ty = input_type(&field.ty)?; - let default = match field.name.as_str() { - "w" | "h" | "clicks" => Some("1.0"), - "amount" => Some("3.0"), - "options" if field.ty == "MouseOptions" => Some("0.0"), - _ if field.ty == "bool" => Some("false"), - _ => None, - }; - let ty = if default.is_some() { - format!("Option<{ty}>") - } else { - ty - }; - let value = if let Some(default) = default { - format!("{prefix}{name}.unwrap_or({default})") - } else { - format!("{prefix}{name}") - }; - Ok(( - name.clone(), - ty, - input_value(&field.ty, &value, &js_key(&name))?, - )) -} - -fn input_type(ty: &str) -> Result { - if let Some(inner) = option(ty) { - return Ok(format!("Option<{}>", input_type(inner)?)); - } - Ok(match ty { - "u8" | "u16" | "u32" | "u64" | "usize" | "i32" | "MouseOptions" => "f64".into(), - "LocatorQuery" => "LocatorExpression".into(), - _ => js_type(ty)?, - }) -} - -fn input_value(ty: &str, value: &str, label: &str) -> Result { - if let Some(inner) = option(ty) { - if input_type(inner)? == inner { - return Ok(value.into()); - } - let conversion = input_value(inner, "v", label)?; - let conversion = conversion - .strip_suffix('?') - .map(str::to_owned) - .unwrap_or_else(|| format!("Ok({conversion})")); - return Ok(format!( - "{value}.map(|v| -> Result<_> {{ {conversion} }}).transpose()?" - )); - } - Ok(match ty { - "u16" => format!("u16_value({value}, {label:?})?"), - "u8" => format!("u8_value({value}, {label:?})?"), - "i32" => format!("i32_value({value}, {label:?})?"), - "u64" => format!("integer({value}, {label:?}, u64::MAX)?"), - "u32" | "usize" => format!("integer({value}, {label:?}, {ty}::MAX as u64)? as {ty}"), - "LocatorQuery" => format!("query({value})?"), - "MouseOptions" => format!("mouse_options({value})?"), - _ => { - input_type(ty)?; - value.into() - } - }) -} - -pub(super) fn types(models: &Models, js: &mut String) -> Result<()> { - js.push_str("#[napi] pub type Color = Either;\n#[derive(Default)] #[napi(object)] pub struct MouseClickOptions {\n"); - for field in models.fields("MouseAction::Click")? { - let (name, ty, _) = parameter("mouse_click", &field, "")?; - writeln!(js, "pub {}: {},", ident(&name), ty)?; - } - js.push_str("}\n"); - Ok(()) -} - -// Result unions preserve the existing adapter contract. The first variant supplies the type. -const RESULTS: &[(&str, &[&str])] = &[ - ("Unit", &["Unit"]), - ("Open", &["Open"]), - ("State", &["State"]), - ("Text", &["Text", "Clipboard", "Recording", "Screenshot"]), - ("PackedScreen", &["PackedScreen"]), - ("Cells", &["Cells"]), - ("Matches", &["Matches"]), - ("Command", &["Command", "Output", "Cwd", "Title"]), - ("ExitCode", &["ExitCode"]), - ("Cursor", &["Cursor"]), - ("Size", &["Size"]), - ("BellCount", &["BellCount"]), - ("BellEvents", &["BellEvents"]), - ("Snapshot", &["Snapshot"]), -]; - -fn result_type(models: &Models, variant: &str) -> Result { - let fields = models.fields(&format!("OperationResult::{variant}"))?; - match fields.as_slice() { - [] => Ok("()".into()), - [field] => js_type( - field - .ty - .strip_prefix("Box<") - .and_then(|t| t.strip_suffix('>')) - .unwrap_or(&field.ty), - ) - .or_else(|error| { - if field.ty == "SnapshotResult" { - Ok(field.ty.clone()) - } else { - Err(error) - } - }), - _ => bail!("unsupported result shape: {variant}"), - } -} - -fn result_name(variant: &str) -> String { - match variant { - "Unit" => "unit".into(), - "Text" => "string_result".into(), - "Command" => "optional_string_result".into(), - "Matches" => "matches".into(), - _ => format!("{}_result", snake(variant)), - } -} - -pub(super) fn results(models: &Models, out: &mut String) -> Result<()> { - let covered: BTreeSet<_> = RESULTS - .iter() - .flat_map(|r| r.1.iter().copied()) - .chain(["Modes", "Colors"]) - .collect(); - let owned: BTreeSet<_> = models.enums["OperationResult"] - .iter() - .map(String::as_str) - .collect(); - if covered != owned { - bail!("OperationResult N-API coverage mismatch"); - } - for (selector, variants) in RESULTS { - writeln!( - out, - "pub fn {}(value: OperationResult) -> Result<{}> {{ match value {{", - result_name(selector), - result_type(models, selector)? - )?; - for variant in *variants { - let fields = models.fields(&format!("OperationResult::{variant}"))?; - let (pattern, conversion) = match fields.as_slice() { - [] => (format!("OperationResult::{variant}"), "Ok(())".into()), - [field] => { - let (ty, value) = if let Some(inner) = field - .ty - .strip_prefix("Box<") - .and_then(|t| t.strip_suffix('>')) - { - (inner, "*value") - } else { - (field.ty.as_str(), "value") - }; - let conversion = match ty { - "Vec" => "value.into_iter().map(cell).collect()".into(), - "SnapshotResult" => "Ok(value.into())".into(), - "ScreenshotResult" => { - let mut arms = String::new(); - for variant in &models.enums["ScreenshotResult"] { - let fields = - models.fields(&format!("ScreenshotResult::{variant}"))?; - if fields.len() != 1 || fields[0].ty != "String" { - bail!("unsupported screenshot result"); - } - write!( - arms, - "api::ScreenshotResult::{variant}(value) => Ok(value)," - )?; - } - format!("match value {{ {arms} }}") - } - _ => format!("Ok({})", js_output(ty, value)?), - }; - (format!("OperationResult::{variant}(value)"), conversion) - } - _ => bail!("unsupported result shape: {variant}"), - }; - writeln!(out, "{pattern} => {conversion},")?; - } - out.push_str( - "_ => Err(internal(\"native operation returned an unexpected result\")), } }\n", - ); - } - Ok(()) -} - -fn operation_method( - models: &Models, - method: &str, - source: &str, - result: &str, - trace: &str, -) -> Result { - let (operation, nested) = source - .split_once('.') - .map_or((source, None), |(a, b)| (a, Some(b))); - let model = if operation == "Mouse" { - format!("MouseAction::{}", nested.unwrap()) - } else { - format!("Operation::{operation}") - }; - let mut params = Vec::new(); - let mut assignments = String::new(); - let mut setup = String::new(); - let mut expression = String::new(); - let options = match operation { - "WaitTitle" | "ExpectTitle" => Some(("TitleOptions", Some("text"))), - "Snapshot" => Some(("SnapshotOptions", Some("name"))), - "Screenshot" => Some(("ScreenshotOptions", None)), - "StartRecording" => Some(("RecordingOptions", None)), - _ => None, - }; - if let Some((dto, separate)) = options { - if let Some(name) = separate { - let field = models - .fields(&model)? - .into_iter() - .find(|f| f.name == name) - .context("separate option argument")?; - params.push(format!("{name}: {}", input_type(&field.ty)?)); - } - let optional = operation != "StartRecording"; - params.push(format!( - "options: {}", - if optional { - format!("Option<{dto}>") - } else { - dto.into() - } - )); - if optional { - setup.push_str("let options = options.unwrap_or_default();"); - } - expression = format!( - "{}_operation(options{})", - snake(operation), - separate.map(|s| format!(", {s}")).unwrap_or_default() - ); - } else if operation == "WaitClipboard" { - // This JS overload chooses between two operations and validates a regex. - let text = models - .fields("ClipboardPattern::Text")? - .into_iter() - .next() - .context("clipboard text")?; - params.push(format!("text: Option<{}>", input_type(&text.ty)?)); - params.push("options: Option".into()); - setup.push_str("let options = options.unwrap_or_default();"); - expression = "wait_clipboard_operation(text, options)".into(); - } else { - let grouped = source == "Mouse.Click"; - if grouped { - params.push("options: Option".into()); - setup.push_str("let options = options.unwrap_or_default();"); - } - for field in models.fields(&model)? { - let value = if operation == "Key" && field.name == "action" { - format!("KeyAction::{}", nested.context("key action")?) - } else { - let (name, ty, value) = - parameter(method, &field, if grouped { "options." } else { "" })?; - if !grouped { - params.push(format!("{name}: {ty}")); - } - value - }; - assignment(&mut assignments, ident(&field.name), &value)?; - } - let value = if assignments.is_empty() { - model.clone() - } else { - format!("{model} {{ {assignments} }}") - }; - let value = if operation == "Mouse" { - format!("Operation::Mouse {{ action: {value} }}") - } else { - value - }; - write!(expression, "Ok({value})")?; - } - let trace = if method == "find_locator" { - params.push("require_one: Option".into()); - setup.push_str("let require_one = require_one.unwrap_or(false);"); - expression = format!("Ok(if require_one {{ Operation::ResolveLocator {{ {assignments} }} }} else {{ Operation::FindLocator {{ {assignments} }} }})"); - "if require_one { \"locator.location\" } else { \"locator.locations\" }".into() - } else { - format!("{trace:?}") - }; - Ok(format!( - "#[napi{}] pub async fn {method}(&self{}) -> Result<{}> {{ {setup} self.inner.call({trace}, move || {{ {expression} }}, {}).await }}\n", - if method == "type_text" { "(js_name = \"type\")" } else { "" }, - params.iter().map(|p| format!(", {p}")).collect::(), - result_type(models, result)?, result_name(result), - )) -} - -/// Lifecycle signatures come from the real runtime functions, not a parallel declaration. -fn forward(sig: &syn::Signature, receiver: bool) -> Result { - let name = sig.ident.to_string(); - let args = sig - .inputs - .iter() - .filter_map(|arg| match arg { - FnArg::Typed(arg) => Some(match arg.pat.as_ref() { - Pat::Ident(pat) => Ok(pat.ident.to_string()), - _ => Err(anyhow::anyhow!("unsupported runtime argument")), - }), - FnArg::Receiver(_) => None, - }) - .collect::>>()? - .join(","); - let target = if receiver { - if name == "new" { - "SessionRuntime::" - } else { - "self.inner." - } - } else { - "super::" - }; - Ok(format!( - "#[napi{}] pub {} {{ {target}{name}({args}){}{} }}\n", - if name == "new" { "(constructor)" } else { "" }, - sig.to_token_stream(), - if sig.asyncness.is_some() { - ".await" - } else { - "" - }, - if name == "new" { - ".map(|inner| Self { inner })" - } else { - "" - }, - )) -} - -pub(super) fn bindings(models: &Models, root: &Path) -> Result { - coverage(models)?; - let runtime = syn::parse_file(&fs::read_to_string( - root.join("crates/tui-test-ffi/src/napi/mod.rs"), - )?)?; - let mut lifecycle = std::collections::BTreeMap::new(); - let mut globals = std::collections::BTreeMap::new(); - for item in runtime.items { - match item { - Item::Impl(item) if item.self_ty.to_token_stream().to_string() == "SessionRuntime" => { - for item in item.items { - if let ImplItem::Fn(item) = item { - if LIFECYCLE.contains(&item.sig.ident.to_string().as_str()) { - lifecycle.insert(item.sig.ident.to_string(), item.sig); - } - } - } - } - Item::Fn(item) if GLOBALS.contains(&item.sig.ident.to_string().as_str()) => { - globals.insert(item.sig.ident.to_string(), item.sig); - } - _ => {} - } - } - let mut out = format!("// {NOTICE}\nuse super::*;\nuse napi_derive::napi;\nuse tui_test::api::{{KeyAction, MouseAction}};\n#[napi] pub struct NativeSession {{ inner: SessionRuntime }}\n#[napi] impl NativeSession {{\n"); - for name in LIFECYCLE.iter().filter(|name| **name != "close") { - out.push_str(&forward( - lifecycle - .get(*name) - .with_context(|| format!("missing lifecycle method {name}"))?, - true, - )?); - } - for &(method, source, result, trace) in METHODS { - out.push_str(&operation_method(models, method, source, result, trace)?); - // Keep the published declaration order stable. - if method == "restart" { - out.push_str(&forward( - lifecycle - .get("close") - .context("missing lifecycle method close")?, - true, - )?); - } - if method == "highlight_locator" { - out.push_str("#[napi] pub async fn panic_probe(&self) -> Result<()> { blocking(|| panic!(\"intentional native panic probe\")).await }\n"); - } - } - out.push_str("}\n"); - for name in GLOBALS { - out.push_str(&forward( - globals - .get(*name) - .with_context(|| format!("missing global {name}"))?, - false, - )?); - } - out.push_str("#[napi] pub async fn close_all() -> Result<()> { blocking(super::close_all_sync).await }\n"); - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn root() -> &'static Path { - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) - } - - #[test] - fn operation_fields_drive_parameters_validation_and_dispatch() { - let mut models = Models::load(root()).unwrap(); - models - .parse("enum Operation { Resize { cols: u16, rows: String, new_limit: Option } }") - .unwrap(); - let output = operation_method(&models, "resize", "Resize", "Unit", "resize").unwrap(); - assert!(output.contains("cols: f64, rows: String, new_limit: Option")); - assert!(output.contains("cols: u16_value(cols, \"cols\")?")); - assert!(output.contains("rows,")); - assert!(output.contains("new_limit: new_limit.map")); - assert!(output.contains("integer(v, \"newLimit\", u64::MAX)")); - assert!(output.contains("Operation::Resize")); - models - .parse("enum Operation { Resize { unknown: Unsupported } }") - .unwrap(); - assert!(operation_method(&models, "resize", "Resize", "Unit", "resize").is_err()); - } - - #[test] - fn mouse_fields_drive_both_options_and_method_conversion() { - let mut models = Models::load(root()).unwrap(); - models - .parse("enum MouseAction { Click { value: Option } }") - .unwrap(); - let mut declarations = String::new(); - types(&models, &mut declarations).unwrap(); - let method = - operation_method(&models, "mouse_click", "Mouse.Click", "Unit", "mouse.click").unwrap(); - assert!(declarations.contains("pub value: Option")); - assert!(method.contains("value: options.value.map")); - assert!(method.contains("u16_value(v, \"value\")")); - } - - #[test] - fn result_payload_drives_method_signature_and_selection() { - let mut models = Models::load(root()).unwrap(); - let source = fs::read_to_string(root().join("crates/tui-test/src/api.rs")).unwrap(); - models - .parse(&source.replace("ExitCode(Option)", "ExitCode(Option)")) - .unwrap(); - let method = operation_method( - &models, - "get_exit_code", - "GetExitCode", - "ExitCode", - "getExitCode", - ) - .unwrap(); - assert!(method.contains("Result>")); - let mut conversions = String::new(); - results(&models, &mut conversions).unwrap(); - assert!(conversions - .contains("fn exit_code_result(value: OperationResult) -> Result>")); - assert!(conversions.contains("OperationResult::ExitCode(value) => Ok(value)")); - } - - #[test] - fn new_variants_require_an_explicit_export_decision() { - for model in ["Operation", "MouseAction", "KeyAction", "OperationResult"] { - let mut models = Models::load(root()).unwrap(); - models - .enums - .get_mut(model) - .unwrap() - .push("NewVariant".into()); - let result = if model == "OperationResult" { - results(&models, &mut String::new()) - } else { - coverage(&models) - }; - assert!( - result - .unwrap_err() - .to_string() - .contains("coverage mismatch"), - "{model}" - ); - } - } - - #[test] - fn lifecycle_wrappers_follow_runtime_signatures() { - let item: syn::ItemFn = syn::parse_str("async fn open(&self, options: Option, probe: bool) -> Result {}").unwrap(); - let wrapper = forward(&item.sig, true).unwrap(); - assert!(wrapper.contains("probe : bool")); - assert!(wrapper.contains("self.inner.open(options,probe).await")); - let item: syn::ItemFn = syn::parse_str("fn new(name: String) -> Result {}").unwrap(); - let wrapper = forward(&item.sig, true).unwrap(); - assert!(wrapper.contains("#[napi(constructor)]")); - assert!(wrapper.contains("SessionRuntime::new(name).map(|inner| Self { inner })")); - } - - #[test] - fn every_native_export_is_generated_and_matches_the_published_surface() { - check_exports(root()).unwrap(); - let models = Models::load(root()).unwrap(); - let output = bindings(&models, root()).unwrap(); - let mut methods = BTreeSet::new(); - let parsed = syn::parse_file(&output).unwrap(); - for item in parsed.items { - if let Item::Impl(item) = item { - for item in item.items { - if let ImplItem::Fn(item) = item { - assert!(item.attrs.iter().any(|a| a.path().is_ident("napi"))); - let name = item.sig.ident.to_string(); - methods.insert(match name.as_str() { - "new" => "constructor".into(), - "type_text" => "type".into(), - _ => js_key(&name), - }); - } - } - } - } - let declarations = - fs::read_to_string(root().join("bindings/js/native/index.d.ts")).unwrap(); - let published = declarations - .split("export declare class NativeSession {") - .nth(1) - .unwrap() - .split('}') - .next() - .unwrap() - .lines() - .filter_map(|line| line.trim().split_once('(').map(|(name, _)| name.to_owned())) - .collect::>(); - assert_eq!(methods, published); - for name in GLOBALS { - assert!(output.contains(&format!("fn {name}"))); - } - assert!(output.contains("fn close_all()")); - } - - #[test] - fn manual_exports_cannot_hide_in_helpers_or_nested_modules() { - let directory = super::super::tests::directory(); - let source = directory.path().join("crates/tui-test-ffi/src"); - fs::create_dir_all(source.join("napi/nested")).unwrap(); - for (path, text) in [ - ("helper.rs", "#[napi] pub fn extra() {}"), - ( - "napi/nested/types.rs", - "mod nested { #[napi(object)] pub struct Extra {} }", - ), - ( - "napi/nested/types.rs", - "macro_rules! exports { () => { # [ napi ] pub fn extra() {} } }", - ), - ( - "napi/nested/types.rs", - "#[cfg_attr(feature = \"napi\", napi)] pub fn extra() {}", - ), - ( - "napi/nested/types.rs", - "use napi_derive::napi as export; #[export] pub fn extra() {}", - ), - ] { - let path = source.join(path); - fs::write(&path, text).unwrap(); - let error = check_exports(directory.path()).unwrap_err().to_string(); - assert!(error.contains("handwritten N-API export"), "{error}"); - fs::remove_file(path).unwrap(); - } - } - - #[test] - fn trace_names_defaults_and_selectors_remain_explicit() { - let models = Models::load(root()).unwrap(); - let output = bindings(&models, root()).unwrap(); - for (_, _, _, trace) in METHODS { - assert!(output.contains(&format!("{trace:?}")), "{trace}"); - } - for expression in [ - "if require_one { \"locator.location\" } else { \"locator.locations\" }", - "Operation::ResolveLocator", - "Operation::FindLocator", - "full.unwrap_or(false)", - "w.unwrap_or(1.0)", - "h.unwrap_or(1.0)", - "options.clicks.unwrap_or(1.0)", - "button.unwrap_or(0.0)", - "amount.unwrap_or(3.0)", - "blocking(|| panic!(\"intentional native panic probe\"))", - ] { - assert!(output.contains(expression), "{expression}"); - } - } -} From 9a4addcbf3c04a1ff86f9d9426ea0df7e0e5734d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 11:22:46 -0400 Subject: [PATCH 09/15] fix: make native CI checks portable and explicit Signed-off-by: Ayman Bagabas --- bindings/js/test/native.test.mjs | 2 +- crates/tui-test-ffi/src/tasks.rs | 20 +++++++++++--------- crates/tui-test-ffi/src/tests.rs | 6 ++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 31463b61..fd48b24c 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -142,7 +142,7 @@ test("concurrent waits cannot starve input or close", async () => { assert.ok(sizes.every((size) => size.cols === 80 && size.rows === 30)); assert.equal(completedWaits, 0, "short reads waited for long waits to complete"); const start = performance.now(); - await session.write("koffi-completed\n"); + await session.submit("koffi-completed"); await Promise.all(waits); assert.ok(performance.now() - start < 2000, "waits blocked the input call"); const pending = Array.from({ length: 32 }, () => session.waitTitle("never-set", { timeoutMs: 5000 }).catch(mapNativeError)); diff --git a/crates/tui-test-ffi/src/tasks.rs b/crates/tui-test-ffi/src/tasks.rs index 6a04a99b..19e6f3f4 100644 --- a/crates/tui-test-ffi/src/tasks.rs +++ b/crates/tui-test-ffi/src/tasks.rs @@ -78,17 +78,19 @@ pub unsafe extern "C" fn tui_task_poll(task: *mut TuiTask) -> *mut TuiResult { let Some(receiver) = &task.receiver else { return std::ptr::null_mut(); }; - let completion = match receiver.try_recv() { - Ok(value) => value, - Err(TryRecvError::Empty) => return std::ptr::null_mut(), + match receiver.try_recv() { + Ok(completion) => { + task.receiver = None; + match completion { + Ok(value) => output::operation(value), + Err(error) => output::error(error), + } + } + Err(TryRecvError::Empty) => std::ptr::null_mut(), Err(TryRecvError::Disconnected) => { - Err(TuiTestError::internal("native worker disconnected")) + task.receiver = None; + output::error(TuiTestError::internal("native worker disconnected")) } - }; - task.receiver = None; - match completion { - Ok(value) => output::operation(value), - Err(error) => output::error(error), } } diff --git a/crates/tui-test-ffi/src/tests.rs b/crates/tui-test-ffi/src/tests.rs index 8212ba9c..e65c10ba 100644 --- a/crates/tui-test-ffi/src/tests.rs +++ b/crates/tui-test-ffi/src/tests.rs @@ -71,6 +71,7 @@ unsafe fn message(result: *mut TuiResult) -> String { 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!(!result.is_null()); assert_eq!(unsafe { (*result).error_kind }, 3); assert!(unsafe { message(result) }.contains("no active session")); unsafe { tui_result_free(result) }; @@ -106,6 +107,7 @@ fn pointer_options_reject_null_and_preserve_validation() { ] }; for result in results { + assert!(!result.is_null()); assert_eq!(unsafe { (*result).error_kind }, 2); unsafe { tui_result_free(result) }; } @@ -122,6 +124,7 @@ fn panic_is_contained_and_next_call_succeeds() { TuiOptionalBool::default(), ) }; + assert!(!result.is_null()); assert_eq!(unsafe { (*result).error_kind }, 0); unsafe { tui_result_free(result) }; } @@ -140,6 +143,7 @@ fn invalid_utf8_and_absent_required_inputs_report_usage() { }, ] { let result = unsafe { tui_state(session) }; + assert!(!result.is_null()); assert_eq!(unsafe { (*result).error_kind }, 2); unsafe { tui_result_free(result) }; } @@ -224,6 +228,7 @@ fn c_context_and_owned_context_share_validation() { .unwrap_err(); let encoded = context.to_string(); let result = unsafe { tui_state(text(&encoded)) }; + assert!(!result.is_null()); assert_eq!( unsafe { (*result).error_kind }, owned.kind.exit_code() as u32 @@ -244,6 +249,7 @@ fn c_context_and_owned_context_share_validation() { assert_eq!(context.screen_history_limit, Some(0)); let result = unsafe { tui_state(text(r#"{"name":"ffi-context","unknown":true}"#)) }; + assert!(!result.is_null()); assert_eq!(unsafe { (*result).error_kind }, 2); assert!(unsafe { message(result) }.contains("unknown field")); unsafe { tui_result_free(result) }; From 27abbff4b279bf702ac13311e0c17843ecce54eb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 11:47:39 -0400 Subject: [PATCH 10/15] test: check native result pointers before field access Signed-off-by: Ayman Bagabas --- crates/tui-test-ffi/src/tests.rs | 37 +++++++++++++++----------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/crates/tui-test-ffi/src/tests.rs b/crates/tui-test-ffi/src/tests.rs index e65c10ba..2bbce907 100644 --- a/crates/tui-test-ffi/src/tests.rs +++ b/crates/tui-test-ffi/src/tests.rs @@ -25,12 +25,12 @@ fn typed_start_copies_inputs_and_transfers_result_once() { unsafe { tui_state_start(text(&context)) } }; let result = unsafe { completed(task) }; - assert_eq!(unsafe { (*result).error_kind }, 3); + assert_eq!(unsafe { error_kind(result) }, 3); assert!(unsafe { message(result) }.contains("no active session")); unsafe { tui_result_free(result) }; let invalid = unsafe { tui_state_start(TuiString::default()) }; let result = unsafe { completed(invalid) }; - assert_eq!(unsafe { (*result).error_kind }, 2); + assert_eq!(unsafe { error_kind(result) }, 2); unsafe { tui_result_free(result) }; } @@ -38,7 +38,7 @@ fn typed_start_copies_inputs_and_transfers_result_once() { fn asynchronous_panics_are_contained() { let task = tasks::tui_panic_probe_start(); let result = unsafe { completed(task) }; - assert_eq!(unsafe { (*result).error_kind }, 5); + assert_eq!(unsafe { error_kind(result) }, 5); assert!(unsafe { message(result) }.contains("intentional native panic probe")); unsafe { tui_result_free(result) }; } @@ -64,15 +64,20 @@ fn text(value: &str) -> TuiString { } } unsafe fn message(result: *mut TuiResult) -> String { - unsafe { (*result).error_message.required().unwrap() } + let result = unsafe { result.as_ref() }.expect("native call returned a null result"); + unsafe { result.error_message.required().unwrap() } +} +unsafe fn error_kind(result: *mut TuiResult) -> u32 { + unsafe { result.as_ref() } + .expect("native call returned a null result") + .error_kind } #[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!(!result.is_null()); - assert_eq!(unsafe { (*result).error_kind }, 3); + assert_eq!(unsafe { error_kind(result) }, 3); assert!(unsafe { message(result) }.contains("no active session")); unsafe { tui_result_free(result) }; } @@ -107,15 +112,14 @@ fn pointer_options_reject_null_and_preserve_validation() { ] }; for result in results { - assert!(!result.is_null()); - assert_eq!(unsafe { (*result).error_kind }, 2); + assert_eq!(unsafe { error_kind(result) }, 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_eq!(unsafe { error_kind(result) }, 5); assert!(unsafe { message(result) }.contains("panicked")); unsafe { tui_result_free(result) }; let result = unsafe { @@ -124,8 +128,7 @@ fn panic_is_contained_and_next_call_succeeds() { TuiOptionalBool::default(), ) }; - assert!(!result.is_null()); - assert_eq!(unsafe { (*result).error_kind }, 0); + assert_eq!(unsafe { error_kind(result) }, 0); unsafe { tui_result_free(result) }; } #[test] @@ -143,8 +146,7 @@ fn invalid_utf8_and_absent_required_inputs_report_usage() { }, ] { let result = unsafe { tui_state(session) }; - assert!(!result.is_null()); - assert_eq!(unsafe { (*result).error_kind }, 2); + assert_eq!(unsafe { error_kind(result) }, 2); unsafe { tui_result_free(result) }; } } @@ -228,11 +230,7 @@ fn c_context_and_owned_context_share_validation() { .unwrap_err(); let encoded = context.to_string(); let result = unsafe { tui_state(text(&encoded)) }; - assert!(!result.is_null()); - assert_eq!( - unsafe { (*result).error_kind }, - owned.kind.exit_code() as u32 - ); + assert_eq!(unsafe { error_kind(result) }, owned.kind.exit_code() as u32); assert_eq!(unsafe { message(result) }, owned.message); unsafe { tui_result_free(result) }; } @@ -249,8 +247,7 @@ fn c_context_and_owned_context_share_validation() { assert_eq!(context.screen_history_limit, Some(0)); let result = unsafe { tui_state(text(r#"{"name":"ffi-context","unknown":true}"#)) }; - assert!(!result.is_null()); - assert_eq!(unsafe { (*result).error_kind }, 2); + assert_eq!(unsafe { error_kind(result) }, 2); assert!(unsafe { message(result) }.contains("unknown field")); unsafe { tui_result_free(result) }; } From 2e222b2715204b71686306dab1900d26b81c503b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 11:47:39 -0400 Subject: [PATCH 11/15] fix: retain the native DLL through Windows thread cleanup Signed-off-by: Ayman Bagabas --- bindings/js/README.md | 1 + bindings/js/native/index.js | 17 ++++++++++++++++- bindings/js/test/native.test.mjs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/bindings/js/README.md b/bindings/js/README.md index 5d46cc92..680cb4aa 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -13,6 +13,7 @@ Node 20+ is supported. The package is ESM only. Bun and Deno support is best eff Each platform package contains the `tui-test-ffi` shared library. The JavaScript adapter uses Koffi to call the same typed C ABI as Go and Python. It copies native results before it frees their Rust allocations. +On Windows, the DLL stays loaded until process exit so native thread cleanup remains valid after JavaScript worker exit. Koffi supplies its own native addon; this package no longer builds a separate N-API adapter. Keep optional dependencies enabled during installation; they contain the platform binaries for both packages. The package needs no CLI. diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index 07a881e6..2f6f96db 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -26,9 +26,24 @@ function libraryPath() { } const library = koffi.load(libraryPath()); +let platformLibrary; +if (process.platform === "win32") { + // Rust thread-local destructors can outlive the JavaScript environment. + platformLibrary = koffi.load("kernel32.dll"); + const pin = platformLibrary.func("int32_t __stdcall GetModuleHandleExW(uint32_t flags, void *address, _Out_ void **module)"); + const GET_MODULE_HANDLE_EX_FLAG_PIN = 1; + const GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS = 4; + if (!pin( + GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + library.symbol("tui_abi_version"), + [null], + )) { + throw new Error("Failed to retain the tui-test native library for process lifetime"); + } +} // Keep the library handle alive with its functions. Bun cannot safely finalize // Koffi's handle during garbage collection, even while native functions remain. -const functions = { ...bind(library), library }; +const functions = { ...bind(library), library, platformLibrary }; const version = functions.tui_abi_version(); if (version !== abiVersion) { throw new Error(`tui-test native ABI mismatch: expected ${abiVersion}, got ${version}`); diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index fd48b24c..9f4d1cbf 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -40,6 +40,37 @@ test("the native function table retains its library through garbage collection", `); }); +test("a worker-only native library survives worker and process teardown", { + skip: !!process.versions.bun || typeof globalThis.Deno !== "undefined", +}, () => { + const worker = ` + (async () => { + const { workerData } = await import("node:worker_threads"); + const { TuiTest } = await import(workerData); + const terminal = TuiTest.ephemeral(); + try { + await terminal.run(process.execPath, ["-e", "console.log('ready')"]); + await terminal.getSize(); + } finally { + await terminal.close(); + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + `; + runWithGc(` + import assert from "node:assert/strict"; + import { once } from "node:events"; + import { Worker } from "node:worker_threads"; + for (let i = 0; i < 3; i++) { + const worker = new Worker(${JSON.stringify(worker)}, { + eval: true, + workerData: ${JSON.stringify(new URL("../dist/index.js", import.meta.url).href)}, + }); + const [code] = await once(worker, "exit"); + assert.equal(code, 0); + } + `); +}); + test("nested input buffers remain live during native start and marshaling", () => { runWithGc(` import assert from "node:assert/strict"; From 25254f1bf189729404e134152cfd48d36020961d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Oct 2026 12:14:29 -0400 Subject: [PATCH 12/15] build: advance the shared native ABI to version 4 Signed-off-by: Ayman Bagabas --- bindings/go/internal/native/abi.go | 2 +- bindings/js/native/abi.js | 2 +- bindings/python/src/tui_test/_abi.py | 2 +- crates/tui-test-ffi/README.md | 2 ++ crates/tui-test-ffi/include/tui_test.h | 2 +- crates/tui-test-ffi/src/lib.rs | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bindings/go/internal/native/abi.go b/bindings/go/internal/native/abi.go index 1b9f1718..cc84ece5 100644 --- a/bindings/go/internal/native/abi.go +++ b/bindings/go/internal/native/abi.go @@ -6,7 +6,7 @@ import ( "unsafe" ) -const abiVersion = 3 +const abiVersion = 4 type abiString struct { _ structs.HostLayout diff --git a/bindings/js/native/abi.js b/bindings/js/native/abi.js index 5c5d469e..3f6d7b50 100644 --- a/bindings/js/native/abi.js +++ b/bindings/js/native/abi.js @@ -1,6 +1,6 @@ // Generated by cargo run -p tui-test-bindgen. Do not edit. import koffi from 'koffi'; -export const abiVersion = 3; +export const abiVersion = 4; export const types = {}; types.TuiString = koffi.struct("TuiString", { data: "void *", diff --git a/bindings/python/src/tui_test/_abi.py b/bindings/python/src/tui_test/_abi.py index ccd4feb5..ce35d2d2 100644 --- a/bindings/python/src/tui_test/_abi.py +++ b/bindings/python/src/tui_test/_abi.py @@ -1,7 +1,7 @@ # Generated by cargo run -p tui-test-bindgen. Do not edit. import ctypes -ABI_VERSION = 3 +ABI_VERSION = 4 class TuiString(ctypes.Structure): diff --git a/crates/tui-test-ffi/README.md b/crates/tui-test-ffi/README.md index c467aa1c..15318b11 100644 --- a/crates/tui-test-ffi/README.md +++ b/crates/tui-test-ffi/README.md @@ -8,6 +8,8 @@ Koffi supplies its own native addon. The library and language package must come from the same source revision. This ABI is an internal contract, not a stable public C SDK. The language adapters preserve their public convenience APIs. +ABI version 4 includes the shared pointer and asynchronous entrypoints. +The existing `tui_screenshot` signature remains unchanged; `tui_screenshot_ptr` adds the pointer form. ## Build and use diff --git a/crates/tui-test-ffi/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h index 1c49443d..e801a5a1 100644 --- a/crates/tui-test-ffi/include/tui_test.h +++ b/crates/tui-test-ffi/include/tui_test.h @@ -8,7 +8,7 @@ #include #include -#define ABI_VERSION 3 +#define ABI_VERSION 4 /** * An owned asynchronous call. Poll until a result arrives, then free the task. diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index c8781b4d..13360cc7 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -15,7 +15,7 @@ pub use types::*; mod tasks_generated; pub use tasks_generated::*; -pub const ABI_VERSION: u32 = 3; +pub const ABI_VERSION: u32 = 4; type Result = std::result::Result; fn boundary(f: impl FnOnce() -> Result) -> *mut TuiResult { From 81749415d8f814e8e4d61a512df773682effa9ff Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 14 Oct 2026 18:22:29 -0400 Subject: [PATCH 13/15] fix(ffi): generate C++-safe wait option fields Signed-off-by: Ayman Bagabas --- .github/scripts/check-native-header.c | 7 +++++++ .github/workflows/ci.yml | 5 +++++ bindings/js/native/abi.js | 4 ++-- bindings/js/native/adapter.js | 2 +- bindings/js/test/native.test.mjs | 14 ++++++++++++++ bindings/python/src/tui_test/_abi.py | 2 +- crates/tui-test-ffi/include/tui_test.h | 2 +- crates/tui-test-ffi/src/lib.rs | 8 ++++---- crates/tui-test-ffi/src/types.rs | 2 +- tools/bindgen/src/boundaries.rs | 9 ++++++++- 10 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/check-native-header.c diff --git a/.github/scripts/check-native-header.c b/.github/scripts/check-native-header.c new file mode 100644 index 00000000..9dc5e28b --- /dev/null +++ b/.github/scripts/check-native-header.c @@ -0,0 +1,7 @@ +#include "tui_test.h" + +int main(void) { + TuiWaitOptions options = {0}; + options.not_ = true; + return !options.not_; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc40931..b47b41fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,11 @@ jobs: - run: cargo fmt --all -- --check - name: Verify generated native declarations run: cargo run --locked -p tui-test-bindgen -- --check + - name: Compile the native header as C and C++ + if: runner.os != 'Windows' + run: | + cc -std=c11 -fsyntax-only -Icrates/tui-test-ffi/include .github/scripts/check-native-header.c + c++ -std=c++17 -x c++ -fsyntax-only -Icrates/tui-test-ffi/include .github/scripts/check-native-header.c - name: Test native package assembly if: runner.os == 'Linux' run: node --test .github/scripts/release/package-node.test.mjs diff --git a/bindings/js/native/abi.js b/bindings/js/native/abi.js index 3f6d7b50..da0e5dc9 100644 --- a/bindings/js/native/abi.js +++ b/bindings/js/native/abi.js @@ -136,7 +136,7 @@ shift: "bool", types.TuiWaitOptions = koffi.struct("TuiWaitOptions", { timeout_ms: "TuiOptionalU64", regex: "bool", -not: "bool", +not_: "bool", }); types.TuiRecordingOptions = koffi.struct("TuiRecordingOptions", { path: "TuiString", @@ -333,7 +333,7 @@ shift: "bool", TuiWaitOptions: { timeout_ms: "TuiOptionalU64", regex: "bool", -not: "bool", +not_: "bool", }, TuiRecordingOptions: { path: "TuiString", diff --git a/bindings/js/native/adapter.js b/bindings/js/native/adapter.js index 44c3db0f..4ce9acf1 100644 --- a/bindings/js/native/adapter.js +++ b/bindings/js/native/adapter.js @@ -309,7 +309,7 @@ export function createAdapter(functions) { const input = new Inputs(); const s = (v) => input.string(v); const u = (v) => input.struct("TuiOptionalU64", v); - const wait = (v) => input.struct("TuiWaitOptions", v); + const wait = (v) => input.struct("TuiWaitOptions", { ...v, not_: v.not }); const options = values.options ?? {}; const v = values; let name = `tui_${snake(method)}`; diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 9f4d1cbf..85d1df23 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -158,6 +158,20 @@ test("Koffi adapter loads the explicit shared library and honors invalid overrid assert.match(invalid.stderr, /Failed to load shared library/); }); +test("the C-compatible wait field preserves public negation options", async () => { + const session = new NativeSession(`negated-title-${process.pid}`); + try { + await session.run({ + program: process.execPath, + args: [typeof globalThis.Deno === "undefined" ? "-e" : "eval", "setInterval(() => {}, 1000)"], + }); + await session.waitTitle("never-matches", { not: true, timeoutMs: 0 }); + await assert.rejects(session.waitTitle("never-matches", { timeoutMs: 0 })); + } finally { + await session.close(); + } +}); + test("concurrent waits cannot starve input or close", async () => { const session = new NativeSession(`koffi-concurrency-${process.pid}`); try { diff --git a/bindings/python/src/tui_test/_abi.py b/bindings/python/src/tui_test/_abi.py index ce35d2d2..e640ff29 100644 --- a/bindings/python/src/tui_test/_abi.py +++ b/bindings/python/src/tui_test/_abi.py @@ -284,7 +284,7 @@ class TuiScreenshotOptions(ctypes.Structure): TuiWaitOptions._fields_ = [ ("timeout_ms", TuiOptionalU64), ("regex", ctypes.c_bool), - ("not", ctypes.c_bool), + ("not_", ctypes.c_bool), ] diff --git a/crates/tui-test-ffi/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h index e801a5a1..29a1af85 100644 --- a/crates/tui-test-ffi/include/tui_test.h +++ b/crates/tui-test-ffi/include/tui_test.h @@ -223,7 +223,7 @@ typedef struct TuiMouseOptions { typedef struct TuiWaitOptions { struct TuiOptionalU64 timeout_ms; bool regex; - bool not; + bool not_; } TuiWaitOptions; typedef struct TuiQuery { diff --git a/crates/tui-test-ffi/src/lib.rs b/crates/tui-test-ffi/src/lib.rs index 13360cc7..a1340279 100644 --- a/crates/tui-test-ffi/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -540,7 +540,7 @@ pub unsafe extern "C" fn tui_wait_title( Ok(Operation::WaitTitle { text: text.required()?, regex: options.regex, - not: options.not, + not: options.not_, timeout_ms: options.timeout_ms.option(), }) }) @@ -560,7 +560,7 @@ pub unsafe extern "C" fn tui_expect_title( Ok(Operation::ExpectTitle { text: text.required()?, regex: options.regex, - not: options.not, + not: options.not_, timeout_ms: options.timeout_ms.option(), }) }) @@ -712,7 +712,7 @@ pub unsafe extern "C" fn tui_wait_locator( execute(session, "locator.wait", || { Ok(Operation::WaitLocator { query: input::query(query)?, - not: options.not, + not: options.not_, timeout_ms: options.timeout_ms.option(), }) }) @@ -731,7 +731,7 @@ pub unsafe extern "C" fn tui_expect_locator( execute(session, "locator.expect", || { Ok(Operation::WaitLocator { query: input::query(query)?, - not: options.not, + not: options.not_, timeout_ms: options.timeout_ms.option(), }) }) diff --git a/crates/tui-test-ffi/src/types.rs b/crates/tui-test-ffi/src/types.rs index fae2b7b8..1cbc3dfc 100644 --- a/crates/tui-test-ffi/src/types.rs +++ b/crates/tui-test-ffi/src/types.rs @@ -234,7 +234,7 @@ pub struct TuiMouseOptions { pub struct TuiWaitOptions { pub timeout_ms: TuiOptionalU64, pub regex: bool, - pub not: bool, + pub not_: bool, } #[repr(C)] #[derive(Clone, Copy, Default)] diff --git a/tools/bindgen/src/boundaries.rs b/tools/bindgen/src/boundaries.rs index d4c41934..0322eaea 100644 --- a/tools/bindgen/src/boundaries.rs +++ b/tools/bindgen/src/boundaries.rs @@ -384,7 +384,12 @@ fn input_models(models: &Models, c: &mut String, ci: &mut String) -> Result<()> "#[repr(C)] #[derive(Clone,Copy,Default)] pub struct Tui{target} {{" )?; for f in input_fields(models, model, false)? { - writeln!(c, "pub {}: {},", ident(&f.name), f.ty)?; + let field = if f.name == "not" { + "not_" + } else { + ident(&f.name) + }; + writeln!(c, "pub {field}: {},", f.ty)?; } c.push_str("}\n"); } @@ -580,6 +585,8 @@ pub(crate) mod tests { models.parse("struct Timeouts { new_wait: Option } struct DiagnosticRetentionOptions { new_limit: u16 }").unwrap(); let (mut c, mut ci) = Default::default(); input_models(&models, &mut c, &mut ci).unwrap(); + assert!(c.contains("pub not_: bool")); + assert!(!c.contains("pub not: bool")); assert!(c.contains("pub new_wait: TuiOptionalU64")); assert!(ci.contains("new_wait: value.new_wait.option()")); let context = session_input(&models).unwrap(); From 9f32eb66d63082747f3a9ad0d6c80be3eaf4d49e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 14 Oct 2026 18:22:29 -0400 Subject: [PATCH 14/15] fix(js): retain native code through Unix worker teardown Signed-off-by: Ayman Bagabas --- bindings/js/README.md | 2 +- bindings/js/native/index.js | 18 +++++++++++++++- bindings/js/test/native.test.mjs | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/bindings/js/README.md b/bindings/js/README.md index 680cb4aa..aea38b17 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -13,7 +13,7 @@ Node 20+ is supported. The package is ESM only. Bun and Deno support is best eff Each platform package contains the `tui-test-ffi` shared library. The JavaScript adapter uses Koffi to call the same typed C ABI as Go and Python. It copies native results before it frees their Rust allocations. -On Windows, the DLL stays loaded until process exit so native thread cleanup remains valid after JavaScript worker exit. +On all supported platforms, the library stays loaded until process exit so pending native tasks remain valid after JavaScript worker exit. Koffi supplies its own native addon; this package no longer builds a separate N-API adapter. Keep optional dependencies enabled during installation; they contain the platform binaries for both packages. The package needs no CLI. diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index 2f6f96db..276942f9 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -25,7 +25,8 @@ function libraryPath() { return createRequire(import.meta.url).resolve(`@microsoft/tui-test-${tag}`); } -const library = koffi.load(libraryPath()); +const path = libraryPath(); +const library = koffi.load(path); let platformLibrary; if (process.platform === "win32") { // Rust thread-local destructors can outlive the JavaScript environment. @@ -40,6 +41,21 @@ if (process.platform === "win32") { )) { throw new Error("Failed to retain the tui-test native library for process lifetime"); } +} else { + platformLibrary = koffi.load(null); + const open = platformLibrary.func("void *dlopen(const char *path, int flags)"); + const close = platformLibrary.func("int dlclose(void *handle)"); + const lastError = platformLibrary.func("const char *dlerror()"); + const RTLD_NOW = 2; + const RTLD_NODELETE = process.platform === "darwin" ? 0x80 : 0x1000; + // Keep code mapped after the last environment exits, including detached tasks. + const retained = open(path, RTLD_NOW | RTLD_NODELETE); + if (retained === null) { + throw new Error(`Failed to retain the tui-test native library: ${lastError()}`); + } + if (close(retained) !== 0) { + throw new Error(`Failed to release the temporary native library handle: ${lastError()}`); + } } // Keep the library handle alive with its functions. Bun cannot safely finalize // Koffi's handle during garbage collection, even while native functions remain. diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 85d1df23..d80cc8ec 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -71,6 +71,43 @@ test("a worker-only native library survives worker and process teardown", { `); }); +test("terminating a worker during a native wait preserves the loaded engine", { + skip: !!process.versions.bun || typeof globalThis.Deno !== "undefined", +}, () => { + const worker = ` + (async () => { + const { parentPort, workerData } = await import("node:worker_threads"); + const binding = await import(workerData.url); + if (workerData.cleanup) { + const assert = (await import("node:assert/strict")).default; + assert.ok((await binding.sessions()).includes("pending-worker")); + await binding.closeAll(); + return; + } + const session = new binding.NativeSession("pending-worker"); + await session.run({ program: process.execPath, args: ["-e", "setInterval(() => {}, 1000)"] }); + session.waitTitle("never-matches", { timeoutMs: 30000 }).catch(() => {}); + parentPort.postMessage("waiting"); + })().catch(error => { console.error(error); process.exitCode = 1; }); + `; + runWithGc(` + import assert from "node:assert/strict"; + import { once } from "node:events"; + import { Worker } from "node:worker_threads"; + const options = { + eval: true, + workerData: { url: ${JSON.stringify(new URL("../native/index.js", import.meta.url).href)} }, + }; + const worker = new Worker(${JSON.stringify(worker)}, options); + assert.deepEqual(await once(worker, "message"), ["waiting"]); + assert.equal(await worker.terminate(), 1); + const cleanup = new Worker(${JSON.stringify(worker)}, { + ...options, workerData: { ...options.workerData, cleanup: true }, + }); + assert.deepEqual(await once(cleanup, "exit"), [0]); + `); +}); + test("nested input buffers remain live during native start and marshaling", () => { runWithGc(` import assert from "node:assert/strict"; From d33557757b47a7e3204fa9c1bf0729f32441fc71 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 14 Oct 2026 18:22:29 -0400 Subject: [PATCH 15/15] fix(go): document and validate musl shared library builds Signed-off-by: Ayman Bagabas --- .github/workflows/ci.yml | 19 +++++++++++++++++++ bindings/go/CONTRIBUTING.md | 16 ++++++++++++++++ bindings/go/README.md | 6 ++++++ 3 files changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b47b41fd..c3816186 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,25 @@ jobs: go test ./... go run ./examples/smoke + - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + if: runner.os == 'Linux' + with: + tool: cargo-zigbuild + + - name: Test Go binding on musl + if: runner.os == 'Linux' + run: | + rustup target add x86_64-unknown-linux-musl + RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=-crt-static" cargo zigbuild --locked -p tui-test-ffi --target x86_64-unknown-linux-musl + test -f target/x86_64-unknown-linux-musl/debug/libtui_test_ffi.so + cd bindings/go + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '-I /lib/ld-musl-x86_64.so.1' -o ../../target/go-musl-smoke ./examples/smoke + cd ../.. + docker run --rm --platform linux/amd64 \ + -v "$PWD/target:/work:ro" \ + -e TUI_TEST_NATIVE_LIBRARY=/work/x86_64-unknown-linux-musl/debug/libtui_test_ffi.so \ + alpine:3.23 sh -c 'apk add --no-cache libstdc++ && /work/go-musl-smoke' + - name: Install Python binding run: python -m pip install --disable-pip-version-check -e ./bindings/python diff --git a/bindings/go/CONTRIBUTING.md b/bindings/go/CONTRIBUTING.md index 370f6eb3..15ef9b5c 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -44,6 +44,22 @@ From the repository root: cargo build --locked -p tui-test-ffi ``` +On musl Linux, disable static CRT linkage so Rust emits a `cdylib`: + +```sh +RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=-crt-static" cargo build --locked -p tui-test-ffi +``` + +Keep this flag for release builds and explicit musl targets. +For a cross-build, install `cargo-zigbuild` and the Rust target, then run: + +```sh +RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=-crt-static" cargo zigbuild --locked -p tui-test-ffi --target x86_64-unknown-linux-musl +``` + +The cross-built library is `target/x86_64-unknown-linux-musl/debug/libtui_test_ffi.so`. +It must run on a musl host with the C++ runtime installed, such as Alpine with `libstdc++`. + The shared library enables all terminal backends and bundled recording font styles. Zig is required by the Ghostty dependency. Set `TUI_TEST_NATIVE_LIBRARY` to the built library before running a Go application or test. For Linux: diff --git a/bindings/go/README.md b/bindings/go/README.md index f6980d9e..952a02ba 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -33,6 +33,12 @@ Use a host-native build. Cross-compilation also requires the destination Rust ta cargo build --locked -p tui-test-ffi ``` + On musl Linux, use this command instead so Rust emits the shared library: + + ```sh + RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=-crt-static" cargo build --locked -p tui-test-ffi + ``` + The debug library is `target/debug/libtui_test_ffi.so` on Linux, `target/debug/libtui_test_ffi.dylib` on macOS, or `target/debug/tui_test_ffi.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: