diff --git a/.github/scripts/release/package-node.mjs b/.github/scripts/release/package-node.mjs index 1963859..91cbd34 100644 --- a/.github/scripts/release/package-node.mjs +++ b/.github/scripts/release/package-node.mjs @@ -7,7 +7,6 @@ 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 readmePath = path.join(bindingsDirectory, "README.md"); const nativePackages = fs .readdirSync(nativePackagesDirectory, { withFileTypes: true }) @@ -38,7 +37,6 @@ for (const nativePackage of nativePackages) { } const originalPackage = fs.readFileSync(packagePath, "utf8"); -const originalReadme = fs.readFileSync(readmePath, "utf8"); const rootPackage = JSON.parse(originalPackage); rootPackage.optionalDependencies = {}; @@ -61,24 +59,6 @@ try { "--pack-destination", outputDirectory, ]); - - rootPackage.name = "shell-use"; - fs.writeFileSync( - packagePath, - `${JSON.stringify(rootPackage, null, 2)}\n`, - ); - fs.writeFileSync( - readmePath, - originalReadme.replaceAll("@microsoft/shell-use", "shell-use"), - ); - runNpm([ - "pack", - bindingsDirectory, - "--ignore-scripts", - "--pack-destination", - outputDirectory, - ]); } finally { fs.writeFileSync(packagePath, originalPackage); - fs.writeFileSync(readmePath, originalReadme); } diff --git a/.github/scripts/release/publish-npm.mjs b/.github/scripts/release/publish-npm.mjs index 8c3f927..59e32ed 100644 --- a/.github/scripts/release/publish-npm.mjs +++ b/.github/scripts/release/publish-npm.mjs @@ -10,10 +10,6 @@ const packages = listPackageTarballs("npm-packages").map((tarball) => ({ manifest: readPackageManifest(tarball), })); -if (packages.length < 10) { - throw new Error("Expected eight native packages and two root packages"); -} - function publishIfMissing({ tarball, manifest }) { const packageVersion = `${manifest.name}@${manifest.version}`; const result = spawnNpm(["view", packageVersion, "version"], { @@ -47,11 +43,21 @@ function publishIfMissing({ tarball, manifest }) { const nativePackages = packages.filter(({ manifest }) => manifest.name.startsWith("@microsoft/shell-use-"), ); -const rootPackages = packages.filter( - ({ manifest }) => - manifest.name === "@microsoft/shell-use" || manifest.name === "shell-use", +const rootPackage = packages.filter( + ({ manifest }) => manifest.name === "@microsoft/shell-use", ); -for (const packageArtifact of [...nativePackages, ...rootPackages]) { +if ( + nativePackages.length !== 8 || + rootPackage.length !== 1 +) { + throw new Error( + `Expected eight native packages and @microsoft/shell-use; found ${packages + .map(({ manifest }) => manifest.name) + .join(", ")}`, + ); +} + +for (const packageArtifact of [...nativePackages, ...rootPackage]) { publishIfMissing(packageArtifact); } diff --git a/.github/scripts/release/smoke-node.mjs b/.github/scripts/release/smoke-node.mjs index a8d70c7..31b3c11 100644 --- a/.github/scripts/release/smoke-node.mjs +++ b/.github/scripts/release/smoke-node.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; @@ -19,30 +20,49 @@ function findPackage(name) { if (matches.length !== 1) { throw new Error(`Expected one ${name} package, found ${matches.length}`); } - return matches[0].tarball; + return matches[0]; } const rootPackage = findPackage("@microsoft/shell-use"); const platformPackage = findPackage("@microsoft/shell-use-linux-x64-gnu"); const smokeDirectory = path.resolve("smoke"); -fs.mkdirSync(smokeDirectory); +for (const { manifest } of [rootPackage, platformPackage]) { + if (manifest.bin !== undefined) { + throw new Error(`${manifest.name} unexpectedly declares a CLI executable`); + } +} + +fs.mkdirSync(smokeDirectory, { recursive: true }); runNpm(["init", "-y"], { cwd: smokeDirectory, stdio: ["ignore", "ignore", "inherit"], }); -runNpm(["install", "--ignore-scripts", platformPackage], { +runNpm(["install", "--ignore-scripts", platformPackage.tarball], { cwd: smokeDirectory, }); runNpm( - ["install", "--ignore-scripts", "--omit=optional", rootPackage], + [ + "install", + "--ignore-scripts", + "--omit=optional", + rootPackage.tarball, + ], { cwd: smokeDirectory }, ); +process.env.SHELL_USE_BIN = path.join(smokeDirectory, "missing-shell-use"); +if (process.platform !== "win32") { + process.env.PATH = "/usr/bin:/bin"; +} +const cliProbe = spawnSync("shell-use", ["--version"], { stdio: "ignore" }); +if (!cliProbe.error || cliProbe.error.code !== "ENOENT") { + throw new Error("shell-use CLI unexpectedly available in smoke PATH"); +} + const requireFromSmoke = createRequire(path.join(smokeDirectory, "package.json")); -const packageEntry = requireFromSmoke.resolve("@microsoft/shell-use"); +const packageEntry = requireFromSmoke.resolve(rootPackage.manifest.name); const { ShellUse } = await import(pathToFileURL(packageEntry).href); - const session = ShellUse.ephemeral("release-smoke"); try { await session.open(); diff --git a/.github/scripts/release/smoke-python.py b/.github/scripts/release/smoke-python.py index bfee76e..0c05d32 100644 --- a/.github/scripts/release/smoke-python.py +++ b/.github/scripts/release/smoke-python.py @@ -1,5 +1,6 @@ import asyncio import os +import shutil import subprocess import sys import venv @@ -22,8 +23,11 @@ def main(): return wheels = sorted(Path("dist").glob("*.whl")) - if not wheels: - raise RuntimeError("No Python wheels found in dist") + if len(wheels) != 1: + raise RuntimeError(f"Expected one Python wheel in dist, found {len(wheels)}") + wheel = wheels[0] + if "abi3" not in wheel.name: + raise RuntimeError(f"Expected an abi3 wheel, found {wheel.name}") smoke_directory = Path("smoke") venv.create(smoke_directory, with_pip=True) @@ -38,13 +42,32 @@ def main(): "pip", "install", "--disable-pip-version-check", - *wheels, + wheel, ], check=True, ) + + runtime_env = os.environ.copy() + runtime_env["SHELL_USE_BIN"] = str( + (smoke_directory / "missing-shell-use").resolve() + ) + if os.name == "nt": + runtime_path = [str(python.parent)] + system_root = runtime_env.get("SystemRoot") + if system_root: + runtime_path.extend( + [str(Path(system_root) / "System32"), str(Path(system_root))] + ) + else: + runtime_path = [str(python.parent), "/usr/bin", "/bin"] + runtime_env["PATH"] = os.pathsep.join(runtime_path) + if shutil.which("shell-use", path=runtime_env["PATH"]) is not None: + raise RuntimeError("shell-use CLI unexpectedly available in smoke PATH") + subprocess.run( [python, Path(__file__).resolve(), "--run-smoke"], check=True, + env=runtime_env, ) diff --git a/.github/scripts/release/verify-versions.mjs b/.github/scripts/release/verify-versions.mjs index 078a360..6609ba7 100644 --- a/.github/scripts/release/verify-versions.mjs +++ b/.github/scripts/release/verify-versions.mjs @@ -6,15 +6,42 @@ if (!releaseTag) { } const expected = releaseTag.replace(/^v/, ""); -const jsPackage = JSON.parse( - fs.readFileSync("bindings/js/package.json", "utf8"), -); + +function read(file) { + return fs.readFileSync(file, "utf8"); +} + +function matchedVersion(file, pattern) { + const version = read(file).match(pattern)?.[1]; + if (!version) { + throw new Error(`Could not read a version from ${file}`); + } + return version; +} + +const jsPackage = JSON.parse(read("bindings/js/package.json")); +const jsPackageLock = JSON.parse(read("bindings/js/package-lock.json")); const versions = { - "Cargo.toml": fs.readFileSync("Cargo.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1], + "Cargo.toml [workspace.package]": matchedVersion( + "Cargo.toml", + /^\[workspace\.package\]\s*$[\s\S]*?^\s*version\s*=\s*"([^"]+)"/m, + ), "bindings/js/package.json": jsPackage.version, - "bindings/js/src/version.ts": fs.readFileSync("bindings/js/src/version.ts", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1], - "bindings/python/pyproject.toml": fs.readFileSync("bindings/python/pyproject.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1], - "bindings/python/src/shell_use/_config.py": fs.readFileSync("bindings/python/src/shell_use/_config.py", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1], + "bindings/js/package-lock.json": jsPackageLock.version, + "bindings/js/package-lock.json packages['']": + jsPackageLock.packages?.[""]?.version, + "bindings/js/src/version.ts": matchedVersion( + "bindings/js/src/version.ts", + /VERSION\s*=\s*"([^"]+)"/, + ), + "bindings/python/pyproject.toml": matchedVersion( + "bindings/python/pyproject.toml", + /^\s*version\s*=\s*"([^"]+)"/m, + ), + "bindings/python/src/shell_use/_config.py": matchedVersion( + "bindings/python/src/shell_use/_config.py", + /VERSION\s*=\s*"([^"]+)"/, + ), }; for (const [file, version] of Object.entries(versions)) { @@ -23,9 +50,30 @@ for (const [file, version] of Object.entries(versions)) { } } -const nativeLoader = fs.readFileSync("bindings/js/native/index.js", "utf8"); -if (!nativeLoader.includes(`'${expected}'`)) { +const workspaceVersionManifests = [ + "crates/shell-use/Cargo.toml", + "crates/shell-use-cli/Cargo.toml", + "bindings/js/Cargo.toml", + "bindings/python/native/Cargo.toml", +]; +for (const file of workspaceVersionManifests) { + if (!/^\s*version\.workspace\s*=\s*true\s*$/m.test(read(file))) { + throw new Error(`${file} must inherit workspace.package.version`); + } +} + +const nativeLoader = read("bindings/js/native/index.js"); +const loaderVersions = new Set( + [...nativeLoader.matchAll(/bindingPackageVersion !== '([^']+)'/g)].map( + ([, version]) => version, + ), +); +if (loaderVersions.size !== 1 || !loaderVersions.has(expected)) { throw new Error( - `bindings/js/native/index.js was not regenerated for ${expected}`, + `bindings/js/native/index.js has package versions ${[...loaderVersions].join(", ") || "none"}; expected ${expected}`, ); } + +console.log( + `Verified ${expected} across release metadata; Rust packages inherit the workspace version.`, +); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab29f67..fb9a1cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: - 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 @@ -71,7 +74,7 @@ jobs: shell: bash run: | git diff --exit-code -- bindings/js/native/index.js bindings/js/native/index.d.ts - ! grep -q "JsonValue" bindings/js/native/index.d.ts + ! grep -Eq 'JsonValue|Promise|\brequest\(|\bBuffer\b' bindings/js/native/index.d.ts - uses: oven-sh/setup-bun@v2 if: runner.os == 'Linux' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71702c9..0df645b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,8 +387,17 @@ jobs: artifacts/* smoke-node: + name: Smoke Node (${{ matrix.name }}) needs: package-node runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Node 20 + version: "20" + - name: Node current + version: "node" steps: - uses: actions/checkout@v4 with: @@ -401,14 +410,23 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "24" + node-version: ${{ matrix.version }} - - name: Install packed root and platform packages + - name: Smoke packed root and platform packages without the CLI run: node .github/scripts/release/smoke-node.mjs smoke-python: + name: Smoke Python (${{ matrix.name }}) needs: build-python runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Python 3.8 + version: "3.8" + - name: Python current + version: "3.14" steps: - uses: actions/checkout@v4 with: @@ -421,7 +439,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: ${{ matrix.version }} - - name: Install built wheel + - name: Smoke the built abi3 wheel without the CLI run: python .github/scripts/release/smoke-python.py diff --git a/.gitignore b/.gitignore index 0287909..c172ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ /target *.gif +*.pyc *.trace +__pycache__/ *.node .shell-use/ bindings/js/target bindings/js/native/target bindings/python/target -bindings/python/native/target \ No newline at end of file +bindings/python/native/target +bindings/python/stub-gen/target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e00cc8e..77cf3fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,8 +698,6 @@ dependencies = [ "napi-sys", "nohash-hasher", "rustc-hash", - "serde", - "serde_json", "tokio", ] @@ -885,6 +883,20 @@ dependencies = [ "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" @@ -939,16 +951,6 @@ dependencies = [ "cc", ] -[[package]] -name = "pythonize" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95" -dependencies = [ - "pyo3", - "serde", -] - [[package]] name = "quote" version = "1.0.46" @@ -1177,6 +1179,7 @@ dependencies = [ "dialoguer", "dirs", "interprocess", + "serde", "serde_json", "sha2", "shell-use", @@ -1189,7 +1192,6 @@ dependencies = [ "napi", "napi-build", "napi-derive", - "serde_json", "shell-use", ] @@ -1197,10 +1199,11 @@ dependencies = [ name = "shell-use-python" version = "0.0.1-beta.5" dependencies = [ + "anyhow", "pyo3", - "pythonize", - "serde_json", + "pyo3-async-runtimes", "shell-use", + "tokio", ] [[package]] diff --git a/README.md b/README.md index ac6b08a..7a53063 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ Watch a live session in a second terminal while an agent drives it. Both share the same daemon. `monitor` takes over an alternate screen and streams the session in full color at ~20fps; press `q`, `Esc`, or `Ctrl-C` to detach. +In-process Python and Node sessions cannot be monitored from another process. + https://github.com/user-attachments/assets/741c985f-7861-41c5-9ceb-0f82f705b43f | Command | Description | diff --git a/SKILL.md b/SKILL.md index a0084da..d673019 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: shell-use -description: 'Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use cli. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live session while an agent drives it; or doing any of this from Python or Node with the shell-use bindings.' +description: "Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use cli. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live cli session while an agent drives it; or driving process-local sessions from Python or Node with the shell-use bindings." --- # shell-use @@ -45,87 +45,87 @@ Three commands let an agent look up the rest of the surface instead of guessing: Every command returns a stable exit code so you can branch on the failure class without parsing text: -| Code | Meaning | -| --- | --- | -| `0` | success | -| `1` | assertion or wait condition not met (`expect` / `wait`) | -| `2` | usage / invalid argument | -| `3` | no active session (run `open` / `run` first) | -| `4` | daemon or IPC error | -| `5` | internal error | +| Code | Meaning | +| ---- | ------------------------------------------------------- | +| `0` | success | +| `1` | assertion or wait condition not met (`expect` / `wait`) | +| `2` | usage / invalid argument | +| `3` | no active session (run `open` / `run` first) | +| `4` | daemon or IPC error | +| `5` | internal error | ## Command reference ### Session & lifecycle -| Command | Description | -| --- | --- | -| `open [--shell S] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a shell session (auto-starts the daemon). `--env` is repeatable. | -| `run [args...] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a session running a program directly (no shell). | -| `sessions` | List active sessions. | -| `close [--all]` | Close the current session (or every session with `--all`). | -| `daemon start` | Start this session's daemon. Most commands start one on demand. | -| `daemon status` | Inspect a session's daemon (pid, log path). Exit 3 if none is running. | -| `daemon stop --session N \| --all` | Stop one session's daemon, or every daemon. Needs a target. | +| Command | Description | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| `open [--shell S] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a shell session (auto-starts the daemon). `--env` is repeatable. | +| `run [args...] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a session running a program directly (no shell). | +| `sessions` | List active sessions. | +| `close [--all]` | Close the current session (or every session with `--all`). | +| `daemon start` | Start this session's daemon. Most commands start one on demand. | +| `daemon status` | Inspect a session's daemon (pid, log path). Exit 3 if none is running. | +| `daemon stop --session N \| --all` | Stop one session's daemon, or every daemon. Needs a target. | ### Inspection -| Command | Description | -| --- | --- | -| `state` | cwd, size, cursor, last command + exit code, timeouts, and a text snapshot. | -| `text [--full]` | Rendered viewport text, or full scrollback with `--full`. | -| `screenshot [PATH] [-o FILE] [--full]` | Terminal text to stdout, or a full-color SVG image (crisp at any zoom, svg-term-style window) when a path is given. | -| `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | -| `get command\|output\|exit-code\|cwd\|cursor\|size` | One structured field. | +| Command | Description | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `state` | cwd, size, cursor, last command + exit code, timeouts, and a text snapshot. | +| `text [--full]` | Rendered viewport text, or full scrollback with `--full`. | +| `screenshot [PATH] [-o FILE] [--full]` | Terminal text to stdout, or a full-color SVG image (crisp at any zoom, svg-term-style window) when a path is given. | +| `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | +| `get command\|output\|exit-code\|cwd\|cursor\|size` | One structured field. | ### Input -| Command | Description | -| --- | --- | -| `type "text"` | Type literal text (no return key). | -| `submit ["text"]` | Type text then press the shell's return key. Omit text to just submit. | -| `press ` | Named keys, e.g. `press Escape : w q Enter`, `press Ctrl+C`. | -| `keys "Ctrl+a"` | A single key combo. | -| `mouse click X Y` / `mouse click --on-text "OK" [--button N] [--clicks N]` | Click by coordinates or by visible label. | -| `mouse move\|down\|up\|drag\|scroll ...` | Full mouse control (`--button` default 0=left, `scroll --amount` default 3). | +| Command | Description | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `type "text"` | Type literal text (no return key). | +| `submit ["text"]` | Type text then press the shell's return key. Omit text to just submit. | +| `press ` | Named keys, e.g. `press Escape : w q Enter`, `press Ctrl+C`. | +| `keys "Ctrl+a"` | A single key combo. | +| `mouse click X Y` / `mouse click --on-text "OK" [--button N] [--clicks N]` | Click by coordinates or by visible label. | +| `mouse move\|down\|up\|drag\|scroll ...` | Full mouse control (`--button` default 0=left, `scroll --amount` default 3). | ### PTY control -| Command | Description | -| --- | --- | -| `resize COLS ROWS` | Resize the PTY and emulator. | -| `write ` | Write raw bytes to the PTY (no return key appended). | -| `signal INT\|TERM\|KILL\|QUIT` | Send a signal to the session's child process. | -| `kill` | Kill the session's child process. | +| Command | Description | +| ------------------------------ | ---------------------------------------------------- | +| `resize COLS ROWS` | Resize the PTY and emulator. | +| `write ` | Write raw bytes to the PTY (no return key appended). | +| `signal INT\|TERM\|KILL\|QUIT` | Send a signal to the session's child process. | +| `kill` | Kill the session's child process. | ### Wait (block until a condition holds) -| Command | Description | -| --- | --- | -| `wait text "T" [--regex --full --not --timeout MS]` | Until text/regex is (with `--not`, is not) visible. Most precise wait. | -| `wait idle [--timeout MS]` | Until the screen stops repainting (~250ms quiet). | -| `wait command [--timeout MS]` | Until the current foreground command finishes (needs shell integration). | -| `wait exit [--timeout MS]` | Until the session's program/shell itself exits. | -| `wait ready [--timeout MS]` | Until the shell reports a ready prompt (needs shell integration). `open` waits by default. | +| Command | Description | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `wait text "T" [--regex --full --not --timeout MS]` | Until text/regex is (with `--not`, is not) visible. Most precise wait. | +| `wait idle [--timeout MS]` | Until the screen stops repainting (~250ms quiet). | +| `wait command [--timeout MS]` | Until the current foreground command finishes (needs shell integration). | +| `wait exit [--timeout MS]` | Until the session's program/shell itself exits. | +| `wait ready [--timeout MS]` | Until the shell reports a ready prompt (needs shell integration). `open` waits by default. | ### Expect (exit 0 = pass, 1 = fail) -| Command | Description | -| --- | --- | -| `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility plus optional color. `--no-strict` relaxes a strict single-match. | -| `expect exit-code N [--timeout MS]` | The last command's exit code. Waits for the command to finish first. | -| `expect output "T" [--regex]` | The last command's captured output. | -| `expect snapshot NAME [-u] [--include-colors]` | Compare the screen against `__snapshots__/NAME.snap`; `-u` writes/updates it. | +| Command | Description | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility plus optional color. `--no-strict` relaxes a strict single-match. | +| `expect exit-code N [--timeout MS]` | The last command's exit code. Waits for the command to finish first. | +| `expect output "T" [--regex]` | The last command's captured output. | +| `expect snapshot NAME [-u] [--include-colors]` | Compare the screen against `__snapshots__/NAME.snap`; `-u` writes/updates it. | Colors accept ansi-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). ### Recording, monitor & self-docs -| Command | Description | -| --- | --- | -| `get-recording [session]` | Print a session's asciinema v2 cast to stdout (works even after it stopped). | -| `monitor` | Watch the session live, full-color, in another terminal. | -| `usage` / `agent-context` / `skill` | Self-documentation (see top of guide). | +| Command | Description | +| ----------------------------------- | ---------------------------------------------------------------------------- | +| `get-recording [session]` | Print a session's asciinema v2 cast to stdout (works even after it stopped). | +| `monitor` | Watch the session live, full-color, in another terminal. | +| `usage` / `agent-context` / `skill` | Self-documentation (see top of guide). | ## Workflow: run a command and check the result @@ -226,22 +226,28 @@ It needs an interactive terminal (exit `2` otherwise) and an existing session (exit `3` if none). It only reads shared screen state, so watching never blocks the commands the agent runs; resizing the window re-fits the frame. -## Programmatic use (Python, Node, Deno & Bun) +This works only with standalone CLI sessions. -Two client libraries drive the same daemon from code instead of the shell, with -methods that mirror the cli command surface. Both are async and dependency-free, -and both need the `shell-use` binary on `PATH` (or pointed to with the -`SHELL_USE_BIN` env var, or a `binary` argument). They start and reuse the daemon -exactly like the cli, so a session opened from code can be watched with -`shell-use monitor` from another terminal. The JavaScript package is a single -ESM module that runs on Node, Deno, and Bun; it imports only built-in modules, -so it pulls in nothing extra on any of them. +## Programmatic use (Python and JavaScript) + +The Python and JavaScript packages bind the Rust terminal engine directly and +run sessions in-process. Session names, registries, and recordings are +process-local. A native session cannot be listed, attached to, controlled, or +monitored from another process, including by the standalone CLI. + +Language packages do not install or require the `shell-use` CLI. Only the +standalone CLI uses the daemon and JSON-over-local-socket protocol described +elsewhere in this guide. + +Node is the supported JavaScript runtime. Bun and Deno compatibility is best +effort and does not gate releases. Deno requires a local `node_modules` +directory and `--allow-ffi` in addition to read/write permissions. ```sh pip install shell-use # Python 3.8+, imported as `shell_use` npm install @microsoft/shell-use # Node 20+ (ESM only) -bun add @microsoft/shell-use # Bun -deno add npm:@microsoft/shell-use # Deno 2 +bun add @microsoft/shell-use # Bun (best effort) +deno add npm:@microsoft/shell-use # Deno 2 (best effort) ``` Python: @@ -261,7 +267,7 @@ async def main(): asyncio.run(main()) ``` -Node, Deno, or Bun (the same code runs on all three): +Node (the same API may work on Bun and Deno): ```js import { ShellUse } from "@microsoft/shell-use"; @@ -275,29 +281,27 @@ await su.expectExitCode(0); await su.close(); ``` -On Windows, Deno needs `-A` (`--allow-all`) rather than just -`--allow-read --allow-write`, because the daemon IPC uses a named pipe. - Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / -`kill`, `state`, `text`, `cells`, `get` (plus shorthands `get_command` / -`get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), -`screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit`, -`expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, and -`close`. Module-level helpers cover the rest: `sessions`, `close_all`, -`daemon_status`, `daemon_stop`, `get_recording`. The JavaScript client uses the same names in camelCase (`waitCommand`, `expectText`, `getExitCode`, `closeAll`, etc). - -The constructor takes the session name plus optional `binary` and `home` -overrides: `ShellUse(session="default", *, binary=None, home=None)` in Python, -`new ShellUse(session?, { binary?, home? })` in JavaScript. `run` takes the program then its args (`await su.run("vim", "file.txt")` in Python, +`kill`, `state`, `text`, `cells`, the dedicated `get_command` / `get_output` / +`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` methods, +`screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / +`wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / +`expect_snapshot`, and `close`. Python module-level helpers are `sessions`, +`close_all`, and `get_recording`; JavaScript exports `sessions`, `closeAll`, +and `getRecording`. The JavaScript client otherwise uses the same names in +camelCase (`waitCommand`, `expectText`, `getExitCode`, etc.). + +The constructors accept a session name plus timeout and artifact options: +`ShellUse(session="default", *, timeouts=None, artifacts=None)` in Python and +`new ShellUse(session?, { timeouts?, artifacts? })` in JavaScript. `run` takes +the program then its arguments (`await su.run("vim", "file.txt")` in Python, `await su.run("vim", ["file.txt"])` in JavaScript). Failures raise typed errors instead of returning exit codes, one class per row of -the [exit-code table](#exit-codes): `ExpectationError` (1), `UsageError` (2), -`NoSessionError` (3), `DaemonError` (4), and `InternalError` (5), all subclasses -of `ShellUseError`. On its first call a client also checks that the daemon's -version matches the package and raises `VersionMismatchError` if they differ; -stop the daemon (`daemon_stop`) so it restarts on the matching binary. +the applicable [exit-code table](#exit-codes): `ExpectationError` (1), +`UsageError` (2), `NoSessionError` (3), and `InternalError` (5), all subclasses +of `ShellUseError`. ## Supported shells & integration diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml index 923bf6f..6e67c0e 100644 --- a/bindings/js/Cargo.toml +++ b/bindings/js/Cargo.toml @@ -15,9 +15,8 @@ crate-type = ["cdylib", "rlib"] test = false [dependencies] -napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "serde-json", "async"] } +napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"] } napi-derive = "3" -serde_json.workspace = true shell-use.workspace = true [build-dependencies] diff --git a/bindings/js/README.md b/bindings/js/README.md index c986ee8..3471a81 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -1,18 +1,18 @@ # @microsoft/shell-use - Node bindings for [`shell-use`](https://github.com/microsoft/shell-use); a terminal engine for driving and asserting on real shells and TUI programs. +Node bindings for [`shell-use`](https://github.com/microsoft/shell-use); a terminal engine for driving and asserting on real shells and TUI programs. ## Install ```sh npm install @microsoft/shell-use # Node 20+ -bun add @microsoft/shell-use # Bun +bun add @microsoft/shell-use # Bun (best effort) -deno add npm:@microsoft/shell-use # Deno 2 +deno add npm:@microsoft/shell-use # Deno 2 (best effort) ``` -The package is only ESM +The package is ESM only. ## Runtime Requirements @@ -38,24 +38,27 @@ await su.close(); Every failure maps to one of the engine's error kinds: -| Class | `exitCode` | Meaning | -| --- | --- | --- | -| `ExpectationError` | 1 | an `expect`/`wait` condition was not met | -| `UsageError` | 2 | invalid argument (e.g. a bad regex) | -| `NoSessionError` | 3 | no active session | -| `InternalError` | 5 | internal engine error | +| Class | `exitCode` | Meaning | +| ------------------ | ---------- | ---------------------------------------- | +| `ExpectationError` | 1 | an `expect`/`wait` condition was not met | +| `UsageError` | 2 | invalid argument (e.g. a bad regex) | +| `NoSessionError` | 3 | no active session | +| `InternalError` | 5 | internal engine error | All derive from `ShellUseError` and carry `kind` and `exitCode`. `waitX` and `expectX` reject with `ExpectationError` on failure. Assertion errors include the current visible terminal content. ## API -`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`), `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`, `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. `open` and `run` accept `{ cols, rows, cwd, env, waitReady, retries, timeouts }`. The timeout classes are `text`, `idle`, `command`, `exit`, and `ready`; `timeouts` sets session defaults, the constructor sets client-wide ones. Unknown class names throw. -`ShellUse.ephemeral(prefix?, opts?)` creates a client bound to a unique session name (via `uniqueSession()`), useful for parallel test workers that shouldn't collide. All sessions as process local. `artifacts: { dir, onFailure }` attaches the terminal contents to an `ExpectationError`. +`ShellUse.ephemeral(prefix?, opts?)` creates a client bound to a unique session +name (via `uniqueSession()`), useful for parallel test workers that should not +collide. All sessions are process-local. `artifacts: { dir, onFailure }` +attaches the terminal contents to an `ExpectationError`. `@microsoft/shell-use/test` has helpers for terminal tests: `createTerminal`, `withTerminal`, `closeAllTracked`, `defaultShell`, and `terminalSnapshot`. @@ -69,18 +72,19 @@ await withTerminal({}, async (t) => { }); ``` -Each terminal is isolated and uniquely named, so parallel workers don't collide. `setTerminalDefaults(...)` sets suite-wide options (`artifacts`, `timeouts`, ...). +Each terminal has a unique name, so parallel workers do not collide. +`setTerminalDefaults(...)` sets suite-wide options (`artifacts`, `timeouts`, +...). ## Cancellation and recordings -Cancelling a promise does not cancel the underlying Rust operation. Operations for single sessoins wait for completion (ex: `close()`, `closeAll()`). +Cancelling a promise does not cancel the underlying Rust operation. Operations for single sessions wait for completion (ex: `close()`, `closeAll()`). -Closing a session removes it from `sessions()`, but keeps its recording. `getRecording()` can read that recording for the rest of the -process. The 1024 most recently closed session have their recordings retained. +Closing a session removes it from `sessions()`, but keeps its recording. `getRecording()` can read that recording for the rest of the process. The 1024 most recently closed sessions have their recordings retained. ## Configuration -| Variable | Purpose | -| --- | --- | -| `SHELL_USE_SESSION` | default session name | +| Variable | Purpose | +| ------------------------------ | --------------------------------------------------------------------------- | +| `SHELL_USE_SESSION` | default session name | | `SHELL_USE_TIMEOUT__MS` | fallback timeout for one class (`TEXT`, `IDLE`, `COMMAND`, `EXIT`, `READY`) | diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 8ffb94e..89f98e1 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -3,13 +3,216 @@ export declare class NativeSession { constructor(name: string) name(): string - request(payload: any): Promise + open(options?: OpenOptions | undefined | null): Promise + run(options: RunOptions): Promise + close(): 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> + getCommand(): Promise + getOutput(): Promise + getExitCode(): Promise + getCwd(): Promise + getCursor(): Promise + getSize(): Promise + write(data: string): Promise + type(text: string): Promise + submit(data?: string | undefined | null): Promise + press(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 + waitText(text: string, options?: WaitTextOptions | 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 + expectText(text: string, options?: ExpectTextOptions | undefined | null): Promise + expectExitCode(code: number, timeoutMs?: number | undefined | null): Promise + expectOutput(text: string, regex?: boolean | undefined | null): Promise + snapshot(name: string, options?: SnapshotOptions | undefined | null): Promise + screenshot(options?: ScreenshotOptions | undefined | null): Promise + panicProbe(): Promise +} + +export interface Cell { + x: number + y: number + char: string + fg: Color + bg: Color + bold: boolean + dim: boolean + italic: boolean + inverse: boolean + invisible: boolean + strike: boolean + blink: boolean + underline: boolean + underline_style: UnderlineStyle + underline_color: Color } export declare function closeAll(): Promise export declare function closeAllSync(): void +export type Color = + number | string + +export interface Cursor { + x: number + y: number +} + +export interface EffectiveTimeouts { + text: number + idle: number + command: number + exit: number + ready: number +} + +export interface ExpectTextOptions { + regex?: boolean + full?: boolean + strict?: boolean + not?: boolean + fg?: string + bg?: string + timeoutMs?: number +} + +export interface MouseClickOptions { + x?: number + y?: number + onText?: string + button?: number + clicks?: number +} + +export interface OpenOptions { + shell?: Shell + cols?: number + rows?: number + cwd?: string + env?: Array<[string, string]> + waitReady?: boolean + timeouts?: Timeouts +} + +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 interface RunOptions { + program: string + args?: Array + cols?: number + rows?: number + cwd?: string + env?: Array<[string, string]> + waitReady?: boolean + timeouts?: Timeouts +} + +export interface ScreenshotOptions { + full?: boolean + path?: string +} + export declare function sessions(): Promise> + +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 Size { + cols: number + rows: number +} + +export interface SnapshotOptions { + update?: boolean + includeColors?: boolean + cwd?: string +} + +export declare const enum SnapshotResult { + Passed = 'passed', + Written = 'written', + Updated = 'updated' +} + +export interface State { + session_shell: string | null + cols: number + rows: number + cursor: Cursor + cwd: string | null + last_command: string | null + last_exit: number | null + exited: number | null + ready: boolean + timeouts: EffectiveTimeouts + text: string +} + +export interface Timeouts { + text?: number + idle?: number + command?: number + exit?: number + ready?: number +} + +export declare const enum UnderlineStyle { + None = 'none', + Single = 'single', + Double = 'double', + Curly = 'curly', + Dotted = 'dotted', + Dashed = 'dashed' +} + +export interface WaitTextOptions { + regex?: boolean + full?: boolean + not?: boolean + timeoutMs?: number +} diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index d6a634f..1e1f68a 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -590,9 +590,12 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { NativeSession, closeAll, closeAllSync, recording, sessions } = nativeBinding +const { NativeSession, closeAll, closeAllSync, recording, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding export { NativeSession } export { closeAll } export { closeAllSync } export { recording } export { sessions } +export { Shell } +export { SnapshotResult } +export { UnderlineStyle } diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index a374887..beabd81 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -1,103 +1,1128 @@ #![deny(clippy::all)] -use napi::bindgen_prelude::{spawn_blocking, ToNapiValue, TypeName}; -use napi::{sys, Error, Result, Status, ValueType}; +use std::any::Any; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use napi::bindgen_prelude::{spawn_blocking, Buffer, Either}; +use napi::{Error, Result, Status}; use napi_derive::napi; -use serde_json::Value; -use shell_use::runtime::global_registry; +use shell_use::shell::Shell as CoreShell; +use shell_use::{ + global_registry, Cell as CoreCell, CellColor, Cursor as CoreCursor, + EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, MouseAction, + OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, Operation, OperationResult, + RunOptions as CoreRunOptions, ScreenshotResult as CoreScreenshotResult, SessionHandle, + ShellUseError, Size as CoreSize, SnapshotResult as CoreSnapshotResult, State as CoreState, + Timeouts as CoreTimeouts, +}; + +const ERROR_PREFIX: &str = "__shell_use_native_error__:"; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +#[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(object)] +pub struct Timeouts { + pub text: Option, + pub idle: Option, + pub command: Option, + pub exit: Option, + pub ready: Option, +} -fn internal_error(context: &str, error: impl std::fmt::Display) -> Error { - Error::new(Status::GenericFailure, format!("{context}: {error}")) +#[napi(object)] +pub struct OpenOptions { + pub shell: Option, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub timeouts: Option, } -// `Task::JsValue` requires `TypeName`, which `serde_json::Value` lacks. -pub struct JsonValue(Value); +#[napi(object)] +pub struct RunOptions { + pub program: String, + pub args: Option>, + pub cols: Option, + pub rows: Option, + pub cwd: Option, + pub env: Option>, + pub wait_ready: Option, + pub timeouts: 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 TypeName for JsonValue { - fn type_name() -> &'static str { - "unknown" +impl From for OpenResult { + fn from(value: CoreOpenResult) -> Self { + Self { + shell_pid: value.shell_pid, + session: value.session, + ready: value.ready, + recording: value.recording, + } } +} - fn value_type() -> ValueType { - ValueType::Unknown +#[napi(object)] +pub struct Cursor { + pub x: u16, + pub y: u16, +} + +impl From for Cursor { + fn from(value: CoreCursor) -> Self { + Self { + x: value.x, + y: value.y, + } } } -impl ToNapiValue for JsonValue { - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - unsafe { Value::to_napi_value(env, val.0) } +#[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, 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 cwd: Option, + #[napi(js_name = "last_command")] + pub last_command: Option, + #[napi(js_name = "last_exit")] + pub last_exit: Option, + pub exited: Option, + pub ready: bool, + pub timeouts: EffectiveTimeouts, + pub text: String, +} + +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(), + cwd: value.cwd, + last_command: value.last_command, + last_exit: value.last_exit, + exited: value.exited, + ready: value.ready, + 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(ShellUseError::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, +} + +impl TryFrom for Cell { + type Error = ShellUseError; + + 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), + }) + } +} + +#[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(object)] +pub struct WaitTextOptions { + pub regex: Option, + pub full: Option, + pub not: Option, + pub timeout_ms: Option, +} + +#[napi(object)] +pub struct ExpectTextOptions { + pub regex: Option, + pub full: Option, + pub strict: Option, + pub not: Option, + pub fg: Option, + pub bg: Option, + pub timeout_ms: Option, +} + +#[napi(object)] +pub struct SnapshotOptions { + pub update: Option, + pub include_colors: Option, + pub cwd: Option, +} + +#[napi(object)] +pub struct ScreenshotOptions { + pub full: Option, + pub path: 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: ShellUseError) -> Error { + Error::new( + Status::GenericFailure, + format!("{ERROR_PREFIX}{}\n{}", error.kind.as_str(), error.message), + ) +} + +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(ShellUseError::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(ShellUseError::internal(format!( + "{context} worker failed: {error}" + ))) + })? +} + +fn timeout(value: Option, name: &str) -> std::result::Result, ShellUseError> { + 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(ShellUseError::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 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(ShellUseError::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 open_options(value: Option) -> std::result::Result { + let Some(value) = value else { + return Ok(CoreOpenOptions::default()); + }; + Ok(CoreOpenOptions { + shell: value.shell.map(Into::into), + cols: match value.cols { + Some(cols) => u16_value(cols, "cols")?, + None => shell_use::config::DEFAULT_COLS, + }, + rows: match value.rows { + Some(rows) => u16_value(rows, "rows")?, + None => shell_use::config::DEFAULT_ROWS, + }, + cwd: value.cwd, + env: value.env.unwrap_or_default(), + wait_ready: value.wait_ready, + timeouts: core_timeouts(value.timeouts)?, + }) +} + +fn run_options(value: RunOptions) -> std::result::Result { + if value.program.is_empty() { + return Err(ShellUseError::usage("program must not be empty")); + } + Ok(CoreRunOptions { + program: value.program, + args: value.args.unwrap_or_default(), + cols: match value.cols { + Some(cols) => u16_value(cols, "cols")?, + None => shell_use::config::DEFAULT_COLS, + }, + rows: match value.rows { + Some(rows) => u16_value(rows, "rows")?, + None => shell_use::config::DEFAULT_ROWS, + }, + cwd: value.cwd, + env: value.env.unwrap_or_default(), + wait_ready: value.wait_ready, + timeouts: core_timeouts(value.timeouts)?, + }) +} + +fn unexpected(operation: &str) -> ShellUseError { + ShellUseError::internal(format!("{operation} returned an unexpected result type")) +} + +async fn execute( + handle: SessionHandle, + 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 = handle.execute(operation)?; + convert(result) + }) + .await +} + #[napi] pub struct NativeSession { - name: String, + handle: SessionHandle, } #[napi] impl NativeSession { #[napi(constructor)] pub fn new(name: String) -> Self { - NativeSession { name } + Self { + handle: global_registry().session(name), + } } #[napi] pub fn name(&self) -> String { - self.name.clone() + self.handle.name().to_string() + } + + #[napi] + pub async fn open(&self, options: Option) -> Result { + let handle = self.handle.clone(); + blocking("open", move || { + let result = handle.execute(Operation::Open(open_options(options)?))?; + 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(); + blocking("run", move || { + let result = handle.execute(Operation::Run(run_options(options)?))?; + match result { + OperationResult::Open(value) => Ok(value.into()), + _ => Err(unexpected("run")), + } + }) + .await + } + + #[napi] + pub async fn close(&self) -> Result<()> { + execute( + self.handle.clone(), + "close", + Operation::Close, + |result| match result { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("close")), + }, + ) + .await + } + + #[napi] + pub async fn state(&self) -> Result { + execute( + self.handle.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(), + "text", + Operation::Text { + full: full.unwrap_or(false), + }, + |result| match result { + OperationResult::Text(value) => Ok(value), + _ => Err(unexpected("text")), + }, + ) + .await + } + + #[napi] + pub async fn packed_screen(&self, full: Option) -> Result { + execute( + self.handle.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(); + 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 handle.execute(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(), + "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(), + "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(), + "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(), + "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(), + "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(), + "getSize", + Operation::GetSize, + |result| match result { + OperationResult::Size(value) => Ok(value.into()), + _ => Err(unexpected("getSize")), + }, + ) + .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::Press { keys }).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(); + 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, + button: u8_value(options.button.unwrap_or(0.0), "button")?, + clicks: u8_value(options.clicks.unwrap_or(1.0), "clicks")?, + }; + match handle.execute(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(); + blocking("mouseMove", move || { + let action = MouseAction::Move { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + }; + match handle.execute(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(); + blocking("mouseDown", move || { + let action = MouseAction::Down { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + button: u8_value(button.unwrap_or(0.0), "button")?, + }; + match handle.execute(Operation::Mouse { action })? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("mouseDown")), + } + }) + .await } - #[napi(ts_return_type = "Promise")] - pub async fn request(&self, payload: Value) -> Result { - let name = self.name.clone(); - let output = spawn_blocking(move || { - let response = global_registry().response_value(&name, payload); - serde_json::to_value(response) - .map_err(|error| internal_error("failed to encode shell-use response", error)) + #[napi] + pub async fn mouse_up(&self, x: f64, y: f64, button: Option) -> Result<()> { + let handle = self.handle.clone(); + blocking("mouseUp", move || { + let action = MouseAction::Up { + x: u16_value(x, "x")?, + y: u16_value(y, "y")?, + button: u8_value(button.unwrap_or(0.0), "button")?, + }; + match handle.execute(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(); + 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")?, + button: u8_value(button.unwrap_or(0.0), "button")?, + }; + match handle.execute(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(); + blocking("mouseScroll", move || { + let action = MouseAction::Scroll { + direction, + amount: u16_value(amount.unwrap_or(3.0), "amount")?, + }; + match handle.execute(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(); + blocking("resize", move || { + let operation = Operation::Resize { + cols: u16_value(cols, "cols")?, + rows: u16_value(rows, "rows")?, + }; + match handle.execute(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 wait_text(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or(WaitTextOptions { + regex: None, + full: None, + not: None, + timeout_ms: None, + }); + let handle = self.handle.clone(); + blocking("waitText", move || { + let operation = Operation::WaitText { + text, + regex: options.regex.unwrap_or(false), + full: options.full.unwrap_or(false), + timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, + not: options.not.unwrap_or(false), + }; + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("waitText")), + } + }) + .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 expect_text( + &self, + text: String, + options: Option, + ) -> Result<()> { + let options = options.unwrap_or(ExpectTextOptions { + regex: None, + full: None, + strict: None, + not: None, + fg: None, + bg: None, + timeout_ms: None, + }); + let handle = self.handle.clone(); + blocking("expectText", move || { + let operation = Operation::ExpectText { + text, + regex: options.regex.unwrap_or(false), + full: options.full.unwrap_or(false), + strict: options.strict.unwrap_or(true), + not: options.not.unwrap_or(false), + fg: options.fg, + bg: options.bg, + timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, + }; + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("expectText")), + } + }) + .await + } + + #[napi] + pub async fn expect_exit_code(&self, code: f64, timeout_ms: Option) -> Result<()> { + let handle = self.handle.clone(); + blocking("expectExitCode", move || { + let operation = Operation::ExpectExitCode { + code: i32_value(code, "code")?, + timeout_ms: timeout(timeout_ms, "timeoutMs")?, + }; + match handle.execute(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 snapshot( + &self, + name: String, + options: Option, + ) -> Result { + let options = options.unwrap_or(SnapshotOptions { + update: None, + include_colors: None, + cwd: None, + }); + execute( + self.handle.clone(), + "snapshot", + Operation::Snapshot { + name, + update: options.update.unwrap_or(false), + include_colors: options.include_colors.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, + }); + execute( + self.handle.clone(), + "screenshot", + Operation::Screenshot { + full: options.full.unwrap_or(false), + path: options.path, + }, + |result| match result { + OperationResult::Screenshot(CoreScreenshotResult::Path(value)) + | OperationResult::Screenshot(CoreScreenshotResult::Text(value)) => Ok(value), + _ => Err(unexpected("screenshot")), + }, + ) + .await + } + + #[napi] + pub async fn panic_probe(&self) -> Result<()> { + blocking( + "panicProbe", + || -> std::result::Result<(), ShellUseError> { + panic!("intentional native panic probe") + }, + ) + .await + } +} + +impl NativeSession { + async fn unit(&self, operation_name: &'static str, operation: Operation) -> Result<()> { + execute( + self.handle.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(); + blocking(operation_name, move || { + let operation = operation(timeout(timeout_ms, "timeoutMs")?); + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected(operation_name)), + } }) .await - .map_err(|error| internal_error("native request task failed", error))??; - Ok(JsonValue(output)) } } #[napi] pub async fn sessions() -> Result> { - spawn_blocking(|| global_registry().sessions()) - .await - .map_err(|error| internal_error("native sessions task failed", error)) + blocking("sessions", || Ok(global_registry().sessions())).await } #[napi] pub async fn close_all() -> Result<()> { - spawn_blocking(|| { + blocking("closeAll", || { global_registry().close_all(); + Ok(()) }) .await - .map_err(|error| internal_error("native close task failed", error)) } #[napi] -pub fn close_all_sync() { - global_registry().close_all(); +pub fn close_all_sync() -> Result<()> { + ffi_boundary(|| { + global_registry().close_all(); + Ok(()) + }) } #[napi] pub async fn recording(name: String) -> Result { - spawn_blocking(move || { + blocking("recording", move || { global_registry().recording(&name).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { - Error::new( - Status::GenericFailure, + ShellUseError::new( + ErrorKind::NoSession, format!("no recording for session '{name}'"), ) } else { - internal_error( - &format!("failed to read the recording for session '{name}'"), - error, - ) + ShellUseError::internal(format!( + "failed to read the recording for session '{name}': {error}" + )) } }) }) .await - .map_err(|error| internal_error("native recording task failed", error))? } diff --git a/bindings/js/package.json b/bindings/js/package.json index 9ebf948..b15ee83 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -42,14 +42,14 @@ "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:ts": "tsc -p tsconfig.json", + "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/conformance.test.mjs ./test/integration.test.mjs ./test/options.test.mjs ./test/protocol.test.mjs ./test/helpers.test.mjs", - "test:bun": "npm run build && bun test ./test/conformance.test.mjs && bun test ./test/integration.test.mjs && bun test ./test/options.test.mjs && bun test ./test/protocol.test.mjs && bun test ./test/helpers.test.mjs", + "test:node": "npm run build && node --test ./test/conformance.test.mjs ./test/integration.test.mjs ./test/options.test.mjs ./test/native.test.mjs ./test/helpers.test.mjs", + "test:bun": "npm run build && bun test ./test/conformance.test.mjs && bun test ./test/integration.test.mjs && bun test ./test/options.test.mjs && bun test ./test/native.test.mjs && bun test ./test/helpers.test.mjs", "test:deno": "npm run build && deno test -A ./test", "test:all": "npm run test:node && npm run test:bun && npm run test:deno" }, diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index ca76e31..8abfc05 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -5,6 +5,7 @@ import { DEFAULT_COLS, DEFAULT_ROWS, assertTimeoutClasses, + envPairs, resolveSession, resolveTimeout, timeoutsPayload, @@ -13,12 +14,13 @@ import type { TimeoutClass } from "./config.js"; import { uniqueSession } from "./ephemeral.js"; import { ExpectationError } from "./errors.js"; import { NativeRuntime } from "./native.js"; -import { envPairs, unwrap } from "./protocol.js"; import type { Cell, ClientOptions, + Cursor, OpenResult, Shell, + Size, SpawnOptions, State, } from "./types.js"; @@ -69,11 +71,15 @@ function withOperation(error: unknown, operation: string): unknown { return error; } +function optional(value: T | null | undefined): T | undefined { + return value ?? undefined; +} + class Mouse { - #client: ShellUse; + #runtime: NativeRuntime; - constructor(client: ShellUse) { - this.#client = client; + constructor(runtime: NativeRuntime) { + this.#runtime = runtime; } async click( @@ -81,35 +87,25 @@ class Mouse { y: number | null = null, opts: { onText?: string; button?: number; clicks?: number } = {}, ): Promise { - await this.#client.send({ - kind: "mouse", - action: { - op: "click", - x, - y, - on_text: opts.onText ?? null, - button: opts.button ?? 0, - clicks: opts.clicks ?? 1, - }, + await this.#runtime.mouseClick({ + x: optional(x), + y: optional(y), + onText: opts.onText, + button: opts.button ?? 0, + clicks: opts.clicks ?? 1, }); } async move(x: number, y: number): Promise { - await this.#client.send({ kind: "mouse", action: { op: "move", x, y } }); + await this.#runtime.mouseMove(x, y); } async down(x: number, y: number, opts: MouseButtonOptions = {}): Promise { - await this.#client.send({ - kind: "mouse", - action: { op: "down", x, y, button: opts.button ?? 0 }, - }); + await this.#runtime.mouseDown(x, y, opts.button ?? 0); } async up(x: number, y: number, opts: MouseButtonOptions = {}): Promise { - await this.#client.send({ - kind: "mouse", - action: { op: "up", x, y, button: opts.button ?? 0 }, - }); + await this.#runtime.mouseUp(x, y, opts.button ?? 0); } async drag( @@ -119,17 +115,11 @@ class Mouse { y2: number, opts: MouseButtonOptions = {}, ): Promise { - await this.#client.send({ - kind: "mouse", - action: { op: "drag", x1, y1, x2, y2, button: opts.button ?? 0 }, - }); + await this.#runtime.mouseDrag(x1, y1, x2, y2, opts.button ?? 0); } async scroll(direction: "up" | "down", opts: { amount?: number } = {}): Promise { - await this.#client.send({ - kind: "mouse", - action: { op: "scroll", direction, amount: opts.amount ?? 3 }, - }); + await this.#runtime.mouseScroll(direction, opts.amount ?? 3); } } @@ -147,34 +137,17 @@ export class ShellUse { } this.#options = opts; this.#runtime = new NativeRuntime(this.session); - this.mouse = new Mouse(this); + this.mouse = new Mouse(this.#runtime); } static ephemeral(prefix?: string, opts: ClientOptions = {}): ShellUse { return new ShellUse(uniqueSession(prefix), opts); } - async send(payload: unknown): Promise { - const resp = await this.#runtime.request(payload); - return unwrap(resp); - } - #timeout(cls: TimeoutClass, callTimeout?: number): number | undefined { return resolveTimeout(cls, callTimeout, this.#options); } - #withTimeout( - payload: Record, - cls: TimeoutClass, - callTimeout?: number, - ): Record { - const timeout = this.#timeout(cls, callTimeout); - if (timeout !== undefined) { - payload.timeout_ms = timeout; - } - return payload; - } - async #guard(operation: string, action: () => Promise): Promise { try { return await action(); @@ -214,11 +187,11 @@ export class ShellUse { } catch {} } - async #spawn(payload: Record, retries: number): Promise { + async #spawn(action: () => Promise, retries: number): Promise { let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { try { - return (await this.send(payload)) as OpenResult; + return await action(); } catch (error) { lastError = error; if (attempt < retries) { @@ -230,47 +203,40 @@ export class ShellUse { } async open(opts: SpawnOptions & { shell?: Shell } = {}): Promise { - const payload: Record = { - kind: "open", - shell: opts.shell ?? null, - program: null, + if (opts.timeouts) { + assertTimeoutClasses(opts.timeouts); + } + const options = { + shell: opts.shell, cols: opts.cols ?? DEFAULT_COLS, rows: opts.rows ?? DEFAULT_ROWS, - cwd: opts.cwd ?? null, + cwd: opts.cwd, env: envPairs(opts.env), + waitReady: opts.waitReady, + timeouts: timeoutsPayload(opts.timeouts), }; - if (opts.waitReady !== undefined) { - payload.wait_ready = opts.waitReady; - } - const timeouts = timeoutsPayload(opts.timeouts); - if (timeouts !== undefined) { - payload.timeouts = timeouts; - } - return this.#spawn(payload, opts.retries ?? 0); + return this.#spawn(() => this.#runtime.open(options), opts.retries ?? 0); } async run(program: string, args: string[] = [], opts: SpawnOptions = {}): Promise { - const payload: Record = { - kind: "open", - shell: null, - program: [program, ...args], + if (opts.timeouts) { + assertTimeoutClasses(opts.timeouts); + } + const options = { + program, + args, cols: opts.cols ?? DEFAULT_COLS, rows: opts.rows ?? DEFAULT_ROWS, - cwd: opts.cwd ?? null, + cwd: opts.cwd, env: envPairs(opts.env), + waitReady: opts.waitReady, + timeouts: timeoutsPayload(opts.timeouts), }; - if (opts.waitReady !== undefined) { - payload.wait_ready = opts.waitReady; - } - const timeouts = timeoutsPayload(opts.timeouts); - if (timeouts !== undefined) { - payload.timeouts = timeouts; - } - return this.#spawn(payload, opts.retries ?? 0); + return this.#spawn(() => this.#runtime.run(options), opts.retries ?? 0); } async close(): Promise { - unwrap(await this.#runtime.request({ kind: "close" })); + await this.#runtime.close(); } async closeQuiet(): Promise { @@ -280,164 +246,138 @@ export class ShellUse { } async type(text: string): Promise { - await this.send({ kind: "write", data: text }); + await this.#runtime.type(text); } async write(data: string): Promise { - await this.send({ kind: "write", data }); + await this.#runtime.write(data); } async submit(text: string | null = null): Promise { - await this.send({ kind: "submit", data: text }); + await this.#runtime.submit(optional(text)); } async press(...keys: string[]): Promise { - await this.send({ kind: "press", keys }); + await this.#runtime.press(keys); } async keys(combo: string): Promise { - await this.send({ kind: "press", keys: [combo] }); + await this.#runtime.press([combo]); } async resize(cols: number, rows: number): Promise { - await this.send({ kind: "resize", cols, rows }); + await this.#runtime.resize(cols, rows); } async signal(name: string): Promise { - await this.send({ kind: "signal", name }); + await this.#runtime.signal(name); } async kill(): Promise { - await this.send({ kind: "signal", name: "KILL" }); + await this.#runtime.signal("KILL"); } async state(): Promise { - return (await this.send({ kind: "state" })) as State; + return this.#runtime.state(); } async text(opts: { full?: boolean } = {}): Promise { - const data = (await this.send({ kind: "text", full: opts.full ?? false })) as { - text: string; - }; - return data.text; + return this.#runtime.text(opts.full ?? false); } async cells(x: number, y: number, w = 1, h = 1): Promise { - const data = (await this.send({ kind: "cells", x, y, w, h })) as { cells: Cell[] }; - return data.cells; - } - - async get(field: string): Promise { - const data = (await this.send({ kind: "get", field })) as { value: unknown }; - return data.value; + return this.#runtime.cells(x, y, w, h); } async getCommand(): Promise { - return (await this.get("command")) as string | null; + return this.#runtime.getCommand(); } async getOutput(): Promise { - return (await this.get("output")) as string | null; + return this.#runtime.getOutput(); } async getExitCode(): Promise { - return (await this.get("exit-code")) as number | null; + return this.#runtime.getExitCode(); } async getCwd(): Promise { - return (await this.get("cwd")) as string | null; + return this.#runtime.getCwd(); } - async getCursor(): Promise<{ x: number; y: number }> { - return (await this.get("cursor")) as { x: number; y: number }; + async getCursor(): Promise { + return this.#runtime.getCursor(); } - async getSize(): Promise<{ cols: number; rows: number }> { - return (await this.get("size")) as { cols: number; rows: number }; + async getSize(): Promise { + return this.#runtime.getSize(); } async screenshot(path: string | null = null, opts: { full?: boolean } = {}): Promise { - const data = (await this.send({ kind: "screenshot", full: opts.full ?? false, path })) as { - path?: string; - text?: string; - }; - return (data.path ?? data.text) as string; + return this.#runtime.screenshot({ + full: opts.full ?? false, + path: optional(path), + }); } async waitText(text: string, opts: WaitTextOptions = {}): Promise { await this.#guard("waitText", () => - this.send( - this.#withTimeout( - { - kind: "wait_text", - text, - regex: opts.regex ?? false, - full: opts.full ?? false, - not: opts.not ?? false, - }, - "text", - opts.timeout, - ), - ), + this.#runtime.waitText(text, { + regex: opts.regex ?? false, + full: opts.full ?? false, + not: opts.not ?? false, + timeoutMs: this.#timeout("text", opts.timeout), + }), ); } async waitIdle(opts: { timeout?: number } = {}): Promise { await this.#guard("waitIdle", () => - this.send(this.#withTimeout({ kind: "wait_idle" }, "idle", opts.timeout)), + this.#runtime.waitIdle(this.#timeout("idle", opts.timeout)), ); } async waitCommand(opts: { timeout?: number } = {}): Promise { await this.#guard("waitCommand", () => - this.send(this.#withTimeout({ kind: "wait_command" }, "command", opts.timeout)), + this.#runtime.waitCommand(this.#timeout("command", opts.timeout)), ); } async waitExit(opts: { timeout?: number } = {}): Promise { await this.#guard("waitExit", () => - this.send(this.#withTimeout({ kind: "wait_exit" }, "exit", opts.timeout)), + this.#runtime.waitExit(this.#timeout("exit", opts.timeout)), ); } async waitReady(opts: { timeout?: number } = {}): Promise { await this.#guard("waitReady", () => - this.send(this.#withTimeout({ kind: "wait_ready" }, "ready", opts.timeout)), + this.#runtime.waitReady(this.#timeout("ready", opts.timeout)), ); } async expectText(text: string, opts: ExpectTextOptions = {}): Promise { await this.#guard("expectText", () => - this.send( - this.#withTimeout( - { - kind: "expect_text", - text, - regex: opts.regex ?? false, - full: opts.full ?? false, - strict: opts.strict ?? true, - not: opts.not ?? false, - fg: opts.fg ?? null, - bg: opts.bg ?? null, - }, - "text", - opts.timeout, - ), - ), + this.#runtime.expectText(text, { + regex: opts.regex ?? false, + full: opts.full ?? false, + strict: opts.strict ?? true, + not: opts.not ?? false, + fg: opts.fg, + bg: opts.bg, + timeoutMs: this.#timeout("text", opts.timeout), + }), ); } async expectExitCode(code: number, opts: { timeout?: number } = {}): Promise { await this.#guard("expectExitCode", () => - this.send( - this.#withTimeout({ kind: "expect_exit_code", code }, "command", opts.timeout), - ), + this.#runtime.expectExitCode(code, this.#timeout("command", opts.timeout)), ); } async expectOutput(text: string, opts: { regex?: boolean } = {}): Promise { await this.#guard("expectOutput", () => - this.send({ kind: "expect_output", text, regex: opts.regex ?? false }), + this.#runtime.expectOutput(text, opts.regex ?? false), ); } @@ -445,16 +385,13 @@ export class ShellUse { name: string, opts: { update?: boolean; includeColors?: boolean } = {}, ): Promise { - const data = (await this.#guard("expectSnapshot", () => - this.send({ - kind: "snapshot", - name, + return this.#guard("expectSnapshot", () => + this.#runtime.snapshot(name, { update: opts.update ?? false, - include_colors: opts.includeColors ?? false, + includeColors: opts.includeColors ?? false, cwd: process.cwd(), }), - )) as { status: string }; - return data.status; + ); } async [Symbol.asyncDispose](): Promise { diff --git a/bindings/js/src/config.ts b/bindings/js/src/config.ts index a88b7af..79dd64a 100644 --- a/bindings/js/src/config.ts +++ b/bindings/js/src/config.ts @@ -65,3 +65,22 @@ export function assertTimeoutClasses(timeouts: Timeouts): void { ); } } + +export function envPairs( + env?: Record | [string, string][], +): [string, string][] { + if (!env) { + return []; + } + if (Array.isArray(env)) { + return env; + } + return Object.entries(env).map(([key, value]) => [key, coerceEnvValue(value)]); +} + +function coerceEnvValue(value: string | number | boolean): string { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + return String(value); +} diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 09b37a7..6c3c151 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -21,6 +21,7 @@ export type { ClientOptions, Color, Cursor, + EffectiveTimeouts, OpenResult, Shell, Size, diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index 36126e1..f8863c1 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -1,17 +1,41 @@ -import type { Response } from "./types.js"; +import { InternalError, UsageError, makeError } from "./errors.js"; +import type { + Cell, + Cursor, + EffectiveTimeouts, + ExpectTextOptions, + MouseClickOptions, + OpenOptions, + OpenResult, + PackedScreen, + RunOptions, + ScreenshotOptions, + Size, + SnapshotOptions, + State, + Timeouts, + WaitTextOptions, +} from "../native/index.js"; -export interface NativeSessionHandle { - name(): string; - request(payload: unknown): Promise; -} +type NativeBinding = typeof import("../native/index.js"); +type NativeSessionHandle = InstanceType; +type RuntimeOpenOptions = Omit & { shell?: string }; -interface NativeBinding { - NativeSession: new (name: string) => NativeSessionHandle; - sessions(): Promise; - closeAll(): Promise; - closeAllSync(): void; - recording(name: string): Promise; -} +const ERROR_PREFIX = "__shell_use_native_error__:"; +const USAGE_NAPI_CODES = new Set([ + "InvalidArg", + "ObjectExpected", + "StringExpected", + "NameExpected", + "FunctionExpected", + "NumberExpected", + "BooleanExpected", + "ArrayExpected", + "BigintExpected", + "DateExpected", + "ArrayBufferExpected", + "DetachableArraybufferExpected", +]); let bindingPromise: Promise | undefined; let cachedBinding: NativeBinding | undefined; @@ -31,8 +55,7 @@ function installExitHook(): void { async function importBinding(): Promise { try { - const module = await import("../native/index.js"); - const binding = module as unknown as NativeBinding; + const binding = await import("../native/index.js"); cachedBinding = binding; installExitHook(); return binding; @@ -59,6 +82,48 @@ async function loadBinding(): Promise { } } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function mapNativeError(error: unknown): Error { + const message = errorMessage(error); + const encodedAt = message.indexOf(ERROR_PREFIX); + if (encodedAt >= 0) { + const encoded = message.slice(encodedAt + ERROR_PREFIX.length); + const newline = encoded.indexOf("\n"); + if (newline >= 0) { + const mapped = makeError(encoded.slice(0, newline), encoded.slice(newline + 1)); + Object.defineProperty(mapped, "cause", { + configurable: true, + value: error, + }); + return mapped; + } + } + const code = + typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; + const isTypeError = + error instanceof TypeError || + (typeof error === "object" && + error !== null && + "name" in error && + error.name === "TypeError"); + return isTypeError || (code !== undefined && USAGE_NAPI_CODES.has(code)) + ? new UsageError(message) + : new InternalError(message); +} + +async function invoke(action: () => Promise): Promise { + try { + return await action(); + } catch (error) { + throw mapNativeError(error); + } +} + async function createSession(name: string): Promise { const binding = await loadBinding(); return new binding.NativeSession(name); @@ -71,23 +136,197 @@ export class NativeRuntime { this.#session = createSession(name); } - async request(payload: unknown): Promise { + async #call(action: (session: NativeSessionHandle) => Promise): Promise { const session = await this.#session; - return session.request(payload); + return invoke(() => action(session)); + } + + open(options?: RuntimeOpenOptions): Promise { + return this.#call((session) => session.open(options as OpenOptions | undefined)); + } + + run(options: RunOptions): Promise { + return this.#call((session) => session.run(options)); + } + + close(): Promise { + return this.#call((session) => session.close()); + } + + state(): Promise { + return this.#call((session) => session.state()); + } + + text(full = false): Promise { + return this.#call((session) => session.text(full)); + } + + /** + * Private packed snapshot. The detached Uint8Array is read-only by contract + * and contains newline-delimited full logical rows, including trailing spaces + * and blank rows. UTF-8 byte offsets are not terminal cell offsets. + */ + async packedScreen(full = false): Promise { + const screen = await this.#call((session) => session.packedScreen(full)); + return Object.freeze(screen); + } + + cells(x: number, y: number, w = 1, h = 1): Promise { + return this.#call((session) => session.cells(x, y, w, h)); + } + + getCommand(): Promise { + return this.#call((session) => session.getCommand()); + } + + getOutput(): Promise { + return this.#call((session) => session.getOutput()); + } + + getExitCode(): Promise { + return this.#call((session) => session.getExitCode()); + } + + getCwd(): Promise { + return this.#call((session) => session.getCwd()); + } + + getCursor(): Promise { + return this.#call((session) => session.getCursor()); + } + + getSize(): Promise { + return this.#call((session) => session.getSize()); + } + + write(data: string): Promise { + return this.#call((session) => session.write(data)); + } + + type(text: string): Promise { + return this.#call((session) => session.type(text)); + } + + submit(data?: string): Promise { + return this.#call((session) => session.submit(data)); + } + + press(keys: string[]): Promise { + return this.#call((session) => session.press(keys)); + } + + mouseClick(options?: MouseClickOptions): Promise { + return this.#call((session) => session.mouseClick(options)); + } + + mouseMove(x: number, y: number): Promise { + return this.#call((session) => session.mouseMove(x, y)); + } + + mouseDown(x: number, y: number, button = 0): Promise { + return this.#call((session) => session.mouseDown(x, y, button)); + } + + mouseUp(x: number, y: number, button = 0): Promise { + return this.#call((session) => session.mouseUp(x, y, button)); + } + + mouseDrag( + x1: number, + y1: number, + x2: number, + y2: number, + button = 0, + ): Promise { + return this.#call((session) => session.mouseDrag(x1, y1, x2, y2, button)); + } + + mouseScroll(direction: "up" | "down", amount = 3): Promise { + return this.#call((session) => session.mouseScroll(direction, amount)); + } + + resize(cols: number, rows: number): Promise { + return this.#call((session) => session.resize(cols, rows)); + } + + signal(name: string): Promise { + return this.#call((session) => session.signal(name)); + } + + waitText(text: string, options?: WaitTextOptions): Promise { + return this.#call((session) => session.waitText(text, options)); + } + + waitIdle(timeoutMs?: number): Promise { + return this.#call((session) => session.waitIdle(timeoutMs)); + } + + waitCommand(timeoutMs?: number): Promise { + return this.#call((session) => session.waitCommand(timeoutMs)); + } + + waitExit(timeoutMs?: number): Promise { + return this.#call((session) => session.waitExit(timeoutMs)); + } + + waitReady(timeoutMs?: number): Promise { + return this.#call((session) => session.waitReady(timeoutMs)); + } + + expectText(text: string, options?: ExpectTextOptions): Promise { + return this.#call((session) => session.expectText(text, options)); + } + + expectExitCode(code: number, timeoutMs?: number): Promise { + return this.#call((session) => session.expectExitCode(code, timeoutMs)); + } + + expectOutput(text: string, regex = false): Promise { + return this.#call((session) => session.expectOutput(text, regex)); + } + + async snapshot(name: string, options?: SnapshotOptions): Promise { + return this.#call((session) => session.snapshot(name, options)); + } + + screenshot(options?: ScreenshotOptions): Promise { + return this.#call((session) => session.screenshot(options)); + } + + panicProbe(): Promise { + return this.#call((session) => session.panicProbe()); } } export async function sessions(): Promise { const binding = await loadBinding(); - return binding.sessions(); + return invoke(() => binding.sessions()); } export async function closeAll(): Promise { const binding = await loadBinding(); - await binding.closeAll(); + await invoke(() => binding.closeAll()); } export async function recording(name: string): Promise { const binding = await loadBinding(); - return binding.recording(name); + return invoke(() => binding.recording(name)); } + +export type { + Cell as NativeCell, + Cursor as NativeCursor, + EffectiveTimeouts as NativeEffectiveTimeouts, + ExpectTextOptions as NativeExpectTextOptions, + MouseClickOptions as NativeMouseClickOptions, + OpenOptions as NativeOpenOptions, + OpenResult as NativeOpenResult, + PackedScreen as NativePackedScreen, + RunOptions as NativeRunOptions, + ScreenshotOptions as NativeScreenshotOptions, + Size as NativeSize, + SnapshotOptions as NativeSnapshotOptions, + State as NativeState, + Timeouts as NativeTimeouts, + WaitTextOptions as NativeWaitTextOptions, +}; diff --git a/bindings/js/src/protocol.ts b/bindings/js/src/protocol.ts deleted file mode 100644 index fc4b842..0000000 --- a/bindings/js/src/protocol.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { makeError } from "./errors.js"; -import type { Response } from "./types.js"; - -export function unwrap(resp: Response): unknown { - if (resp.ok) { - return resp.data; - } - throw makeError(resp.kind, resp.message || "shell-use error"); -} - -export function envPairs( - env?: Record | [string, string][], -): [string, string][] { - if (!env) { - return []; - } - if (Array.isArray(env)) { - return env; - } - return Object.entries(env).map(([key, value]) => [key, coerceEnvValue(value)]); -} - -function coerceEnvValue(value: string | number | boolean): string { - if (typeof value === "boolean") { - return value ? "true" : "false"; - } - return String(value); -} diff --git a/bindings/js/src/sessions.ts b/bindings/js/src/sessions.ts index 07653e3..fc5c96a 100644 --- a/bindings/js/src/sessions.ts +++ b/bindings/js/src/sessions.ts @@ -1,5 +1,4 @@ import { resolveSession } from "./config.js"; -import { NoSessionError } from "./errors.js"; import * as native from "./native.js"; export async function sessions(): Promise { @@ -11,14 +10,5 @@ export async function closeAll(): Promise { } export async function getRecording(session?: string): Promise { - const name = resolveSession(session); - try { - return await native.recording(name); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes(`no recording for session '${name}'`)) { - throw new NoSessionError(`no recording for session '${name}'`); - } - throw error; - } + return native.recording(resolveSession(session)); } diff --git a/bindings/js/src/types.ts b/bindings/js/src/types.ts index f0cecdf..ccef6e2 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -1,3 +1,13 @@ +import type { + Cell as NativeCell, + Cursor as NativeCursor, + EffectiveTimeouts as NativeEffectiveTimeouts, + OpenResult as NativeOpenResult, + Size as NativeSize, + State as NativeState, + Timeouts as NativeTimeouts, +} from "../native/index.js"; + export type Color = "default" | number | string; /** `"none"` is a value, not an absence: an un-underlined cell reports it. */ @@ -20,33 +30,16 @@ export type Shell = | "elvish" | "nushell"; -export interface Cursor { - x: number; - y: number; -} +export type Cursor = NativeCursor; -export interface Size { - cols: number; - rows: number; -} +export type Size = NativeSize; -export interface Cell { - x: number; - y: number; +export interface Cell extends Omit { /** The cell's grapheme; `" "` when blank, `""` for the second column of a double-width character. */ - char: string; fg: Color; bg: Color; - bold: boolean; - dim: boolean; - italic: boolean; - inverse: boolean; - invisible: boolean; - strike: boolean; /** Always `false` from the alacritty backend, which cannot report blink. */ - blink: boolean; /** Shorthand for `underline_style !== "none"`. */ - underline: boolean; underline_style: UnderlineStyle; /** * `"default"` means the underline follows the text color. Tracked @@ -56,32 +49,11 @@ export interface Cell { underline_color: Color; } -export interface State { - session_shell: string | null; - cols: number; - rows: number; - cursor: Cursor; - cwd: string | null; - last_command: string | null; - last_exit: number | null; - exited: number | null; - ready: boolean; - text: string; -} +export type EffectiveTimeouts = NativeEffectiveTimeouts; -export interface OpenResult { - shell_pid: number | null; - session: string; - ready: boolean; - recording: string; -} +export type State = NativeState; -export interface Response { - ok: boolean; - data?: unknown; - message?: string; - kind?: string; -} +export type OpenResult = NativeOpenResult; export interface SpawnOptions { cols?: number; @@ -93,13 +65,7 @@ export interface SpawnOptions { timeouts?: Timeouts; } -export interface Timeouts { - text?: number; - idle?: number; - command?: number; - exit?: number; - ready?: number; -} +export type Timeouts = NativeTimeouts; export interface TerminalArtifact { text?: string; diff --git a/bindings/js/test/conformance.test.mjs b/bindings/js/test/conformance.test.mjs index dd7b5ba..2fac9ea 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -28,7 +28,14 @@ const MAPPING = { text: [["client", "text"]], screenshot: [["client", "screenshot"]], cells: [["client", "cells"]], - get: [["client", "get"]], + get: [ + ["client", "getCommand"], + ["client", "getOutput"], + ["client", "getExitCode"], + ["client", "getCwd"], + ["client", "getCursor"], + ["client", "getSize"], + ], type: [["client", "type"]], submit: [["client", "submit"]], press: [["client", "press"]], diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 5a12593..8b9de15 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -7,13 +7,15 @@ import { test } from "node:test"; import { ExpectationError, + InternalError, NoSessionError, ShellUse, - UsageError, + closeAll, getRecording, sessions, uniqueSession, } from "../dist/index.js"; +import { NativeRuntime } from "../dist/native.js"; import { withTerminal } from "../dist/test/index.js"; const shell = process.platform === "win32" ? "pwsh" : undefined; @@ -30,16 +32,27 @@ test("echo roundtrip drives a real session", async () => { await su.expectExitCode(0); const state = await su.state(); assert.ok(state.cols > 0); - }); -}); + assert.match(await su.text(), /hello-sdk/); + assert.match(await su.getCommand(), /echo hello-sdk/); + assert.match(await su.getOutput(), /hello-sdk/); + assert.equal(await su.getExitCode(), 0); + assert.equal(typeof (await su.getCwd()), "string"); + assert.deepEqual(await su.getCursor(), state.cursor); + assert.deepEqual(await su.getSize(), { cols: state.cols, rows: state.rows }); -test("cli control requests are rejected by native sessions", async () => { - await withTerminal({ shell }, async (session) => { - await assert.rejects( - session.send({ kind: "shutdown" }), - (error) => error instanceof UsageError, - ); - assert.ok((await session.state()).cols > 0); + await su.resize(92, 26); + assert.deepEqual(await su.getSize(), { cols: 92, rows: 26 }); + assert.ok((await su.cells(0, 0, 92, 26)).length > 0); + assert.match(await su.screenshot(), /hello-sdk/); + + await su.write("echo typed-write"); + await su.keys("Enter"); + await su.waitText("typed-write"); + await su.waitCommand(); + await su.type("echo typed-type"); + await su.press("Enter"); + await su.waitText("typed-type"); + await su.waitCommand(); }); }); @@ -147,6 +160,174 @@ test("concurrent waits do not starve filesystem work", async () => { } }); +test("same-name clients share one serialized native session", async () => { + const name = uniqueSession("same-name"); + const first = new ShellUse(name); + const second = new ShellUse(name); + try { + await first.open({ shell }); + await second.submit("echo shared-native-session"); + await second.waitCommand(); + await first.waitText("shared-native-session"); + assert.match(await first.text(), /shared-native-session/); + + await second.resize(101, 27); + assert.deepEqual(await first.getSize(), { cols: 101, rows: 27 }); + } finally { + await first.closeQuiet(); + await second.closeQuiet(); + } +}); + +test("abandoning a raced promise keeps later operations serialized and safe", async () => { + const su = new ShellUse(uniqueSession("promise-abandonment")); + try { + await su.run(process.execPath, evalArgs); + await su.waitText("ready", { timeout: 2000 }); + + const pending = su + .waitText("never-visible-abandonment-marker", { timeout: 250 }) + .then( + () => null, + (error) => error, + ); + const race = await Promise.race([ + pending.then(() => "completed"), + new Promise((resolve) => setTimeout(() => resolve("abandoned"), 25)), + ]); + assert.equal(race, "abandoned"); + + const start = Date.now(); + const laterState = su.state(); + const error = await pending; + assert.ok(error instanceof ExpectationError); + const state = await laterState; + assert.ok(Date.now() - start >= 150); + assert.ok(state.text.includes("ready")); + assert.match(await su.text(), /ready/); + } finally { + await su.closeQuiet(); + } +}); + +test("private packed screens retain full UTF-8 logical rows and own their bytes", async () => { + const name = uniqueSession("packed-screen"); + const su = new ShellUse(name); + const runtime = new NativeRuntime(name); + const script = + "process.stdout.write('\\x1b[31mI\\x1b[0m🙂\\x1b[38;2;1;2;3mR\\x1b[0m\\n');" + + "setInterval(() => {}, 1000)"; + try { + const args = typeof globalThis.Deno === "undefined" ? ["-e", script] : ["eval", script]; + const opened = await su.run(process.execPath, args); + assert.ok(Object.hasOwn(opened, "shell_pid")); + await su.waitText("R", { timeout: 2000 }); + assert.equal((await su.state()).session_shell, null); + + const cells = await su.cells(0, 0, 10, 2); + const indexed = cells.find((cell) => cell.char === "I"); + const rgb = cells.find((cell) => cell.char === "R"); + assert.equal(indexed?.fg, 1); + assert.equal(rgb?.fg, "#010203"); + + const first = await runtime.packedScreen(false); + assert.ok(first.utf8 instanceof Uint8Array); + assert.ok(first.cols > 0 && first.rows > 0); + assert.equal( + Object.getOwnPropertyDescriptor(first, "utf8")?.writable, + false, + ); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const logicalRows = decoder.decode(first.utf8).split("\n"); + assert.equal(logicalRows.length, first.rows); + assert.ok(logicalRows[0].startsWith("I🙂R")); + assert.ok(logicalRows[0].endsWith(" ")); + assert.equal(logicalRows.at(-1), " ".repeat(first.cols)); + assert.ok(encoder.encode(logicalRows[0]).byteLength > first.cols); + assert.notEqual(first.utf8.indexOf("R".charCodeAt(0)), rgb?.x); + + first.utf8.fill(0); + const second = await runtime.packedScreen(false); + assert.notStrictEqual(first.utf8, second.utf8); + assert.match(decoder.decode(second.utf8), /I🙂R/); + await su.close(); + assert.equal(first.utf8.length > 0, true); + } finally { + await su.closeQuiet(); + } +}); + +test("panic containment rejects as InternalError and Node keeps running", async () => { + const runtime = new NativeRuntime(uniqueSession("panic-probe")); + await assert.rejects( + runtime.panicProbe(), + (error) => + error instanceof InternalError && + error.message.includes("intentional native panic probe"), + ); + + try { + await runtime.run({ program: process.execPath, args: evalArgs }); + await runtime.waitText("ready", { timeoutMs: 2000 }); + assert.match(await runtime.text(), /ready/); + } finally { + await runtime.close(); + } +}); + +test("typed mouse and signal operations execute against a real program", async () => { + const su = new ShellUse(uniqueSession("typed-input-signal")); + try { + await su.run(process.execPath, evalArgs); + await su.waitText("ready", { timeout: 2000 }); + await su.mouse.move(1, 1); + await su.mouse.down(1, 1); + await su.mouse.up(1, 1); + await su.mouse.drag(1, 1, 2, 2); + await su.mouse.scroll("down", { amount: 1 }); + await su.mouse.click(1, 1); + await su.signal("KILL"); + assert.match(await su.text(), /ready/); + await su.close(); + await assert.rejects(su.state(), (error) => error instanceof NoSessionError); + } finally { + await su.closeQuiet(); + } +}); + +test("closeAll interrupts in-flight waits and closes every process-local session", async () => { + const terminals = [ + new ShellUse(uniqueSession("close-all-a")), + new ShellUse(uniqueSession("close-all-b")), + ]; + await Promise.all(terminals.map((terminal) => terminal.run(process.execPath, evalArgs))); + await Promise.all( + terminals.map((terminal) => terminal.waitText("ready", { timeout: 2000 })), + ); + + const waiting = terminals[0] + .waitText("never-visible-close-all-marker", { timeout: 30_000 }) + .then( + () => null, + (error) => error, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + const start = Date.now(); + await closeAll(); + assert.ok(Date.now() - start < 2000); + assert.ok((await waiting) instanceof ExpectationError); + + const open = await sessions(); + for (const terminal of terminals) { + assert.ok(!open.includes(terminal.session)); + await assert.rejects( + terminal.state(), + (error) => error instanceof NoSessionError, + ); + } +}); + test("sessions lists an open session", async () => { const su = new ShellUse(uniqueSession("nodetest")); await su.open({ shell }); @@ -168,10 +349,6 @@ test("close evicts the session and retains its recording", async () => { assert.ok(!(await sessions()).includes(name)); await assert.rejects(session.state(), (error) => error instanceof NoSessionError); - await assert.rejects( - session.send({ kind: "shutdown" }), - (error) => error instanceof UsageError, - ); assert.match(await getRecording(name), /retained-recording/); await assert.rejects( getRecording(uniqueSession("missing-recording")), diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs new file mode 100644 index 0000000..99c180b --- /dev/null +++ b/bindings/js/test/native.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { ShellUse } from "../dist/index.js"; + +test("generated native declarations expose typed operations", async () => { + const declarations = await readFile( + new URL("../native/index.d.ts", import.meta.url), + "utf8", + ); + for (const type of [ + "OpenResult", + "State", + "Cursor", + "Size", + "Timeouts", + "Cell", + "PackedScreen", + ]) { + assert.match(declarations, new RegExp(`export (?:interface|type) ${type}\\b`)); + } + for (const method of [ + "open", + "run", + "close", + "state", + "text", + "cells", + "getCommand", + "write", + "type", + "submit", + "press", + "mouseClick", + "resize", + "signal", + "waitText", + "expectText", + "snapshot", + "screenshot", + "packedScreen", + "panicProbe", + ]) { + assert.match(declarations, new RegExp(`\\b${method}\\(`)); + } + assert.doesNotMatch(declarations, /\brequest\(/); + assert.doesNotMatch(declarations, /Promise/); + assert.match( + declarations, + /interface PackedScreen \{[\s\S]*readonly cols: number[\s\S]*readonly rows: number[\s\S]*readonly utf8: Uint8Array[\s\S]*\}/, + ); + assert.doesNotMatch(declarations, /\bBuffer\b/); + assert.doesNotMatch(declarations, /interface PackedScreen \{[\s\S]*\bbuffer:/); +}); + +test("public facade omits generic request dispatchers", async () => { + const declarations = await readFile( + new URL("../dist/client.d.ts", import.meta.url), + "utf8", + ); + assert.equal("send" in ShellUse.prototype, false); + assert.equal("get" in ShellUse.prototype, false); + assert.doesNotMatch(declarations, /\bsend\(/); + assert.doesNotMatch(declarations, /\bget\(/); + assert.doesNotMatch(declarations, /payload|request dispatcher/); +}); diff --git a/bindings/js/test/options.test.mjs b/bindings/js/test/options.test.mjs index 4425d18..5f843a4 100644 --- a/bindings/js/test/options.test.mjs +++ b/bindings/js/test/options.test.mjs @@ -1,21 +1,24 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { ExpectationError, ShellUse, uniqueSession } from "../dist/index.js"; -import { resolveTimeout, timeoutsPayload } from "../dist/config.js"; -import { envPairs } from "../dist/protocol.js"; - -class CapturingClient extends ShellUse { - constructor(...args) { - super(...args); - this.sent = []; - this.reply = undefined; - } - async send(payload) { - this.sent.push(payload); - return this.reply; - } -} +import { + ExpectationError, + ShellUse, + UsageError, + uniqueSession, +} from "../dist/index.js"; +import { + envPairs, + resolveTimeout, + timeoutsPayload, +} from "../dist/config.js"; +import { NativeRuntime } from "../dist/native.js"; + +const shell = process.platform === "win32" ? "pwsh" : undefined; +const evalArgs = + typeof globalThis.Deno === "undefined" + ? ["-e", "console.log('ready'); setInterval(() => {}, 1000)"] + : ["eval", "console.log('ready'); setInterval(() => {}, 1000)"]; const ALL_TIMEOUT_ENV_VARS = [ "SHELL_USE_TIMEOUT_MS", @@ -54,38 +57,30 @@ function withEnv(vars, fn) { test("resolveTimeout returns undefined when nothing is configured", () => { for (const cls of CLASSES) { - assert.equal(resolveTimeout(cls), undefined, `expected ${cls} -> undefined`); - assert.equal(resolveTimeout(cls, undefined, {}), undefined); + assert.equal(resolveTimeout(cls), undefined); assert.equal(resolveTimeout(cls, undefined, { timeouts: {} }), undefined); } }); -test("resolveTimeout precedence: per-call beats timeouts[class] beats omitted", () => { +test("resolveTimeout precedence is per-call then client class", () => { assert.equal(resolveTimeout("text", 111, { timeouts: { text: 333 } }), 111); assert.equal(resolveTimeout("text", undefined, { timeouts: { text: 333 } }), 333); assert.equal(resolveTimeout("text", undefined, { timeouts: { idle: 333 } }), undefined); + assert.equal(resolveTimeout("text", 0), 0); }); -test("resolveTimeout never reads an environment variable (engine owns ranks 3-5)", () => { +test("resolveTimeout leaves environment fallback to the engine", () => { const vars = Object.fromEntries(ALL_TIMEOUT_ENV_VARS.map((name) => [name, "1234"])); withEnv(vars, () => { for (const cls of CLASSES) { - assert.equal(resolveTimeout(cls), undefined, `expected ${cls} to ignore env`); + assert.equal(resolveTimeout(cls), undefined); } }); }); -test("resolveTimeout honours an explicit zero per-call timeout", () => { - assert.equal(resolveTimeout("text", 0), 0); -}); - -test("timeoutsPayload is undefined when empty so the field is omitted", () => { +test("timeoutsPayload omits empty values and keeps known classes", () => { assert.equal(timeoutsPayload(undefined), undefined); assert.equal(timeoutsPayload({}), undefined); - assert.equal(timeoutsPayload({ text: undefined, ready: undefined }), undefined); -}); - -test("timeoutsPayload keeps only the classes that are set", () => { assert.deepEqual(timeoutsPayload({ text: 1000, command: 2000 }), { text: 1000, command: 2000, @@ -93,151 +88,165 @@ test("timeoutsPayload keeps only the classes that are set", () => { assert.deepEqual(timeoutsPayload({ ready: 45000 }), { ready: 45000 }); }); -test("wait/expect omit timeout_ms when no client timeout is configured", async () => { - const c = new CapturingClient("s"); - await c.waitText("x"); - await c.waitIdle(); - await c.waitCommand(); - await c.waitExit(); - await c.waitReady(); - await c.expectText("x"); - await c.expectExitCode(0); - for (const payload of c.sent) { - assert.ok( - !Object.prototype.hasOwnProperty.call(payload, "timeout_ms"), - `${payload.kind} should omit timeout_ms`, - ); - } -}); - -test("client-level timeouts are sent as an explicit timeout_ms per class", async () => { - const c = new CapturingClient("s", { - timeouts: { text: 1000, idle: 2000, command: 3000, exit: 4000, ready: 5000 }, - }); - await c.waitText("x"); - await c.waitIdle(); - await c.waitCommand(); - await c.waitExit(); - await c.waitReady(); - await c.expectText("x"); - await c.expectExitCode(0); - const byKind = Object.fromEntries(c.sent.map((p) => [p.kind, p.timeout_ms])); - assert.deepEqual(byKind, { - wait_text: 1000, - wait_idle: 2000, - wait_command: 3000, - wait_exit: 4000, - wait_ready: 5000, - expect_text: 1000, // expectText resolves through the `text` class - expect_exit_code: 3000, // expectExitCode resolves through the `command` class - }); -}); - -test("a per-call timeout beats the client-level class default", async () => { - const c = new CapturingClient("s", { timeouts: { text: 1000, command: 9000 } }); - await c.waitText("x", { timeout: 50 }); - await c.expectExitCode(0, { timeout: 75 }); - assert.equal(c.sent[0].timeout_ms, 50); - assert.equal(c.sent[1].timeout_ms, 75); -}); - -test("expectExitCode sends a timeout only when given one", async () => { - const c = new CapturingClient("s"); - await c.expectExitCode(0); - assert.deepEqual(c.sent[0], { kind: "expect_exit_code", code: 0 }); - await c.expectExitCode(1, { timeout: 250 }); - assert.deepEqual(c.sent[1], { kind: "expect_exit_code", code: 1, timeout_ms: 250 }); -}); - -test("open omits the timeouts object when no session defaults are set", async () => { - const c = new CapturingClient("s"); - await c.open(); - assert.ok( - !Object.prototype.hasOwnProperty.call(c.sent[0], "timeouts"), - "open should omit an empty timeouts object", - ); -}); - -test("open seeds only the session-default classes that are set", async () => { - const c = new CapturingClient("s"); - await c.open({ timeouts: { command: 60000, ready: 45000 } }); - assert.deepEqual(c.sent[0].timeouts, { command: 60000, ready: 45000 }); -}); - -test("run seeds session-default timeouts too", async () => { - const c = new CapturingClient("s"); - await c.run("vim", [], { timeouts: { text: 1500 } }); - assert.deepEqual(c.sent[0].timeouts, { text: 1500 }); -}); - -test("envPairs coerces record values to strings", () => { +test("envPairs coerces records and preserves pair arrays", () => { assert.deepEqual(envPairs({ A: "1", B: 2, C: true, D: false }), [ ["A", "1"], ["B", "2"], ["C", "true"], ["D", "false"], ]); -}); - -test("envPairs passes array form through and handles empty input", () => { assert.deepEqual(envPairs([["X", "Y"]]), [["X", "Y"]]); assert.deepEqual(envPairs(), []); }); -test("close is idempotent and needs no prior open", async () => { - const su = new ShellUse(uniqueSession("close-idempotency")); - await su.close(); - await su.close(); +test("unknown timeout classes are rejected before native dispatch", async () => { + assert.throws(() => timeoutsPayload({ comand: 100 }), /comand/); + assert.throws(() => new ShellUse("s", { timeouts: { txt: 100 } }), /txt/); + const su = new ShellUse(uniqueSession("bad-open-timeout")); + await assert.rejects(() => su.open({ timeouts: { txt: 100 } }), /txt/); await su.closeQuiet(); }); -test("an ephemeral client closes cleanly without ever opening", async () => { - const su = ShellUse.ephemeral("never-opened"); - await su.close(); - await su.close(); +test("session timeout defaults are visible in typed state", async () => { + const su = new ShellUse(uniqueSession("typed-timeouts")); + try { + await su.open({ + shell, + timeouts: { text: 1234, idle: 2345, command: 3456, exit: 4567, ready: 5678 }, + }); + assert.deepEqual((await su.state()).timeouts, { + text: 1234, + idle: 2345, + command: 3456, + exit: 4567, + ready: 5678, + }); + } finally { + await su.closeQuiet(); + } }); -test("timeoutsPayload rejects an unknown class", () => { - assert.throws(() => timeoutsPayload({ comand: 100 }), /comand/); +test("client and per-call timeout precedence reaches native waits", async () => { + const su = new ShellUse(uniqueSession("typed-timeout-precedence"), { + timeouts: { text: 120 }, + }); + try { + await su.run(process.execPath, evalArgs, { timeouts: { text: 2000 } }); + await su.waitText("ready", { timeout: 2000 }); + await assert.rejects( + su.waitText("missing-client-timeout"), + (error) => + error instanceof ExpectationError && + error.message.includes("timed out after 120ms"), + ); + await assert.rejects( + su.waitText("missing-call-timeout", { timeout: 30 }), + (error) => + error instanceof ExpectationError && + error.message.includes("timed out after 30ms"), + ); + } finally { + await su.closeQuiet(); + } }); -test("open rejects an unknown timeout class", async () => { - const c = new CapturingClient("s"); - await assert.rejects(() => c.open({ timeouts: { txt: 100 } }), /txt/); +test("wait and expectation failures retain operation names", async () => { + const su = new ShellUse(uniqueSession("typed-operation-errors")); + try { + await su.run(process.execPath, evalArgs); + await su.waitText("ready", { timeout: 2000 }); + await assert.rejects( + su.waitText("missing-wait", { timeout: 20 }), + (error) => + error instanceof ExpectationError && + error.message.startsWith("waitText: "), + ); + await assert.rejects( + su.expectText("missing-expect", { timeout: 20 }), + (error) => + error instanceof ExpectationError && + error.message.startsWith("expectText: "), + ); + } finally { + await su.closeQuiet(); + } }); -test("the constructor rejects an unknown timeout class", () => { - assert.throws(() => new ShellUse("s", { timeouts: { txt: 100 } }), /txt/); - assert.doesNotThrow(() => new ShellUse("s", { timeouts: { text: 100 } })); - assert.doesNotThrow(() => new ShellUse("s")); -}); +test("typed validation and engine usage errors map to UsageError", async () => { + const invalid = new ShellUse(uniqueSession("typed-invalid-size")); + await assert.rejects( + invalid.open({ cols: -1 }), + (error) => error instanceof UsageError && error.kind === "usage", + ); + await invalid.closeQuiet(); -test("timeoutsPayload keeps every known class", () => { - assert.deepEqual(timeoutsPayload({ text: 1, ready: 2 }), { text: 1, ready: 2 }); -}); + const invalidShell = new ShellUse(uniqueSession("typed-invalid-shell")); + await assert.rejects( + invalidShell.open({ shell: "definitely-not-a-shell" }), + (error) => error instanceof UsageError && error.kind === "usage", + ); + await invalidShell.closeQuiet(); -test("every wait and expect method tags its failure with the operation", async () => { - const cases = { - waitText: ["x"], - waitIdle: [], - waitCommand: [], - waitExit: [], - waitReady: [], - expectText: ["x"], - expectExitCode: [0], - expectOutput: ["x"], - expectSnapshot: ["x"], - }; - for (const [name, args] of Object.entries(cases)) { - const c = new CapturingClient("s"); - c.send = async () => { - throw new ExpectationError("boom"); - }; + const su = new ShellUse(uniqueSession("typed-invalid-regex")); + try { + await su.run(process.execPath, evalArgs); await assert.rejects( - () => c[name](...args), - (error) => - error instanceof ExpectationError && error.message.startsWith(`${name}: `), - `${name} did not tag its failure`, + su.expectText("(", { regex: true, timeout: 20 }), + (error) => error instanceof UsageError && error.kind === "usage", ); + } finally { + await su.closeQuiet(); } }); + +test("expectExitCode rejects unsafe JavaScript numbers as UsageError", async () => { + const su = ShellUse.ephemeral("invalid-exit-code"); + try { + for (const code of [ + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 2_147_483_648, + -2_147_483_649, + ]) { + await assert.rejects( + su.expectExitCode(code), + (error) => + error instanceof UsageError && + error.message.includes("code must be an integer"), + `expected ${String(code)} to be rejected`, + ); + } + } finally { + await su.closeQuiet(); + } +}); + +test("N-API argument conversion errors map to UsageError", async () => { + const runtime = new NativeRuntime(uniqueSession("native-argument-errors")); + const invalidCalls = [ + () => runtime.run(null), + () => runtime.write(42), + () => runtime.resize("80", 24), + () => runtime.text("false"), + () => runtime.press("Enter"), + ]; + try { + for (const call of invalidCalls) { + await assert.rejects( + call, + (error) => error instanceof UsageError && error.exitCode === 2, + ); + } + } finally { + await runtime.close(); + } +}); + +test("close remains idempotent without a prior open", async () => { + const su = ShellUse.ephemeral("never-opened"); + await su.close(); + await su.close(); + await su.closeQuiet(); +}); diff --git a/bindings/js/test/protocol.test.mjs b/bindings/js/test/protocol.test.mjs deleted file mode 100644 index 8fcaa44..0000000 --- a/bindings/js/test/protocol.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { ShellUse } from "../dist/index.js"; - -class CapturingClient extends ShellUse { - constructor(...args) { - super(...args); - this.sent = []; - this.reply = undefined; - } - async send(payload) { - this.sent.push(payload); - return this.reply; - } -} - -test("open builds a complete open payload", async () => { - const c = new CapturingClient("s"); - await c.open({ cols: 120, rows: 40, env: { K: "V" } }); - assert.deepEqual(c.sent[0], { - kind: "open", - shell: null, - program: null, - cols: 120, - rows: 40, - cwd: null, - env: [["K", "V"]], - }); -}); - -test("run sets program and null shell", async () => { - const c = new CapturingClient("s"); - await c.run("vim", ["file.txt"]); - assert.deepEqual(c.sent[0].program, ["vim", "file.txt"]); - assert.equal(c.sent[0].shell, null); -}); - -test("keys wraps a single combo, press forwards tokens", async () => { - const c = new CapturingClient("s"); - await c.keys("Ctrl+a"); - await c.press("Escape", "Enter"); - assert.deepEqual(c.sent[0], { kind: "press", keys: ["Ctrl+a"] }); - assert.deepEqual(c.sent[1], { kind: "press", keys: ["Escape", "Enter"] }); -}); - -test("kill maps to signal KILL", async () => { - const c = new CapturingClient("s"); - await c.kill(); - assert.deepEqual(c.sent[0], { kind: "signal", name: "KILL" }); -}); - -test("getExitCode uses the kebab field name", async () => { - const c = new CapturingClient("s"); - c.reply = { value: 0 }; - await c.getExitCode(); - assert.deepEqual(c.sent[0], { kind: "get", field: "exit-code" }); -}); - -test("mouse click builds a nested action", async () => { - const c = new CapturingClient("s"); - await c.mouse.click(null, null, { onText: "OK", clicks: 2 }); - assert.deepEqual(c.sent[0], { - kind: "mouse", - action: { op: "click", x: null, y: null, on_text: "OK", button: 0, clicks: 2 }, - }); -}); - -test("waitText omits timeout_ms when no client timeout is configured", async () => { - const c = new CapturingClient("s"); - await c.waitText("done"); - assert.deepEqual(c.sent[0], { - kind: "wait_text", - text: "done", - regex: false, - full: false, - not: false, - }); -}); - -test("waitText carries a client-level text timeout as timeout_ms", async () => { - const c = new CapturingClient("s", { timeouts: { text: 1500 } }); - await c.waitText("done"); - assert.deepEqual(c.sent[0], { - kind: "wait_text", - text: "done", - regex: false, - full: false, - not: false, - timeout_ms: 1500, - }); -}); - -test("waitCommand omits timeout_ms when unset", async () => { - const c = new CapturingClient("s"); - await c.waitCommand(); - assert.deepEqual(c.sent[0], { kind: "wait_command" }); -}); - -test("expectText is strict by default and forwards colors", async () => { - const c = new CapturingClient("s"); - await c.expectText("ERR", { fg: "#ff0000" }); - assert.equal(c.sent[0].strict, true); - assert.equal(c.sent[0].fg, "#ff0000"); - assert.ok(!("timeout_ms" in c.sent[0])); -}); diff --git a/bindings/python/README.md b/bindings/python/README.md index f07bac9..9d649df 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -43,24 +43,26 @@ async with ShellUse("vim-session") as su: Every failure maps to one of the engine's error kinds: -| Exception | Exit code | Meaning | -| --- | --- | --- | -| `ExpectationError` | 1 | an `expect`/`wait` condition was not met | -| `UsageError` | 2 | invalid argument (e.g. a bad regex) | -| `NoSessionError` | 3 | no active session | -| `InternalError` | 5 | internal engine error | +| Exception | Exit code | Meaning | +| ------------------ | --------- | ---------------------------------------- | +| `ExpectationError` | 1 | an `expect`/`wait` condition was not met | +| `UsageError` | 2 | invalid argument (e.g. a bad regex) | +| `NoSessionError` | 3 | no active session | +| `InternalError` | 5 | internal engine error | All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError` on failure. Assertion errors include the current visible terminal content. ## API -`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. `open()` and `run()` accept `wait_ready=`, `retries=`, and `timeouts=`. The timeout classes are `text`, `idle`, `command`, `exit`, and `ready`; `timeouts=` sets session defaults, the constructor takes the same `Timeouts` (or a dict) as a client-wide default. Unknown class names raise. -`ShellUse.ephemeral(prefix=None, **kwargs)` binds a client to a unique, process local session name. `artifacts={"dir": ..., "on_failure": ...}` attaches the terminal contents to an `ExpectationError`. +`ShellUse.ephemeral(prefix=None, **kwargs)` binds a client to a unique, +process-local session name. `artifacts={"dir": ..., "on_failure": ...}` +attaches the terminal contents to an `ExpectationError`. `shell_use.testing` has helpers for terminal tests: `create_terminal`, `terminal` (an async context manager), `close_all_tracked`, `DEFAULT_SHELL`, and `terminal_snapshot`. @@ -78,14 +80,13 @@ Each terminal is uniquely named, so parallel workers don't collide. `set_termina ## Cancellation and recordings -Cancelling a promise does not cancel the underlying Rust operation. Operations for single sessoins wait for completion (ex: `close()`, `close_all()`). +Cancelling a task does not cancel the underlying Rust operation. Operations for single sessoins wait for completion (ex: `close()`, `close_all()`). -Closing a session removes it from `sessions()`, but keeps its recording. `get_recordings()` can read that recording for the rest of the -process. The 1024 most recently closed session have their recordings retained. +Closing a session removes it from `sessions()`, but keeps its recording. `get_recording()` can read that recording for the rest of the process. The 1024 most recently closed sessions have their recordings retained. ## Configuration -| Variable | Purpose | -| --- | --- | -| `SHELL_USE_SESSION` | default session name | +| Variable | Purpose | +| ------------------------------ | --------------------------------------------------------------------------- | +| `SHELL_USE_SESSION` | default session name | | `SHELL_USE_TIMEOUT__MS` | fallback timeout for one class (`TEXT`, `IDLE`, `COMMAND`, `EXIT`, `READY`) | diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml index 8e9aa86..0db93c9 100644 --- a/bindings/python/native/Cargo.toml +++ b/bindings/python/native/Cargo.toml @@ -14,10 +14,15 @@ crate-type = ["cdylib", "rlib"] test = false [dependencies] +anyhow.workspace = true shell-use.workspace = true -pyo3 = { version = "0.28", features = ["abi3-py38", "generate-import-lib"] } -pythonize = "0.28" -serde_json.workspace = true +pyo3 = { version = "0.28" } +pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } +tokio = { version = "1", features = ["rt-multi-thread"] } [features] -extension-module = ["pyo3/extension-module"] +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 index 6e32e98..4e756e7 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -1,11 +1,45 @@ -use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError, PyValueError}; +use std::any::Any; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use pyo3::exceptions::PyException; use pyo3::prelude::*; -use pyo3::types::PyModule; -use pythonize::{depythonize, pythonize}; +use pyo3::types::{PyBool, PyBytes, PyDict, PyInt, PyList, PyMemoryView, PyModule, PyTuple}; use shell_use::runtime::global_registry; +use shell_use::shell::Shell; +use shell_use::{ + Cell, CellColor, Cursor, ErrorKind, MouseAction, OpenOptions, OpenResult, Operation, + OperationResult, PackedScreen, RunOptions, ScreenshotResult, ShellUseError, Size, + SnapshotResult, State, Timeouts, +}; + +pyo3::create_exception!( + shell_use._native, + NativeAssertionError, + PyException, + "Native assertion failure." +); +pyo3::create_exception!( + shell_use._native, + NativeUsageError, + PyException, + "Native usage error." +); +pyo3::create_exception!( + shell_use._native, + NativeNoSessionError, + PyException, + "Native session was not found." +); +pyo3::create_exception!( + shell_use._native, + NativeInternalError, + PyException, + "Native internal error." +); #[pyclass(module = "shell_use._native", frozen)] struct NativeSession { + #[pyo3(get)] name: String, } @@ -13,62 +47,1262 @@ struct NativeSession { impl NativeSession { #[new] fn new(name: String) -> Self { - NativeSession { name } + Self { name } + } + + #[pyo3(signature = ( + shell, + cols, + rows, + cwd, + env, + wait_ready, + text_timeout, + idle_timeout, + command_timeout, + exit_timeout, + ready_timeout + ))] + #[allow(clippy::too_many_arguments)] + fn open<'py>( + &self, + py: Python<'py>, + shell: Option, + cols: Bound<'py, PyAny>, + rows: Bound<'py, PyAny>, + cwd: Option, + env: Vec<(String, String)>, + wait_ready: Option, + text_timeout: Option>, + idle_timeout: Option>, + command_timeout: Option>, + exit_timeout: Option>, + ready_timeout: Option>, + ) -> PyResult> { + let cols = capture_integer(&cols); + let rows = capture_integer(&rows); + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_open( + &name, + Operation::Open(OpenOptions { + shell: parse_shell(shell.as_deref())?, + cols: integer_u16(&cols, "cols")?, + rows: integer_u16(&rows, "rows")?, + cwd, + env, + wait_ready, + 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")?, + }, + }), + ) + }, + open_to_py, + ) + } + + #[pyo3(signature = ( + program, + args, + cols, + rows, + cwd, + env, + wait_ready, + text_timeout, + idle_timeout, + command_timeout, + exit_timeout, + ready_timeout + ))] + #[allow(clippy::too_many_arguments)] + fn run<'py>( + &self, + py: Python<'py>, + program: String, + args: Vec, + cols: Bound<'py, PyAny>, + rows: Bound<'py, PyAny>, + cwd: Option, + env: Vec<(String, String)>, + wait_ready: Option, + text_timeout: Option>, + idle_timeout: Option>, + command_timeout: Option>, + exit_timeout: Option>, + ready_timeout: Option>, + ) -> PyResult> { + let cols = capture_integer(&cols); + let rows = capture_integer(&rows); + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_open( + &name, + Operation::Run(RunOptions { + program, + args, + cols: integer_u16(&cols, "cols")?, + rows: integer_u16(&rows, "rows")?, + cwd, + env, + wait_ready, + 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")?, + }, + }), + ) + }, + open_to_py, + ) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Close), + unit_to_py, + ) + } + + fn state<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_state(&name, Operation::State), + state_to_py, + ) + } + + fn text<'py>(&self, py: Python<'py>, full: bool) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_text(&name, Operation::Text { full }), + string_to_py, + ) + } + + fn packed_screen<'py>(&self, py: Python<'py>, full: bool) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_packed_screen(&name, 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_cells( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || execute_command(&name, Operation::GetCommand), + optional_string_to_py, + ) + } + + fn get_output<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_output(&name, Operation::GetOutput), + optional_string_to_py, + ) + } + + fn get_exit_code<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_exit_code(&name, Operation::GetExitCode), + optional_i32_to_py, + ) + } + + fn get_cwd<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_cwd(&name, Operation::GetCwd), + optional_string_to_py, + ) + } + + fn get_cursor<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_cursor(&name, Operation::GetCursor), + cursor_to_py, + ) + } + + fn get_size<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_size(&name, Operation::GetSize), + size_to_py, + ) + } + + fn write<'py>(&self, py: Python<'py>, data: String) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Write { data }), + unit_to_py, + ) + } + + #[pyo3(name = "type")] + fn type_text<'py>(&self, py: Python<'py>, text: String) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Write { data: text }), + unit_to_py, + ) + } + + #[pyo3(signature = (data))] + fn submit<'py>(&self, py: Python<'py>, data: Option) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Submit { data }), + unit_to_py, + ) + } + + fn press<'py>(&self, py: Python<'py>, keys: Vec) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Press { keys }), + unit_to_py, + ) + } + + fn keys<'py>(&self, py: Python<'py>, combo: String) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Press { keys: vec![combo] }), + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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, + button: integer_u8(&button, "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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::Mouse { + action: MouseAction::Down { + x: integer_u16(&x, "x")?, + y: integer_u16(&y, "y")?, + button: integer_u8(&button, "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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::Mouse { + action: MouseAction::Up { + x: integer_u16(&x, "x")?, + y: integer_u16(&y, "y")?, + button: integer_u8(&button, "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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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")?, + button: integer_u8(&button, "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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::Signal { name: signal }), + unit_to_py, + ) + } + + fn kill<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::Signal { + name: "KILL".to_string(), + }, + ) + }, + unit_to_py, + ) + } + + #[pyo3(signature = (text, regex, full, not_, timeout_ms))] + fn wait_text<'py>( + &self, + py: Python<'py>, + text: String, + regex: bool, + full: bool, + not_: bool, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitText { + text, + regex, + full, + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + not: not_, + }, + ) + }, + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitCommand { + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) } - #[getter] - fn name(&self) -> &str { - &self.name + #[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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitExit { + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) } - fn request<'py>(&self, py: Python<'py>, payload: Bound<'py, PyAny>) -> PyResult> { - let request: serde_json::Value = - depythonize(&payload).map_err(|e| PyValueError::new_err(e.to_string()))?; + #[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 name = self.name.clone(); - let response = py.detach(move || global_registry().response_value(&name, request)); - let response = serde_json::to_value(&response) - .map_err(|e| PyRuntimeError::new_err(format!("failed to encode response: {e}")))?; - Ok(pythonize(py, &response) - .map_err(|e| PyRuntimeError::new_err(e.to_string()))? - .unbind()) + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitReady { + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) } - fn recording(&self, py: Python<'_>) -> PyResult { + #[pyo3(signature = (text, regex, full, strict, not_, fg, bg, timeout_ms))] + #[allow(clippy::too_many_arguments)] + fn expect_text<'py>( + &self, + py: Python<'py>, + text: String, + regex: bool, + full: bool, + strict: bool, + not_: bool, + fg: Option, + bg: Option, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); let name = self.name.clone(); - py.detach(move || global_registry().recording(&name)) - .map_err(|e| io_error_to_py(&e)) + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::ExpectText { + text, + regex, + full, + strict, + not: not_, + fg, + bg, + 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 name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + 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 name = self.name.clone(); + future_blocking( + py, + move || execute_unit(&name, Operation::ExpectOutput { text, regex }), + unit_to_py, + ) + } + + #[pyo3(signature = (name, update, include_colors, cwd))] + fn snapshot<'py>( + &self, + py: Python<'py>, + name: String, + update: bool, + include_colors: bool, + cwd: Option, + ) -> PyResult> { + let session = self.name.clone(); + future_blocking( + py, + move || { + execute_snapshot( + &session, + Operation::Snapshot { + name, + update, + include_colors, + cwd, + }, + ) + }, + snapshot_to_py, + ) + } + + #[pyo3(signature = (path, full))] + fn screenshot<'py>( + &self, + py: Python<'py>, + path: Option, + full: bool, + ) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_screenshot(&name, Operation::Screenshot { full, path }), + screenshot_to_py, + ) + } + + fn recording<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || { + global_registry() + .recording(&name) + .map_err(io_error_to_shell_error) + }, + string_to_py, + ) } } #[pyfunction] -fn sessions(py: Python<'_>) -> Vec { - py.detach(|| global_registry().sessions()) +fn sessions(py: Python<'_>) -> PyResult> { + future_blocking(py, || Ok(global_registry().sessions()), string_list_to_py) } #[pyfunction] -fn close_all(py: Python<'_>) { - py.detach(|| global_registry().close_all()); +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 recording(py: Python<'_>, name: String) -> PyResult { - py.detach(move || global_registry().recording(&name)) - .map_err(|e| io_error_to_py(&e)) +fn panic_probe(py: Python<'_>) -> PyResult> { + future_blocking( + py, + || -> Result<(), ShellUseError> { + panic!("shell-use Python panic probe"); + }, + unit_to_py, + ) } -fn io_error_to_py(error: &std::io::Error) -> PyErr { +#[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(ShellUseError::internal(format!( + "native Python operation panicked: {}", + panic_message(payload.as_ref()) + ))) + }) + }) + .await + .map_err(|error| ShellUseError::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: ShellUseError) -> 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::(), + }; + PyErr::from_type(exception, error.message) + }) +} + +fn io_error_to_shell_error(error: std::io::Error) -> ShellUseError { if error.kind() == std::io::ErrorKind::NotFound { - PyFileNotFoundError::new_err(error.to_string()) + ShellUseError::new(ErrorKind::NoSession, error.to_string()) } else { - PyRuntimeError::new_err(error.to_string()) + ShellUseError::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 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, ShellUseError> { + value.map(|value| integer_u64(value, name)).transpose() +} + +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(|| { + ShellUseError::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(|| { + ShellUseError::usage(format!( + "{name} must be an integer in the range {}..={}", + i32::MIN, + i32::MAX + )) + }) +} + +fn parse_shell(value: Option<&str>) -> Result, ShellUseError> { + 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(ShellUseError::usage(format!( + "unknown shell '{other}'; expected bash, powershell, pwsh, cmd, fish, zsh, xonsh, elvish, or nushell" + ))); + } + }; + Ok(shell) + }) + .transpose() +} + +fn unexpected_result(expected: &str) -> ShellUseError { + ShellUseError::internal(format!( + "native Python binding expected {expected}, but the engine returned another result type" + )) +} + +fn execute_unit(name: &str, operation: Operation) -> Result<(), ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected_result("no value")), } } +fn execute_open(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Open(value) => Ok(value), + _ => Err(unexpected_result("an open result")), + } +} + +fn execute_state(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::State(value) => Ok(value), + _ => Err(unexpected_result("terminal state")), + } +} + +fn execute_text(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Text(value) => Ok(value), + _ => Err(unexpected_result("terminal text")), + } +} + +fn execute_packed_screen(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::PackedScreen(value) => Ok(value), + _ => Err(unexpected_result("a packed screen")), + } +} + +fn execute_cells(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Cells(value) => Ok(value), + _ => Err(unexpected_result("terminal cells")), + } +} + +fn execute_command(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Command(value) => Ok(value), + _ => Err(unexpected_result("the last command")), + } +} + +fn execute_output(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Output(value) => Ok(value), + _ => Err(unexpected_result("the last output")), + } +} + +fn execute_exit_code(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::ExitCode(value) => Ok(value), + _ => Err(unexpected_result("the last exit code")), + } +} + +fn execute_cwd(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Cwd(value) => Ok(value), + _ => Err(unexpected_result("the current working directory")), + } +} + +fn execute_cursor(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Cursor(value) => Ok(value), + _ => Err(unexpected_result("the cursor position")), + } +} + +fn execute_size(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Size(value) => Ok(value), + _ => Err(unexpected_result("the terminal size")), + } +} + +fn execute_snapshot(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Snapshot(value) => Ok(value), + _ => Err(unexpected_result("a snapshot status")), + } +} + +fn execute_screenshot(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::Screenshot(value) => Ok(value), + _ => Err(unexpected_result("a screenshot")), + } +} + +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 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)?; + 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 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("cwd", value.cwd)?; + result.set_item("last_command", value.last_command)?; + result.set_item("last_exit", value.last_exit)?; + result.set_item("exited", value.exited)?; + result.set_item("ready", value.ready)?; + 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)?; + 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 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/scripts/generate_stubs.py b/bindings/python/scripts/generate_stubs.py new file mode 100644 index 0000000..3e10fde --- /dev/null +++ b/bindings/python/scripts/generate_stubs.py @@ -0,0 +1,33 @@ +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/shell_use/_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/src/shell_use/_native.pyi b/bindings/python/src/shell_use/_native.pyi index eca38e3..19f3e81 100644 --- a/bindings/python/src/shell_use/_native.pyi +++ b/bindings/python/src/shell_use/_native.pyi @@ -1,21 +1,97 @@ -from typing import Any, Dict +# This file is automatically generated by pyo3_stub_gen +# ruff: noqa: E501, F401, F403, F405 +import builtins +import typing +__all__ = [ + "NativeAssertionError", + "NativeInternalError", + "NativeNoSessionError", + "NativeSession", + "NativeUsageError", + "close_all", + "panic_probe", + "recording", + "sessions", +] -class NativeSession: - def __init__(self, name: str) -> None: ... +class NativeAssertionError(builtins.Exception): + r""" + Native assertion failure. + """ + ... - @property - def name(self) -> str: ... +class NativeInternalError(builtins.Exception): + r""" + Native internal error. + """ + ... - def request(self, payload: Dict[str, Any]) -> Dict[str, Any]: ... +class NativeNoSessionError(builtins.Exception): + r""" + Native session was not found. + """ + ... - def recording(self) -> str: ... +@typing.final +class NativeSession: + @property + def name(self) -> builtins.str: ... + def __new__(cls, name: str) -> NativeSession: ... + def open(self, shell: typing.Optional[str], cols: int, rows: int, cwd: typing.Optional[str], env: typing.List[typing.Tuple[str, str]], wait_ready: typing.Optional[bool], 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]) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + def run(self, program: str, args: typing.List[str], cols: int, rows: int, cwd: typing.Optional[str], env: typing.List[typing.Tuple[str, str]], wait_ready: typing.Optional[bool], 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]) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + def close(self) -> typing.Awaitable[None]: ... + def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + def text(self, full: bool) -> typing.Awaitable[str]: ... + def packed_screen(self, full: bool) -> typing.Awaitable[typing.Tuple[memoryview, int, int]]: + r""" + 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_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + 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 keys(self, combo: 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_text(self, text: str, regex: bool, full: bool, not_: 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 expect_text(self, text: str, regex: bool, full: bool, strict: bool, not_: bool, fg: typing.Optional[str], bg: typing.Optional[str], 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 snapshot(self, name: str, update: bool, include_colors: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... + def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... + def recording(self) -> typing.Awaitable[str]: ... +class NativeUsageError(builtins.Exception): + r""" + Native usage error. + """ + ... -def sessions() -> list[str]: ... +def _close_all_blocking() -> None: ... +def close_all() -> typing.Awaitable[None]: ... -def close_all() -> None: ... +def panic_probe() -> typing.Awaitable[None]: ... +def recording(name: str) -> typing.Awaitable[str]: ... -def recording(name: str) -> str: ... +def sessions() -> typing.Awaitable[typing.List[str]]: ... diff --git a/bindings/python/src/shell_use/_protocol.py b/bindings/python/src/shell_use/_protocol.py deleted file mode 100644 index 9471664..0000000 --- a/bindings/python/src/shell_use/_protocol.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union - -from .errors import make_error - -EnvLike = Union[Mapping[str, str], Iterable[Tuple[str, str]], None] - - -def unwrap(resp: Dict[str, Any]) -> Any: - if resp.get("ok"): - return resp.get("data") - raise make_error(resp.get("kind"), resp.get("message") or "shell-use error") - - -def env_pairs(env: EnvLike) -> List[List[str]]: - if env is None: - return [] - items = env.items() if isinstance(env, Mapping) else env - return [[str(k), str(v)] for k, v in items] diff --git a/bindings/python/src/shell_use/client.py b/bindings/python/src/shell_use/client.py index 0f69cea..456e4be 100644 --- a/bindings/python/src/shell_use/client.py +++ b/bindings/python/src/shell_use/client.py @@ -1,32 +1,57 @@ from __future__ import annotations -import asyncio import atexit import os import time -from typing import Any, Callable, Dict, List, Optional, TypeVar +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + TypeVar, + Union, +) from . import _config as cfg from . import _ephemeral as ephemeral from . import _native as native -from ._protocol import EnvLike, env_pairs, unwrap -from .errors import ExpectationError, NoSessionError, TerminalArtifact +from .errors import ( + ExpectationError, + InternalError, + NoSessionError, + TerminalArtifact, + UsageError, +) from .types import Cell, State, Timeouts _TERMINAL_MARKER = "Terminal content:\n" +_TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready") _T = TypeVar("_T") +EnvLike = Union[Mapping[str, str], Iterable[Tuple[str, str]], None] -async def _to_thread(func: Callable[..., _T], *args: Any) -> _T: - # asyncio.to_thread requires Python 3.9. - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, func, *args) +async def _await_native(awaitable: Awaitable[_T]) -> _T: + try: + return await awaitable + except native.NativeAssertionError as error: + raise ExpectationError(str(error)) from error + except native.NativeUsageError as error: + raise UsageError(str(error)) from error + except native.NativeNoSessionError as error: + raise NoSessionError(str(error)) from error + except native.NativeInternalError as error: + raise InternalError(str(error)) from error def _atexit_close_all() -> None: try: - native.close_all() + native._close_all_blocking() except Exception: pass @@ -34,6 +59,18 @@ def _atexit_close_all() -> None: atexit.register(_atexit_close_all) +def _env_pairs(env: EnvLike) -> List[Tuple[str, str]]: + if env is None: + return [] + items = env.items() if isinstance(env, Mapping) else env + return [(str(key), str(value)) for key, value in items] + + +def _session_timeout_values(timeouts: object) -> Tuple[Optional[int], ...]: + normalized = cfg.session_timeouts_payload(timeouts) or {} + return tuple(normalized.get(class_name) for class_name in _TIMEOUT_CLASSES) + + def _extract_terminal_text(message: Optional[str]) -> Optional[str]: if not message: return None @@ -43,7 +80,6 @@ def _extract_terminal_text(message: Optional[str]) -> Optional[str]: return message[index + len(_TERMINAL_MARKER):].rstrip("\n") or None - class _Mouse: def __init__(self, client: "ShellUse") -> None: self._c = client @@ -57,57 +93,28 @@ async def click( button: int = 0, clicks: int = 1, ) -> None: - await self._c.send( - { - "kind": "mouse", - "action": { - "op": "click", - "x": x, - "y": y, - "on_text": on_text, - "button": button, - "clicks": clicks, - }, - } + await self._c._await( + self._c._native.mouse_click(x, y, on_text, button, clicks) ) async def move(self, x: int, y: int) -> None: - await self._c.send({"kind": "mouse", "action": {"op": "move", "x": x, "y": y}}) + await self._c._await(self._c._native.mouse_move(x, y)) async def down(self, x: int, y: int, *, button: int = 0) -> None: - await self._c.send( - {"kind": "mouse", "action": {"op": "down", "x": x, "y": y, "button": button}} - ) + await self._c._await(self._c._native.mouse_down(x, y, button)) async def up(self, x: int, y: int, *, button: int = 0) -> None: - await self._c.send( - {"kind": "mouse", "action": {"op": "up", "x": x, "y": y, "button": button}} - ) + await self._c._await(self._c._native.mouse_up(x, y, button)) async def drag( self, x1: int, y1: int, x2: int, y2: int, *, button: int = 0 ) -> None: - await self._c.send( - { - "kind": "mouse", - "action": { - "op": "drag", - "x1": x1, - "y1": y1, - "x2": x2, - "y2": y2, - "button": button, - }, - } + await self._c._await( + self._c._native.mouse_drag(x1, y1, x2, y2, button) ) async def scroll(self, direction: str, *, amount: int = 3) -> None: - await self._c.send( - { - "kind": "mouse", - "action": {"op": "scroll", "direction": direction, "amount": amount}, - } - ) + await self._c._await(self._c._native.mouse_scroll(direction, amount)) class ShellUse: @@ -133,21 +140,17 @@ def ephemeral(cls, prefix: Optional[str] = None, **kwargs: Any) -> "ShellUse": def session(self) -> str: return self._session - def _with_timeout( - self, payload: Dict[str, Any], class_name: str, call: Optional[int] - ) -> Dict[str, Any]: - value = cfg.resolve_timeout(class_name, call=call, timeouts=self._timeouts) - if value is not None: - payload["timeout_ms"] = value - return payload + def _timeout(self, class_name: str, call: Optional[int]) -> Optional[int]: + return cfg.resolve_timeout( + class_name, call=call, timeouts=self._timeouts + ) - async def send(self, payload: Dict[str, Any]) -> Any: - resp = await _to_thread(self._native.request, payload) - return unwrap(resp) + async def _await(self, awaitable: Awaitable[_T]) -> _T: + return await _await_native(awaitable) - async def _guarded(self, op_name: str, payload: Dict[str, Any]) -> Any: + async def _guarded(self, op_name: str, awaitable: Awaitable[_T]) -> _T: try: - return await self.send(payload) + return await self._await(awaitable) except ExpectationError as error: error.message = f"{op_name}: {error.message}" error.args = (error.message,) @@ -185,23 +188,29 @@ async def _write_artifact_svg(self) -> Optional[str]: return None os.makedirs(directory, exist_ok=True) self._artifact_counter += 1 - n = self._artifact_counter timestamp = time.strftime("%Y%m%d-%H%M%S") - filename = "{}-{}-{}.svg".format(self._session, timestamp, n) + filename = "{}-{}-{}.svg".format( + self._session, timestamp, self._artifact_counter + ) path = os.path.join(directory, filename) await self.screenshot(path) return path - async def _spawn(self, payload: Dict[str, Any], retries: int) -> Dict[str, Any]: + async def _spawn( + self, + start: Callable[[], Awaitable[Dict[str, Any]]], + retries: int, + ) -> Dict[str, Any]: attempts = retries + 1 if retries > 0 else 1 for attempt in range(attempts): try: - return await self.send(payload) + return await self._await(start()) except Exception: if attempt + 1 < attempts: await self.close_quiet() else: raise + raise AssertionError("unreachable") async def open( self, @@ -215,21 +224,20 @@ async def open( timeouts: Optional[Timeouts] = None, retries: int = 0, ) -> Dict[str, Any]: - payload = { - "kind": "open", - "shell": shell, - "program": None, - "cols": cols, - "rows": rows, - "cwd": cwd, - "env": env_pairs(env), - } # type: Dict[str, Any] - if wait_ready is not None: - payload["wait_ready"] = wait_ready - session_timeouts = cfg.session_timeouts_payload(timeouts) - if session_timeouts is not None: - payload["timeouts"] = session_timeouts - return await self._spawn(payload, retries) + env_values = _env_pairs(env) + timeout_values = _session_timeout_values(timeouts) + return await self._spawn( + lambda: self._native.open( + shell, + cols, + rows, + cwd, + env_values, + wait_ready, + *timeout_values, + ), + retries, + ) async def run( self, @@ -243,24 +251,24 @@ async def run( timeouts: Optional[Timeouts] = None, retries: int = 0, ) -> Dict[str, Any]: - payload = { - "kind": "open", - "shell": None, - "program": [program, *args], - "cols": cols, - "rows": rows, - "cwd": cwd, - "env": env_pairs(env), - } # type: Dict[str, Any] - if wait_ready is not None: - payload["wait_ready"] = wait_ready - session_timeouts = cfg.session_timeouts_payload(timeouts) - if session_timeouts is not None: - payload["timeouts"] = session_timeouts - return await self._spawn(payload, retries) + env_values = _env_pairs(env) + timeout_values = _session_timeout_values(timeouts) + return await self._spawn( + lambda: self._native.run( + program, + list(args), + cols, + rows, + cwd, + env_values, + wait_ready, + *timeout_values, + ), + retries, + ) async def close(self) -> None: - await self.send({"kind": "close"}) + await self._await(self._native.close()) async def close_quiet(self) -> None: try: @@ -269,63 +277,67 @@ async def close_quiet(self) -> None: pass async def type(self, text: str) -> None: - await self.send({"kind": "write", "data": text}) + await self._await(self._native.type(text)) async def write(self, data: str) -> None: - await self.send({"kind": "write", "data": data}) + await self._await(self._native.write(data)) async def submit(self, text: Optional[str] = None) -> None: - await self.send({"kind": "submit", "data": text}) + await self._await(self._native.submit(text)) async def press(self, *keys: str) -> None: - await self.send({"kind": "press", "keys": list(keys)}) + await self._await(self._native.press(list(keys))) async def keys(self, combo: str) -> None: - await self.send({"kind": "press", "keys": [combo]}) + await self._await(self._native.keys(combo)) async def resize(self, cols: int, rows: int) -> None: - await self.send({"kind": "resize", "cols": cols, "rows": rows}) + await self._await(self._native.resize(cols, rows)) async def signal(self, name: str) -> None: - await self.send({"kind": "signal", "name": name}) + await self._await(self._native.signal(name)) async def kill(self) -> None: - await self.send({"kind": "signal", "name": "KILL"}) + await self._await(self._native.kill()) async def state(self) -> State: - return State.from_dict(await self.send({"kind": "state"})) + return State.from_dict(await self._await(self._native.state())) async def text(self, *, full: bool = False) -> str: - return (await self.send({"kind": "text", "full": full}))["text"] + return await self._await(self._native.text(full)) - async def cells(self, x: int, y: int, w: int = 1, h: int = 1) -> List[Cell]: - data = await self.send({"kind": "cells", "x": x, "y": y, "w": w, "h": h}) - return [Cell(**c) for c in data["cells"]] + async def _packed_screen( + self, *, full: bool = False + ) -> Tuple[memoryview, int, int]: + """Return owned UTF-8 logical rows and terminal cell dimensions.""" + return await self._await(self._native.packed_screen(full)) - async def get(self, field: str) -> Any: - return (await self.send({"kind": "get", "field": field}))["value"] + async def cells(self, x: int, y: int, w: int = 1, h: int = 1) -> List[Cell]: + data = await self._await(self._native.cells(x, y, w, h)) + return [Cell(**cell) for cell in data] async def get_command(self) -> Optional[str]: - return await self.get("command") + return await self._await(self._native.get_command()) async def get_output(self) -> Optional[str]: - return await self.get("output") + return await self._await(self._native.get_output()) async def get_exit_code(self) -> Optional[int]: - return await self.get("exit-code") + return await self._await(self._native.get_exit_code()) async def get_cwd(self) -> Optional[str]: - return await self.get("cwd") + return await self._await(self._native.get_cwd()) async def get_cursor(self) -> Dict[str, int]: - return await self.get("cursor") + return await self._await(self._native.get_cursor()) async def get_size(self) -> Dict[str, int]: - return await self.get("size") + return await self._await(self._native.get_size()) - async def screenshot(self, path: Optional[str] = None, *, full: bool = False) -> str: - data = await self.send({"kind": "screenshot", "full": full, "path": path}) - return data.get("path") or data.get("text") + async def screenshot( + self, path: Optional[str] = None, *, full: bool = False + ) -> str: + return await self._await(self._native.screenshot(path, full)) async def wait_text( self, @@ -338,41 +350,33 @@ async def wait_text( ) -> None: await self._guarded( "wait_text", - self._with_timeout( - { - "kind": "wait_text", - "text": text, - "regex": regex, - "full": full, - "not": not_, - }, - "text", - timeout, + self._native.wait_text( + text, regex, full, not_, self._timeout("text", timeout) ), ) async def wait_idle(self, *, timeout: Optional[int] = None) -> None: await self._guarded( "wait_idle", - self._with_timeout({"kind": "wait_idle"}, "idle", timeout), + self._native.wait_idle(self._timeout("idle", timeout)), ) async def wait_command(self, *, timeout: Optional[int] = None) -> None: await self._guarded( "wait_command", - self._with_timeout({"kind": "wait_command"}, "command", timeout), + self._native.wait_command(self._timeout("command", timeout)), ) async def wait_exit(self, *, timeout: Optional[int] = None) -> None: await self._guarded( "wait_exit", - self._with_timeout({"kind": "wait_exit"}, "exit", timeout), + self._native.wait_exit(self._timeout("exit", timeout)), ) async def wait_ready(self, *, timeout: Optional[int] = None) -> None: await self._guarded( "wait_ready", - self._with_timeout({"kind": "wait_ready"}, "ready", timeout), + self._native.wait_ready(self._timeout("ready", timeout)), ) async def expect_text( @@ -389,50 +393,46 @@ async def expect_text( ) -> None: await self._guarded( "expect_text", - self._with_timeout( - { - "kind": "expect_text", - "text": text, - "regex": regex, - "full": full, - "strict": strict, - "not": not_, - "fg": fg, - "bg": bg, - }, - "text", - timeout, + self._native.expect_text( + text, + regex, + full, + strict, + not_, + fg, + bg, + self._timeout("text", timeout), ), ) - async def expect_exit_code(self, code: int, *, timeout: Optional[int] = None) -> None: + async def expect_exit_code( + self, code: int, *, timeout: Optional[int] = None + ) -> None: await self._guarded( "expect_exit_code", - self._with_timeout( - {"kind": "expect_exit_code", "code": code}, "command", timeout + self._native.expect_exit_code( + code, self._timeout("command", timeout) ), ) async def expect_output(self, text: str, *, regex: bool = False) -> None: await self._guarded( - "expect_output", {"kind": "expect_output", "text": text, "regex": regex} + "expect_output", self._native.expect_output(text, regex) ) async def expect_snapshot( - self, name: str, *, update: bool = False, include_colors: bool = False + self, + name: str, + *, + update: bool = False, + include_colors: bool = False, ) -> str: - return ( - await self._guarded( - "expect_snapshot", - { - "kind": "snapshot", - "name": name, - "update": update, - "include_colors": include_colors, - "cwd": os.getcwd(), - }, - ) - )["status"] + return await self._guarded( + "expect_snapshot", + self._native.snapshot( + name, update, include_colors, os.getcwd() + ), + ) async def __aenter__(self) -> "ShellUse": return self @@ -442,16 +442,20 @@ async def __aexit__(self, *exc: Any) -> None: async def sessions() -> List[str]: - return await _to_thread(native.sessions) + return await _await_native(native.sessions()) async def close_all() -> None: - await _to_thread(native.close_all) + await _await_native(native.close_all()) async def get_recording(session: Optional[str] = None) -> str: name = cfg.resolve_session(session) try: - return await _to_thread(native.recording, name) - except FileNotFoundError: - raise NoSessionError(f"no recording for session '{name}'") + 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/shell_use/types.py b/bindings/python/src/shell_use/types.py index afe6d5e..86c25f5 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -53,6 +53,7 @@ class State: last_exit: Optional[int] exited: Optional[int] ready: bool + timeouts: Timeouts text: str session_shell: Optional[str] @@ -67,6 +68,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "State": last_exit=d.get("last_exit"), exited=d.get("exited"), ready=d.get("ready", False), + timeouts=Timeouts(**d["timeouts"]), text=d.get("text", ""), session_shell=d.get("session_shell"), ) diff --git a/bindings/python/stub-gen/.gitignore b/bindings/python/stub-gen/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/bindings/python/stub-gen/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/bindings/python/stub-gen/Cargo.lock b/bindings/python/stub-gen/Cargo.lock new file mode 100644 index 0000000..52e3ee0 --- /dev/null +++ b/bindings/python/stub-gen/Cargo.lock @@ -0,0 +1,1003 @@ +# 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 = "shell-use-python-stub-gen" +version = "0.0.0" +dependencies = [ + "pyo3-stub-gen", + "pyo3-stub-gen-derive", +] + +[[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 = "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 new file mode 100644 index 0000000..c97a643 --- /dev/null +++ b/bindings/python/stub-gen/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "shell-use-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 new file mode 100644 index 0000000..5fe0cd1 --- /dev/null +++ b/bindings/python/stub-gen/src/main.rs @@ -0,0 +1,282 @@ +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("shell_use._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("shell_use._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 + + class NativeSession: + def __new__(cls, name: str) -> NativeSession: ... + + def open( + self, + shell: typing.Optional[str], + cols: int, + rows: int, + cwd: typing.Optional[str], + env: typing.List[typing.Tuple[str, str]], + wait_ready: typing.Optional[bool], + 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], + ) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + + def run( + self, + program: str, + args: typing.List[str], + cols: int, + rows: int, + cwd: typing.Optional[str], + env: typing.List[typing.Tuple[str, str]], + wait_ready: typing.Optional[bool], + 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], + ) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + + def close(self) -> typing.Awaitable[None]: ... + def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... + def text(self, full: bool) -> typing.Awaitable[str]: ... + 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_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + 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 keys(self, combo: 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_text( + self, + text: str, + regex: bool, + full: bool, + not_: 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 expect_text( + self, + text: str, + regex: bool, + full: bool, + strict: bool, + not_: bool, + fg: typing.Optional[str], + bg: typing.Optional[str], + 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 snapshot( + self, + name: str, + update: bool, + include_colors: bool, + cwd: typing.Optional[str], + ) -> typing.Awaitable[str]: ... + def screenshot(self, path: typing.Optional[str], full: bool) -> 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 = "shell_use._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/shell_use/_native.pyi"); + let stub_info = pyo3_stub_gen::StubInfo::from_pyproject_toml(pyproject)?; + let module = stub_info.modules.get("shell_use._native").ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "missing shell_use._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_conformance.py b/bindings/python/tests/test_conformance.py index 8919ba4..bc5d640 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -17,7 +17,14 @@ "text": [("client", "text")], "screenshot": [("client", "screenshot")], "cells": [("client", "cells")], - "get": [("client", "get")], + "get": [ + ("client", "get_command"), + ("client", "get_output"), + ("client", "get_exit_code"), + ("client", "get_cwd"), + ("client", "get_cursor"), + ("client", "get_size"), + ], "type": [("client", "type")], "submit": [("client", "submit")], "press": [("client", "press")], diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index 2c80849..de60f02 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -1,4 +1,5 @@ import asyncio +import gc import os import shutil import sys @@ -9,13 +10,16 @@ import shell_use from shell_use import ( ExpectationError, + InternalError, NoSessionError, ShellUse, + Timeouts, UsageError, get_recording, testing, unique_session, ) +from shell_use.client import _panic_probe SHELL = "pwsh" if sys.platform == "win32" else None @@ -41,13 +45,59 @@ async def scenario(): run(scenario()) - def test_cli_control_requests_are_rejected(self): + def test_invalid_shell_is_a_typed_usage_error(self): async def scenario(): - async with self._client() as su: - await su.open(shell=SHELL) + su = self._client() + try: with self.assertRaises(UsageError): - await su.send({"kind": "shutdown"}) - self.assertGreater((await su.state()).cols, 0) + await su.open(shell="not-a-real-shell") + finally: + await su.close_quiet() + + run(scenario()) + + def test_effective_timeouts_are_exposed_in_typed_state(self): + async def scenario(): + expected = Timeouts( + text=1234, + idle=2345, + command=3456, + exit=4567, + ready=5678, + ) + async with self._client() as su: + await su.open(shell=SHELL, timeouts=expected) + self.assertEqual((await su.state()).timeouts, expected) + + run(scenario()) + + def test_invalid_numeric_arguments_are_typed_usage_errors(self): + async def scenario(): + su = self._client() + cases = [ + ("u16-negative", lambda: su.resize(-1, 24)), + ("u16-too-large", lambda: su.cells(2**16, 0)), + ("u16-bool", lambda: su.resize(True, 24)), + ("u8-negative", lambda: su.mouse.down(0, 0, button=-1)), + ("u8-too-large", lambda: su.mouse.down(0, 0, button=2**8)), + ("u8-bool", lambda: su.mouse.down(0, 0, button=True)), + ("u64-negative", lambda: su.wait_idle(timeout=-1)), + ("u64-too-large", lambda: su.wait_idle(timeout=2**64)), + ("u64-huge", lambda: su.wait_idle(timeout=10**1000)), + ("u64-bool", lambda: su.wait_idle(timeout=True)), + ( + "i32-too-small", + lambda: su.expect_exit_code(-(2**31) - 1), + ), + ("i32-too-large", lambda: su.expect_exit_code(2**31)), + ("i32-bool", lambda: su.expect_exit_code(True)), + ("non-integer", lambda: su.resize(object(), 24)), + ] + for label, call in cases: + with self.subTest(label=label): + with self.assertRaises(UsageError) as raised: + await call() + self.assertIn("must be an integer", str(raised.exception)) run(scenario()) @@ -125,14 +175,190 @@ async def scenario(): self.assertNotIn(name, await shell_use.sessions()) with self.assertRaises(NoSessionError): await su.state() - with self.assertRaises(UsageError): - await su.send({"kind": "shutdown"}) self.assertIn("retained-recording", await get_recording(name)) with self.assertRaises(NoSessionError): await get_recording(unique_session("missing-recording")) run(scenario()) + def test_same_name_clients_share_typed_operations(self): + async def scenario(): + name = unique_session("same-name") + first = ShellUse(name) + second = ShellUse(name) + try: + await first.open(shell=SHELL) + await second.submit("echo shared-session") + await first.wait_command() + self.assertIn("shared-session", await second.text()) + self.assertIn(name, await shell_use.sessions()) + finally: + await first.close_quiet() + await second.close_quiet() + + run(scenario()) + + def test_close_all_cleans_process_local_sessions(self): + async def scenario(): + first = self._client() + second = self._client() + await first.open(shell=SHELL) + await second.open(shell=SHELL) + await shell_use.close_all() + self.assertNotIn(first.session, await shell_use.sessions()) + self.assertNotIn(second.session, await shell_use.sessions()) + with self.assertRaises(NoSessionError): + await first.state() + with self.assertRaises(NoSessionError): + await second.state() + + run(scenario()) + + def test_close_all_interrupts_in_flight_waits(self): + async def scenario(): + su = self._client() + await su.open(shell=SHELL) + wait = asyncio.create_task( + su.wait_text("never-visible", timeout=60_000) + ) + await asyncio.sleep(0.05) + + await asyncio.wait_for(shell_use.close_all(), timeout=2) + with self.assertRaises(ExpectationError) as raised: + await wait + self.assertIn("session exited before", str(raised.exception)) + self.assertNotIn(su.session, await shell_use.sessions()) + + run(scenario()) + + def test_typed_operation_results_keep_public_shapes(self): + async def scenario(): + async with self._client() as su: + opened = await su.open(shell=SHELL, cols=92, rows=28) + self.assertEqual(opened["session"], su.session) + self.assertIn("ready", opened) + await su.resize(90, 27) + await su.type("echo typed-input") + await su.press("Enter") + await su.wait_command() + await su.expect_output("typed-input", regex=False) + await su.expect_text("typed-input", strict=False) + self.assertEqual(await su.get_exit_code(), 0) + self.assertIn("echo typed-input", await su.get_command()) + self.assertIn("typed-input", await su.get_output()) + self.assertIsInstance(await su.get_cwd(), (str, type(None))) + self.assertEqual(await su.get_size(), {"cols": 90, "rows": 27}) + cursor = await su.get_cursor() + self.assertEqual(set(cursor), {"x", "y"}) + cells = await su.cells(0, 0, 2, 1) + self.assertTrue(cells) + self.assertIsInstance(cells[0].fg, (str, int)) + self.assertIsInstance(cells[0].bg, (str, int)) + self.assertIn("typed-input", await su.screenshot()) + await su.mouse.click(0, 0) + await su.mouse.move(1, 1) + await su.mouse.down(1, 1) + await su.mouse.up(1, 1) + await su.mouse.drag(0, 0, 1, 1) + await su.mouse.scroll("down", amount=1) + + run(scenario()) + + def test_signal_and_wait_exit_are_typed_operations(self): + async def scenario(): + async with self._client() as su: + await su.run( + sys.executable, + "-c", + "import time; print('signal-ready', flush=True); time.sleep(60)", + ) + await su.wait_text("signal-ready", timeout=5000) + with self.assertRaises(ExpectationError): + await su.wait_exit(timeout=30) + await su.signal("KILL") + + run(scenario()) + + def test_packed_screen_preserves_logical_utf8_rows(self): + async def scenario(): + su = self._client() + await su.run( + sys.executable, + "-c", + "import sys,time; " + "sys.stdout.buffer.write(" + "bytes.fromhex('c3a9e7958c5820200d0a0d0a5a')); " + "sys.stdout.flush(); time.sleep(60)", + cols=8, + rows=4, + wait_ready=False, + ) + await su.wait_text("X", timeout=5000) + view, cols, rows = await su._packed_screen() + self.assertIsInstance(view, memoryview) + self.assertTrue(view.readonly) + self.assertEqual((cols, rows), (8, 4)) + before = bytes(view) + text = before.decode("utf-8") + lines = text.split("\n") + self.assertEqual(len(lines), rows) + self.assertTrue(lines[0].startswith("é界X")) + self.assertTrue(lines[0].endswith(" ")) + self.assertEqual(lines[1], " " * cols) + self.assertTrue(lines[2].startswith("Z")) + self.assertEqual(lines[3], " " * cols) + + x_byte_offset = before.index(b"X") + self.assertEqual(x_byte_offset, len("é界".encode("utf-8"))) + self.assertNotEqual(x_byte_offset, 3) + self.assertEqual((await su.cells(3, 0))[0].char, "X") + + await su.close() + del su + gc.collect() + self.assertEqual(bytes(view), before) + if len(view): + with self.assertRaises(TypeError): + view[0] = 0 + + run(scenario()) + + def test_panic_probe_maps_to_internal_error_and_process_survives(self): + async def scenario(): + with self.assertRaises(InternalError) as raised: + await _panic_probe() + self.assertIn("panic probe", str(raised.exception)) + async with self._client() as su: + await su.open(shell=SHELL) + self.assertGreater((await su.state()).cols, 0) + + run(scenario()) + + def test_cancelling_wait_keeps_native_operation_serialized(self): + async def scenario(): + async with self._client() as su: + await su.run( + sys.executable, + "-c", + "import time; print('cancel-ready', flush=True); time.sleep(60)", + ) + await su.wait_text("cancel-ready", timeout=5000) + wait = asyncio.create_task( + su.wait_text("never-visible", timeout=350) + ) + await asyncio.sleep(0.05) + wait.cancel() + with self.assertRaises(asyncio.CancelledError): + await wait + + started = asyncio.get_running_loop().time() + state = await su.state() + elapsed = asyncio.get_running_loop().time() - started + self.assertGreater(state.cols, 0) + self.assertGreaterEqual(elapsed, 0.15) + + run(scenario()) + def test_any_shared_handle_can_close_a_reopened_named_session(self): async def scenario(): name = unique_session("shared-close") diff --git a/bindings/python/tests/test_native_api.py b/bindings/python/tests/test_native_api.py new file mode 100644 index 0000000..8a1780f --- /dev/null +++ b/bindings/python/tests/test_native_api.py @@ -0,0 +1,116 @@ +import asyncio +import inspect +import unittest +from pathlib import Path + +from shell_use import _native +from shell_use import unique_session + + +class _IndexValue: + def __init__(self, value): + self.value = value + + def __index__(self): + return self.value + + +class NativeSurfaceTests(unittest.TestCase): + def test_native_session_has_only_typed_terminal_methods(self): + session = _native.NativeSession(unique_session("surface")) + self.assertFalse(hasattr(session, "request")) + for name in ( + "open", + "run", + "close", + "state", + "text", + "packed_screen", + "cells", + "get_command", + "get_output", + "get_exit_code", + "get_cwd", + "get_cursor", + "get_size", + "write", + "type", + "submit", + "press", + "keys", + "mouse_click", + "mouse_move", + "mouse_down", + "mouse_up", + "mouse_drag", + "mouse_scroll", + "resize", + "signal", + "kill", + "wait_text", + "wait_idle", + "wait_command", + "wait_exit", + "wait_ready", + "expect_text", + "expect_exit_code", + "expect_output", + "snapshot", + "screenshot", + "recording", + ): + self.assertTrue(hasattr(session, name), name) + + def test_native_error_classes_are_distinct(self): + classes = { + _native.NativeAssertionError, + _native.NativeUsageError, + _native.NativeNoSessionError, + _native.NativeInternalError, + } + self.assertEqual(len(classes), 4) + for exception in classes: + self.assertTrue(issubclass(exception, Exception)) + + def test_invalid_integer_is_reported_from_native_awaitable(self): + async def scenario(): + session = _native.NativeSession(unique_session("native-number")) + awaitable = session.resize(-1, 24) + self.assertTrue(inspect.isawaitable(awaitable)) + with self.assertRaises(_native.NativeUsageError): + await awaitable + + asyncio.run(scenario()) + + def test_index_objects_are_accepted_before_range_validation(self): + async def scenario(): + session = _native.NativeSession(unique_session("native-index")) + with self.assertRaises(_native.NativeNoSessionError): + await session.resize(_IndexValue(80), _IndexValue(24)) + + asyncio.run(scenario()) + + def test_unsigned_values_above_i64_are_accepted(self): + async def scenario(): + session = _native.NativeSession(unique_session("native-u64")) + with self.assertRaises(_native.NativeNoSessionError): + await session.wait_idle(2**63) + + asyncio.run(scenario()) + + +class NativeStubTests(unittest.TestCase): + def test_native_futures_are_annotated_as_awaitables(self): + stub = ( + Path(__file__).resolve().parents[1] + / "src" + / "shell_use" + / "_native.pyi" + ).read_text(encoding="utf-8") + self.assertNotIn("async def ", stub) + self.assertIn("def open(", stub) + self.assertIn("typing.Awaitable[", stub) + + +if __name__ == "__main__": + unittest.main() diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index 275ed11..8701f59 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -13,18 +13,31 @@ def run(coro): return asyncio.run(coro) -class _CapturingClient(client.ShellUse): - def __init__(self, *a, **k): - super().__init__(*a, **k) - self.sent = [] +class _FakeNative: + def __init__(self): + self.calls = [] self.reply = {} - self.raise_kind = None + self.error = None + + def __getattr__(self, name): + def invoke(*args): + self.calls.append((name, args)) + + async def complete(): + if self.error is not None: + raise self.error + return self.reply + + return complete() - async def send(self, payload): - self.sent.append(payload) - if self.raise_kind is not None: - raise self.raise_kind - return self.reply + return invoke + + +class _CapturingClient(client.ShellUse): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fake = _FakeNative() + self._native = self.fake class TimeoutResolutionTests(unittest.TestCase): @@ -43,20 +56,15 @@ def test_timeouts_field_used_when_no_call(self): cfg.resolve_timeout("command", timeouts={"command": 222}), 222 ) - def test_timeouts_field_beats_omitted(self): - both = {"command": 222} - self.assertEqual(cfg.resolve_timeout("command", timeouts=both), 222) - self.assertIsNone(cfg.resolve_timeout("idle", timeouts=both)) - def test_none_entry_in_timeouts_falls_through(self): self.assertIsNone(cfg.resolve_timeout("text", timeouts={"text": None})) def test_normalize_timeouts_accepts_dataclass_and_mapping(self): self.assertIsNone(cfg.normalize_timeouts(None)) self.assertEqual(cfg.normalize_timeouts({"command": 42})["command"], 42) - norm = cfg.normalize_timeouts(Timeouts(command=42)) - self.assertEqual(norm["command"], 42) - self.assertIsNone(norm["idle"]) + normalized = cfg.normalize_timeouts(Timeouts(command=42)) + self.assertEqual(normalized["command"], 42) + self.assertIsNone(normalized["idle"]) def test_session_timeouts_payload_omits_when_empty(self): self.assertIsNone(cfg.session_timeouts_payload(None)) @@ -65,177 +73,180 @@ def test_session_timeouts_payload_omits_when_empty(self): def test_session_timeouts_payload_keeps_only_set_classes(self): self.assertEqual( - cfg.session_timeouts_payload(Timeouts(command=2000, ready=3000)), + cfg.session_timeouts_payload( + Timeouts(command=2000, ready=3000) + ), {"command": 2000, "ready": 3000}, ) - self.assertEqual(cfg.session_timeouts_payload({"text": 100}), {"text": 100}) - - -class ClientTimeoutPayloadTests(unittest.TestCase): - def test_omits_timeout_ms_when_unconfigured(self): - c = _CapturingClient("s") - run(c.wait_idle()) - self.assertNotIn("timeout_ms", c.sent[0]) - run(c.wait_command()) - self.assertNotIn("timeout_ms", c.sent[1]) - run(c.wait_exit()) - self.assertNotIn("timeout_ms", c.sent[2]) - run(c.wait_ready()) - self.assertNotIn("timeout_ms", c.sent[3]) - - def test_client_timeouts_field_threads_into_payload(self): - c = _CapturingClient("s", timeouts=Timeouts(command=2000, idle=1500)) - run(c.wait_command()) - self.assertEqual(c.sent[0]["timeout_ms"], 2000) - run(c.wait_idle()) - self.assertEqual(c.sent[1]["timeout_ms"], 1500) - run(c.wait_exit()) - self.assertNotIn("timeout_ms", c.sent[2]) - - def test_per_call_beats_client_timeouts_which_beats_omitted(self): - c = _CapturingClient("s", timeouts=Timeouts(idle=1000)) - run(c.wait_idle(timeout=50)) # per-call wins - self.assertEqual(c.sent[0]["timeout_ms"], 50) - run(c.wait_idle()) # client-level default applies - self.assertEqual(c.sent[1]["timeout_ms"], 1000) - run(c.wait_command()) # nothing configured -> omitted - self.assertNotIn("timeout_ms", c.sent[2]) - - def test_text_class_covers_wait_text_and_expect_text(self): - c = _CapturingClient("s", timeouts=Timeouts(text=1234)) - run(c.wait_text("x")) - self.assertEqual(c.sent[0]["timeout_ms"], 1234) - run(c.expect_text("x")) - self.assertEqual(c.sent[1]["timeout_ms"], 1234) - - def test_command_class_covers_wait_command_and_expect_exit_code(self): - c = _CapturingClient("s", timeouts=Timeouts(command=2222)) - run(c.wait_command()) - self.assertEqual(c.sent[0]["timeout_ms"], 2222) - run(c.expect_exit_code(0)) - self.assertEqual(c.sent[1]["timeout_ms"], 2222) - - def test_expect_exit_code_sends_a_per_call_timeout(self): - c = _CapturingClient("s") - run(c.expect_exit_code(0, timeout=777)) - self.assertEqual(c.sent[0]["timeout_ms"], 777) - run(c.expect_exit_code(0)) - self.assertNotIn("timeout_ms", c.sent[1]) - - def test_plain_dict_timeouts_supported(self): - c = _CapturingClient("s", timeouts={"command": 4321}) - run(c.wait_command()) - self.assertEqual(c.sent[0]["timeout_ms"], 4321) - - -class OpenSessionTimeoutTests(unittest.TestCase): - def test_open_omits_timeouts_when_unset(self): - c = _CapturingClient("s") - run(c.open()) - self.assertNotIn("timeouts", c.sent[0]) - - def test_open_omits_timeouts_when_all_classes_none(self): - c = _CapturingClient("s") - run(c.open(timeouts=Timeouts())) - self.assertNotIn("timeouts", c.sent[0]) - - def test_open_forwards_only_set_classes(self): - c = _CapturingClient("s") - run(c.open(timeouts=Timeouts(text=1000, ready=2000))) - self.assertEqual(c.sent[0]["timeouts"], {"text": 1000, "ready": 2000}) - - def test_open_timeouts_accepts_plain_dict(self): - c = _CapturingClient("s") - run(c.open(timeouts={"command": 5000})) - self.assertEqual(c.sent[0]["timeouts"], {"command": 5000}) - - def test_run_forwards_session_timeouts(self): - c = _CapturingClient("s") - run(c.run("vim", timeouts=Timeouts(idle=1500))) - self.assertEqual(c.sent[0]["timeouts"], {"idle": 1500}) - -class UniqueSessionTests(unittest.TestCase): - def test_format_and_uniqueness(self): - a = ephemeral.unique_session() - b = ephemeral.unique_session() - self.assertTrue(a.startswith("shell-use-")) - self.assertNotEqual(a, b) - def test_sanitizes_unsafe_characters(self): - name = ephemeral.unique_session("a b/c\\d:e.f") - self.assertIsNotNone(re.fullmatch(r"[A-Za-z0-9_-]+", name)) +class TypedCallTests(unittest.TestCase): + def test_open_uses_typed_arguments(self): + terminal = _CapturingClient("s") + run( + terminal.open( + cols=120, + rows=40, + env={"K": "V"}, + timeouts=Timeouts(text=100, ready=200), + ) + ) + name, args = terminal.fake.calls[0] + self.assertEqual(name, "open") + self.assertEqual(args[:6], (None, 120, 40, None, [("K", "V")], None)) + self.assertEqual(args[6:], (100, None, None, None, 200)) + + def test_run_uses_program_and_argv(self): + terminal = _CapturingClient("s") + run(terminal.run("vim", "file.txt")) + name, args = terminal.fake.calls[0] + self.assertEqual(name, "run") + self.assertEqual(args[0], "vim") + self.assertEqual(args[1], ["file.txt"]) + + def test_input_helpers_use_distinct_typed_methods(self): + terminal = _CapturingClient("s") + run(terminal.type("typed")) + run(terminal.write("written")) + run(terminal.submit("echo hi")) + run(terminal.keys("Ctrl+a")) + run(terminal.press("Escape", "Enter")) + self.assertEqual( + terminal.fake.calls, + [ + ("type", ("typed",)), + ("write", ("written",)), + ("submit", ("echo hi",)), + ("keys", ("Ctrl+a",)), + ("press", (["Escape", "Enter"],)), + ], + ) - def test_capped_at_64(self): - name = ephemeral.unique_session("x" * 500) - self.assertLessEqual(len(name), 64) - self.assertRegex(name, r"-\d+-[0-9a-f]+-\d+$") + def test_mouse_helpers_use_typed_methods(self): + terminal = _CapturingClient("s") + run(terminal.mouse.click(on_text="OK", clicks=2)) + run(terminal.mouse.move(1, 2)) + run(terminal.mouse.down(1, 2, button=1)) + run(terminal.mouse.up(1, 2, button=1)) + run(terminal.mouse.drag(1, 2, 3, 4, button=1)) + run(terminal.mouse.scroll("down", amount=4)) + self.assertEqual( + terminal.fake.calls, + [ + ("mouse_click", (None, None, "OK", 0, 2)), + ("mouse_move", (1, 2)), + ("mouse_down", (1, 2, 1)), + ("mouse_up", (1, 2, 1)), + ("mouse_drag", (1, 2, 3, 4, 1)), + ("mouse_scroll", ("down", 4)), + ], + ) - def test_default_prefix(self): - self.assertTrue(ephemeral.unique_session("").startswith("shell-use-")) + def test_typed_getters_use_distinct_native_methods(self): + terminal = _CapturingClient("s") + for method in ( + terminal.get_command, + terminal.get_output, + terminal.get_exit_code, + terminal.get_cwd, + terminal.get_cursor, + terminal.get_size, + ): + run(method()) + self.assertEqual( + [name for name, _ in terminal.fake.calls], + [ + "get_command", + "get_output", + "get_exit_code", + "get_cwd", + "get_cursor", + "get_size", + ], + ) + self.assertFalse(hasattr(client.ShellUse, "send")) + self.assertFalse(hasattr(client.ShellUse, "get")) -class WaitReadyPayloadTests(unittest.TestCase): - def test_wait_ready_omits_timeout_when_unset(self): - c = _CapturingClient("s") - run(c.wait_ready()) - self.assertEqual(c.sent[0], {"kind": "wait_ready"}) +class ClientTimeoutTests(unittest.TestCase): + def test_unconfigured_waits_pass_none(self): + terminal = _CapturingClient("s") + run(terminal.wait_idle()) + run(terminal.wait_command()) + run(terminal.wait_exit()) + run(terminal.wait_ready()) + self.assertEqual( + terminal.fake.calls, + [ + ("wait_idle", (None,)), + ("wait_command", (None,)), + ("wait_exit", (None,)), + ("wait_ready", (None,)), + ], + ) - def test_open_omits_wait_ready_when_none(self): - c = _CapturingClient("s") - run(c.open()) - self.assertNotIn("wait_ready", c.sent[0]) + def test_client_and_per_call_timeouts_resolve(self): + terminal = _CapturingClient( + "s", timeouts=Timeouts(text=1234, command=2222, idle=1500) + ) + run(terminal.wait_text("x")) + run(terminal.wait_idle(timeout=50)) + run(terminal.expect_exit_code(0)) + self.assertEqual(terminal.fake.calls[0][1][-1], 1234) + self.assertEqual(terminal.fake.calls[1], ("wait_idle", (50,))) + self.assertEqual( + terminal.fake.calls[2], ("expect_exit_code", (0, 2222)) + ) - def test_open_forwards_wait_ready(self): - c = _CapturingClient("s") - run(c.open(wait_ready=True)) - self.assertEqual(c.sent[0]["wait_ready"], True) - run(c.run("vim", wait_ready=False)) - self.assertEqual(c.sent[1]["wait_ready"], False) + def test_open_and_run_forward_session_timeouts(self): + terminal = _CapturingClient("s") + run(terminal.open(timeouts=Timeouts(text=1000, ready=2000))) + run(terminal.run("vim", timeouts=Timeouts(idle=1500))) + self.assertEqual( + terminal.fake.calls[0][1][-5:], (1000, None, None, None, 2000) + ) + self.assertEqual( + terminal.fake.calls[1][1][-5:], (None, 1500, None, None, None) + ) class RetryTests(unittest.TestCase): def test_retries_reattempt_and_reraise_last(self): - calls = {"n": 0} + terminal = _CapturingClient("s") + attempts = {"count": 0} - class Flaky(client.ShellUse): - async def send(self, payload): - calls["n"] += 1 - raise RuntimeError("attempt %d" % calls["n"]) + def open_call(*args): + terminal.fake.calls.append(("open", args)) - async def close_quiet(self): - pass + async def complete(): + attempts["count"] += 1 + raise RuntimeError("attempt %d" % attempts["count"]) - c = Flaky("s") + return complete() + + terminal.fake.open = open_call with self.assertRaises(RuntimeError) as raised: - run(c.open(retries=2)) - self.assertEqual(calls["n"], 3) + run(terminal.open(retries=2)) + self.assertEqual(attempts["count"], 3) self.assertEqual(str(raised.exception), "attempt 3") def test_no_retries_single_attempt(self): - calls = {"n": 0} - - class Flaky(client.ShellUse): - async def send(self, payload): - calls["n"] += 1 - raise RuntimeError("boom") - - async def close_quiet(self): - pass - - c = Flaky("s") + terminal = _CapturingClient("s") + terminal.fake.error = RuntimeError("boom") with self.assertRaises(RuntimeError): - run(c.open()) - self.assertEqual(calls["n"], 1) + run(terminal.open()) + self.assertEqual( + len([call for call in terminal.fake.calls if call[0] == "open"]), + 1, + ) class MessagePrefixTests(unittest.TestCase): - def _prefix_for(self, method_name, *args, **kwargs): - c = _CapturingClient("s") - c.raise_kind = ExpectationError("boom") - method = getattr(c, method_name) + def _prefix_for(self, method_name, *args): + terminal = _CapturingClient("s") + terminal.fake.error = ExpectationError("boom") with self.assertRaises(ExpectationError) as raised: - run(method(*args, **kwargs)) + run(getattr(terminal, method_name)(*args)) return str(raised.exception) def test_all_wait_and_expect_methods_prefix(self): @@ -246,68 +257,60 @@ def test_all_wait_and_expect_methods_prefix(self): "wait_exit": (), "wait_ready": (), "expect_text": ("x",), + "expect_exit_code": (0,), "expect_output": ("x",), "expect_snapshot": ("x",), } for name, args in cases.items(): - message = self._prefix_for(name, *args) self.assertTrue( - message.startswith(name + ": "), - "%s did not prefix: %r" % (name, message), + self._prefix_for(name, *args).startswith(name + ": ") ) - def test_expect_exit_code_prefixes(self): - c = _CapturingClient("s") - c.raise_kind = ExpectationError("boom") - with self.assertRaises(ExpectationError) as raised: - run(c.expect_exit_code(0)) - self.assertTrue(str(raised.exception).startswith("expect_exit_code: ")) - class ArtifactCaptureTests(unittest.TestCase): - def test_no_artifacts_leaves_terminal_none(self): - c = _CapturingClient("s") - c.raise_kind = ExpectationError("nope\n\nTerminal content:\n╭──╮\n╰──╯") - with self.assertRaises(ExpectationError) as raised: - run(c.wait_text("x")) - self.assertIsNone(raised.exception.terminal) - def test_text_mode_captures_terminal_text_only(self): - c = _CapturingClient("s", artifacts={"dir": "unused", "on_failure": "text"}) - c.raise_kind = ExpectationError("nope\n\nTerminal content:\n╭──╮\n╰──╯") + terminal = _CapturingClient( + "s", artifacts={"dir": "unused", "on_failure": "text"} + ) + terminal.fake.error = ExpectationError( + "nope\n\nTerminal content:\n╭──╮\n╰──╯" + ) with self.assertRaises(ExpectationError) as raised: - run(c.wait_text("x")) - terminal = raised.exception.terminal - self.assertIsInstance(terminal, TerminalArtifact) - self.assertIn("╭──╮", terminal.text) - self.assertIsNone(terminal.screenshot) + run(terminal.wait_text("x")) + artifact = raised.exception.terminal + self.assertIsInstance(artifact, TerminalArtifact) + self.assertIn("╭──╮", artifact.text) + self.assertIsNone(artifact.screenshot) def test_capture_never_masks_original_error(self): - c = _CapturingClient("s", artifacts={"dir": "unused", "on_failure": "svg"}) - c.raise_kind = ExpectationError("nope\n\nTerminal content:\n╭──╮\n╰──╯") + terminal = _CapturingClient( + "s", artifacts={"dir": "unused", "on_failure": "svg"} + ) + terminal.fake.error = ExpectationError( + "nope\n\nTerminal content:\n╭──╮\n╰──╯" + ) - async def boom(*a, **k): + async def boom(*args, **kwargs): raise RuntimeError("screenshot exploded") - c.screenshot = boom + terminal.screenshot = boom with self.assertRaises(ExpectationError): - run(c.wait_text("x")) - + run(terminal.wait_text("x")) -class CloseIdempotencyTests(unittest.TestCase): - def test_close_is_idempotent_without_an_open_terminal(self): - async def scenario(): - c = client.ShellUse(ephemeral.unique_session("idem")) - await c.close() - await c.close() - await c.close_quiet() - run(scenario()) +class UniqueSessionTests(unittest.TestCase): + def test_format_and_uniqueness(self): + first = ephemeral.unique_session() + second = ephemeral.unique_session() + self.assertTrue(first.startswith("shell-use-")) + self.assertNotEqual(first, second) - def test_ephemeral_uses_a_unique_process_local_session(self): - c = client.ShellUse.ephemeral("worker") - self.assertNotEqual(c.session, "default") - run(c.close()) + def test_sanitizes_and_caps_names(self): + name = ephemeral.unique_session("a b/c\\d:e.f") + self.assertIsNotNone(re.fullmatch(r"[A-Za-z0-9_-]+", name)) + long_name = ephemeral.unique_session("x" * 500) + self.assertLessEqual(len(long_name), 64) + self.assertRegex(long_name, r"-\d+-[0-9a-f]+-\d+$") class UnknownTimeoutClassTests(unittest.TestCase): @@ -317,15 +320,8 @@ def test_normalize_rejects_unknown_keys(self): self.assertIn("comand", str(raised.exception)) def test_open_rejects_unknown_keys(self): - c = _CapturingClient("s") with self.assertRaises(ValueError): - run(c.open(timeouts={"txt": 100})) - - def test_known_keys_still_pass(self): - self.assertEqual( - cfg.session_timeouts_payload({"text": 1, "ready": 2}), - {"text": 1, "ready": 2}, - ) + run(_CapturingClient("s").open(timeouts={"txt": 100})) if __name__ == "__main__": diff --git a/bindings/python/tests/test_protocol.py b/bindings/python/tests/test_protocol.py deleted file mode 100644 index a5a7394..0000000 --- a/bindings/python/tests/test_protocol.py +++ /dev/null @@ -1,141 +0,0 @@ -import asyncio -import unittest - -from shell_use import client -from shell_use._protocol import env_pairs, unwrap -from shell_use.errors import ( - ExpectationError, - InternalError, - NoSessionError, - UsageError, -) - - -def run(coro): - return asyncio.run(coro) - - -class CapturingClient(client.ShellUse): - def __init__(self, *a, **k): - super().__init__(*a, **k) - self.sent = [] - self.reply = {"ok": True, "data": {}} - - async def send(self, payload): - self.sent.append(payload) - return unwrap(self.reply) - - -class ProtocolTests(unittest.TestCase): - def test_env_pairs_from_mapping(self): - self.assertEqual(env_pairs({"A": "1", "B": "2"}), [["A", "1"], ["B", "2"]]) - - def test_env_pairs_from_iterable(self): - self.assertEqual(env_pairs([("A", "1")]), [["A", "1"]]) - - def test_env_pairs_none(self): - self.assertEqual(env_pairs(None), []) - - def test_unwrap_ok(self): - self.assertEqual(unwrap({"ok": True, "data": {"x": 1}}), {"x": 1}) - - def test_unwrap_maps_kinds(self): - for kind, exc in [ - ("assertion", ExpectationError), - ("usage", UsageError), - ("no_session", NoSessionError), - ("internal", InternalError), - ]: - with self.assertRaises(exc): - unwrap({"ok": False, "kind": kind, "message": "boom"}) - - def test_open_payload(self): - c = CapturingClient("s") - run(c.open(cols=120, rows=40, env={"K": "V"})) - self.assertEqual( - c.sent[0], - { - "kind": "open", - "shell": None, - "program": None, - "cols": 120, - "rows": 40, - "cwd": None, - "env": [["K", "V"]], - }, - ) - - def test_run_payload(self): - c = CapturingClient("s") - run(c.run("vim", "file.txt")) - self.assertEqual(c.sent[0]["program"], ["vim", "file.txt"]) - self.assertIsNone(c.sent[0]["shell"]) - - def test_submit_and_keys(self): - c = CapturingClient("s") - run(c.submit("echo hi")) - run(c.keys("Ctrl+a")) - run(c.press("Escape", "Enter")) - self.assertEqual(c.sent[0], {"kind": "submit", "data": "echo hi"}) - self.assertEqual(c.sent[1], {"kind": "press", "keys": ["Ctrl+a"]}) - self.assertEqual(c.sent[2], {"kind": "press", "keys": ["Escape", "Enter"]}) - - def test_kill_is_signal_kill(self): - c = CapturingClient("s") - run(c.kill()) - self.assertEqual(c.sent[0], {"kind": "signal", "name": "KILL"}) - - def test_get_field_kebab(self): - c = CapturingClient("s") - c.reply = {"ok": True, "data": {"value": 0}} - run(c.get_exit_code()) - self.assertEqual(c.sent[0], {"kind": "get", "field": "exit-code"}) - - def test_mouse_click_payload(self): - c = CapturingClient("s") - run(c.mouse.click(on_text="OK", clicks=2)) - self.assertEqual( - c.sent[0], - { - "kind": "mouse", - "action": { - "op": "click", - "x": None, - "y": None, - "on_text": "OK", - "button": 0, - "clicks": 2, - }, - }, - ) - - def test_wait_text_defaults(self): - c = CapturingClient("s") - run(c.wait_text("done")) - self.assertEqual( - c.sent[0], - { - "kind": "wait_text", - "text": "done", - "regex": False, - "full": False, - "not": False, - }, - ) - self.assertNotIn("timeout_ms", c.sent[0]) - - def test_expect_text_strict_and_color(self): - c = CapturingClient("s") - run(c.expect_text("ERR", fg="#ff0000")) - self.assertEqual(c.sent[0]["strict"], True) - self.assertEqual(c.sent[0]["fg"], "#ff0000") - self.assertNotIn("timeout_ms", c.sent[0]) - - def test_wait_command_omits_timeout_when_unset(self): - c = CapturingClient("s") - run(c.wait_command()) - self.assertEqual(c.sent[0], {"kind": "wait_command"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/crates/shell-use-cli/Cargo.toml b/crates/shell-use-cli/Cargo.toml index 8244d3b..d4f56f5 100644 --- a/crates/shell-use-cli/Cargo.toml +++ b/crates/shell-use-cli/Cargo.toml @@ -19,6 +19,7 @@ crossterm.workspace = true dialoguer.workspace = true dirs.workspace = true interprocess.workspace = true +serde.workspace = true serde_json.workspace = true sha2.workspace = true shell-use.workspace = true diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index e8b5538..194c1aa 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -1,8 +1,8 @@ use clap::{Args, Parser, Subcommand}; use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; -use shell_use::protocol::TimeoutDefaults; use shell_use::shell::Shell; +use shell_use::Timeouts; #[derive(Clone, Copy, clap::ValueEnum)] #[clap(rename_all = "lowercase")] @@ -55,9 +55,9 @@ pub struct TimeoutArgs { pub ready: Option, } -impl From for TimeoutDefaults { +impl From for Timeouts { fn from(args: TimeoutArgs) -> Self { - TimeoutDefaults { + Timeouts { text: args.text, idle: args.idle, command: args.command, @@ -424,7 +424,7 @@ mod tests { let Some(Command::Open { timeouts, .. }) = cli.command else { panic!("expected Open"); }; - let defaults: TimeoutDefaults = timeouts.into(); + let defaults: Timeouts = timeouts.into(); assert_eq!(defaults.text, Some(30_000)); assert_eq!(defaults.idle, Some(15_000)); assert_eq!(defaults.ready, Some(20_000)); diff --git a/crates/shell-use-cli/src/daemon.rs b/crates/shell-use-cli/src/daemon.rs index 640c60a..00347d2 100644 --- a/crates/shell-use-cli/src/daemon.rs +++ b/crates/shell-use-cli/src/daemon.rs @@ -10,8 +10,9 @@ use interprocess::local_socket::Stream; use shell_use::engine::Engine; use shell_use::logger::Logger; -use shell_use::protocol::Request; +use shell_use::Operation; +use crate::protocol::{Request, Response}; use crate::{config, ipc, monitor}; pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { @@ -71,7 +72,12 @@ pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { Request::Status => Some(true), _ => None, }; - let (mut response, shutdown) = engine.handle(req); + let shutdown = matches!(&req, Request::Close | Request::Shutdown); + let mut response = match req { + Request::Ping | Request::Shutdown => Response::ok(), + Request::Status => status_response(&engine), + operation => operation.execute(&engine), + }; if let Some(status) = enrich { enrich_cli_response(&mut response, &session_name, logging, status); } @@ -86,12 +92,26 @@ pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { Ok(()) } -fn enrich_cli_response( - response: &mut shell_use::protocol::Response, - session: &str, - logging: bool, - status: bool, -) { +fn status_response(engine: &Engine) -> Response { + let status = engine.status(); + let mut data = serde_json::json!({ + "session": status.session, + "shell_pid": status.shell_pid, + }); + if status.cols.is_some() { + let object = data + .as_object_mut() + .expect("daemon status is always a JSON object"); + object.insert("cols".to_string(), serde_json::json!(status.cols)); + object.insert("rows".to_string(), serde_json::json!(status.rows)); + object.insert("shell".to_string(), serde_json::json!(status.shell)); + object.insert("exited".to_string(), serde_json::json!(status.exited)); + object.insert("timeouts".to_string(), serde_json::json!(status.timeouts)); + } + Response::with(data) +} + +fn enrich_cli_response(response: &mut Response, session: &str, logging: bool, status: bool) { let Some(data) = response .data .as_mut() @@ -132,7 +152,7 @@ fn spawn_idle_watchdog(engine: Arc, last_activity: Arc>, "idle timeout: no activity for {}s, shutting down", idle.as_secs() )); - let _ = engine.handle(Request::Close); + let _ = engine.execute(Operation::Close); cleanup(&session); std::process::exit(0); } diff --git a/crates/shell-use-cli/src/ipc.rs b/crates/shell-use-cli/src/ipc.rs index 859da47..8bcef01 100644 --- a/crates/shell-use-cli/src/ipc.rs +++ b/crates/shell-use-cli/src/ipc.rs @@ -9,7 +9,7 @@ use interprocess::local_socket::{GenericFilePath, GenericNamespaced, ListenerOpt pub use interprocess::local_socket::Stream; -use shell_use::protocol::{Request, Response}; +use crate::protocol::{Request, Response}; fn to_name(raw: &str) -> std::io::Result> { if cfg!(windows) { diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index 245e401..a027da8 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -4,6 +4,7 @@ mod config; mod daemon; mod ipc; mod monitor; +mod protocol; mod skill; use std::path::Path; @@ -12,7 +13,7 @@ use std::time::{Duration, Instant}; use clap::{CommandFactory, Parser}; use cli::{Cli, Command, DaemonCmd, ExpectCmd, GetArg, MouseCmd, WaitCmd}; -use shell_use::protocol::{GetField, MouseAction, Request, Response}; +use protocol::{GetField, MouseAction, Request, Response}; /// Long-form agent skill manifest, printed by `shell-use skill`. const SKILL_MD: &str = include_str!("../../../SKILL.md"); diff --git a/crates/shell-use-cli/src/monitor.rs b/crates/shell-use-cli/src/monitor.rs index 4fb14c8..c2e2eb9 100644 --- a/crates/shell-use-cli/src/monitor.rs +++ b/crates/shell-use-cli/src/monitor.rs @@ -259,7 +259,7 @@ enum Action { fn stream_loop(socket: &str) -> i32 { use crate::ipc; - use shell_use::protocol::Request; + use crate::protocol::Request; loop { let (vcols, vrows) = crossterm::terminal::size().unwrap_or((80, 24)); diff --git a/crates/shell-use-cli/src/protocol.rs b/crates/shell-use-cli/src/protocol.rs new file mode 100644 index 0000000..b86ccd6 --- /dev/null +++ b/crates/shell-use-cli/src/protocol.rs @@ -0,0 +1,446 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use shell_use::{ + Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, ShellUseError, +}; + +pub use shell_use::{ErrorKind, MouseAction, Timeouts}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Request { + Ping, + Open { + shell: Option, + program: Option>, + cols: u16, + rows: u16, + cwd: Option, + env: Vec<(String, String)>, + #[serde(default)] + wait_ready: Option, + #[serde(default)] + timeouts: Timeouts, + }, + Close, + Status, + State, + Text { + full: bool, + }, + Cells { + x: u16, + y: u16, + w: u16, + h: u16, + }, + Get { + field: GetField, + }, + Write { + data: String, + }, + Submit { + data: Option, + }, + Press { + keys: Vec, + }, + Mouse { + action: MouseAction, + }, + Resize { + cols: u16, + rows: u16, + }, + Signal { + name: String, + }, + WaitText { + text: String, + regex: bool, + full: bool, + #[serde(default)] + timeout_ms: Option, + not: bool, + }, + WaitIdle { + #[serde(default)] + timeout_ms: Option, + }, + WaitCommand { + #[serde(default)] + timeout_ms: Option, + }, + WaitExit { + #[serde(default)] + timeout_ms: Option, + }, + WaitReady { + #[serde(default)] + timeout_ms: Option, + }, + ExpectText { + text: String, + regex: bool, + full: bool, + strict: bool, + not: bool, + fg: Option, + bg: Option, + #[serde(default)] + timeout_ms: Option, + }, + ExpectExitCode { + code: i32, + #[serde(default)] + timeout_ms: Option, + }, + ExpectOutput { + text: String, + regex: bool, + }, + Snapshot { + name: String, + update: bool, + include_colors: bool, + #[serde(default)] + cwd: Option, + }, + Screenshot { + full: bool, + path: Option, + }, + Monitor { + cols: u16, + rows: u16, + }, + Shutdown, +} + +impl Request { + pub fn execute(self, engine: &Engine) -> Response { + match self.into_operation() { + Ok(operation) => Response::from_result(engine.execute(operation)), + Err(error) => Response::from_error(error), + } + } + + fn into_operation(self) -> Result { + match self { + Request::Open { + shell, + program, + cols, + rows, + cwd, + env, + wait_ready, + timeouts, + } => { + if let Some(program) = program { + let mut parts = program.into_iter(); + let executable = parts + .next() + .ok_or_else(|| ShellUseError::usage("empty program"))?; + Ok(Operation::Run(RunOptions { + program: executable, + args: parts.collect(), + cols, + rows, + cwd, + env, + wait_ready, + timeouts, + })) + } else { + Ok(Operation::Open(OpenOptions { + shell, + cols, + rows, + cwd, + env, + wait_ready, + timeouts, + })) + } + } + Request::Close => Ok(Operation::Close), + Request::State => Ok(Operation::State), + Request::Text { full } => Ok(Operation::Text { full }), + Request::Cells { x, y, w, h } => Ok(Operation::Cells { x, y, w, h }), + Request::Get { field } => Ok(match field { + GetField::Command => Operation::GetCommand, + GetField::Output => Operation::GetOutput, + GetField::ExitCode => Operation::GetExitCode, + GetField::Cwd => Operation::GetCwd, + GetField::Cursor => Operation::GetCursor, + GetField::Size => Operation::GetSize, + }), + Request::Write { data } => Ok(Operation::Write { data }), + Request::Submit { data } => Ok(Operation::Submit { data }), + Request::Press { keys } => Ok(Operation::Press { keys }), + Request::Mouse { action } => Ok(Operation::Mouse { action }), + Request::Resize { cols, rows } => Ok(Operation::Resize { cols, rows }), + Request::Signal { name } => Ok(Operation::Signal { name }), + Request::WaitText { + text, + regex, + full, + timeout_ms, + not, + } => Ok(Operation::WaitText { + text, + regex, + full, + timeout_ms, + not, + }), + Request::WaitIdle { timeout_ms } => Ok(Operation::WaitIdle { timeout_ms }), + Request::WaitCommand { timeout_ms } => Ok(Operation::WaitCommand { timeout_ms }), + Request::WaitExit { timeout_ms } => Ok(Operation::WaitExit { timeout_ms }), + Request::WaitReady { timeout_ms } => Ok(Operation::WaitReady { timeout_ms }), + Request::ExpectText { + text, + regex, + full, + strict, + not, + fg, + bg, + timeout_ms, + } => Ok(Operation::ExpectText { + text, + regex, + full, + strict, + not, + fg, + bg, + timeout_ms, + }), + Request::ExpectExitCode { code, timeout_ms } => { + Ok(Operation::ExpectExitCode { code, timeout_ms }) + } + Request::ExpectOutput { text, regex } => Ok(Operation::ExpectOutput { text, regex }), + Request::Snapshot { + name, + update, + include_colors, + cwd, + } => Ok(Operation::Snapshot { + name, + update, + include_colors, + cwd, + }), + Request::Screenshot { full, path } => Ok(Operation::Screenshot { full, path }), + Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown => { + Err(ShellUseError::usage( + "daemon control request cannot execute as a terminal operation", + )) + } + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GetField { + Command, + Output, + ExitCode, + Cwd, + Cursor, + Size, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +impl Response { + pub fn ok() -> Self { + Self { + ok: true, + data: None, + message: None, + kind: None, + } + } + + pub fn with(data: serde_json::Value) -> Self { + Self { + ok: true, + data: Some(data), + message: None, + kind: None, + } + } + + pub fn from_result(result: Result) -> Self { + match result { + Ok(result) => match operation_data(result) { + Ok(Some(data)) => Self::with(data), + Ok(None) => Self::ok(), + Err(error) => Self::from_error(error), + }, + Err(error) => Self::from_error(error), + } + } + + pub fn from_error(error: ShellUseError) -> Self { + Self { + ok: false, + data: None, + message: Some(error.message), + kind: Some(error.kind), + } + } +} + +fn operation_data(result: OperationResult) -> Result, ShellUseError> { + let value = match result { + OperationResult::Unit => return Ok(None), + OperationResult::Open(value) => serde_json::to_value(value), + OperationResult::State(value) => serde_json::to_value(value), + OperationResult::Text(text) => Ok(json!({ "text": text })), + OperationResult::PackedScreen(screen) => Ok(json!({ + "cols": screen.cols, + "rows": screen.rows, + "text": String::from_utf8_lossy(&screen.utf8), + })), + OperationResult::Cells(cells) => Ok(json!({ "cells": cells })), + OperationResult::Command(value) => Ok(json!({ "value": value })), + OperationResult::Output(value) => Ok(json!({ "value": value })), + OperationResult::ExitCode(value) => Ok(json!({ "value": value })), + OperationResult::Cwd(value) => Ok(json!({ "value": value })), + OperationResult::Cursor(value) => Ok(json!({ "value": value })), + OperationResult::Size(value) => Ok(json!({ "value": value })), + OperationResult::Snapshot(status) => Ok(json!({ "status": status })), + OperationResult::Screenshot(ScreenshotResult::Path(path)) => Ok(json!({ "path": path })), + OperationResult::Screenshot(ScreenshotResult::Text(text)) => Ok(json!({ "text": text })), + } + .map_err(|error| ShellUseError::internal(format!("failed to encode cli response: {error}")))?; + Ok(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_open_req(wait_ready: Option, timeouts: Timeouts) -> Request { + Request::Open { + shell: None, + program: None, + cols: 80, + rows: 30, + cwd: None, + env: vec![], + wait_ready, + timeouts, + } + } + + #[test] + fn open_without_wait_ready_still_deserializes() { + let raw = r#"{"kind":"open","shell":null,"program":null,"cols":80,"rows":30, + "cwd":null,"env":[]}"#; + let request: Request = serde_json::from_str(raw).expect("deserialize legacy open"); + match request { + Request::Open { + wait_ready, + cols, + timeouts, + .. + } => { + assert_eq!(wait_ready, None); + assert_eq!(cols, 80); + assert_eq!(timeouts, Timeouts::default()); + } + other => panic!("expected Open, got {other:?}"), + } + } + + #[test] + fn waits_accept_a_concrete_timeout_from_older_clients() { + let raw = r#"{"kind":"wait_idle","timeout_ms":1234}"#; + match serde_json::from_str::(raw).expect("deserialize wait_idle") { + Request::WaitIdle { timeout_ms } => assert_eq!(timeout_ms, Some(1234)), + other => panic!("expected WaitIdle, got {other:?}"), + } + } + + #[test] + fn waits_treat_an_absent_timeout_as_unset() { + for raw in [ + r#"{"kind":"wait_idle"}"#, + r#"{"kind":"wait_command"}"#, + r#"{"kind":"wait_exit"}"#, + r#"{"kind":"wait_ready"}"#, + ] { + let request: Request = serde_json::from_str(raw).expect("deserialize wait"); + let timeout = match request { + Request::WaitIdle { timeout_ms } + | Request::WaitCommand { timeout_ms } + | Request::WaitExit { timeout_ms } + | Request::WaitReady { timeout_ms } => timeout_ms, + other => panic!("expected a wait, got {other:?}"), + }; + assert_eq!(timeout, None); + } + } + + #[test] + fn expect_exit_code_timeout_is_optional() { + let raw = r#"{"kind":"expect_exit_code","code":0}"#; + match serde_json::from_str::(raw).expect("deserialize expect_exit_code") { + Request::ExpectExitCode { code, timeout_ms } => { + assert_eq!(code, 0); + assert_eq!(timeout_ms, None); + } + other => panic!("expected ExpectExitCode, got {other:?}"), + } + } + + #[test] + fn open_round_trips_session_timeout_defaults() { + let timeouts = Timeouts { + text: Some(30_000), + idle: Some(15_000), + ready: Some(20_000), + ..Timeouts::default() + }; + let request = make_open_req(None, timeouts); + let encoded = serde_json::to_string(&request).expect("serialize open"); + match serde_json::from_str::(&encoded).expect("deserialize open") { + Request::Open { timeouts: got, .. } => { + assert_eq!(got, timeouts); + assert_eq!(got.get(shell_use::config::TimeoutClass::Text), Some(30_000)); + assert_eq!(got.get(shell_use::config::TimeoutClass::Command), None); + } + other => panic!("expected Open, got {other:?}"), + } + } + + #[test] + fn open_round_trips_an_explicit_wait_ready() { + for expected in [Some(true), Some(false), None] { + let request = make_open_req(expected, Timeouts::default()); + let encoded = serde_json::to_string(&request).expect("serialize open"); + let decoded: Request = serde_json::from_str(&encoded).expect("deserialize open"); + match decoded { + Request::Open { wait_ready, .. } => assert_eq!(wait_ready, expected), + other => panic!("expected Open, got {other:?}"), + } + } + } +} diff --git a/crates/shell-use/src/api.rs b/crates/shell-use/src/api.rs new file mode 100644 index 0000000..5a593c7 --- /dev/null +++ b/crates/shell-use/src/api.rs @@ -0,0 +1,406 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::shell::Shell; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Timeouts { + pub text: Option, + pub idle: Option, + pub command: Option, + pub exit: Option, + pub ready: Option, +} + +impl Timeouts { + pub fn get(&self, class: crate::config::TimeoutClass) -> Option { + use crate::config::TimeoutClass::*; + match class { + Text => self.text, + Idle => self.idle, + Command => self.command, + Exit => self.exit, + Ready => self.ready, + } + } +} + +#[derive(Debug, Clone)] +pub struct OpenOptions { + pub shell: Option, + pub cols: u16, + pub rows: u16, + pub cwd: Option, + pub env: Vec<(String, String)>, + pub wait_ready: Option, + pub timeouts: Timeouts, +} + +impl Default for OpenOptions { + fn default() -> Self { + Self { + shell: None, + cols: crate::config::DEFAULT_COLS, + rows: crate::config::DEFAULT_ROWS, + cwd: None, + env: Vec::new(), + wait_ready: None, + timeouts: Timeouts::default(), + } + } +} + +#[derive(Debug, Clone)] +pub struct RunOptions { + pub program: String, + pub args: Vec, + pub cols: u16, + pub rows: u16, + pub cwd: Option, + pub env: Vec<(String, String)>, + pub wait_ready: Option, + pub timeouts: Timeouts, +} + +#[derive(Debug, Clone)] +pub enum Operation { + Open(OpenOptions), + Run(RunOptions), + Close, + State, + Text { + full: bool, + }, + PackedScreen { + full: bool, + }, + Cells { + x: u16, + y: u16, + w: u16, + h: u16, + }, + GetCommand, + GetOutput, + GetExitCode, + GetCwd, + GetCursor, + GetSize, + Write { + data: String, + }, + Submit { + data: Option, + }, + Press { + keys: Vec, + }, + Mouse { + action: MouseAction, + }, + Resize { + cols: u16, + rows: u16, + }, + Signal { + name: String, + }, + WaitText { + text: String, + regex: bool, + full: bool, + timeout_ms: Option, + not: bool, + }, + WaitIdle { + timeout_ms: Option, + }, + WaitCommand { + timeout_ms: Option, + }, + WaitExit { + timeout_ms: Option, + }, + WaitReady { + timeout_ms: Option, + }, + ExpectText { + text: String, + regex: bool, + full: bool, + strict: bool, + not: bool, + fg: Option, + bg: Option, + timeout_ms: Option, + }, + ExpectExitCode { + code: i32, + timeout_ms: Option, + }, + ExpectOutput { + text: String, + regex: bool, + }, + Snapshot { + name: String, + update: bool, + include_colors: bool, + cwd: Option, + }, + Screenshot { + full: bool, + path: Option, + }, +} + +#[derive(Debug, Clone)] +pub enum OperationResult { + Unit, + Open(OpenResult), + State(State), + Text(String), + PackedScreen(PackedScreen), + Cells(Vec), + Command(Option), + Output(Option), + ExitCode(Option), + Cwd(Option), + Cursor(Cursor), + Size(Size), + Snapshot(SnapshotResult), + Screenshot(ScreenshotResult), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + Assertion, + Usage, + NoSession, + Internal, +} + +impl ErrorKind { + pub fn exit_code(self) -> i32 { + match self { + ErrorKind::Assertion => 1, + ErrorKind::Usage => 2, + ErrorKind::NoSession => 3, + ErrorKind::Internal => 5, + } + } + + pub fn as_str(self) -> &'static str { + match self { + ErrorKind::Assertion => "assertion", + ErrorKind::Usage => "usage", + ErrorKind::NoSession => "no_session", + ErrorKind::Internal => "internal", + } + } +} + +#[derive(Debug, Clone)] +pub struct ShellUseError { + pub kind: ErrorKind, + pub message: String, +} + +impl ShellUseError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn assertion(message: impl Into) -> Self { + Self::new(ErrorKind::Assertion, message) + } + + pub fn usage(message: impl Into) -> Self { + Self::new(ErrorKind::Usage, message) + } + + pub fn no_session() -> Self { + Self::new( + ErrorKind::NoSession, + "no active session; run `shell-use open` (or `shell-use run `) first", + ) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(ErrorKind::Internal, message) + } +} + +impl fmt::Display for ShellUseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ShellUseError {} + +#[derive(Debug, Clone, Serialize)] +pub struct OpenResult { + pub shell_pid: Option, + pub session: String, + pub ready: bool, + pub recording: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct Cursor { + pub x: u16, + pub y: u16, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct Size { + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct EffectiveTimeouts { + pub text: u64, + pub idle: u64, + pub command: u64, + pub exit: u64, + pub ready: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct State { + pub session_shell: Option, + pub cols: u16, + pub rows: u16, + pub cursor: Cursor, + pub cwd: Option, + pub last_command: Option, + pub last_exit: Option, + pub exited: Option, + pub ready: bool, + pub timeouts: EffectiveTimeouts, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CellColor { + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +impl Serialize for CellColor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + CellColor::Default => serializer.serialize_str("default"), + CellColor::Indexed(index) => serializer.serialize_u8(*index), + CellColor::Rgb(r, g, b) => serializer.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Cell { + pub x: u16, + pub y: u16, + pub char: String, + pub fg: CellColor, + pub bg: CellColor, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub inverse: bool, + pub invisible: bool, + pub strike: bool, + pub blink: bool, + pub underline: bool, + pub underline_style: String, + pub underline_color: CellColor, +} + +#[derive(Debug, Clone)] +pub struct PackedScreen { + /// Logical terminal dimensions for the newline-delimited UTF-8 snapshot. + /// Rows retain trailing spaces and blank lines; byte offsets are not cell + /// offsets because Unicode graphemes may occupy multiple bytes. + pub cols: u16, + pub rows: u16, + pub utf8: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotResult { + Passed, + Written, + Updated, +} + +#[derive(Debug, Clone)] +pub enum ScreenshotResult { + Path(String), + Text(String), +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStatus { + pub session: String, + pub shell_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exited: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeouts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum MouseAction { + Click { + x: Option, + y: Option, + on_text: Option, + button: u8, + clicks: u8, + }, + Move { + x: u16, + y: u16, + }, + Down { + x: u16, + y: u16, + button: u8, + }, + Up { + x: u16, + y: u16, + button: u8, + }, + Drag { + x1: u16, + y1: u16, + x2: u16, + y2: u16, + button: u8, + }, + Scroll { + direction: String, + amount: u16, + }, +} diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index 33d418f..a3d3c3c 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -4,30 +4,36 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; -use serde_json::json; - +use crate::api::{ + Cell, CellColor, Cursor, EffectiveTimeouts, ErrorKind, OpenOptions, OpenResult, Operation, + OperationResult, PackedScreen, RunOptions, RuntimeStatus, ScreenshotResult, ShellUseError, + Size, SnapshotResult, +}; use crate::assert::color::{self, Expected}; use crate::assert::snapshot::{self, SnapshotStatus}; use crate::config::{self, POLL_DELAY_MS}; use crate::input::{keys, mouse}; use crate::logger::Logger; -use crate::protocol::{ErrorKind, GetField, MouseAction, Request, Response, TimeoutDefaults}; -use crate::session::{Session, TermState}; +use crate::session::{Session as TerminalSession, TermState}; use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::locator::{self, Pattern}; pub struct Engine { name: String, operations: Mutex<()>, - session: Mutex>, + session: Mutex>, live: Arc>>, + interrupt: Mutex>, logger: Arc, recording_path: PathBuf, } -/// The current session's renderable state, shared with monitor threads so they -/// can read the live grid without contending on the session lock (which long -/// `wait`s hold). +#[derive(Clone)] +struct InterruptTarget { + pty: Arc>, + cancelled: Arc, +} + struct LiveTarget { state: Arc>, shell: Option<&'static str>, @@ -41,22 +47,28 @@ pub struct LiveFrame { pub shell: Option<&'static str>, } -/// One-line request description for the verbose log. `Open` redacts env values -/// (they may contain secrets) and reports only the variable count. -fn req_summary(req: &Request) -> String { - match req { - Request::Open { - shell, - program, - cols, - rows, - cwd, - env, - wait_ready, - timeouts, - } => format!( - "Open {{ shell: {shell:?}, program: {program:?}, {cols}x{rows}, cwd: {cwd:?}, wait_ready: {wait_ready:?}, timeouts: {timeouts:?}, env: <{} vars> }}", - env.len() +fn operation_summary(operation: &Operation) -> String { + match operation { + Operation::Open(options) => format!( + "Open {{ shell: {:?}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + options.shell, + options.cols, + options.rows, + options.cwd, + options.wait_ready, + options.timeouts, + options.env.len() + ), + Operation::Run(options) => format!( + "Run {{ program: {:?}, args: {:?}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + options.program, + options.args, + options.cols, + options.rows, + options.cwd, + options.wait_ready, + options.timeouts, + options.env.len() ), other => format!("{other:?}"), } @@ -64,54 +76,90 @@ fn req_summary(req: &Request) -> String { impl Engine { pub fn new(name: String, logger: Arc, recording_path: PathBuf) -> Self { - Engine { + Self { name, operations: Mutex::new(()), session: Mutex::new(None), live: Arc::new(Mutex::new(None)), + interrupt: Mutex::new(None), logger, recording_path, } } - pub fn handle(&self, req: Request) -> (Response, bool) { + pub fn execute(&self, operation: Operation) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.execute_inner(operation) + })) + .unwrap_or_else(|payload| { + Err(ShellUseError::internal(format!( + "native terminal operation panicked: {}", + panic_message(payload.as_ref()) + ))) + }) + } + + fn execute_inner(&self, operation: Operation) -> Result { let _operation = self .operations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if self.logger.enabled() { - self.logger.event(&format!("req {}", req_summary(&req))); - } - match req { - Request::Ping => (Response::ok(), false), - Request::Shutdown => (Response::ok(), true), - Request::Open { - shell, - program, - cols, - rows, - cwd, - env, - wait_ready, - timeouts, - } => ( - self.open(shell, program, cols, rows, cwd, env, wait_ready, timeouts), - false, - ), - Request::Close => { - *self.live.lock().unwrap() = None; - if let Some(s) = self.lock_session().take() { - s.kill(); + self.logger + .event(&format!("operation {}", operation_summary(&operation))); + } + match operation { + Operation::Open(options) => self.open(options).map(OperationResult::Open), + Operation::Run(options) => self.run(options).map(OperationResult::Open), + Operation::Close => { + *self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *self + .interrupt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + if let Some(session) = self.lock_session().take() { + session.kill(); } - (Response::ok(), true) + Ok(OperationResult::Unit) } - Request::Status => (self.status(), false), - other => (self.with_session(|s| dispatch(s, other)), false), + other => self.with_session(|session| dispatch(session, other)), } } + fn open(&self, options: OpenOptions) -> Result { + self.spawn( + options.shell, + None, + options.cols, + options.rows, + options.cwd, + options.env, + options.wait_ready, + options.timeouts, + ) + } + + fn run(&self, options: RunOptions) -> Result { + let mut program = Vec::with_capacity(options.args.len() + 1); + program.push(options.program); + program.extend(options.args); + self.spawn( + None, + Some(program), + options.cols, + options.rows, + options.cwd, + options.env, + options.wait_ready, + options.timeouts, + ) + } + #[allow(clippy::too_many_arguments)] - fn open( + fn spawn( &self, shell: Option, program: Option>, @@ -120,13 +168,20 @@ impl Engine { cwd: Option, env: Vec<(String, String)>, wait_ready: Option, - timeouts: TimeoutDefaults, - ) -> Response { - *self.live.lock().unwrap() = None; + timeouts: crate::api::Timeouts, + ) -> Result { + *self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *self + .interrupt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; if let Some(previous) = self.lock_session().take() { previous.kill(); } - match Session::open( + let session = TerminalSession::open( shell, program.clone(), cols, @@ -136,79 +191,112 @@ impl Engine { timeouts, self.logger.clone(), self.recording_path.clone(), - ) { - Ok(s) => { - let shell_pid = s.pid(); - let ready_timeout = open_ready_timeout(&s); - let ready = if wait_ready.unwrap_or(program.is_none()) { - await_ready(&s, ready_timeout) - } else { - s.state.lock().unwrap().tracker.is_ready() - }; - if wait_ready == Some(true) && !ready { - let message = assertion_message( - &s, - &format!( - "open: the session started but reported no prompt within \ - {ready_timeout}ms; pass --no-wait-ready if it has no shell \ - integration" - ), - ); - s.kill(); - return Response::assertion(message); - } - let live = LiveTarget { - state: s.state.clone(), - shell: s.shell.map(|sh| sh.as_str()), - }; - *self.lock_session() = Some(s); - *self.live.lock().unwrap() = Some(live); - Response::with(json!({ - "shell_pid": shell_pid, - "session": self.name, - "ready": ready, - "recording": self.recording_path.to_string_lossy(), - })) - } + ) + .map_err(|error| ShellUseError::internal(format!("failed to open session: {error}")))?; - Err(e) => Response::internal(format!("failed to open session: {e}")), + let shell_pid = session.pid(); + let ready_timeout = open_ready_timeout(&session); + let ready = if wait_ready.unwrap_or(program.is_none()) { + await_ready(&session, ready_timeout) + } else { + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .is_ready() + }; + if wait_ready == Some(true) && !ready { + let message = assertion_message( + &session, + &format!( + "open: the session started but reported no prompt within \ + {ready_timeout}ms; pass --no-wait-ready if it has no shell \ + integration" + ), + ); + session.kill(); + return Err(ShellUseError::assertion(message)); } + let live = LiveTarget { + state: session.state.clone(), + shell: session.shell.map(|value| value.as_str()), + }; + *self + .interrupt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(InterruptTarget { + pty: session.pty.clone(), + cancelled: session.cancelled.clone(), + }); + *self.lock_session() = Some(session); + *self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(live); + Ok(OpenResult { + shell_pid, + session: self.name.clone(), + ready, + recording: self.recording_path.to_string_lossy().into_owned(), + }) } - fn status(&self) -> Response { - let guard = self.lock_session(); - match guard.as_ref() { - Some(s) => { - let st = s.state.lock().unwrap(); - Response::with(json!({ - "session": self.name, - "shell_pid": s.pid(), - "cols": s.cols, - "rows": s.rows, - "shell": s.shell.map(|sh| sh.as_str()), - "exited": st.exited, - "timeouts": effective_timeouts(s), - })) + fn with_session(&self, operation: F) -> Result + where + F: FnOnce(&mut TerminalSession) -> Result, + { + let mut guard = self.lock_session(); + let session = guard.as_mut().ok_or_else(ShellUseError::no_session)?; + match operation(session) { + Err(mut error) if error.kind == ErrorKind::Assertion => { + error.message = assertion_message(session, &error.message); + Err(error) } - None => Response::with(json!({ - "session": self.name, - "shell_pid": null, - })), + result => result, } } - fn with_session Response>(&self, f: F) -> Response { - let mut guard = self.lock_session(); - match guard.as_mut() { - Some(s) => f(s), - None => Response::no_session(), + pub fn status(&self) -> RuntimeStatus { + let guard = self.lock_session(); + match guard.as_ref() { + Some(session) => { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + RuntimeStatus { + session: self.name.clone(), + shell_pid: session.pid(), + cols: Some(session.cols), + rows: Some(session.rows), + shell: session.shell.map(|value| value.as_str().to_string()), + exited: state.exited, + timeouts: Some(effective_timeouts(session)), + } + } + None => RuntimeStatus { + session: self.name.clone(), + shell_pid: None, + cols: None, + rows: None, + shell: None, + exited: None, + timeouts: None, + }, } } pub fn frame(&self) -> Option { - let live = self.live.lock().unwrap(); + let live = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); live.as_ref().map(|target| { - let state = target.state.lock().unwrap(); + let state = target + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); LiveFrame { grid: state.emu.viewable_rows(), cursor: state.emu.cursor(), @@ -223,6 +311,24 @@ impl Engine { self.logger.event(message); } + pub fn interrupt(&self) { + let target = self + .interrupt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(target) = target { + target + .cancelled + .store(true, std::sync::atomic::Ordering::Release); + target + .pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .kill(); + } + } + pub fn is_open(&self) -> bool { self.lock_session().is_some() } @@ -231,7 +337,7 @@ impl Engine { &self.recording_path } - fn lock_session(&self) -> MutexGuard<'_, Option> { + fn lock_session(&self) -> MutexGuard<'_, Option> { self.session .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -248,25 +354,30 @@ impl Drop for Engine { } } -/// Cap only `open`'s implicit ready wait when no ready budget is configured. -fn open_ready_timeout(s: &Session) -> u64 { - s.timeouts +fn open_ready_timeout(session: &TerminalSession) -> u64 { + session + .timeouts .get(config::TimeoutClass::Ready) .or_else(|| config::TimeoutClass::Ready.env_ms()) .unwrap_or(config::OPEN_READY_CAP_MS) } -/// Poll until the shell reports a prompt, or the session exits or times out. -fn await_ready(s: &Session, timeout_ms: u64) -> bool { +fn await_ready(session: &TerminalSession, timeout_ms: u64) -> bool { let start = Instant::now(); let cap = Duration::from_millis(timeout_ms); loop { + if session.cancelled.load(std::sync::atomic::Ordering::Acquire) { + return false; + } { - let st = s.state.lock().unwrap(); - if st.tracker.is_ready() { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.tracker.is_ready() { return true; } - if st.exited.is_some() { + if state.exited.is_some() { return false; } } @@ -277,72 +388,175 @@ fn await_ready(s: &Session, timeout_ms: u64) -> bool { } } -fn viewable(s: &Session) -> Vec> { - s.state.lock().unwrap().emu.viewable_rows() +fn viewable(session: &TerminalSession) -> Vec> { + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .viewable_rows() } -fn grid(s: &Session, full: bool) -> Vec> { - let st = s.state.lock().unwrap(); +fn grid(session: &TerminalSession, full: bool) -> Vec> { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if full { - st.emu.full_rows() + state.emu.full_rows() } else { - st.emu.viewable_rows() + state.emu.viewable_rows() } } fn text_of(rows: &[Vec]) -> String { rows_to_strings(rows) .iter() - .map(|l| l.trim_end()) + .map(|line| line.trim_end()) .collect::>() .join("\n") .trim_end() .to_string() } -fn dispatch(s: &mut Session, req: Request) -> Response { - let mut response = match req { - Request::State => state(s), - Request::Text { full } => Response::with(json!({ "text": text_of(&grid(s, full)) })), - Request::Cells { x, y, w, h } => cells(s, x, y, w, h), - Request::Get { field } => get(s, field), - Request::Write { data } => act(s.write(data.as_bytes())), - Request::Submit { data } => act(s.submit(&data.unwrap_or_default())), - Request::Press { keys } => press(s, keys), - Request::Mouse { action } => mouse_action(s, action), - Request::Resize { cols, rows } => act(s.resize(cols, rows)), - Request::Signal { name } => act(s.pty.lock().unwrap().signal(&name)), - Request::WaitText { +fn dispatch( + session: &mut TerminalSession, + operation: Operation, +) -> Result { + match operation { + Operation::State => Ok(OperationResult::State(state(session))), + Operation::Text { full } => Ok(OperationResult::Text(text_of(&grid(session, full)))), + Operation::PackedScreen { full } => { + Ok(OperationResult::PackedScreen(packed_screen(session, full))) + } + Operation::Cells { x, y, w, h } => Ok(OperationResult::Cells(cells(session, x, y, w, h))), + Operation::GetCommand => Ok(OperationResult::Command( + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .last_command() + .map(str::to_string), + )), + Operation::GetOutput => Ok(OperationResult::Output( + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .last_output() + .map(str::to_string), + )), + Operation::GetExitCode => Ok(OperationResult::ExitCode( + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .last_exit(), + )), + Operation::GetCwd => Ok(OperationResult::Cwd( + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .cwd() + .map(str::to_string), + )), + Operation::GetCursor => { + let (x, y) = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .cursor(); + Ok(OperationResult::Cursor(Cursor { x, y })) + } + Operation::GetSize => { + let (cols, rows) = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .size(); + Ok(OperationResult::Size(Size { cols, rows })) + } + Operation::Write { data } => { + act(session.write(data.as_bytes()))?; + Ok(OperationResult::Unit) + } + Operation::Submit { data } => { + act(session.submit(&data.unwrap_or_default()))?; + Ok(OperationResult::Unit) + } + Operation::Press { keys } => { + press(session, keys)?; + Ok(OperationResult::Unit) + } + Operation::Mouse { action } => { + mouse_action(session, action)?; + Ok(OperationResult::Unit) + } + Operation::Resize { cols, rows } => { + act(session.resize(cols, rows))?; + Ok(OperationResult::Unit) + } + Operation::Signal { name } => { + act(session + .pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .signal(&name))?; + Ok(OperationResult::Unit) + } + Operation::WaitText { text, regex, full, timeout_ms, not, - } => wait_text( - s, - &text, - regex, - full, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Text)), - not, - ), - Request::WaitIdle { timeout_ms } => wait_idle( - s, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Idle)), - ), - Request::WaitCommand { timeout_ms } => wait_command( - s, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Command)), - ), - Request::WaitExit { timeout_ms } => wait_exit( - s, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Exit)), - ), - Request::WaitReady { timeout_ms } => wait_ready( - s, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Ready)), - ), - Request::ExpectText { + } => { + wait_text( + session, + &text, + regex, + full, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + not, + )?; + Ok(OperationResult::Unit) + } + Operation::WaitIdle { timeout_ms } => { + wait_idle( + session, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Idle)), + )?; + Ok(OperationResult::Unit) + } + Operation::WaitCommand { timeout_ms } => { + wait_command( + session, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)), + )?; + Ok(OperationResult::Unit) + } + Operation::WaitExit { timeout_ms } => { + wait_exit( + session, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Exit)), + )?; + Ok(OperationResult::Unit) + } + Operation::WaitReady { timeout_ms } => { + wait_ready( + session, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Ready)), + )?; + Ok(OperationResult::Unit) + } + Operation::ExpectText { text, regex, full, @@ -351,184 +565,178 @@ fn dispatch(s: &mut Session, req: Request) -> Response { fg, bg, timeout_ms, - } => expect_text( - s, - &text, - regex, - full, - strict, - not, - fg, - bg, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Text)), - ), - Request::ExpectExitCode { code, timeout_ms } => expect_exit_code( - s, - code, - timeout_ms.unwrap_or_else(|| s.timeout_for(config::TimeoutClass::Command)), - ), - Request::ExpectOutput { text, regex } => expect_output(s, &text, regex), - Request::Snapshot { + } => { + expect_text( + session, + &text, + regex, + full, + strict, + not, + fg, + bg, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + )?; + Ok(OperationResult::Unit) + } + Operation::ExpectExitCode { code, timeout_ms } => { + expect_exit_code( + session, + code, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)), + )?; + Ok(OperationResult::Unit) + } + Operation::ExpectOutput { text, regex } => { + expect_output(session, &text, regex)?; + Ok(OperationResult::Unit) + } + Operation::Snapshot { name, update, include_colors, cwd, - } => do_snapshot(s, &name, update, include_colors, cwd), - Request::Screenshot { full, path } => screenshot(s, full, path), - _ => Response::internal("unsupported request"), - }; - if response.kind == Some(ErrorKind::Assertion) { - if let Some(message) = response.message.take() { - response.message = Some(assertion_message(s, &message)); + } => Ok(OperationResult::Snapshot(do_snapshot( + session, + &name, + update, + include_colors, + cwd, + )?)), + Operation::Screenshot { full, path } => Ok(OperationResult::Screenshot(screenshot( + session, full, path, + )?)), + Operation::Open(_) | Operation::Run(_) | Operation::Close => { + Err(ShellUseError::internal("unsupported nested operation")) } } - response } -fn act(r: anyhow::Result<()>) -> Response { - match r { - Ok(()) => Response::ok(), - Err(e) => Response::internal(e.to_string()), - } +fn act(result: anyhow::Result<()>) -> Result<(), ShellUseError> { + result.map_err(|error| ShellUseError::internal(error.to_string())) } -fn state(s: &Session) -> Response { - let st = s.state.lock().unwrap(); - let (cx, cy) = st.emu.cursor(); - let (cols, rows) = st.emu.size(); - let text = text_of(&st.emu.viewable_rows()); - Response::with(json!({ - "session_shell": s.shell.map(|sh| sh.as_str()), - "cols": cols, - "rows": rows, - "cursor": { "x": cx, "y": cy }, - "cwd": st.tracker.cwd(), - "last_command": st.tracker.last_command(), - "last_exit": st.tracker.last_exit(), - "exited": st.exited, - "ready": st.tracker.is_ready(), - "timeouts": effective_timeouts(s), - "text": text, - })) +fn state(session: &TerminalSession) -> crate::api::State { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (x, y) = state.emu.cursor(); + let (cols, rows) = state.emu.size(); + crate::api::State { + session_shell: session.shell.map(|value| value.as_str().to_string()), + cols, + rows, + cursor: Cursor { x, y }, + cwd: state.tracker.cwd().map(str::to_string), + last_command: state.tracker.last_command().map(str::to_string), + last_exit: state.tracker.last_exit(), + exited: state.exited, + ready: state.tracker.is_ready(), + timeouts: effective_timeouts(session), + text: text_of(&state.emu.viewable_rows()), + } } -fn effective_timeouts(s: &Session) -> serde_json::Value { +fn effective_timeouts(session: &TerminalSession) -> EffectiveTimeouts { use config::TimeoutClass::*; - json!({ - "text": s.timeout_for(Text), - "idle": s.timeout_for(Idle), - "command": s.timeout_for(Command), - "exit": s.timeout_for(Exit), - "ready": s.timeout_for(Ready), - }) + EffectiveTimeouts { + text: session.timeout_for(Text), + idle: session.timeout_for(Idle), + command: session.timeout_for(Command), + exit: session.timeout_for(Exit), + ready: session.timeout_for(Ready), + } +} + +fn packed_screen(session: &TerminalSession, full: bool) -> PackedScreen { + let rows = grid(session, full); + PackedScreen { + cols: session.cols, + rows: rows.len().min(u16::MAX as usize) as u16, + utf8: rows_to_strings(&rows).join("\n").into_bytes(), + } } -fn cells(s: &Session, x: u16, y: u16, w: u16, h: u16) -> Response { - let rows = viewable(s); +fn cells(session: &TerminalSession, x: u16, y: u16, w: u16, h: u16) -> Vec { + let rows = viewable(session); let mut out = Vec::new(); for row in y..y.saturating_add(h.max(1)) { for col in x..x.saturating_add(w.max(1)) { - if let Some(cell) = rows.get(row as usize).and_then(|r| r.get(col as usize)) { - out.push(cell_json(col, row, cell)); + if let Some(cell) = rows + .get(row as usize) + .and_then(|line| line.get(col as usize)) + { + out.push(cell_model(col, row, cell)); } } } - Response::with(json!({ "cells": out })) -} - -/// One cell in wire form. Every attribute the neutral model carries is -/// reported, including ones no current backend can source, so a client can be -/// written against the full vocabulary rather than against alacritty. -fn cell_json(x: u16, y: u16, cell: &EmuCell) -> serde_json::Value { - json!({ - "x": x, - "y": y, - "char": cell.ch.as_str(), - "fg": color_json(cell.fg), - "bg": color_json(cell.bg), - "bold": cell.has(Attrs::BOLD), - "dim": cell.has(Attrs::DIM), - "italic": cell.has(Attrs::ITALIC), - "inverse": cell.has(Attrs::INVERSE), - "invisible": cell.has(Attrs::INVISIBLE), - "strike": cell.has(Attrs::STRIKE), - "blink": cell.has(Attrs::BLINK), - "underline": cell.underline.is_underlined(), - // Never null: an un-underlined cell is the "none" style, and an - // underline that follows the text color is "default", the same - // sentinel `fg` and `bg` use. A client can switch on the string - // without a null check. - "underline_style": cell.underline.name(), - "underline_color": color_json(cell.underline_color), - }) -} - -/// Wire form: `"default"`, a 256-color index, or `"#rrggbb"`. Named and -/// indexed colors both serialize to their palette index, so the split between -/// them stays internal and the language bindings are unaffected. -fn color_json(c: Option) -> serde_json::Value { - match c { - None => json!(crate::assert::color::DEFAULT), - Some(Color::Rgb(r, g, b)) => json!(format!("#{r:02x}{g:02x}{b:02x}")), - Some(c) => json!(c.to_index()), - } -} - -fn get(s: &Session, field: GetField) -> Response { - let st = s.state.lock().unwrap(); - let value = match field { - GetField::Command => json!(st.tracker.last_command()), - GetField::Output => json!(st.tracker.last_output()), - GetField::ExitCode => json!(st.tracker.last_exit()), - GetField::Cwd => json!(st.tracker.cwd()), - GetField::Cursor => { - let (x, y) = st.emu.cursor(); - json!({ "x": x, "y": y }) - } - GetField::Size => { - let (cols, rows) = st.emu.size(); - json!({ "cols": cols, "rows": rows }) - } - }; - Response::with(json!({ "value": value })) + out } -fn press(s: &Session, tokens: Vec) -> Response { - match keys::tokens_to_seq(&tokens) { - Ok(seq) => act(s.write(seq.as_bytes())), - Err(e) => Response::usage(e.to_string()), +fn cell_model(x: u16, y: u16, cell: &EmuCell) -> Cell { + Cell { + x, + y, + char: cell.ch.to_string(), + fg: cell_color(cell.fg), + bg: cell_color(cell.bg), + bold: cell.has(Attrs::BOLD), + dim: cell.has(Attrs::DIM), + italic: cell.has(Attrs::ITALIC), + inverse: cell.has(Attrs::INVERSE), + invisible: cell.has(Attrs::INVISIBLE), + strike: cell.has(Attrs::STRIKE), + blink: cell.has(Attrs::BLINK), + underline: cell.underline.is_underlined(), + underline_style: cell.underline.name().to_string(), + underline_color: cell_color(cell.underline_color), } } -fn mouse_action(s: &Session, action: MouseAction) -> Response { - let seq = match action { - MouseAction::Click { +fn cell_color(color: Option) -> CellColor { + match color { + None => CellColor::Default, + Some(Color::Rgb(r, g, b)) => CellColor::Rgb(r, g, b), + Some(color) => CellColor::Indexed(color.to_index()), + } +} + +fn press(session: &TerminalSession, tokens: Vec) -> Result<(), ShellUseError> { + let sequence = + keys::tokens_to_seq(&tokens).map_err(|error| ShellUseError::usage(error.to_string()))?; + act(session.write(sequence.as_bytes())) +} + +fn mouse_action( + session: &TerminalSession, + action: crate::api::MouseAction, +) -> Result<(), ShellUseError> { + let sequence = match action { + crate::api::MouseAction::Click { x, y, on_text, button, clicks, } => { - let (cx, cy) = if let Some(text) = on_text { - match locate_center(s, &text) { - Some(p) => p, - None => { - return Response::assertion(format!("text not found on screen: {text}")) - } - } + let (x, y) = if let Some(text) = on_text { + locate_center(session, &text).ok_or_else(|| { + ShellUseError::assertion(format!("text not found on screen: {text}")) + })? } else { (x.unwrap_or(0), y.unwrap_or(0)) }; let mut out = String::new(); for _ in 0..clicks.max(1) { - out.push_str(&mouse::click(cx, cy, button)); + out.push_str(&mouse::click(x, y, button)); } out } - MouseAction::Move { x, y } => mouse::motion(x, y), - MouseAction::Down { x, y, button } => mouse::down(x, y, button), - MouseAction::Up { x, y, button } => mouse::up(x, y, button), - MouseAction::Drag { + crate::api::MouseAction::Move { x, y } => mouse::motion(x, y), + crate::api::MouseAction::Down { x, y, button } => mouse::down(x, y, button), + crate::api::MouseAction::Up { x, y, button } => mouse::up(x, y, button), + crate::api::MouseAction::Drag { x1, y1, x2, @@ -540,32 +748,31 @@ fn mouse_action(s: &Session, action: MouseAction) -> Response { mouse::motion(x2, y2), mouse::up(x2, y2, button) ), - MouseAction::Scroll { direction, amount } => { + crate::api::MouseAction::Scroll { direction, amount } => { let up = direction.eq_ignore_ascii_case("up"); - let (cx, cy) = (0, 0); (0..amount.max(1)) - .map(|_| mouse::scroll(cx, cy, up)) + .map(|_| mouse::scroll(0, 0, up)) .collect() } }; - act(s.write(seq.as_bytes())) + act(session.write(sequence.as_bytes())) } -fn locate_center(s: &Session, text: &str) -> Option<(u16, u16)> { - let rows = viewable(s); +fn locate_center(session: &TerminalSession, text: &str) -> Option<(u16, u16)> { + let rows = viewable(session); let pattern = Pattern::new(text, false).ok()?; let cells = locator::find(&rows, &pattern, false).ok()??; if cells.is_empty() { return None; } - let mid = &cells[cells.len() / 2]; - Some((mid.x as u16, mid.y as u16)) + let middle = &cells[cells.len() / 2]; + Some((middle.x as u16, middle.y as u16)) } -fn poll_until bool>(mut f: F, timeout_ms: u64) -> bool { +fn poll_until bool>(mut predicate: F, timeout_ms: u64) -> bool { let start = Instant::now(); loop { - if f() { + if predicate() { return true; } if start.elapsed() >= Duration::from_millis(timeout_ms) { @@ -575,87 +782,134 @@ fn poll_until bool>(mut f: F, timeout_ms: u64) -> bool { } } -fn matches_now(s: &Session, pattern: &Pattern, full: bool, strict: bool) -> anyhow::Result { - let rows = grid(s, full); - Ok(locator::find(&rows, pattern, strict)?.is_some()) +fn matches_now( + session: &TerminalSession, + pattern: &Pattern, + full: bool, + strict: bool, +) -> anyhow::Result { + Ok(locator::find(&grid(session, full), pattern, strict)?.is_some()) +} + +fn session_stopped(session: &TerminalSession) -> bool { + session.cancelled.load(std::sync::atomic::Ordering::Acquire) + || session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .exited + .is_some() } fn wait_text( - s: &Session, + session: &TerminalSession, text: &str, regex: bool, full: bool, timeout_ms: u64, not: bool, -) -> Response { - let pattern = match Pattern::new(text, regex) { - Ok(p) => p, - Err(e) => return Response::usage(format!("invalid regex: {e}")), - }; - let found = poll_until( - || matches_now(s, &pattern, full, false).unwrap_or(false) != not, +) -> Result<(), ShellUseError> { + let pattern = Pattern::new(text, regex) + .map_err(|error| ShellUseError::usage(format!("invalid regex: {error}")))?; + let mut matched = false; + poll_until( + || { + matched = matches_now(session, &pattern, full, false).unwrap_or(false) != not; + matched || session_stopped(session) + }, timeout_ms, ); - if found { - Response::ok() - } else if not { - Response::assertion(timeout_message(&pattern.describe(), timeout_ms, true)) + if matched { + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited before '{}' became {}", + pattern.describe(), + if not { "hidden" } else { "visible" } + ))) } else { - Response::assertion(timeout_message(&pattern.describe(), timeout_ms, false)) + Err(ShellUseError::assertion(timeout_message( + &pattern.describe(), + timeout_ms, + not, + ))) } } -fn wait_idle(s: &Session, timeout_ms: u64) -> Response { +fn wait_idle(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { let quiet = Duration::from_millis(250); - let ok = poll_until( + if poll_until( || { - let st = s.state.lock().unwrap(); - st.last_change.elapsed() >= quiet + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_change + .elapsed() + >= quiet + || session.cancelled.load(std::sync::atomic::Ordering::Acquire) }, timeout_ms, - ); - if ok { - Response::ok() + ) { + Ok(()) } else { - Response::assertion("wait idle: screen kept changing until timeout") + Err(ShellUseError::assertion( + "wait idle: screen kept changing until timeout", + )) } } -fn awaiting_command_start(st: &TermState) -> bool { - st.awaiting_start - .is_some_and(|seen| st.tracker.started_count() == seen) +fn awaiting_command_start(state: &TermState) -> bool { + state + .awaiting_start + .is_some_and(|seen| state.tracker.started_count() == seen) } -fn command_settled(s: &Session, baseline: u64) -> bool { +fn command_settled(session: &TerminalSession, baseline: u64) -> bool { const QUIET: Duration = Duration::from_millis(300); - let st = s.state.lock().unwrap(); - if st.exited.is_some() { + if session.cancelled.load(std::sync::atomic::Ordering::Acquire) { return true; } - let tracker = &st.tracker; + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.exited.is_some() { + return true; + } + let tracker = &state.tracker; if !tracker.started() { - return st.last_change.elapsed() >= QUIET; + return state.last_change.elapsed() >= QUIET; } - if awaiting_command_start(&st) { + if awaiting_command_start(&state) { return false; } tracker.finished_count() > baseline || !tracker.executing() } -fn wait_command(s: &Session, timeout_ms: u64) -> Response { - let baseline = s.state.lock().unwrap().tracker.finished_count(); - if poll_until(|| command_settled(s, baseline), timeout_ms) { - return Response::ok(); +fn wait_command(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { + let baseline = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .finished_count(); + if poll_until(|| command_settled(session, baseline), timeout_ms) { + Ok(()) + } else { + Err(ShellUseError::assertion(format!( + "wait command: timed out after {timeout_ms}ms; {}", + stall_reason(session) + ))) } - Response::assertion(format!( - "wait command: timed out after {timeout_ms}ms; {}", - stall_reason(s) - )) } -fn stall_reason(s: &Session) -> String { - let st = s.state.lock().unwrap(); - if awaiting_command_start(&st) { +fn stall_reason(session: &TerminalSession) -> String { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if awaiting_command_start(&state) { "the shell never started a command for the input that was sent, so there \ is nothing to wait for (was the line submitted?)" .to_string() @@ -664,26 +918,40 @@ fn stall_reason(s: &Session) -> String { } } -fn wait_exit(s: &Session, timeout_ms: u64) -> Response { - let ok = poll_until(|| s.state.lock().unwrap().exited.is_some(), timeout_ms); - if ok { - Response::ok() +fn wait_exit(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { + if poll_until( + || { + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .exited + .is_some() + || session.cancelled.load(std::sync::atomic::Ordering::Acquire) + }, + timeout_ms, + ) { + Ok(()) } else { - Response::assertion("wait exit: session still running at timeout") + Err(ShellUseError::assertion( + "wait exit: session still running at timeout", + )) } } -fn wait_ready(s: &Session, timeout_ms: u64) -> Response { - if await_ready(s, timeout_ms) { - Response::ok() +fn wait_ready(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { + if await_ready(session, timeout_ms) { + Ok(()) } else { - Response::assertion("wait ready: no prompt was reported within timeout") + Err(ShellUseError::assertion( + "wait ready: no prompt was reported within timeout", + )) } } #[allow(clippy::too_many_arguments)] fn expect_text( - s: &Session, + session: &TerminalSession, text: &str, regex: bool, full: bool, @@ -692,56 +960,78 @@ fn expect_text( fg: Option, bg: Option, timeout_ms: u64, -) -> Response { - let pattern = match Pattern::new(text, regex) { - Ok(p) => p, - Err(e) => return Response::usage(format!("invalid regex: {e}")), - }; +) -> Result<(), ShellUseError> { + let pattern = Pattern::new(text, regex) + .map_err(|error| ShellUseError::usage(format!("invalid regex: {error}")))?; for spec in [&fg, &bg].into_iter().flatten() { - if let Err(e) = Expected::parse(spec) { - return Response::usage(e.to_string()); - } + Expected::parse(spec).map_err(|error| ShellUseError::usage(error.to_string()))?; } if fg.is_none() && bg.is_none() && not { - let gone = poll_until( - || !matches_now(s, &pattern, full, false).unwrap_or(true), + let mut gone = false; + poll_until( + || { + gone = !matches_now(session, &pattern, full, false).unwrap_or(true); + gone || session_stopped(session) + }, timeout_ms, ); return if gone { - Response::ok() + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited before '{}' became hidden", + pattern.describe() + ))) } else { - Response::assertion(timeout_message(&pattern.describe(), timeout_ms, true)) + Err(ShellUseError::assertion(timeout_message( + &pattern.describe(), + timeout_ms, + true, + ))) }; } - let mut last_err: Option = None; - let ok = poll_until( - || match locator::find(&grid(s, full), &pattern, strict) { - Ok(Some(cells)) if !cells.is_empty() => { - if let Some(err) = check_colors(&cells, &fg, &bg, not) { - last_err = Some(err); + let mut last_error = None; + let mut matched = false; + poll_until( + || { + matched = match locator::find(&grid(session, full), &pattern, strict) { + Ok(Some(cells)) if !cells.is_empty() => { + if let Some(error) = check_colors(&cells, &fg, &bg, not) { + last_error = Some(error); + false + } else { + true + } + } + Ok(_) => false, + Err(error) => { + last_error = Some(error.to_string()); false - } else { - true } - } - Ok(_) => false, - Err(e) => { - last_err = Some(e.to_string()); - false - } + }; + matched || session_stopped(session) }, timeout_ms, ); - if ok { - Response::ok() - } else if let Some(err) = last_err { - Response::assertion(err) + if matched { + Ok(()) + } else if let Some(error) = last_error { + Err(ShellUseError::assertion(error)) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited before '{}' matched", + pattern.describe() + ))) } else { - Response::assertion(timeout_message(&pattern.describe(), timeout_ms, false)) + Err(ShellUseError::assertion(timeout_message( + &pattern.describe(), + timeout_ms, + false, + ))) } } @@ -754,32 +1044,32 @@ fn check_colors( let want = !not; if let Some(spec) = fg { let expected = Expected::parse(spec).ok()?; - for c in cells { - if color::matches(c.cell.fg, &expected) != want { + for cell in cells { + if color::matches(cell.cell.fg, &expected) != want { return Some(format!( "expected fg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(c.cell.fg, &expected), - c.cell.ch, - c.x, - c.y + color::describe_cell(cell.cell.fg, &expected), + cell.cell.ch, + cell.x, + cell.y )); } } } if let Some(spec) = bg { let expected = Expected::parse(spec).ok()?; - for c in cells { - if color::matches(c.cell.bg, &expected) != want { + for cell in cells { + if color::matches(cell.cell.bg, &expected) != want { return Some(format!( "expected bg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(c.cell.bg, &expected), - c.cell.ch, - c.x, - c.y + color::describe_cell(cell.cell.bg, &expected), + cell.cell.ch, + cell.x, + cell.y )); } } @@ -787,88 +1077,101 @@ fn check_colors( None } -/// Assert the last completed command's exit code. -/// Wait first: `last_exit` holds the previous code until a new command finishes. -fn expect_exit_code(s: &Session, code: i32, timeout_ms: u64) -> Response { - let baseline = s.state.lock().unwrap().tracker.finished_count(); - if !poll_until(|| command_settled(s, baseline), timeout_ms) { - return Response::assertion(format!( +fn expect_exit_code( + session: &TerminalSession, + code: i32, + timeout_ms: u64, +) -> Result<(), ShellUseError> { + let baseline = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .finished_count(); + if !poll_until(|| command_settled(session, baseline), timeout_ms) { + return Err(ShellUseError::assertion(format!( "expected exit code {code}: timed out after {timeout_ms}ms; {}", - stall_reason(s) - )); + stall_reason(session) + ))); } - let actual = s.state.lock().unwrap().tracker.last_exit(); - match actual { - Some(a) if a == code => Response::ok(), - Some(a) => Response::assertion(format!("expected exit code {code}, got {a}")), - None => Response::assertion("no command exit code tracked yet"), + match session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .tracker + .last_exit() + { + Some(actual) if actual == code => Ok(()), + Some(actual) => Err(ShellUseError::assertion(format!( + "expected exit code {code}, got {actual}" + ))), + None => Err(ShellUseError::assertion("no command exit code tracked yet")), } } -fn expect_output(s: &Session, text: &str, regex: bool) -> Response { - let output = s +fn expect_output(session: &TerminalSession, text: &str, regex: bool) -> Result<(), ShellUseError> { + let output = session .state .lock() - .unwrap() + .unwrap_or_else(std::sync::PoisonError::into_inner) .tracker .last_output() - .map(|o| o.to_string()); - let Some(output) = output else { - return Response::assertion("no command output tracked yet"); - }; - let hit = if regex { - match regex::Regex::new(text) { - Ok(re) => re.is_match(&output), - Err(e) => return Response::usage(format!("invalid regex: {e}")), - } + .map(str::to_string) + .ok_or_else(|| ShellUseError::assertion("no command output tracked yet"))?; + let matched = if regex { + regex::Regex::new(text) + .map_err(|error| ShellUseError::usage(format!("invalid regex: {error}")))? + .is_match(&output) } else { output.contains(text) }; - if hit { - Response::ok() + if matched { + Ok(()) } else { - Response::assertion(format!( + Err(ShellUseError::assertion(format!( "output did not contain '{text}'\n---\n{output}\n---" - )) + ))) } } fn do_snapshot( - s: &Session, + session: &TerminalSession, name: &str, update: bool, include_colors: bool, cwd: Option, -) -> Response { - let rows = viewable(s); - let cols = s.cols; - let content = snapshot::serialize(&rows, cols, include_colors); +) -> Result { + let rows = viewable(session); + let content = snapshot::serialize(&rows, session.cols, include_colors); let base = cwd .map(std::path::PathBuf::from) .or_else(|| std::env::current_dir().ok()) .unwrap_or_default(); match snapshot::compare(&base, name, &content, update) { - Ok(SnapshotStatus::Passed) => Response::with(json!({ "status": "passed" })), - Ok(SnapshotStatus::Written) => Response::with(json!({ "status": "written" })), - Ok(SnapshotStatus::Updated) => Response::with(json!({ "status": "updated" })), - Ok(SnapshotStatus::Failed { expected, actual }) => Response::assertion(format!( + Ok(SnapshotStatus::Passed) => Ok(SnapshotResult::Passed), + Ok(SnapshotStatus::Written) => Ok(SnapshotResult::Written), + Ok(SnapshotStatus::Updated) => Ok(SnapshotResult::Updated), + Ok(SnapshotStatus::Failed { expected, actual }) => Err(ShellUseError::assertion(format!( "snapshot mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}" - )), - Err(e) => Response::internal(e.to_string()), + ))), + Err(error) => Err(ShellUseError::internal(error.to_string())), } } -fn screenshot(s: &Session, full: bool, path: Option) -> Response { - let rows = grid(s, full); +fn screenshot( + session: &TerminalSession, + full: bool, + path: Option, +) -> Result { + let rows = grid(session, full); match path { Some(path) => { - let svg = crate::render::svg::render_svg(&rows, s.cols); - match std::fs::write(&path, svg) { - Ok(()) => Response::with(json!({ "path": path })), - Err(e) => Response::internal(e.to_string()), - } + let svg = crate::render::svg::render_svg(&rows, session.cols); + std::fs::write(&path, svg) + .map_err(|error| ShellUseError::internal(error.to_string()))?; + Ok(ScreenshotResult::Path(path)) } - None => Response::with(json!({ "text": text_of(&rows) })), + None => Ok(ScreenshotResult::Text(text_of(&rows))), } } @@ -880,8 +1183,8 @@ fn timeout_message(pattern: &str, timeout_ms: u64, not: bool) -> String { ) } -fn assertion_message(s: &Session, message: &str) -> String { - let screen = snapshot::serialize(&viewable(s), s.cols, false); +fn assertion_message(session: &TerminalSession, message: &str) -> String { + let screen = snapshot::serialize(&viewable(session), session.cols, false); format!("{message}\n\nTerminal content:\n{screen}") } @@ -893,16 +1196,23 @@ fn format_timeout(timeout_ms: u64) -> String { } } +fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { + if let Some(message) = payload.downcast_ref::<&'static str>() { + message + } else if let Some(message) = payload.downcast_ref::() { + message.as_str() + } else { + "unknown panic" + } +} + #[cfg(test)] mod tests { use super::*; use crate::terminal::cell::{NamedColor, UnderlineStyle}; - /// Every attribute in the vocabulary reaches the wire, including ones the - /// alacritty backend can never source (blink), so clients written against - /// the full model keep working when another backend starts reporting them. #[test] - fn cell_json_reports_the_whole_vocabulary() { + fn cell_model_reports_the_whole_vocabulary() { let cell = EmuCell { ch: "x".into(), fg: Some(Color::Named(NamedColor::Red)), @@ -911,47 +1221,53 @@ mod tests { underline_color: Some(Color::Rgb(1, 2, 3)), attrs: Attrs::all(), }; - let v = cell_json(3, 4, &cell); - assert_eq!(v["x"], json!(3)); - assert_eq!(v["char"], json!("x")); - assert_eq!(v["fg"], json!(1)); - assert_eq!(v["bg"], json!(196)); - for key in [ - "bold", - "dim", - "italic", - "inverse", - "invisible", - "strike", - "blink", - "underline", - ] { - assert_eq!(v[key], json!(true), "{key} must be reported"); - } - assert_eq!(v["underline_style"], json!("curly")); - assert_eq!(v["underline_color"], json!("#010203")); - } - - /// The underline fields are never null, so a client can switch on the - /// style string and compare the color the same way it does `fg`. + let value = cell_model(3, 4, &cell); + assert_eq!(value.x, 3); + assert_eq!(value.char, "x"); + assert_eq!(value.fg, CellColor::Indexed(1)); + assert_eq!(value.bg, CellColor::Indexed(196)); + assert!(value.bold); + assert!(value.dim); + assert!(value.italic); + assert!(value.inverse); + assert!(value.invisible); + assert!(value.strike); + assert!(value.blink); + assert!(value.underline); + assert_eq!(value.underline_style, "curly"); + assert_eq!(value.underline_color, CellColor::Rgb(1, 2, 3)); + } + #[test] - fn cell_json_underline_fields_are_never_null() { - let v = cell_json(0, 0, &EmuCell::blank()); - assert_eq!(v["underline"], json!(false)); - assert_eq!(v["underline_style"], json!("none")); - assert_eq!(v["underline_color"], json!("default")); - assert_eq!(v["blink"], json!(false)); - - // Underlined, but with no color of its own: it follows the text color, - // which is the same thing `fg: "default"` means. + fn cell_model_underline_fields_are_never_absent() { + let value = cell_model(0, 0, &EmuCell::blank()); + assert!(!value.underline); + assert_eq!(value.underline_style, "none"); + assert_eq!(value.underline_color, CellColor::Default); + assert!(!value.blink); + let cell = EmuCell { underline: UnderlineStyle::Single, underline_color: None, ..EmuCell::blank() }; - let v = cell_json(0, 0, &cell); - assert_eq!(v["underline"], json!(true)); - assert_eq!(v["underline_style"], json!("single")); - assert_eq!(v["underline_color"], json!("default")); + let value = cell_model(0, 0, &cell); + assert!(value.underline); + assert_eq!(value.underline_style, "single"); + assert_eq!(value.underline_color, CellColor::Default); + } + + #[test] + fn panic_payloads_become_internal_errors() { + let error = std::panic::catch_unwind(|| panic!("ffi-panic")) + .map_err(|payload| { + ShellUseError::internal(format!( + "native terminal operation panicked: {}", + panic_message(payload.as_ref()) + )) + }) + .unwrap_err(); + assert_eq!(error.kind, ErrorKind::Internal); + assert!(error.message.contains("ffi-panic")); } } diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index b9d041b..ffa3784 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -1,12 +1,17 @@ +pub mod api; pub mod assert; pub mod config; pub mod engine; pub mod input; pub mod logger; -pub mod protocol; pub mod render; pub mod runtime; -pub mod session; pub mod shell; pub mod terminal; pub mod trace; + +mod session; + +pub use api::*; +pub use engine::Engine; +pub use runtime::{global_registry, Session, SessionHandle, SessionRegistry}; diff --git a/crates/shell-use/src/protocol.rs b/crates/shell-use/src/protocol.rs deleted file mode 100644 index 78d96df..0000000 --- a/crates/shell-use/src/protocol.rs +++ /dev/null @@ -1,415 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::shell::Shell; - -/// Per-session default timeouts, one per [`crate::config::TimeoutClass`]. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct TimeoutDefaults { - pub text: Option, - pub idle: Option, - pub command: Option, - pub exit: Option, - pub ready: Option, -} - -impl TimeoutDefaults { - /// The default configured for `class`, if any. - pub fn get(&self, class: crate::config::TimeoutClass) -> Option { - use crate::config::TimeoutClass::*; - match class { - Text => self.text, - Idle => self.idle, - Command => self.command, - Exit => self.exit, - Ready => self.ready, - } - } -} - -/// A terminal operation shared by native bindings and the cli adapter. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Request { - Ping, - Open { - shell: Option, - program: Option>, - cols: u16, - rows: u16, - cwd: Option, - env: Vec<(String, String)>, - /// Whether to wait for readiness; `None` keeps the target default. - #[serde(default)] - wait_ready: Option, - #[serde(default)] - timeouts: TimeoutDefaults, - }, - Close, - Status, - State, - Text { - full: bool, - }, - Cells { - x: u16, - y: u16, - w: u16, - h: u16, - }, - Get { - field: GetField, - }, - Write { - data: String, - }, - Submit { - data: Option, - }, - Press { - keys: Vec, - }, - Mouse { - action: MouseAction, - }, - Resize { - cols: u16, - rows: u16, - }, - Signal { - name: String, - }, - WaitText { - text: String, - regex: bool, - full: bool, - #[serde(default)] - timeout_ms: Option, - not: bool, - }, - WaitIdle { - #[serde(default)] - timeout_ms: Option, - }, - WaitCommand { - #[serde(default)] - timeout_ms: Option, - }, - WaitExit { - #[serde(default)] - timeout_ms: Option, - }, - WaitReady { - #[serde(default)] - timeout_ms: Option, - }, - ExpectText { - text: String, - regex: bool, - full: bool, - strict: bool, - not: bool, - fg: Option, - bg: Option, - #[serde(default)] - timeout_ms: Option, - }, - ExpectExitCode { - code: i32, - #[serde(default)] - timeout_ms: Option, - }, - ExpectOutput { - text: String, - regex: bool, - }, - Snapshot { - name: String, - update: bool, - include_colors: bool, - /// The client's working directory; `__snapshots__` is resolved against - /// it so snapshots land next to the caller, not the daemon. - #[serde(default)] - cwd: Option, - }, - Screenshot { - full: bool, - path: Option, - }, - Monitor { - cols: u16, - rows: u16, - }, - Shutdown, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum GetField { - Command, - Output, - ExitCode, - Cwd, - Cursor, - Size, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "op", rename_all = "snake_case")] -pub enum MouseAction { - Click { - x: Option, - y: Option, - on_text: Option, - button: u8, - clicks: u8, - }, - Move { - x: u16, - y: u16, - }, - Down { - x: u16, - y: u16, - button: u8, - }, - Up { - x: u16, - y: u16, - button: u8, - }, - Drag { - x1: u16, - y1: u16, - x2: u16, - y2: u16, - button: u8, - }, - Scroll { - direction: String, - amount: u16, - }, -} - -/// Classifies a failure so the cli can map it to a stable process exit code. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ErrorKind { - /// An assertion or wait condition was not met (e.g. `expect`/`wait`). - Assertion, - /// An invalid argument value reached the daemon (e.g. bad regex/color). - Usage, - /// No active session for the target (run `open`/`run` first). - NoSession, - /// An internal error (spawn, I/O, rendering, ...). - Internal, -} - -impl ErrorKind { - /// Stable process exit code for this failure class. - pub fn exit_code(self) -> i32 { - match self { - ErrorKind::Assertion => 1, - ErrorKind::Usage => 2, - ErrorKind::NoSession => 3, - ErrorKind::Internal => 5, - } - } -} - -/// A terminal operation result shared by native bindings and the cli adapter. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Response { - pub ok: bool, - /// Human/JSON payload describing the result. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data: Option, - /// Error or assertion-failure message when `ok` is false. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - /// Failure classification when `ok` is false; drives the cli exit code. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -impl Response { - pub fn ok() -> Self { - Response { - ok: true, - data: None, - message: None, - kind: None, - } - } - - pub fn with(data: serde_json::Value) -> Self { - Response { - ok: true, - data: Some(data), - message: None, - kind: None, - } - } - - /// A failure of the given class. - pub fn err(kind: ErrorKind, message: impl Into) -> Self { - Response { - ok: false, - data: None, - message: Some(message.into()), - kind: Some(kind), - } - } - - /// An assertion / wait failure (exit code 1). - pub fn assertion(message: impl Into) -> Self { - Response::err(ErrorKind::Assertion, message) - } - - /// An invalid-argument failure (exit code 2). - pub fn usage(message: impl Into) -> Self { - Response::err(ErrorKind::Usage, message) - } - - /// A "no active session" failure (exit code 3). - pub fn no_session() -> Self { - Response::err( - ErrorKind::NoSession, - "no active session; run `shell-use open` (or `shell-use run `) first", - ) - } - - /// An internal failure (exit code 5). - pub fn internal(message: impl Into) -> Self { - Response::err(ErrorKind::Internal, message) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_open_req(wait_ready: Option, timeouts: TimeoutDefaults) -> Request { - Request::Open { - shell: None, - program: None, - cols: 80, - rows: 30, - cwd: None, - env: vec![], - wait_ready, - timeouts, - } - } - - /// Clients released before `wait_ready` existed must still deserialize. - #[test] - fn open_without_wait_ready_still_deserializes() { - let raw = r#"{"kind":"open","shell":null,"program":null,"cols":80,"rows":30, - "cwd":null,"env":[]}"#; - let req: Request = serde_json::from_str(raw).expect("deserialize legacy open"); - match req { - Request::Open { - wait_ready, - cols, - timeouts, - .. - } => { - assert_eq!(wait_ready, None); - assert_eq!(cols, 80); - assert_eq!( - timeouts, - TimeoutDefaults::default(), - "an absent timeouts object means nothing is configured" - ); - } - other => panic!("expected Open, got {other:?}"), - } - } - - /// Older clients' concrete `timeout_ms` must remain an explicit override. - #[test] - fn waits_accept_a_concrete_timeout_from_older_clients() { - let raw = r#"{"kind":"wait_idle","timeout_ms":1234}"#; - match serde_json::from_str::(raw).expect("deserialize wait_idle") { - Request::WaitIdle { timeout_ms } => assert_eq!(timeout_ms, Some(1234)), - other => panic!("expected WaitIdle, got {other:?}"), - } - } - - #[test] - fn waits_treat_an_absent_timeout_as_unset() { - for raw in [ - r#"{"kind":"wait_idle"}"#, - r#"{"kind":"wait_command"}"#, - r#"{"kind":"wait_exit"}"#, - r#"{"kind":"wait_ready"}"#, - ] { - let req: Request = serde_json::from_str(raw).expect("deserialize wait"); - let timeout = match req { - Request::WaitIdle { timeout_ms } - | Request::WaitCommand { timeout_ms } - | Request::WaitExit { timeout_ms } - | Request::WaitReady { timeout_ms } => timeout_ms, - other => panic!("expected a wait, got {other:?}"), - }; - assert_eq!(timeout, None, "{raw} should leave the timeout unset"); - } - } - - /// `expect exit-code` gained a timeout; older payloads omit it. - #[test] - fn expect_exit_code_timeout_is_optional() { - let raw = r#"{"kind":"expect_exit_code","code":0}"#; - match serde_json::from_str::(raw).expect("deserialize expect_exit_code") { - Request::ExpectExitCode { code, timeout_ms } => { - assert_eq!(code, 0); - assert_eq!(timeout_ms, None); - } - other => panic!("expected ExpectExitCode, got {other:?}"), - } - } - - #[test] - fn open_round_trips_session_timeout_defaults() { - let timeouts = TimeoutDefaults { - text: Some(30_000), - idle: Some(15_000), - ready: Some(20_000), - ..TimeoutDefaults::default() - }; - let req = make_open_req(None, timeouts); - let encoded = serde_json::to_string(&req).expect("serialize open"); - match serde_json::from_str::(&encoded).expect("deserialize open") { - Request::Open { timeouts: got, .. } => { - assert_eq!(got, timeouts); - assert_eq!(got.get(crate::config::TimeoutClass::Text), Some(30_000)); - assert_eq!(got.get(crate::config::TimeoutClass::Command), None); - } - other => panic!("expected Open, got {other:?}"), - } - } - - #[test] - fn open_round_trips_an_explicit_wait_ready() { - for expected in [Some(true), Some(false), None] { - let req = make_open_req(expected, TimeoutDefaults::default()); - let encoded = serde_json::to_string(&req).expect("serialize open"); - let decoded: Request = serde_json::from_str(&encoded).expect("deserialize open"); - match decoded { - Request::Open { wait_ready, .. } => assert_eq!(wait_ready, expected), - other => panic!("expected Open, got {other:?}"), - } - } - } - - #[test] - fn wait_ready_uses_a_snake_case_kind() { - let req: Request = serde_json::from_str(r#"{"kind":"wait_ready","timeout_ms":1234}"#) - .expect("deserialize wait_ready"); - match req { - Request::WaitReady { timeout_ms } => assert_eq!(timeout_ms, Some(1234)), - other => panic!("expected WaitReady, got {other:?}"), - } - } -} diff --git a/crates/shell-use/src/runtime.rs b/crates/shell-use/src/runtime.rs index 8988d80..ebc2319 100644 --- a/crates/shell-use/src/runtime.rs +++ b/crates/shell-use/src/runtime.rs @@ -1,44 +1,27 @@ use std::collections::{HashMap, VecDeque}; -use std::fmt; -use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, Weak}; -use serde_json::Value; use sha2::{Digest, Sha256}; +use crate::api::{OpenOptions, OpenResult, Operation, OperationResult, RunOptions, ShellUseError}; use crate::engine::Engine; use crate::logger::Logger; -use crate::protocol::{ErrorKind, Request, Response}; const MAX_COMPLETED_RECORDINGS: usize = 1024; -#[derive(Debug, Clone)] -pub struct ShellUseError { - pub kind: ErrorKind, - pub message: String, -} - -impl fmt::Display for ShellUseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for ShellUseError {} - #[derive(Clone)] -pub struct Runtime { +pub struct Session { name: Arc, engine: Arc, } -impl Runtime { +impl Session { pub fn new(name: impl Into) -> Self { let name = name.into(); let recording_path = native_recording_path(&name); - Runtime { + Self { name: Arc::from(name.as_str()), engine: Arc::new(Engine::new( name, @@ -52,44 +35,40 @@ impl Runtime { &self.name } - pub fn response(&self, request: Request) -> Response { - if matches!( - request, - Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown - ) { - return Response::usage("request is only available through the cli daemon"); + pub fn execute(&self, operation: Operation) -> Result { + self.engine.execute(operation) + } + + pub fn open(&self, options: OpenOptions) -> Result { + match self.execute(Operation::Open(options))? { + OperationResult::Open(result) => Ok(result), + _ => Err(ShellUseError::internal( + "open returned an unexpected result type", + )), } - catch_unwind(AssertUnwindSafe(|| self.engine.handle(request).0)).unwrap_or_else(|payload| { - Response::internal(format!( - "native terminal operation panicked: {}", - panic_message(payload.as_ref()) - )) - }) - } - - pub fn response_value(&self, request: Value) -> Response { - match serde_json::from_value(request) { - Ok(request) => self.response(request), - Err(error) => Response::usage(format!("invalid request: {error}")), + } + + pub fn run(&self, options: RunOptions) -> Result { + match self.execute(Operation::Run(options))? { + OperationResult::Open(result) => Ok(result), + _ => Err(ShellUseError::internal( + "run returned an unexpected result type", + )), } } - pub fn request(&self, request: Request) -> Result { - unwrap_response(self.response(request)) + pub fn close(&self) -> Result<(), ShellUseError> { + self.execute(Operation::Close).map(|_| ()) } - pub fn request_value(&self, request: Value) -> Result { - unwrap_response(self.response_value(request)) + pub fn interrupt(&self) { + self.engine.interrupt(); } pub fn is_open(&self) -> bool { self.engine.is_open() } - pub fn close(&self) -> Result<(), ShellUseError> { - self.request(Request::Close).map(|_| ()) - } - pub fn recording_path(&self) -> &Path { self.engine.recording_path() } @@ -99,8 +78,55 @@ impl Runtime { } } +#[derive(Clone)] +pub struct SessionHandle { + name: Arc, + registry: SessionRegistry, +} + +impl SessionHandle { + pub fn name(&self) -> &str { + &self.name + } + + pub fn execute(&self, operation: Operation) -> Result { + self.registry.execute(&self.name, operation) + } + + pub fn open(&self, options: OpenOptions) -> Result { + match self.execute(Operation::Open(options))? { + OperationResult::Open(result) => Ok(result), + _ => Err(ShellUseError::internal( + "open returned an unexpected result type", + )), + } + } + + pub fn run(&self, options: RunOptions) -> Result { + match self.execute(Operation::Run(options))? { + OperationResult::Open(result) => Ok(result), + _ => Err(ShellUseError::internal( + "run returned an unexpected result type", + )), + } + } + + pub fn close(&self) -> Result<(), ShellUseError> { + self.registry.close(&self.name) + } + + pub fn recording(&self) -> std::io::Result { + self.registry.recording(&self.name) + } +} + +#[derive(Clone)] pub struct SessionRegistry { - sessions: Mutex>, + inner: Arc, +} + +struct RegistryInner { + sessions: Mutex>, recordings: Mutex, generations: Mutex>>>, lifecycle: RwLock<()>, @@ -114,56 +140,66 @@ struct CompletedRecordings { impl Default for SessionRegistry { fn default() -> Self { - SessionRegistry { - sessions: Mutex::new(HashMap::new()), - recordings: Mutex::new(CompletedRecordings::default()), - generations: Mutex::new(HashMap::new()), - lifecycle: RwLock::new(()), + Self { + inner: Arc::new(RegistryInner { + sessions: Mutex::new(HashMap::new()), + recordings: Mutex::new(CompletedRecordings::default()), + generations: Mutex::new(HashMap::new()), + lifecycle: RwLock::new(()), + }), } } } impl SessionRegistry { - fn get_or_create_locked(&self, name: String) -> Runtime { + pub fn session(&self, name: impl Into) -> SessionHandle { + let name = name.into(); + SessionHandle { + name: Arc::from(name), + registry: self.clone(), + } + } + + fn get_or_create_locked(&self, name: String) -> Session { let mut sessions = self.lock_sessions(); sessions .entry(name.clone()) - .or_insert_with(|| Runtime::new(name)) + .or_insert_with(|| Session::new(name)) .clone() } - pub fn response_value(&self, name: &str, request: Value) -> Response { - match serde_json::from_value(request) { - Ok(request) => self.response(name, request), - Err(error) => Response::usage(format!("invalid request: {error}")), - } - } - - pub fn response(&self, name: &str, request: Request) -> Response { - if matches!( - request, - Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown - ) { - return Response::usage("request is only available through the cli daemon"); - } - let _lifecycle = self - .lifecycle - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); + pub fn execute( + &self, + name: &str, + operation: Operation, + ) -> Result { let generation = self.generation(name); let _generation = generation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - match request { - Request::Open { .. } => self - .get_or_create_locked(name.to_string()) - .response(request), - Request::Close => self.close_response_locked(name), + match operation { + Operation::Open(_) | Operation::Run(_) => { + let _lifecycle = self + .inner + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.get_or_create_locked(name.to_string()) + .execute(operation) + } + Operation::Close => self.close_locked(name).map(|_| OperationResult::Unit), other => { - let runtime = self.lock_sessions().get(name).cloned(); - runtime - .map(|runtime| runtime.response(other)) - .unwrap_or_else(Response::no_session) + let session = { + let _lifecycle = self + .inner + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.lock_sessions().get(name).cloned() + }; + session + .ok_or_else(ShellUseError::no_session)? + .execute(other) } } } @@ -172,39 +208,36 @@ impl SessionRegistry { let sessions = self .lock_sessions() .iter() - .map(|(name, runtime)| (name.clone(), runtime.clone())) + .map(|(name, session)| (name.clone(), session.clone())) .collect::>(); let mut names = sessions .into_iter() - .filter_map(|(name, runtime)| runtime.is_open().then_some(name)) + .filter_map(|(name, session)| session.is_open().then_some(name)) .collect::>(); names.sort(); names } pub fn close(&self, name: &str) -> Result<(), ShellUseError> { - let _lifecycle = self - .lifecycle - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); let generation = self.generation(name); let _generation = generation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - unwrap_response(self.close_response_locked(name)).map(|_| ()) + self.close_locked(name) } pub fn close_all(&self) { - let _lifecycle = self - .lifecycle - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); let (sessions, removed) = { + let _lifecycle = self + .inner + .lifecycle + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut recordings = self.lock_recordings(); let sessions = std::mem::take(&mut *self.lock_sessions()); let mut removed = Vec::new(); - for (name, runtime) in &sessions { - let path = runtime.recording_path(); + for (name, session) in &sessions { + let path = session.recording_path(); if path.is_file() { removed.extend(Self::cache_recording( &mut recordings, @@ -216,47 +249,79 @@ impl SessionRegistry { (sessions, removed) }; Self::remove_recording_files(removed); - for runtime in sessions.into_values() { - let _ = runtime.close(); + for session in sessions.values() { + session.interrupt(); + } + for session in sessions.into_values() { + let _ = session.close(); } } pub fn recording(&self, name: &str) -> std::io::Result { - let _lifecycle = self - .lifecycle - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); let generation = self.generation(name); let _generation = generation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let recordings = self.lock_recordings(); - let runtime = self.lock_sessions().get(name).cloned(); - if let Some(runtime) = runtime { - let result = runtime.recording(); - drop(recordings); - return result; + let (session, completed) = { + let _lifecycle = self + .inner + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let recordings = self.lock_recordings(); + let session = self.lock_sessions().get(name).cloned(); + let completed = recordings.paths.get(name).cloned(); + (session, completed) + }; + if let Some(session) = session { + return session.recording(); } - let path = recordings.paths.get(name).ok_or_else(|| { + let path = completed.ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "unknown native session") })?; std::fs::read_to_string(path) } - fn lock_sessions(&self) -> MutexGuard<'_, HashMap> { - self.sessions + fn close_locked(&self, name: &str) -> Result<(), ShellUseError> { + let (session, removed) = { + let _lifecycle = self + .inner + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut recordings = self.lock_recordings(); + let Some(session) = self.lock_sessions().remove(name) else { + return Ok(()); + }; + let path = session.recording_path(); + let removed = if path.is_file() { + Self::cache_recording(&mut recordings, name.to_string(), path.to_path_buf()) + } else { + Vec::new() + }; + (session, removed) + }; + Self::remove_recording_files(removed); + session.close() + } + + fn lock_sessions(&self) -> MutexGuard<'_, HashMap> { + self.inner + .sessions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn lock_recordings(&self) -> MutexGuard<'_, CompletedRecordings> { - self.recordings + self.inner + .recordings .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn generation(&self, name: &str) -> Arc> { let mut generations = self + .inner .generations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -269,24 +334,6 @@ impl SessionRegistry { generation } - fn close_response_locked(&self, name: &str) -> Response { - let (runtime, removed) = { - let mut recordings = self.lock_recordings(); - let Some(runtime) = self.lock_sessions().remove(name) else { - return Response::ok(); - }; - let path = runtime.recording_path(); - let removed = if path.is_file() { - Self::cache_recording(&mut recordings, name.to_string(), path.to_path_buf()) - } else { - Vec::new() - }; - (runtime, removed) - }; - Self::remove_recording_files(removed); - runtime.response(Request::Close) - } - #[cfg(test)] fn remember_recording(&self, name: String, path: PathBuf) { let removed = Self::cache_recording(&mut self.lock_recordings(), name, path); @@ -341,32 +388,10 @@ fn native_recording_path(name: &str) -> PathBuf { .join(format!("{}-{sequence}.cast", &digest[..16])) } -fn unwrap_response(response: Response) -> Result { - if response.ok { - return Ok(response.data.unwrap_or(Value::Null)); - } - Err(ShellUseError { - kind: response.kind.unwrap_or(ErrorKind::Internal), - message: response - .message - .unwrap_or_else(|| "shell-use operation failed".to_string()), - }) -} - -fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { - if let Some(message) = payload.downcast_ref::<&'static str>() { - message - } else if let Some(message) = payload.downcast_ref::() { - message.as_str() - } else { - "unknown panic" - } -} - #[cfg(test)] mod tests { use super::*; - use serde_json::json; + use crate::api::{ErrorKind, Operation}; #[test] fn registry_reuses_names_and_lists_only_open_sessions() { @@ -378,24 +403,10 @@ mod tests { } #[test] - fn invalid_request_is_a_usage_error() { - let runtime = Runtime::new("invalid-request"); - let response = runtime.response_value(json!({"kind": "missing"})); - assert_eq!(response.kind, Some(ErrorKind::Usage)); - } - - #[test] - fn cli_control_requests_are_rejected() { - let runtime = Runtime::new("cli-control"); - for request in [ - Request::Ping, - Request::Status, - Request::Monitor { cols: 80, rows: 24 }, - Request::Shutdown, - ] { - let response = runtime.response(request); - assert_eq!(response.kind, Some(ErrorKind::Usage)); - } + fn closed_session_operations_report_no_session() { + let registry = SessionRegistry::default(); + let error = registry.execute("missing", Operation::State).unwrap_err(); + assert_eq!(error.kind, ErrorKind::NoSession); } #[test] @@ -428,7 +439,7 @@ mod tests { } #[test] - fn non_open_requests_do_not_hide_completed_recordings() { + fn missing_operations_do_not_hide_completed_recordings() { let registry = SessionRegistry::default(); let path = std::env::temp_dir().join(format!( "shell-use-retained-recording-{}.cast", @@ -438,12 +449,11 @@ mod tests { registry.remember_recording("retained".to_string(), path.clone()); assert_eq!( - registry.response("retained", Request::State).kind, - Some(ErrorKind::NoSession) - ); - assert_eq!( - registry.response("retained", Request::Shutdown).kind, - Some(ErrorKind::Usage) + registry + .execute("retained", Operation::State) + .unwrap_err() + .kind, + ErrorKind::NoSession ); assert_eq!(registry.recording("retained").unwrap(), "retained"); assert!(registry.sessions().is_empty()); @@ -471,7 +481,7 @@ mod tests { } #[test] - fn active_runtime_does_not_fall_back_to_prior_recording() { + fn active_session_does_not_fall_back_to_prior_recording() { let registry = SessionRegistry::default(); let path = std::env::temp_dir().join(format!( "shell-use-prior-recording-{}.cast", diff --git a/crates/shell-use/src/session.rs b/crates/shell-use/src/session.rs index 41cbd35..4be0396 100644 --- a/crates/shell-use/src/session.rs +++ b/crates/shell-use/src/session.rs @@ -2,6 +2,7 @@ //! tracker, with a background reader thread. use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Instant; @@ -29,9 +30,10 @@ pub struct Session { pub cols: u16, pub rows: u16, /// Per-class timeout defaults for the lifetime of this session. - pub timeouts: crate::protocol::TimeoutDefaults, + pub timeouts: crate::api::Timeouts, pub pty: Arc>, pub state: Arc>, + pub cancelled: Arc, recorder: Arc>, logger: Arc, _reader: JoinHandle<()>, @@ -53,7 +55,7 @@ impl Session { rows: u16, cwd: Option, env: Vec<(String, String)>, - timeouts: crate::protocol::TimeoutDefaults, + timeouts: crate::api::Timeouts, logger: Arc, recording_path: PathBuf, ) -> anyhow::Result { @@ -83,6 +85,7 @@ impl Session { exited: None, })); let pty = Arc::new(Mutex::new(pty)); + let cancelled = Arc::new(AtomicBool::new(false)); let mut rec_env: Vec<(&str, String)> = vec![("TERM", "xterm-256color".to_string())]; if let Some(sh) = shell { @@ -108,9 +111,14 @@ impl Session { Ok(0) | Err(_) => break, Ok(n) => { reader_logger.read(&buf[..n]); - reader_recorder.lock().unwrap().on_data(&buf[..n]); + reader_recorder + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .on_data(&buf[..n]); let pending = { - let mut st = reader_state.lock().unwrap(); + let mut st = reader_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); st.emu.process(&buf[..n]); st.tracker.feed(&buf[..n]); st.last_change = Instant::now(); @@ -127,7 +135,9 @@ impl Session { } let code = reader_pty.lock().ok().and_then(|mut p| p.try_wait()); reader_logger.event(&format!("pty exited code={:?}", code)); - let mut st = reader_state.lock().unwrap(); + let mut st = reader_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); st.exited = Some(code.unwrap_or(0)); st.last_change = Instant::now(); }); @@ -144,6 +154,7 @@ impl Session { timeouts, pty, state, + cancelled, recorder, logger, _reader: handle, @@ -153,13 +164,19 @@ impl Session { pub fn write(&self, data: &[u8]) -> anyhow::Result<()> { self.logger.write(data); { - let mut st = self.state.lock().unwrap(); + let mut st = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if !st.tracker.executing() { let started_count = st.tracker.started_count(); st.awaiting_start = Some(started_count); } } - self.pty.lock().unwrap().write(data)?; + self.pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .write(data)?; Ok(()) } @@ -172,21 +189,37 @@ impl Session { pub fn resize(&mut self, cols: u16, rows: u16) -> anyhow::Result<()> { self.logger.event(&format!("resize {cols}x{rows}")); - self.recorder.lock().unwrap().on_resize(cols, rows); + self.recorder + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .on_resize(cols, rows); self.cols = cols; self.rows = rows; - let mut st = self.state.lock().unwrap(); + let mut st = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); st.emu.resize(cols, rows); drop(st); - self.pty.lock().unwrap().resize(cols, rows)?; + self.pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .resize(cols, rows)?; Ok(()) } pub fn kill(&self) { - self.pty.lock().unwrap().kill(); + self.cancelled.store(true, Ordering::Release); + self.pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .kill(); } pub fn pid(&self) -> Option { - self.pty.lock().unwrap().pid() + self.pty + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pid() } } diff --git a/crates/shell-use/tests/runtime.rs b/crates/shell-use/tests/runtime.rs index 0044715..89a8bfc 100644 --- a/crates/shell-use/tests/runtime.rs +++ b/crates/shell-use/tests/runtime.rs @@ -1,121 +1,79 @@ -use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; -use shell_use::protocol::{ErrorKind, Request, TimeoutDefaults}; -use shell_use::runtime::{global_registry, SessionRegistry}; use std::sync::Arc; use std::time::{Duration, Instant}; +use shell_use::{ + global_registry, ErrorKind, OpenOptions, Operation, OperationResult, SessionRegistry, Timeouts, +}; + #[test] -fn named_runtimes_share_a_process_local_terminal() { +fn named_handles_share_a_process_local_terminal() { let name = format!("native-runtime-{}", std::process::id()); let registry = global_registry(); - let response = registry.response( - &name, - Request::Open { - shell: None, - program: None, - cols: DEFAULT_COLS, - rows: DEFAULT_ROWS, - cwd: None, - env: Vec::new(), - wait_ready: None, - timeouts: TimeoutDefaults::default(), - }, - ); - assert!(response.ok); - assert!( - registry - .response( - &name, - Request::Submit { - data: Some("echo native-runtime".to_string()), - }, - ) - .ok - ); - assert!( - registry - .response( - &name, - Request::WaitCommand { - timeout_ms: Some(30_000), - }, - ) - .ok - ); - assert!( - registry - .response( - &name, - Request::ExpectText { - text: "native-runtime".to_string(), - regex: false, - full: false, - strict: false, - not: false, - fg: None, - bg: None, - timeout_ms: Some(5_000), - }, - ) - .ok - ); + let first = registry.session(name.clone()); + let second = registry.session(name.clone()); + + first.open(OpenOptions::default()).expect("open terminal"); + second + .execute(Operation::Submit { + data: Some("echo native-runtime".to_string()), + }) + .expect("submit command"); + first + .execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + }) + .expect("wait for command"); + second + .execute(Operation::ExpectText { + text: "native-runtime".to_string(), + regex: false, + full: false, + strict: false, + not: false, + fg: None, + bg: None, + timeout_ms: Some(5_000), + }) + .expect("find command output"); assert!(registry.sessions().contains(&name)); - registry.close(&name).expect("close terminal"); + first.close().expect("close terminal"); assert!(!registry.sessions().contains(&name)); - assert!(registry - .recording(&name) - .expect("read closed recording") + assert!(second + .recording() + .expect("read recording") .contains("native-runtime")); - assert!( - registry - .response( - &name, - Request::Open { - shell: None, - program: None, - cols: DEFAULT_COLS, - rows: DEFAULT_ROWS, - cwd: None, - env: Vec::new(), - wait_ready: Some(false), - timeouts: TimeoutDefaults::default(), - }, - ) - .ok - ); - registry.close(&name).expect("close replacement"); + second + .open(OpenOptions { + wait_ready: Some(false), + ..OpenOptions::default() + }) + .expect("open replacement"); + first + .close() + .expect("close replacement through first handle"); } #[test] fn unrelated_session_state_does_not_wait_behind_another_session() { let registry = Arc::new(SessionRegistry::default()); for name in ["waiting", "responsive"] { - assert!( - registry - .response( - name, - Request::Open { - shell: None, - program: None, - cols: DEFAULT_COLS, - rows: DEFAULT_ROWS, - cwd: None, - env: Vec::new(), - wait_ready: Some(false), - timeouts: TimeoutDefaults::default(), - }, - ) - .ok - ); + registry + .session(name) + .open(OpenOptions { + wait_ready: Some(false), + timeouts: Timeouts::default(), + ..OpenOptions::default() + }) + .expect("open terminal"); } let waiting = Arc::clone(®istry); let wait = std::thread::spawn(move || { - waiting.response( + waiting.execute( "waiting", - Request::WaitText { + Operation::WaitText { text: "text-that-will-never-appear".to_string(), regex: false, full: false, @@ -127,9 +85,65 @@ fn unrelated_session_state_does_not_wait_behind_another_session() { std::thread::sleep(Duration::from_millis(100)); let start = Instant::now(); - assert!(registry.response("responsive", Request::State).ok); + assert!(matches!( + registry.execute("responsive", Operation::State), + Ok(OperationResult::State(_)) + )); assert!(start.elapsed() < Duration::from_millis(400)); - assert_eq!(wait.join().unwrap().kind, Some(ErrorKind::Assertion)); + assert_eq!(wait.join().unwrap().unwrap_err().kind, ErrorKind::Assertion); registry.close_all(); } + +#[test] +fn packed_screen_is_native_owned_utf8() { + let registry = SessionRegistry::default(); + let session = registry.session("packed-screen"); + session + .open(OpenOptions { + wait_ready: Some(false), + ..OpenOptions::default() + }) + .expect("open terminal"); + let OperationResult::PackedScreen(screen) = session + .execute(Operation::PackedScreen { full: false }) + .expect("capture packed screen") + else { + panic!("unexpected packed screen result"); + }; + let text = String::from_utf8(screen.utf8).expect("packed screen is UTF-8"); + assert_eq!(text.split('\n').count(), screen.rows as usize); + session.close().expect("close terminal"); +} + +#[test] +fn close_all_interrupts_in_flight_waits() { + let registry = Arc::new(SessionRegistry::default()); + registry + .session("interrupt-wait") + .open(OpenOptions { + wait_ready: Some(false), + ..OpenOptions::default() + }) + .expect("open terminal"); + + let waiting = Arc::clone(®istry); + let wait = std::thread::spawn(move || { + waiting.execute( + "interrupt-wait", + Operation::WaitText { + text: "never-appears".to_string(), + regex: false, + full: false, + timeout_ms: Some(30_000), + not: false, + }, + ) + }); + std::thread::sleep(Duration::from_millis(100)); + + let start = Instant::now(); + registry.close_all(); + assert!(start.elapsed() < Duration::from_secs(2)); + assert_eq!(wait.join().unwrap().unwrap_err().kind, ErrorKind::Assertion); +}