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/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 new file mode 100644 index 00000000..38d5c093 --- /dev/null +++ b/.github/scripts/release/package-node.test.mjs @@ -0,0 +1,64 @@ +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 { 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"); + 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"], + dependencies: { koffi: "^3.3.3" }, + nativeTargets, + })); + fs.writeFileSync(path.join(bindings, "index.js"), "export {};\n"); + for (const platform of targets) { + fs.writeFileSync(path.join(bindings, "artifacts", library(platform)), "shared library fixture"); + } + return directory; +} + +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"); + execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }); + assert.equal(fs.readFileSync(packagePath, "utf8"), original); + 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(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 missing shared library artifact", (t) => { + const directory = fixture(t); + fs.unlinkSync(path.join(directory, "bindings/js/artifacts", library("darwin-arm64"))); + assert.throws( + () => execFileSync(process.execPath, [script], { cwd: directory, stdio: "pipe" }), + /Missing shared library/, + ); +}); 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..c3816186 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,21 +53,67 @@ 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: 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 - 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 + + - 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 - 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 @@ -89,10 +135,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 ac7ec648..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" \ @@ -278,22 +275,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 +304,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 +324,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 +358,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..010170a4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,16 @@ *.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 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..63677ad2 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" @@ -302,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" @@ -398,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" @@ -582,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" @@ -659,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", ] @@ -691,6 +637,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" @@ -931,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" @@ -1035,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" @@ -1105,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" @@ -1221,12 +1104,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 +1155,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 +1164,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 +1191,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", ] @@ -1569,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" @@ -1822,12 +1617,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 +1634,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" @@ -1935,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" @@ -2023,6 +1816,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 +1847,7 @@ dependencies = [ ] [[package]] -name = "tui-test-go" +name = "tui-test-ffi" version = "0.1.0" dependencies = [ "serde", @@ -2051,30 +1855,6 @@ dependencies = [ "tui-test-rs", ] -[[package]] -name = "tui-test-node" -version = "0.1.0" -dependencies = [ - "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]] name = "tui-test-rs" version = "0.1.0" @@ -2123,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/Cargo.toml b/Cargo.toml index a5017251..fdbd3086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,9 @@ [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..8f7f2a33 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ 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). +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 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..15ef9b5c 100644 --- a/bindings/go/CONTRIBUTING.md +++ b/bindings/go/CONTRIBUTING.md @@ -8,11 +8,31 @@ 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 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. +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 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 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. +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 +cargo run --locked -p tui-test-bindgen +``` + +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 @@ -21,34 +41,50 @@ 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. +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_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 +95,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..952a02ba 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -30,10 +30,16 @@ 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. + 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: @@ -44,21 +50,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/integration_test.go b/bindings/go/integration_test.go index 4f8671c0..72de1fc0 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" @@ -27,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()) } @@ -247,11 +251,32 @@ 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 { 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 733da79b..cc84ece5 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,21 +6,78 @@ import ( "unsafe" ) -// These host-layout structures mirror the private C ABI in native.h. +const abiVersion = 4 + 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 abiOptionalF64 struct { + _ structs.HostLayout + present bool + value float64 +} +type abiPair struct { + _ structs.HostLayout + key abiString + value abiString +} +type abiQuery struct { + _ structs.HostLayout + json abiString +} +type abiMode struct { + _ structs.HostLayout + name abiString + enabled bool +} +type abiColor struct { + _ structs.HostLayout + kind uint32 + index uint8 + red uint8 + green uint8 + blue uint8 +} +type abiResult struct { + _ structs.HostLayout + errorKind uint32 + errorMessage abiString + errorDetails abiString + errorArtifact abiString + text abiString + number uint64 + exitCode abiOptionalI32 + open abiOpenResult + state abiState + cursor abiCursor + size abiSize + cells *abiCell + cellsLen uintptr + matches *abiMatch + matchesLen uintptr + bells *abiBellEvent + bellsLen uintptr + strings *abiString + stringsLen uintptr + snapshot uint32 + privateData unsafe.Pointer } type abiOpenResult struct { _ structs.HostLayout @@ -36,13 +94,15 @@ 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 abiBellEvent struct { + _ structs.HostLayout + sequence uint64 + elapsedMS uint64 } type abiState struct { _ structs.HostLayout @@ -65,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 @@ -78,19 +133,6 @@ type abiTerminalColors struct { palette *abiPair paletteLen uintptr } -type abiSize struct { - _ structs.HostLayout - cols uint16 - rows uint16 -} -type abiColor struct { - _ structs.HostLayout - kind uint32 - index uint8 - red uint8 - green uint8 - blue uint8 -} type abiCell struct { _ structs.HostLayout x uint16 @@ -130,44 +172,13 @@ type abiMatch struct { spans *abiSpan spansLen uintptr } -type abiBellEvent struct { - _ structs.HostLayout - sequence uint64 - elapsedMS uint64 -} -type abiResult struct { - _ structs.HostLayout - errorKind uint32 - errorMessage abiString - errorDetails abiString - errorArtifact abiString - text abiString - number uint64 - exitCode abiOptionalI32 - open abiOpenResult - state abiState - cursor abiCursor - size abiSize - cells *abiCell - cellsLen uintptr - matches *abiMatch - matchesLen uintptr - bells *abiBellEvent - bellsLen uintptr - strings *abiString - stringsLen uintptr - snapshot uint32 - privateData unsafe.Pointer -} -type abiPair struct { - _ structs.HostLayout - key abiString - value abiString -} -type abiOptionalBool struct { +type abiTimeouts struct { _ structs.HostLayout - present bool - value bool + text abiOptionalU64 + idle abiOptionalU64 + command abiOptionalU64 + exit abiOptionalU64 + ready abiOptionalU64 } type abiOpenOptions struct { _ structs.HostLayout @@ -200,15 +211,6 @@ type abiWaitOptions struct { 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 @@ -220,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 09c2fd96..326aa2a9 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) @@ -56,14 +55,11 @@ 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 } 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,13 +113,8 @@ 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_screenshot_ptr", 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..896e0a23 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 } @@ -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/go/native/src/input.rs b/bindings/go/native/src/input.rs deleted file mode 100644 index 56fc26ff..00000000 --- a/bindings/go/native/src/input.rs +++ /dev/null @@ -1,299 +0,0 @@ -use crate::types::*; -use tui_test::api::*; -use tui_test::locator_query::LocatorExpression; -use tui_test::profile::{Profile, Rgb}; -use tui_test::shell::Shell; -use tui_test::{ - DiagnosticRetentionOptions, ExecutionContext, FailureArtifactMode, FailureArtifactOptions, - TraceMode, TraceOptions, -}; - -type Result = std::result::Result; - -impl TuiOptionalU64 { - pub(crate) fn option(self) -> Option { - self.present.then_some(self.value) - } -} -impl TuiOptionalF64 { - pub(crate) fn option(self) -> Option { - self.present.then_some(self.value) - } -} -impl TuiOptionalBool { - pub(crate) fn option(self) -> Option { - self.present.then_some(self.value) - } -} - -// Every exported entrypoint is unsafe: the caller must provide valid readable -// buffers for the duration of the call. Null/length and UTF-8 checks catch -// representable usage mistakes, but cannot prove foreign pointer validity. -pub(crate) unsafe fn slice<'a, T>(data: *const T, len: usize) -> Result<&'a [T]> { - if len == 0 { - return Ok(&[]); - } - if data.is_null() || len > isize::MAX as usize / std::mem::size_of::() { - return Err(TuiTestError::usage( - "invalid native array pointer or length", - )); - } - Ok(unsafe { std::slice::from_raw_parts(data, len) }) -} -impl TuiString { - pub(crate) unsafe fn optional(self) -> Result> { - if self.data.is_null() { - if self.len != 0 { - return Err(TuiTestError::usage( - "null native string with nonzero length", - )); - } - return Ok(None); - } - let bytes = unsafe { slice(self.data, self.len)? }; - std::str::from_utf8(bytes) - .map(|s| Some(s.to_owned())) - .map_err(|_| TuiTestError::usage("native string must be UTF-8")) - } - pub(crate) unsafe fn required(self) -> Result { - unsafe { self.optional()? } - .ok_or_else(|| TuiTestError::usage("required native string is absent")) - } -} -pub(crate) unsafe fn strings(data: *const TuiString, len: usize) -> Result> { - unsafe { slice(data, len)? } - .iter() - .map(|s| unsafe { s.required() }) - .collect() -} -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct SessionInput { - name: String, - #[serde(default)] - artifact_directory: Option, - #[serde(default)] - artifact_mode: Option, - #[serde(default)] - artifact_include_recording: bool, - #[serde(default)] - trace_mode: Option, - #[serde(default)] - trace_directory: Option, - #[serde(default)] - screen_history_limit: Option, -} - -pub(crate) unsafe fn session(value: TuiString) -> Result<(String, ExecutionContext)> { - let json = unsafe { value.required()? }; - let value: SessionInput = serde_json::from_str(&json) - .map_err(|error| TuiTestError::usage(format!("invalid native session context: {error}")))?; - let artifact = match value.artifact_mode { - None => None, - Some(mode) => { - let mode = match mode.as_str() { - "all" => FailureArtifactMode::All, - "html" => FailureArtifactMode::Html, - "text" => FailureArtifactMode::Text, - "none" => FailureArtifactMode::None, - other => { - return Err(TuiTestError::usage(format!( - "unknown failure artifact mode {other:?}; expected all, html, text, or none" - ))) - } - }; - let options = FailureArtifactOptions { - directory: value.artifact_directory.unwrap_or_default().into(), - mode, - include_recording: value.artifact_include_recording, - }; - options.validate().map_err(TuiTestError::usage)?; - Some(options) - } - }; - let trace = match value.trace_mode { - None => None, - Some(mode) => { - let mode = match mode.as_str() { - "off" => TraceMode::Off, - "on-failure" => TraceMode::OnFailure, - "on" => TraceMode::On, - other => { - return Err(TuiTestError::usage(format!( - "unknown trace mode {other:?}; expected off, on-failure, or on" - ))) - } - }; - let options = TraceOptions { - mode, - directory: value - .trace_directory - .map(Into::into) - .unwrap_or_else(|| TraceOptions::default().directory), - }; - options.validate().map_err(TuiTestError::usage)?; - Some(options) - } - }; - let mut retention = DiagnosticRetentionOptions::default(); - if let Some(limit) = value.screen_history_limit { - retention.screen_history_limit = limit; - } - retention.validate().map_err(TuiTestError::usage)?; - Ok(( - value.name, - ExecutionContext { - artifact, - trace, - retention, - ..ExecutionContext::default() - }, - )) -} -unsafe fn pairs(data: *const TuiPair, len: usize) -> Result> { - unsafe { slice(data, len)? } - .iter() - .map(|p| Ok((unsafe { p.key.required()? }, unsafe { p.value.required()? }))) - .collect() -} -pub(crate) fn u16_option(value: TuiOptionalU64, name: &str) -> Result> { - value - .option() - .map(|v| { - u16::try_from(v) - .map_err(|_| TuiTestError::usage(format!("{name} must be between 0 and 65535"))) - }) - .transpose() -} -pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result { - let backend = unsafe { value.backend.optional()? } - .map(|s| s.parse()) - .transpose() - .map_err(TuiTestError::usage)? - .unwrap_or_default(); - let shell = unsafe { value.shell.optional()? } - .map(|s| match s.as_str() { - "bash" => Ok(Shell::Bash), - "powershell" => Ok(Shell::Powershell), - "pwsh" => Ok(Shell::Pwsh), - "cmd" => Ok(Shell::Cmd), - "fish" => Ok(Shell::Fish), - "zsh" => Ok(Shell::Zsh), - "xonsh" => Ok(Shell::Xonsh), - "elvish" => Ok(Shell::Elvish), - "nushell" => Ok(Shell::Nushell), - _ => Err(TuiTestError::usage(format!("unknown shell {s:?}"))), - }) - .transpose()?; - let mut profile = Profile::default(); - if let Some(scrollback) = value.scrollback.option() { - profile.scrollback = usize::try_from(scrollback) - .map_err(|_| TuiTestError::usage("scrollback exceeds addressable size"))?; - } - for (name, color) in unsafe { pairs(value.colors, value.colors_len)? } { - let color = Rgb::parse(&color).map_err(TuiTestError::usage)?; - if !profile.colors.set_named(&name, color) { - return Err(TuiTestError::usage(format!( - "unknown profile color {name:?}" - ))); - } - } - let mode = match unsafe { value.recording_mode.optional()? } - .as_deref() - .unwrap_or("disabled") - { - "always" => AutomaticRecordingMode::Always, - "disabled" => AutomaticRecordingMode::Disabled, - "on-failure" => AutomaticRecordingMode::OnFailure, - other => { - return Err(TuiTestError::usage(format!( - "unknown automatic recording mode {other:?}" - ))) - } - }; - Ok(OpenOptions { - backend, - shell, - profile, - cols: u16_option(value.cols, "cols")?.unwrap_or(80), - rows: u16_option(value.rows, "rows")?.unwrap_or(30), - cwd: unsafe { value.cwd.optional()? }, - env: unsafe { pairs(value.env, value.env_len)? }, - wait_ready: value.wait_ready.option(), - restart: value.restart, - timeouts: Timeouts { - text: value.timeouts.text.option(), - idle: value.timeouts.idle.option(), - command: value.timeouts.command.option(), - exit: value.timeouts.exit.option(), - ready: value.timeouts.ready.option(), - }, - recording: AutomaticRecording { - mode, - directory: unsafe { value.recording_directory.optional()? }.map(Into::into), - }, - }) -} -pub(crate) fn mouse(value: TuiMouseOptions) -> Result { - let button = match value.button { - 0 => MouseButton::Left, - 1 => MouseButton::Middle, - 2 => MouseButton::Right, - _ => return Err(TuiTestError::usage("unknown mouse button")), - }; - Ok(MouseOptions { - button, - alt: value.alt, - ctrl: value.ctrl, - shift: value.shift, - }) -} -pub(crate) unsafe fn query(value: TuiQuery) -> Result { - let json = unsafe { value.json.required()? }; - serde_json::from_str::(&json) - .map_err(|error| TuiTestError::usage(format!("invalid locator expression: {error}")))? - .into_query() -} - -pub(crate) unsafe fn capture_background( - background: TuiString, - transparent: bool, -) -> Result> { - let background = unsafe { background.optional()? }; - if background.is_some() && transparent { - return Err(TuiTestError::usage( - "background and transparent options conflict", - )); - } - if transparent { - return Ok(Some(CaptureBackground::Transparent)); - } - background - .map(|value| CaptureBackground::parse(&value)) - .transpose() -} -pub(crate) unsafe fn recording(value: TuiRecordingOptions) -> Result { - let format = unsafe { value.format.optional()? } - .map(|s| match s.as_str() { - "apng" => Ok(RecordingFormat::Apng), - "gif" => Ok(RecordingFormat::Gif), - "mp4" => Ok(RecordingFormat::Mp4), - "cast" => Ok(RecordingFormat::Cast), - _ => Err(TuiTestError::usage("unknown recording format")), - }) - .transpose()?; - let fps = value - .fps - .option() - .map(|v| u8::try_from(v).map_err(|_| TuiTestError::usage("fps must be between 0 and 255"))) - .transpose()?; - Ok(Operation::StartRecording { - path: unsafe { value.path.required()? }, - format, - fps, - speed: value.speed.option(), - idle_time_limit: value.idle_time_limit.option(), - zoom: value.zoom.option(), - background: unsafe { capture_background(value.background, value.transparent)? }, - }) -} diff --git a/bindings/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 deleted file mode 100644 index 39dad007..00000000 --- a/bindings/js/Cargo.toml +++ /dev/null @@ -1,30 +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", "rlib"] -test = false - -[dependencies] -napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"] } -napi-derive = "3" -serde = { workspace = true } -serde_json = { workspace = true } -tui-test = { workspace = true, features = [ - "ghostty", - "rio", - "xtermjs", - "recording-font-jetbrains-mono-styles", -] } - -[build-dependencies] -napi-build = "2" diff --git a/bindings/js/README.md b/bindings/js/README.md index fc1441ce..aea38b17 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -10,6 +10,20 @@ 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 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 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. +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` 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 ```js @@ -18,10 +32,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/build.rs b/bindings/js/build.rs deleted file mode 100644 index 0f1b0100..00000000 --- a/bindings/js/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - napi_build::setup(); -} diff --git a/bindings/js/native/abi.js b/bindings/js/native/abi.js new file mode 100644 index 00000000..da0e5dc9 --- /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 = 4; +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..4ce9acf1 --- /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, not_: v.not }); + 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 286639e9..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 - findLocator(expression: LocatorExpression, requireOne?: boolean | undefined | null): Promise> - waitLocator(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> - getCommand(): Promise - getOutput(): Promise - getExitCode(): Promise - getCwd(): 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 - 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 - 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 - panicProbe(): 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,113 +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. - * - * 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. - */ 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 - visible: boolean - shape: string - 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 - direction?: LocatorStageDirection - text?: string - regex?: boolean - full?: boolean - whitespace?: string - occurrence?: string - nth?: number - style?: LocatorStyle - link?: string - within?: number - left?: number - right?: number - input?: number - has?: number - hasNot?: 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 { @@ -215,49 +143,6 @@ export interface OpenOptions { screenHistoryLimit?: number } -export interface OpenResult { - shell_pid: number | null - session: string - ready: boolean - 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. */ - 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 -} - -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 @@ -274,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 { @@ -308,90 +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 - 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 RecordingOptions { + path: string + format?: RecordingFormat + fps?: number + speed?: number + idleTimeLimit?: number + zoom?: number + background?: string + transparent?: boolean } -/** The colors the terminal is painting with. */ -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 overrode (`OSC 4`), keyed by index. - * - * Only entries that differ from the profile are listed. - */ - palette: Record +export interface MouseClickOptions { + x?: number + y?: number + onText?: string + button?: number + clicks?: number } -export interface TextMatch { - text: string - start: TextPosition - end: TextPosition - 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 - not?: boolean - timeoutMs?: number +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..276942f9 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -1,605 +1,67 @@ -// prettier-ignore -/* eslint-disable */ -// @ts-nocheck -/* auto-generated by NAPI-RS */ +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"; -import { createRequire } from 'module' -const require = createRequire(import.meta.url) -const __dirname = new URL('.', import.meta.url).pathname +export { + Backend, Shell, RecordingFormat, SnapshotResult, UnderlineStyle, + LocatorNodeKind, LocatorStageDirection, +} from "./models.js"; -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 -} - -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 - } +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 isMuslFromReport = () => { - let report = null - if (process.report && typeof process.report.getReport === 'function') { - process.report.excludeNetwork = true - report = process.report.getReport() +const path = libraryPath(); +const library = koffi.load(path); +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"); } - if (!report) { - return null +} 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 (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 + if (close(retained) !== 0) { + throw new Error(`Failed to release the temporary native library handle: ${lastError()}`); } } - -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}`)) - } +// 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, platformLibrary }; +const version = functions.tui_abi_version(); +if (version !== abiVersion) { + throw new Error(`tui-test native ABI mismatch: expected ${abiVersion}, got ${version}`); } - -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/lib.rs b/bindings/js/native/lib.rs deleted file mode 100644 index 608c4026..00000000 --- a/bindings/js/native/lib.rs +++ /dev/null @@ -1,2175 +0,0 @@ -#![deny(clippy::all)] - -use std::any::Any; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -use napi::bindgen_prelude::{ - spawn_blocking, Buffer, Either, FromNapiValue, JsObjectValue, Object, ValidateNapiValue, -}; -use napi::{Error, Result, Status}; -use std::collections::HashMap; - -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, -}; - -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, -} - -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}'" - ))), - } -} - -#[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", - ))); - } - 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()) - )))), - } -} - -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)) - .await - .map_err(|error| { - native_error(TuiTestError::internal(format!( - "{context} worker failed: {error}" - ))) - })? -} - -fn timeout(value: Option, name: &str) -> std::result::Result, TuiTestError> { - value - .map(|value| integer(value, name, u64::MAX)) - .transpose() -} - -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}')" - ))), - } -} - -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 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 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:?}"))) - } - }; - 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:?}" - ))); - } - } - Ok(profile) -} - -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 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 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 -} - -#[napi] -pub struct NativeSession { - handle: SessionHandle, - recording: CoreAutomaticRecording, - context: CoreExecutionContext, -} - -#[napi] -impl NativeSession { - #[napi(constructor)] - pub fn new( - name: String, - recording: Option, - artifacts: Option, - trace: Option, - ) -> Result { - 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() - }, - }) - } - - #[napi] - pub fn name(&self) -> String { - self.handle.name().to_string() - } - - #[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")), - } - }) - .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)?), - )?; - match result { - OperationResult::Open(value) => Ok(value.into()), - _ => Err(unexpected("run")), - } - }) - .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, - )?, - }, - )?; - match result { - OperationResult::Open(value) => Ok(value.into()), - _ => Err(unexpected("restart")), - } - }) - .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(()) - }) - .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")), - }, - ) - .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")), - }, - ) - .await - } - - #[napi] - pub async fn find_locator( - &self, - 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" - }, - operation, - )? { - OperationResult::Matches(matches) => { - Ok(matches.into_iter().map(TextMatch::from).collect()) - } - _ => Err(unexpected("findLocator")), - } - }) - .await - } - - #[napi] - pub async fn wait_locator( - &self, - expression: LocatorExpression, - 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")?, - }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("waitLocator")), - } - }) - .await - } - - #[napi] - 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("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")?, - }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("clickLocator")), - } - }) - .await - } - - #[napi] - pub async fn highlight_locator( - &self, - expression: LocatorExpression, - 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")?, - }, - )? { - OperationResult::Matches(matches) => { - Ok(matches.into_iter().map(TextMatch::from).collect()) - } - _ => Err(unexpected("highlightLocator")), - } - }) - .await - } - - #[napi] - pub async fn expect_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")?, - }, - )? { - OperationResult::Unit => Ok(()), - _ => Err(unexpected("expectLocator")), - } - }) - .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")), - }, - ) - .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")), - } - }) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .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")), - }, - ) - .await - } - - #[napi] - pub async fn write(&self, data: String) -> Result<()> { - self.unit("write", Operation::Write { data }).await - } - - #[napi(js_name = "type")] - pub async fn type_text(&self, text: String) -> Result<()> { - self.unit("type", Operation::Write { data: text }).await - } - - #[napi] - pub async fn submit(&self, data: Option) -> Result<()> { - self.unit("submit", Operation::Submit { data }).await - } - - #[napi] - pub async fn press(&self, keys: Vec) -> Result<()> { - self.unit( - "press", - Operation::Key { - keys, - action: KeyAction::Press, - }, - ) - .await - } - - #[napi] - pub async fn key_down(&self, keys: Vec) -> Result<()> { - self.unit( - "keydown", - Operation::Key { - keys, - action: KeyAction::Down, - }, - ) - .await - } - - #[napi] - pub async fn repeat(&self, keys: Vec) -> Result<()> { - self.unit( - "repeat", - Operation::Key { - keys, - action: KeyAction::Repeat, - }, - ) - .await - } - - #[napi] - pub async fn key_up(&self, keys: Vec) -> Result<()> { - self.unit( - "keyup", - Operation::Key { - keys, - action: KeyAction::Up, - }, - ) - .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")), - } - }) - .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")), - } - }) - .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")), - } - }) - .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")), - } - }) - .await - } - - #[napi] - pub async fn mouse_drag( - &self, - x1: f64, - y1: f64, - x2: f64, - 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")), - } - }) - .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")), - } - }) - .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")), - } - }) - .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")), - }, - ) - .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")), - }, - ) - .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")), - } - }) - .await - } - - #[napi] - pub async fn wait_clipboard( - &self, - 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")), - } - }) - .await - } - - #[napi] - pub async fn wait_idle(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitIdle", timeout_ms, |timeout_ms| Operation::WaitIdle { - timeout_ms, - }) - .await - } - - #[napi] - pub async fn wait_command(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitCommand", timeout_ms, |timeout_ms| { - Operation::WaitCommand { timeout_ms } - }) - .await - } - - #[napi] - pub async fn wait_exit(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitExit", timeout_ms, |timeout_ms| Operation::WaitExit { - timeout_ms, - }) - .await - } - - #[napi] - pub async fn wait_ready(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitReady", timeout_ms, |timeout_ms| Operation::WaitReady { - timeout_ms, - }) - .await - } - - #[napi] - pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { - self.timeout_unit("waitBell", timeout_ms, |timeout_ms| Operation::WaitBell { - timeout_ms, - }) - .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")), - } - }) - .await - } - - #[napi] - pub async fn expect_output(&self, text: String, regex: Option) -> Result<()> { - self.unit( - "expectOutput", - Operation::ExpectOutput { - text, - regex: regex.unwrap_or(false), - }, - ) - .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")), - } - }) - .await - } - - #[napi] - pub async fn snapshot( - &self, - 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")), - }, - ) - .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")), - }, - ) - .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, - }, - ) - .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") - }) - .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)), - } - }) - .await - } -} - -#[napi] -pub async fn sessions() -> Result> { - blocking("sessions", || Ok(global_registry().sessions())).await -} - -#[napi] -pub async fn close_all() -> Result<()> { - blocking("closeAll", || { - global_registry().close_all(); - Ok(()) - }) - .await -} - -#[napi] -pub fn close_all_sync() -> Result<()> { - ffi_boundary(|| { - global_registry().close_all(); - Ok(()) - }) -} - -#[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}" - )) - } - }) - }) - .await -} 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 4767015a..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": "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", - "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 new file mode 100644 index 00000000..67915862 --- /dev/null +++ b/bindings/js/scripts/build-native.mjs @@ -0,0 +1,45 @@ +import { execFileSync } from "node:child_process"; +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); +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 = 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 }; +delete env.CARGO_TARGET_DIR; +if (target.endsWith("-musl")) { + env.RUSTFLAGS = `${env.RUSTFLAGS ?? ""} -C target-feature=-crt-static`.trim(); +} +execFileSync("cargo", [ + zigbuild ? "zigbuild" : "build", "--locked", "-p", "tui-test-ffi", + ...(debug ? [] : ["--release"]), + ...(explicitTarget ? ["--target", target] : []), +], { cwd: root, env, stdio: "inherit" }); + +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 3943023c..89ab3bd2 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) => { @@ -134,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, @@ -593,8 +601,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(); } @@ -606,7 +618,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 +817,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 +900,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..d80cc8ec 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -1,5 +1,9 @@ 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 { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { test } from "node:test"; import { TuiTest } from "../dist/index.js"; @@ -13,6 +17,342 @@ import { NativeRuntime, mapNativeError } from "../dist/native.js"; const ERROR_PREFIX = "__tui_test_native_error__:"; +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("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("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"; + 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(".js"))) { + copyFileSync(new URL(file, native), join(directory, file)); + } + 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; + 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, + }, + }); + 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("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 { + 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.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)); + 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", () => { const details = { schema_version: 1, @@ -122,6 +462,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/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/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..675f1086 --- /dev/null +++ b/bindings/python/MANIFEST.in @@ -0,0 +1,8 @@ +include README.md +include setup.py +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 ad77c32e..2ec853b4 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -10,6 +10,55 @@ 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, 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`. +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..c4bf1287 --- /dev/null +++ b/bindings/python/setup.py @@ -0,0 +1,84 @@ +"""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() + 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") + # 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") + 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) + + +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..e640ff29 --- /dev/null +++ b/bindings/python/src/tui_test/_abi.py @@ -0,0 +1,475 @@ +# Generated by cargo run -p tui-test-bindgen. Do not edit. +import ctypes + +ABI_VERSION = 4 + + +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 TuiQuery(ctypes.Structure): + pass + + +class TuiMode(ctypes.Structure): + pass + + +class TuiColor(ctypes.Structure): + pass + + +class TuiResult(ctypes.Structure): + pass + + +class TuiOpenResult(ctypes.Structure): + pass + + +class TuiCursor(ctypes.Structure): + pass + + +class TuiSize(ctypes.Structure): + pass + + +class TuiBellEvent(ctypes.Structure): + pass + + +class TuiState(ctypes.Structure): + pass + + +class TuiTerminalColors(ctypes.Structure): + pass + + +class TuiCell(ctypes.Structure): + pass + + +class TuiPosition(ctypes.Structure): + pass + + +class TuiSpan(ctypes.Structure): + pass + + +class TuiMatch(ctypes.Structure): + pass + + +class TuiTimeouts(ctypes.Structure): + pass + + +class TuiOpenOptions(ctypes.Structure): + pass + + +class TuiMouseOptions(ctypes.Structure): + pass + + +class TuiWaitOptions(ctypes.Structure): + pass + + +class TuiRecordingOptions(ctypes.Structure): + pass + + +class TuiScreenshotOptions(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), +] + + +TuiQuery._fields_ = [ + ("json", TuiString), +] + + +TuiMode._fields_ = [ + ("name", TuiString), + ("enabled", 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), +] + + +TuiOpenResult._fields_ = [ + ("shell_pid", TuiOptionalU64), + ("session", TuiString), + ("ready", ctypes.c_bool), + ("recording", TuiString), +] + + +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), +] + + +TuiBellEvent._fields_ = [ + ("sequence", ctypes.c_uint64), + ("elapsed_ms", ctypes.c_uint64), +] + + +TuiTerminalColors._fields_ = [ + ("foreground", TuiString), + ("background", TuiString), + ("cursor", TuiString), + ("palette", ctypes.POINTER(TuiPair)), + ("palette_len", ctypes.c_size_t), +] + + +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), +] + + +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), +] + + +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), + ("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_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/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..15318b11 --- /dev/null +++ b/crates/tui-test-ffi/README.md @@ -0,0 +1,134 @@ +# Shared native library + +`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. +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 + +From the repository root: + +```sh +env -u CARGO_TARGET_DIR cargo build --locked -p tui-test-ffi +``` + +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: + +```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`. + +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 ' + 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.waitExit(); + const state = await terminal.state(); + console.log(`Terminal size: ${state.cols}x${state.rows}`); + console.log(await terminal.text()); + } finally { + await terminal.closeQuiet(); + } +' +``` + +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 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, operations, and input actions | +| `locator_query.rs` | Locator nodes, expressions, and node kinds | +| `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 | + +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 +``` + +`--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. + +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. + +## 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 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. + +## 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. + +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/include/tui_test.h b/crates/tui-test-ffi/include/tui_test.h new file mode 100644 index 00000000..29a1af85 --- /dev/null +++ b/crates/tui-test-ffi/include/tui_test.h @@ -0,0 +1,1179 @@ +/* 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 4 + +/** + * 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 + * 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 TuiScreenshotOptions { + bool full; + struct TuiString path; + struct TuiOptionalF64 zoom; + struct TuiString background; + bool transparent; +} TuiScreenshotOptions; + +typedef struct TuiRecordingOptions { + struct TuiString path; + struct TuiString format; + struct TuiOptionalU64 fps; + struct TuiOptionalF64 speed; + struct TuiOptionalF64 idle_time_limit; + struct TuiOptionalF64 zoom; + struct TuiString background; + bool transparent; +} TuiRecordingOptions; + +#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); + +/** + * 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 + * throughout this call; see TuiString and the input structure contracts. + */ +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 + +#endif /* TUI_TEST_FFI_H */ diff --git a/crates/tui-test-ffi/src/input.rs b/crates/tui-test-ffi/src/input.rs new file mode 100644 index 00000000..5903f418 --- /dev/null +++ b/crates/tui-test-ffi/src/input.rs @@ -0,0 +1,155 @@ +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::ExecutionContext; + +type Result = std::result::Result; + +include!("input_generated.rs"); + +impl TuiOptionalU64 { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} +impl TuiOptionalF64 { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} +impl TuiOptionalBool { + pub(crate) fn option(self) -> Option { + self.present.then_some(self.value) + } +} + +// Every exported entrypoint is unsafe: the caller must provide valid readable +// buffers for the duration of the call. Null/length and UTF-8 checks catch +// representable usage mistakes, but cannot prove foreign pointer validity. +pub(crate) unsafe fn slice<'a, T>(data: *const T, len: usize) -> Result<&'a [T]> { + if len == 0 { + return Ok(&[]); + } + if data.is_null() || len > isize::MAX as usize / std::mem::size_of::() { + return Err(TuiTestError::usage( + "invalid native array pointer or length", + )); + } + Ok(unsafe { std::slice::from_raw_parts(data, len) }) +} +impl TuiString { + pub(crate) unsafe fn optional(self) -> Result> { + if self.data.is_null() { + if self.len != 0 { + return Err(TuiTestError::usage( + "null native string with nonzero length", + )); + } + return Ok(None); + } + let bytes = unsafe { slice(self.data, self.len)? }; + std::str::from_utf8(bytes) + .map(|s| Some(s.to_owned())) + .map_err(|_| TuiTestError::usage("native string must be UTF-8")) + } + pub(crate) unsafe fn required(self) -> Result { + unsafe { self.optional()? } + .ok_or_else(|| TuiTestError::usage("required native string is absent")) + } +} +pub(crate) unsafe fn strings(data: *const TuiString, len: usize) -> Result> { + unsafe { slice(data, len)? } + .iter() + .map(|s| unsafe { s.required() }) + .collect() +} +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}")))?; + value.validate() +} +unsafe fn pairs(data: *const TuiPair, len: usize) -> Result> { + unsafe { slice(data, len)? } + .iter() + .map(|p| Ok((unsafe { p.key.required()? }, unsafe { p.value.required()? }))) + .collect() +} +pub(crate) fn u16_option(value: TuiOptionalU64, name: &str) -> Result> { + value + .option() + .map(|v| { + u16::try_from(v) + .map_err(|_| TuiTestError::usage(format!("{name} must be between 0 and 65535"))) + }) + .transpose() +} +pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result { + let backend = unsafe { value.backend.optional()? } + .map(|s| s.parse()) + .transpose() + .map_err(TuiTestError::usage)? + .unwrap_or_default(); + let shell = unsafe { value.shell.optional()? } + .map(|s| { + serde_json::from_value(s.clone().into()) + .map_err(|_| TuiTestError::usage(format!("unknown shell {s:?}"))) + }) + .transpose()?; + let mut profile = Profile::default(); + if let Some(scrollback) = value.scrollback.option() { + profile.scrollback = usize::try_from(scrollback) + .map_err(|_| TuiTestError::usage("scrollback exceeds addressable size"))?; + } + for (name, color) in unsafe { pairs(value.colors, value.colors_len)? } { + let color = Rgb::parse(&color).map_err(TuiTestError::usage)?; + if !profile.colors.set_named(&name, color) { + return Err(TuiTestError::usage(format!( + "unknown profile color {name:?}" + ))); + } + } + let mode = 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 { + 0 => MouseButton::Left, + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + _ => return Err(TuiTestError::usage("unknown mouse button")), + }; + Ok(mouse_input(value, button)) +} +pub(crate) unsafe fn query(value: TuiQuery) -> Result { + let json = unsafe { value.json.required()? }; + serde_json::from_str::(&json) + .map_err(|error| TuiTestError::usage(format!("invalid locator expression: {error}")))? + .into_query() +} + +pub(crate) unsafe fn capture_background( + background: TuiString, + transparent: bool, +) -> Result> { + crate::session::capture_background(unsafe { background.optional()? }, transparent) +} +pub(crate) unsafe fn recording(value: TuiRecordingOptions) -> Result { + let format = unsafe { value.format.optional()? } + .map(|s| { + serde_json::from_value(s.into()) + .map_err(|_| TuiTestError::usage("unknown recording format")) + }) + .transpose()?; + let fps = value + .fps + .option() + .map(|v| u8::try_from(v).map_err(|_| TuiTestError::usage("fps must be between 0 and 255"))) + .transpose()?; + 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/bindings/go/native/src/lib.rs b/crates/tui-test-ffi/src/lib.rs similarity index 92% rename from bindings/go/native/src/lib.rs rename to crates/tui-test-ffi/src/lib.rs index 152579d3..a1340279 100644 --- a/bindings/go/native/src/lib.rs +++ b/crates/tui-test-ffi/src/lib.rs @@ -1,20 +1,28 @@ -//! Private typed C ABI for the Go binding. All borrowed input buffers must -//! remain valid for the call. Inputs are copied before entering the engine; -//! output storage belongs to Rust until tui_result_free is called. +//! 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; 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 = 4; + 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( @@ -22,12 +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)? }; - global_registry() - .session(session) - .execute_with_context(operation()?, context.with_operation(operation_name)) - }) + 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( @@ -45,7 +58,7 @@ fn finish_trace_and_close( } #[no_mangle] pub extern "C" fn tui_abi_version() -> u32 { - 3 + ABI_VERSION } #[no_mangle] /// # Safety @@ -138,12 +151,12 @@ 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] pub extern "C" fn tui_close_all() -> *mut TuiResult { - boundary(|| { + tasks::execute(|| { global_registry().close_all(); Ok(OperationResult::Unit) }) @@ -153,51 +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()? }; - 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}" - )) - } - }) - }) + 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)? }; - 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"), - ) - }, - ); + match unsafe { input::session(session) } { + Ok((name, context)) => { + tasks::execute(move || session::close(name, context, failed.option())) } - handle.execute_with_context(Operation::Close, context.with_operation("close")) - }) + Err(error) => output::error(error), + } } #[no_mangle] /// # Safety @@ -556,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(), }) }) @@ -576,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(), }) }) @@ -728,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(), }) }) @@ -747,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(), }) }) @@ -895,6 +879,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/bindings/go/native/src/output.rs b/crates/tui-test-ffi/src/output.rs similarity index 53% rename from bindings/go/native/src/output.rs rename to crates/tui-test-ffi/src/output.rs index 226eb061..a512918d 100644 --- a/bindings/go/native/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 new file mode 100644 index 00000000..90e9c94d --- /dev/null +++ b/crates/tui-test-ffi/src/session.rs @@ -0,0 +1,93 @@ +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, +}; + +include!("session_generated.rs"); + +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( + 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/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/tasks.rs b/crates/tui-test-ffi/src/tasks.rs new file mode 100644 index 00000000..19e6f3f4 --- /dev/null +++ b/crates/tui-test-ffi/src/tasks.rs @@ -0,0 +1,110 @@ +//! 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(); + }; + 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) => { + task.receiver = None; + output::error(TuiTestError::internal("native worker disconnected")) + } + } +} + +#[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/bindings/go/native/src/tests.rs b/crates/tui-test-ffi/src/tests.rs similarity index 53% rename from bindings/go/native/src/tests.rs rename to crates/tui-test-ffi/src/tests.rs index 3f2a576e..2bbce907 100644 --- a/bindings/go/native/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 { 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 { error_kind(result) }, 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 { error_kind(result) }, 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 { @@ -7,14 +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_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) }; } @@ -37,17 +100,26 @@ 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 { - 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 { @@ -56,7 +128,7 @@ fn panic_is_contained_and_next_call_succeeds() { TuiOptionalBool::default(), ) }; - assert_eq!(unsafe { (*result).error_kind }, 0); + assert_eq!(unsafe { error_kind(result) }, 0); unsafe { tui_result_free(result) }; } #[test] @@ -74,7 +146,7 @@ fn invalid_utf8_and_absent_required_inputs_report_usage() { }, ] { let result = unsafe { tui_state(session) }; - assert_eq!(unsafe { (*result).error_kind }, 2); + assert_eq!(unsafe { error_kind(result) }, 2); unsafe { tui_result_free(result) }; } } @@ -133,6 +205,67 @@ 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 { error_kind(result) }, 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 { error_kind(result) }, 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() { 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/bindings/go/native/src/types.rs b/crates/tui-test-ffi/src/types.rs similarity index 95% rename from bindings/go/native/src/types.rs rename to crates/tui-test-ffi/src/types.rs index c8885a72..1cbc3dfc 100644 --- a/bindings/go/native/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,66 @@ 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, +} +#[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/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/boundaries.rs b/tools/bindgen/src/boundaries.rs new file mode 100644 index 00000000..0322eaea --- /dev/null +++ b/tools/bindgen/src/boundaries.rs @@ -0,0 +1,635 @@ +//! 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 = "javascript.rs"] +mod javascript; + +#[derive(Clone)] +struct Field { + name: String, + ty: String, +} + +struct Models { + fields: BTreeMap>, + enums: BTreeMap>, +} + +impl Models { + fn load(root: &Path) -> Result { + let mut models = Self { + fields: BTreeMap::new(), + enums: BTreeMap::new(), + }; + for module in [ + "api", + "locator_query", + "diagnostics", + "profile", + "shell/mod", + "terminal/backend", + "terminal/cell", + ] { + models.parse(&fs::read_to_string( + root.join(format!("crates/tui-test/src/{module}.rs")), + )?)?; + } + Ok(models) + } + + fn parse(&mut self, source: &str) -> Result<()> { + fn fields(values: &Fields) -> Vec { + values + .iter() + .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(_)) => { + self.fields + .insert(item.ident.to_string(), fields(&item.fields)); + } + Item::Enum(item) => { + let name = item.ident.to_string(); + self.enums.insert( + name.clone(), + item.variants.iter().map(|v| v.ident.to_string()).collect(), + ); + for variant in item.variants { + self.fields.insert( + format!("{name}::{}", variant.ident), + fields(&variant.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 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(), + "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 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, cc: &mut String) -> Result<()> { + for &model in OUTPUTS { + if model == "PackedScreen" { + continue; + } + let c_enabled = model != "EffectiveTimeouts"; + if c_enabled { + writeln!( + c, + "#[repr(C)] #[derive(Clone,Copy,Default)] pub struct {} {{", + c_name(model) + )?; + } + writeln!( + cc, + "#[allow(unused_variables)] fn {}(s: &mut Storage, v: tui_test::api::{model}) -> {} {{", + snake(model), + c_name(model) + )?; + let mut init = String::new(); + 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}"); + 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 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 c_enabled { + c.push_str("}\n"); + } + writeln!(cc, "{} {{ {init} }} }}", c_name(model))?; + } + Ok(()) +} + +/// 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)? { + 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", _) + if option(&f.ty).is_none() && f.name != "program" => + { + format!("Option<{}>", f.ty) + } + ("TraceOptions", _) | ("FailureArtifactOptions", "mode" | "include_recording") => { + format!("Option<{}>", f.ty) + } + ("LocatorNode", "occurrence") => "Option".into(), + ("Operation::WaitTitle" | "Operation::Snapshot" | "Operation::Screenshot", _) + if f.ty == "bool" => + { + "Option".into() + } + _ => 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")? { + f.ty = format!("Option<{}>", f.ty); + fields.push(f); + } + } + 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, ci: &mut String) -> Result<()> { + for (model, target) in [ + ("Timeouts", "Timeouts"), + ("OpenOptions", "OpenOptions"), + ("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"); + } + writeln!( + c, + "#[repr(C)] #[derive(Clone,Copy,Default)] pub struct Tui{target} {{" + )?; + for f in input_fields(models, model, false)? { + let field = if f.name == "not" { + "not_" + } else { + ident(&f.name) + }; + writeln!(c, "pub {field}: {},", f.ty)?; + } + c.push_str("}\n"); + } + ci.push_str("fn timeouts(value: TuiTimeouts) -> Timeouts { Timeouts {\n"); + for f in models.fields("Timeouts")? { + writeln!(ci, "{}: value.{}.option(),", f.name, f.name)?; + } + ci.push_str("} }\n"); + Ok(()) +} + +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, 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 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(), + "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(ci, name, &value)?; + } + ci.push_str("}) }\nfn 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("} }\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(), + "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"); + 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 cc = notice.clone(); + let mut ci = notice; + 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), + ("output_generated.rs", cc), + ("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, + )?; + } + javascript::generate(&models, root, check)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + pub(crate) fn directory() -> tempfile::TempDir { + 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_fields_drive_c_types_and_conversions() { + 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 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("struct Cursor { unsupported: Unknown }") + .unwrap(); + assert!(output_models(&models, &mut c, &mut cc).is_err()); + } + #[test] + 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 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 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(); + assert!(context.contains("pub new_limit: Option")); + assert!(context.contains("new_limit: self.new_limit.unwrap_or(defaults.new_limit)")); + } + + #[test] + 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 [ + "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 [ + "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(path); + let original = fs::read_to_string(&output).unwrap(); + fs::write(&output, "// stale\n").unwrap(); + 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 new file mode 100644 index 00000000..83dcb114 --- /dev/null +++ b/tools/bindgen/src/main.rs @@ -0,0 +1,430 @@ +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."; + +mod boundaries; +mod koffi; + +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("../.."); + 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)?; + 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); + } + } + + koffi::generate(&root, &structs, &functions, &version, check)?; + 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, + )?; + 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 names: BTreeSet<_> = functions.iter().map(|f| f.sig.ident.to_string()).collect(); + let functions: Vec<_> = functions + .iter() + .copied() + .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 { + 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) +} + +#[cfg(test)] +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(); + 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 = boundaries::tests::directory(); + 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()); + } +}