diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9da5c52..143d372 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,6 +49,9 @@ jobs:
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
with:
node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: ui/package-lock.json
+ - run: cd ui && npm ci
- run: make lint
test:
@@ -60,6 +63,12 @@ jobs:
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: ui/package-lock.json
+ - run: cd ui && npm ci
- run: make test
build:
@@ -71,6 +80,12 @@ jobs:
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: ui/package-lock.json
+ - run: cd ui && npm ci
- run: make build
- name: Smoke test the binary
run: |
@@ -94,11 +109,14 @@ jobs:
with:
node-version-file: .nvmrc
cache: npm
- cache-dependency-path: examples/package-lock.json
+ cache-dependency-path: |
+ examples/package-lock.json
+ ui/package-lock.json
- name: Install Chromium for Playwright, and ffmpeg for demo cuts
working-directory: examples
run: |
npm ci
npx playwright install --with-deps chromium
sudo apt-get install -y --no-install-recommends ffmpeg
+ - run: cd ui && npm ci
- run: make e2e
diff --git a/Cargo.lock b/Cargo.lock
index 2700c07..e043756 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -104,6 +104,70 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "axum-macros",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-macros"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -150,6 +214,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "borsh"
version = "1.8.1"
@@ -322,6 +395,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -356,6 +435,15 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -368,8 +456,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid",
+ "crypto-common 0.2.2",
]
[[package]]
@@ -648,18 +747,39 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "humantime"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "hyper"
version = "1.12.0"
@@ -672,6 +792,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -899,6 +1020,7 @@ dependencies = [
"anyhow",
"clap",
"lest-core",
+ "lest-server",
"regex",
"serde",
"serde_json",
@@ -928,7 +1050,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
- "sha2",
+ "sha2 0.10.9",
"tempfile",
"thiserror",
"time",
@@ -938,6 +1060,26 @@ dependencies = [
"which",
]
+[[package]]
+name = "lest-server"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "futures",
+ "lest-core",
+ "rand 0.9.5",
+ "reqwest",
+ "rust-embed",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tower-http",
+]
+
[[package]]
name = "libc"
version = "0.2.190"
@@ -986,12 +1128,34 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1414,6 +1578,41 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rust-embed"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23"
+dependencies = [
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-impl"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053"
+dependencies = [
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "syn 2.0.119",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-utils"
+version = "8.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a"
+dependencies = [
+ "sha2 0.11.0",
+ "walkdir",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1574,6 +1773,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1607,7 +1817,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
- "digest",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
]
[[package]]
@@ -1831,6 +2052,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -1887,6 +2120,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -1897,10 +2131,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
+ "futures-core",
"futures-util",
"http",
"http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -1925,6 +2168,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-core",
]
@@ -1956,6 +2200,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
+
[[package]]
name = "unicode-ident"
version = "1.0.26"
diff --git a/Cargo.toml b/Cargo.toml
index 93fe1e6..ef8b529 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
-members = ["crates/lest-core", "crates/lest"]
+members = ["crates/lest-core", "crates/lest-server", "crates/lest"]
[workspace.package]
version = "0.1.0"
@@ -35,3 +35,4 @@ tempfile = "3"
clap = { version = "4", features = ["derive", "env"] }
axum = "0.8"
lest-core = { path = "crates/lest-core" }
+lest-server = { path = "crates/lest-server" }
diff --git a/Makefile b/Makefile
index d8cf25f..d4d478b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,8 @@
-.PHONY: deps tidy fmt lint test test-cover build e2e check install clean
+.PHONY: deps tidy fmt lint test test-cover ui build e2e check install clean
deps:
cargo fetch --locked
+ cd ui && npm ci
tidy:
cargo metadata --locked --format-version 1 >/dev/null
@@ -13,13 +14,19 @@ lint:
node scripts/check-workflows.mjs
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
+ cd ui && npm run typecheck
test:
cargo test --workspace --locked
+ cd ui && npm test
test-cover: test
-build:
+# The binary embeds ui/dist, so the UI is built first.
+ui:
+ cd ui && npm run build
+
+build: ui
cargo build --release --locked -p lest
e2e: build
@@ -27,7 +34,7 @@ e2e: build
check: tidy lint test build
-install:
+install: ui
cargo install --path crates/lest --locked
clean:
diff --git a/crates/lest-core/src/browser.rs b/crates/lest-core/src/browser.rs
index 413e421..3b6484b 100644
--- a/crates/lest-core/src/browser.rs
+++ b/crates/lest-core/src/browser.rs
@@ -350,7 +350,9 @@ impl BrowserDriver for NodeBrowser {
sink.line(stream, line);
});
tokio::pin!(proc);
- let mut ticker = tokio::time::interval(Duration::from_millis(150));
+ // Short steps finish quickly; follow the events closely so the
+ // live view can attach while the page is still open.
+ let mut ticker = tokio::time::interval(Duration::from_millis(50));
let res = loop {
tokio::select! {
r = &mut proc => break r,
diff --git a/crates/lest-core/src/runner/artifacts.rs b/crates/lest-core/src/runner/artifacts.rs
index beca833..3561581 100644
--- a/crates/lest-core/src/runner/artifacts.rs
+++ b/crates/lest-core/src/runner/artifacts.rs
@@ -107,6 +107,10 @@ pub fn store_file(
Ok(Artifact { label: label.map(str::to_string).unwrap_or(file_name), path: rel, mime: mime.to_string(), bytes })
}
+pub fn mime_for_name(name: &str) -> &'static str {
+ mime_for(Path::new(name))
+}
+
pub fn mime_for(path: &Path) -> &'static str {
match path.extension().map(|e| e.to_string_lossy().to_lowercase()).as_deref() {
Some("png") => "image/png",
@@ -118,7 +122,11 @@ pub fn mime_for(path: &Path) -> &'static str {
Some("webm") => "video/webm",
Some("vtt") => "text/vtt",
Some("json") => "application/json",
- Some("html" | "htm") => "text/html",
+ Some("html" | "htm") => "text/html; charset=utf-8",
+ Some("js" | "mjs") => "text/javascript",
+ Some("css") => "text/css",
+ Some("ico") => "image/x-icon",
+ Some("woff2") => "font/woff2",
Some("xml") => "application/xml",
Some("csv") => "text/csv",
Some("md") => "text/markdown",
diff --git a/crates/lest-core/src/runner/mod.rs b/crates/lest-core/src/runner/mod.rs
index f29bfcc..5d1d2c2 100644
--- a/crates/lest-core/src/runner/mod.rs
+++ b/crates/lest-core/src/runner/mod.rs
@@ -27,7 +27,7 @@ use crate::spec::{CleanupPolicy, Flow, Step, StepKind};
use crate::store::{Store, new_run_id};
use crate::tools::{self, Need};
-pub use artifacts::store_file as store_artifact;
+pub use artifacts::{mime_for, mime_for_name, store_file as store_artifact};
pub use steps::{AttemptOutcome, Recording};
/// Everything a run needs from its surroundings.
diff --git a/crates/lest-server/Cargo.toml b/crates/lest-server/Cargo.toml
new file mode 100644
index 0000000..b9c6e28
--- /dev/null
+++ b/crates/lest-server/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "lest-server"
+description = "Local HTTP API and UI server for Lest"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+futures.workspace = true
+axum = { workspace = true, features = ["macros"] }
+tower-http = { version = "0.6", features = ["fs"] }
+tokio-stream = { version = "0.1", features = ["sync"] }
+rust-embed = "8"
+rand.workspace = true
+lest-core.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+reqwest.workspace = true
diff --git a/crates/lest-server/build.rs b/crates/lest-server/build.rs
new file mode 100644
index 0000000..9f06d77
--- /dev/null
+++ b/crates/lest-server/build.rs
@@ -0,0 +1,14 @@
+// The UI is embedded from ui/dist. Without a built UI (a Rust-only build or
+// CI job), embed a page that says how to build it.
+fn main() {
+ let dist = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ui/dist");
+ if !dist.join("index.html").exists() {
+ std::fs::create_dir_all(&dist).expect("create ui/dist");
+ std::fs::write(
+ dist.join("index.html"),
+ "
Lest The Lest UI was not built into this binary. Run npm ci && npm run build in ui/, then rebuild.
",
+ )
+ .expect("write placeholder");
+ }
+ println!("cargo:rerun-if-changed=../../ui/dist");
+}
diff --git a/crates/lest-server/src/api.rs b/crates/lest-server/src/api.rs
new file mode 100644
index 0000000..401d17f
--- /dev/null
+++ b/crates/lest-server/src/api.rs
@@ -0,0 +1,510 @@
+//! JSON API routes.
+
+use std::collections::BTreeMap;
+use std::convert::Infallible;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use axum::Json;
+use axum::Router;
+use axum::extract::{Path, Query, Request, State};
+use axum::http::StatusCode;
+use axum::response::sse::{Event, KeepAlive, Sse};
+use axum::response::{IntoResponse, Response};
+use axum::routing::{get, post};
+use futures::Stream;
+use lest_core::catalog::{Catalog, Diagnostic, LoadedFlow};
+use lest_core::event::{EventBody, PlanNode};
+use lest_core::project::Project;
+use lest_core::report::RunReport;
+use lest_core::runner::{self, BrowserOptions, Engine, RunCancel, RunRequest};
+use lest_core::spec::{Demo, Input, StepKind};
+use lest_core::store::{RunSummary, new_run_id};
+use lest_core::validate::validate_catalog;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value as Json_, json};
+use tokio::sync::mpsc;
+use tokio_stream::StreamExt;
+use tokio_stream::wrappers::BroadcastStream;
+use tower_http::services::ServeFile;
+
+use crate::{AppState, LiveRun, run_dir_of};
+
+type AppResult = Result;
+
+pub(crate) struct ApiError(StatusCode, String);
+
+impl IntoResponse for ApiError {
+ fn into_response(self) -> Response {
+ (self.0, Json(json!({"error": self.1}))).into_response()
+ }
+}
+
+fn not_found(msg: impl Into) -> ApiError {
+ ApiError(StatusCode::NOT_FOUND, msg.into())
+}
+
+fn bad_request(msg: impl Into) -> ApiError {
+ ApiError(StatusCode::BAD_REQUEST, msg.into())
+}
+
+pub(crate) fn routes() -> Router> {
+ Router::new()
+ .route("/state", get(state))
+ .route("/flows", get(flows))
+ .route("/flows/{id}", get(flow))
+ .route("/runs", get(runs).post(start_run))
+ .route("/runs/{id}", get(run))
+ .route("/runs/{id}/cancel", post(cancel_run))
+ .route("/runs/{id}/files/{*path}", get(run_file))
+ .route("/runs/{id}/reveal", post(reveal))
+ .route("/tools/{name}/login", post(tool_login))
+ .route("/events", get(events))
+}
+
+fn catalog(project: &Project) -> Catalog {
+ let mut c = Catalog::load(project);
+ validate_catalog(&mut c);
+ c
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProjectInfo {
+ name: String,
+ root: String,
+ has_config: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct StateResponse {
+ project: ProjectInfo,
+ version: String,
+ live_runs: Vec,
+}
+
+async fn state(State(s): State>) -> Json {
+ let p = s.project.read().expect("lock").clone();
+ let live_runs = s.runs.lock().expect("lock").iter().filter(|(_, r)| !r.done).map(|(k, _)| k.clone()).collect();
+ Json(StateResponse {
+ project: ProjectInfo { name: p.name(), root: p.root.display().to_string(), has_config: p.has_config },
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ live_runs,
+ })
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct FlowSummary {
+ id: String,
+ name: String,
+ description: Option,
+ tags: Vec,
+ path: String,
+ /// The directory part of `path`, for grouping.
+ folder: String,
+ steps: Vec,
+ finally: Vec,
+ inputs: Vec ,
+ environments: Vec,
+ default_environment: Option,
+ demo: Option,
+ tools: Vec,
+ secrets: Vec,
+ services: Vec,
+ uses_browser: bool,
+ records: bool,
+ last_run: Option,
+}
+
+fn plan(steps: &[lest_core::spec::Step], prefix: &str, catalog: &Catalog) -> Vec {
+ steps
+ .iter()
+ .map(|s| {
+ let id = format!("{prefix}{}", s.id);
+ let children = match s.kind() {
+ StepKind::Group => plan(s.children(), prefix, catalog),
+ StepKind::Flow => catalog
+ .get(s.flow.as_deref().unwrap_or_default())
+ .map(|c| {
+ let p = format!("{id}/");
+ let mut v = plan(&c.flow.steps, &p, catalog);
+ v.extend(plan(&c.flow.finally, &p, catalog));
+ v
+ })
+ .unwrap_or_default(),
+ _ => vec![],
+ };
+ PlanNode { id, name: s.display_name().to_string(), kind: s.kind(), notes: s.notes.clone(), children }
+ })
+ .collect()
+}
+
+fn any_step(steps: &[lest_core::spec::Step], f: &dyn Fn(&lest_core::spec::Step) -> bool) -> bool {
+ steps.iter().any(|s| f(s) || any_step(s.children(), f))
+}
+
+fn summarize(lf: &LoadedFlow, catalog: &Catalog, s: &AppState) -> FlowSummary {
+ let f = &lf.flow;
+ let folder = lf.rel_path.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
+ let all: Vec<&lest_core::spec::Step> = f.steps.iter().chain(f.finally.iter()).collect();
+ let steps: Vec = all.into_iter().cloned().collect();
+ FlowSummary {
+ id: f.id.clone(),
+ name: f.name.clone(),
+ description: f.description.clone(),
+ tags: f.tags.clone(),
+ path: lf.rel_path.clone(),
+ folder,
+ steps: plan(&f.steps, "", catalog),
+ finally: plan(&f.finally, "", catalog),
+ inputs: f.inputs.clone(),
+ environments: f.environments.keys().cloned().collect(),
+ default_environment: f.default_environment.clone(),
+ demo: f.demo.clone(),
+ tools: f.tools.iter().map(|t| t.name().to_string()).collect(),
+ secrets: f.secrets.clone(),
+ services: f.services.iter().map(|s| s.id.clone()).collect(),
+ uses_browser: any_step(&steps, &|s| s.browser.is_some()),
+ records: any_step(&steps, &|s| s.browser.as_ref().is_some_and(|b| b.record)),
+ last_run: s.store.latest(&f.id).map(|r| RunSummary::from(&r)),
+ }
+}
+
+#[derive(Serialize)]
+struct FlowsResponse {
+ flows: Vec,
+ diagnostics: Vec,
+}
+
+async fn flows(State(s): State>) -> Json {
+ let project = s.project.read().expect("lock").clone();
+ let s2 = s.clone();
+ let out = tokio::task::spawn_blocking(move || {
+ let c = catalog(&project);
+ FlowsResponse {
+ flows: c.flows.iter().map(|lf| summarize(lf, &c, &s2)).collect(),
+ diagnostics: c.diagnostics.clone(),
+ }
+ })
+ .await
+ .expect("catalog task");
+ Json(out)
+}
+
+#[derive(Serialize)]
+struct FlowDetail {
+ #[serde(flatten)]
+ summary: FlowSummary,
+ source: String,
+ diagnostics: Vec,
+}
+
+async fn flow(State(s): State>, Path(id): Path) -> AppResult> {
+ let project = s.project.read().expect("lock").clone();
+ let c = catalog(&project);
+ let lf = c.get(&id).ok_or_else(|| not_found(format!("no flow {id}")))?;
+ let source = std::fs::read_to_string(&lf.path).unwrap_or_default();
+ let diagnostics = c.diagnostics.iter().filter(|d| d.file == lf.rel_path).cloned().collect();
+ Ok(Json(FlowDetail { summary: summarize(lf, &c, &s), source, diagnostics }))
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct StartRun {
+ flow_id: String,
+ environment: Option,
+ #[serde(default)]
+ inputs: BTreeMap,
+ /// Resume from this top-level step, reusing `resume_run_id` (default:
+ /// the latest run).
+ from_step: Option,
+ resume_run_id: Option,
+ #[serde(default = "yes")]
+ live_view: bool,
+ #[serde(default)]
+ headed: bool,
+}
+
+fn yes() -> bool {
+ true
+}
+
+async fn start_run(State(s): State>, Json(req): Json) -> AppResult> {
+ let project = s.project.read().expect("lock").clone();
+ let c = catalog(&project);
+ let lf = c.get(&req.flow_id).ok_or_else(|| not_found(format!("no flow {}", req.flow_id)))?;
+ let errors: Vec = c
+ .diagnostics
+ .iter()
+ .filter(|d| d.severity == lest_core::catalog::Severity::Error && d.flow.as_deref() == Some(&lf.flow.id))
+ .map(|d| d.to_string())
+ .collect();
+ if !errors.is_empty() {
+ return Err(bad_request(format!("the flow has validation errors: {}", errors.join("; "))));
+ }
+ let resume = match &req.from_step {
+ None => None,
+ Some(step) => {
+ let old = match &req.resume_run_id {
+ Some(id) => s.store.find(id).map_err(|e| not_found(e.to_string()))?,
+ None => s.store.latest(&req.flow_id).ok_or_else(|| not_found("no earlier run to resume from"))?,
+ };
+ Some((old, step.clone()))
+ }
+ };
+ let run_id = new_run_id();
+ let cancel = RunCancel::default();
+ s.runs.lock().expect("lock").insert(
+ run_id.clone(),
+ LiveRun {
+ flow_id: req.flow_id.clone(),
+ started_at: runner::now_rfc3339(),
+ environment: req.environment.clone().or_else(|| lf.flow.default_environment.clone()),
+ cancel: cancel.clone(),
+ events: Vec::new(),
+ done: false,
+ started: Instant::now(),
+ },
+ );
+ let engine = Engine {
+ project,
+ catalog: Arc::new(c),
+ store: s.store.clone(),
+ keyring: s.keyring.clone(),
+ browser: Some(s.browser.clone()),
+ };
+ let request = RunRequest {
+ flow_id: req.flow_id,
+ environment: req.environment,
+ inputs: req.inputs,
+ resume,
+ interactive: false,
+ browser: BrowserOptions { headed: req.headed, live_view: req.live_view },
+ };
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+ let forward = {
+ let s = s.clone();
+ let run_id = run_id.clone();
+ tokio::spawn(async move {
+ while let Some(ev) = rx.recv().await {
+ let done = matches!(ev.body, EventBody::RunFinished { .. });
+ {
+ let mut runs = s.runs.lock().expect("lock");
+ if let Some(live) = runs.get_mut(&run_id) {
+ live.events.push(ev.clone());
+ live.done |= done;
+ }
+ }
+ let _ = s.events.send(Arc::new(ev));
+ }
+ prune(&s);
+ })
+ };
+ let id = run_id.clone();
+ let s2 = s.clone();
+ tokio::spawn(async move {
+ runner::execute_with_id(&engine, request, tx, cancel, id.clone()).await;
+ let _ = forward.await;
+ // However the run ended, it is no longer live.
+ if let Some(live) = s2.runs.lock().expect("lock").get_mut(&id) {
+ live.done = true;
+ }
+ });
+ Ok(Json(json!({"runId": run_id})))
+}
+
+/// Finished runs are kept in memory briefly for late subscribers; their
+/// reports are on disk.
+fn prune(s: &AppState) {
+ let mut runs = s.runs.lock().expect("lock");
+ runs.retain(|_, r| !r.done || r.started.elapsed() < Duration::from_secs(600));
+}
+
+#[derive(Deserialize)]
+struct CancelBody {
+ /// `main` (default): stop the steps; `cleanup`: also abandon cleanup.
+ stage: Option,
+}
+
+async fn cancel_run(
+ State(s): State>,
+ Path(id): Path,
+ body: Option>,
+) -> AppResult> {
+ let runs = s.runs.lock().expect("lock");
+ let live = runs.get(&id).ok_or_else(|| not_found(format!("no running run {id}")))?;
+ let stage = body.and_then(|b| b.0.stage).unwrap_or_else(|| "main".into());
+ live.cancel.main.cancel();
+ if stage == "cleanup" {
+ live.cancel.cleanup.cancel();
+ }
+ Ok(Json(json!({"cancelled": stage})))
+}
+
+#[derive(Deserialize)]
+struct RunsQuery {
+ flow: Option,
+ limit: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RunsResponse {
+ running: Vec,
+ runs: Vec,
+}
+
+async fn runs(State(s): State>, Query(q): Query) -> Json {
+ let running = s
+ .runs
+ .lock()
+ .expect("lock")
+ .iter()
+ .filter(|(_, r)| !r.done && q.flow.as_ref().is_none_or(|f| f == &r.flow_id))
+ .map(|(id, r)| json!({"runId": id, "flowId": r.flow_id, "startedAt": r.started_at, "environment": r.environment}))
+ .collect();
+ let store = s.store.clone();
+ let runs = tokio::task::spawn_blocking(move || store.list(q.flow.as_deref(), q.limit.unwrap_or(100)))
+ .await
+ .unwrap_or_default();
+ Json(RunsResponse { running, runs })
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RunResponse {
+ run_id: String,
+ flow_id: String,
+ done: bool,
+ /// Events so far, for a run still in progress.
+ events: Vec,
+ report: Option,
+}
+
+async fn run(State(s): State>, Path(id): Path) -> AppResult> {
+ {
+ let runs = s.runs.lock().expect("lock");
+ if let Some(live) = runs.get(&id)
+ && !live.done
+ {
+ return Ok(Json(RunResponse {
+ run_id: id,
+ flow_id: live.flow_id.clone(),
+ done: false,
+ events: live.events.clone(),
+ report: None,
+ }));
+ }
+ }
+ let r = s.store.find(&id).map_err(|e| not_found(e.to_string()))?;
+ Ok(Json(RunResponse {
+ run_id: r.run_id.clone(),
+ flow_id: r.flow_id.clone(),
+ done: true,
+ events: vec![],
+ report: Some(r),
+ }))
+}
+
+async fn run_file(
+ State(s): State>,
+ Path((id, path)): Path<(String, String)>,
+ req: Request,
+) -> AppResult {
+ let dir = run_dir_of(&s, &id).ok_or_else(|| not_found(format!("no run {id}")))?;
+ let dir = std::fs::canonicalize(&dir).map_err(|_| not_found("run directory is gone"))?;
+ let file = std::fs::canonicalize(dir.join(&path)).map_err(|_| not_found(format!("no file {path}")))?;
+ // Only files inside the run directory, never through `..` or links out.
+ if !file.starts_with(&dir) || !file.is_file() {
+ return Err(not_found(format!("no file {path}")));
+ }
+ let mime = lest_core::runner::mime_for_name(&path);
+ let resp = ServeFile::new_with_mime(&file, &mime.parse().expect("mime")).try_call(req).await;
+ let mut resp = match resp {
+ Ok(r) => r.map(axum::body::Body::new),
+ Err(e) => return Err(ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
+ };
+ // Run files are untrusted content from the system under test: they run
+ // in a sandbox with no scripts and no access to the UI's origin, and
+ // anything that could execute downloads instead of rendering.
+ let h = resp.headers_mut();
+ h.insert(
+ axum::http::header::CONTENT_SECURITY_POLICY,
+ axum::http::HeaderValue::from_static(
+ "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'",
+ ),
+ );
+ let ext = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
+ if matches!(ext.as_str(), "html" | "htm" | "svg" | "js" | "mjs" | "xhtml" | "xml") {
+ h.insert(axum::http::header::CONTENT_DISPOSITION, axum::http::HeaderValue::from_static("attachment"));
+ }
+ Ok(resp)
+}
+
+#[derive(Deserialize)]
+struct RevealBody {
+ path: Option,
+}
+
+/// Shows a run's file (or its folder) in the system file manager.
+async fn reveal(
+ State(s): State>,
+ Path(id): Path,
+ body: Option>,
+) -> AppResult> {
+ let dir = run_dir_of(&s, &id).ok_or_else(|| not_found(format!("no run {id}")))?;
+ let target = match body.and_then(|b| b.0.path) {
+ Some(p) => {
+ let full = std::fs::canonicalize(dir.join(&p)).map_err(|_| not_found(format!("no file {p}")))?;
+ if !full.starts_with(std::fs::canonicalize(&dir).unwrap_or(dir.clone())) {
+ return Err(not_found(format!("no file {p}")));
+ }
+ full
+ }
+ None => dir,
+ };
+ crate::terminal::reveal(&target).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?;
+ Ok(Json(json!({"revealed": target.display().to_string()})))
+}
+
+/// Opens a terminal running the tool's sign-in command, since sign-ins
+/// prompt for input or open a browser.
+async fn tool_login(State(s): State>, Path(name): Path) -> AppResult> {
+ let project = s.project.read().expect("lock").clone();
+ let profile = project.config.tools.get(&name);
+ let login = profile
+ .and_then(|p| p.login.clone())
+ .ok_or_else(|| not_found(format!("tool {name} has no login command in lest.yaml")))?;
+ // The profile's pins (a profile name, an environment), as the CLI
+ // applies them; pins that need a run's vars are left out.
+ let mut scope = lest_core::expr::Scope::new();
+ scope.set("vars", json!({}));
+ scope.set("run", json!({"environment": null}));
+ let mut prefix = String::new();
+ for (k, t) in profile.map(|p| p.env.clone()).unwrap_or_default() {
+ if let Ok(v) = lest_core::expr::interpolate(&t, &scope)
+ && !v.is_empty()
+ {
+ prefix.push_str(&format!("{k}='{}' ", v.replace('\'', "'\\''")));
+ }
+ }
+ let login = format!("{prefix}{login}");
+ match crate::terminal::open(&login, &project.root) {
+ Ok(how) => Ok(Json(json!({"launched": true, "how": how, "command": login}))),
+ Err(e) => Ok(Json(json!({"launched": false, "error": e, "command": login}))),
+ }
+}
+
+async fn events(State(s): State>) -> Sse>> {
+ let rx = s.events.subscribe();
+ let stream = BroadcastStream::new(rx).map(|item| {
+ Ok(match item {
+ Ok(ev) => Event::default().event("run").json_data(ev.as_ref()).unwrap_or_else(|_| Event::default()),
+ // A slow client missed events; it should reload what it shows.
+ Err(_) => Event::default().event("resync").data("{}"),
+ })
+ });
+ Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
+}
diff --git a/crates/lest-server/src/lib.rs b/crates/lest-server/src/lib.rs
new file mode 100644
index 0000000..dfd7123
--- /dev/null
+++ b/crates/lest-server/src/lib.rs
@@ -0,0 +1,280 @@
+//! The local server behind Lest's UI: a JSON API, a server-sent event stream
+//! of run events, run files with range requests, and the embedded UI.
+//!
+//! It binds loopback only. Every request needs a loopback `Host` header,
+//! which blocks DNS-rebinding pages. API requests need the per-launch token,
+//! either as the `lest_token_` cookie (set when the UI is opened with
+//! `?token=`) or as a bearer token; the app shell and its assets load
+//! without it and carry no data. Requests that change something and arrive
+//! with the cookie must come from the UI's own origin, because a page on
+//! another local port counts as same-site and would send the cookie too.
+
+mod api;
+mod terminal;
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex, RwLock};
+use std::time::Instant;
+
+use axum::Router;
+use axum::body::Body;
+use axum::extract::{Request, State};
+use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
+use axum::middleware::{self, Next};
+use axum::response::{IntoResponse, Redirect, Response};
+use lest_core::event::RunEvent;
+use lest_core::paths::StateRoots;
+use lest_core::project::Project;
+use lest_core::runner::{BrowserDriver, RunCancel};
+use lest_core::secrets::Keyring;
+use lest_core::store::Store;
+use rust_embed::RustEmbed;
+use tokio::sync::broadcast;
+
+#[derive(RustEmbed)]
+#[folder = "../../ui/dist"]
+struct Ui;
+
+/// Builds the browser driver once the server's origin is known (the live
+/// view's DevTools socket accepts only that origin).
+pub type BrowserFactory = Arc) -> Arc + Send + Sync>;
+
+pub struct Options {
+ pub project: Project,
+ pub roots: StateRoots,
+ /// 0 picks a free port.
+ pub port: u16,
+ pub token: String,
+ pub keyring: Arc,
+ pub browser: BrowserFactory,
+}
+
+pub(crate) struct LiveRun {
+ pub flow_id: String,
+ pub started_at: String,
+ pub environment: Option,
+ pub cancel: RunCancel,
+ pub events: Vec,
+ pub done: bool,
+ pub started: Instant,
+}
+
+pub(crate) struct AppState {
+ pub project: RwLock,
+ /// The cookie's name carries the port, so two `lest ui` processes do
+ /// not overwrite each other's token.
+ pub cookie: String,
+ pub store: Store,
+ pub keyring: Arc,
+ pub browser: Arc,
+ pub token: String,
+ pub runs: Mutex>,
+ pub events: broadcast::Sender>,
+}
+
+pub struct Server {
+ pub addr: SocketAddr,
+ pub token: String,
+ router: Router,
+ listener: tokio::net::TcpListener,
+}
+
+impl Server {
+ /// The URL that opens the UI and hands it the token.
+ pub fn url(&self) -> String {
+ format!("http://{}/?token={}", self.addr, self.token)
+ }
+
+ pub async fn serve(self) -> std::io::Result<()> {
+ axum::serve(self.listener, self.router).await
+ }
+}
+
+/// A random token for one launch.
+pub fn new_token() -> String {
+ (0..32).map(|_| format!("{:x}", rand::random_range(0..16u8))).collect()
+}
+
+pub async fn bind(opts: Options) -> anyhow::Result {
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", opts.port)).await?;
+ let addr = listener.local_addr()?;
+ let origin = format!("http://{addr}");
+ let (events, _) = broadcast::channel(4096);
+ let state = Arc::new(AppState {
+ project: RwLock::new(opts.project),
+ store: Store::new(opts.roots),
+ keyring: opts.keyring,
+ cookie: format!("lest_token_{}", addr.port()),
+ // The live view may run from either loopback name.
+ browser: (opts.browser)(Some(format!("{origin},http://localhost:{}", addr.port()))),
+ token: opts.token.clone(),
+ runs: Mutex::new(HashMap::new()),
+ events,
+ });
+ let router = Router::new()
+ .nest("/api", api::routes())
+ .fallback(static_file)
+ .layer(middleware::from_fn_with_state(state.clone(), guard))
+ .with_state(state);
+ Ok(Server { addr, token: opts.token, router, listener })
+}
+
+fn is_loopback_host(headers: &HeaderMap) -> bool {
+ let Some(host) = headers.get(header::HOST).and_then(|h| h.to_str().ok()) else { return false };
+ let name = match host.rsplit_once(':') {
+ Some((n, port)) if port.chars().all(|c| c.is_ascii_digit()) => n,
+ _ => host,
+ };
+ matches!(name, "127.0.0.1" | "localhost" | "[::1]")
+}
+
+fn cookie_token(headers: &HeaderMap, name: &str) -> Option {
+ headers.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')).find_map(|c| {
+ let (k, v) = c.trim().split_once('=')?;
+ (k == name).then(|| v.to_string())
+ })
+}
+
+/// A cookie-authenticated request that changes something must come from
+/// the UI itself: its `Origin` (or `Sec-Fetch-Site`) must match.
+fn same_origin(headers: &HeaderMap) -> bool {
+ let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("");
+ if let Some(origin) = headers.get(header::ORIGIN).and_then(|o| o.to_str().ok()) {
+ return origin == format!("http://{host}");
+ }
+ match headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
+ Some(site) => site == "same-origin",
+ // No browser headers at all: not a cross-site browser request.
+ None => true,
+ }
+}
+
+fn bearer(headers: &HeaderMap) -> Option {
+ headers.get(header::AUTHORIZATION)?.to_str().ok()?.strip_prefix("Bearer ").map(str::to_string)
+}
+
+/// Equal-length comparison that does not stop at the first difference.
+fn same(a: &str, b: &str) -> bool {
+ a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
+}
+
+async fn guard(State(state): State>, req: Request, next: Next) -> Response {
+ if !is_loopback_host(req.headers()) {
+ return (StatusCode::FORBIDDEN, "lest only answers requests addressed to localhost").into_response();
+ }
+ // Opening the UI with ?token= trades it for a cookie and drops it from
+ // the address bar.
+ if let Some(q) = req.uri().query()
+ && let Some(t) = q.split('&').find_map(|kv| kv.strip_prefix("token="))
+ {
+ if same(t, &state.token) {
+ let mut resp = Redirect::to(req.uri().path()).into_response();
+ let cookie = format!("{}={}; Path=/; HttpOnly; SameSite=Strict", state.cookie, state.token);
+ resp.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("cookie"));
+ return resp;
+ }
+ return (StatusCode::UNAUTHORIZED, "wrong token").into_response();
+ }
+ let by_bearer = bearer(req.headers()).is_some_and(|t| same(&t, &state.token));
+ let by_cookie = cookie_token(req.headers(), &state.cookie).is_some_and(|t| same(&t, &state.token));
+ let path = req.uri().path().to_string();
+ if !(by_bearer || by_cookie) && (path.starts_with("/api/") || path == "/" || path.ends_with(".html")) {
+ return (
+ StatusCode::UNAUTHORIZED,
+ "Open Lest with the address `lest ui` printed (it carries this server's token).",
+ )
+ .into_response();
+ }
+ let changes = !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD);
+ if changes && !by_bearer && !same_origin(req.headers()) {
+ return (StatusCode::FORBIDDEN, "requests that change something must come from the Lest UI").into_response();
+ }
+ let mut resp = next.run(req).await;
+ let h = resp.headers_mut();
+ // Routes can set a stricter policy (run files do).
+ if !h.contains_key(header::CONTENT_SECURITY_POLICY) {
+ h.insert(
+ header::CONTENT_SECURITY_POLICY,
+ HeaderValue::from_static(
+ "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; \
+ connect-src 'self' ws://127.0.0.1:* ws://localhost:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
+ ),
+ );
+ }
+ h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
+ h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
+ resp
+}
+
+async fn static_file(req: Request) -> Response {
+ let path = req.uri().path().trim_start_matches('/');
+ let path = if path.is_empty() { "index.html" } else { path };
+ let (file, name) = match Ui::get(path) {
+ Some(f) => (f, path.to_string()),
+ // Client-side routes all serve the app shell.
+ None => match Ui::get("index.html") {
+ Some(f) => (f, "index.html".to_string()),
+ None => return StatusCode::NOT_FOUND.into_response(),
+ },
+ };
+ let mime = lest_core::runner::mime_for_name(&name);
+ let mut resp = Response::new(Body::from(file.data.into_owned()));
+ resp.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_str(mime).expect("mime"));
+ if name.starts_with("assets/") {
+ resp.headers_mut()
+ .insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"));
+ } else {
+ resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
+ }
+ resp
+}
+
+pub(crate) fn run_dir_of(state: &AppState, run_id: &str) -> Option {
+ if let Some(live) = state.runs.lock().expect("lock").get(run_id) {
+ return Some(state.store.run_dir(&live.flow_id, run_id));
+ }
+ state.store.find(run_id).ok().map(|r| state.store.run_dir(&r.flow_id, &r.run_id))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn only_loopback_hosts_pass() {
+ let mut h = HeaderMap::new();
+ for (host, ok) in [
+ ("127.0.0.1:4100", true),
+ ("localhost:4100", true),
+ ("[::1]:4100", true),
+ ("evil.example.com", false),
+ ("127.0.0.1.evil.com:80", false),
+ ] {
+ h.insert(header::HOST, HeaderValue::from_static(host));
+ assert_eq!(is_loopback_host(&h), ok, "{host}");
+ }
+ }
+
+ #[test]
+ fn reads_the_token_cookie() {
+ let mut h = HeaderMap::new();
+ h.insert(header::COOKIE, HeaderValue::from_static("a=1; lest_token_4100=abc; lest_token_4200=def"));
+ assert_eq!(cookie_token(&h, "lest_token_4200").as_deref(), Some("def"));
+ }
+
+ #[test]
+ fn changes_need_the_ui_origin() {
+ let mut h = HeaderMap::new();
+ h.insert(header::HOST, HeaderValue::from_static("127.0.0.1:4100"));
+ assert!(same_origin(&h));
+ h.insert(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:4100"));
+ assert!(same_origin(&h));
+ h.insert(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:4200"));
+ assert!(!same_origin(&h));
+ h.remove(header::ORIGIN);
+ h.insert("sec-fetch-site", HeaderValue::from_static("same-site"));
+ assert!(!same_origin(&h));
+ }
+}
diff --git a/crates/lest-server/src/terminal.rs b/crates/lest-server/src/terminal.rs
new file mode 100644
index 0000000..56d1075
--- /dev/null
+++ b/crates/lest-server/src/terminal.rs
@@ -0,0 +1,65 @@
+//! Opening a terminal for interactive commands (tool sign-ins, an agent
+//! hand-off) and revealing files in the system file manager.
+
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+fn shell_quote(s: &str) -> String {
+ format!("'{}'", s.replace('\'', "'\\''"))
+}
+
+/// Runs `command` in `cwd` in a new terminal window. Returns how, or an
+/// error when no terminal could be found (the caller shows the command to
+/// copy instead).
+pub fn open(command: &str, cwd: &Path) -> Result {
+ let line = format!("cd {} && {command}", shell_quote(&cwd.display().to_string()));
+ if cfg!(target_os = "macos") {
+ let script = format!(
+ "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell",
+ line.replace('\\', "\\\\").replace('"', "\\\"")
+ );
+ let ok = Command::new("osascript").arg("-e").arg(script).stdout(Stdio::null()).status();
+ return match ok {
+ Ok(s) if s.success() => Ok("Terminal".into()),
+ _ => Err("could not open Terminal".into()),
+ };
+ }
+ let keep_open = format!("{line}; echo; echo '[press enter to close]'; read _");
+ for (bin, args) in [
+ ("x-terminal-emulator", vec!["-e", "sh", "-c"]),
+ ("gnome-terminal", vec!["--", "sh", "-c"]),
+ ("konsole", vec!["-e", "sh", "-c"]),
+ ("xterm", vec!["-e", "sh", "-c"]),
+ ] {
+ if which_ok(bin) {
+ let spawned =
+ Command::new(bin).args(&args).arg(&keep_open).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
+ if spawned.is_ok() {
+ return Ok(bin.to_string());
+ }
+ }
+ }
+ Err("no terminal emulator found".into())
+}
+
+fn which_ok(bin: &str) -> bool {
+ std::env::var_os("PATH").is_some_and(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file()))
+}
+
+/// Shows a file or folder in the system file manager.
+pub fn reveal(path: &Path) -> Result<(), String> {
+ let status = if cfg!(target_os = "macos") {
+ // -R reveals; plain `open` would launch an .app directory.
+ Command::new("open").arg("-R").arg(path).status()
+ } else if cfg!(windows) {
+ Command::new("explorer").arg(format!("/select,{}", path.display())).status()
+ } else {
+ let dir = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
+ Command::new("xdg-open").arg(dir).status()
+ };
+ match status {
+ Ok(s) if s.success() => Ok(()),
+ Ok(s) => Err(format!("the file manager exited {}", s.code().unwrap_or(-1))),
+ Err(e) => Err(e.to_string()),
+ }
+}
diff --git a/crates/lest/Cargo.toml b/crates/lest/Cargo.toml
index b138f47..d0f00c1 100644
--- a/crates/lest/Cargo.toml
+++ b/crates/lest/Cargo.toml
@@ -20,6 +20,7 @@ serde_yaml_ng.workspace = true
tokio.workspace = true
tokio-util.workspace = true
lest-core.workspace = true
+lest-server.workspace = true
regex.workspace = true
which.workspace = true
diff --git a/crates/lest/src/args.rs b/crates/lest/src/args.rs
index b79a7f7..e307886 100644
--- a/crates/lest/src/args.rs
+++ b/crates/lest/src/args.rs
@@ -48,6 +48,8 @@ pub enum Command {
#[arg(value_enum, default_value_t = SchemaKind::Flow)]
kind: SchemaKind,
},
+ /// Open the UI.
+ Ui(UiArgs),
/// Remove stored run data.
Data {
#[command(subcommand)]
@@ -89,6 +91,16 @@ pub struct RunArgs {
pub quiet: bool,
}
+#[derive(Debug, Args)]
+pub struct UiArgs {
+ /// Port to listen on (default: any free port).
+ #[arg(long, default_value_t = 0)]
+ pub port: u16,
+ /// Print the address instead of opening a browser.
+ #[arg(long)]
+ pub no_browser: bool,
+}
+
#[derive(Debug, Args)]
pub struct ListArgs {
/// Only flows with this tag.
diff --git a/crates/lest/src/main.rs b/crates/lest/src/main.rs
index 7ca54be..30ea8c8 100644
--- a/crates/lest/src/main.rs
+++ b/crates/lest/src/main.rs
@@ -114,6 +114,7 @@ async fn dispatch(cli: Cli, style: Style) -> Result {
Ok(0)
}
Command::Data { command } => cmd_data(&ctx, command),
+ Command::Ui(a) => cmd_ui(&ctx, a).await,
}
}
@@ -834,6 +835,34 @@ fn cmd_secrets(ctx: &Ctx, command: SecretsCommand) -> Result {
}
}
+async fn cmd_ui(ctx: &Ctx, a: UiArgs) -> Result {
+ let roots = ctx.roots.clone();
+ let project = ctx.project.clone();
+ let server = lest_server::bind(lest_server::Options {
+ project: ctx.project.clone(),
+ roots: ctx.roots.clone(),
+ port: a.port,
+ token: lest_server::new_token(),
+ keyring: keyring(),
+ browser: Arc::new(move |origin| browser_driver(&project, &roots, origin)),
+ })
+ .await?;
+ let url = server.url();
+ eprintln!("lest ui: {url}");
+ eprintln!("project: {}", ctx.project.root.display());
+ if !a.no_browser {
+ let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
+ if std::process::Command::new(opener).arg(&url).status().is_err() {
+ eprintln!("open the address above in a browser");
+ }
+ }
+ tokio::select! {
+ r = server.serve() => r?,
+ _ = tokio::signal::ctrl_c() => {}
+ }
+ Ok(0)
+}
+
fn cmd_data(ctx: &Ctx, command: DataCommand) -> Result {
let store = ctx.store();
match command {
diff --git a/docs/images/demo.png b/docs/images/demo.png
new file mode 100644
index 0000000..5b54139
Binary files /dev/null and b/docs/images/demo.png differ
diff --git a/docs/images/flow-failed-dark.png b/docs/images/flow-failed-dark.png
new file mode 100644
index 0000000..3173313
Binary files /dev/null and b/docs/images/flow-failed-dark.png differ
diff --git a/docs/images/flow-failed.png b/docs/images/flow-failed.png
new file mode 100644
index 0000000..79a43b6
Binary files /dev/null and b/docs/images/flow-failed.png differ
diff --git a/docs/images/flow-passed.png b/docs/images/flow-passed.png
new file mode 100644
index 0000000..f38a565
Binary files /dev/null and b/docs/images/flow-passed.png differ
diff --git a/docs/images/flow-running-live.png b/docs/images/flow-running-live.png
new file mode 100644
index 0000000..a197678
Binary files /dev/null and b/docs/images/flow-running-live.png differ
diff --git a/docs/images/flows.png b/docs/images/flows.png
new file mode 100644
index 0000000..7d4c49b
Binary files /dev/null and b/docs/images/flows.png differ
diff --git a/docs/images/runs.png b/docs/images/runs.png
new file mode 100644
index 0000000..d2de884
Binary files /dev/null and b/docs/images/runs.png differ
diff --git a/docs/images/settings.png b/docs/images/settings.png
new file mode 100644
index 0000000..1f9cba3
Binary files /dev/null and b/docs/images/settings.png differ
diff --git a/docs/ui.md b/docs/ui.md
new file mode 100644
index 0000000..3e0cbe5
--- /dev/null
+++ b/docs/ui.md
@@ -0,0 +1,140 @@
+# The UI
+
+`lest ui` serves a web UI on `127.0.0.1` for the current project. It reads the
+same flows, runs the same runner and shows the same reports as the CLI. The
+source is in `ui/` (see [ui/README.md](../ui/README.md) for development).
+
+The left rail holds Flows, Runs, Demos and Settings, and shows the project
+name. It collapses to icons; the choice is remembered in the browser.
+
+## Flows
+
+
+
+Every flow in the project as a card, grouped by folder. A card shows the last
+run's result as a dot (hollow when the flow never ran), the first line of the
+description, tags, and how long ago the last run started and how long it took.
+Search matches names, ids and tags; tag chips filter. Validation problems from
+the flow files appear in a banner above the cards, and the affected cards carry
+a warning mark.
+
+## A flow
+
+
+
+The header shows the flow's name, id, file path, tags and description. Under
+it, the run controls: the environment (when the flow defines any), one field
+per input (a list for inputs with choices; required inputs are marked), Watch
+browser for flows with browser steps, and Run.
+
+While a run is in progress, Run becomes Cancel. The first click stops the
+steps; `finally` steps and cleanups still run. Clicking again while cleanup
+runs skips the remaining cleanup.
+
+Below the controls is the latest run of the flow: one started from this page,
+one in progress on the server, or the newest stored run.
+
+- **Summary:** result, duration (ticking while running), when it started, the
+ environment, the run id, and the number of warnings.
+- **Setup:** tool preflight results, with a Sign in button when a tool needs an
+ interactive sign-in (it opens a terminal running the tool's login command,
+ or shows the command to run), and service start messages.
+- **Steps:** the step tree. Each row shows a status glyph, name, id, kind and
+ duration. A polling step shows its attempt and a countdown to the next one.
+ Groups and flow calls expand while they run or fail and collapse when they
+ pass; clicking a row overrides that. Expanding a step shows its **Output**
+ (live lines while it runs, the report's stdout and stderr after),
+ **Details** (what ran, exit code, HTTP status, attempts, outputs, notes) and
+ **Artifacts**.
+- **Side panel:** the live browser view, the run's artifacts grouped by step
+ (images open in a lightbox, videos play in place), cleanups with their
+ status, and warnings.
+
+### Failures
+
+
+
+A failed step opens with its headline at the top of the step: one
+deterministic line built from the error, the exit code and the output, with
+the step's `notes` under it. The actions:
+
+- **Copy rerun command:** `lest run --from `, with the
+ run to reuse earlier results from, the environment and the inputs.
+- **Copy context:** plain text with the flow file, run, environment, inputs,
+ the failed step, what it ran, the headline, exit code or HTTP status,
+ attempts, notes, the last 40 lines of stderr and stdout, earlier steps'
+ statuses and the rerun command. It is meant to be pasted into an issue, a
+ chat or your own coding agent.
+- The overflow menu copies the headline or step id, opens the run folder in
+ the file manager, or opens the run on its own page.
+
+The headline block is laid out so another text can take its place later
+without moving anything else on the page.
+
+The UI follows the system's light or dark setting, or the choice in Settings:
+
+
+
+## Live view
+
+
+
+When a browser step starts, the runner reports the page it is driving (its
+DevTools port and target id) as a `browserPage` event. The UI opens a WebSocket
+straight to that page's DevTools endpoint and sends `Page.startScreencast`.
+Chromium then pushes JPEG frames as the page changes. The UI acknowledges each
+frame as it arrives, keeps only the newest one and draws it at most about 30
+times a second into an image element, so a fast page never queues frames.
+
+When the step opens a popup, the runner reports the popup as the active page
+and the view switches to it; when the popup closes, it switches back. The view
+shows the page's address and a LIVE badge; IDLE means the page has not changed
+for a moment (the screencast sends frames only on change). It stops when the
+browser closes or the run ends, and shows "Live view unavailable" if the
+connection fails.
+
+The browser accepts DevTools connections only from the UI's own origin
+(`--remote-allow-origins`). Turning Watch browser off starts browsers without a
+DevTools port, so there is no live view for that run.
+
+## Runs
+
+
+
+Every stored run, newest first, with runs in progress at the top. Filter by
+flow and result. A row opens the run on its own page (`/runs/`), which
+uses the same run view with Run again in place of the run controls.
+
+## Demos
+
+
+
+Flows with a `demo:` block (or the `demo` tag). The gallery card shows the
+demo's title, summary and beat labels. A demo's page shows the story as a
+numbered list and a Record button, which runs the flow. While it records, the
+beats light up as the recording reaches them and the live browser view takes
+the main area. Afterwards the page plays the recording: the edited demo video
+with chapters when post-production produced one, otherwise the browser step's
+raw recording. Popup recordings, the beat sheet and screenshots are listed as
+extras. A flow that has not recorded yet says so.
+
+## Settings
+
+
+
+Theme (System, Light, Dark), and the project's name, root, config file and
+the Lest version.
+
+## Updating the screenshots
+
+The images in `docs/images/` come from the real UI:
+
+```bash
+cd examples && lest ui --no-browser --port 4790
+# in another shell, with the address it printed
+cd ui && LEST_UI_URL='http://127.0.0.1:4790/?token=...' npm run screenshots
+```
+
+The script starts runs through the API and waits for each state before it
+captures. The project root appears in `settings.png`, so run the server from
+a copy of `examples/` at a neutral path if your checkout path is personal.
diff --git a/harness/lest-harness.mjs b/harness/lest-harness.mjs
index 5fe9675..e319f50 100644
--- a/harness/lest-harness.mjs
+++ b/harness/lest-harness.mjs
@@ -257,9 +257,16 @@ async function main() {
// frame the size of the main viewport.
page.waitForLoadState("domcontentloaded").then(() => page.evaluate(() => [innerWidth, innerHeight])).then((s) => (info.size = s)).catch(() => {});
current = page;
+ let last = "";
const report = async () => {
const id = await targetId(context, page);
- if (id) emit({ type: "page", targetId: id, url: page.url(), role });
+ const key = `${id} ${page.url()}`;
+ // One event per page and address: navigation and tracking can both
+ // report the same state.
+ if (id && key !== last) {
+ last = key;
+ emit({ type: "page", targetId: id, url: page.url(), role });
+ }
};
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) report();
diff --git a/scripts/check-denylist.sh b/scripts/check-denylist.sh
index 6fb9939..8fa5cb6 100755
--- a/scripts/check-denylist.sh
+++ b/scripts/check-denylist.sh
@@ -16,9 +16,10 @@ status=0
if [ "${1:-}" = "--stdin" ]; then
if grep -inE -f "$patterns" -; then status=1; fi
else
- # Binary files are scanned too (images can carry text in metadata).
+ # Text files only: short patterns match random bytes in compressed
+ # images. Review images by eye before publishing them.
files="$(git ls-files)"
- if [ -n "$files" ] && git ls-files -z | xargs -0 grep -inE -f "$patterns" --; then status=1; fi
+ if [ -n "$files" ] && git ls-files -z | xargs -0 grep -inIE -f "$patterns" --; then status=1; fi
if git ls-files | grep -iE -f "$patterns"; then status=1; fi
fi
if [ "$status" -ne 0 ]; then
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..3ca16c3
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,79 @@
+# Lest UI
+
+The web UI that `lest ui` serves. React and TypeScript, built with Vite into
+`ui/dist`, which the `lest-server` crate embeds. What the screens do is in
+[docs/ui.md](../docs/ui.md).
+
+## Build
+
+```bash
+cd ui
+npm ci
+npm run build # typecheck, then build ui/dist
+```
+
+Release builds of `lest` embed `ui/dist` at compile time, so build the UI
+before `cargo build --release`. Debug builds read `ui/dist` from disk on each
+request: rebuild the UI and reload the page, no Rust rebuild needed. Without a
+built UI, the server embeds a placeholder page that says how to build it.
+`ui/dist` is not committed.
+
+## Develop with hot reload
+
+Start `lest ui` against a project, then point the Vite dev server at it:
+
+```bash
+cd examples && lest ui --no-browser --port 4790
+# prints: lest ui: http://127.0.0.1:4790/?token=
+
+cd ui
+LEST_UI_URL='http://127.0.0.1:4790/?token=' npm run dev
+```
+
+The dev server proxies `/api` to `LEST_UI_URL` and adds
+`Authorization: Bearer ` (taken from `LEST_UI_TOKEN` if set, otherwise
+from the URL). Open the address Vite prints. The live browser view does not
+work through the dev server, because the browser accepts DevTools connections
+only from the `lest ui` origin.
+
+## Checks
+
+```bash
+npm run typecheck # tsc --noEmit
+npm test # vitest: run state reducer, markdown, copy context
+npm run build
+```
+
+The reducer tests replay real event streams captured from the example flows
+(`src/test/fixtures/*.json`). To refresh them, run a flow while reading the
+event stream and keep that run's events:
+
+```bash
+curl -sN -H "Authorization: Bearer " http://127.0.0.1:4790/api/events
+```
+
+Each `data:` line is one event; a fixture is the JSON array of one run's
+events in order. Replace machine-specific paths before committing.
+
+## Screenshots
+
+`npm run screenshots` drives the real UI with Playwright and writes the PNGs
+in `docs/images/`. It needs `LEST_UI_URL` and a running `lest ui` on the
+examples project; see [docs/ui.md](../docs/ui.md#updating-the-screenshots).
+
+## Layout
+
+| Path | Contents |
+|---|---|
+| `src/types.ts` | JSON shapes of the server API and run events. |
+| `src/api.ts` | Fetch helpers for `/api`. |
+| `src/store/runState.ts` | The pure reducer that folds events and reports into run state. |
+| `src/store/store.ts` | The single SSE connection and the run store. |
+| `src/components/RunView.tsx` | The run view: summary, setup, step tree, step bodies, side panel. |
+| `src/components/LiveView.tsx` | The DevTools screencast view. |
+| `src/pages/` | One file per screen. |
+| `src/lib/` | Router, formatting, Markdown subset, copy context. |
+
+Dependencies are kept to `react` and `react-dom`. No component or CSS
+libraries; icons are inline SVG. Colors are CSS custom properties in
+`src/styles.css`, redefined for the dark theme.
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..474868d
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ lest
+
+
+
+
+
+
+
diff --git a/ui/package-lock.json b/ui/package-lock.json
new file mode 100644
index 0000000..996bdfb
--- /dev/null
+++ b/ui/package-lock.json
@@ -0,0 +1,1357 @@
+{
+ "name": "lest-ui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lest-ui",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "playwright": "^1.63.0",
+ "typescript": "^6.0.3",
+ "vite": "^8.3.3",
+ "vitest": "^4.1.11"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.152.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
+ "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
+ "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
+ "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
+ "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
+ "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
+ "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
+ "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
+ "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
+ "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
+ "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
+ "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
+ "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
+ "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
+ "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.152.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
+ "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.152.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.12",
+ "@rolldown/binding-android-arm64": "1.2.12",
+ "@rolldown/binding-darwin-arm64": "1.2.12",
+ "@rolldown/binding-darwin-x64": "1.2.12",
+ "@rolldown/binding-freebsd-x64": "1.2.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.12",
+ "@rolldown/binding-linux-arm64-musl": "1.2.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-musl": "1.2.12",
+ "@rolldown/binding-openharmony-arm64": "1.2.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.12",
+ "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
+ "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
+ "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.11",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000..a337af9
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "lest-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "The web UI served by lest ui",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "screenshots": "node scripts/screenshots.mjs"
+ },
+ "dependencies": {
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "playwright": "^1.63.0",
+ "typescript": "^6.0.3",
+ "vite": "^8.3.3",
+ "vitest": "^4.1.11"
+ }
+}
diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg
new file mode 100644
index 0000000..690a2f6
--- /dev/null
+++ b/ui/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/ui/scripts/screenshots.mjs b/ui/scripts/screenshots.mjs
new file mode 100644
index 0000000..3205c74
--- /dev/null
+++ b/ui/scripts/screenshots.mjs
@@ -0,0 +1,148 @@
+// Captures the docs screenshots from the real UI against a running
+// `lest ui` serving the examples project:
+//
+// cd examples && lest ui --no-browser --port 4790
+// LEST_UI_URL='http://127.0.0.1:4790/?token=...' npm run screenshots
+//
+// Starts no server. Triggers runs through the API and waits for each state
+// before capturing. Writes PNGs to docs/images/ at 2x.
+
+import { mkdir } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromium } from "playwright";
+
+const printed = process.env.LEST_UI_URL;
+if (!printed) {
+ console.error("Set LEST_UI_URL to the address `lest ui --no-browser` printed (with ?token=).");
+ process.exit(2);
+}
+const url = new URL(printed);
+const origin = url.origin;
+const token = url.searchParams.get("token") ?? "";
+const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "images");
+const VIEWPORT = { width: 1440, height: 900 };
+
+async function api(path, init = {}) {
+ const res = await fetch(`${origin}/api${path}`, {
+ ...init,
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json", ...(init.headers ?? {}) },
+ });
+ if (!res.ok) throw new Error(`${init.method ?? "GET"} ${path}: ${res.status} ${await res.text()}`);
+ return res.json();
+}
+
+const startRun = (flowId) =>
+ api("/runs", { method: "POST", body: JSON.stringify({ flowId, liveView: true }) }).then((r) => r.runId);
+
+async function waitDone(runId, timeoutMs = 180_000) {
+ const end = Date.now() + timeoutMs;
+ while (Date.now() < end) {
+ const r = await api(`/runs/${runId}`);
+ if (r.done && r.report) return r.report;
+ await new Promise((res) => setTimeout(res, 400));
+ }
+ throw new Error(`run ${runId} did not finish`);
+}
+
+async function shot(page, name) {
+ // Let transitions and lazy images settle.
+ await page.waitForTimeout(400);
+ await page.evaluate(() => Promise.all([...document.images].filter((i) => !i.complete).map((i) => new Promise((r) => { i.onload = i.onerror = r; }))));
+ const path = join(outDir, name);
+ await page.screenshot({ path });
+ console.log(`wrote ${path}`);
+}
+
+async function open(context) {
+ const page = await context.newPage();
+ // Trades the token for the session cookie.
+ await page.goto(printed);
+ await page.waitForSelector(".rail");
+ return page;
+}
+
+async function go(page, path) {
+ await page.evaluate((p) => {
+ history.pushState(null, "", p);
+ dispatchEvent(new PopStateEvent("popstate"));
+ }, path);
+}
+
+async function main() {
+ await mkdir(outDir, { recursive: true });
+ const browser = await chromium.launch();
+ const light = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 2, colorScheme: "light" });
+ const page = await open(light);
+
+ console.log("running everything, plants-api and wrong-password");
+ await waitDone(await startRun("everything"));
+ const passed = await startRun("plants-api");
+ await waitDone(passed);
+ const failed = await startRun("wrong-password");
+ await waitDone(failed);
+
+ // A live run, captured mid-recording with a frame in the live view.
+ console.log("recording connect-weather");
+ await go(page, "/flows/connect-weather");
+ await page.waitForSelector(".controls");
+ const live = await startRun("connect-weather");
+ await page.waitForSelector(`.summary .value.mono:has-text("${live}")`);
+ await page.waitForSelector(".live img:not([hidden])", { timeout: 60_000 });
+ await page.waitForSelector(".live-badge:not(.idle)", { timeout: 30_000 });
+ // Wait for the consent popup or the dashboard to be on screen.
+ await page.waitForFunction(() => document.querySelector(".live-url span[title]")?.textContent?.includes("oauth"), null, { timeout: 30_000 }).catch(() => {});
+ await page.waitForTimeout(500);
+ await shot(page, "flow-running-live.png");
+ await waitDone(live);
+
+ await go(page, "/flows");
+ await page.waitForSelector(".card");
+ await shot(page, "flows.png");
+
+ await go(page, "/flows/plants-api");
+ await page.waitForSelector(`.summary .value.mono:has-text("${passed}")`);
+ await page.click('.step-row:has-text("Wait for the sensor to pair")');
+ await page.click('.step.open [role="tab"]:has-text("Details")');
+ await shot(page, "flow-passed.png");
+
+ await go(page, "/flows/wrong-password");
+ await page.waitForSelector(".explain");
+ await page.click('.step.open [role="tab"]:has-text("Artifacts")');
+ await page.waitForSelector(".step.open .thumb img");
+ await shot(page, "flow-failed.png");
+
+ await go(page, "/runs");
+ await page.waitForSelector("table.runs tbody tr");
+ await shot(page, "runs.png");
+
+ await go(page, "/demos/connect-weather");
+ await page.waitForSelector(".demo-video video");
+ await page.waitForFunction(() => (document.querySelector(".demo-video video")?.readyState ?? 0) >= 2, null, { timeout: 15_000 }).catch(() => {});
+ await page.evaluate(() => {
+ const v = document.querySelector(".demo-video video");
+ if (v && Number.isFinite(v.duration)) v.currentTime = Math.min(v.duration * 0.6, v.duration - 0.1);
+ });
+ await page.waitForTimeout(600);
+ await shot(page, "demo.png");
+
+ await go(page, "/settings");
+ await page.waitForSelector(".seg");
+ await page.waitForSelector(".kv");
+ await shot(page, "settings.png");
+
+ const dark = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 2, colorScheme: "dark" });
+ const dpage = await open(dark);
+ await go(dpage, "/flows/wrong-password");
+ await dpage.waitForSelector(".explain");
+ await dpage.click('.step.open [role="tab"]:has-text("Artifacts")');
+ await dpage.waitForSelector(".step.open .thumb img");
+ await shot(dpage, "flow-failed-dark.png");
+
+ await browser.close();
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
new file mode 100644
index 0000000..066b0ba
--- /dev/null
+++ b/ui/src/App.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useState, type ReactNode } from "react";
+import {
+ IconDemos,
+ IconFlows,
+ IconRuns,
+ IconSettings,
+ IconSidebar,
+ LogoMark,
+} from "./components/icons";
+import { Toaster } from "./components/ui";
+import { linkHandler, matchRoute, usePath } from "./lib/router";
+import { readPref, useProject, writePref } from "./lib/project";
+import { useConnection, useLiveRunIds } from "./store/store";
+import { FlowsPage } from "./pages/Flows";
+import { FlowPage } from "./pages/Flow";
+import { RunsPage } from "./pages/Runs";
+import { RunPage } from "./pages/Run";
+import { DemosPage } from "./pages/Demos";
+import { DemoPage } from "./pages/Demo";
+import { SettingsPage } from "./pages/Settings";
+
+const RAIL_KEY = "lest.railCollapsed";
+
+export function App() {
+ const path = usePath();
+ const route = matchRoute(path);
+ const [collapsed, setCollapsed] = useState(() => readPref(RAIL_KEY, false));
+ const project = useProject();
+
+ useEffect(() => {
+ const base = project?.project.name ?? "lest";
+ const title: Record = {
+ flows: "Flows",
+ flow: "Flow",
+ runs: "Runs",
+ run: "Run",
+ demos: "Demos",
+ demo: "Demo",
+ settings: "Settings",
+ notFound: "Not found",
+ };
+ document.title = `${title[route.name]} - ${base}`;
+ }, [route.name, project]);
+
+ let page: ReactNode;
+ switch (route.name) {
+ case "flows":
+ page = ;
+ break;
+ case "flow":
+ page = ;
+ break;
+ case "runs":
+ page = ;
+ break;
+ case "run":
+ page = ;
+ break;
+ case "demos":
+ page = ;
+ break;
+ case "demo":
+ page = ;
+ break;
+ case "settings":
+ page = ;
+ break;
+ default:
+ page = (
+
+ );
+ }
+
+ const section =
+ route.name === "flow" || route.name === "flows"
+ ? "flows"
+ : route.name === "run" || route.name === "runs"
+ ? "runs"
+ : route.name === "demo" || route.name === "demos"
+ ? "demos"
+ : route.name;
+
+ return (
+
+ {
+ writePref(RAIL_KEY, !collapsed);
+ setCollapsed(!collapsed);
+ }}
+ />
+ {page}
+
+
+ );
+}
+
+function Rail({
+ section,
+ collapsed,
+ projectName,
+ onToggle,
+}: {
+ section: string;
+ collapsed: boolean;
+ projectName: string | null;
+ onToggle: () => void;
+}) {
+ const live = useLiveRunIds();
+ const liveCount = live ? live.split(",").length : 0;
+ const conn = useConnection();
+ const link = (to: string, id: string, label: string, icon: ReactNode, extra?: ReactNode) => (
+
+ {icon}
+ {label}
+ {extra}
+
+ );
+ return (
+
+
+ {projectName && (
+
+ Project
+ {projectName}
+
+ )}
+ {link("/flows", "flows", "Flows", )}
+ {link(
+ "/runs",
+ "runs",
+ "Runs",
+ ,
+ liveCount > 0 ? (
+
+
+ {liveCount} running
+
+ ) : undefined,
+ )}
+ {link("/demos", "demos", "Demos", )}
+
+
+ {conn !== "open" && (
+
+
+ Reconnecting
+
+ )}
+ {link("/settings", "settings", "Settings",
)}
+
+
+ Collapse
+ {collapsed ? "Expand the sidebar" : ""}
+
+
+
+ );
+}
diff --git a/ui/src/api.ts b/ui/src/api.ts
new file mode 100644
index 0000000..f4a79f1
--- /dev/null
+++ b/ui/src/api.ts
@@ -0,0 +1,76 @@
+// Calls to the Lest server. The page is served same-origin with the auth
+// cookie already set, so plain fetch works.
+
+import type { FlowDetail, FlowsResponse, RunResponse, RunsResponse, StateResponse } from "./types";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+async function call(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`/api${path}`, { credentials: "same-origin", ...init });
+ const text = await res.text();
+ let body: unknown = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = null;
+ }
+ if (!res.ok) {
+ const msg =
+ body && typeof body === "object" && "error" in body ? String((body as { error: unknown }).error) : text;
+ throw new ApiError(res.status, msg || `${res.status} ${res.statusText}`);
+ }
+ return body as T;
+}
+
+function post(path: string, body?: unknown): Promise {
+ return call(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: body === undefined ? "{}" : JSON.stringify(body),
+ });
+}
+
+export interface StartRunRequest {
+ flowId: string;
+ environment?: string;
+ inputs?: Record;
+ liveView?: boolean;
+ fromStep?: string;
+ resumeRunId?: string;
+}
+
+export interface LoginResponse {
+ launched: boolean;
+ how?: string;
+ error?: string;
+ command: string;
+}
+
+export const api = {
+ state: () => call("/state"),
+ flows: () => call("/flows"),
+ flow: (id: string) => call(`/flows/${encodeURIComponent(id)}`),
+ runs: (flow?: string, limit = 200) => {
+ const q = new URLSearchParams({ limit: String(limit) });
+ if (flow) q.set("flow", flow);
+ return call(`/runs?${q}`);
+ },
+ run: (id: string) => call(`/runs/${encodeURIComponent(id)}`),
+ startRun: (req: StartRunRequest) => post<{ runId: string }>("/runs", req),
+ cancel: (id: string, stage: "main" | "cleanup") =>
+ post<{ cancelled: string }>(`/runs/${encodeURIComponent(id)}/cancel`, { stage }),
+ reveal: (id: string, path?: string) => post<{ revealed: string }>(`/runs/${encodeURIComponent(id)}/reveal`, { path }),
+ toolLogin: (name: string) => post(`/tools/${encodeURIComponent(name)}/login`),
+};
+
+export function fileUrl(runId: string, path: string): string {
+ const parts = path.split("/").map(encodeURIComponent).join("/");
+ return `/api/runs/${encodeURIComponent(runId)}/files/${parts}`;
+}
diff --git a/ui/src/components/LiveView.tsx b/ui/src/components/LiveView.tsx
new file mode 100644
index 0000000..11b2db2
--- /dev/null
+++ b/ui/src/components/LiveView.tsx
@@ -0,0 +1,148 @@
+// Streams the running browser step's page through the DevTools screencast:
+// the UI talks to Chromium directly, acknowledges every frame, and draws only
+// the newest one, at most once per animation frame.
+
+import { useEffect, useRef, useState } from "react";
+import type { BrowserState } from "../store/runState";
+
+type Phase = "connecting" | "live" | "idle" | "unavailable" | "ended";
+
+const IDLE_AFTER_MS = 1500;
+const MIN_FRAME_GAP_MS = 33;
+
+export function LiveView({ browser, big = false }: { browser: BrowserState; big?: boolean }) {
+ const img = useRef(null);
+ const box = useRef(null);
+ const [phase, setPhase] = useState("connecting");
+ const [hasFrame, setHasFrame] = useState(false);
+ const phaseRef = useRef("connecting");
+ const { cdpPort, targetId, active } = browser;
+
+ useEffect(() => {
+ const set = (p: Phase) => {
+ if (phaseRef.current !== p) {
+ phaseRef.current = p;
+ setPhase(p);
+ }
+ };
+ if (!active) {
+ set("ended");
+ return;
+ }
+ set("connecting");
+ let ws: WebSocket | null = null;
+ let latest: string | null = null;
+ let raf = 0;
+ let lastDraw = 0;
+ let lastFrameAt = 0;
+ let closed = false;
+ let gotFrame = false;
+ const draw = (t: number) => {
+ raf = 0;
+ if (!latest) return;
+ if (t - lastDraw < MIN_FRAME_GAP_MS) {
+ raf = requestAnimationFrame(draw);
+ return;
+ }
+ lastDraw = t;
+ if (img.current) img.current.src = `data:image/jpeg;base64,${latest}`;
+ latest = null;
+ if (!gotFrame) {
+ gotFrame = true;
+ setHasFrame(true);
+ }
+ };
+ const idleTimer = setInterval(() => {
+ if (lastFrameAt && Date.now() - lastFrameAt > IDLE_AFTER_MS && phaseRef.current === "live") set("idle");
+ }, 500);
+ const connect = () => {
+ try {
+ ws = new WebSocket(`ws://127.0.0.1:${cdpPort}/devtools/page/${targetId}`);
+ } catch {
+ set("unavailable");
+ return;
+ }
+ const sock = ws;
+ sock.onopen = () => {
+ const width = Math.round((box.current?.clientWidth || 360) * (window.devicePixelRatio || 1));
+ sock.send(
+ JSON.stringify({
+ id: 1,
+ method: "Page.startScreencast",
+ params: { format: "jpeg", quality: 70, maxWidth: width, maxHeight: width, everyNthFrame: 1 },
+ }),
+ );
+ };
+ sock.onmessage = (m) => {
+ let msg: { method?: string; params?: { data: string; sessionId: number } };
+ try {
+ msg = JSON.parse(typeof m.data === "string" ? m.data : "");
+ } catch {
+ return;
+ }
+ if (msg.method !== "Page.screencastFrame" || !msg.params) return;
+ sock.send(JSON.stringify({ id: 2, method: "Page.screencastFrameAck", params: { sessionId: msg.params.sessionId } }));
+ latest = msg.params.data;
+ lastFrameAt = Date.now();
+ set("live");
+ if (!raf) raf = requestAnimationFrame(draw);
+ };
+ sock.onerror = () => {
+ if (!closed && !gotFrame) set("unavailable");
+ };
+ sock.onclose = () => {
+ if (!closed) set(gotFrame ? "ended" : "unavailable");
+ };
+ };
+ // A short step can report its page and close within milliseconds; wait
+ // briefly so a browser that is already gone is not dialed.
+ const delay = setTimeout(connect, 120);
+ return () => {
+ closed = true;
+ clearTimeout(delay);
+ clearInterval(idleTimer);
+ if (raf) cancelAnimationFrame(raf);
+ try {
+ ws?.close();
+ } catch {
+ /* already closed */
+ }
+ };
+ }, [cdpPort, targetId, active]);
+
+ const badge =
+ phase === "live" ? (
+
+
+ LIVE
+
+ ) : phase === "idle" ? (
+
+
+ IDLE
+
+ ) : null;
+
+ const message =
+ phase === "connecting" && !hasFrame
+ ? "Connecting to the browser"
+ : phase === "unavailable" && !hasFrame
+ ? "Live view unavailable"
+ : phase === "ended" && !hasFrame
+ ? "The browser closed"
+ : null;
+
+ return (
+
+
+
+ {message &&
{message}
}
+
+
+ {badge}
+ {phase === "ended" && hasFrame && Last frame }
+ {browser.url}
+
+
+ );
+}
diff --git a/ui/src/components/RunControls.tsx b/ui/src/components/RunControls.tsx
new file mode 100644
index 0000000..0f04f0e
--- /dev/null
+++ b/ui/src/components/RunControls.tsx
@@ -0,0 +1,164 @@
+import { useState } from "react";
+import { api } from "../api";
+import { isLive } from "../store/runState";
+import { noteStarted, useRun } from "../store/store";
+import type { FlowSummary, Scalar } from "../types";
+import { IconPlay, IconRecord, IconStop } from "./icons";
+import { Switch } from "./ui";
+
+function defaultValue(v: Scalar | undefined): string {
+ return v === undefined ? "" : String(v);
+}
+
+/** Environment, inputs, live view and Run/Cancel for one flow. */
+export function RunControls({
+ flow,
+ runId,
+ record = false,
+ onStarted,
+}: {
+ flow: FlowSummary;
+ /** The run shown under the controls; Cancel acts on it while it is live. */
+ runId: string | null;
+ record?: boolean;
+ onStarted?: (runId: string) => void;
+}) {
+ const [env, setEnv] = useState(flow.defaultEnvironment ?? flow.environments[0] ?? "");
+ const [inputs, setInputs] = useState>(() =>
+ Object.fromEntries(flow.inputs.map((i) => [i.name, defaultValue(i.default)])),
+ );
+ const [liveView, setLiveView] = useState(true);
+ const [error, setError] = useState(null);
+ const [starting, setStarting] = useState(false);
+ const [cancelStage, setCancelStage] = useState>({});
+ const { run } = useRun(runId);
+ const live = run ? isLive(run) : false;
+ const stage = runId ? cancelStage[runId] : undefined;
+
+ const missing = flow.inputs.filter((i) => i.required && !inputs[i.name]);
+
+ const start = async () => {
+ setError(null);
+ setStarting(true);
+ try {
+ const values = Object.fromEntries(Object.entries(inputs).filter(([, v]) => v !== ""));
+ const r = await api.startRun({
+ flowId: flow.id,
+ environment: env || undefined,
+ inputs: values,
+ liveView: flow.usesBrowser ? liveView : false,
+ });
+ noteStarted(flow.id, r.runId);
+ onStarted?.(r.runId);
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setStarting(false);
+ }
+ };
+
+ const cancel = async () => {
+ if (!runId) return;
+ const next = stage === "main" ? "cleanup" : "main";
+ setCancelStage((c) => ({ ...c, [runId]: next }));
+ try {
+ await api.cancel(runId, next);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/RunView.tsx b/ui/src/components/RunView.tsx
new file mode 100644
index 0000000..46f90de
--- /dev/null
+++ b/ui/src/components/RunView.tsx
@@ -0,0 +1,798 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { api, fileUrl } from "../api";
+import { buildContext, rerunCommand } from "../lib/context";
+import { formatBytes, formatDuration, formatTime, plural, timeAgo } from "../lib/format";
+import { navigate } from "../lib/router";
+import {
+ allStepIds,
+ isLive,
+ runArtifacts,
+ topLevelOf,
+ type RunState,
+ type StepState,
+} from "../store/runState";
+import { useRun } from "../store/store";
+import type { Artifact, RunReport, StepReport, StepStatus, ToolReport } from "../types";
+import {
+ IconCopy,
+ IconExternal,
+ IconFile,
+ IconFolder,
+ IconTerminal,
+ IconWarning,
+} from "./icons";
+import { LiveView } from "./LiveView";
+import { ResultBadge, StatusDot, StatusGlyph } from "./status";
+import { CopyButton, copyWithToast, Lightbox, OverflowMenu, toast, useNow } from "./ui";
+
+export interface RunViewProps {
+ runId: string;
+ projectRoot: string | null;
+ /** Flow file path relative to the project, for Copy context before the
+ * report is loaded. */
+ flowPath?: string;
+ showSide?: boolean;
+}
+
+type OpenImage = { src: string; caption: string } | null;
+
+export function RunView({ runId, projectRoot, flowPath, showSide = true }: RunViewProps) {
+ const { run, failed } = useRun(runId);
+ const [image, setImage] = useState(null);
+ const live = run ? isLive(run) : false;
+ const now = useNow(200, live);
+
+ if (!run || (run.lastSeq < 0 && !run.report)) {
+ return {failed ? `Run ${runId} could not be loaded.` : "Loading run"}
;
+ }
+ const ctx: Ctx = { run, now, live, projectRoot, flowPath, openImage: setImage };
+ return (
+
+
+
+ {run.finished?.error && (
+
+
+
+
The run could not complete
+
{run.finished.error}
+
+
+ )}
+
+
+
+ {showSide &&
}
+ {image &&
setImage(null)} />}
+
+ );
+}
+
+interface Ctx {
+ run: RunState;
+ now: number;
+ live: boolean;
+ projectRoot: string | null;
+ flowPath?: string;
+ openImage: (img: OpenImage) => void;
+}
+
+function runStatus(run: RunState): StepStatus | RunReport["result"] {
+ return run.finished ? run.finished.result : "running";
+}
+
+function SummaryStrip({ ctx }: { ctx: Ctx }) {
+ const { run, now } = ctx;
+ const status = runStatus(run);
+ const duration = run.finished ? run.finished.durationMs : run.startedAtMs ? now - run.startedAtMs : null;
+ const warnings = run.report?.warnings?.length ?? 0;
+ return (
+
+
+ Result
+
+
+
+
+
+ Duration
+ {formatDuration(duration) || "0ms"}
+
+ {run.startedAtMs && (
+
+ Started
+
+ {timeAgo(run.startedAtMs, Math.max(now, Date.now()))}
+
+
+ )}
+ {run.environment && (
+
+ Environment
+ {run.environment}
+
+ )}
+
+ Run
+
+ {run.runId}
+
+
+
+ {run.report?.resumedFrom && (
+
+ Resumed from
+
+ {run.report.resumedFrom.step} of {run.report.resumedFrom.runId}
+
+
+ )}
+
+ {warnings > 0 && (
+
+ Warnings
+
+
+ {warnings}
+
+
+ )}
+
+ );
+}
+
+const toolStatusText: Record = {
+ ready: "ready",
+ missing: "missing",
+ "too-old": "too old",
+ "signed-out": "signed out",
+ unknown: "version unknown",
+};
+
+function toolDot(t: ToolReport) {
+ return t.status === "ready" ? "passed" : t.status === "unknown" ? "skipped" : "failed";
+}
+
+function Setup({ ctx }: { ctx: Ctx }) {
+ const { run } = ctx;
+ const tools = run.tools;
+ const services = run.lastSeq === Number.MAX_SAFE_INTEGER ? (run.report?.services ?? []) : [];
+ if (!tools.length && !run.progress.length && !services.length) return null;
+ return (
+
+
{tools.length ? "Preflight" : "Setup"}
+
+ {tools.map((t) => (
+
+ ))}
+ {Object.entries(run.signIn)
+ .filter(([name]) => !tools.some((t) => t.name === name))
+ .map(([name, cmd]) => (
+
+ ))}
+ {run.progress.map((p, i) => (
+
+
+ {p.message}
+
+ {run.startedAtMs ? `+${formatDuration(p.atMs - run.startedAtMs)}` : ""}
+
+
+ ))}
+ {services.map((s) => (
+
+
+
+ service {s.id} {s.reused ? "was already running and was reused" : "started"}
+
+ {s.log && (
+
+
+ log
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function ToolRow({ tool, signIn }: { tool: ToolReport; signIn?: string }) {
+ const [result, setResult] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const login = async () => {
+ setBusy(true);
+ try {
+ const r = await api.toolLogin(tool.name);
+ if (r.launched) {
+ setResult(`Opened ${r.how ?? "a terminal"} running: ${r.command}`);
+ } else {
+ setResult(`No terminal could be opened. Run this yourself: ${r.command}`);
+ void copyWithToast(r.command, "sign-in command");
+ }
+ } catch (e) {
+ setResult(signIn ? `Could not start sign-in (${(e as Error).message}). Run: ${signIn}` : (e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+ <>
+
+
+ {tool.name}
+ {tool.version && {tool.version} }
+ {tool.message}
+
+ {toolStatusText[tool.status]}
+ {signIn && (
+ void login()} disabled={busy}>
+ Sign in
+
+ )}
+
+
+ {result && (
+
+
+ {result}
+
+
+ )}
+ >
+ );
+}
+
+// Step tree
+
+function autoOpen(s: StepState, live: boolean): boolean {
+ if (s.children.length) {
+ if (s.status === "running" || s.status === "failed" || s.status === "errored") return true;
+ return live && s.status === "pending";
+ }
+ return (s.status === "failed" || s.status === "errored") && !s.report?.tolerated;
+}
+
+function StepTree({ ctx }: { ctx: Ctx }) {
+ const { run } = ctx;
+ const [toggles, setToggles] = useState>({});
+ const toggle = (id: string, open: boolean) => setToggles((t) => ({ ...t, [id]: !open }));
+ if (!run.roots.length && !run.finallyRoots.length) {
+ return (
+
+
{run.finished ? "This run executed no steps." : "Waiting for the first step"}
+
+ );
+ }
+ const node = (id: string, depth: number) => (
+
+ );
+ return (
+
+
+ Steps
+ {stepCounts(run)}
+
+
+ {run.roots.map((id) => node(id, 0))}
+ {run.finallyRoots.length > 0 && (
+ <>
+
Finally
+ {run.finallyRoots.map((id) => node(id, 0))}
+ >
+ )}
+
+
+ );
+}
+
+function stepCounts(run: RunState): string {
+ const leaves = allStepIds(run)
+ .map((id) => run.steps[id])
+ .filter((s): s is StepState => !!s && s.children.length === 0);
+ const done = leaves.filter((s) => s.status !== "pending" && s.status !== "running").length;
+ return `${done} of ${leaves.length}`;
+}
+
+function StepNode({
+ id,
+ depth,
+ ctx,
+ toggles,
+ onToggle,
+}: {
+ id: string;
+ depth: number;
+ ctx: Ctx;
+ toggles: Record;
+ onToggle: (id: string, open: boolean) => void;
+}) {
+ const s = ctx.run.steps[id];
+ const rowRef = useRef(null);
+ const isContainer = !!s && s.children.length > 0;
+ const status = s?.status;
+ useEffect(() => {
+ if (ctx.live && status === "running" && !isContainer && rowRef.current) {
+ rowRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }
+ }, [status, ctx.live, isContainer]);
+ if (!s) return null;
+ const open = toggles[id] ?? autoOpen(s, ctx.live);
+ const dur =
+ s.status === "running" && s.startedAtMs !== null ? ctx.now - s.startedAtMs : s.durationMs;
+ return (
+
+
onToggle(id, open)}
+ >
+
+
+
+
+
+
+ {s.name}
+ {s.name !== id && {id} }
+
+ {s.report?.tolerated && failure tolerated }
+
+ {s.kind}
+ {s.status === "pending" || s.status === "skipped" ? "" : formatDuration(dur)}
+
+
+ {open && isContainer && (
+
+ {s.children.map((c) => (
+
+ ))}
+
+ )}
+ {open && !isContainer &&
}
+
+ );
+}
+
+function Attempts({ s, now }: { s: StepState; now: number }) {
+ if (s.retry && s.status === "running") {
+ const wait = Math.max(0, s.retry.nextAtMs - now);
+ return (
+
+ attempt {s.retry.attempt + 1} of {s.retry.maxAttempts}, next in {Math.ceil(wait / 1000)}s
+
+ );
+ }
+ const max = s.maxAttempts;
+ if (s.status === "running" && max && s.attempt > 1) {
+ return (
+
+ attempt {s.attempt} of {max}
+
+ );
+ }
+ if (s.status !== "running" && s.attempt > 1) {
+ return {plural(s.attempt, "attempt")} ;
+ }
+ return null;
+}
+
+type Tab = "output" | "details" | "artifacts";
+
+function StepBody({ s, depth, ctx }: { s: StepState; depth: number; ctx: Ctx }) {
+ const [tab, setTab] = useState("output");
+ const failedHere = (s.status === "failed" || s.status === "errored") && s.report;
+ const tabs: { id: Tab; label: string; n?: number }[] = [
+ { id: "output", label: "Output" },
+ { id: "details", label: "Details" },
+ { id: "artifacts", label: "Artifacts", n: s.artifacts.length },
+ ];
+ return (
+
+ {failedHere &&
}
+
+ {tabs.map((t) => (
+ setTab(t.id)}
+ >
+ {t.label}
+ {t.n ? {t.n} : null}
+
+ ))}
+
+
+ {tab === "output" &&
}
+ {tab === "details" &&
}
+ {tab === "artifacts" &&
+ (s.artifacts.length ? (
+
+ ) : (
+
This step saved no artifacts.
+ ))}
+
+
+ );
+}
+
+const MAX_RENDERED_LINES = 2000;
+
+function prettyIfJson(text: string): string {
+ const t = text.trim();
+ if (t.length > 200_000 || !(t.startsWith("{") || t.startsWith("["))) return text;
+ try {
+ return JSON.stringify(JSON.parse(t), null, 2);
+ } catch {
+ return text;
+ }
+}
+
+function tail(text: string): { text: string; dropped: number } {
+ const lines = text.replace(/\n+$/, "").split("\n");
+ if (lines.length <= MAX_RENDERED_LINES) return { text: lines.join("\n"), dropped: 0 };
+ return { text: lines.slice(-MAX_RENDERED_LINES).join("\n"), dropped: lines.length - MAX_RENDERED_LINES };
+}
+
+function OutputPane({ s, live }: { s: StepState; live: boolean }) {
+ const pre = useRef(null);
+ const finished = s.report && s.status !== "running";
+ const lines = s.output;
+ useEffect(() => {
+ if (live && !finished && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
+ }, [lines.length, live, finished]);
+
+ if (finished && (s.report?.stdout || s.report?.stderr)) {
+ const parts: ReactNode[] = [];
+ const both = !!s.report?.stdout && !!s.report?.stderr;
+ for (const stream of ["stdout", "stderr"] as const) {
+ const raw = s.report?.[stream];
+ if (!raw) continue;
+ const { text, dropped } = tail(stream === "stdout" ? prettyIfJson(raw) : raw);
+ parts.push(
+
+ {both &&
{stream} }
+ {dropped > 0 &&
Showing the last {MAX_RENDERED_LINES} lines; {dropped} earlier lines are in the report.
}
+
+ {text.split("\n").map((l, i) => (
+
+ {l}
+ {"\n"}
+
+ ))}
+
+
,
+ );
+ }
+ return <>{parts}>;
+ }
+ if (!lines.length) {
+ return (
+
+ {s.status === "running" ? "Waiting for output" : s.status === "pending" ? "Not run yet." : "No output."}
+
+ );
+ }
+ const shown = lines.slice(-MAX_RENDERED_LINES);
+ const dropped = s.droppedLines + lines.length - shown.length;
+ return (
+ <>
+ {dropped > 0 && {dropped} earlier lines are not shown.
}
+
+ {shown.map((l) => (
+
+ {l.line}
+ {"\n"}
+
+ ))}
+
+ >
+ );
+}
+
+function DetailsPane({ s }: { s: StepState }) {
+ const r: StepReport | null = s.report;
+ const rows: [string, ReactNode][] = [];
+ if (r?.resolved) rows.push(["Resolved", {r.resolved.trimEnd()} ]);
+ rows.push(["Status", s.status + (r?.tolerated ? " (failure tolerated by continueOnError)" : "")]);
+ if (r?.exitCode !== undefined && r?.exitCode !== null) rows.push(["Exit code", {r.exitCode} ]);
+ if (r?.httpStatus) rows.push(["HTTP status", {r.httpStatus} ]);
+ if (s.attempt) rows.push(["Attempts", s.maxAttempts ? `${s.attempt} of up to ${s.maxAttempts}` : String(s.attempt)]);
+ if (r?.startedAt) rows.push(["Started", formatTime(r.startedAt)]);
+ if (s.durationMs !== null) rows.push(["Duration", formatDuration(s.durationMs)]);
+ if (r?.error && r.error !== r.headline) rows.push(["Error", {r.error} ]);
+ if (r?.error && r.error === r.headline && !(s.status === "failed" || s.status === "errored"))
+ rows.push(["Error", {r.error} ]);
+ if (s.notes) rows.push(["Notes", s.notes]);
+ const outputs = Object.entries(r?.outputs ?? {});
+ if (outputs.length) {
+ rows.push([
+ "Outputs",
+
+
+ {outputs.map(([k, v]) => (
+
+ {k}
+
+ {typeof v === "string" ? v : JSON.stringify(v, null, 2)}
+
+
+ ))}
+
+
,
+ ]);
+ }
+ if (s.beats.length) rows.push(["Beats", s.beats.map((b) => `${b.marker} at ${formatDuration(b.atMs)}`).join(", ")]);
+ if (!r && s.status === "running") rows.push(["", Details appear when the step finishes. ]);
+ return (
+
+ {rows.map(([k, v], i) => (
+
+ ))}
+
+ );
+}
+
+function KvRow({ k, v }: { k: string; v: ReactNode }) {
+ return (
+ <>
+ {k}
+ {v}
+ >
+ );
+}
+
+/** The failure explanation slot. The primary line holds the deterministic
+ * headline today; the marker and secondary lines keep their place so a
+ * different primary text can fill it without moving anything. */
+function FailedCard({ s, ctx }: { s: StepState; ctx: Ctx }) {
+ const r = s.report as StepReport;
+ const { run } = ctx;
+ const top = topLevelOf(run, s.id);
+ const flowId = run.flowId ?? run.report?.flowId ?? "";
+ const report = run.report ?? syntheticReport(run, ctx);
+ const rerun = rerunCommand({
+ flowId,
+ fromStep: top,
+ runId: run.runId,
+ environment: run.environment,
+ inputs: report.inputs,
+ });
+ const headline = r.headline ?? r.error ?? `${s.name} ${s.status}`;
+ return (
+
+
+ {s.notes &&
{s.notes}
}
+
+ void copyWithToast(rerun, "rerun command")}>
+
+ Copy rerun command
+
+ void copyWithToast(buildContext({ report, step: r, topLevel: top }), "context")}
+ >
+
+ Copy context
+
+ , onSelect: () => void copyWithToast(headline, "headline") },
+ { label: "Copy step id", icon: , onSelect: () => void copyWithToast(s.id, "step id") },
+ {
+ label: "Show run folder",
+ icon: ,
+ onSelect: () =>
+ void api.reveal(run.runId).then(
+ () => toast("Opened the run folder"),
+ (e: Error) => toast(e.message),
+ ),
+ },
+ ...(location.pathname.startsWith("/runs/")
+ ? []
+ : [{ label: "Open this run", icon: , onSelect: () => navigate(`/runs/${run.runId}`) }]),
+ ]}
+ />
+
+
+ );
+}
+
+/** A report-shaped view of a run whose report has not loaded yet. */
+function syntheticReport(run: RunState, ctx: Ctx): RunReport {
+ const toReport = (id: string): StepReport => {
+ const s = run.steps[id]!;
+ return (
+ s.report ?? {
+ id,
+ name: s.name,
+ kind: s.kind,
+ status: s.status,
+ attempts: s.attempt,
+ durationMs: s.durationMs ?? 0,
+ children: s.children.map(toReport),
+ }
+ );
+ };
+ return {
+ schemaVersion: 1,
+ runId: run.runId,
+ flowId: run.flowId ?? "",
+ flowName: run.flowName ?? "",
+ flowPath: ctx.flowPath ?? "",
+ projectDir: ctx.projectRoot ?? "",
+ environment: run.environment ?? undefined,
+ startedAt: new Date(run.startedAtMs ?? Date.now()).toISOString(),
+ durationMs: run.finished?.durationMs ?? 0,
+ result: run.finished?.result ?? "failed",
+ inputs: {},
+ steps: run.roots.map(toReport),
+ finally: run.finallyRoots.map(toReport),
+ };
+}
+
+// Artifacts
+
+export function ArtifactGrid({
+ runId,
+ artifacts,
+ report,
+ onOpen,
+ compact = false,
+}: {
+ runId: string;
+ artifacts: Artifact[];
+ report: RunReport | null;
+ onOpen: (img: OpenImage) => void;
+ compact?: boolean;
+}) {
+ return (
+
+ {artifacts.map((a) => {
+ const url = fileUrl(runId, a.path);
+ if (a.mime.startsWith("image/")) {
+ return (
+
onOpen({ src: url, caption: a.label })}>
+
+
+ {a.label}
+ {!compact && {formatBytes(a.bytes)} }
+
+
+ );
+ }
+ if (a.mime.startsWith("video/")) {
+ const chapters =
+ report?.demo?.chaptersVtt && report.demo.video === a.path
+ ? fileUrl(runId, report.demo.chaptersVtt)
+ : null;
+ return (
+
+ );
+ }
+ return (
+
+
+ {a.label}
+ {formatBytes(a.bytes)}
+
+ );
+ })}
+
+ );
+}
+
+// Side panel
+
+function SidePanel({ ctx }: { ctx: Ctx }) {
+ const { run, live } = ctx;
+ const groups = runArtifacts(run);
+ const cleanups = cleanupRows(run);
+ const warnings = run.report?.warnings ?? [];
+ const showLive = live && run.browser;
+ return (
+
+ {showLive && run.browser && (
+
+
+ Live browser
+ {run.browser.stepId}
+
+
+
+ )}
+
+
+ Artifacts
+ {groups.reduce((n, g) => n + g.artifacts.length, 0) || ""}
+
+ {groups.length ? (
+ groups.map((g) => (
+
+
+ {g.step.name}
+ {g.step.name !== g.step.id && {g.step.id} }
+
+
+
+ ))
+ ) : (
+
{live ? "Screenshots, recordings and files appear here as steps save them." : "This run saved no artifacts."}
+ )}
+
+ {cleanups.length > 0 && (
+
+
Cleanups
+
+ {cleanups.map((c) => (
+
+
+ {c.stepId}
+ {c.command && {c.command} }
+ {c.label}
+
+ ))}
+
+
+ )}
+ {warnings.length > 0 && (
+
+
Warnings
+
+ {warnings.map((w, i) => (
+
+
+ {w}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function cleanupRows(run: RunState) {
+ const rows: { stepId: string; command: string; dot: StepStatus; label: string }[] = [];
+ const seen = new Set();
+ for (const c of run.report?.cleanups ?? []) {
+ seen.add(c.stepId);
+ const dot: StepStatus =
+ c.status === "passed" ? "passed" : c.status === "failed" ? "failed" : c.status === "pending" ? "pending" : "skipped";
+ const label =
+ c.status === "not-run" ? (c.policy === "manual" ? "manual, not run" : "not run") : c.status;
+ rows.push({ stepId: c.stepId, command: c.command.trim().split("\n")[0] ?? "", dot, label });
+ }
+ for (const [stepId, st] of Object.entries(run.cleanups)) {
+ if (seen.has(stepId)) continue;
+ rows.push({ stepId, command: "", dot: st, label: st === "running" ? "running" : st });
+ }
+ return rows;
+}
diff --git a/ui/src/components/icons.tsx b/ui/src/components/icons.tsx
new file mode 100644
index 0000000..4b173b2
--- /dev/null
+++ b/ui/src/components/icons.tsx
@@ -0,0 +1,170 @@
+import type { SVGProps } from "react";
+
+type P = SVGProps & { size?: number };
+
+function Svg({ size = 16, children, ...rest }: P) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function LogoMark({ size = 20 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export const IconFlows = (p: P) => (
+
+
+
+
+
+);
+export const IconRuns = (p: P) => (
+
+
+
+);
+export const IconDemos = (p: P) => (
+
+
+
+
+);
+export const IconSettings = (p: P) => (
+
+
+
+
+);
+export const IconChevron = (p: P) => (
+
+
+
+);
+export const IconCopy = (p: P) => (
+
+
+
+
+);
+export const IconMore = (p: P) => (
+
+
+
+
+
+);
+export const IconPlay = (p: P) => (
+
+
+
+);
+export const IconStop = (p: P) => (
+
+
+
+);
+export const IconRecord = (p: P) => (
+
+
+
+);
+export const IconSearch = (p: P) => (
+
+
+
+
+);
+export const IconX = (p: P) => (
+
+
+
+);
+export const IconSidebar = (p: P) => (
+
+
+
+
+);
+export const IconWarning = (p: P) => (
+
+
+
+
+);
+export const IconFile = (p: P) => (
+
+
+
+
+);
+export const IconFolder = (p: P) => (
+
+
+
+);
+export const IconDownload = (p: P) => (
+
+
+
+);
+export const IconTerminal = (p: P) => (
+
+
+
+
+);
+export const IconRefresh = (p: P) => (
+
+
+
+
+);
+export const IconSun = (p: P) => (
+
+
+
+
+);
+export const IconMoon = (p: P) => (
+
+
+
+);
+export const IconDisplay = (p: P) => (
+
+
+
+
+);
+export const IconExternal = (p: P) => (
+
+
+
+);
diff --git a/ui/src/components/status.tsx b/ui/src/components/status.tsx
new file mode 100644
index 0000000..843a24d
--- /dev/null
+++ b/ui/src/components/status.tsx
@@ -0,0 +1,96 @@
+import type { RunResult, StepStatus } from "../types";
+
+export type AnyStatus = StepStatus | RunResult | "none";
+
+export function StatusDot({ status, label }: { status: AnyStatus; label?: string }) {
+ return ;
+}
+
+export function statusLabel(s: AnyStatus): string {
+ switch (s) {
+ case "none":
+ return "never run";
+ case "pending":
+ return "not run";
+ default:
+ return s;
+ }
+}
+
+export function ResultBadge({ status }: { status: AnyStatus }) {
+ return (
+
+
+ {statusLabel(status)}
+
+ );
+}
+
+/** The step status glyph: a check, a cross, a bang, a dash, a spinner. */
+export function StatusGlyph({ status }: { status: StepStatus }) {
+ const common = {
+ width: 16,
+ height: 16,
+ viewBox: "0 0 16 16",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.6,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ "aria-hidden": true,
+ };
+ let body;
+ switch (status) {
+ case "passed":
+ body = (
+
+
+
+
+ );
+ break;
+ case "failed":
+ body = (
+
+
+
+
+ );
+ break;
+ case "errored":
+ body = (
+
+
+
+
+ );
+ break;
+ case "skipped":
+ body = (
+
+
+
+
+ );
+ break;
+ case "running":
+ body = (
+
+
+
+
+ );
+ break;
+ default:
+ body = (
+
+
+
+ );
+ }
+ return (
+
+ {body}
+
+ );
+}
diff --git a/ui/src/components/ui.tsx b/ui/src/components/ui.tsx
new file mode 100644
index 0000000..9d5c257
--- /dev/null
+++ b/ui/src/components/ui.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
+import { copyText } from "../lib/context";
+import { IconCopy, IconMore, IconX } from "./icons";
+
+/** Re-renders every `ms` while `active`; returns the current time. */
+export function useNow(ms: number, active: boolean): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!active) return;
+ setNow(Date.now());
+ const t = setInterval(() => setNow(Date.now()), ms);
+ return () => clearInterval(t);
+ }, [ms, active]);
+ return active ? now : Date.now();
+}
+
+// Toasts: one short message at a time.
+let toastMsg: string | null = null;
+let toastTimer: ReturnType | undefined;
+const toastListeners = new Set<() => void>();
+export function toast(msg: string) {
+ toastMsg = msg;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => {
+ toastMsg = null;
+ toastListeners.forEach((l) => l());
+ }, 2200);
+ toastListeners.forEach((l) => l());
+}
+export function Toaster() {
+ const msg = useSyncExternalStore(
+ (l) => {
+ toastListeners.add(l);
+ return () => toastListeners.delete(l);
+ },
+ () => toastMsg,
+ );
+ return msg ? (
+
+ {msg}
+
+ ) : null;
+}
+
+export async function copyWithToast(text: string, what: string) {
+ const ok = await copyText(text);
+ toast(ok ? `Copied ${what}` : `Could not copy ${what}`);
+}
+
+export function CopyButton({ text, label }: { text: string; label: string }) {
+ return (
+ void copyWithToast(text, label)}>
+
+ Copy {label}
+
+ );
+}
+
+export interface MenuItem {
+ label: string;
+ icon?: ReactNode;
+ onSelect: () => void;
+}
+
+export function OverflowMenu({ items, label = "More actions" }: { items: MenuItem[]; label?: string }) {
+ const [open, setOpen] = useState(false);
+ const wrap = useRef(null);
+ const button = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ button.current?.focus();
+ }
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onKey);
+ wrap.current?.querySelector(".menu button")?.focus();
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+ return (
+
+
setOpen((o) => !o)}
+ >
+
+ {label}
+
+ {open && (
+
+ {items.map((it) => (
+ {
+ setOpen(false);
+ it.onSelect();
+ }}
+ >
+ {it.icon}
+ {it.label}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function Lightbox({ src, caption, onClose }: { src: string; caption: string; onClose: () => void }) {
+ const closeRef = useRef(null);
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ document.addEventListener("keydown", onKey);
+ closeRef.current?.focus();
+ return () => document.removeEventListener("keydown", onKey);
+ }, [onClose]);
+ return (
+
+
+
+ Close
+
+
e.stopPropagation()}>
+
+ {caption}
+
+
+ );
+}
+
+export function Switch({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ label: string;
+}) {
+ return (
+
+ onChange(e.target.checked)} />
+
+ {label}
+
+ );
+}
diff --git a/ui/src/lib/context.ts b/ui/src/lib/context.ts
new file mode 100644
index 0000000..f781bdc
--- /dev/null
+++ b/ui/src/lib/context.ts
@@ -0,0 +1,122 @@
+// Text a person can paste into a terminal or hand to someone (or to their own
+// agent) to pick up a failure: built from the run report, client-side.
+
+import type { RunReport, StepReport } from "../types";
+import { shellQuote } from "./format";
+
+export interface RerunOptions {
+ flowId: string;
+ fromStep: string;
+ runId?: string;
+ environment?: string | null;
+ inputs?: Record;
+}
+
+/** `lest run --from `, with the environment, inputs and the run
+ * whose earlier results it reuses. */
+export function rerunCommand(o: RerunOptions): string {
+ const parts = ["lest", "run", shellQuote(o.flowId), "--from", shellQuote(o.fromStep)];
+ if (o.runId) parts.push("--resume-run", shellQuote(o.runId));
+ if (o.environment) parts.push("--env", shellQuote(o.environment));
+ for (const [k, v] of Object.entries(o.inputs ?? {})) parts.push("--input", shellQuote(`${k}=${v}`));
+ return parts.join(" ");
+}
+
+function lastLines(text: string | undefined, n: number): string[] {
+ if (!text) return [];
+ const lines = text.replace(/\n+$/, "").split("\n");
+ return lines.slice(Math.max(0, lines.length - n));
+}
+
+function walk(steps: StepReport[], out: StepReport[] = []): StepReport[] {
+ for (const s of steps) {
+ out.push(s);
+ walk(s.children ?? [], out);
+ }
+ return out;
+}
+
+export interface ContextInput {
+ report: RunReport;
+ step: StepReport;
+ /** The top-level step containing `step` (what --from takes). */
+ topLevel: string;
+}
+
+export function buildContext({ report, step, topLevel }: ContextInput): string {
+ const lines: string[] = [];
+ const add = (label: string, value: string | number | undefined | null) => {
+ if (value === undefined || value === null || value === "") return;
+ lines.push(`${label}: ${value}`);
+ };
+ lines.push(`Lest flow failure`);
+ lines.push("");
+ add("Flow", `${report.flowName} (${report.flowId})`);
+ add("Flow file", `${report.projectDir}/${report.flowPath}`);
+ add("Run", `${report.runId} (${report.result})`);
+ add("Environment", report.environment);
+ const inputs = Object.entries(report.inputs ?? {});
+ if (inputs.length) add("Inputs", inputs.map(([k, v]) => `${k}=${v}`).join(", "));
+ lines.push("");
+ add("Failed step", `${step.id} "${step.name}"${step.kind ? ` (${step.kind})` : ""}`);
+ add("Status", step.status);
+ add("Resolved", step.resolved);
+ add("Headline", step.headline);
+ if (step.error && step.error !== step.headline) add("Error", step.error);
+ add("Exit code", step.exitCode);
+ add("HTTP status", step.httpStatus);
+ add("Attempts", step.attempts);
+ if (step.notes) {
+ lines.push("Notes:");
+ for (const l of step.notes.trim().split("\n")) lines.push(` ${l}`);
+ }
+ for (const [name, text] of [
+ ["stderr", step.stderr],
+ ["stdout", step.stdout],
+ ] as const) {
+ const tail = lastLines(text, 40);
+ if (!tail.length) continue;
+ lines.push("");
+ lines.push(`Last ${tail.length} lines of ${name}:`);
+ for (const l of tail) lines.push(` ${l}`);
+ }
+ const all = walk([...report.steps, ...(report.finally ?? [])]);
+ const idx = all.findIndex((s) => s.id === step.id);
+ const earlier = idx < 0 ? all : all.slice(0, idx);
+ if (earlier.length) {
+ lines.push("");
+ lines.push("Earlier steps:");
+ for (const s of earlier) lines.push(` ${s.status ?? "pending"} ${s.id}`);
+ }
+ lines.push("");
+ lines.push("Rerun from the failed step:");
+ lines.push(
+ ` ${rerunCommand({
+ flowId: report.flowId,
+ fromStep: topLevel,
+ runId: report.runId,
+ environment: report.environment,
+ inputs: report.inputs,
+ })}`,
+ );
+ return lines.join("\n") + "\n";
+}
+
+export async function copyText(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fallback for contexts without the async clipboard API.
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.setAttribute("readonly", "");
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand("copy");
+ ta.remove();
+ return ok;
+ }
+}
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts
new file mode 100644
index 0000000..955c31c
--- /dev/null
+++ b/ui/src/lib/format.ts
@@ -0,0 +1,62 @@
+export function formatDuration(ms: number | null | undefined): string {
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return "";
+ if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
+ const s = ms / 1000;
+ if (s < 10) return `${s.toFixed(1)}s`;
+ if (s < 60) return `${Math.round(s)}s`;
+ const m = Math.floor(s / 60);
+ const rem = Math.round(s - m * 60);
+ if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ return `${h}h ${m - h * 60}m`;
+}
+
+export function timeAgo(when: string | number, now = Date.now()): string {
+ const t = typeof when === "number" ? when : Date.parse(when);
+ if (!Number.isFinite(t)) return "";
+ const s = Math.max(0, Math.round((now - t) / 1000));
+ if (s < 10) return "just now";
+ if (s < 60) return `${s} sec ago`;
+ const m = Math.round(s / 60);
+ if (m < 60) return `${m} min ago`;
+ const h = Math.round(m / 60);
+ if (h < 24) return `${h} hr ago`;
+ const d = Math.round(h / 24);
+ if (d < 30) return d === 1 ? "yesterday" : `${d} days ago`;
+ return new Date(t).toLocaleDateString();
+}
+
+export function formatTime(when: string | number): string {
+ const t = typeof when === "number" ? when : Date.parse(when);
+ if (!Number.isFinite(t)) return "";
+ return new Date(t).toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+export function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
+}
+
+export function firstLine(text: string | null | undefined): string {
+ if (!text) return "";
+ const para = text.trim().split(/\n\s*\n/)[0] ?? "";
+ return para.replace(/\s+/g, " ").trim();
+}
+
+/** Quotes a value for a POSIX shell when it needs it. */
+export function shellQuote(v: string): string {
+ if (/^[A-Za-z0-9_./:=@%+-]+$/.test(v)) return v;
+ return `'${v.replace(/'/g, `'\\''`)}'`;
+}
+
+export function plural(n: number, one: string, many = `${one}s`): string {
+ return `${n} ${n === 1 ? one : many}`;
+}
diff --git a/ui/src/lib/markdown.tsx b/ui/src/lib/markdown.tsx
new file mode 100644
index 0000000..f1a9508
--- /dev/null
+++ b/ui/src/lib/markdown.tsx
@@ -0,0 +1,150 @@
+// A small Markdown subset for flow descriptions: paragraphs, bullet and
+// numbered lists, inline code, bold, italic and links. Everything else is
+// rendered as text (React escapes it).
+
+import { Fragment, type ReactNode } from "react";
+
+export type Block =
+ | { type: "p"; text: string }
+ | { type: "ul" | "ol"; items: string[] };
+
+export function parseBlocks(src: string): Block[] {
+ const blocks: Block[] = [];
+ let para: string[] = [];
+ let list: { type: "ul" | "ol"; items: string[] } | null = null;
+ const flushPara = () => {
+ if (para.length) blocks.push({ type: "p", text: para.join(" ") });
+ para = [];
+ };
+ const flushList = () => {
+ if (list) blocks.push(list);
+ list = null;
+ };
+ for (const raw of src.replace(/\r\n/g, "\n").split("\n")) {
+ const line = raw.trimEnd();
+ const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
+ if (ul || ol) {
+ flushPara();
+ const type = ul ? "ul" : "ol";
+ if (!list || list.type !== type) {
+ flushList();
+ list = { type, items: [] };
+ }
+ list.items.push(((ul ?? ol) as RegExpExecArray)[1] ?? "");
+ } else if (!line.trim()) {
+ flushPara();
+ flushList();
+ } else if (list && /^\s+\S/.test(line)) {
+ list.items[list.items.length - 1] += ` ${line.trim()}`;
+ } else {
+ flushList();
+ para.push(line.trim());
+ }
+ }
+ flushPara();
+ flushList();
+ return blocks;
+}
+
+export type Inline =
+ | { type: "text"; text: string }
+ | { type: "code"; text: string }
+ | { type: "strong" | "em"; children: Inline[] }
+ | { type: "link"; href: string; children: Inline[] };
+
+// Absolute web and mail links, same-site paths and anchors; never a
+// protocol-relative `//host` link.
+const SAFE_HREF = /^(https?:|mailto:|\/(?!\/)|#)/i;
+
+export function parseInline(src: string): Inline[] {
+ const out: Inline[] = [];
+ let text = "";
+ const pushText = () => {
+ if (text) out.push({ type: "text", text });
+ text = "";
+ };
+ let i = 0;
+ while (i < src.length) {
+ const rest = src.slice(i);
+ let m: RegExpExecArray | null;
+ if ((m = /^`([^`]+)`/.exec(rest))) {
+ pushText();
+ out.push({ type: "code", text: m[1] ?? "" });
+ i += m[0].length;
+ } else if ((m = /^\*\*([^*]+)\*\*/.exec(rest)) || (m = /^__([^_]+)__/.exec(rest))) {
+ pushText();
+ out.push({ type: "strong", children: parseInline(m[1] ?? "") });
+ i += m[0].length;
+ } else if ((m = /^\*([^*\s][^*]*)\*/.exec(rest))) {
+ pushText();
+ out.push({ type: "em", children: parseInline(m[1] ?? "") });
+ i += m[0].length;
+ } else if ((m = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest)) && SAFE_HREF.test(m[2] ?? "")) {
+ pushText();
+ out.push({ type: "link", href: m[2] ?? "", children: parseInline(m[1] ?? "") });
+ i += m[0].length;
+ } else {
+ text += src[i];
+ i++;
+ }
+ }
+ pushText();
+ return out;
+}
+
+function renderInline(nodes: Inline[]): ReactNode {
+ return nodes.map((n, i) => {
+ switch (n.type) {
+ case "text":
+ return {n.text} ;
+ case "code":
+ return {n.text};
+ case "strong":
+ return {renderInline(n.children)} ;
+ case "em":
+ return {renderInline(n.children)} ;
+ case "link":
+ return (
+
+ {renderInline(n.children)}
+
+ );
+ }
+ });
+}
+
+export function Markdown({ text, className }: { text: string; className?: string }) {
+ return (
+
+ {parseBlocks(text).map((b, i) =>
+ b.type === "p" ? (
+
{renderInline(parseInline(b.text))}
+ ) : b.type === "ul" ? (
+
+ {b.items.map((it, j) => (
+ {renderInline(parseInline(it))}
+ ))}
+
+ ) : (
+
+ {b.items.map((it, j) => (
+ {renderInline(parseInline(it))}
+ ))}
+
+ ),
+ )}
+
+ );
+}
+
+/** Plain text of the first paragraph, with inline markup removed. */
+export function plainFirstLine(src: string | null | undefined): string {
+ if (!src) return "";
+ const first = parseBlocks(src)[0];
+ if (!first) return "";
+ const text = first.type === "p" ? first.text : (first.items[0] ?? "");
+ const flat = (nodes: Inline[]): string =>
+ nodes.map((n) => (n.type === "text" || n.type === "code" ? n.text : flat(n.children))).join("");
+ return flat(parseInline(text));
+}
diff --git a/ui/src/lib/project.ts b/ui/src/lib/project.ts
new file mode 100644
index 0000000..2a5c003
--- /dev/null
+++ b/ui/src/lib/project.ts
@@ -0,0 +1,95 @@
+import { useEffect, useState } from "react";
+import { api } from "../api";
+import type { StateResponse } from "../types";
+
+let cached: StateResponse | null = null;
+let pending: Promise | null = null;
+
+/** The server's project and version, fetched once per page load. */
+export function useProject(): StateResponse | null {
+ const [state, setState] = useState(cached);
+ useEffect(() => {
+ if (cached) return;
+ pending ??= api.state();
+ let alive = true;
+ pending.then(
+ (s) => {
+ cached = s;
+ if (alive) setState(s);
+ },
+ () => {
+ pending = null;
+ },
+ );
+ return () => {
+ alive = false;
+ };
+ }, []);
+ return state;
+}
+
+/** Runs an async loader and re-runs it when `deps` change. */
+export function useLoad(load: () => Promise, deps: unknown[]): { data: T | null; error: string | null } {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ useEffect(() => {
+ let alive = true;
+ load().then(
+ (d) => {
+ if (!alive) return;
+ setData(d);
+ setError(null);
+ },
+ (e: Error) => alive && setError(e.message),
+ );
+ return () => {
+ alive = false;
+ };
+ }, deps);
+ return { data, error };
+}
+
+export type Theme = "system" | "light" | "dark";
+const THEME_KEY = "lest.theme";
+
+export function readTheme(): Theme {
+ try {
+ const t = localStorage.getItem(THEME_KEY);
+ return t === "light" || t === "dark" ? t : "system";
+ } catch {
+ return "system";
+ }
+}
+
+export function applyTheme(t: Theme) {
+ const root = document.documentElement;
+ if (t === "system") root.removeAttribute("data-theme");
+ else root.setAttribute("data-theme", t);
+}
+
+export function saveTheme(t: Theme) {
+ try {
+ if (t === "system") localStorage.removeItem(THEME_KEY);
+ else localStorage.setItem(THEME_KEY, t);
+ } catch {
+ /* storage unavailable: the choice lasts for this page only */
+ }
+ applyTheme(t);
+}
+
+export function readPref(key: string, fallback: boolean): boolean {
+ try {
+ const v = localStorage.getItem(key);
+ return v === null ? fallback : v === "1";
+ } catch {
+ return fallback;
+ }
+}
+
+export function writePref(key: string, v: boolean) {
+ try {
+ localStorage.setItem(key, v ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/ui/src/lib/router.ts b/ui/src/lib/router.ts
new file mode 100644
index 0000000..cee725b
--- /dev/null
+++ b/ui/src/lib/router.ts
@@ -0,0 +1,60 @@
+// A minimal history router: the server serves the app shell for every
+// non-API path, so plain paths work on reload.
+
+import { useSyncExternalStore, type MouseEvent } from "react";
+
+const listeners = new Set<() => void>();
+
+function notify() {
+ for (const l of listeners) l();
+}
+
+if (typeof window !== "undefined") window.addEventListener("popstate", notify);
+
+export function navigate(to: string, opts: { replace?: boolean } = {}) {
+ if (to === location.pathname + location.search) return;
+ if (opts.replace) history.replaceState(null, "", to);
+ else history.pushState(null, "", to);
+ window.scrollTo(0, 0);
+ notify();
+}
+
+export function usePath(): string {
+ return useSyncExternalStore(
+ (l) => {
+ listeners.add(l);
+ return () => listeners.delete(l);
+ },
+ () => location.pathname,
+ );
+}
+
+export type Route =
+ | { name: "flows" }
+ | { name: "flow"; id: string }
+ | { name: "runs" }
+ | { name: "run"; runId: string }
+ | { name: "demos" }
+ | { name: "demo"; id: string }
+ | { name: "settings" }
+ | { name: "notFound" };
+
+export function matchRoute(path: string): Route {
+ const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
+ const [a, b] = parts;
+ if (parts.length === 0) return { name: "flows" };
+ if (a === "flows") return b ? { name: "flow", id: b } : { name: "flows" };
+ if (a === "runs") return b ? { name: "run", runId: b } : { name: "runs" };
+ if (a === "demos") return b ? { name: "demo", id: b } : { name: "demos" };
+ if (a === "settings" && !b) return { name: "settings" };
+ return { name: "notFound" };
+}
+
+/** onClick for that routes in-app for plain left clicks. */
+export function linkHandler(to: string) {
+ return (e: MouseEvent) => {
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ e.preventDefault();
+ navigate(to);
+ };
+}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
new file mode 100644
index 0000000..89e39e2
--- /dev/null
+++ b/ui/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+import { applyTheme, readTheme } from "./lib/project";
+import { connect } from "./store/store";
+import "./styles.css";
+
+applyTheme(readTheme());
+connect();
+
+createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+);
diff --git a/ui/src/pages/Demo.tsx b/ui/src/pages/Demo.tsx
new file mode 100644
index 0000000..f47d85c
--- /dev/null
+++ b/ui/src/pages/Demo.tsx
@@ -0,0 +1,258 @@
+import { useRef, useState } from "react";
+import { api, fileUrl } from "../api";
+import { ArtifactGrid } from "../components/RunView";
+import { IconDownload, IconFile } from "../components/icons";
+import { LiveView } from "../components/LiveView";
+import { RunControls } from "../components/RunControls";
+import { ResultBadge } from "../components/status";
+import { Lightbox, useNow } from "../components/ui";
+import { formatBytes, formatDuration, formatTime, timeAgo } from "../lib/format";
+import { useLoad } from "../lib/project";
+import { linkHandler } from "../lib/router";
+import { allStepIds, isLive, runBeats, type RunState } from "../store/runState";
+import { useRun } from "../store/store";
+import type { Artifact, FlowDetail } from "../types";
+import { FlowDiagnostics, FlowHeader, useShownRun } from "./Flow";
+
+function clock(ms: number): string {
+ const s = Math.floor(ms / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+}
+
+interface Deliverable {
+ video: { path: string; label: string; chapters: string | null } | null;
+ extras: { label: string; path: string; bytes?: number }[];
+ stills: Artifact[];
+}
+
+function deliverable(run: RunState): Deliverable {
+ const arts = allStepIds(run).flatMap((id) => run.steps[id]?.artifacts ?? []);
+ const demo = run.report?.demo;
+ const recording = arts.find((a) => a.label === "Recording");
+ const extras: Deliverable["extras"] = [];
+ let video: Deliverable["video"] = null;
+ if (demo?.video) {
+ video = { path: demo.video, label: "Demo video", chapters: demo.chaptersVtt ?? null };
+ if (demo.rawVideo) extras.push({ label: "Raw recording", path: demo.rawVideo });
+ } else if (recording) {
+ video = { path: recording.path, label: "Recording", chapters: null };
+ }
+ if (demo?.chaptersVtt) extras.push({ label: "Chapters (WebVTT)", path: demo.chaptersVtt });
+ if (demo?.beatSheet) extras.push({ label: "Beat sheet", path: demo.beatSheet });
+ for (const a of arts) {
+ if (a.mime.startsWith("video/") && a.path !== video?.path) extras.push({ label: a.label, path: a.path, bytes: a.bytes });
+ }
+ if (recording && video?.path !== recording.path && !extras.some((e) => e.path === recording.path)) {
+ extras.push({ label: "Raw recording", path: recording.path, bytes: recording.bytes });
+ }
+ return { video, extras, stills: arts.filter((a) => a.mime.startsWith("image/")) };
+}
+
+export function DemoPage({ id }: { id: string }) {
+ const { data: flow, error } = useLoad(() => api.flow(id), [id]);
+ const shown = useShownRun(id);
+ if (error) {
+ return (
+
+
+
Demo not found
+
{error}
+
+
+ );
+ }
+ if (!flow) return ;
+ return (
+
+
+
+
+
+
+ );
+}
+
+function DemoBody({ flow, runId }: { flow: FlowDetail; runId: string | null }) {
+ const { run } = useRun(runId);
+ const [image, setImage] = useState<{ src: string; caption: string } | null>(null);
+ const videoRef = useRef(null);
+ const live = run ? isLive(run) : false;
+ const now = useNow(250, live);
+ const seen = run ? runBeats(run) : [];
+ const beats = flow.demo?.beats ?? [];
+ const litMarkers = new Set(seen.map((b) => b.marker));
+ const lastLit = [...beats].reverse().find((b) => litMarkers.has(b.marker))?.marker;
+ const d = run ? deliverable(run) : null;
+
+ let stage;
+ if (run && live && run.browser) {
+ stage = ;
+ } else if (run && live) {
+ stage = (
+
+
+ Preparing the recording
+ {run.progress.length ? run.progress[run.progress.length - 1]!.message : "Running the steps before the recording"}
+
+
+ );
+ } else if (run && d?.video) {
+ stage = (
+
+
+ {d.video.chapters && }
+
+
+ );
+ } else {
+ stage = (
+
+
+ No recording yet
+ {run
+ ? `The latest run (${run.finished?.result ?? "unknown"}) saved no recording.`
+ : "Press Record to run the flow and record the walkthrough."}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {live ? "Recording" : "Recording"}
+
+ {run && d?.video && !live && (
+
+
+ Download
+
+ )}
+
+
+ {stage}
+
+ {run && !live && d && (d.extras.length > 0 || d.stills.length > 0) && (
+
+
Extras
+ {d.extras.length > 0 && (
+
+ {d.extras.map((e) => (
+
+
+
+ {e.label}
+
+ {e.bytes ? formatBytes(e.bytes) : ""}
+
+ ))}
+
+ )}
+ {d.stills.length > 0 && (
+
+ )}
+
+ )}
+
+
+ {image &&
setImage(null)} />}
+
+ );
+}
diff --git a/ui/src/pages/Demos.tsx b/ui/src/pages/Demos.tsx
new file mode 100644
index 0000000..1004961
--- /dev/null
+++ b/ui/src/pages/Demos.tsx
@@ -0,0 +1,93 @@
+import { api, fileUrl } from "../api";
+import { StatusDot } from "../components/status";
+import { formatTime, timeAgo } from "../lib/format";
+import { plainFirstLine } from "../lib/markdown";
+import { useLoad } from "../lib/project";
+import { linkHandler } from "../lib/router";
+import { useFinishedCount } from "../store/store";
+import type { FlowSummary, RunReport, StepReport } from "../types";
+
+export function isDemo(f: FlowSummary): boolean {
+ return !!f.demo || f.tags.includes("demo");
+}
+
+function walk(steps: StepReport[]): StepReport[] {
+ return steps.flatMap((s) => [s, ...walk(s.children ?? [])]);
+}
+
+/** A still for a demo card: the beat sheet, else the recording step's last
+ * screenshot, else any screenshot. */
+export function coverOf(report: RunReport): string | null {
+ if (report.demo?.beatSheet) return report.demo.beatSheet;
+ const steps = walk([...report.steps, ...(report.finally ?? [])]);
+ const rec = steps.find((s) => s.artifacts?.some((a) => a.label === "Recording"));
+ const pick = (s?: StepReport) => s?.artifacts?.filter((a) => a.mime.startsWith("image/")).pop()?.path ?? null;
+ return pick(rec) ?? steps.map(pick).filter(Boolean).pop() ?? null;
+}
+
+export function DemosPage() {
+ const finished = useFinishedCount();
+ const { data, error } = useLoad(() => api.flows(), [finished]);
+ if (error) return Could not load flows: {error}
;
+ if (!data) return ;
+ const demos = data.flows.filter(isDemo);
+ return (
+
+
+
+
Demos
+
Flows that record a walkthrough. Recording one runs the flow.
+
+
+ {demos.length === 0 ? (
+
+
No demos in this project
+
+ A demo is a flow with a demo: block (title, summary, beat labels) and a browser step with{" "}
+ record: true. Flows tagged demo are listed here too.
+
+
+ ) : (
+
+ {demos.map((f) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function DemoCard({ flow }: { flow: FlowSummary }) {
+ const last = flow.lastRun;
+ const { data } = useLoad(() => (last ? api.run(last.runId) : Promise.resolve(null)), [last?.runId]);
+ const cover = data?.report ? coverOf(data.report) : null;
+ const href = `/demos/${encodeURIComponent(flow.id)}`;
+ const status = last?.result ?? "none";
+ return (
+
+
+ {cover && last ?
:
No recording yet }
+
+
+
{flow.demo?.title ?? flow.name}
+
+ {flow.demo?.summary ?? plainFirstLine(flow.description)}
+
+ {flow.demo?.beats && flow.demo.beats.length > 0 && (
+
+ {flow.demo.beats.map((b) => (
+ {b.label}
+ ))}
+
+ )}
+
+
+
+ {last ? `Last recorded ${timeAgo(last.startedAt)}${last.result !== "passed" ? ` (${last.result})` : ""}` : "Never recorded"}
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/Flow.tsx b/ui/src/pages/Flow.tsx
new file mode 100644
index 0000000..494cd75
--- /dev/null
+++ b/ui/src/pages/Flow.tsx
@@ -0,0 +1,113 @@
+import { api } from "../api";
+import { RunControls } from "../components/RunControls";
+import { RunView } from "../components/RunView";
+import { IconWarning } from "../components/icons";
+import { Markdown } from "../lib/markdown";
+import { useLoad, useProject } from "../lib/project";
+import { linkHandler } from "../lib/router";
+import { useLatestRunOf } from "../store/store";
+import type { FlowDetail } from "../types";
+
+export function FlowHeader({ flow, section = "flows" }: { flow: FlowDetail; section?: "flows" | "demos" }) {
+ return (
+
+
+
+
{section === "demos" ? (flow.demo?.title ?? flow.name) : flow.name}
+
+ {flow.id}
+
+ {flow.path}
+ {flow.tags.length > 0 && (
+ <>
+
+ {flow.tags.map((t) => (
+
+ {t}
+
+ ))}
+ >
+ )}
+
+ {section === "demos" && flow.demo?.summary ? (
+
{flow.demo.summary}
+ ) : (
+ flow.description &&
+ )}
+
+
+ );
+}
+
+export function FlowDiagnostics({ flow }: { flow: FlowDetail }) {
+ if (!flow.diagnostics.length) return null;
+ const errors = flow.diagnostics.some((d) => d.severity === "error");
+ return (
+
+
+
+
{errors ? "This flow cannot run until these are fixed" : "Warnings in this flow"}
+
+ {flow.diagnostics.map((d, i) => (
+
+ {d.at && {d.at} }
+ {d.message}
+
+ ))}
+
+
+
+ );
+}
+
+/** The run a flow page shows: one started since the page loaded, else one in
+ * progress on the server, else the latest stored run. */
+export function useShownRun(flowId: string): { runId: string | null; loaded: boolean } {
+ const started = useLatestRunOf(flowId);
+ const { data } = useLoad(() => api.runs(flowId, 1), [flowId]);
+ const initial = data ? (data.running[0]?.runId ?? data.runs[0]?.runId ?? null) : null;
+ return { runId: started ?? initial, loaded: !!data || !!started };
+}
+
+export function FlowPage({ id }: { id: string }) {
+ const { data: flow, error } = useLoad(() => api.flow(id), [id]);
+ const project = useProject();
+ const shown = useShownRun(id);
+
+ if (error) {
+ return (
+
+ );
+ }
+ if (!flow) return ;
+
+ return (
+
+
+
+
+ {shown.runId ? (
+
+ ) : shown.loaded ? (
+
+
No runs yet
+
Press Run to start this flow. Steps, output and artifacts appear here as it runs.
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/pages/Flows.tsx b/ui/src/pages/Flows.tsx
new file mode 100644
index 0000000..5deb622
--- /dev/null
+++ b/ui/src/pages/Flows.tsx
@@ -0,0 +1,215 @@
+import { useMemo, useState } from "react";
+import { api } from "../api";
+import { IconSearch, IconWarning, IconX } from "../components/icons";
+import { StatusDot, type AnyStatus } from "../components/status";
+import { useNow } from "../components/ui";
+import { formatDuration, timeAgo } from "../lib/format";
+import { plainFirstLine } from "../lib/markdown";
+import { useLoad } from "../lib/project";
+import { linkHandler } from "../lib/router";
+import { getRun, useFinishedCount, useLiveRunIds } from "../store/store";
+import type { Diagnostic, FlowSummary } from "../types";
+
+/** Flow ids with a run in progress that this page has seen. */
+export function useRunningFlows(): Set {
+ const ids = useLiveRunIds();
+ return useMemo(() => {
+ const out = new Set();
+ for (const id of ids ? ids.split(",") : []) {
+ const f = getRun(id)?.flowId;
+ if (f) out.add(f);
+ }
+ return out;
+ }, [ids]);
+}
+
+export function FlowsPage() {
+ const finished = useFinishedCount();
+ const { data, error } = useLoad(() => api.flows(), [finished]);
+ const [query, setQuery] = useState("");
+ const [tags, setTags] = useState([]);
+ const [dismissed, setDismissed] = useState(false);
+ const running = useRunningFlows();
+ const now = useNow(30_000, true);
+
+ if (error) return Could not load flows: {error}
;
+ if (!data) return ;
+
+ const allTags = [...new Set(data.flows.flatMap((f) => f.tags))].sort();
+ const q = query.trim().toLowerCase();
+ const visible = data.flows.filter(
+ (f) =>
+ (!q || f.name.toLowerCase().includes(q) || f.id.includes(q) || f.tags.some((t) => t.toLowerCase().includes(q))) &&
+ tags.every((t) => f.tags.includes(t)),
+ );
+ // Folders are shown relative to the directory every flow shares (often
+ // the project's single flows folder).
+ const firsts = new Set(data.flows.map((f) => f.path.split("/")[0]));
+ const shared = firsts.size === 1 && data.flows.every((f) => f.path.includes("/")) ? `${[...firsts][0]}/` : "";
+ const label = (folder: string) => (folder + "/").slice(shared.length).replace(/\/$/, "");
+ const folders = new Map();
+ for (const f of visible) {
+ const key = label(f.folder);
+ const list = folders.get(key) ?? [];
+ list.push(f);
+ folders.set(key, list);
+ }
+ const diagsByFile = new Map();
+ for (const d of data.diagnostics) diagsByFile.set(d.file, [...(diagsByFile.get(d.file) ?? []), d]);
+
+ return (
+
+
+
+
Flows
+
+ {data.flows.length} {data.flows.length === 1 ? "flow" : "flows"} in this project
+
+
+
+
+ {data.diagnostics.length > 0 && !dismissed && (
+
d.severity === "error") ? "banner-error" : "banner-warning"}`} role="alert">
+
+
+
+ {data.diagnostics.length === 1 ? "1 problem" : `${data.diagnostics.length} problems`} in flow files
+
+
+ {data.diagnostics.map((d, i) => (
+
+ {d.file}
+
+ {d.at ? {d.at}: : null}
+ {d.message}
+
+
+ ))}
+
+
+
setDismissed(true)}>
+
+ Dismiss
+
+
+ )}
+
+ {data.flows.length === 0 ? (
+
+
No flows yet
+
+ Lest looks for *.lest.yaml files in the folders listed under flows: in{" "}
+ lest.yaml at the project root.
+
+
+ Run lest init in your project to create lest.yaml and a first flow, then reload
+ this page.
+
+
+ ) : (
+ <>
+
+
+
+ Search flows
+ setQuery(e.target.value)}
+ />
+
+ {allTags.length > 0 && (
+
+ {allTags.map((t) => (
+ setTags(tags.includes(t) ? tags.filter((x) => x !== t) : [...tags, t])}
+ >
+ {t}
+
+ ))}
+
+ )}
+
+ {visible.length === 0 &&
}
+ {[...folders.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([folder, flows]) => (
+
+
+ {folder || "top level"} {flows.length}
+
+
+ {flows.map((f) => (
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+function FlowCard({
+ flow,
+ now,
+ running,
+ problems,
+}: {
+ flow: FlowSummary;
+ now: number;
+ running: boolean;
+ problems: Diagnostic[];
+}) {
+ const status: AnyStatus = running ? "running" : (flow.lastRun?.result ?? "none");
+ const href = `/flows/${encodeURIComponent(flow.id)}`;
+ return (
+
+
+
+ {flow.name}
+ {problems.length > 0 && (
+ p.message).join("\n")}>
+
+ {problems.length} problems
+
+ )}
+
+ {plainFirstLine(flow.description) || No description }
+
+
+ {flow.tags.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ {running ? (
+ running
+ ) : flow.lastRun ? (
+ <>
+ {timeAgo(flow.lastRun.startedAt, now)}
+ {formatDuration(flow.lastRun.durationMs)}
+ >
+ ) : (
+ never run
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/Run.tsx b/ui/src/pages/Run.tsx
new file mode 100644
index 0000000..4edcb13
--- /dev/null
+++ b/ui/src/pages/Run.tsx
@@ -0,0 +1,100 @@
+import { useState } from "react";
+import { api } from "../api";
+import { RunView } from "../components/RunView";
+import { IconRefresh, IconStop } from "../components/icons";
+import { useProject } from "../lib/project";
+import { linkHandler, navigate } from "../lib/router";
+import { isLive } from "../store/runState";
+import { noteStarted, useRun } from "../store/store";
+
+export function RunPage({ runId }: { runId: string }) {
+ const { run } = useRun(runId);
+ const project = useProject();
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [cancelled, setCancelled] = useState<"main" | "cleanup" | null>(null);
+ const flowId = run?.flowId ?? run?.report?.flowId ?? null;
+ const flowName = run?.flowName ?? run?.report?.flowName ?? flowId ?? "Run";
+ const live = run ? isLive(run) : false;
+
+ const again = async () => {
+ if (!flowId) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const r = await api.startRun({
+ flowId,
+ environment: run?.environment ?? undefined,
+ inputs: run?.report?.inputs ?? {},
+ });
+ noteStarted(flowId, r.runId);
+ navigate(`/flows/${encodeURIComponent(flowId)}`);
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const cancel = async () => {
+ const stage = cancelled === "main" ? "cleanup" : "main";
+ setCancelled(stage);
+ try {
+ await api.cancel(runId, stage);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
+ const flowHref = flowId ? `/flows/${encodeURIComponent(flowId)}` : "/flows";
+ return (
+
+
+
+
+
+ {flowId ? (
+
+ {flowName}
+
+ ) : (
+ flowName
+ )}
+
+ {run?.report && (
+
+ {run.report.flowId}
+
+ {run.report.flowPath}
+
+ )}
+
+
+ {live ? (
+ void cancel()} disabled={cancelled === "cleanup"}>
+
+ {cancelled ? "Skip cleanup" : "Cancel"}
+
+ ) : (
+ void again()} disabled={!flowId || busy}>
+
+ Run again
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/Runs.tsx b/ui/src/pages/Runs.tsx
new file mode 100644
index 0000000..70ebc63
--- /dev/null
+++ b/ui/src/pages/Runs.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from "react";
+import { api } from "../api";
+import { IconWarning } from "../components/icons";
+import { StatusDot, statusLabel } from "../components/status";
+import { useNow } from "../components/ui";
+import { formatDuration, formatTime, timeAgo } from "../lib/format";
+import { useLoad } from "../lib/project";
+import { linkHandler, navigate } from "../lib/router";
+import { getRun, hydrate, useFinishedCount, useLiveRunIds } from "../store/store";
+import type { RunResult } from "../types";
+
+const RESULTS: RunResult[] = ["passed", "failed", "errored", "cancelled"];
+
+export function RunsPage() {
+ const finished = useFinishedCount();
+ const liveIds = useLiveRunIds();
+ const { data, error } = useLoad(() => Promise.all([api.runs(undefined, 200), api.flows()]), [finished, liveIds]);
+ const [flow, setFlow] = useState("");
+ const [results, setResults] = useState([]);
+ const now = useNow(1000, true);
+ useEffect(() => {
+ for (const r of data?.[0].running ?? []) if (!getRun(r.runId)) void hydrate(r.runId);
+ }, [data]);
+
+ if (error) return Could not load runs: {error}
;
+ if (!data) return ;
+ const [runs, flows] = data;
+ const names = new Map(flows.flows.map((f) => [f.id, f.name]));
+ const flowIds = [...new Set([...runs.runs.map((r) => r.flowId), ...runs.running.map((r) => r.flowId)])].sort();
+ const running = runs.running.filter((r) => !flow || r.flowId === flow);
+ const stored = runs.runs.filter(
+ (r) => (!flow || r.flowId === flow) && (!results.length || results.includes(r.result)),
+ );
+ const open = (id: string) => navigate(`/runs/${encodeURIComponent(id)}`);
+
+ return (
+
+
+
+
Runs
+
Newest first. Reports are kept in the data directory.
+
+
+
+
+ Flow
+ setFlow(e.target.value)}>
+ All flows
+ {flowIds.map((id) => (
+
+ {names.get(id) ?? id}
+
+ ))}
+
+
+
+ {RESULTS.map((r) => (
+ setResults(results.includes(r) ? results.filter((x) => x !== r) : [...results, r])}
+ >
+
+ {r}
+
+ ))}
+
+
+ {running.length === 0 && stored.length === 0 ? (
+
+
{runs.runs.length ? "No run matches" : "No runs yet"}
+
{runs.runs.length ? "Change the filters to see more runs." : "Start a flow from the Flows page or with lest run."}
+
+ ) : (
+
+
+
+
+ Status
+ Flow
+ Run
+ Environment
+ Started
+ Duration
+ Warnings
+
+
+
+ {!results.length &&
+ running.map((r) => {
+ const live = getRun(r.runId);
+ const started = live?.startedAtMs ?? (Date.parse(r.startedAt) || null);
+ return (
+ open(r.runId)}>
+
+
+
+ running
+
+
+
+
+ {names.get(r.flowId) ?? r.flowId}
+
+
+ {r.runId}
+ {live?.environment ?? r.environment ?? ""}
+ {started ? timeAgo(started, now) : ""}
+ {started ? formatDuration(now - started) : ""}
+
+
+ );
+ })}
+ {stored.map((r) => (
+ open(r.runId)}>
+
+
+
+ {statusLabel(r.result)}
+
+
+
+
+ {r.flowName}
+
+
+ {r.runId}
+ {r.environment ?? ""}
+ {timeAgo(r.startedAt, now)}
+ {formatDuration(r.durationMs)}
+
+ {r.warnings > 0 ? (
+
+
+ {r.warnings}
+
+ ) : (
+ 0
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx
new file mode 100644
index 0000000..5842b7c
--- /dev/null
+++ b/ui/src/pages/Settings.tsx
@@ -0,0 +1,76 @@
+import { useState, type ReactNode } from "react";
+import { IconDisplay, IconMoon, IconSun } from "../components/icons";
+import { CopyButton } from "../components/ui";
+import { readTheme, saveTheme, useProject, type Theme } from "../lib/project";
+
+const THEMES: { id: Theme; label: string; icon: ReactNode }[] = [
+ { id: "system", label: "System", icon: },
+ { id: "light", label: "Light", icon: },
+ { id: "dark", label: "Dark", icon: },
+];
+
+export function SettingsPage() {
+ const [theme, setTheme] = useState(readTheme);
+ const state = useProject();
+ return (
+
+
+
+
Settings
+
Saved in this browser.
+
+
+
+
+
+ Appearance
+
+
+
+
Theme
+
System follows your operating system's light or dark setting.
+
+
+ {THEMES.map((t) => (
+ {
+ setTheme(t.id);
+ saveTheme(t.id);
+ }}
+ >
+ {t.icon}
+ {t.label}
+
+ ))}
+
+
+
+
+
+ Project
+
+ {state ? (
+
+
Name
+
{state.project.name}
+
Root
+
+ {state.project.root}
+
+
Config
+
{state.project.hasConfig ? lest.yaml : "No lest.yaml; defaults apply"}
+
Lest version
+
{state.version}
+
+ ) : (
+ Loading
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/store/runState.ts b/ui/src/store/runState.ts
new file mode 100644
index 0000000..4f03348
--- /dev/null
+++ b/ui/src/store/runState.ts
@@ -0,0 +1,432 @@
+// Pure run state: folds a run's events (and, once it exists, its report) into
+// a step tree the UI renders. No I/O here; store.ts owns fetching and SSE.
+
+import type {
+ Artifact,
+ Beat,
+ OutputStream,
+ PlanNode,
+ RunEvent,
+ RunReport,
+ RunResult,
+ StepKind,
+ StepReport,
+ StepStatus,
+ ToolReport,
+} from "../types";
+
+/** Per step, only the newest lines are kept in memory. */
+export const MAX_LINES_PER_STEP = 5000;
+
+export interface OutputLine {
+ seq: number;
+ stream: OutputStream;
+ line: string;
+}
+
+export interface RetryState {
+ attempt: number;
+ maxAttempts: number;
+ reason: string;
+ /** Server clock (ms since epoch) when the next attempt starts. */
+ nextAtMs: number;
+}
+
+export interface StepState {
+ id: string;
+ name: string;
+ kind: StepKind;
+ notes?: string;
+ parent: string | null;
+ children: string[];
+ status: StepStatus;
+ /** The attempt in progress or the last one made; 0 before the first. */
+ attempt: number;
+ maxAttempts: number | null;
+ retry: RetryState | null;
+ startedAtMs: number | null;
+ finishedAtMs: number | null;
+ durationMs: number | null;
+ output: OutputLine[];
+ droppedLines: number;
+ artifacts: Artifact[];
+ beats: Beat[];
+ report: StepReport | null;
+}
+
+export interface BrowserState {
+ stepId: string;
+ cdpPort: number;
+ targetId: string;
+ url: string;
+ active: boolean;
+}
+
+export interface FinishedState {
+ result: RunResult;
+ durationMs: number;
+ error: string | null;
+ atMs: number;
+}
+
+export interface RunState {
+ runId: string;
+ flowId: string | null;
+ flowName: string | null;
+ environment: string | null;
+ /** The highest event seq applied; -1 before any. */
+ lastSeq: number;
+ /** An event arrived after a missing one; the store refetches the run. */
+ gap: boolean;
+ /** Events that arrived during a gap, applied after the refetched
+ * snapshot so nothing newer than the snapshot is lost. */
+ pending: RunEvent[];
+ startedAtMs: number | null;
+ steps: Record;
+ roots: string[];
+ finallyRoots: string[];
+ tools: ToolReport[];
+ signIn: Record;
+ progress: { atMs: number; message: string }[];
+ cleanups: Record;
+ browser: BrowserState | null;
+ finished: FinishedState | null;
+ report: RunReport | null;
+}
+
+export function emptyRun(runId: string): RunState {
+ return {
+ runId,
+ flowId: null,
+ flowName: null,
+ environment: null,
+ lastSeq: -1,
+ gap: false,
+ pending: [],
+ startedAtMs: null,
+ steps: {},
+ roots: [],
+ finallyRoots: [],
+ tools: [],
+ signIn: {},
+ progress: [],
+ cleanups: {},
+ browser: null,
+ finished: null,
+ report: null,
+ };
+}
+
+function newStep(id: string, name: string, kind: StepKind, parent: string | null, notes?: string): StepState {
+ return {
+ id,
+ name,
+ kind,
+ notes,
+ parent,
+ children: [],
+ status: "pending",
+ attempt: 0,
+ maxAttempts: null,
+ retry: null,
+ startedAtMs: null,
+ finishedAtMs: null,
+ durationMs: null,
+ output: [],
+ droppedLines: 0,
+ artifacts: [],
+ beats: [],
+ report: null,
+ };
+}
+
+function addPlan(steps: Record, nodes: PlanNode[], parent: string | null): string[] {
+ return nodes.map((n) => {
+ const s = newStep(n.id, n.name, n.kind, parent, n.notes);
+ steps[n.id] = s;
+ s.children = addPlan(steps, n.children ?? [], n.id);
+ return n.id;
+ });
+}
+
+/** Copies the step (and its parent chain is untouched); returns the copy. */
+function edit(run: RunState, id: string): StepState {
+ const cur = run.steps[id];
+ const s: StepState = cur
+ ? { ...cur }
+ : newStep(id, id.split("/").pop() ?? id, "run", null);
+ if (!cur) run.roots = [...run.roots, id];
+ run.steps = { ...run.steps, [id]: s };
+ return s;
+}
+
+function applyStepReport(run: RunState, r: StepReport, atMs: number | null, overwrite: boolean): void {
+ const existing = run.steps[r.id];
+ if (existing && existing.report && !overwrite) return;
+ const s = edit(run, r.id);
+ if (r.kind) s.kind = r.kind;
+ s.name = r.name || s.name;
+ if (r.notes) s.notes = r.notes;
+ s.status = r.status ?? s.status;
+ s.attempt = Math.max(s.attempt, r.attempts);
+ s.retry = null;
+ s.durationMs = r.durationMs;
+ if (r.startedAt && s.startedAtMs === null) s.startedAtMs = Date.parse(r.startedAt);
+ s.finishedAtMs = r.finishedAt ? Date.parse(r.finishedAt) : atMs;
+ if (r.artifacts && r.artifacts.length) s.artifacts = r.artifacts;
+ if (r.beats && r.beats.length) s.beats = r.beats;
+ s.report = r;
+ for (const c of r.children ?? []) {
+ if (!run.steps[c.id]) {
+ const child = edit(run, c.id);
+ child.parent = r.id;
+ run.roots = run.roots.filter((x) => x !== c.id);
+ if (!s.children.includes(c.id)) s.children = [...s.children, c.id];
+ }
+ applyStepReport(run, c, atMs, overwrite);
+ }
+}
+
+/** Applies one event. Duplicates and stale events are ignored; an event
+ * after a missing one marks a gap and is ignored. */
+export function applyEvent(prev: RunState, ev: RunEvent): RunState {
+ if (ev.runId !== prev.runId) return prev;
+ if (ev.seq <= prev.lastSeq) return prev;
+ if (ev.seq !== prev.lastSeq + 1) {
+ const pending = prev.pending.length < 5000 ? [...prev.pending, ev] : prev.pending;
+ return { ...prev, gap: true, pending };
+ }
+ const run: RunState = { ...prev, lastSeq: ev.seq, gap: false };
+ switch (ev.kind) {
+ case "runStarted": {
+ const steps: Record = {};
+ run.flowId = ev.flowId;
+ run.flowName = ev.flowName;
+ run.environment = ev.environment;
+ run.startedAtMs = ev.atMs;
+ run.roots = addPlan(steps, ev.steps, null);
+ run.finallyRoots = addPlan(steps, ev.finally, null);
+ run.steps = steps;
+ break;
+ }
+ case "tool": {
+ const i = run.tools.findIndex((t) => t.name === ev.tool.name);
+ run.tools = i < 0 ? [...run.tools, ev.tool] : run.tools.map((t, j) => (j === i ? ev.tool : t));
+ break;
+ }
+ case "signInNeeded":
+ run.signIn = { ...run.signIn, [ev.tool]: ev.command };
+ break;
+ case "stepStarted": {
+ const s = edit(run, ev.stepId);
+ s.status = "running";
+ s.attempt = ev.attempt;
+ s.retry = null;
+ if (s.startedAtMs === null) s.startedAtMs = ev.atMs;
+ break;
+ }
+ case "stepOutput": {
+ const s = edit(run, ev.stepId);
+ const out = [...s.output, { seq: ev.seq, stream: ev.stream, line: ev.line }];
+ const over = out.length - MAX_LINES_PER_STEP;
+ if (over > 0) {
+ out.splice(0, over);
+ s.droppedLines += over;
+ }
+ s.output = out;
+ break;
+ }
+ case "stepRetrying": {
+ const s = edit(run, ev.stepId);
+ s.maxAttempts = ev.maxAttempts;
+ s.retry = {
+ attempt: ev.attempt,
+ maxAttempts: ev.maxAttempts,
+ reason: ev.reason,
+ nextAtMs: ev.atMs + ev.nextAttemptInMs,
+ };
+ break;
+ }
+ case "stepFinished":
+ applyStepReport(run, ev.step, ev.atMs, true);
+ break;
+ case "stepArtifact": {
+ const s = edit(run, ev.stepId);
+ if (!s.artifacts.some((a) => a.path === ev.artifact.path)) s.artifacts = [...s.artifacts, ev.artifact];
+ break;
+ }
+ case "stepBeat": {
+ const s = edit(run, ev.stepId);
+ s.beats = [...s.beats, ev.beat];
+ break;
+ }
+ case "browserPage":
+ run.browser = {
+ stepId: ev.stepId,
+ cdpPort: ev.cdpPort,
+ targetId: ev.targetId,
+ url: ev.url,
+ active: true,
+ };
+ break;
+ case "browserClosed":
+ if (run.browser && run.browser.stepId === ev.stepId) run.browser = { ...run.browser, active: false };
+ break;
+ case "cleanupStarted":
+ run.cleanups = { ...run.cleanups, [ev.stepId]: "running" };
+ break;
+ case "cleanupFinished":
+ run.cleanups = { ...run.cleanups, [ev.stepId]: ev.status };
+ break;
+ case "demoProgress":
+ run.progress = [...run.progress, { atMs: ev.atMs, message: ev.message }];
+ break;
+ case "runFinished":
+ run.finished = { result: ev.result, durationMs: ev.durationMs, error: ev.error, atMs: ev.atMs };
+ if (run.browser) run.browser = { ...run.browser, active: false };
+ break;
+ }
+ return run;
+}
+
+export function applyEvents(run: RunState, events: RunEvent[]): RunState {
+ const sorted = [...events].sort((a, b) => a.seq - b.seq);
+ return sorted.reduce(applyEvent, run);
+}
+
+function planFromReport(steps: StepReport[]): PlanNode[] {
+ return steps.map((s) => ({
+ id: s.id,
+ name: s.name,
+ kind: s.kind ?? "run",
+ notes: s.notes,
+ children: planFromReport(s.children ?? []),
+ }));
+}
+
+/** Folds the canonical report into the state. Builds the tree from the
+ * report when no events were seen (a stored run). */
+export function applyReport(prev: RunState, report: RunReport): RunState {
+ const run: RunState = { ...prev, report };
+ if (prev.lastSeq < 0) {
+ const steps: Record = {};
+ run.roots = addPlan(steps, planFromReport(report.steps), null);
+ run.finallyRoots = addPlan(steps, planFromReport(report.finally ?? []), null);
+ run.steps = steps;
+ run.flowId = report.flowId;
+ run.flowName = report.flowName;
+ run.environment = report.environment ?? null;
+ run.startedAtMs = Date.parse(report.startedAt);
+ run.tools = report.tools ?? [];
+ // Stored runs take no further events.
+ run.lastSeq = Number.MAX_SAFE_INTEGER;
+ }
+ for (const s of [...report.steps, ...(report.finally ?? [])]) applyStepReport(run, s, null, true);
+ if (!run.finished) {
+ run.finished = {
+ result: report.result,
+ durationMs: report.durationMs,
+ error: report.error ?? null,
+ atMs: report.finishedAt ? Date.parse(report.finishedAt) : Date.parse(report.startedAt) + report.durationMs,
+ };
+ }
+ const cleanups: Record = { ...run.cleanups };
+ for (const c of report.cleanups ?? []) {
+ cleanups[c.stepId] = c.status === "not-run" ? "skipped" : c.status;
+ }
+ run.cleanups = cleanups;
+ if (run.browser) run.browser = { ...run.browser, active: false };
+ return run;
+}
+
+export function runFromReport(report: RunReport): RunState {
+ return applyReport(emptyRun(report.runId), report);
+}
+
+/** The top-level ancestor of a step (what `lest run --from` takes). */
+export function topLevelOf(run: RunState, id: string): string {
+ let cur = run.steps[id];
+ let top = id;
+ while (cur && cur.parent) {
+ top = cur.parent;
+ cur = run.steps[cur.parent];
+ }
+ return top;
+}
+
+/** Every step id in display order, depth first. */
+export function allStepIds(run: RunState): string[] {
+ const out: string[] = [];
+ const walk = (id: string) => {
+ out.push(id);
+ for (const c of run.steps[id]?.children ?? []) walk(c);
+ };
+ for (const r of [...run.roots, ...run.finallyRoots]) walk(r);
+ return out;
+}
+
+/** The leaf step that failed the run, if any (not a tolerated failure). */
+export function firstFailure(run: RunState): StepState | null {
+ for (const id of allStepIds(run)) {
+ const s = run.steps[id];
+ if (!s) continue;
+ if ((s.status === "failed" || s.status === "errored") && s.children.length === 0 && !s.report?.tolerated) {
+ return s;
+ }
+ }
+ return null;
+}
+
+export function isLive(run: RunState): boolean {
+ return run.lastSeq >= 0 && run.finished === null;
+}
+
+/** The labeled beats seen so far across the run, in order. */
+export function runBeats(run: RunState): Beat[] {
+ return allStepIds(run).flatMap((id) => run.steps[id]?.beats ?? []);
+}
+
+export function runArtifacts(run: RunState): { step: StepState; artifacts: Artifact[] }[] {
+ return allStepIds(run)
+ .map((id) => run.steps[id])
+ .filter((s): s is StepState => !!s && s.artifacts.length > 0)
+ .map((step) => ({ step, artifacts: step.artifacts }));
+}
+
+// The app-wide state: runs keyed by id. An event only ever touches its own
+// run's entry.
+
+export interface RunsState {
+ runs: Record;
+}
+
+export type RunsAction =
+ | { type: "event"; event: RunEvent }
+ | { type: "events"; runId: string; events: RunEvent[] }
+ | { type: "report"; report: RunReport };
+
+export function reduceRuns(state: RunsState, action: RunsAction): RunsState {
+ switch (action.type) {
+ case "event": {
+ const id = action.event.runId;
+ const prev = state.runs[id] ?? emptyRun(id);
+ const next = applyEvent(prev, action.event);
+ if (next === prev && state.runs[id]) return state;
+ return { runs: { ...state.runs, [id]: next } };
+ }
+ case "events": {
+ const prev = state.runs[action.runId] ?? emptyRun(action.runId);
+ const mine = action.events.filter((e) => e.runId === action.runId);
+ const held = prev.pending;
+ const snap = applyEvents({ ...prev, gap: false, pending: [] }, mine);
+ // Then whatever arrived during the gap and is newer than the snapshot.
+ const next = applyEvents(snap, held.filter((e) => e.seq > snap.lastSeq));
+ return { runs: { ...state.runs, [action.runId]: next } };
+ }
+ case "report": {
+ const id = action.report.runId;
+ const prev = state.runs[id] ?? emptyRun(id);
+ return { runs: { ...state.runs, [id]: applyReport(prev, action.report) } };
+ }
+ }
+}
diff --git a/ui/src/store/store.ts b/ui/src/store/store.ts
new file mode 100644
index 0000000..df5d526
--- /dev/null
+++ b/ui/src/store/store.ts
@@ -0,0 +1,157 @@
+// The app's run store: one SSE connection, runs keyed by id, read through
+// useSyncExternalStore.
+
+import { useEffect, useSyncExternalStore } from "react";
+import { api } from "../api";
+import type { RunEvent, RunReport } from "../types";
+import { isLive, reduceRuns, type RunsAction, type RunsState, type RunState } from "./runState";
+
+type Listener = () => void;
+
+let state: RunsState = { runs: {} };
+const listeners = new Set();
+const fetching = new Set();
+const failed = new Set();
+/** The newest run seen starting per flow since the page loaded. */
+const latestByFlow: Record = {};
+/** Bumped when any run finishes, so lists that show last runs refetch. */
+let finishedCount = 0;
+let connection: "connecting" | "open" | "closed" = "connecting";
+
+function emit() {
+ for (const l of listeners) l();
+}
+
+function dispatch(action: RunsAction) {
+ const next = reduceRuns(state, action);
+ if (next === state) return;
+ state = next;
+ for (const run of Object.values(state.runs)) {
+ if (run.gap) void hydrate(run.runId);
+ }
+ emit();
+}
+
+/** Loads a run from the server: events so far for a live run, the report
+ * for a finished one. */
+export async function hydrate(runId: string): Promise {
+ if (fetching.has(runId)) return;
+ fetching.add(runId);
+ try {
+ const r = await api.run(runId);
+ if (r.events.length) dispatch({ type: "events", runId, events: r.events });
+ if (r.report) dispatch({ type: "report", report: r.report });
+ } catch {
+ // The run view shows that the run could not be loaded.
+ failed.add(runId);
+ emit();
+ } finally {
+ fetching.delete(runId);
+ }
+}
+
+async function loadReport(runId: string): Promise {
+ // The report is written right before runFinished, so it is there now.
+ try {
+ const r = await api.run(runId);
+ if (r.report) dispatch({ type: "report", report: r.report as RunReport });
+ } catch {
+ /* the events already carry the final step reports */
+ }
+}
+
+let source: EventSource | null = null;
+
+export function connect(): void {
+ if (source || typeof EventSource === "undefined") return;
+ source = new EventSource("/api/events");
+ let dropped = false;
+ source.onopen = () => {
+ connection = "open";
+ emit();
+ // Events sent while the stream was down are gone: refetch every run
+ // still shown as live.
+ if (dropped) {
+ dropped = false;
+ for (const run of Object.values(state.runs)) if (isLive(run)) void hydrate(run.runId);
+ }
+ };
+ source.onerror = () => {
+ dropped = true;
+ connection = source?.readyState === EventSource.CLOSED ? "closed" : "connecting";
+ emit();
+ };
+ source.addEventListener("run", (m) => {
+ let ev: RunEvent;
+ try {
+ ev = JSON.parse((m as MessageEvent).data) as RunEvent;
+ } catch {
+ return;
+ }
+ if (ev.kind === "runStarted") latestByFlow[ev.flowId] = ev.runId;
+ dispatch({ type: "event", event: ev });
+ if (ev.kind === "runFinished") {
+ finishedCount++;
+ void loadReport(ev.runId);
+ emit();
+ }
+ });
+ source.addEventListener("resync", () => {
+ for (const run of Object.values(state.runs)) {
+ if (!run.finished) void hydrate(run.runId);
+ }
+ finishedCount++;
+ emit();
+ });
+}
+
+function subscribe(l: Listener) {
+ listeners.add(l);
+ return () => {
+ listeners.delete(l);
+ };
+}
+
+/** Marks a run as started from this page, so its flow view follows it before
+ * the first event arrives. */
+export function noteStarted(flowId: string, runId: string) {
+ latestByFlow[flowId] = runId;
+ emit();
+}
+
+export function useRun(runId: string | null | undefined): { run: RunState | null; failed: boolean } {
+ const run = useSyncExternalStore(subscribe, () => (runId ? state.runs[runId] ?? null : null));
+ const isFailed = useSyncExternalStore(subscribe, () => (runId ? failed.has(runId) : false));
+ useEffect(() => {
+ if (!runId) return;
+ const cur = state.runs[runId];
+ if (!cur || (cur.lastSeq < 0 && !cur.report)) void hydrate(runId);
+ }, [runId]);
+ return { run, failed: isFailed };
+}
+
+export function useLatestRunOf(flowId: string): string | null {
+ return useSyncExternalStore(subscribe, () => latestByFlow[flowId] ?? null);
+}
+
+export function useFinishedCount(): number {
+ return useSyncExternalStore(subscribe, () => finishedCount);
+}
+
+export function useConnection(): typeof connection {
+ return useSyncExternalStore(subscribe, () => connection);
+}
+
+/** Live runs currently in the store (started or seen since page load). */
+export function useLiveRunIds(): string {
+ return useSyncExternalStore(subscribe, () =>
+ Object.values(state.runs)
+ .filter((r) => r.lastSeq >= 0 && !r.finished && r.lastSeq !== Number.MAX_SAFE_INTEGER)
+ .map((r) => r.runId)
+ .join(","),
+ );
+}
+
+export function getRun(runId: string): RunState | null {
+ return state.runs[runId] ?? null;
+}
diff --git a/ui/src/styles.css b/ui/src/styles.css
new file mode 100644
index 0000000..21ba725
--- /dev/null
+++ b/ui/src/styles.css
@@ -0,0 +1,2132 @@
+/* Lest UI. Colors are tokens on :root, redefined for dark mode. */
+
+:root {
+ --bg: #f6f5f1;
+ --surface: #ffffff;
+ --surface-2: #fbfaf8;
+ --sunken: #f0eee8;
+ --hover: #f3f1ec;
+ --border: #e5e2da;
+ --border-strong: #d4d0c5;
+ --text: #1b1e1d;
+ --text-2: #4f5653;
+ --text-3: #7f8682;
+ --accent: #0f6b5c;
+ --accent-hover: #0b5a4d;
+ --accent-soft: #e2efeb;
+ --accent-text: #0c5e51;
+ --on-accent: #ffffff;
+ --passed: #1d8048;
+ --passed-soft: #e3f2e8;
+ --failed: #cf2f3a;
+ --failed-soft: #fbe8e8;
+ --errored: #c2541b;
+ --errored-soft: #fbeee4;
+ --skipped: #9aa09c;
+ --skipped-soft: #efeeea;
+ --running: var(--accent);
+ --warning: #9a6a0c;
+ --warning-soft: #fbf3dd;
+ --code-bg: #f4f2ec;
+ --stderr: #a8402a;
+ --shadow-sm: 0 1px 2px rgba(30, 28, 20, 0.05);
+ --shadow-md: 0 1px 2px rgba(30, 28, 20, 0.05), 0 6px 20px -6px rgba(30, 28, 20, 0.14);
+ --shadow-lg: 0 20px 60px -10px rgba(20, 18, 10, 0.35);
+ --scrim: rgba(20, 22, 21, 0.72);
+ --radius: 8px;
+ --radius-sm: 5px;
+ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+ color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #111312;
+ --surface: #181b1a;
+ --surface-2: #1c201e;
+ --sunken: #0d0f0e;
+ --hover: #212523;
+ --border: #282d2b;
+ --border-strong: #38403c;
+ --text: #e7eae8;
+ --text-2: #a9b1ad;
+ --text-3: #77807b;
+ --accent: #3db39b;
+ --accent-hover: #55c4ad;
+ --accent-soft: rgba(61, 179, 155, 0.14);
+ --accent-text: #6fd0bb;
+ --on-accent: #06201b;
+ --passed: #45c07a;
+ --passed-soft: rgba(69, 192, 122, 0.13);
+ --failed: #f0656c;
+ --failed-soft: rgba(240, 101, 108, 0.13);
+ --errored: #f08b4f;
+ --errored-soft: rgba(240, 139, 79, 0.13);
+ --skipped: #6d7571;
+ --skipped-soft: rgba(140, 150, 145, 0.12);
+ --warning: #e0b45a;
+ --warning-soft: rgba(224, 180, 90, 0.12);
+ --code-bg: #141716;
+ --stderr: #f19a84;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px -8px rgba(0, 0, 0, 0.6);
+ --shadow-lg: 0 20px 60px -10px rgba(0, 0, 0, 0.7);
+ --scrim: rgba(0, 0, 0, 0.78);
+ color-scheme: dark;
+ }
+}
+
+:root[data-theme="dark"] {
+ --bg: #111312;
+ --surface: #181b1a;
+ --surface-2: #1c201e;
+ --sunken: #0d0f0e;
+ --hover: #212523;
+ --border: #282d2b;
+ --border-strong: #38403c;
+ --text: #e7eae8;
+ --text-2: #a9b1ad;
+ --text-3: #77807b;
+ --accent: #3db39b;
+ --accent-hover: #55c4ad;
+ --accent-soft: rgba(61, 179, 155, 0.14);
+ --accent-text: #6fd0bb;
+ --on-accent: #06201b;
+ --passed: #45c07a;
+ --passed-soft: rgba(69, 192, 122, 0.13);
+ --failed: #f0656c;
+ --failed-soft: rgba(240, 101, 108, 0.13);
+ --errored: #f08b4f;
+ --errored-soft: rgba(240, 139, 79, 0.13);
+ --skipped: #6d7571;
+ --skipped-soft: rgba(140, 150, 145, 0.12);
+ --warning: #e0b45a;
+ --warning-soft: rgba(224, 180, 90, 0.12);
+ --code-bg: #141716;
+ --stderr: #f19a84;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px -8px rgba(0, 0, 0, 0.6);
+ --shadow-lg: 0 20px 60px -10px rgba(0, 0, 0, 0.7);
+ --scrim: rgba(0, 0, 0, 0.78);
+ color-scheme: dark;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 13.5px/1.5 var(--font);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+body {
+ min-height: 100vh;
+}
+
+a {
+ color: var(--accent-text);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+
+code,
+kbd,
+pre,
+.mono {
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+/* App shell */
+
+.app {
+ display: grid;
+ grid-template-columns: var(--rail-w, 216px) minmax(0, 1fr);
+ min-height: 100vh;
+}
+.app.rail-collapsed {
+ --rail-w: 56px;
+}
+
+.rail {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 12px 10px;
+ border-right: 1px solid var(--border);
+ background: var(--surface-2);
+ overflow: hidden;
+}
+.rail-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 2px 4px 12px;
+ min-height: 44px;
+}
+.wordmark {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--text);
+ font-weight: 650;
+ font-size: 17px;
+ letter-spacing: -0.02em;
+}
+.wordmark:hover {
+ text-decoration: none;
+}
+.wordmark svg {
+ flex: none;
+}
+.rail-project {
+ padding: 0 8px 10px;
+ margin-bottom: 6px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-3);
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rail-project strong {
+ display: block;
+ color: var(--text);
+ font-weight: 560;
+ font-size: 12.5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rail-link {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 32px;
+ padding: 0 9px;
+ border-radius: 6px;
+ color: var(--text-2);
+ font-weight: 500;
+ white-space: nowrap;
+}
+.rail-link:hover {
+ background: var(--hover);
+ color: var(--text);
+ text-decoration: none;
+}
+.rail-link[aria-current="page"] {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: var(--shadow-sm), inset 0 0 0 1px var(--border);
+}
+.rail-link[aria-current="page"] svg {
+ color: var(--accent);
+}
+.rail-link svg {
+ flex: none;
+ color: var(--text-3);
+}
+.rail-link .count {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--accent-text);
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.rail-spacer {
+ flex: 1;
+}
+.rail-foot {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ border-top: 1px solid var(--border);
+ padding-top: 8px;
+}
+.rail-toggle {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 30px;
+ padding: 0 9px;
+ border: 0;
+ background: none;
+ border-radius: 6px;
+ color: var(--text-3);
+ cursor: pointer;
+ white-space: nowrap;
+ font-size: 12px;
+}
+.rail-toggle:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+.rail-collapsed .rail-label,
+.rail-collapsed .rail-project,
+.rail-collapsed .wordmark-text,
+.rail-collapsed .rail-link .count-text {
+ display: none;
+}
+.rail-collapsed .rail-head {
+ justify-content: center;
+ padding-left: 0;
+ padding-right: 0;
+}
+.rail-collapsed .rail-link,
+.rail-collapsed .rail-toggle {
+ justify-content: center;
+ padding: 0;
+}
+.rail-collapsed .rail-link .count {
+ position: absolute;
+ margin: -14px 0 0 18px;
+}
+
+.main {
+ min-width: 0;
+}
+.page {
+ max-width: 1320px;
+ margin: 0 auto;
+ padding: 24px 32px 64px;
+}
+.page-wide {
+ max-width: none;
+}
+.page-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+.page-head h1 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 640;
+ letter-spacing: -0.015em;
+}
+.page-head .sub {
+ color: var(--text-3);
+ margin-top: 2px;
+}
+
+/* Controls */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 11px;
+ border-radius: 6px;
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 520;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+ box-shadow: var(--shadow-sm);
+ transition: background 0.12s, border-color 0.12s;
+}
+.btn:hover {
+ background: var(--hover);
+}
+.btn:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+.btn-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--on-accent);
+}
+.btn-primary:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+.btn-danger {
+ color: var(--failed);
+}
+.btn-ghost {
+ border-color: transparent;
+ background: none;
+ box-shadow: none;
+ color: var(--text-2);
+}
+.btn-ghost:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+.btn-sm {
+ height: 26px;
+ padding: 0 8px;
+ font-size: 12.5px;
+}
+.btn-icon {
+ width: 28px;
+ padding: 0;
+}
+.btn-lg {
+ height: 34px;
+ padding: 0 16px;
+ font-size: 13.5px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+.field-label {
+ font-size: 11.5px;
+ font-weight: 560;
+ color: var(--text-2);
+ display: flex;
+ gap: 4px;
+ align-items: baseline;
+}
+.field-label .req {
+ color: var(--failed);
+ font-weight: 600;
+}
+.input,
+.select {
+ height: 30px;
+ padding: 0 9px;
+ border-radius: 6px;
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ min-width: 0;
+}
+.select {
+ padding-right: 26px;
+ appearance: none;
+ background-image: linear-gradient(45deg, transparent 50%, var(--text-3) 50%),
+ linear-gradient(135deg, var(--text-3) 50%, transparent 50%);
+ background-position: calc(100% - 13px) 13px, calc(100% - 9px) 13px;
+ background-size: 4px 4px;
+ background-repeat: no-repeat;
+}
+.input:focus,
+.select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.search {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+.search svg {
+ position: absolute;
+ left: 9px;
+ color: var(--text-3);
+ pointer-events: none;
+}
+.search .input {
+ padding-left: 30px;
+ width: 280px;
+}
+
+.switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+ font-size: 12.5px;
+ color: var(--text-2);
+ height: 30px;
+}
+.switch input {
+ position: absolute;
+ opacity: 0;
+ width: 1px;
+ height: 1px;
+}
+.switch-track {
+ width: 28px;
+ height: 16px;
+ border-radius: 999px;
+ background: var(--border-strong);
+ position: relative;
+ transition: background 0.15s;
+ flex: none;
+}
+.switch-track::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s;
+}
+.switch input:checked + .switch-track {
+ background: var(--accent);
+}
+.switch input:checked + .switch-track::after {
+ transform: translateX(12px);
+}
+.switch input:focus-visible + .switch-track {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: 999px;
+ background: var(--sunken);
+ color: var(--text-2);
+ font-size: 11.5px;
+ font-weight: 500;
+ white-space: nowrap;
+ border: 1px solid transparent;
+}
+button.chip {
+ cursor: pointer;
+ height: 24px;
+ padding: 0 9px;
+ border-color: var(--border);
+ background: var(--surface);
+}
+button.chip:hover {
+ border-color: var(--border-strong);
+}
+button.chip[aria-pressed="true"] {
+ background: var(--accent-soft);
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+ color: var(--accent-text);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: 5px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+.badge-passed {
+ background: var(--passed-soft);
+ color: var(--passed);
+}
+.badge-failed {
+ background: var(--failed-soft);
+ color: var(--failed);
+}
+.badge-errored {
+ background: var(--errored-soft);
+ color: var(--errored);
+}
+.badge-cancelled,
+.badge-skipped,
+.badge-pending {
+ background: var(--skipped-soft);
+ color: var(--text-2);
+}
+.badge-running {
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+
+.kind {
+ display: inline-flex;
+ align-items: center;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ color: var(--text-3);
+ font: 500 10.5px/1 var(--mono);
+ letter-spacing: 0.02em;
+ flex: none;
+}
+
+/* Status */
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
+ display: inline-block;
+ background: var(--skipped);
+}
+.dot-passed {
+ background: var(--passed);
+}
+.dot-failed {
+ background: var(--failed);
+}
+.dot-errored {
+ background: var(--errored);
+}
+.dot-cancelled,
+.dot-skipped {
+ background: var(--skipped);
+}
+.dot-none,
+.dot-pending {
+ background: transparent;
+ box-shadow: inset 0 0 0 1.5px var(--border-strong);
+}
+.dot-running {
+ background: var(--running);
+ animation: pulse 1.6s ease-out infinite;
+}
+@keyframes pulse {
+ 0% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--running) 55%, transparent);
+ }
+ 70% {
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--running) 0%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
+
+.edge-passed {
+ --edge: var(--passed);
+}
+.edge-failed {
+ --edge: var(--failed);
+}
+.edge-errored {
+ --edge: var(--errored);
+}
+.edge-running {
+ --edge: var(--running);
+}
+.edge-cancelled,
+.edge-skipped {
+ --edge: var(--skipped);
+}
+.edge-none,
+.edge-pending {
+ --edge: transparent;
+}
+
+.glyph {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ display: inline-grid;
+ place-items: center;
+}
+.glyph-passed {
+ color: var(--passed);
+}
+.glyph-failed {
+ color: var(--failed);
+}
+.glyph-errored {
+ color: var(--errored);
+}
+.glyph-skipped,
+.glyph-pending {
+ color: var(--text-3);
+}
+.glyph-running {
+ color: var(--running);
+}
+.spin {
+ animation: spin 0.9s linear infinite;
+ transform-origin: center;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .spin,
+ .dot-running {
+ animation: none;
+ }
+}
+
+/* Banners and empty states */
+
+.banner {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding: 10px 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ background: var(--surface);
+ margin-bottom: 16px;
+}
+.banner-warning {
+ background: var(--warning-soft);
+ border-color: color-mix(in srgb, var(--warning) 30%, transparent);
+}
+.banner-error {
+ background: var(--failed-soft);
+ border-color: color-mix(in srgb, var(--failed) 30%, transparent);
+}
+.banner-body {
+ flex: 1;
+ min-width: 0;
+}
+.banner-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.banner ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.banner li {
+ display: flex;
+ gap: 8px;
+ padding: 2px 0;
+ color: var(--text-2);
+}
+.banner li .mono {
+ color: var(--text);
+}
+
+.empty {
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--text-2);
+ border: 1px dashed var(--border-strong);
+ border-radius: 10px;
+ background: var(--surface-2);
+}
+.empty h2 {
+ margin: 0 0 6px;
+ font-size: 15px;
+ color: var(--text);
+}
+.empty p {
+ margin: 4px auto;
+ max-width: 520px;
+}
+.empty code {
+ background: var(--code-bg);
+ padding: 1px 5px;
+ border-radius: 4px;
+}
+.muted {
+ color: var(--text-3);
+}
+.loading {
+ color: var(--text-3);
+ padding: 40px 0;
+ text-align: center;
+}
+
+/* Flows explorer */
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 18px;
+}
+.toolbar .chips {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.section-label {
+ font-size: 11px;
+ font-weight: 650;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-3);
+ margin: 22px 0 8px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.section-label .n {
+ font-weight: 500;
+ letter-spacing: 0;
+}
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 10px;
+}
+.card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 12px 14px 12px 15px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+ transition: border-color 0.12s, box-shadow 0.12s, transform 0.12s;
+}
+.card::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--edge, transparent);
+}
+a.card:hover {
+ text-decoration: none;
+ border-color: var(--border-strong);
+ box-shadow: var(--shadow-md);
+}
+.card-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 590;
+ font-size: 13.5px;
+ min-width: 0;
+}
+.card-title .name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.card-desc {
+ color: var(--text-2);
+ font-size: 12.5px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: 2.9em;
+}
+.card-foot {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 2px;
+ min-height: 22px;
+}
+.card-foot .tags {
+ display: flex;
+ gap: 4px;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ min-width: 0;
+}
+.card-foot .meta {
+ margin-left: auto;
+ color: var(--text-3);
+ font-size: 12px;
+ white-space: nowrap;
+ display: flex;
+ gap: 8px;
+}
+.card-warn {
+ color: var(--warning);
+ display: inline-flex;
+ margin-left: auto;
+}
+.card-id {
+ color: var(--text-3);
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+/* Flow header */
+
+.flow-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px 24px;
+ margin-bottom: 16px;
+}
+.crumbs {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ color: var(--text-3);
+ font-size: 12.5px;
+ margin-bottom: 6px;
+}
+.crumbs a {
+ color: var(--text-3);
+}
+.crumbs a:hover {
+ color: var(--text);
+}
+.flow-head h1 {
+ margin: 0;
+ font-size: 21px;
+ font-weight: 650;
+ letter-spacing: -0.018em;
+ line-height: 1.25;
+}
+.flow-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 6px;
+ color: var(--text-3);
+ font-size: 12px;
+}
+.flow-meta .mono {
+ font-size: 11.5px;
+}
+.flow-meta .id {
+ color: var(--text-2);
+}
+.sep {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--border-strong);
+}
+.flow-desc {
+ margin-top: 10px;
+ color: var(--text-2);
+ max-width: 820px;
+}
+.md p {
+ margin: 0 0 6px;
+}
+.md p:last-child {
+ margin-bottom: 0;
+}
+.md ul,
+.md ol {
+ margin: 4px 0 6px;
+ padding-left: 20px;
+}
+.md code {
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ padding: 0 4px;
+ border-radius: 4px;
+ font-size: 11.5px;
+}
+
+.controls {
+ display: flex;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+ margin-bottom: 16px;
+}
+.controls .spacer {
+ flex: 1;
+}
+.controls .field .select,
+.controls .field .input {
+ min-width: 140px;
+}
+.controls-note {
+ font-size: 12px;
+ color: var(--text-3);
+ align-self: center;
+}
+.controls-error {
+ flex-basis: 100%;
+ color: var(--failed);
+ font-size: 12.5px;
+}
+
+/* Run view */
+
+.run-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 360px;
+ gap: 16px;
+ align-items: start;
+}
+.run-layout.no-side {
+ grid-template-columns: minmax(0, 1fr);
+}
+.run-main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.summary {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px 18px;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+ position: relative;
+ overflow: hidden;
+}
+.summary::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--edge, transparent);
+}
+.summary .item {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ min-width: 0;
+}
+.summary .label {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ font-weight: 600;
+}
+.summary .value {
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.summary .value.big {
+ font-size: 15px;
+ font-weight: 600;
+}
+.summary .grow {
+ flex: 1;
+}
+.summary .warn {
+ color: var(--warning);
+}
+.copy-btn {
+ border: 0;
+ background: none;
+ padding: 2px;
+ border-radius: 4px;
+ color: var(--text-3);
+ cursor: pointer;
+ display: inline-grid;
+ place-items: center;
+}
+.copy-btn:hover {
+ color: var(--text);
+ background: var(--hover);
+}
+
+.panel {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+}
+.panel-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ font-size: 11px;
+ font-weight: 650;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-3);
+ background: var(--surface-2);
+ min-height: 36px;
+}
+.panel-head .right {
+ margin-left: auto;
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.panel-body {
+ padding: 10px 12px;
+}
+.panel-empty {
+ padding: 14px 12px;
+ color: var(--text-3);
+ font-size: 12.5px;
+}
+
+.quiet-rows {
+ display: flex;
+ flex-direction: column;
+}
+.quiet-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ border-top: 1px solid var(--border);
+ font-size: 12.5px;
+ color: var(--text-2);
+ min-height: 34px;
+}
+.quiet-row:first-child {
+ border-top: 0;
+}
+.quiet-row .mono {
+ color: var(--text);
+}
+.quiet-row .right {
+ margin-left: auto;
+ color: var(--text-3);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.quiet-row .msg {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Step tree */
+
+.tree {
+ padding: 4px 0;
+}
+.tree-label {
+ padding: 10px 14px 4px;
+ font-size: 10.5px;
+ font-weight: 650;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var(--text-3);
+}
+.step {
+ position: relative;
+}
+.step-row {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 36px;
+ padding: 0 12px 0 calc(12px + var(--depth, 0) * 20px);
+ border: 0;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+ color: var(--text);
+}
+.step-row::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 5px;
+ bottom: 5px;
+ width: 3px;
+ border-radius: 0 2px 2px 0;
+ background: var(--edge, transparent);
+}
+.step-row:hover {
+ background: var(--hover);
+}
+.step-row:focus-visible {
+ outline-offset: -2px;
+}
+.step.open > .step-row {
+ background: var(--surface-2);
+}
+.chev {
+ flex: none;
+ width: 14px;
+ color: var(--text-3);
+ transition: transform 0.12s;
+ display: inline-grid;
+ place-items: center;
+}
+.step.open > .step-row .chev {
+ transform: rotate(90deg);
+}
+.step-name {
+ font-weight: 540;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+.step-row .step-id {
+ color: var(--text-3);
+ font-size: 11.5px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 40px;
+ flex-shrink: 10;
+}
+.step-row .right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: none;
+ padding-left: 8px;
+}
+.step-row .dur {
+ font-variant-numeric: tabular-nums;
+ color: var(--text-3);
+ font-size: 12px;
+ min-width: 44px;
+ text-align: right;
+}
+.step-status-running .dur {
+ color: var(--accent-text);
+}
+.attempt {
+ font-size: 11.5px;
+ color: var(--text-2);
+ background: var(--sunken);
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.attempt.retrying {
+ color: var(--warning);
+ background: var(--warning-soft);
+}
+.tolerated {
+ font-size: 11px;
+ color: var(--warning);
+ white-space: nowrap;
+}
+.step-status-skipped .step-name,
+.step-status-pending .step-name {
+ color: var(--text-2);
+ font-weight: 500;
+}
+.step-children {
+ position: relative;
+}
+.step-children::before {
+ content: "";
+ position: absolute;
+ left: calc(19px + var(--depth, 0) * 20px);
+ top: 0;
+ bottom: 6px;
+ width: 1px;
+ background: var(--border);
+}
+
+.step-body {
+ margin: 2px 12px 10px calc(34px + var(--depth, 0) * 20px);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.explain {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 4px;
+ padding: 14px 16px 12px;
+ background: var(--failed-soft);
+ border-bottom: 1px solid color-mix(in srgb, var(--failed) 22%, transparent);
+}
+.explain.errored {
+ background: var(--errored-soft);
+ border-bottom-color: color-mix(in srgb, var(--errored) 22%, transparent);
+}
+.explain-top {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+.explain-top .glyph {
+ margin-top: 3px;
+}
+.explain-primary {
+ flex: 1;
+ min-width: 0;
+ font-size: 14.5px;
+ font-weight: 560;
+ line-height: 1.45;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+.explain-marker {
+ font-size: 11px;
+ color: var(--text-3);
+ white-space: nowrap;
+ margin-top: 3px;
+}
+.explain-secondary {
+ margin-left: 26px;
+ color: var(--text-2);
+ font-size: 12.5px;
+ white-space: pre-wrap;
+}
+.explain-actions {
+ display: flex;
+ gap: 6px;
+ margin: 8px 0 0 26px;
+ flex-wrap: wrap;
+}
+
+.tabs {
+ display: flex;
+ gap: 2px;
+ padding: 0 8px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-2);
+}
+.tab {
+ height: 32px;
+ padding: 0 10px;
+ border: 0;
+ background: none;
+ color: var(--text-3);
+ font-weight: 540;
+ font-size: 12.5px;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.tab:hover {
+ color: var(--text);
+}
+.tab[aria-selected="true"] {
+ color: var(--text);
+ border-bottom-color: var(--accent);
+}
+.tab .n {
+ font-size: 11px;
+ color: var(--text-3);
+ background: var(--sunken);
+ border-radius: 999px;
+ padding: 0 6px;
+}
+.tab-panel {
+ padding: 0;
+}
+
+.output {
+ margin: 0;
+ padding: 10px 14px;
+ max-height: 420px;
+ overflow: auto;
+ background: var(--code-bg);
+ font: 11.5px/1.6 var(--mono);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ color: var(--text);
+}
+.output .l-stderr {
+ color: var(--stderr);
+}
+.output .l-browser {
+ color: var(--text-3);
+}
+.output-label {
+ display: block;
+ font: 600 10.5px/1 var(--font);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-3);
+ padding: 10px 14px 0;
+ background: var(--code-bg);
+}
+.output-note {
+ padding: 6px 14px;
+ font-size: 11.5px;
+ color: var(--text-3);
+ background: var(--code-bg);
+ border-bottom: 1px solid var(--border);
+}
+.output-empty {
+ padding: 14px;
+ color: var(--text-3);
+ font-size: 12.5px;
+}
+
+.kv {
+ display: grid;
+ grid-template-columns: 120px minmax(0, 1fr);
+ gap: 0;
+ font-size: 12.5px;
+}
+.kv > div {
+ padding: 7px 14px;
+ border-top: 1px solid var(--border);
+ min-width: 0;
+}
+.kv > div:nth-child(-n + 2) {
+ border-top: 0;
+}
+.kv .k {
+ color: var(--text-3);
+ font-weight: 500;
+}
+.kv .v {
+ overflow-wrap: anywhere;
+}
+.kv .v pre {
+ margin: 0;
+ white-space: pre-wrap;
+ font-size: 11.5px;
+}
+.outputs-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+.outputs-table td {
+ padding: 4px 0;
+ vertical-align: top;
+ border-top: 1px solid var(--border);
+}
+.outputs-table tr:first-child td {
+ border-top: 0;
+}
+.outputs-table td:first-child {
+ width: 1%;
+ padding-right: 16px;
+ white-space: nowrap;
+ color: var(--text-2);
+}
+.outputs-table pre {
+ margin: 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.artifacts {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 10px;
+ padding: 12px;
+}
+.artifacts.single-col {
+ grid-template-columns: minmax(0, 1fr);
+}
+.thumb {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ border: 0;
+ background: none;
+ padding: 0;
+ text-align: left;
+ cursor: zoom-in;
+ color: var(--text);
+ min-width: 0;
+}
+.thumb img {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+ object-position: top;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--sunken);
+ display: block;
+}
+.thumb:hover img {
+ border-color: var(--border-strong);
+}
+.thumb-label {
+ font-size: 12px;
+ display: flex;
+ justify-content: space-between;
+ gap: 6px;
+ min-width: 0;
+}
+.thumb-label span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.thumb-label .muted {
+ flex: none;
+ font-size: 11px;
+}
+.video-art {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.video-art video,
+.demo-video video {
+ width: 100%;
+ border-radius: 6px;
+ background: #000;
+ display: block;
+ border: 1px solid var(--border);
+}
+.file-art {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--text);
+ min-width: 0;
+}
+.file-art span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.file-art .muted {
+ margin-left: auto;
+ flex: none;
+}
+
+/* Side panel */
+
+.side {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 32px);
+ overflow: auto;
+}
+.side .artifacts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ padding: 8px 12px 12px;
+}
+.side-group-label {
+ padding: 10px 12px 0;
+ font-size: 11.5px;
+ color: var(--text-2);
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+.side-group-label .mono {
+ color: var(--text-3);
+ font-size: 11px;
+}
+
+.live {
+ position: relative;
+ background: var(--sunken);
+ aspect-ratio: 16 / 10;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+}
+.live img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+ background: #fff;
+}
+.live.ended img {
+ opacity: 0.55;
+ filter: grayscale(0.4);
+}
+.live-msg {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--text-3);
+ font-size: 12.5px;
+ text-align: center;
+ padding: 16px;
+}
+.live-url {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-top: 1px solid var(--border);
+ font: 11px/1.4 var(--mono);
+ color: var(--text-2);
+ white-space: nowrap;
+ overflow: hidden;
+}
+.live-url span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.live-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 18px;
+ padding: 0 6px;
+ border-radius: 4px;
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ background: var(--failed);
+ color: #fff;
+}
+.live-badge .d {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #fff;
+ animation: blink 1.2s ease-in-out infinite;
+}
+@keyframes blink {
+ 50% {
+ opacity: 0.3;
+ }
+}
+.live-badge.idle {
+ background: var(--sunken);
+ color: var(--text-3);
+}
+.live-badge.idle .d {
+ background: var(--text-3);
+ animation: none;
+}
+
+.cleanup-cmd {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 1;
+}
+.warnings-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.warnings-list li {
+ padding: 8px 12px;
+ border-top: 1px solid var(--border);
+ font-size: 12.5px;
+ color: var(--text-2);
+ display: flex;
+ gap: 8px;
+}
+.warnings-list li:first-child {
+ border-top: 0;
+}
+.warnings-list svg {
+ flex: none;
+ color: var(--warning);
+ margin-top: 2px;
+}
+
+/* Menus and overlays */
+
+.menu-wrap {
+ position: relative;
+ display: inline-flex;
+}
+.menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ z-index: 30;
+ min-width: 200px;
+ padding: 4px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ box-shadow: var(--shadow-md);
+}
+.menu.left {
+ left: 0;
+ right: auto;
+}
+.menu button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ height: 30px;
+ padding: 0 9px;
+ border: 0;
+ background: none;
+ border-radius: 5px;
+ cursor: pointer;
+ text-align: left;
+ font-size: 12.5px;
+ color: var(--text);
+}
+.menu button:hover,
+.menu button:focus-visible {
+ background: var(--hover);
+ outline: none;
+}
+
+.lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background: var(--scrim);
+ display: grid;
+ place-items: center;
+ padding: 40px;
+ cursor: zoom-out;
+}
+.lightbox figure {
+ margin: 0;
+ max-width: 100%;
+ max-height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+}
+.lightbox img {
+ max-width: 100%;
+ max-height: calc(100vh - 120px);
+ border-radius: 6px;
+ box-shadow: var(--shadow-lg);
+ background: #fff;
+}
+.lightbox figcaption {
+ color: #e8eae8;
+ font-size: 12.5px;
+}
+.lightbox .close {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ color: #fff;
+ background: rgba(255, 255, 255, 0.12);
+ border: 0;
+}
+
+.toast {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 60;
+ background: var(--text);
+ color: var(--bg);
+ padding: 8px 14px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ box-shadow: var(--shadow-md);
+}
+
+/* Runs table */
+
+.table-wrap {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+}
+table.runs {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+table.runs th {
+ text-align: left;
+ font-size: 11px;
+ font-weight: 650;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ padding: 9px 12px;
+ background: var(--surface-2);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+table.runs td {
+ padding: 0 12px;
+ height: 40px;
+ border-top: 1px solid var(--border);
+ white-space: nowrap;
+}
+table.runs tbody tr:first-child td {
+ border-top: 0;
+}
+table.runs tbody tr {
+ cursor: pointer;
+ position: relative;
+}
+table.runs tbody tr:hover td {
+ background: var(--hover);
+}
+table.runs td:first-child {
+ position: relative;
+ padding-left: 15px;
+}
+table.runs td:first-child::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 6px;
+ bottom: 6px;
+ width: 3px;
+ border-radius: 0 2px 2px 0;
+ background: var(--edge, transparent);
+}
+table.runs .status-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-transform: capitalize;
+ height: 40px;
+}
+table.runs .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+table.runs .flow-cell a {
+ color: var(--text);
+ font-weight: 540;
+}
+table.runs .id-cell {
+ color: var(--text-3);
+ font-family: var(--mono);
+ font-size: 11.5px;
+}
+table.runs tr.running td {
+ background: color-mix(in srgb, var(--accent-soft) 55%, transparent);
+}
+
+/* Demos */
+
+.demo-card {
+ padding: 0;
+}
+.demo-card .demo-cover {
+ aspect-ratio: 16 / 9;
+ background: var(--sunken);
+ border-bottom: 1px solid var(--border);
+ overflow: hidden;
+ display: grid;
+ place-items: center;
+ color: var(--text-3);
+ font-size: 12px;
+}
+.demo-card .demo-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: top;
+}
+.demo-card .demo-text {
+ padding: 12px 14px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.demo-card h3 {
+ margin: 0;
+ font-size: 14.5px;
+ font-weight: 620;
+}
+.story {
+ margin: 4px 0 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: beat;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.story li {
+ counter-increment: beat;
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 12.5px;
+ color: var(--text-2);
+}
+.story li::before {
+ content: counter(beat);
+ font: 600 10.5px/16px var(--mono);
+ color: var(--text-3);
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: 1px solid var(--border-strong);
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ transform: translateY(-1px);
+}
+.story.big {
+ gap: 0;
+}
+.story.big li {
+ font-size: 13.5px;
+ padding: 9px 12px;
+ border-top: 1px solid var(--border);
+ align-items: center;
+ color: var(--text-2);
+ transition: background 0.3s, color 0.3s;
+}
+.story.big li:first-child {
+ border-top: 0;
+}
+.story.big li::before {
+ width: 22px;
+ height: 22px;
+ font-size: 11px;
+ line-height: 22px;
+ transition: background 0.3s, color 0.3s, border-color 0.3s;
+}
+.story.big li.lit {
+ color: var(--text);
+}
+.story.big li.lit::before {
+ content: "\2713";
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--on-accent);
+}
+.story.big li.current {
+ background: var(--accent-soft);
+}
+.story.big li .at {
+ margin-left: auto;
+ font: 11px var(--mono);
+ color: var(--text-3);
+}
+.demo-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ gap: 16px;
+ align-items: start;
+}
+.demo-stage {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+.demo-video video {
+ border-radius: 0;
+ border: 0;
+}
+.demo-none {
+ aspect-ratio: 16 / 9;
+ display: grid;
+ place-items: center;
+ text-align: center;
+ color: var(--text-3);
+ background: var(--sunken);
+ padding: 24px;
+}
+.demo-none strong {
+ display: block;
+ color: var(--text);
+ font-size: 14px;
+ margin-bottom: 4px;
+}
+.live.big {
+ aspect-ratio: 16 / 9;
+}
+.extras {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.extras li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-top: 1px solid var(--border);
+ font-size: 12.5px;
+}
+.extras li:first-child {
+ border-top: 0;
+}
+.extras .muted {
+ margin-left: auto;
+ font-size: 11.5px;
+}
+
+/* Settings */
+
+.settings {
+ max-width: 720px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.seg {
+ display: inline-flex;
+ padding: 2px;
+ border-radius: 7px;
+ background: var(--sunken);
+ border: 1px solid var(--border);
+}
+.seg button {
+ height: 28px;
+ padding: 0 14px;
+ border: 0;
+ background: none;
+ border-radius: 5px;
+ cursor: pointer;
+ color: var(--text-2);
+ font-weight: 520;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.seg button[aria-checked="true"] {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: var(--shadow-sm), inset 0 0 0 1px var(--border);
+}
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 16px;
+ border-top: 1px solid var(--border);
+}
+.settings-row:first-child {
+ border-top: 0;
+}
+.settings-row .label {
+ font-weight: 560;
+}
+.settings-row .hint {
+ color: var(--text-3);
+ font-size: 12.5px;
+}
+
+@media (max-width: 1100px) {
+ .run-layout,
+ .demo-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .side {
+ position: static;
+ max-height: none;
+ }
+ .side .artifacts {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ }
+}
+@media (max-width: 760px) {
+ .app {
+ --rail-w: 56px;
+ }
+ .app .rail-label,
+ .app .rail-project,
+ .app .wordmark-text {
+ display: none;
+ }
+ .page {
+ padding: 16px;
+ }
+ .search .input {
+ width: 100%;
+ }
+}
+
+.step-body .artifacts {
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+}
+.step-body .thumb img {
+ aspect-ratio: auto;
+ max-height: 340px;
+ object-fit: contain;
+ object-position: center top;
+}
+
+/* A button that reads as text in a list (demo chapters). */
+.linklike {
+ font: inherit;
+ color: inherit;
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+ text-align: left;
+ text-decoration: underline;
+ text-decoration-color: var(--border-strong);
+ text-underline-offset: 3px;
+}
+.linklike:hover {
+ text-decoration-color: currentColor;
+}
diff --git a/ui/src/test/fixtures/connect-weather.json b/ui/src/test/fixtures/connect-weather.json
new file mode 100644
index 0000000..eb7669f
--- /dev/null
+++ b/ui/src/test/fixtures/connect-weather.json
@@ -0,0 +1,417 @@
+[
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 0,
+ "atMs": 1791378859545,
+ "kind": "runStarted",
+ "flowId": "connect-weather",
+ "flowName": "Connect the weather service",
+ "environment": "local",
+ "runDir": "/tmp/lest-data/runs/connect-weather/20261007T131419Z-j4yh",
+ "steps": [
+ {
+ "id": "sign_in",
+ "name": "Sign in (off camera)",
+ "kind": "flow",
+ "children": [
+ {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser"
+ }
+ ]
+ },
+ {
+ "id": "record",
+ "name": "Record the walkthrough",
+ "kind": "browser"
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 1,
+ "atMs": 1791378859588,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 2,
+ "atMs": 1791378859803,
+ "kind": "stepStarted",
+ "stepId": "sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 3,
+ "atMs": 1791378859803,
+ "kind": "stepStarted",
+ "stepId": "sign_in/login",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 4,
+ "atMs": 1791378860164,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[0] fill ok"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 5,
+ "atMs": 1791378860171,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[1] fill ok"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 6,
+ "atMs": 1791378860210,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[2] click ok"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 7,
+ "atMs": 1791378860240,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[3] expectText ok"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 8,
+ "atMs": 1791378860257,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61412,
+ "targetId": "65FCAA00EEE2D6F470F29A2725DCBC49",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 9,
+ "atMs": 1791378860257,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61412,
+ "targetId": "65FCAA00EEE2D6F470F29A2725DCBC49",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 10,
+ "atMs": 1791378860257,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61412,
+ "targetId": "65FCAA00EEE2D6F470F29A2725DCBC49",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 11,
+ "atMs": 1791378860258,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[4] screenshot ok"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 12,
+ "atMs": 1791378860282,
+ "kind": "stepArtifact",
+ "stepId": "sign_in/login",
+ "artifact": {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49844
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 13,
+ "atMs": 1791378860282,
+ "kind": "browserClosed",
+ "stepId": "sign_in/login"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 14,
+ "atMs": 1791378860282,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:19.803098Z",
+ "finishedAt": "2026-10-07T13:14:20.282345Z",
+ "durationMs": 479,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49844
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 15,
+ "atMs": 1791378860282,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in",
+ "name": "Sign in (off camera)",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:19.803022Z",
+ "finishedAt": "2026-10-07T13:14:20.282374Z",
+ "durationMs": 479,
+ "resolved": "flow sign-in",
+ "children": [
+ {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:19.803098Z",
+ "finishedAt": "2026-10-07T13:14:20.282345Z",
+ "durationMs": 479,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49844
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 16,
+ "atMs": 1791378860282,
+ "kind": "stepStarted",
+ "stepId": "record",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 17,
+ "atMs": 1791378860736,
+ "kind": "browserPage",
+ "stepId": "record",
+ "cdpPort": 61420,
+ "targetId": "B945BDAAD5D839F8CC44BDFF1E35BECA",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 18,
+ "atMs": 1791378860736,
+ "kind": "browserPage",
+ "stepId": "record",
+ "cdpPort": 61420,
+ "targetId": "B945BDAAD5D839F8CC44BDFF1E35BECA",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 19,
+ "atMs": 1791378861935,
+ "kind": "stepBeat",
+ "stepId": "record",
+ "beat": {
+ "marker": "plants",
+ "atMs": 1497
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 20,
+ "atMs": 1791378864036,
+ "kind": "browserPage",
+ "stepId": "record",
+ "cdpPort": 61420,
+ "targetId": "73C04B13E3153D55CB6738AF7A9C8EC9",
+ "url": "http://127.0.0.1:4173/oauth/authorize?client=sprout"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 21,
+ "atMs": 1791378864184,
+ "kind": "stepBeat",
+ "stepId": "record",
+ "beat": {
+ "marker": "consent",
+ "atMs": 3773
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 22,
+ "atMs": 1791378865985,
+ "kind": "browserPage",
+ "stepId": "record",
+ "cdpPort": 61420,
+ "targetId": "73C04B13E3153D55CB6738AF7A9C8EC9",
+ "url": "http://127.0.0.1:4173/oauth/approve"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 23,
+ "atMs": 1791378866586,
+ "kind": "browserPage",
+ "stepId": "record",
+ "cdpPort": 61420,
+ "targetId": "B945BDAAD5D839F8CC44BDFF1E35BECA",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 24,
+ "atMs": 1791378866736,
+ "kind": "stepBeat",
+ "stepId": "record",
+ "beat": {
+ "marker": "connected",
+ "atMs": 6397
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 25,
+ "atMs": 1791378868386,
+ "kind": "stepArtifact",
+ "stepId": "record",
+ "artifact": {
+ "label": "connected",
+ "path": "artifacts/record/connected.png",
+ "mime": "image/png",
+ "bytes": 44679
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 26,
+ "atMs": 1791378868511,
+ "kind": "browserClosed",
+ "stepId": "record"
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 27,
+ "atMs": 1791378868512,
+ "kind": "stepArtifact",
+ "stepId": "record",
+ "artifact": {
+ "label": "Recording",
+ "path": "artifacts/record/video/page@90b270b529d549d14c0658ad2335148b.webm",
+ "mime": "video/webm",
+ "bytes": 252891
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 28,
+ "atMs": 1791378868512,
+ "kind": "stepArtifact",
+ "stepId": "record",
+ "artifact": {
+ "label": "Recording (popup)",
+ "path": "artifacts/record/video/page@a4560dbf571a1b6614c213cbb287ab8c.webm",
+ "mime": "video/webm",
+ "bytes": 75274
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 29,
+ "atMs": 1791378868512,
+ "kind": "stepFinished",
+ "step": {
+ "id": "record",
+ "name": "Record the walkthrough",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:20.282386Z",
+ "finishedAt": "2026-10-07T13:14:28.512129Z",
+ "durationMs": 8229,
+ "resolved": "browser script connect-weather.mjs",
+ "exitCode": 0,
+ "artifacts": [
+ {
+ "label": "connected",
+ "path": "artifacts/record/connected.png",
+ "mime": "image/png",
+ "bytes": 44679
+ },
+ {
+ "label": "Recording",
+ "path": "artifacts/record/video/page@90b270b529d549d14c0658ad2335148b.webm",
+ "mime": "video/webm",
+ "bytes": 252891
+ },
+ {
+ "label": "Recording (popup)",
+ "path": "artifacts/record/video/page@a4560dbf571a1b6614c213cbb287ab8c.webm",
+ "mime": "video/webm",
+ "bytes": 75274
+ }
+ ],
+ "beats": [
+ {
+ "marker": "plants",
+ "atMs": 1497
+ },
+ {
+ "marker": "consent",
+ "atMs": 3773
+ },
+ {
+ "marker": "connected",
+ "atMs": 6397
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131419Z-j4yh",
+ "seq": 30,
+ "atMs": 1791378868513,
+ "kind": "runFinished",
+ "result": "passed",
+ "durationMs": 8968,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/connect-weather/20261007T131419Z-j4yh/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/fixtures/dashboard.json b/ui/src/test/fixtures/dashboard.json
new file mode 100644
index 0000000..d0640db
--- /dev/null
+++ b/ui/src/test/fixtures/dashboard.json
@@ -0,0 +1,363 @@
+[
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 0,
+ "atMs": 1791378856718,
+ "kind": "runStarted",
+ "flowId": "dashboard",
+ "flowName": "Dashboard shows every plant",
+ "environment": "local",
+ "runDir": "/tmp/lest-data/runs/dashboard/20261007T131416Z-01zn",
+ "steps": [
+ {
+ "id": "sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "children": [
+ {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser"
+ }
+ ]
+ },
+ {
+ "id": "open",
+ "name": "Open the dashboard",
+ "kind": "browser"
+ },
+ {
+ "id": "check",
+ "name": "The summary counts the plants",
+ "kind": "assert"
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 1,
+ "atMs": 1791378856897,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 2,
+ "atMs": 1791378857106,
+ "kind": "stepStarted",
+ "stepId": "sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 3,
+ "atMs": 1791378857106,
+ "kind": "stepStarted",
+ "stepId": "sign_in/login",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 4,
+ "atMs": 1791378857410,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61393,
+ "targetId": "9357D06B5CDA9A6FDF8BAEA7079546AE",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 5,
+ "atMs": 1791378857432,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[0] fill ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 6,
+ "atMs": 1791378857440,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[1] fill ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 7,
+ "atMs": 1791378857465,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[2] click ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 8,
+ "atMs": 1791378857495,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[3] expectText ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 9,
+ "atMs": 1791378857514,
+ "kind": "stepOutput",
+ "stepId": "sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[4] screenshot ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 10,
+ "atMs": 1791378857540,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61393,
+ "targetId": "9357D06B5CDA9A6FDF8BAEA7079546AE",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 11,
+ "atMs": 1791378857540,
+ "kind": "browserPage",
+ "stepId": "sign_in/login",
+ "cdpPort": 61393,
+ "targetId": "9357D06B5CDA9A6FDF8BAEA7079546AE",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 12,
+ "atMs": 1791378857540,
+ "kind": "stepArtifact",
+ "stepId": "sign_in/login",
+ "artifact": {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49828
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 13,
+ "atMs": 1791378857540,
+ "kind": "browserClosed",
+ "stepId": "sign_in/login"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 14,
+ "atMs": 1791378857540,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:17.106457Z",
+ "finishedAt": "2026-10-07T13:14:17.540265Z",
+ "durationMs": 433,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49828
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 15,
+ "atMs": 1791378857540,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:17.106415Z",
+ "finishedAt": "2026-10-07T13:14:17.540295Z",
+ "durationMs": 433,
+ "resolved": "flow sign-in",
+ "children": [
+ {
+ "id": "sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:17.106457Z",
+ "finishedAt": "2026-10-07T13:14:17.540265Z",
+ "durationMs": 433,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49828
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 16,
+ "atMs": 1791378857540,
+ "kind": "stepStarted",
+ "stepId": "open",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 17,
+ "atMs": 1791378857993,
+ "kind": "browserPage",
+ "stepId": "open",
+ "cdpPort": 61396,
+ "targetId": "2E0835506EA9DD63F481C01F61654156",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 18,
+ "atMs": 1791378857993,
+ "kind": "browserPage",
+ "stepId": "open",
+ "cdpPort": 61396,
+ "targetId": "2E0835506EA9DD63F481C01F61654156",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 19,
+ "atMs": 1791378858656,
+ "kind": "stepOutput",
+ "stepId": "open",
+ "stream": "browser",
+ "line": "[browser] actions[0] waitFor ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 20,
+ "atMs": 1791378858662,
+ "kind": "stepOutput",
+ "stepId": "open",
+ "stream": "browser",
+ "line": "[browser] actions[1] read ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 21,
+ "atMs": 1791378858686,
+ "kind": "stepOutput",
+ "stepId": "open",
+ "stream": "browser",
+ "line": "[browser] actions[2] screenshot ok"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 22,
+ "atMs": 1791378858712,
+ "kind": "stepArtifact",
+ "stepId": "open",
+ "artifact": {
+ "label": "dashboard",
+ "path": "artifacts/open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 42128
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 23,
+ "atMs": 1791378858712,
+ "kind": "browserClosed",
+ "stepId": "open"
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 24,
+ "atMs": 1791378858712,
+ "kind": "stepFinished",
+ "step": {
+ "id": "open",
+ "name": "Open the dashboard",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:17.540306Z",
+ "finishedAt": "2026-10-07T13:14:18.712216Z",
+ "durationMs": 1171,
+ "resolved": "browser http://127.0.0.1:4173/app (3 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] waitFor ok\n[browser] actions[1] read ok\n[browser] actions[2] screenshot ok\n",
+ "outputs": {
+ "summary": "3 plants, 1 needs water"
+ },
+ "artifacts": [
+ {
+ "label": "dashboard",
+ "path": "artifacts/open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 42128
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 25,
+ "atMs": 1791378858712,
+ "kind": "stepStarted",
+ "stepId": "check",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 26,
+ "atMs": 1791378858712,
+ "kind": "stepFinished",
+ "step": {
+ "id": "check",
+ "name": "The summary counts the plants",
+ "kind": "assert",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:18.712243Z",
+ "finishedAt": "2026-10-07T13:14:18.71292Z",
+ "durationMs": 0,
+ "resolved": "steps.open.outputs.summary.matches('^[0-9]+ plants, [0-9]+ needs? water$')"
+ }
+ },
+ {
+ "runId": "20261007T131416Z-01zn",
+ "seq": 27,
+ "atMs": 1791378858715,
+ "kind": "runFinished",
+ "result": "passed",
+ "durationMs": 1996,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/dashboard/20261007T131416Z-01zn/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/fixtures/everything.json b/ui/src/test/fixtures/everything.json
new file mode 100644
index 0000000..58a6727
--- /dev/null
+++ b/ui/src/test/fixtures/everything.json
@@ -0,0 +1,977 @@
+[
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 0,
+ "atMs": 1791378869171,
+ "kind": "runStarted",
+ "flowId": "everything",
+ "flowName": "Everything",
+ "environment": null,
+ "runDir": "/tmp/lest-data/runs/everything/20261007T131429Z-samf",
+ "steps": [
+ {
+ "id": "checks",
+ "name": "Independent checks",
+ "kind": "group",
+ "children": [
+ {
+ "id": "api",
+ "name": "api",
+ "kind": "flow",
+ "children": [
+ {
+ "id": "api/sign_in",
+ "name": "Sign in through the API",
+ "kind": "http"
+ },
+ {
+ "id": "api/add_plant",
+ "name": "Add a plant",
+ "kind": "http"
+ },
+ {
+ "id": "api/sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ },
+ {
+ "id": "api/listed",
+ "name": "The plant is listed",
+ "kind": "run"
+ }
+ ]
+ },
+ {
+ "id": "web",
+ "name": "web",
+ "kind": "flow",
+ "children": [
+ {
+ "id": "web/sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "children": [
+ {
+ "id": "web/sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser"
+ }
+ ]
+ },
+ {
+ "id": "web/open",
+ "name": "Open the dashboard",
+ "kind": "browser"
+ },
+ {
+ "id": "web/check",
+ "name": "The summary counts the plants",
+ "kind": "assert"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 1,
+ "atMs": 1791378869202,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 2,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "checks",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 3,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "api",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 4,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "api/sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 5,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "web",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 6,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "web/sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 7,
+ "atMs": 1791378869412,
+ "kind": "stepStarted",
+ "stepId": "web/sign_in/login",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 8,
+ "atMs": 1791378869415,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api/sign_in",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412156Z",
+ "finishedAt": "2026-10-07T13:14:29.415208Z",
+ "durationMs": 3,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"token\":\"6e2da47a-8a3a-4f38-b7bf-369412bf41f8\",\"user\":{\"id\":\"u-1\",\"email\":\"demo@example.com\",\"name\":\"Robin\"}}",
+ "httpStatus": 200,
+ "outputs": {
+ "token": "6e2da47a-8a3a-4f38-b7bf-369412bf41f8"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 9,
+ "atMs": 1791378869415,
+ "kind": "stepStarted",
+ "stepId": "api/add_plant",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 10,
+ "atMs": 1791378869416,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api/add_plant",
+ "name": "Add a plant",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.415242Z",
+ "finishedAt": "2026-10-07T13:14:29.416515Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/plants",
+ "stdout": "{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}",
+ "httpStatus": 201,
+ "outputs": {
+ "id": "p-4"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 11,
+ "atMs": 1791378869416,
+ "kind": "stepStarted",
+ "stepId": "api/sensor_online",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 12,
+ "atMs": 1791378869418,
+ "kind": "stepRetrying",
+ "stepId": "api/sensor_online",
+ "attempt": 1,
+ "maxAttempts": 6,
+ "reason": "`self.outputs.sensor == 'online'` is not true yet",
+ "nextAttemptInMs": 300
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 13,
+ "atMs": 1791378869719,
+ "kind": "stepStarted",
+ "stepId": "api/sensor_online",
+ "attempt": 2
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 14,
+ "atMs": 1791378869721,
+ "kind": "stepRetrying",
+ "stepId": "api/sensor_online",
+ "attempt": 2,
+ "maxAttempts": 6,
+ "reason": "`self.outputs.sensor == 'online'` is not true yet",
+ "nextAttemptInMs": 300
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 15,
+ "atMs": 1791378869749,
+ "kind": "stepOutput",
+ "stepId": "web/sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[0] fill ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 16,
+ "atMs": 1791378869758,
+ "kind": "stepOutput",
+ "stepId": "web/sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[1] fill ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 17,
+ "atMs": 1791378869799,
+ "kind": "stepOutput",
+ "stepId": "web/sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[2] click ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 18,
+ "atMs": 1791378869829,
+ "kind": "stepOutput",
+ "stepId": "web/sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[3] expectText ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 19,
+ "atMs": 1791378869844,
+ "kind": "stepOutput",
+ "stepId": "web/sign_in/login",
+ "stream": "browser",
+ "line": "[browser] actions[4] screenshot ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 20,
+ "atMs": 1791378869866,
+ "kind": "browserPage",
+ "stepId": "web/sign_in/login",
+ "cdpPort": 61462,
+ "targetId": "FB706016E032BF7AA325B0DDE810D845",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 21,
+ "atMs": 1791378869866,
+ "kind": "browserPage",
+ "stepId": "web/sign_in/login",
+ "cdpPort": 61462,
+ "targetId": "FB706016E032BF7AA325B0DDE810D845",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 22,
+ "atMs": 1791378869866,
+ "kind": "browserPage",
+ "stepId": "web/sign_in/login",
+ "cdpPort": 61462,
+ "targetId": "FB706016E032BF7AA325B0DDE810D845",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 23,
+ "atMs": 1791378869866,
+ "kind": "stepArtifact",
+ "stepId": "web/sign_in/login",
+ "artifact": {
+ "label": "signed-in",
+ "path": "artifacts/web__sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49823
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 24,
+ "atMs": 1791378869868,
+ "kind": "browserClosed",
+ "stepId": "web/sign_in/login"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 25,
+ "atMs": 1791378869868,
+ "kind": "stepFinished",
+ "step": {
+ "id": "web/sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412556Z",
+ "finishedAt": "2026-10-07T13:14:29.868765Z",
+ "durationMs": 456,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/web__sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49823
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 26,
+ "atMs": 1791378869868,
+ "kind": "stepFinished",
+ "step": {
+ "id": "web/sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412548Z",
+ "finishedAt": "2026-10-07T13:14:29.868801Z",
+ "durationMs": 456,
+ "resolved": "flow sign-in",
+ "children": [
+ {
+ "id": "web/sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412556Z",
+ "finishedAt": "2026-10-07T13:14:29.868765Z",
+ "durationMs": 456,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/web__sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49823
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 27,
+ "atMs": 1791378869868,
+ "kind": "stepStarted",
+ "stepId": "web/open",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 28,
+ "atMs": 1791378870022,
+ "kind": "stepStarted",
+ "stepId": "api/sensor_online",
+ "attempt": 3
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 29,
+ "atMs": 1791378870024,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api/sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 3,
+ "startedAt": "2026-10-07T13:14:29.416853Z",
+ "finishedAt": "2026-10-07T13:14:30.024867Z",
+ "durationMs": 608,
+ "resolved": "GET http://127.0.0.1:4173/api/plants/p-4/status",
+ "stdout": "{\"id\":\"p-4\",\"sensor\":\"online\",\"polls\":3}",
+ "httpStatus": 200,
+ "outputs": {
+ "sensor": "online"
+ },
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 30,
+ "atMs": 1791378870024,
+ "kind": "stepStarted",
+ "stepId": "api/listed",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 31,
+ "atMs": 1791378870322,
+ "kind": "browserPage",
+ "stepId": "web/open",
+ "cdpPort": 61471,
+ "targetId": "35228B198A50268D574F63894A483534",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 32,
+ "atMs": 1791378870322,
+ "kind": "browserPage",
+ "stepId": "web/open",
+ "cdpPort": 61471,
+ "targetId": "35228B198A50268D574F63894A483534",
+ "url": "http://127.0.0.1:4173/app"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 33,
+ "atMs": 1791378870764,
+ "kind": "stepOutput",
+ "stepId": "api/listed",
+ "stream": "stdout",
+ "line": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 34,
+ "atMs": 1791378870765,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api/listed",
+ "name": "The plant is listed",
+ "kind": "run",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:30.024902Z",
+ "finishedAt": "2026-10-07T13:14:30.765032Z",
+ "durationMs": 740,
+ "resolved": "curl -fsS -H \"Authorization: Bearer $TOKEN\" \"$base_url/api/plants\"",
+ "exitCode": 0,
+ "stdout": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}",
+ "outputs": {
+ "names": [
+ "Monstera",
+ "Fiddle-leaf fig",
+ "Snake plant",
+ "Basil"
+ ]
+ }
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 35,
+ "atMs": 1791378870765,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api",
+ "name": "api",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412136Z",
+ "finishedAt": "2026-10-07T13:14:30.7651Z",
+ "durationMs": 1352,
+ "resolved": "flow plants-api",
+ "children": [
+ {
+ "id": "api/sign_in",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412156Z",
+ "finishedAt": "2026-10-07T13:14:29.415208Z",
+ "durationMs": 3,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"token\":\"6e2da47a-8a3a-4f38-b7bf-369412bf41f8\",\"user\":{\"id\":\"u-1\",\"email\":\"demo@example.com\",\"name\":\"Robin\"}}",
+ "httpStatus": 200,
+ "outputs": {
+ "token": "6e2da47a-8a3a-4f38-b7bf-369412bf41f8"
+ }
+ },
+ {
+ "id": "api/add_plant",
+ "name": "Add a plant",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.415242Z",
+ "finishedAt": "2026-10-07T13:14:29.416515Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/plants",
+ "stdout": "{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}",
+ "httpStatus": 201,
+ "outputs": {
+ "id": "p-4"
+ }
+ },
+ {
+ "id": "api/sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 3,
+ "startedAt": "2026-10-07T13:14:29.416853Z",
+ "finishedAt": "2026-10-07T13:14:30.024867Z",
+ "durationMs": 608,
+ "resolved": "GET http://127.0.0.1:4173/api/plants/p-4/status",
+ "stdout": "{\"id\":\"p-4\",\"sensor\":\"online\",\"polls\":3}",
+ "httpStatus": 200,
+ "outputs": {
+ "sensor": "online"
+ },
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ },
+ {
+ "id": "api/listed",
+ "name": "The plant is listed",
+ "kind": "run",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:30.024902Z",
+ "finishedAt": "2026-10-07T13:14:30.765032Z",
+ "durationMs": 740,
+ "resolved": "curl -fsS -H \"Authorization: Bearer $TOKEN\" \"$base_url/api/plants\"",
+ "exitCode": 0,
+ "stdout": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}",
+ "outputs": {
+ "names": [
+ "Monstera",
+ "Fiddle-leaf fig",
+ "Snake plant",
+ "Basil"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 36,
+ "atMs": 1791378870994,
+ "kind": "stepOutput",
+ "stepId": "web/open",
+ "stream": "browser",
+ "line": "[browser] actions[0] waitFor ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 37,
+ "atMs": 1791378870996,
+ "kind": "stepOutput",
+ "stepId": "web/open",
+ "stream": "browser",
+ "line": "[browser] actions[1] read ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 38,
+ "atMs": 1791378871014,
+ "kind": "stepOutput",
+ "stepId": "web/open",
+ "stream": "browser",
+ "line": "[browser] actions[2] screenshot ok"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 39,
+ "atMs": 1791378871039,
+ "kind": "stepArtifact",
+ "stepId": "web/open",
+ "artifact": {
+ "label": "dashboard",
+ "path": "artifacts/web__open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 46559
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 40,
+ "atMs": 1791378871039,
+ "kind": "browserClosed",
+ "stepId": "web/open"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 41,
+ "atMs": 1791378871039,
+ "kind": "stepFinished",
+ "step": {
+ "id": "web/open",
+ "name": "Open the dashboard",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.868816Z",
+ "finishedAt": "2026-10-07T13:14:31.039069Z",
+ "durationMs": 1170,
+ "resolved": "browser http://127.0.0.1:4173/app (3 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] waitFor ok\n[browser] actions[1] read ok\n[browser] actions[2] screenshot ok\n",
+ "outputs": {
+ "summary": "4 plants, 1 needs water"
+ },
+ "artifacts": [
+ {
+ "label": "dashboard",
+ "path": "artifacts/web__open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 46559
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 42,
+ "atMs": 1791378871039,
+ "kind": "stepStarted",
+ "stepId": "web/check",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 43,
+ "atMs": 1791378871039,
+ "kind": "stepFinished",
+ "step": {
+ "id": "web/check",
+ "name": "The summary counts the plants",
+ "kind": "assert",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:31.039096Z",
+ "finishedAt": "2026-10-07T13:14:31.039699Z",
+ "durationMs": 0,
+ "resolved": "steps.open.outputs.summary.matches('^[0-9]+ plants, [0-9]+ needs? water$')"
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 44,
+ "atMs": 1791378871039,
+ "kind": "stepFinished",
+ "step": {
+ "id": "web",
+ "name": "web",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412538Z",
+ "finishedAt": "2026-10-07T13:14:31.039722Z",
+ "durationMs": 1627,
+ "resolved": "flow dashboard",
+ "children": [
+ {
+ "id": "web/sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412548Z",
+ "finishedAt": "2026-10-07T13:14:29.868801Z",
+ "durationMs": 456,
+ "resolved": "flow sign-in",
+ "children": [
+ {
+ "id": "web/sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412556Z",
+ "finishedAt": "2026-10-07T13:14:29.868765Z",
+ "durationMs": 456,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/web__sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49823
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "web/open",
+ "name": "Open the dashboard",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.868816Z",
+ "finishedAt": "2026-10-07T13:14:31.039069Z",
+ "durationMs": 1170,
+ "resolved": "browser http://127.0.0.1:4173/app (3 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] waitFor ok\n[browser] actions[1] read ok\n[browser] actions[2] screenshot ok\n",
+ "outputs": {
+ "summary": "4 plants, 1 needs water"
+ },
+ "artifacts": [
+ {
+ "label": "dashboard",
+ "path": "artifacts/web__open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 46559
+ }
+ ]
+ },
+ {
+ "id": "web/check",
+ "name": "The summary counts the plants",
+ "kind": "assert",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:31.039096Z",
+ "finishedAt": "2026-10-07T13:14:31.039699Z",
+ "durationMs": 0,
+ "resolved": "steps.open.outputs.summary.matches('^[0-9]+ plants, [0-9]+ needs? water$')"
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 45,
+ "atMs": 1791378871039,
+ "kind": "stepFinished",
+ "step": {
+ "id": "checks",
+ "name": "Independent checks",
+ "kind": "group",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412114Z",
+ "finishedAt": "2026-10-07T13:14:31.039741Z",
+ "durationMs": 1627,
+ "children": [
+ {
+ "id": "api",
+ "name": "api",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412136Z",
+ "finishedAt": "2026-10-07T13:14:30.7651Z",
+ "durationMs": 1352,
+ "resolved": "flow plants-api",
+ "children": [
+ {
+ "id": "api/sign_in",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412156Z",
+ "finishedAt": "2026-10-07T13:14:29.415208Z",
+ "durationMs": 3,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"token\":\"6e2da47a-8a3a-4f38-b7bf-369412bf41f8\",\"user\":{\"id\":\"u-1\",\"email\":\"demo@example.com\",\"name\":\"Robin\"}}",
+ "httpStatus": 200,
+ "outputs": {
+ "token": "6e2da47a-8a3a-4f38-b7bf-369412bf41f8"
+ }
+ },
+ {
+ "id": "api/add_plant",
+ "name": "Add a plant",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.415242Z",
+ "finishedAt": "2026-10-07T13:14:29.416515Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/plants",
+ "stdout": "{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}",
+ "httpStatus": 201,
+ "outputs": {
+ "id": "p-4"
+ }
+ },
+ {
+ "id": "api/sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 3,
+ "startedAt": "2026-10-07T13:14:29.416853Z",
+ "finishedAt": "2026-10-07T13:14:30.024867Z",
+ "durationMs": 608,
+ "resolved": "GET http://127.0.0.1:4173/api/plants/p-4/status",
+ "stdout": "{\"id\":\"p-4\",\"sensor\":\"online\",\"polls\":3}",
+ "httpStatus": 200,
+ "outputs": {
+ "sensor": "online"
+ },
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ },
+ {
+ "id": "api/listed",
+ "name": "The plant is listed",
+ "kind": "run",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:30.024902Z",
+ "finishedAt": "2026-10-07T13:14:30.765032Z",
+ "durationMs": 740,
+ "resolved": "curl -fsS -H \"Authorization: Bearer $TOKEN\" \"$base_url/api/plants\"",
+ "exitCode": 0,
+ "stdout": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}",
+ "outputs": {
+ "names": [
+ "Monstera",
+ "Fiddle-leaf fig",
+ "Snake plant",
+ "Basil"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "id": "web",
+ "name": "web",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412538Z",
+ "finishedAt": "2026-10-07T13:14:31.039722Z",
+ "durationMs": 1627,
+ "resolved": "flow dashboard",
+ "children": [
+ {
+ "id": "web/sign_in",
+ "name": "sign_in",
+ "kind": "flow",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412548Z",
+ "finishedAt": "2026-10-07T13:14:29.868801Z",
+ "durationMs": 456,
+ "resolved": "flow sign-in",
+ "children": [
+ {
+ "id": "web/sign_in/login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.412556Z",
+ "finishedAt": "2026-10-07T13:14:29.868765Z",
+ "durationMs": 456,
+ "resolved": "browser http://127.0.0.1:4173/login (5 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\n[browser] actions[3] expectText ok\n[browser] actions[4] screenshot ok\n",
+ "artifacts": [
+ {
+ "label": "signed-in",
+ "path": "artifacts/web__sign_in__login/signed-in.png",
+ "mime": "image/png",
+ "bytes": 49823
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "web/open",
+ "name": "Open the dashboard",
+ "kind": "browser",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:29.868816Z",
+ "finishedAt": "2026-10-07T13:14:31.039069Z",
+ "durationMs": 1170,
+ "resolved": "browser http://127.0.0.1:4173/app (3 actions)",
+ "exitCode": 0,
+ "stderr": "[browser] actions[0] waitFor ok\n[browser] actions[1] read ok\n[browser] actions[2] screenshot ok\n",
+ "outputs": {
+ "summary": "4 plants, 1 needs water"
+ },
+ "artifacts": [
+ {
+ "label": "dashboard",
+ "path": "artifacts/web__open/dashboard.png",
+ "mime": "image/png",
+ "bytes": 46559
+ }
+ ]
+ },
+ {
+ "id": "web/check",
+ "name": "The summary counts the plants",
+ "kind": "assert",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:31.039096Z",
+ "finishedAt": "2026-10-07T13:14:31.039699Z",
+ "durationMs": 0,
+ "resolved": "steps.open.outputs.summary.matches('^[0-9]+ plants, [0-9]+ needs? water$')"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 46,
+ "atMs": 1791378871039,
+ "kind": "cleanupStarted",
+ "stepId": "api/add_plant"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 47,
+ "atMs": 1791378871059,
+ "kind": "cleanupFinished",
+ "stepId": "api/add_plant",
+ "status": "passed"
+ },
+ {
+ "runId": "20261007T131429Z-samf",
+ "seq": 48,
+ "atMs": 1791378871061,
+ "kind": "runFinished",
+ "result": "passed",
+ "durationMs": 1889,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/everything/20261007T131429Z-samf/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/fixtures/plants-api-cancelled.json b/ui/src/test/fixtures/plants-api-cancelled.json
new file mode 100644
index 0000000..ef98f9c
--- /dev/null
+++ b/ui/src/test/fixtures/plants-api-cancelled.json
@@ -0,0 +1,188 @@
+[
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 0,
+ "atMs": 1791378906589,
+ "kind": "runStarted",
+ "flowId": "plants-api",
+ "flowName": "Plants API",
+ "environment": "local",
+ "runDir": "/tmp/lest-data/runs/plants-api/20261007T131506Z-d7mf",
+ "steps": [
+ {
+ "id": "sign_in",
+ "name": "Sign in through the API",
+ "kind": "http"
+ },
+ {
+ "id": "add_plant",
+ "name": "Add a plant",
+ "kind": "http"
+ },
+ {
+ "id": "sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ },
+ {
+ "id": "listed",
+ "name": "The plant is listed",
+ "kind": "run"
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 1,
+ "atMs": 1791378906629,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 2,
+ "atMs": 1791378906838,
+ "kind": "stepStarted",
+ "stepId": "sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 3,
+ "atMs": 1791378906840,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:15:06.838983Z",
+ "finishedAt": "2026-10-07T13:15:06.840847Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"token\":\"9caa5222-b31d-4c29-8c9c-bd3a9bda9aa8\",\"user\":{\"id\":\"u-1\",\"email\":\"demo@example.com\",\"name\":\"Robin\"}}",
+ "httpStatus": 200,
+ "outputs": {
+ "token": "9caa5222-b31d-4c29-8c9c-bd3a9bda9aa8"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 4,
+ "atMs": 1791378906840,
+ "kind": "stepStarted",
+ "stepId": "add_plant",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 5,
+ "atMs": 1791378906842,
+ "kind": "stepFinished",
+ "step": {
+ "id": "add_plant",
+ "name": "Add a plant",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:15:06.840883Z",
+ "finishedAt": "2026-10-07T13:15:06.842427Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/plants",
+ "stdout": "{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}",
+ "httpStatus": 201,
+ "outputs": {
+ "id": "p-4"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 6,
+ "atMs": 1791378906842,
+ "kind": "stepStarted",
+ "stepId": "sensor_online",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 7,
+ "atMs": 1791378906844,
+ "kind": "stepRetrying",
+ "stepId": "sensor_online",
+ "attempt": 1,
+ "maxAttempts": 6,
+ "reason": "`self.outputs.sensor == 'online'` is not true yet",
+ "nextAttemptInMs": 300
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 8,
+ "atMs": 1791378907091,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "status": "failed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:15:06.842796Z",
+ "finishedAt": "2026-10-07T13:15:07.09192Z",
+ "durationMs": 249,
+ "resolved": "GET http://127.0.0.1:4173/api/plants/p-4/status",
+ "stdout": "{\"id\":\"p-4\",\"sensor\":\"pairing\",\"polls\":1}",
+ "httpStatus": 200,
+ "outputs": {
+ "sensor": "pairing"
+ },
+ "error": "`self.outputs.sensor == 'online'` was still false after 1 attempt",
+ "headline": "`self.outputs.sensor == 'online'` was still false after 1 attempt",
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ }
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 9,
+ "atMs": 1791378907091,
+ "kind": "stepFinished",
+ "step": {
+ "id": "listed",
+ "name": "The plant is listed",
+ "kind": "run",
+ "status": "skipped",
+ "attempts": 0,
+ "durationMs": 0,
+ "error": "skipped: cancelled"
+ }
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 10,
+ "atMs": 1791378907092,
+ "kind": "cleanupStarted",
+ "stepId": "add_plant"
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 11,
+ "atMs": 1791378907111,
+ "kind": "cleanupFinished",
+ "stepId": "add_plant",
+ "status": "passed"
+ },
+ {
+ "runId": "20261007T131506Z-d7mf",
+ "seq": 12,
+ "atMs": 1791378907113,
+ "kind": "runFinished",
+ "result": "cancelled",
+ "durationMs": 523,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/plants-api/20261007T131506Z-d7mf/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/fixtures/plants-api.json b/ui/src/test/fixtures/plants-api.json
new file mode 100644
index 0000000..7b03b59
--- /dev/null
+++ b/ui/src/test/fixtures/plants-api.json
@@ -0,0 +1,242 @@
+[
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 0,
+ "atMs": 1791378832740,
+ "kind": "runStarted",
+ "flowId": "plants-api",
+ "flowName": "Plants API",
+ "environment": "local",
+ "runDir": "/tmp/lest-data/runs/plants-api/20261007T131352Z-yxqy",
+ "steps": [
+ {
+ "id": "sign_in",
+ "name": "Sign in through the API",
+ "kind": "http"
+ },
+ {
+ "id": "add_plant",
+ "name": "Add a plant",
+ "kind": "http"
+ },
+ {
+ "id": "sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ },
+ {
+ "id": "listed",
+ "name": "The plant is listed",
+ "kind": "run"
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 1,
+ "atMs": 1791378832779,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 2,
+ "atMs": 1791378832993,
+ "kind": "stepStarted",
+ "stepId": "sign_in",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 3,
+ "atMs": 1791378832996,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sign_in",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:13:52.993347Z",
+ "finishedAt": "2026-10-07T13:13:52.996884Z",
+ "durationMs": 3,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"token\":\"93733716-8ce1-4b59-b1e1-9c2c76c9b572\",\"user\":{\"id\":\"u-1\",\"email\":\"demo@example.com\",\"name\":\"Robin\"}}",
+ "httpStatus": 200,
+ "outputs": {
+ "token": "93733716-8ce1-4b59-b1e1-9c2c76c9b572"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 4,
+ "atMs": 1791378832996,
+ "kind": "stepStarted",
+ "stepId": "add_plant",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 5,
+ "atMs": 1791378832999,
+ "kind": "stepFinished",
+ "step": {
+ "id": "add_plant",
+ "name": "Add a plant",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:13:52.996984Z",
+ "finishedAt": "2026-10-07T13:13:52.999362Z",
+ "durationMs": 2,
+ "resolved": "POST http://127.0.0.1:4173/api/plants",
+ "stdout": "{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}",
+ "httpStatus": 201,
+ "outputs": {
+ "id": "p-4"
+ }
+ }
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 6,
+ "atMs": 1791378832999,
+ "kind": "stepStarted",
+ "stepId": "sensor_online",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 7,
+ "atMs": 1791378833002,
+ "kind": "stepRetrying",
+ "stepId": "sensor_online",
+ "attempt": 1,
+ "maxAttempts": 6,
+ "reason": "`self.outputs.sensor == 'online'` is not true yet",
+ "nextAttemptInMs": 300
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 8,
+ "atMs": 1791378833305,
+ "kind": "stepStarted",
+ "stepId": "sensor_online",
+ "attempt": 2
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 9,
+ "atMs": 1791378833308,
+ "kind": "stepRetrying",
+ "stepId": "sensor_online",
+ "attempt": 2,
+ "maxAttempts": 6,
+ "reason": "`self.outputs.sensor == 'online'` is not true yet",
+ "nextAttemptInMs": 300
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 10,
+ "atMs": 1791378833611,
+ "kind": "stepStarted",
+ "stepId": "sensor_online",
+ "attempt": 3
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 11,
+ "atMs": 1791378833622,
+ "kind": "stepFinished",
+ "step": {
+ "id": "sensor_online",
+ "name": "Wait for the sensor to pair",
+ "kind": "http",
+ "status": "passed",
+ "attempts": 3,
+ "startedAt": "2026-10-07T13:13:52.999941Z",
+ "finishedAt": "2026-10-07T13:13:53.621957Z",
+ "durationMs": 622,
+ "resolved": "GET http://127.0.0.1:4173/api/plants/p-4/status",
+ "stdout": "{\"id\":\"p-4\",\"sensor\":\"online\",\"polls\":3}",
+ "httpStatus": 200,
+ "outputs": {
+ "sensor": "online"
+ },
+ "notes": "A new plant's sensor reports \"pairing\" for its first few reads."
+ }
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 12,
+ "atMs": 1791378833622,
+ "kind": "stepStarted",
+ "stepId": "listed",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 13,
+ "atMs": 1791378834395,
+ "kind": "stepOutput",
+ "stepId": "listed",
+ "stream": "stdout",
+ "line": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}"
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 14,
+ "atMs": 1791378834399,
+ "kind": "stepFinished",
+ "step": {
+ "id": "listed",
+ "name": "The plant is listed",
+ "kind": "run",
+ "status": "passed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:13:53.622158Z",
+ "finishedAt": "2026-10-07T13:13:54.399639Z",
+ "durationMs": 777,
+ "resolved": "curl -fsS -H \"Authorization: Bearer $TOKEN\" \"$base_url/api/plants\"",
+ "exitCode": 0,
+ "stdout": "{\"items\":[{\"id\":\"p-1\",\"name\":\"Monstera\",\"room\":\"Living room\",\"moisture\":62},{\"id\":\"p-2\",\"name\":\"Fiddle-leaf fig\",\"room\":\"Hallway\",\"moisture\":38},{\"id\":\"p-3\",\"name\":\"Snake plant\",\"room\":\"Bedroom\",\"moisture\":21},{\"id\":\"p-4\",\"name\":\"Basil\",\"room\":\"Kitchen\",\"moisture\":55}]}",
+ "outputs": {
+ "names": [
+ "Monstera",
+ "Fiddle-leaf fig",
+ "Snake plant",
+ "Basil"
+ ]
+ }
+ }
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 15,
+ "atMs": 1791378834399,
+ "kind": "cleanupStarted",
+ "stepId": "add_plant"
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 16,
+ "atMs": 1791378834456,
+ "kind": "cleanupFinished",
+ "stepId": "add_plant",
+ "status": "passed"
+ },
+ {
+ "runId": "20261007T131352Z-yxqy",
+ "seq": 17,
+ "atMs": 1791378834460,
+ "kind": "runFinished",
+ "result": "passed",
+ "durationMs": 1718,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/plants-api/20261007T131352Z-yxqy/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/fixtures/wrong-password.json b/ui/src/test/fixtures/wrong-password.json
new file mode 100644
index 0000000..1e90f76
--- /dev/null
+++ b/ui/src/test/fixtures/wrong-password.json
@@ -0,0 +1,221 @@
+[
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 0,
+ "atMs": 1791378840157,
+ "kind": "runStarted",
+ "flowId": "wrong-password",
+ "flowName": "Wrong password (fails on purpose)",
+ "environment": "local",
+ "runDir": "/tmp/lest-data/runs/wrong-password/20261007T131400Z-na6u",
+ "steps": [
+ {
+ "id": "api_login",
+ "name": "Sign in through the API",
+ "kind": "http"
+ },
+ {
+ "id": "page_login",
+ "name": "Sign in on the login page",
+ "kind": "browser"
+ }
+ ],
+ "finally": [],
+ "tools": []
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 1,
+ "atMs": 1791378840158,
+ "kind": "demoProgress",
+ "message": "starting service sprout"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 2,
+ "atMs": 1791378840366,
+ "kind": "stepStarted",
+ "stepId": "api_login",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 3,
+ "atMs": 1791378840367,
+ "kind": "stepFinished",
+ "step": {
+ "id": "api_login",
+ "name": "Sign in through the API",
+ "kind": "http",
+ "status": "failed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:00.366063Z",
+ "finishedAt": "2026-10-07T13:14:00.367399Z",
+ "durationMs": 1,
+ "resolved": "POST http://127.0.0.1:4173/api/login",
+ "stdout": "{\"error\":\"wrong email or password\"}",
+ "httpStatus": 401,
+ "headline": "HTTP 401 from POST http://127.0.0.1:4173/api/login: wrong email or password",
+ "tolerated": true
+ }
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 4,
+ "atMs": 1791378840367,
+ "kind": "stepStarted",
+ "stepId": "page_login",
+ "attempt": 1
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 5,
+ "atMs": 1791378840878,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "browser",
+ "line": "[browser] actions[0] fill ok"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 6,
+ "atMs": 1791378840887,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "browser",
+ "line": "[browser] actions[1] fill ok"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 7,
+ "atMs": 1791378840931,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "browser",
+ "line": "[browser] actions[2] click ok"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 8,
+ "atMs": 1791378840971,
+ "kind": "browserPage",
+ "stepId": "page_login",
+ "cdpPort": 61320,
+ "targetId": "34338EFB2E1E7D24CEB1BF3EBD600414",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 9,
+ "atMs": 1791378840971,
+ "kind": "browserPage",
+ "stepId": "page_login",
+ "cdpPort": 61320,
+ "targetId": "34338EFB2E1E7D24CEB1BF3EBD600414",
+ "url": "http://127.0.0.1:4173/login"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 10,
+ "atMs": 1791378855944,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "stderr",
+ "line": "Error: expected h1 to contain \"Good morning\", but it never appeared on http://127.0.0.1:4173/login (\"Sign in \u00b7 Sprout\"); the page shows \"Wrong email or password\""
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 11,
+ "atMs": 1791378855944,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "stderr",
+ "line": " at visible (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:132:13)"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 12,
+ "atMs": 1791378855944,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "stderr",
+ "line": " at async Object.expectText (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:174:19)"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 13,
+ "atMs": 1791378855944,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "stderr",
+ "line": " at async Object.phase (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:298:16)"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 14,
+ "atMs": 1791378855944,
+ "kind": "stepOutput",
+ "stepId": "page_login",
+ "stream": "stderr",
+ "line": " at async main (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:326:7)"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 15,
+ "atMs": 1791378855972,
+ "kind": "stepArtifact",
+ "stepId": "page_login",
+ "artifact": {
+ "label": "Screenshot at failure",
+ "path": "artifacts/page_login/failure.png",
+ "mime": "image/png",
+ "bytes": 23428
+ }
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 16,
+ "atMs": 1791378855994,
+ "kind": "browserClosed",
+ "stepId": "page_login"
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 17,
+ "atMs": 1791378855994,
+ "kind": "stepFinished",
+ "step": {
+ "id": "page_login",
+ "name": "Sign in on the login page",
+ "kind": "browser",
+ "status": "failed",
+ "attempts": 1,
+ "startedAt": "2026-10-07T13:14:00.367435Z",
+ "finishedAt": "2026-10-07T13:14:15.99493Z",
+ "durationMs": 15627,
+ "resolved": "browser http://127.0.0.1:4173/login (4 actions)",
+ "exitCode": 1,
+ "stderr": "[browser] actions[0] fill ok\n[browser] actions[1] fill ok\n[browser] actions[2] click ok\nError: expected h1 to contain \"Good morning\", but it never appeared on http://127.0.0.1:4173/login (\"Sign in \u00b7 Sprout\"); the page shows \"Wrong email or password\"\n at visible (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:132:13)\n at async Object.expectText (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:174:19)\n at async Object.phase (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:298:16)\n at async main (file:///tmp/lest-data/harness/lest-harness-bd3620902796.mjs:326:7)\n",
+ "error": "actions[3] expectText: expected h1 to contain \"Good morning\", but it never appeared on http://127.0.0.1:4173/login (\"Sign in \u00b7 Sprout\"); the page shows \"Wrong email or password\"",
+ "headline": "actions[3] expectText: expected h1 to contain \"Good morning\", but it never appeared on http://127.0.0.1:4173/login (\"Sign in \u00b7 Sprout\"); the page shows \"Wrong email or password\"",
+ "artifacts": [
+ {
+ "label": "Screenshot at failure",
+ "path": "artifacts/page_login/failure.png",
+ "mime": "image/png",
+ "bytes": 23428
+ }
+ ]
+ }
+ },
+ {
+ "runId": "20261007T131400Z-na6u",
+ "seq": 18,
+ "atMs": 1791378855997,
+ "kind": "runFinished",
+ "result": "failed",
+ "durationMs": 15838,
+ "error": null,
+ "reportPath": "/tmp/lest-data/runs/wrong-password/20261007T131400Z-na6u/report.json"
+ }
+]
\ No newline at end of file
diff --git a/ui/src/test/lib.test.ts b/ui/src/test/lib.test.ts
new file mode 100644
index 0000000..c71409f
--- /dev/null
+++ b/ui/src/test/lib.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import { buildContext, rerunCommand } from "../lib/context";
+import { formatDuration, shellQuote, timeAgo } from "../lib/format";
+import { parseBlocks, parseInline, plainFirstLine } from "../lib/markdown";
+import type { RunEvent, RunReport, StepReport } from "../types";
+import wrongPassword from "./fixtures/wrong-password.json";
+
+describe("markdown subset", () => {
+ it("splits paragraphs and lists", () => {
+ const blocks = parseBlocks("First line\ncontinues.\n\n- one\n- two\n wrapped\n\n1. a\n2. b");
+ expect(blocks).toEqual([
+ { type: "p", text: "First line continues." },
+ { type: "ul", items: ["one", "two wrapped"] },
+ { type: "ol", items: ["a", "b"] },
+ ]);
+ });
+
+ it("parses code, bold and safe links, and leaves the rest as text", () => {
+ expect(parseInline("saves `sprout` **now** [docs](https://example.com)")).toEqual([
+ { type: "text", text: "saves " },
+ { type: "code", text: "sprout" },
+ { type: "text", text: " " },
+ { type: "strong", children: [{ type: "text", text: "now" }] },
+ { type: "text", text: " " },
+ { type: "link", href: "https://example.com", children: [{ type: "text", text: "docs" }] },
+ ]);
+ expect(parseInline("[x](javascript:alert(1)) ")).toEqual([
+ { type: "text", text: "[x](javascript:alert(1)) " },
+ ]);
+ });
+
+ it("gives a plain first line for cards", () => {
+ expect(plainFirstLine("Saves the session as `sprout`, so\nothers reuse it.\n\nMore.")).toBe(
+ "Saves the session as sprout, so others reuse it.",
+ );
+ });
+});
+
+describe("format", () => {
+ it("formats durations compactly", () => {
+ expect(formatDuration(622)).toBe("622ms");
+ expect(formatDuration(1718)).toBe("1.7s");
+ expect(formatDuration(15_627)).toBe("16s");
+ expect(formatDuration(125_000)).toBe("2m 5s");
+ });
+ it("formats ages", () => {
+ expect(timeAgo(1_000_000 - 120_000, 1_000_000)).toBe("2 min ago");
+ });
+ it("quotes shell values only when needed", () => {
+ expect(shellQuote("plants-api")).toBe("plants-api");
+ expect(shellQuote("it's here")).toBe(`'it'\\''s here'`);
+ });
+});
+
+describe("copy context", () => {
+ const events = wrongPassword as unknown as RunEvent[];
+ const steps = events
+ .filter((e): e is Extract => e.kind === "stepFinished")
+ .map((e) => e.step) as StepReport[];
+ const report: RunReport = {
+ schemaVersion: 1,
+ runId: events[0]!.runId,
+ flowId: "wrong-password",
+ flowName: "Wrong password (fails on purpose)",
+ flowPath: "flows/troubleshooting/wrong-password.lest.yaml",
+ projectDir: "/work/lest/examples",
+ environment: "local",
+ startedAt: "2026-10-07T13:14:00.157Z",
+ durationMs: 15838,
+ result: "failed",
+ inputs: { plan: "team plan" },
+ steps,
+ };
+ const failed = steps.find((s) => s.id === "page_login")!;
+ const text = buildContext({ report, step: failed, topLevel: "page_login" });
+
+ it("names the flow file, run, failed step and headline", () => {
+ expect(text).toContain("Flow file: /work/lest/examples/flows/troubleshooting/wrong-password.lest.yaml");
+ expect(text).toContain(`Run: ${report.runId} (failed)`);
+ expect(text).toContain('Failed step: page_login "Sign in on the login page" (browser)');
+ expect(text).toContain("Headline: actions[3] expectText");
+ expect(text).toContain("Exit code: 1");
+ expect(text).toContain("Inputs: plan=team plan");
+ });
+
+ it("includes the stderr tail and earlier steps", () => {
+ expect(text).toContain("lines of stderr:");
+ expect(text).toContain("Earlier steps:\n failed api_login");
+ });
+
+ it("ends with a rerun command that reuses the run", () => {
+ expect(text).toContain(
+ `lest run wrong-password --from page_login --resume-run ${report.runId} --env local --input 'plan=team plan'`,
+ );
+ expect(rerunCommand({ flowId: "plants-api", fromStep: "sensor_online" })).toBe(
+ "lest run plants-api --from sensor_online",
+ );
+ });
+});
diff --git a/ui/src/test/runState.test.ts b/ui/src/test/runState.test.ts
new file mode 100644
index 0000000..84eb444
--- /dev/null
+++ b/ui/src/test/runState.test.ts
@@ -0,0 +1,271 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyEvent,
+ applyEvents,
+ applyReport,
+ emptyRun,
+ firstFailure,
+ reduceRuns,
+ runBeats,
+ topLevelOf,
+ type RunState,
+} from "../store/runState";
+import type { RunEvent, RunReport, StepReport } from "../types";
+import plantsApi from "./fixtures/plants-api.json";
+import connectWeather from "./fixtures/connect-weather.json";
+import wrongPassword from "./fixtures/wrong-password.json";
+import everything from "./fixtures/everything.json";
+import cancelled from "./fixtures/plants-api-cancelled.json";
+
+const ev = (x: unknown) => x as RunEvent[];
+
+function run(events: RunEvent[]): RunState {
+ const id = events[0]!.runId;
+ return applyEvents(emptyRun(id), events);
+}
+
+function statuses(r: RunState): Record {
+ return Object.fromEntries(Object.values(r.steps).map((s) => [s.id, s.status]));
+}
+
+describe("a passed API run", () => {
+ const events = ev(plantsApi);
+ const r = run(events);
+
+ it("builds the tree from runStarted and finishes every step", () => {
+ expect(r.roots).toEqual(["sign_in", "add_plant", "sensor_online", "listed"]);
+ expect(statuses(r)).toEqual({
+ sign_in: "passed",
+ add_plant: "passed",
+ sensor_online: "passed",
+ listed: "passed",
+ });
+ expect(r.finished?.result).toBe("passed");
+ expect(r.lastSeq).toBe(events.length - 1);
+ });
+
+ it("records attempts and the cumulative duration of a polled step", () => {
+ const s = r.steps.sensor_online!;
+ expect(s.attempt).toBe(3);
+ expect(s.maxAttempts).toBe(6);
+ expect(s.retry).toBeNull();
+ expect(s.durationMs).toBe(622);
+ expect(s.notes).toContain("pairing");
+ });
+
+ it("shows the retry countdown between attempts", () => {
+ const upTo = events.filter((e) => e.seq <= 7);
+ const mid = run(upTo);
+ const s = mid.steps.sensor_online!;
+ expect(s.status).toBe("running");
+ expect(s.retry).toMatchObject({ attempt: 1, maxAttempts: 6 });
+ expect(s.retry!.nextAtMs).toBe(upTo[7]!.atMs + 300);
+ const next = applyEvent(mid, events[8]!);
+ expect(next.steps.sensor_online!.retry).toBeNull();
+ expect(next.steps.sensor_online!.attempt).toBe(2);
+ expect(next.steps.sensor_online!.startedAtMs).toBe(s.startedAtMs);
+ });
+
+ it("collects live output and cleanup status", () => {
+ expect(r.steps.listed!.output.length).toBe(1);
+ expect(r.steps.listed!.output[0]!.stream).toBe("stdout");
+ expect(r.cleanups).toEqual({ add_plant: "passed" });
+ expect(r.progress.map((p) => p.message)).toEqual(["starting service sprout"]);
+ });
+});
+
+describe("seq handling", () => {
+ const events = ev(plantsApi);
+
+ it("ignores duplicates and stale events", () => {
+ const once = run(events);
+ const twice = events.reduce(applyEvent, once);
+ expect(twice).toBe(once);
+ const replayed = run([...events, ...events.slice(0, 5)]);
+ expect(statuses(replayed)).toEqual(statuses(once));
+ });
+
+ it("marks a gap instead of applying an event after a missing one", () => {
+ const partial = events.slice(0, 4).reduce(applyEvent, emptyRun(events[0]!.runId));
+ const skipped = applyEvent(partial, events[6]!);
+ expect(skipped.gap).toBe(true);
+ expect(skipped.lastSeq).toBe(3);
+ expect(skipped.steps.sensor_online!.status).toBe("pending");
+ // Hydrating with the full list fills the gap.
+ const healed = reduceRuns({ runs: { [partial.runId]: skipped } }, {
+ type: "events",
+ runId: partial.runId,
+ events,
+ }).runs[partial.runId]!;
+ expect(healed.gap).toBe(false);
+ expect(healed.finished?.result).toBe("passed");
+ });
+
+ it("applies events delivered out of order once sorted by hydration", () => {
+ const shuffled = [...events].reverse();
+ expect(statuses(run(shuffled))).toEqual(statuses(run(events)));
+ });
+
+ it("never lets one run's events touch another run", () => {
+ const a = ev(plantsApi);
+ const b = ev(wrongPassword);
+ let state = { runs: {} as Record };
+ const interleaved: RunEvent[] = [];
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ if (a[i]) interleaved.push(a[i]!);
+ if (b[i]) interleaved.push(b[i]!);
+ }
+ for (const e of interleaved) state = reduceRuns(state, { type: "event", event: e });
+ const ra = state.runs[a[0]!.runId]!;
+ const rb = state.runs[b[0]!.runId]!;
+ expect(Object.keys(ra.steps)).toEqual(["sign_in", "add_plant", "sensor_online", "listed"]);
+ expect(Object.keys(rb.steps)).toEqual(["api_login", "page_login"]);
+ expect(ra.finished?.result).toBe("passed");
+ expect(rb.finished?.result).toBe("failed");
+ // A foreign event passed straight to a run's state is ignored.
+ expect(applyEvent(ra, b[3]!)).toBe(ra);
+ });
+});
+
+describe("a failed browser run", () => {
+ const r = run(ev(wrongPassword));
+
+ it("finds the failing leaf step, not the tolerated one", () => {
+ expect(r.steps.api_login!.status).toBe("failed");
+ expect(r.steps.api_login!.report?.tolerated).toBe(true);
+ const f = firstFailure(r)!;
+ expect(f.id).toBe("page_login");
+ expect(f.report?.headline).toContain("Good morning");
+ });
+
+ it("keeps the failure screenshot and the browser output", () => {
+ const s = r.steps.page_login!;
+ expect(s.artifacts.map((a) => a.label)).toEqual(["Screenshot at failure"]);
+ expect(s.output.filter((l) => l.stream === "browser").length).toBe(3);
+ expect(s.output.some((l) => l.stream === "stderr")).toBe(true);
+ });
+
+ it("closes the live view when the browser closes", () => {
+ expect(r.browser?.stepId).toBe("page_login");
+ expect(r.browser?.active).toBe(false);
+ const live = run(ev(wrongPassword).filter((e) => e.seq <= 10));
+ expect(live.browser?.active).toBe(true);
+ expect(live.browser?.cdpPort).toBeGreaterThan(0);
+ });
+});
+
+describe("a demo recording", () => {
+ const r = run(ev(connectWeather));
+
+ it("nests a called flow's steps under the flow step", () => {
+ expect(r.roots).toEqual(["sign_in", "record"]);
+ expect(r.steps.sign_in!.children).toEqual(["sign_in/login"]);
+ expect(r.steps["sign_in/login"]!.parent).toBe("sign_in");
+ expect(topLevelOf(r, "sign_in/login")).toBe("sign_in");
+ });
+
+ it("collects beats and recordings", () => {
+ expect(runBeats(r).map((b) => b.marker)).toEqual(["plants", "consent", "connected"]);
+ const labels = r.steps.record!.artifacts.map((a) => a.label);
+ expect(labels).toContain("Recording");
+ expect(labels).toContain("Recording (popup)");
+ });
+
+ it("follows the active page across a popup", () => {
+ const upToPopup = run(ev(connectWeather).filter((e) => e.seq <= 21));
+ expect(upToPopup.browser?.url).toContain("/oauth/authorize");
+ const back = run(ev(connectWeather).filter((e) => e.seq <= 23));
+ expect(back.browser?.url).toMatch(/\/app$/);
+ });
+});
+
+describe("a suite with parallel flows", () => {
+ const r = run(ev(everything));
+
+ it("tracks interleaved steps of parallel children", () => {
+ expect(r.steps.checks!.children).toEqual(["api", "web"]);
+ expect(r.steps.web!.children).toEqual(["web/sign_in", "web/open", "web/check"]);
+ expect(topLevelOf(r, "web/sign_in/login")).toBe("checks");
+ expect(Object.values(r.steps).every((s) => s.status === "passed")).toBe(true);
+ });
+
+ it("shows both branches running at once mid-run", () => {
+ const mid = run(ev(everything).filter((e) => e.seq <= 14));
+ expect(mid.steps.api!.status).toBe("running");
+ expect(mid.steps["web/sign_in/login"]!.status).toBe("running");
+ expect(mid.steps["api/sensor_online"]!.retry?.attempt).toBe(2);
+ expect(mid.finished).toBeNull();
+ });
+});
+
+describe("a cancelled run", () => {
+ const r = run(ev(cancelled));
+
+ it("marks steps that never ran as skipped and still runs cleanup", () => {
+ expect(r.finished?.result).toBe("cancelled");
+ expect(r.steps.listed!.status).toBe("skipped");
+ expect(r.cleanups.add_plant).toBe("passed");
+ });
+});
+
+describe("reports", () => {
+ const events = ev(wrongPassword);
+ const live = run(events);
+ const steps = events
+ .filter((e): e is Extract => e.kind === "stepFinished")
+ .map((e) => e.step) as StepReport[];
+ const report: RunReport = {
+ schemaVersion: 1,
+ runId: live.runId,
+ flowId: "wrong-password",
+ flowName: "Wrong password (fails on purpose)",
+ flowPath: "flows/troubleshooting/wrong-password.lest.yaml",
+ projectDir: "/work/lest/examples",
+ environment: "local",
+ startedAt: "2026-10-07T13:14:00.157Z",
+ durationMs: 15838,
+ result: "failed",
+ warnings: ["step api_login failed (continueOnError)"],
+ inputs: {},
+ steps,
+ cleanups: [],
+ };
+
+ it("builds a stored run from the report alone", () => {
+ const stored = applyReport(emptyRun(report.runId), report);
+ expect(stored.roots).toEqual(["api_login", "page_login"]);
+ expect(stored.finished?.result).toBe("failed");
+ expect(firstFailure(stored)?.id).toBe("page_login");
+ // A stored run ignores late events.
+ expect(applyEvent(stored, events[0]!)).toBe(stored);
+ });
+
+ it("keeps the live tree when the report arrives after the events", () => {
+ const merged = applyReport(live, report);
+ expect(merged.roots).toEqual(live.roots);
+ expect(merged.report?.warnings?.length).toBe(1);
+ expect(merged.steps.page_login!.output.length).toBe(live.steps.page_login!.output.length);
+ });
+});
+
+describe("hydration during a gap", () => {
+ it("keeps events newer than the snapshot", () => {
+ const ev = (seq: number, body: Record) => ({ runId: "r", seq, atMs: seq, ...body }) as never;
+ const started = ev(0, {
+ kind: "runStarted",
+ flowId: "f",
+ flowName: "F",
+ environment: null,
+ runDir: "/x",
+ steps: [{ id: "a", name: "A", kind: "run" }],
+ finally: [],
+ tools: [],
+ });
+ // The stream delivers seq 2 before the snapshot (seq 0..1) arrives.
+ let s = reduceRuns({ runs: {} }, { type: "event", event: ev(2, { kind: "stepStarted", stepId: "a", attempt: 1 }) });
+ expect(s.runs.r!.gap).toBe(true);
+ s = reduceRuns(s, { type: "events", runId: "r", events: [started, ev(1, { kind: "demoProgress", message: "x" })] });
+ expect(s.runs.r!.lastSeq).toBe(2);
+ expect(s.runs.r!.steps.a!.status).toBe("running");
+ });
+});
diff --git a/ui/src/types.ts b/ui/src/types.ts
new file mode 100644
index 0000000..310c657
--- /dev/null
+++ b/ui/src/types.ts
@@ -0,0 +1,266 @@
+// JSON shapes of the Lest server API (crates/lest-server/src/api.rs) and the
+// run model (crates/lest-core: event.rs, report.rs, store.rs, spec.rs).
+
+export type StepKind = "run" | "http" | "assert" | "browser" | "flow" | "group";
+export type StepStatus = "pending" | "running" | "passed" | "failed" | "errored" | "skipped";
+export type RunResult = "passed" | "failed" | "errored" | "cancelled";
+export type ToolStatus = "ready" | "missing" | "too-old" | "signed-out" | "unknown";
+export type CleanupStatus = "pending" | "passed" | "failed" | "not-run";
+export type OutputStream = "stdout" | "stderr" | "browser";
+export type Scalar = string | number | boolean;
+
+export interface PlanNode {
+ id: string;
+ name: string;
+ kind: StepKind;
+ notes?: string;
+ children?: PlanNode[];
+}
+
+export interface Artifact {
+ label: string;
+ /** Relative to the run directory. */
+ path: string;
+ mime: string;
+ bytes: number;
+}
+
+export interface Beat {
+ marker: string;
+ atMs: number;
+}
+
+export interface ToolReport {
+ name: string;
+ path?: string;
+ version?: string;
+ minVersion?: string;
+ status: ToolStatus;
+ message?: string;
+}
+
+export interface StepReport {
+ id: string;
+ name: string;
+ kind: StepKind | null;
+ status: StepStatus | null;
+ attempts: number;
+ startedAt?: string;
+ finishedAt?: string;
+ durationMs: number;
+ resolved?: string;
+ exitCode?: number;
+ stdout?: string;
+ stderr?: string;
+ httpStatus?: number;
+ outputs?: Record;
+ error?: string;
+ headline?: string;
+ notes?: string;
+ tolerated?: boolean;
+ artifacts?: Artifact[];
+ beats?: Beat[];
+ children?: StepReport[];
+}
+
+export interface CleanupReport {
+ stepId: string;
+ command: string;
+ env: Record;
+ context?: Record;
+ cwd?: string;
+ policy: string;
+ status: CleanupStatus;
+ exitCode?: number;
+ output?: string;
+ error?: string;
+}
+
+export interface DemoChapter {
+ marker: string;
+ label: string;
+ /** Milliseconds into the cut. */
+ atMs: number;
+ still?: string;
+}
+
+export interface DemoReport {
+ video?: string;
+ rawVideo?: string;
+ chaptersVtt?: string;
+ chapters?: DemoChapter[];
+ beatSheet?: string;
+ step?: string;
+ durationMs?: number;
+ rawDurationMs?: number;
+ error?: string;
+}
+
+export interface ServiceReport {
+ id: string;
+ reused: boolean;
+ log?: string;
+}
+
+export interface RunReport {
+ schemaVersion: number;
+ runId: string;
+ flowId: string;
+ flowName: string;
+ flowPath: string;
+ projectDir: string;
+ environment?: string;
+ startedAt: string;
+ finishedAt?: string;
+ durationMs: number;
+ result: RunResult;
+ error?: string;
+ warnings?: string[];
+ inputs: Record;
+ tools?: ToolReport[];
+ steps: StepReport[];
+ finally?: StepReport[];
+ cleanups?: CleanupReport[];
+ services?: ServiceReport[];
+ resumedFrom?: { runId: string; step: string };
+ demo?: DemoReport;
+}
+
+export interface RunSummary {
+ runId: string;
+ flowId: string;
+ flowName: string;
+ environment: string | null;
+ startedAt: string;
+ durationMs: number;
+ result: RunResult;
+ warnings: number;
+ projectDir: string;
+ hasVideo: boolean;
+}
+
+export interface Choice {
+ value: string;
+ label?: string;
+ description?: string;
+}
+
+export interface Input {
+ name: string;
+ label?: string;
+ description?: string;
+ default?: Scalar;
+ required: boolean;
+ choices?: Choice[];
+}
+
+export interface Demo {
+ title?: string;
+ summary?: string;
+ beats?: { marker: string; label: string }[];
+}
+
+export interface Diagnostic {
+ file: string;
+ flow?: string;
+ at?: string;
+ message: string;
+ severity: "error" | "warning";
+}
+
+export interface FlowSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ tags: string[];
+ path: string;
+ folder: string;
+ steps: PlanNode[];
+ finally: PlanNode[];
+ inputs: Input[];
+ environments: string[];
+ defaultEnvironment: string | null;
+ demo: Demo | null;
+ tools: string[];
+ secrets: string[];
+ services: string[];
+ usesBrowser: boolean;
+ records: boolean;
+ lastRun: RunSummary | null;
+}
+
+export interface FlowDetail extends FlowSummary {
+ source: string;
+ diagnostics: Diagnostic[];
+}
+
+export interface FlowsResponse {
+ flows: FlowSummary[];
+ diagnostics: Diagnostic[];
+}
+
+export interface StateResponse {
+ project: { name: string; root: string; hasConfig: boolean };
+ version: string;
+ liveRuns: string[];
+}
+
+export interface RunsResponse {
+ running: { runId: string; flowId: string; startedAt: string; environment: string | null }[];
+ runs: RunSummary[];
+}
+
+export interface RunResponse {
+ runId: string;
+ flowId: string;
+ done: boolean;
+ events: RunEvent[];
+ report: RunReport | null;
+}
+
+interface EventBase {
+ runId: string;
+ seq: number;
+ atMs: number;
+}
+
+export type EventBody =
+ | {
+ kind: "runStarted";
+ flowId: string;
+ flowName: string;
+ environment: string | null;
+ runDir: string;
+ steps: PlanNode[];
+ finally: PlanNode[];
+ tools: string[];
+ }
+ | { kind: "tool"; tool: ToolReport }
+ | { kind: "signInNeeded"; tool: string; command: string }
+ | { kind: "stepStarted"; stepId: string; attempt: number }
+ | { kind: "stepOutput"; stepId: string; stream: OutputStream; line: string }
+ | {
+ kind: "stepRetrying";
+ stepId: string;
+ attempt: number;
+ maxAttempts: number;
+ reason: string;
+ nextAttemptInMs: number;
+ }
+ | { kind: "stepFinished"; step: StepReport }
+ | { kind: "stepArtifact"; stepId: string; artifact: Artifact }
+ | { kind: "stepBeat"; stepId: string; beat: Beat }
+ | { kind: "browserPage"; stepId: string; cdpPort: number; targetId: string; url: string }
+ | { kind: "browserClosed"; stepId: string }
+ | { kind: "cleanupStarted"; stepId: string }
+ | { kind: "cleanupFinished"; stepId: string; status: StepStatus }
+ | { kind: "demoProgress"; message: string }
+ | {
+ kind: "runFinished";
+ result: RunResult;
+ durationMs: number;
+ error: string | null;
+ reportPath: string;
+ };
+
+export type RunEvent = EventBase & EventBody;
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
new file mode 100644
index 0000000..861feff
--- /dev/null
+++ b/ui/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/ui/vite.config.mjs b/ui/vite.config.mjs
new file mode 100644
index 0000000..711440e
--- /dev/null
+++ b/ui/vite.config.mjs
@@ -0,0 +1,26 @@
+// Dev server: proxies /api to a running `lest ui` so the UI can be edited
+// with hot reload. LEST_UI_URL is the address `lest ui` printed; its token is
+// used when LEST_UI_TOKEN is not set.
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+const env = process.env;
+const printed = env.LEST_UI_URL ? new URL(env.LEST_UI_URL) : null;
+const target = printed ? printed.origin : "http://127.0.0.1:4790";
+const token = env.LEST_UI_TOKEN || printed?.searchParams.get("token") || "";
+
+export default defineConfig({
+ plugins: [react()],
+ build: { outDir: "dist", emptyOutDir: true, sourcemap: false },
+ server: {
+ host: "127.0.0.1",
+ proxy: {
+ "/api": {
+ target,
+ changeOrigin: true,
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ },
+ },
+ },
+ test: { include: ["src/**/*.test.ts"], environment: "node" },
+});