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:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
- 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
|
- 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.
|
- hemx core stays small: effects, typed ids, registries, and wire schema only.
|
||||||
|
|||||||
@@ -706,6 +706,9 @@ what a valid business email is.
|
|||||||
### req: test/005
|
### 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]
|
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
|
## check
|
||||||
|
|||||||
+198
-3
@@ -1,13 +1,16 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Child, Command, ExitCode, Stdio};
|
||||||
use std::time::Instant;
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let mut args = env::args().skip(1);
|
let mut args = env::args().skip(1);
|
||||||
match args.next().as_deref() {
|
match args.next().as_deref() {
|
||||||
Some("test") | None => run_test_plan(),
|
Some("test") | None => run_test_plan(),
|
||||||
|
Some("html-examples-smoke") => run_html_examples_smoke(),
|
||||||
Some("bench") => run_bench_plan(),
|
Some("bench") => run_bench_plan(),
|
||||||
Some("workout") => {
|
Some("workout") => {
|
||||||
let subcommand = args.next();
|
let subcommand = args.next();
|
||||||
@@ -34,10 +37,202 @@ fn main() -> ExitCode {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
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 {
|
fn run_app(command: Option<&str>, operands: &[String]) -> ExitCode {
|
||||||
// req: ceremony/005 req: ceremony/006 req: canonical_authoring/003
|
// req: ceremony/005 req: ceremony/006 req: canonical_authoring/003
|
||||||
match command.unwrap_or("help") {
|
match command.unwrap_or("help") {
|
||||||
|
|||||||
Reference in New Issue
Block a user