Files
OpenShell/tests/testkit/README.md
T
Matthew GrossmanandEvan Lezar 33138516ad feat(tests): add the tmachine Session API and run a conformance smoke test on tmachine (#4773)
* feat(tmachine): add the Session API and a test-runner package

Add a library API that boots an environment, runs commands in it with timeouts,
copies files in and out, collects diagnostics and tears it down, so a caller can
drive a machine without writing an Ansible playbook per suite. SSH and exec
timeouts and QEMU cleanup on SIGTERM keep a hung guest or a cancelled CI job from
leaving QEMU running.

Add the `openshell-test-runner` flake package, which runs tests/runner with the
toolchains and VM tools it needs.

The Session API and test-runner package are adapted from NVIDIA/OpenShell#4629.

Co-authored-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* build(cargo): add a check that aligned workspaces lock shared crates like the root

`cargo --locked` only checks a lockfile against its own manifest. A workspace
outside the root that builds root crates through path dependencies, such as the
testkit suites building `openshell-sdk`, has its own Cargo.lock, so the two
lockfiles can resolve a shared crate to different versions without any check
failing. That is how aws-lc-sys 0.46 was picked up in #4629, which does not link
against the AWS-LC that Nix provides.

check_cargo_lockfile_alignment.py fails when an aligned workspace locks a
registry crate that the root lockfile also contains at a version the root
lockfile does not have. With --fix it re-locks the workspace starting from the
root lockfile. tests/testkit is the first aligned workspace.

The script and its tests come from #4629 (a37ebe917).

Co-authored-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* test(conformance): add a sandbox smoke suite built on testkit

Add the `sandbox` crate to the conformance workspace with one testkit test that
creates a sandbox, runs `echo` in it with exec, deletes it and checks it is gone.
It is deliberately small: it exists so the runner can run a real test in a
tmachine environment on CI before the existing suites move to testkit.

The workspace's other crate, the CLI conformance suite, keeps running as it does
today. The conformance workspace joins the lockfile alignment check, so its
lockfile is realigned with the root lockfile.

The test is adapted from the `sandbox-lifecycle` suite in #4629.

Co-authored-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* feat(runner): add a test runner that runs a test family on tmachine

`openshell-test-runner` builds a family's packages (a workspace under
tests/suites/) on the host as a nextest archive for the guest's musl target,
boots one tmachine environment by name with the named installer through the
Session API, runs nextest in it and brings back the JUnit report and log. No
per-suite Ansible playbook is involved:

  nix run .#openshell-test-runner -- --environment ubuntu-docker-rootful \
    --installer deb --family conformance --package sandbox

The runner points the tests at the system under test with environment
variables: OPENSHELL_GATEWAY (the gateway the installer registered, `--gateway`,
default `openshell`) and OPENSHELL_BIN (the installed CLI). It waits for the
gateway with `testkit-ready` before the tests start. Nextest runs with
`--test-threads 1` and a slow-timeout that terminates a hung test.

A run passes if nextest succeeds and its JUnit report shows at least one test
and no failures. `--filter` narrows the tests with a nextest filterset, and
`--keep` leaves the VM up for debugging. Results from an earlier run are
removed first, and the guest's diagnostics are collected after a failure.

Which tests run in which environments is left to the caller for now; there are
no plans, environment registry or per-test requirements, and a broken test is
`#[ignore]`d.

Most of this comes from #4629, adapted to testkit finding the gateway the way the
CLI does instead of reading a target file.

Co-authored-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* ci(tests): lint and test the runner, tmachine and conformance suite

Run fmt, clippy and nextest for tests/tmachine and tests/runner, and fmt and
clippy for the new `sandbox` suite crate, wherever tests/testkit already runs
them: the mise tasks and Branch Checks. The suite's tests need a gateway, so they
run through `mise run e2e:testkit` against a local Docker gateway and, on CI,
through the test runner rather than the unit-test job.

The lockfile task and the Cargo lockfiles job also run the alignment check, and
its own tests run with `mise run test`.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* ci(tests): run testkit suites on tmachine from a reusable E2E Suites workflow

Add a reusable workflow with an explicit matrix: each row names a tmachine
environment and installer, a family and package, and the runner label. Each
row runs `nix run .#openshell-test-runner` once. The jobs use the same
integration inputs as the existing tmachine lanes, cache the tmachine disks
keyed on the Ansible tree, upload the JUnit report and logs whether or not the
run passes, and an aggregator job reports the overall result.

The only row runs the conformance sandbox suite on amd64 Ubuntu with rootful
Docker and the Debian package, which CI can provision today. Branch E2E calls
the workflow when the test:e2e label is set. The job is not in the Core E2E
result's needs yet, so it starts out advisory. The existing conformance,
feature and driver lanes are unchanged.

Modeled on the E2E Suites workflow in #4629.

Co-authored-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* docs(tests): document the runner and the conformance suite

Describe how the runner runs a family in a tmachine environment, locally and
on CI, in TESTING.md, add a README for the sandbox suite, point the testkit
README at the runner, and add rows for tests/suites, tests/tmachine and
tests/runner to the project-structure tables in AGENTS.md and CONTRIBUTING.md.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* refactor(tests): simplify the test runner and tmachine

- Replace tmachine's global QEMU PID registry with until_signal, which
  drops the work on SIGTERM/SIGINT/SIGHUP so kill_on_drop stops QEMU.
  This also lets Ctrl-C end a --keep run.
- Decide a run's status from nextest's exit code (with --no-tests fail)
  instead of parsing JUnit; print the end of the nextest log on failure.
- Move gateway diagnostics from tmachine's Session into the runner, drop
  Session::run_playbooks, and make Session own its VM directly.
- Read TMACHINE_SSH_PORT once and reject invalid values; drop the racy
  port pre-check.
- Build the runner with Nix so tmachine's firmware paths are compiled in,
  and set ANSIBLE_CONFIG only in the wrapper.
- Copy the bundle directory in directly, fold lib.rs into main.rs, and
  make the gateway name required.
- Drop the semver hint from the lockfile alignment check and trim
  duplicated docs.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* fix(tmachine): keep the SSH port check and name a run with no tests

Restore the check that the SSH forward port is free before QEMU starts. QEMU
reports a taken port only by exiting, so a layer build would otherwise run its
playbooks against the VM that already holds the port and cache the result.

Report nextest's no-tests exit code as "no test ran" instead of a bare exit
status.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* fix(runner): keep logs from a timed-out run and gate on the E2E suites

nextest now writes its output to a file in the guest, and the runner copies
nextest.log and junit.xml out after every run, so a run that times out or
loses SSH still leaves a log. A failed JUnit copy no longer turns a test
failure into an error.

The runner exits with 1 when a test failed and 2 when the run could not
complete, and annotates the GitHub Actions run with which it was.

The E2E suites lane is now part of the Core E2E result. Its disk cache key
also covers tmachine's cache versions in setup.rs and install.rs.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* ci(e2e): push test runner builds to Cachix

E2E Suites set up Nix without a Cachix token, so every job rebuilt
openshell-test-runner from source. Accept an optional CACHIX_AUTH_TOKEN
secret, pass it to setup-nix, and pass it from Branch E2E, as Branch Checks
and the binary builds already do.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

---------

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Co-authored-by: Evan Lezar <elezar@nvidia.com>
2026-10-28 18:29:25 +00:00

4.4 KiB

testkit

Shared support for end-to-end test suites. Suites test a running OpenShell gateway through the Rust SDK and the installed openshell CLI.

This is a separate Cargo workspace with its own lockfile:

Crate Purpose
testkit Finds and connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds testkit-ready.
testkit-macros The #[testkit::test] attribute.

Writing a test

A test is an async fn that takes &mut testkit::Context and returns testkit::Result:

use testkit::Context;

/// A long-running sandbox becomes ready, runs a command with exec, and is gone
/// from get and list after delete.
#[testkit::test]
async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result {
    let sandbox = cx.sandbox("lr").command(["sleep", "infinity"]).create().await?;
    sandbox.wait_ready().await?;
    // ...
    Ok(())
}
  • The doc comment is required. It is the test's description.
  • The function name is the nextest test name.
  • Other attributes apply to the test. For example, #[ignore = "reason"] skips it unless run with --run-ignored. #[should_panic] is rejected, because a testkit test panics on any failure. To check that something is refused, assert the failure in the body, for example with cx.cli([...]).await?.failure()? and testkit::ensure!.

Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through cx.sandbox(...). Delete anything else a test creates, such as providers, in the test itself.

What a test can do

Context currently provides:

  • cx.sandbox(suffix) to build and create a sandbox with a run-unique name. The returned Sandbox can wait for ready or finished, stop, start, exec, read its config and draft policy, set a setting, and delete.
  • cx.expect_listed and cx.expect_absent to check a sandbox in list and get.
  • cx.cli([...]) to run the installed CLI against the same gateway, with success(), failure(), and json() on the output.
  • cx.client() for SDK calls testkit does not wrap.
  • poll_until for waiting on other state.

Running against a gateway

Tests find the gateway the same way the openshell CLI does:

  1. OPENSHELL_GATEWAY_ENDPOINT: a gateway URL. Its credentials come from the gateway named by OPENSHELL_GATEWAY, or else the registered gateway with that endpoint. A URL no gateway is registered for is used as is: plaintext, or TLS without a client certificate.
  2. OPENSHELL_GATEWAY: the name of a gateway registered with the CLI.
  3. The CLI's active gateway, set by openshell gateway select.

A registered gateway's endpoint and auth mode come from $XDG_CONFIG_HOME/openshell/gateways/<name>/metadata.json. For mTLS, the CA and client certificate come from its mtls/ directory. testkit supports plaintext and mTLS gateways. OPENSHELL_WORKSPACE selects the workspace (default default), and OPENSHELL_BIN selects the CLI that cx.cli runs (default openshell on PATH). The CLI always targets the same gateway and workspace as the SDK. Each test logs the gateway it used.

Wait for the gateway, then run a suite. Run tests that share a gateway one at a time:

export OPENSHELL_GATEWAY=<name>
cargo run --manifest-path tests/testkit/Cargo.toml --bin testkit-ready -- 300
cargo nextest run --manifest-path <suite>/Cargo.toml --test-threads 1

testkit-ready [TIMEOUT_SECS] exits non-zero if the gateway is not healthy in time or the CLI does not report it connected.

e2e/with-docker-gateway.sh and e2e/with-podman-gateway.sh start an ephemeral gateway and export OPENSHELL_GATEWAY and OPENSHELL_BIN, so a suite runs under them unchanged.

mise run e2e:testkit does this for the suites in tests/suites/conformance. In a tmachine VM, the test runner (tests/runner/) does the same: it sets OPENSHELL_GATEWAY to the gateway the installer registered and OPENSHELL_BIN to the installed CLI, then runs the suites with nextest. See TESTING.md.

Checks

cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check
cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings
cargo nextest run --locked --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml

mise run rust:lint, mise run rust:format:check, and mise run test include these, and Branch Checks runs them.