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 + +![Flows explorer](images/flows.png) + +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 + +![A passed run with a step's details open](images/flow-passed.png) + +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](images/flow-failed.png) + +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: + +![A failed step in the dark theme](images/flow-failed-dark.png) + +## Live view + +![A run with the live browser view](images/flow-running-live.png) + +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 + +![Run history](images/runs.png) + +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 + +![A demo after recording](images/demo.png) + +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 + +![Settings](images/settings.png) + +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 = ( +
+
+

Nothing here

+

+ + Go to flows + +

+
+
+ ); + } + + 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 ( + + ); +} 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 ( +
{ + e.preventDefault(); + if (!live) void start(); + }} + > + {flow.environments.length > 0 && ( + + )} + {flow.inputs.map((input) => ( + + ))} + {flow.usesBrowser && ( + + )} +
+ {live && ( + + {stage === "main" + ? "Stopping steps. Cleanup still runs." + : stage === "cleanup" + ? "Skipping cleanup." + : null} + + )} + {live ? ( + + ) : ( + + )} + {error && ( +
+ {error} +
+ )} + + ); +} 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 && ( + + )} + +
+ {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 ( +
+ + {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) => ( + + ))} +
+
+ {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 ( +
+
+ +
{headline}
+ +
+ {s.notes &&
{s.notes}
} +
+ + + , 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 ( + + ); + } + if (a.mime.startsWith("video/")) { + const chapters = + report?.demo?.chaptersVtt && report.demo.video === a.path + ? fileUrl(runId, report.demo.chaptersVtt) + : null; + return ( +
+ + + {a.label} + + {formatBytes(a.bytes)} + + +
+ ); + } + 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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( +
+ + {open && ( +
+ {items.map((it) => ( + + ))} +
+ )} +
+ ); +} + +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 ( +
+ +
e.stopPropagation()}> + {caption} +
{caption}
+
+
+ ); +} + +export function Switch({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; +}) { + return ( + + ); +} 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) => ( +
  1. {renderInline(parseInline(it))}
  2. + ))} +
+ ), + )} +
+ ); +} + +/** 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
Loading demo
; + 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 = ( +
+ +
+ ); + } 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 ( +
+
+ + {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
Loading demos
; + 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) => ( +
  1. {b.label}
  2. + ))} +
+ )} +
+ + + {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 ( +
+
+

Flow not found

+

{error}

+

+ + Back to flows + +

+
+
+ ); + } + if (!flow) return
Loading flow
; + + 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
Loading flows
; + + 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} + +
  • + ))} +
+
+ +
+ )} + + {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. +

+
+ ) : ( + <> +
+ + {allTags.length > 0 && ( +
+ {allTags.map((t) => ( + + ))} +
+ )} +
+ {visible.length === 0 &&

No flow matches.

} + {[...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 ( +
+
+
+
+ + Runs + + + {runId} +
+

+ {flowId ? ( + + {flowName} + + ) : ( + flowName + )} +

+ {run?.report && ( +
+ {run.report.flowId} + + {run.report.flowPath} +
+ )} +
+
+ {live ? ( + + ) : ( + + )} +
+ {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
Loading runs
; + 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.
+
+
+
+ +
+ {RESULTS.map((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."}

+
+ ) : ( +
+ + + + + + + + + + + + + + {!results.length && + running.map((r) => { + const live = getRun(r.runId); + const started = live?.startedAtMs ?? (Date.parse(r.startedAt) || null); + return ( + open(r.runId)}> + + + + + + + + ); + })} + {stored.map((r) => ( + open(r.runId)}> + + + + + + + + + ))} + +
StatusFlowRunEnvironmentStartedDurationWarnings
+ + + running + + + + {names.get(r.flowId) ?? r.flowId} + + {r.runId}{live?.environment ?? r.environment ?? ""}{started ? timeAgo(started, now) : ""}{started ? formatDuration(now - started) : ""} +
+ + + {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) => ( + + ))} +
+
+
+
+
+ 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" }, +});