From cbb9d687338527739d64a9cf1f4a8c2e53cb554f Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 25 Jun 2026 13:32:31 +0200 Subject: [PATCH] test(xtask): add html examples browser smoke Add a repo-owned xtask command that starts html_examples, drives a CDP browser through click/save/input/search/load paths, and cleans up the server process. req: test/006 req: examples/001 req: htmx_equivalents/002 --- AGENTS.md | 2 +- REQUIREMENTS.md | 3 + hemx-xtask/src/main.rs | 201 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42c25b3..757a831 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run. - Prefer links or pointers to canonical sources over copied lists. - Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. -- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004 +- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory; use the html_examples smoke for focused browser verification of the HTML pattern gallery. req: test/004 req: test/006 - Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/001 - Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets. req: examples/006 - hemx core stays small: effects, typed ids, registries, and wire schema only. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5a5d118..80ceeb0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -706,6 +706,9 @@ what a valid business email is. ### req: test/005 005 Tests that inspect rendered HTML structure, attributes, escaping, or ordering use DOM-aware parsing such as `scraper` or existing local HTML parsing helpers. Raw string assertions are reserved for tiny literal payload checks where parsing would add noise. [north_star] +### req: test/006 +006 Repo-owned browser smoke entry points that guard examples use `hemx-xtask` commands, start their own local example server, drive a real browser through the CDP browser tool, and clean up the example process; durable browser coverage must not rely on ad hoc `/tmp` scripts. [north_star] + --- ## check diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index 9ad6bb3..c9c1060 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -1,13 +1,16 @@ use std::env; use std::fs; +use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; -use std::time::Instant; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; fn main() -> ExitCode { let mut args = env::args().skip(1); match args.next().as_deref() { Some("test") | None => run_test_plan(), + Some("html-examples-smoke") => run_html_examples_smoke(), Some("bench") => run_bench_plan(), Some("workout") => { let subcommand = args.next(); @@ -34,10 +37,202 @@ fn main() -> ExitCode { fn print_help() { println!( - "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" + "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" ); } +struct HtmlExamplesServer { + child: Child, +} + +impl Drop for HtmlExamplesServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn run_html_examples_smoke() -> ExitCode { + // req: examples/001 req: htmx_equivalents/002 req: test/006 + let port = match pick_unused_port() { + Ok(port) => port, + Err(code) => return code, + }; + let addr = format!("127.0.0.1:{port}"); + let url = format!("http://{addr}"); + let _server = match start_html_examples_server(&addr) { + Ok(server) => server, + Err(code) => return code, + }; + if let Err(code) = ensure_cdp_browser() { + return code; + } + + let checks = [ + ("click-to-edit save", CLICK_TO_EDIT_SMOKE), + ("edit-row save", EDIT_ROW_SMOKE), + ("inline validation revalidation", INLINE_VALIDATION_SMOKE), + ("active search", ACTIVE_SEARCH_SMOKE), + ("delete row", DELETE_ROW_SMOKE), + ("lazy load", LAZY_LOAD_SMOKE), + ("click-to-load load more", CLICK_TO_LOAD_SMOKE), + ("infinite/reveal rows", INFINITE_SCROLL_SMOKE), + ("progress", PROGRESS_SMOKE), + ("value select", VALUE_SELECT_SMOKE), + ("reset user input", RESET_INPUT_SMOKE), + ]; + + for (name, script) in checks { + if let Err(code) = cdp_tab_goto(&url) { + return code; + } + if let Err(code) = cdp_wait_for_html_examples() { + return code; + } + if let Err(code) = cdp_assert(name, script) { + return code; + } + println!("html_examples smoke ok: {name}"); + } + + ExitCode::SUCCESS +} + +fn pick_unused_port() -> Result { + TcpListener::bind("127.0.0.1:0") + .and_then(|listener| listener.local_addr()) + .map(|addr| addr.port()) + .map_err(|err| { + eprintln!("failed to choose local html_examples smoke port: {err}"); + ExitCode::FAILURE + }) +} + +fn start_html_examples_server(addr: &str) -> Result { + let mut child = Command::new("cargo") + .args(["run", "-p", "hemx-html-examples"]) + .env("HEMX_HTML_EXAMPLES_PORT", addr.rsplit(':').next().unwrap_or("3029")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|err| { + eprintln!("failed to start hemx-html-examples: {err}"); + ExitCode::FAILURE + })?; + + for _ in 0..80 { + if TcpStream::connect(addr).is_ok() { + return Ok(HtmlExamplesServer { child }); + } + if let Ok(Some(status)) = child.try_wait() { + eprintln!("hemx-html-examples exited before listening on {addr}: {status}"); + return Err(ExitCode::FAILURE); + } + thread::sleep(Duration::from_millis(250)); + } + + let _ = child.kill(); + let _ = child.wait(); + eprintln!("hemx-html-examples did not listen on {addr} within 20s"); + Err(ExitCode::FAILURE) +} + +fn ensure_cdp_browser() -> Result<(), ExitCode> { + if Command::new("cdp-browser") + .arg("status") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return Ok(()); + } + let status = Command::new("cdp-browser") + .args(["launch", "--port", "9222"]) + .status() + .map_err(|err| { + eprintln!("failed to launch cdp-browser; install/fix browser tooling first: {err}"); + ExitCode::FAILURE + })?; + if status.success() { + Ok(()) + } else { + eprintln!("cdp-browser launch failed with {status}"); + Err(ExitCode::from(status.code().unwrap_or(1) as u8)) + } +} + +fn cdp_tab_goto(url: &str) -> Result<(), ExitCode> { + let status = Command::new("cdp-browser") + .args(["tab-goto", url]) + .stdout(Stdio::null()) + .status() + .map_err(|err| { + eprintln!("failed to navigate browser to {url}: {err}"); + ExitCode::FAILURE + })?; + if status.success() { + Ok(()) + } else { + eprintln!("cdp-browser tab-goto failed with {status}"); + Err(ExitCode::from(status.code().unwrap_or(1) as u8)) + } +} + +fn cdp_wait_for_html_examples() -> Result<(), ExitCode> { + for _ in 0..40 { + let output = Command::new("cdp-browser") + .args([ + "js", + "document.readyState === 'complete' && document.querySelectorAll('form').length >= 11", + ]) + .output() + .map_err(|err| { + eprintln!("failed to poll browser page readiness: {err}"); + ExitCode::FAILURE + })?; + if output.status.success() + && String::from_utf8_lossy(&output.stdout).trim().ends_with("true") + { + return Ok(()); + } + thread::sleep(Duration::from_millis(250)); + } + eprintln!("html_examples page did not become ready within 10s"); + Err(ExitCode::FAILURE) +} + +fn cdp_assert(name: &str, script: &str) -> Result<(), ExitCode> { + let output = Command::new("cdp-browser") + .args(["js", script]) + .output() + .map_err(|err| { + eprintln!("failed to run browser smoke `{name}`: {err}"); + ExitCode::FAILURE + })?; + if output.status.success() { + Ok(()) + } else { + eprintln!("browser smoke `{name}` failed"); + eprintln!("{}", String::from_utf8_lossy(&output.stdout)); + eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + Err(ExitCode::from(output.status.code().unwrap_or(1) as u8)) + } +} + +const CLICK_TO_EDIT_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[0]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.name&&f.elements.name.value==="Ada Lovelace"); save.elements.name.value="Ada Byron"; save.elements.email.value="ada.byron@example.com"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!(text().includes("Ada Byron")&&!text().includes("Ada Lovelace ada@example.com"))) throw new Error("click-to-edit did not save"); return true;})()"#; +const EDIT_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[1]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.title); save.elements.title.value="Write dynamic HTML"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!text().includes("Write dynamic HTML")) throw new Error("edit-row did not save"); return true;})()"#; +const INLINE_VALIDATION_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.getAttribute("data-hemx-on")==="input"); const input=f.elements.email; input.value="wrong"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"g"})); await new Promise(r=>setTimeout(r,700)); const afterBad=document.body.textContent; input.value="xyz@example.com"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"m"})); await new Promise(r=>setTimeout(r,700)); const afterGood=document.body.textContent; if(!(afterBad.includes("Email needs an @ sign")&&!afterGood.includes("Email needs an @ sign")&&afterGood.includes("xyz@example.com is valid"))) throw new Error("inline validation did not recover"); return true;})()"#; +const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.query); f.elements.query.value="beta"; f.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(results.length===2&&results.every(t=>t==="Beta"))) throw new Error("active search did not filter rows"); return true;})()"#; +const DELETE_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const del=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.trim()==="Delete"&&Array.from(f.elements).some(e=>e.name==="id"&&e.value==="1")); del.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(text().includes("Review content")) throw new Error("delete row did not remove row"); return true;})()"#; +const LAZY_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!text().includes("Lazy content loaded")) throw new Error("lazy load did not update"); return true;})()"#; +const CLICK_TO_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load more")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 3")&&text().includes("Loaded row 4"))) throw new Error("click-to-load did not append rows"); return true;})()"#; +const INFINITE_SCROLL_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Reveal more rows")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 4")&&text().includes("Loaded row 6"))) throw new Error("infinite scroll did not append rows"); return true;})()"#; +const PROGRESS_SMOKE: &str = r#"(async()=>{await new Promise(r=>setTimeout(r,700)); const progress=document.querySelector("progress"); if(!(progress&&progress.textContent.includes("% complete")&&progress.textContent!=="0% complete")) throw new Error("progress did not tick"); return true;})()"#; +const VALUE_SELECT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.category); f.elements.category.value="numbers"; f.elements.category.dispatchEvent(new Event("change",{bubbles:true,cancelable:true})); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const options=Array.from(document.querySelectorAll("select[name=value] option")).map(option=>option.textContent.trim()); if(!(options.includes("One")&&options.includes("Two")&&!options.includes("Alpha"))) throw new Error("value select did not replace options"); return true;})()"#; +const RESET_INPUT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.message); f.elements.message.value="hello reset"; f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(document.body.textContent.includes("Sent: hello reset")&&f.elements.message.value==="")) throw new Error("reset user input did not clear field"); return true;})()"#; + fn run_app(command: Option<&str>, operands: &[String]) -> ExitCode { // req: ceremony/005 req: ceremony/006 req: canonical_authoring/003 match command.unwrap_or("help") {