diff --git a/.github/workflows/demo-gifs.yml b/.github/workflows/demo-gifs.yml new file mode 100644 index 00000000..ee3a6545 --- /dev/null +++ b/.github/workflows/demo-gifs.yml @@ -0,0 +1,66 @@ +name: demo-gifs + +# Render documentation screencasts (VHS -> GIF) and publish them to the orphan +# `media` branch, which the README embeds via raw URLs. Deliberately never runs +# on pull requests: VHS rendering is slow and would churn the branch. Regenerate +# on demand, and automatically on each release so the docs GIFs track released +# behavior (the release tag doubles as a cache-buster for GitHub's image proxy). +# The feature-branch push trigger is temporary: GitHub cannot manually dispatch a +# workflow until its file exists on the default branch. +on: + workflow_dispatch: + push: + branches: [docs/demo-screencasts] + release: + types: [published] + +permissions: + contents: write # push rendered GIFs to the `media` branch + +concurrency: + group: demo-gifs + cancel-in-progress: true + +jobs: + render: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + + # VHS + its runtime deps. ttyd/ffmpeg from apt; vhs pinned via `go install` + # (Go is preinstalled on the runner). Chrome (used by VHS to rasterize + # frames) is preinstalled; the sysctl re-enables its sandbox on Ubuntu 24.04 + # runners, where unprivileged user namespaces are AppArmor-restricted. + - name: Install VHS toolchain + run: | + sudo apt-get update + sudo apt-get install -y ttyd ffmpeg + go install github.com/charmbracelet/vhs@v0.11.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + + # xtask builds the binaries, starts the isolated mock service, then runs + # VHS against the pure command/key tapes (see docs/demos.md). + - name: Render demos + run: cargo xtask demos + + # Publish from an unignored staging directory. `docs/media/*.gif` is + # intentionally git-ignored on source branches, and the Pages action honors + # that ignore file while preparing assets. + - name: Stage rendered media + run: | + mkdir -p rendered-media + cp docs/media/cli.gif docs/media/console.gif rendered-media/ + + - name: Publish to media branch + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: media + publish_dir: ./rendered-media + force_orphan: true # single-commit branch; old GIF blobs are GC'd + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com + commit_message: "chore(media): regenerate demo screencasts" diff --git a/README.md b/README.md index 60e78851..ddc8dcd1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,28 @@ vLLM. > guarantee of stability. APIs, commands, and behavior may change without > notice. Intended for experimentation and early feedback only. +## Demos + +### ROCm CLI + +Inspect the environment, discover engines and models, find a running service, +and chat with a locally served model: + +![ROCm CLI demo](https://raw.githubusercontent.com/ROCm/rocm-cli/media/cli.gif) + +### ROCm Console + +Explore simulated GPU telemetry, model serving, and offline chat in the +full-screen Console: + +![ROCm Console demo](https://raw.githubusercontent.com/ROCm/rocm-cli/media/console.gif) + + + ## Installation The installer downloads a prebuilt bundle, verifies its SHA-256 checksum, diff --git a/docs/demos.md b/docs/demos.md new file mode 100644 index 00000000..cdca1ba6 --- /dev/null +++ b/docs/demos.md @@ -0,0 +1,115 @@ + + +# Demo screencasts + +The README embeds two complete terminal demos: + +- **ROCm CLI** follows a first-use journey from environment inspection through + local-model chat. +- **ROCm Console** tours the real full-screen TUI with deterministic simulated + telemetry and its offline chat backend. + +Both are authored as [VHS](https://github.com/charmbracelet/vhs) tapes, rendered +in CI, and published to the orphan **`media`** branch. GIFs are never committed +to source branches; the README references them by absolute raw URL. + +## Layout + +| Path | Purpose | +| --- | --- | +| `docs/tapes/cli.tape` | CLI first-use journey. | +| `docs/tapes/console.tape` | Interactive Console tour using `--demo --chat-mock`. | +| `xtask/src/demos.rs` | Builds binaries, starts the isolated mock service, runs VHS, and cleans up. | +| `tests/e2e-cucumber/src/bin/rocm-demo-env.rs` | Mock-service helper: reuses the e2e harness's loopback OpenAI server and plants a service record under isolated config. | +| `.github/workflows/demo-gifs.yml` | Installs VHS and publishes generated GIFs to `media`. | +| `docs/media/` | Git-ignored render output directory. | + +## Deterministic environment + +`cargo xtask demos` performs all setup before VHS starts: + +1. Builds release versions of `rocm` and `rocm-demo-env`. +2. Starts `rocm-demo-env`, which reuses the end-to-end harness's loopback + OpenAI-compatible server and plants a managed-service record under isolated + config, data, and cache directories. +3. Waits for the helper to publish all required environment paths and its + readiness marker. +4. Prepends the release binary directory to `PATH`, renders the selected tapes, + then stops the helper and removes its temporary state. + +Setup deliberately does not live inside a tape. VHS advances on a fixed clock +and does not wait for commands to finish, so hidden setup can race subsequent +keystrokes. The tapes remain pure visible command/key sequences against an +already-ready environment. + +The Console needs no ROCm hardware: `rocm dash --demo` replays the project's +seeded synthetic telemetry through the same UI as a live daemon, and visibly +marks it **SIMULATED DATA**. `--chat-mock` provides the deterministic offline +chat response. The CLI's service and chat commands use only the loopback mock. +Neither demo downloads a model or calls a cloud provider. + +## Storyboards + +### CLI + +`cli.tape` demonstrates: + +1. `rocm examine` +2. `rocm engines list` +3. `rocm model` +4. `rocm services list` +5. one-shot `rocm chat` against the discovered mock service + +### Console + +`console.tape` launches `rocm dash --demo --chat-mock`, visits Home, ROCm, +Serving, Observe, and Chat, asks which simulated GPU needs attention, opens the +built-in help, and exits cleanly. + +## Regenerate + +Install VHS and its runtime dependencies (`ttyd`, `ffmpeg`, and Chrome), then +run from the repository root: + +```bash +# Render both demos, building the binaries first. +cargo xtask demos + +# Render one demo. +cargo xtask demos cli +cargo xtask demos console + +# Reuse release binaries already present in target/release. +cargo xtask demos --skip-build +``` + +Set `ROCM_BIN_DIR` to use binaries from another directory. `ROCM_DEMO_MODEL` +changes the model the mock service advertises; the CLI tape's `--model` +argument must match it (both default to the same identifier), so override +them together if you change one. + +> [!NOTE] +> Browser-backed VHS GIF rendering can hang under WSL2. On WSL2, run the Rust +> tests and command-level checks locally, then use the `demo-gifs` GitHub Actions +> workflow for the actual GIF render. + +## Add or change a demo + +1. Keep each tape a pure command/key sequence; put environment orchestration in + `xtask/src/demos.rs`. +2. When adding a new tape, add its name to the `DEMOS` list in that module. +3. Reference the published GIF as + `https://raw.githubusercontent.com/ROCm/rocm-cli/media/.gif`. +4. Verify the story without relying on GPU hardware, model downloads, or + non-loopback services. + +## Workflow triggers + +The workflow runs on `workflow_dispatch` and on published releases, never on +pull requests. Rendering is slow and would churn the generated-media branch. +GitHub proxies README images through a cache; append a release-based query +parameter if a regenerated image appears stale. diff --git a/docs/media/.gitignore b/docs/media/.gitignore new file mode 100644 index 00000000..284756a4 --- /dev/null +++ b/docs/media/.gitignore @@ -0,0 +1,5 @@ +# Rendered demo screencasts are generated in CI and published to the orphan +# `media` branch (see .github/workflows/demo-gifs.yml) — never committed to the +# source branches. This keeps the directory present so `vhs` has an output path. +*.gif +!.gitignore diff --git a/docs/tapes/cli.tape b/docs/tapes/cli.tape new file mode 100644 index 00000000..edf50629 --- /dev/null +++ b/docs/tapes/cli.tape @@ -0,0 +1,42 @@ +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +# A first-use ROCm CLI journey. `cargo xtask demos` supplies the isolated mock +# service. The --model below must match the mock's model (xtask DEFAULT_MODEL, +# overridable with ROCM_DEMO_MODEL) or `rocm chat` finds no ready service. +Output docs/media/cli.gif + +Set Shell "bash" +Set FontSize 18 +Set Width 1200 +Set Height 700 +Set Padding 20 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 35ms + +Type "rocm examine" +Sleep 500ms +Enter +Sleep 4s + +Ctrl+L +Type "rocm engines list" +Sleep 400ms +Enter +Sleep 3s + +Type "rocm model" +Sleep 400ms +Enter +Sleep 5s + +Ctrl+L +Type "rocm services list" +Sleep 400ms +Enter +Sleep 4s + +Type "rocm chat --provider local --model Qwen/Qwen3.5-0.6B --prompt 'What can ROCm CLI help me do?'" +Sleep 400ms +Enter +Sleep 6s diff --git a/docs/tapes/console.tape b/docs/tapes/console.tape new file mode 100644 index 00000000..bb4bdc5b --- /dev/null +++ b/docs/tapes/console.tape @@ -0,0 +1,44 @@ +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +# Tour the real Console with deterministic telemetry and an offline chat agent. +Output docs/media/console.gif + +Set Shell "bash" +Set FontSize 16 +Set Width 1440 +Set Height 900 +Set Padding 16 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 45ms + +Type "rocm dash --demo --chat-mock" +Sleep 500ms +Enter +Sleep 6s + +# Home → ROCm → Serving → Observe. +Type "2" +Sleep 3s +Type "3" +Sleep 3s +Type "4" +Sleep 5s + +# Chat is pre-consented in mock mode; Enter focuses its input. +Type "5" +Sleep 3s +Enter +Type "Which GPU needs attention?" +Sleep 500ms +Enter +Sleep 5s + +# Leave insert mode, show the global help, then quit. +Escape +Type "?" +Sleep 4s +Type "?" +Sleep 1s +Type "q" +Sleep 2s diff --git a/tests/e2e-cucumber/Cargo.toml b/tests/e2e-cucumber/Cargo.toml index 3f03284c..c1cd2089 100644 --- a/tests/e2e-cucumber/Cargo.toml +++ b/tests/e2e-cucumber/Cargo.toml @@ -10,6 +10,12 @@ publish = false [lints] workspace = true +# Standalone mock-environment runner for documentation screencasts (see +# docs/tapes/). Reuses this crate's mock server; not part of the test harness. +[[bin]] +name = "rocm-demo-env" +path = "src/bin/rocm-demo-env.rs" + [dependencies] axum.workspace = true cucumber = { version = "0.23", features = ["output-json", "output-junit"] } diff --git a/tests/e2e-cucumber/src/bin/rocm-demo-env.rs b/tests/e2e-cucumber/src/bin/rocm-demo-env.rs new file mode 100644 index 00000000..87d96731 --- /dev/null +++ b/tests/e2e-cucumber/src/bin/rocm-demo-env.rs @@ -0,0 +1,102 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Standalone demo environment for VHS screencasts. +//! +//! Starts the same axum mock OpenAI server the cucumber e2e suite uses, plants a +//! managed-service record into an isolated config root, and prints the env vars +//! that point `rocm` at it. `cargo xtask demos` parses this output and exports +//! it before VHS runs, so `rocm chat` / `rocm services list` hit the mock — no +//! GPU, no real model, fully deterministic. Runs until signalled; xtask kills it +//! when rendering finishes. +//! +//! Usage: `rocm-demo-env --root [--model ]` + +use std::io::Write as _; +use std::path::PathBuf; + +use e2e_cucumber::mock_server::{MockServer, write_service_record}; + +#[tokio::main] +async fn main() { + let mut root: Option = None; + let mut model = "Qwen/Qwen3.5-0.6B".to_string(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--root" => root = args.next().map(PathBuf::from), + "--model" => { + if let Some(m) = args.next() { + model = m; + } + } + other => { + eprintln!("rocm-demo-env: unknown argument {other}"); + std::process::exit(2); + } + } + } + let Some(root) = root else { + eprintln!("rocm-demo-env: --root is required"); + std::process::exit(2); + }; + + for sub in ["config", "data", "cache"] { + std::fs::create_dir_all(root.join(sub)).expect("failed to create isolated dir"); + } + + let mock = MockServer::start(&model).await; + let port = mock.port(); + write_service_record(&root.join("data").join("services"), &model, port); + + // Emit the env the tape sources; the CLI reads these to find the isolated + // config and the planted service. The trailing marker line lets the caller + // block until the server is actually ready before running commands. + let mut out = std::io::stdout().lock(); + let cfg = root.join("config"); + let data = root.join("data"); + let cache = root.join("cache"); + writeln!(out, "export ROCM_CLI_CONFIG_DIR={}", cfg.display()).unwrap(); + writeln!(out, "export ROCM_CLI_DATA_DIR={}", data.display()).unwrap(); + writeln!(out, "export ROCM_CLI_CACHE_DIR={}", cache.display()).unwrap(); + writeln!( + out, + "# rocm-demo-env ready on 127.0.0.1:{port} (model {model})" + ) + .unwrap(); + out.flush().unwrap(); + drop(out); + + // Keep the process (and the spawned server task) alive until told to stop. + // `mock` must stay in scope: dropping it shuts the server down. + wait_for_shutdown().await; + mock.stop(); +} + +/// Block until a stop signal. On Unix, ignore SIGINT/SIGHUP — VHS/ttyd emit some +/// during terminal setup, and catching the default disposition would kill the +/// server before the demo command runs — and exit gracefully on SIGTERM (useful +/// when this binary is run standalone and stopped with `kill`). When driven by +/// `cargo xtask demos`, the parent instead hard-kills this process (SIGKILL) once +/// rendering finishes, so this handler need not run in that path. Elsewhere, fall +/// back to Ctrl-C. +#[cfg(unix)] +async fn wait_for_shutdown() { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + let mut sigint = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); + loop { + tokio::select! { + _ = sigterm.recv() => break, + _ = sigint.recv() => {} + _ = sighup.recv() => {} + } + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown() { + tokio::signal::ctrl_c().await.ok(); +} diff --git a/tests/e2e-cucumber/src/mock_server.rs b/tests/e2e-cucumber/src/mock_server.rs index e1f59209..7ccfe14c 100644 --- a/tests/e2e-cucumber/src/mock_server.rs +++ b/tests/e2e-cucumber/src/mock_server.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT use std::net::SocketAddr; +use std::path::Path; use axum::Router; use axum::extract::State; @@ -72,6 +73,49 @@ impl MockServer { } } +/// Write a managed-service record pointing the CLI at a mock server on `port`. +/// +/// Drops the JSON into `services_dir` (`/services/`) exactly as `rocm serve +/// --managed` would. Shared by the cucumber `World` and the standalone +/// `rocm-demo-env` binary so the on-disk schema lives in one place. Black-box: +/// plain JSON matching the CLI's on-disk schema, not a typed import from the +/// rocm-cli crates. +pub fn write_service_record(services_dir: &Path, model: &str, port: u16) { + std::fs::create_dir_all(services_dir).expect("failed to create services dir"); + + // The CLI only extracts host:port from `endpoint_url` and appends + // `/v1/models` for its readiness probe, which the mock serves. + let record = json!({ + "service_id": "e2e-mock", + "engine": "vllm", + "model_ref": model, + "canonical_model_id": model, + "host": "127.0.0.1", + "port": port, + "endpoint_url": format!("http://127.0.0.1:{port}/v1"), + "mode": "managed", + "status": "ready", + "supervisor_pid": 0, + "engine_pid": null, + "runtime_id": null, + "env_id": null, + "device_policy": null, + "gpu_indices": [], + "engine_recipe_json": null, + "restart_count": 0, + "last_restart_unix_ms": null, + "manifest_path": services_dir.join("e2e-mock.json"), + "log_path": services_dir.join("e2e-mock.log"), + "engine_state_path": services_dir.join("e2e-mock.state.json"), + "created_at_unix_ms": 1_700_000_000_000_u64, + }); + std::fs::write( + services_dir.join("e2e-mock.json"), + serde_json::to_vec_pretty(&record).expect("failed to serialize record"), + ) + .expect("failed to write service record"); +} + async fn handle_models(State(state): State) -> Json { Json(json!({ "object": "list", @@ -86,6 +130,12 @@ async fn handle_chat(Json(body): Json) -> Json { .unwrap_or("") .to_string(); + // Tests assert on the request contract, not the reply text, so the default + // is a fixed stub. Demos (`rocm-demo-env`) set `ROCM_MOCK_CHAT_REPLY` to a + // realistic answer so the recorded screencast reads naturally. + let content = std::env::var("ROCM_MOCK_CHAT_REPLY") + .unwrap_or_else(|_| "This is a mock response for testing.".to_string()); + Json(json!({ "id": "mock-completion-1", "object": "chat.completion", @@ -94,7 +144,7 @@ async fn handle_chat(Json(body): Json) -> Json { "index": 0, "message": { "role": "assistant", - "content": "This is a mock response for testing." + "content": content }, "finish_reason": "stop" }], diff --git a/tests/e2e-cucumber/tests/e2e.rs b/tests/e2e-cucumber/tests/e2e.rs index 0930fed0..52a4ee54 100644 --- a/tests/e2e-cucumber/tests/e2e.rs +++ b/tests/e2e-cucumber/tests/e2e.rs @@ -275,41 +275,8 @@ impl E2eWorld { let root = self.isolated_root.as_ref().expect("no isolated root"); let mock = self.mock.as_ref().expect("no mock server running"); let model = self.model_name.as_deref().expect("no model name set"); - let port = mock.port(); let services = root.path().join("data").join("services"); - std::fs::create_dir_all(&services).expect("failed to create services dir"); - - // The CLI only extracts host:port from `endpoint_url` and appends - // `/v1/models` for its readiness probe, which the mock serves. - let record = serde_json::json!({ - "service_id": "e2e-mock", - "engine": "vllm", - "model_ref": model, - "canonical_model_id": model, - "host": "127.0.0.1", - "port": port, - "endpoint_url": format!("http://127.0.0.1:{port}/v1"), - "mode": "managed", - "status": "ready", - "supervisor_pid": 0, - "engine_pid": null, - "runtime_id": null, - "env_id": null, - "device_policy": null, - "gpu_indices": [], - "engine_recipe_json": null, - "restart_count": 0, - "last_restart_unix_ms": null, - "manifest_path": services.join("e2e-mock.json"), - "log_path": services.join("e2e-mock.log"), - "engine_state_path": services.join("e2e-mock.state.json"), - "created_at_unix_ms": 1_700_000_000_000_u64, - }); - std::fs::write( - services.join("e2e-mock.json"), - serde_json::to_vec_pretty(&record).expect("failed to serialize record"), - ) - .expect("failed to write service record"); + e2e_cucumber::mock_server::write_service_record(&services, model, mock.port()); } } diff --git a/xtask/src/demos.rs b/xtask/src/demos.rs new file mode 100644 index 00000000..e9cd0e93 --- /dev/null +++ b/xtask/src/demos.rs @@ -0,0 +1,294 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Build and render the deterministic documentation demos. +//! +//! Environment setup happens here, before VHS starts. VHS advances its tape on +//! a fixed clock and does not wait for hidden setup commands, so the tapes stay +//! pure command/key sequences and run against an already-ready mock service. + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; + +const DEFAULT_MODEL: &str = "Qwen/Qwen3.5-0.6B"; +const DEMOS: &[&str] = &["cli", "console"]; +const READY_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn run(names: &[String], skip_build: bool) -> Result<()> { + let root = workspace_root()?; + let demos = select_demos(names)?; + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + + if !skip_build { + build_binary(&cargo, &root, "rocm", "rocm")?; + build_binary(&cargo, &root, "e2e-cucumber", "rocm-demo-env")?; + } + + let bin_dir = binary_dir(&root); + require_file(&bin_dir.join(binary_name("rocm")))?; + require_file(&bin_dir.join(binary_name("rocm-demo-env")))?; + + let demo_root = unique_demo_root(); + std::fs::create_dir_all(&demo_root) + .with_context(|| format!("failed to create {}", demo_root.display()))?; + + let mock = DemoProcess::start(&bin_dir, &demo_root)?; + let env = mock.wait_until_ready()?; + let path = prepend_path(&bin_dir)?; + + std::fs::create_dir_all(root.join("docs/media")).context("failed to create docs/media")?; + + for demo in demos { + let tape = root.join("docs/tapes").join(format!("{demo}.tape")); + require_file(&tape)?; + eprintln!("rendering {demo} demo"); + let status = Command::new("vhs") + .arg(&tape) + .current_dir(&root) + .env("PATH", &path) + .envs(&env) + .status() + .with_context(|| format!("failed to run VHS for {}", tape.display()))?; + if !status.success() { + bail!("VHS failed while rendering the {demo} demo"); + } + } + + Ok(()) +} + +fn select_demos(names: &[String]) -> Result> { + if names.is_empty() { + return Ok(DEMOS.to_vec()); + } + + let mut selected = Vec::new(); + for name in names { + let Some(&known) = DEMOS.iter().find(|candidate| **candidate == name) else { + bail!( + "unknown demo `{name}`; expected one or more of: {}", + DEMOS.join(", ") + ); + }; + if !selected.contains(&known) { + selected.push(known); + } + } + Ok(selected) +} + +fn build_binary(cargo: &OsStr, root: &Path, package: &str, binary: &str) -> Result<()> { + let status = Command::new(cargo) + .args(["build", "--release", "-p", package, "--bin", binary]) + .current_dir(root) + .status() + .with_context(|| format!("failed to build {binary}"))?; + if !status.success() { + bail!("building {binary} failed"); + } + Ok(()) +} + +struct DemoProcess { + child: Child, + root: PathBuf, + readiness: mpsc::Receiver, String>>, +} + +impl DemoProcess { + fn start(bin_dir: &Path, root: &Path) -> Result { + let model = std::env::var("ROCM_DEMO_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let executable = bin_dir.join(binary_name("rocm-demo-env")); + let mut child = Command::new(&executable) + .arg("--root") + .arg(root) + .args(["--model", &model]) + .env( + "ROCM_MOCK_CHAT_REPLY", + "ROCm CLI can inspect your system, manage runtimes and engines, serve models, monitor inference, and chat with local models.", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to start {}", executable.display()))?; + + let stdout = child + .stdout + .take() + .context("rocm-demo-env stdout was not captured")?; + let (tx, readiness) = mpsc::channel(); + std::thread::spawn(move || { + let result = read_environment(BufReader::new(stdout)); + let _ = tx.send(result); + }); + + Ok(Self { + child, + root: root.to_path_buf(), + readiness, + }) + } + + fn wait_until_ready(&self) -> Result> { + match self.readiness.recv_timeout(READY_TIMEOUT) { + Ok(Ok(env)) => Ok(env), + Ok(Err(message)) => bail!("rocm-demo-env failed before readiness: {message}"), + Err(mpsc::RecvTimeoutError::Timeout) => { + bail!("timed out waiting for rocm-demo-env readiness") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + bail!("rocm-demo-env readiness channel closed unexpectedly") + } + } + } +} + +impl Drop for DemoProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn read_environment(reader: impl BufRead) -> Result, String> { + let mut env = HashMap::new(); + for line in reader.lines() { + let line = line.map_err(|error| error.to_string())?; + if let Some(export) = line.strip_prefix("export ") { + let Some((key, value)) = export.split_once('=') else { + return Err(format!("malformed environment line: {line}")); + }; + if !key.starts_with("ROCM_CLI_") { + return Err(format!("unexpected environment variable: {key}")); + } + env.insert(key.to_string(), value.to_string()); + } + if line.contains("rocm-demo-env ready on") { + for required in [ + "ROCM_CLI_CONFIG_DIR", + "ROCM_CLI_DATA_DIR", + "ROCM_CLI_CACHE_DIR", + ] { + if !env.contains_key(required) { + return Err(format!("readiness marker arrived without {required}")); + } + } + return Ok(env); + } + } + Err("process exited without a readiness marker".to_string()) +} + +fn prepend_path(bin_dir: &Path) -> Result { + let mut paths = vec![bin_dir.to_path_buf()]; + if let Some(current) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(¤t)); + } + std::env::join_paths(paths).context("failed to prepend the demo binary directory to PATH") +} + +fn binary_dir(root: &Path) -> PathBuf { + if let Some(dir) = std::env::var_os("ROCM_BIN_DIR") { + let dir = PathBuf::from(dir); + return if dir.is_absolute() { + dir + } else { + root.join(dir) + }; + } + target_dir(root).join("release") +} + +fn target_dir(root: &Path) -> PathBuf { + match std::env::var_os("CARGO_TARGET_DIR") { + Some(dir) => { + let dir = PathBuf::from(dir); + if dir.is_absolute() { + dir + } else { + root.join(dir) + } + } + None => root.join("target"), + } +} + +fn unique_demo_root() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + std::env::temp_dir().join(format!("rocm-cli-demo-{}-{nonce}", std::process::id())) +} + +fn require_file(path: &Path) -> Result<()> { + if !path.is_file() { + bail!("required file not found: {}", path.display()); + } + Ok(()) +} + +fn binary_name(name: &str) -> String { + format!("{name}{}", std::env::consts::EXE_SUFFIX) +} + +fn workspace_root() -> Result { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let output = Command::new(cargo) + .args(["locate-project", "--workspace", "--message-format", "plain"]) + .output() + .context("failed to run `cargo locate-project`")?; + if !output.status.success() { + bail!( + "`cargo locate-project` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let manifest = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Path::new(&manifest) + .parent() + .map(Path::to_path_buf) + .with_context(|| format!("could not derive workspace root from {manifest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn empty_selection_means_all_demos() { + assert_eq!(select_demos(&[]).unwrap(), vec!["cli", "console"]); + } + + #[test] + fn selection_rejects_unknown_demo() { + let error = select_demos(&["unknown".to_string()]).unwrap_err(); + assert!(error.to_string().contains("unknown demo `unknown`")); + } + + #[test] + fn readiness_parser_requires_all_isolated_paths() { + let input = + b"export ROCM_CLI_CONFIG_DIR=/tmp/config\n# rocm-demo-env ready on 127.0.0.1:1\n"; + let error = read_environment(Cursor::new(input)).unwrap_err(); + assert!(error.contains("ROCM_CLI_DATA_DIR")); + } + + #[test] + fn readiness_parser_returns_isolated_environment() { + let input = b"export ROCM_CLI_CONFIG_DIR=/tmp/config\nexport ROCM_CLI_DATA_DIR=/tmp/data\nexport ROCM_CLI_CACHE_DIR=/tmp/cache\n# rocm-demo-env ready on 127.0.0.1:1\n"; + let env = read_environment(Cursor::new(input)).unwrap(); + assert_eq!(env["ROCM_CLI_CONFIG_DIR"], "/tmp/config"); + assert_eq!(env["ROCM_CLI_DATA_DIR"], "/tmp/data"); + assert_eq!(env["ROCM_CLI_CACHE_DIR"], "/tmp/cache"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f999f93f..5e597ca5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,6 +11,7 @@ //! alias `cargo xtask `. mod affected; +mod demos; mod e2e; mod e2e_report; mod manifest; @@ -112,6 +113,15 @@ enum Command { #[arg(long, conflicts_with_all = ["base", "require_verified"])] check_config: bool, }, + /// Build the demo binaries and render the deterministic CLI and Console GIFs. + Demos { + /// Demo names to render. When omitted, renders `cli` and `console`. + #[arg(value_name = "DEMO")] + names: Vec, + /// Use binaries already present in `ROCM_BIN_DIR` or the release target dir. + #[arg(long)] + skip_build: bool, + }, /// Build the release `rocm` binary and run the cucumber-rs E2E suite. /// /// The harness resolves every scenario's expectation (pass / xfail / skip) @@ -188,6 +198,7 @@ fn run() -> Result<()> { verify_commits::run(base, require_verified)?; } } + Command::Demos { names, skip_build } => demos::run(&names, skip_build)?, Command::E2e { args } => e2e::run(&args)?, Command::E2eReport { artifacts_dir,