From 0395620d362ef75c74cd488f9577afda68922586 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 2 Oct 2026 16:37:44 -0500 Subject: [PATCH] Docs(examples): Add complete API programs why: Core API reference pages need runnable examples with explicit tmux ownership, dependencies, and cleanup. what: - Add seven standalone Lua programs and a complete private-daemon launcher. - Bind eleven native API identities through a validated source manifest. - Export whole files and run them from isolated installed source rocks, including import and runtime failure cleanup checks. The installed-package check and focused validation pass. Required mid and outer attempts hit the existing five-second tooling-test timeout under host load; those gates remain incomplete and their budgets are unchanged. --- CHANGES.md | 2 + examples/api/README.md | 39 ++++++++++ examples/api/capture.lua | 42 +++++++++++ examples/api/connect.lua | 21 ++++++ examples/api/manifest.json | 77 ++++++++++++++++++++ examples/api/new_session.lua | 29 ++++++++ examples/api/new_window.lua | 29 ++++++++ examples/api/query.lua | 36 +++++++++ examples/api/run.sh | 30 ++++++++ examples/api/send_keys.lua | 42 +++++++++++ examples/api/snapshot.lua | 39 ++++++++++ scripts/api_examples.py | 73 +++++++++++++++++++ scripts/export-docs | 12 ++- scripts/package.py | 41 +++++++++++ tests/tooling/test_api_examples.py | 113 +++++++++++++++++++++++++++++ 15 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 examples/api/README.md create mode 100644 examples/api/capture.lua create mode 100644 examples/api/connect.lua create mode 100644 examples/api/manifest.json create mode 100644 examples/api/new_session.lua create mode 100644 examples/api/new_window.lua create mode 100644 examples/api/query.lua create mode 100644 examples/api/run.sh create mode 100644 examples/api/send_keys.lua create mode 100644 examples/api/snapshot.lua create mode 100644 scripts/api_examples.py create mode 100644 tests/tooling/test_api_examples.py diff --git a/CHANGES.md b/CHANGES.md index cf19ee2..db784cc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Docs: add complete common API examples with private tmux setup and cleanup. + ## 0.1.0alpha2-1 - Types: declare `libtmux.Session`, `Window`, `WindowLink`, `Pane`, `Client` diff --git a/examples/api/README.md b/examples/api/README.md new file mode 100644 index 0000000..5d2a8cc --- /dev/null +++ b/examples/api/README.md @@ -0,0 +1,39 @@ +# Complete API examples + +Each Lua file is a standalone program. Save it beside [run.sh](run.sh), which +starts a private tmux daemon and stops it after the program finishes or fails. +No existing session is required. The connection closes without stopping the +daemon; the launcher checks that its bootstrap session still exists before +cleanup. A failed daemon shutdown keeps the socket directory and reports it. + +Use Lua 5.5.1, LuaRocks, Git, tmux 3.2a or newer, a C compiler, and CMake on +Linux. From the checked-out repository root, install the library and its +standalone runtime dependency into a local tree: + +```console +$ luarocks --tree ./rocks install luv 1.52.1-0 && \ + luarocks --tree ./rocks make rockspecs/libtmux-scm-1.rockspec && \ + eval "$(luarocks --tree ./rocks path)" +``` + +Run one complete program: + +```console +$ sh examples/api/run.sh examples/api/connect.lua +``` + +| Program | Task | Expected output | +| --- | --- | --- | +| [connect.lua](connect.lua) | Connect to the launcher's daemon | `connected` | +| [snapshot.lua](snapshot.lua) | List sessions, windows, and panes | Two of each | +| [new_session.lua](new_session.lua) | Create a session | `session: demo` | +| [new_window.lua](new_window.lua) | Create a window in that session | `window: logs` | +| [query.lua](query.lua) | Select sessions by name | `matched: demo` | +| [send_keys.lua](send_keys.lua) | Send text and press Enter | `lua input ready` | +| [capture.lua](capture.lua) | Read a completed command's output | `lua capture ready` | + +The [manifest](manifest.json) attaches whole files to existing API symbols and +records exact expected output. The native documentation export validates the +targets and includes both files needed for each program. Installed-package +checks run those files outside the checkout; no test helper is imported by +the examples. diff --git a/examples/api/capture.lua b/examples/api/capture.lua new file mode 100644 index 0000000..86c88c3 --- /dev/null +++ b/examples/api/capture.lua @@ -0,0 +1,42 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await()) + local pane = created.pane + local function quote(text) + return "'" .. text:gsub("'", "'\\''") .. "'" + end + + -- Signal completion on this socket; do not guess when the shell has printed. + local command = "printf '\\nlua capture ready\\n'; " + .. quote(binary) + .. " -S " + .. quote(socket) + .. " wait-for -S example-ready" + must(pane:send_text(command):await()) + must(pane:send_keys({ "Enter" }):await()) + must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await()) + local capture = must(pane:capture({ history_lines = 20 }):await()) + local found = false + for line in must(capture:text()):gmatch("[^\r\n]+") do + if line == "lua capture ready" then + found = true + end + end + assert(found, "completed command did not print the expected line") + print("lua capture ready") + + must(server:close():await()) + return true +end)) diff --git a/examples/api/connect.lua b/examples/api/connect.lua new file mode 100644 index 0000000..7cc4346 --- /dev/null +++ b/examples/api/connect.lua @@ -0,0 +1,21 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local snapshot = must(server:snapshot({ strict = true }):await()) + assert(#snapshot.sessions:where({ name = "bootstrap" }) == 1, "bootstrap session is missing") + print("connected") + + must(server:close():await()) + return true +end)) diff --git a/examples/api/manifest.json b/examples/api/manifest.json new file mode 100644 index 0000000..dfedc29 --- /dev/null +++ b/examples/api/manifest.json @@ -0,0 +1,77 @@ +{ + "schema": 1, + "setup": { + "lua": "5.5.1", + "luv": "1.52.1-0", + "launcher": "examples/api/run.sh" + }, + "examples": [ + { + "id": "connect", + "symbols": [ + "libtmux.Runtime:connect" + ], + "file": "examples/api/connect.lua", + "description": "Connect to an existing tmux daemon without taking ownership of it.", + "stdout": "connected\n" + }, + { + "id": "snapshot", + "symbols": [ + "libtmux.Server:snapshot", + "libtmux.Snapshot.sessions", + "libtmux.Snapshot.windows", + "libtmux.Snapshot.panes" + ], + "file": "examples/api/snapshot.lua", + "description": "List sessions, windows, and panes from one captured server snapshot.", + "stdout": "sessions: bootstrap, demo\nwindows: bootstrap, main\npanes: 2\n" + }, + { + "id": "new_session", + "symbols": [ + "libtmux.Server:new_session" + ], + "file": "examples/api/new_session.lua", + "description": "Create a session and use the session, window, and pane handles returned with it.", + "stdout": "session: demo\n" + }, + { + "id": "new_window", + "symbols": [ + "libtmux.Session:new_window" + ], + "file": "examples/api/new_window.lua", + "description": "Create a named window in a particular session.", + "stdout": "window: logs\n" + }, + { + "id": "query", + "symbols": [ + "libtmux.Server:query" + ], + "file": "examples/api/query.lua", + "description": "Find sessions by name with a live query and inspect its captured rows.", + "stdout": "matched: demo\n" + }, + { + "id": "send_keys", + "symbols": [ + "libtmux.Pane:send_text", + "libtmux.Pane:send_keys" + ], + "file": "examples/api/send_keys.lua", + "description": "Send literal text and press Enter in a pane.", + "stdout": "lua input ready\n" + }, + { + "id": "capture", + "symbols": [ + "libtmux.Pane:capture" + ], + "file": "examples/api/capture.lua", + "description": "Capture a completed command's output from a pane.", + "stdout": "lua capture ready\n" + } + ] +} diff --git a/examples/api/new_session.lua b/examples/api/new_session.lua new file mode 100644 index 0000000..7771ef4 --- /dev/null +++ b/examples/api/new_session.lua @@ -0,0 +1,29 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must(server + :new_session({ + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + }) + :await()) + assert(created.session and created.window and created.pane, "creation handles are missing") + local snapshot = must(created.session:snapshot():await()) + assert(snapshot.name == "demo", "created session has the wrong name") + print("session: " .. snapshot.name) + + must(server:close():await()) + return true +end)) diff --git a/examples/api/new_window.lua b/examples/api/new_window.lua new file mode 100644 index 0000000..df609e8 --- /dev/null +++ b/examples/api/new_window.lua @@ -0,0 +1,29 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must(server + :new_session({ + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + }) + :await()) + local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await()) + local snapshot = must(logs.window:snapshot():await()) + assert(snapshot.name == "logs", "created window has the wrong name") + print("window: " .. snapshot.name) + + must(server:close():await()) + return true +end)) diff --git a/examples/api/query.lua b/examples/api/query.lua new file mode 100644 index 0000000..c83db66 --- /dev/null +++ b/examples/api/query.lua @@ -0,0 +1,36 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + must(server + :new_session({ + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + }) + :await()) + must(server:new_session({ name = "worker", argv = { "/bin/cat" } }):await()) + local result = must(server + :query({ + kind = "session", + where = { name = "demo" }, + snapshot = { strict = true }, + }) + :await()) + assert(result.complete, "query observed a topology change") + assert(#result.rows == 1 and result.rows[1].name == "demo", "query selected the wrong session") + print("matched: " .. result.rows[1].name) + + must(server:close():await()) + return true +end)) diff --git a/examples/api/run.sh b/examples/api/run.sh new file mode 100644 index 0000000..b6b6047 --- /dev/null +++ b/examples/api/run.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +program=${1:?Pass the complete Lua example filename} +binary=$(command -v "${TMUX_BIN:-tmux}") +case "$binary" in + /*) ;; + *) printf '%s\n' 'tmux must resolve to an absolute path' >&2; exit 1 ;; +esac +directory=$(mktemp -d "${TMPDIR:-/tmp}/libtmux-lua-api.XXXXXX") +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export TMUX_BIN="$binary" TMUX_SOCKET="$socket" ENV=/dev/null BASH_ENV=/dev/null +"$binary" -S "$socket" -f /dev/null new-session -d -s bootstrap -n bootstrap /bin/cat +"${LUA_BIN:-lua}" "$program" +"$binary" -S "$socket" has-session -t '=bootstrap' diff --git a/examples/api/send_keys.lua b/examples/api/send_keys.lua new file mode 100644 index 0000000..cad5c28 --- /dev/null +++ b/examples/api/send_keys.lua @@ -0,0 +1,42 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await()) + local pane = created.pane + local function quote(text) + return "'" .. text:gsub("'", "'\\''") .. "'" + end + + -- Signal completion on this socket; do not guess when the shell has printed. + local command = "printf '\\nlua input ready\\n'; " + .. quote(binary) + .. " -S " + .. quote(socket) + .. " wait-for -S example-ready" + must(pane:send_text(command):await()) + must(pane:send_keys({ "Enter" }):await()) + must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await()) + local capture = must(pane:capture({ history_lines = 20 }):await()) + local found = false + for line in must(capture:text()):gmatch("[^\r\n]+") do + if line == "lua input ready" then + found = true + end + end + assert(found, "completed command did not print the expected line") + print("lua input ready") + + must(server:close():await()) + return true +end)) diff --git a/examples/api/snapshot.lua b/examples/api/snapshot.lua new file mode 100644 index 0000000..03a7277 --- /dev/null +++ b/examples/api/snapshot.lua @@ -0,0 +1,39 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(tostring(err), 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") + +must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + must(server + :new_session({ + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + }) + :await()) + local snapshot = must(server:snapshot({ strict = true }):await()) + local sessions, windows = {}, {} + for _, session in ipairs(snapshot.sessions) do + sessions[#sessions + 1] = session.name + end + for _, window in ipairs(snapshot.windows) do + windows[#windows + 1] = window.name + end + table.sort(sessions) + table.sort(windows) + assert(#snapshot.sessions == 2 and #snapshot.windows == 2 and #snapshot.panes == 2) + print("sessions: " .. table.concat(sessions, ", ")) + print("windows: " .. table.concat(windows, ", ")) + print("panes: " .. #snapshot.panes) + + must(server:close():await()) + return true +end)) diff --git a/scripts/api_examples.py b/scripts/api_examples.py new file mode 100644 index 0000000..9f45859 --- /dev/null +++ b/scripts/api_examples.py @@ -0,0 +1,73 @@ +"""Validate source-owned, complete API examples before export or execution.""" + +import json +from pathlib import Path +import re + + +def public_symbols(declarations): + symbols = set() + for declaration in declarations: + name = declaration["name"] + symbols.add(name) + for field in declaration.get("fields", []): + if field.get("name"): + receiver = re.search(r"\bself\s*:", field.get("view", "")) + separator = ":" if receiver else "." + symbols.add(name + separator + field["name"]) + return symbols + + +def load_examples(root, symbols=None): + root = Path(root).resolve() + manifest = json.loads((root / "examples/api/manifest.json").read_text()) + + def require(condition, message): + if not condition: + raise ValueError("Invalid complete API examples: " + message) + + def keys(value, expected): + require(isinstance(value, dict) and set(value) == set(expected), + "unexpected or missing manifest fields") + + def file(path, expected): + require(path == expected, "unexpected example file path") + candidate = root / path + require(candidate.is_file() and not candidate.is_symlink(), "example file is missing or linked") + require(candidate.resolve().is_relative_to(root), "example file escapes source") + content = candidate.read_bytes().decode("utf-8") + require(bool(content.strip()) and content.endswith("\n") and "\r" not in content, + "example files must contain complete UTF-8 text with LF endings") + + keys(manifest, ("schema", "setup", "examples")) + require(type(manifest["schema"]) is int and manifest["schema"] == 1, "unsupported schema") + setup = manifest["setup"] + keys(setup, ("lua", "luv", "launcher")) + require(isinstance(setup["lua"], str) and re.fullmatch(r"5\.[1-5]\.\d+", setup["lua"]), + "Lua version must be pinned") + require(isinstance(setup["luv"], str) and re.fullmatch(r"\d+\.\d+\.\d+-\d+", setup["luv"]), + "luv version must be pinned") + file(setup["launcher"], "examples/api/run.sh") + require(isinstance(manifest["examples"], list) and bool(manifest["examples"]), "no examples") + ids, targets = set(), set() + for example in manifest["examples"]: + keys(example, ("id", "symbols", "file", "description", "stdout")) + name = example["id"] + require(isinstance(name, str) and re.fullmatch(r"[a-z][a-z_]*", name), "invalid example id") + require(name not in ids, "duplicate example id") + ids.add(name) + file(example["file"], f"examples/api/{name}.lua") + require(isinstance(example["description"], str) and bool(example["description"].strip()), + "missing task description") + require(isinstance(example["stdout"], str) and bool(example["stdout"].strip()) + and example["stdout"].endswith("\n"), "missing expected output") + require(isinstance(example["symbols"], list) and bool(example["symbols"]), "missing API targets") + for symbol in example["symbols"]: + require(isinstance(symbol, str) and re.fullmatch(r"libtmux\.[\w.]+[:.]\w+", symbol), + "invalid API target") + require(symbol not in targets, "duplicate API target") + require(symbols is None or symbol in symbols, "unknown API target: " + symbol) + targets.add(symbol) + actual = {str(path.relative_to(root)) for path in (root / "examples/api").glob("*.lua")} + require(actual == {example["file"] for example in manifest["examples"]}, "unlisted Lua example") + return manifest diff --git a/scripts/export-docs b/scripts/export-docs index 8739b77..fad3080 100755 --- a/scripts/export-docs +++ b/scripts/export-docs @@ -10,6 +10,8 @@ import shutil import subprocess import tempfile +from api_examples import load_examples, public_symbols + ROOT = Path(__file__).resolve().parent.parent SOURCE = ROOT @@ -169,16 +171,20 @@ def build_payload(luals): raise ExportError(f"LuaLS export failed: {result.stdout}{result.stderr}") raw = json.loads((output / "doc.json").read_text()) + declarations = public_declarations(raw) payload = { "schema": 1, "port": "lua", "source": {"repository": "libtmux/libtmux-lua", "revision": source_revision()}, "exporter": {"name": "scripts/export-docs", "version": 1, "luals": tool_version(luals)}, "package": rock_metadata(), - "declarations": public_declarations(raw), + "declarations": declarations, "guides": content_files(["README.md", "docs/**/*.md"]), - "examples": content_files(["examples/**/*.lua"]), + "examples": content_files(["examples/**/*.lua", "examples/api/run.sh"]), } + # Historical sources predate complete examples; an opted-in directory must validate. + if (SOURCE / "examples/api").exists(): + payload["api_examples"] = load_examples(SOURCE, public_symbols(declarations)) validate(payload) return payload @@ -216,7 +222,7 @@ def main(): f"Documentation export: {len(payload['declarations'])} declarations; " f"{sum(len(item.get('fields', [])) for item in payload['declarations'])} fields" ) - except (ExportError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + except (ExportError, OSError, ValueError, subprocess.SubprocessError) as error: raise SystemExit(str(error)) from error diff --git a/scripts/package.py b/scripts/package.py index 66e44bc..61f02e7 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -17,9 +17,11 @@ import zipfile if __package__: + from .api_examples import load_examples from .release import REPOSITORY, candidate, identity, prepare from .runtime_config import executable_path, identify else: + from api_examples import load_examples from release import REPOSITORY, candidate, identity, prepare from runtime_config import executable_path, identify @@ -139,6 +141,44 @@ def live_example(prefix, version, *, lua, cwd, env): print("PASS installed public snapshot/quickstart examples and borrowed-server cleanup", flush=True) +def complete_api_examples(prefix, version, *, lua, cwd, env): + manifest = load_examples(ROOT) + directory = cwd / "complete-api-examples" + directory.mkdir(exist_ok=True) + # Endpoint aliases also use this directory; keep Unix socket paths short. + temporary = Path(tempfile.mkdtemp(prefix="libtmux-lua-api-check-", dir="/tmp")) + launcher = directory / "run.sh" + shutil.copyfile(ROOT / manifest["setup"]["launcher"], launcher) + child_env = dict(env, LUA_BIN=lua, TMPDIR=str(temporary), + TMUX_BIN=shutil.which(env.get("TMUX_BIN", "tmux"))) + child_env["LUA_PATH"] = f"{prefix}/share/lua/{version}/?.lua;{prefix}/share/lua/{version}/?/init.lua" + child_env["LUA_CPATH"] = f"{prefix}/lib/lua/{version}/?.so" + for example in manifest["examples"]: + program = directory / Path(example["file"]).name + shutil.copyfile(ROOT / example["file"], program) + output = run(["sh", str(launcher), str(program)], cwd=directory, env=child_env) + if output != example["stdout"]: + raise RuntimeError(f"{program.name}: complete example output differs: {output!r}") + if list(temporary.iterdir()): + raise RuntimeError(f"{program.name}: complete example left a socket directory") + for name, source in { + "import": 'require("libtmux_example_missing_module")\n', + "runtime": (directory / "connect.lua").read_text().replace( + ' print("connected")', ' error("intentional example failure", 0)'), + }.items(): + program = directory / f"failure-{name}.lua" + program.write_text(source) + result = subprocess.run(["sh", str(launcher), str(program)], cwd=directory, + env=child_env, capture_output=True, text=True, timeout=10) + expected = "libtmux_example_missing_module" if name == "import" else "intentional example failure" + if result.returncode == 0 or expected not in result.stderr: + raise RuntimeError(f"{name}: example failure did not propagate") + if list(temporary.iterdir()): + raise RuntimeError(f"{name}: failing example left a socket directory") + temporary.rmdir() + print("PASS seven complete API programs and import/runtime failure cleanup", flush=True) + + def source_rock(spec, work, env, *, rocks, public_tag): """Pack the committed spec, using an isolated source tree before the public tag exists.""" packing = work / "source-rock" @@ -213,6 +253,7 @@ def check_release(work, env, *, rocks, lua, runtime, cache, output, public_tag): luv = next(work.glob(f"luv-{DEPENDENCIES['luv']}.*.rock")) rocks([f"--tree={prefix}", "install", "--deps-mode=one", str(luv)], cwd=work) live_example(prefix, runtime.version, lua=lua, cwd=work, env=env) + complete_api_examples(prefix, runtime.version, lua=lua, cwd=work, env=env) if output: output.mkdir(parents=True, exist_ok=True) names = (spec.name, artifact.name) diff --git a/tests/tooling/test_api_examples.py b/tests/tooling/test_api_examples.py new file mode 100644 index 0000000..df0d2e4 --- /dev/null +++ b/tests/tooling/test_api_examples.py @@ -0,0 +1,113 @@ +import copy +import json +from pathlib import Path +import shutil +import tempfile +import unittest + +from scripts.api_examples import load_examples, public_symbols + + +ROOT = Path(__file__).resolve().parents[2] +TARGETS = { + "libtmux.Runtime:connect", "libtmux.Server:snapshot", "libtmux.Snapshot.sessions", + "libtmux.Snapshot.windows", "libtmux.Snapshot.panes", "libtmux.Server:new_session", + "libtmux.Session:new_window", "libtmux.Server:query", "libtmux.Pane:send_text", + "libtmux.Pane:send_keys", "libtmux.Pane:capture", +} + + +class CompleteApiExamplesTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="libtmux-lua-api-manifest-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + shutil.copytree(ROOT / "examples/api", self.root / "examples/api") + self.path = self.root / "examples/api/manifest.json" + self.manifest = json.loads(self.path.read_text()) + + def save(self): + self.path.write_text(json.dumps(self.manifest)) + + def test_seven_complete_programs_cover_eleven_existing_targets(self): + manifest = load_examples(self.root, TARGETS) + self.assertEqual(len(manifest["examples"]), 7) + self.assertEqual({symbol for example in manifest["examples"] + for symbol in example["symbols"]}, TARGETS) + for example in manifest["examples"]: + source = (self.root / example["file"]).read_text() + self.assertIn('require("libtmux.runtime.luv")', source) + self.assertIn("server:close():await()", source) + self.assertNotIn('require("tests.', source) + + def test_receiver_and_collection_identities_match_native_annotations(self): + declarations = [ + {"name": "libtmux.Server", "fields": [ + {"name": "snapshot", "view": "fun(self: libtmux.Server):libtmux.Request"}, + ]}, + {"name": "libtmux.Snapshot", "fields": [ + {"name": "sessions", "view": "libtmux.Selection"}, + ]}, + ] + self.assertEqual(public_symbols(declarations), { + "libtmux.Server", "libtmux.Server:snapshot", "libtmux.Snapshot", "libtmux.Snapshot.sessions", + }) + + def test_rejects_unknown_targets_and_duplicate_ownership(self): + with self.assertRaisesRegex(ValueError, "unknown API target"): + load_examples(self.root, TARGETS - {"libtmux.Pane:capture"}) + self.manifest["examples"][1]["symbols"].append("libtmux.Runtime:connect") + self.save() + with self.assertRaisesRegex(ValueError, "duplicate API target"): + load_examples(self.root, TARGETS) + + def test_rejects_unsafe_missing_and_unlisted_files(self): + original = copy.deepcopy(self.manifest) + for path in ("../capture.lua", "/tmp/capture.lua", "examples/api/other.lua"): + self.manifest = copy.deepcopy(original) + self.manifest["examples"][0]["file"] = path + self.save() + with self.subTest(path=path), self.assertRaisesRegex(ValueError, "file path"): + load_examples(self.root) + self.manifest = original + self.save() + extra = self.root / "examples/api/extra.lua" + extra.write_text('print("unlisted")\n') + with self.assertRaisesRegex(ValueError, "unlisted Lua example"): + load_examples(self.root) + extra.unlink() + (self.root / "examples/api/capture.lua").unlink() + with self.assertRaisesRegex(ValueError, "missing or linked"): + load_examples(self.root) + + def test_rejects_unknown_fields_unpinned_versions_and_missing_output(self): + original = copy.deepcopy(self.manifest) + mutations = [ + lambda m: m.update(extra=True), + lambda m: m["setup"].update(lua="latest"), + lambda m: m["setup"].update(luv="1.52"), + lambda m: m["examples"][0].update(stdout=""), + lambda m: m["examples"][0].update(description=""), + lambda m: m["examples"][0].update(symbols=[]), + ] + for index, mutate in enumerate(mutations): + self.manifest = copy.deepcopy(original) + mutate(self.manifest) + self.save() + with self.subTest(index=index), self.assertRaises(ValueError): + load_examples(self.root) + + def test_rejects_linked_and_normalized_source_bytes(self): + program = self.root / "examples/api/connect.lua" + program.unlink() + program.symlink_to(ROOT / "examples/api/connect.lua") + with self.assertRaisesRegex(ValueError, "missing or linked"): + load_examples(self.root) + program.unlink() + program.write_bytes(b'print("changed")\r\n') + with self.assertRaisesRegex(ValueError, "LF endings"): + load_examples(self.root) + + +if __name__ == "__main__": + unittest.main()