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
This commit is contained in:
slhx agent
2026-06-25 13:32:31 +02:00
parent acddea21ee
commit cbb9d68733
3 changed files with 202 additions and 4 deletions
+1 -1
View File
@@ -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.
+3
View File
@@ -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
+198 -3
View File
@@ -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<u16, ExitCode> {
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<HtmlExamplesServer, ExitCode> {
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") {