1775 lines
77 KiB
Rust
1775 lines
77 KiB
Rust
use std::env;
|
|
use std::fs;
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
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();
|
|
let operand = args.next();
|
|
run_workout(subcommand.as_deref(), operand.as_deref())
|
|
}
|
|
Some("app") => {
|
|
let subcommand = args.next();
|
|
let operands = args.collect::<Vec<_>>();
|
|
run_app(subcommand.as_deref(), &operands)
|
|
}
|
|
Some("workout-mobile") => run_workout_mobile(args.next().as_deref()),
|
|
Some("help") | Some("--help") | Some("-h") => {
|
|
print_help();
|
|
ExitCode::SUCCESS
|
|
}
|
|
Some(command) => {
|
|
eprintln!("unknown hemx-ci command `{command}`\n");
|
|
print_help();
|
|
ExitCode::from(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
println!(
|
|
"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/005 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}");
|
|
if let Err(code) = ensure_cdp_browser() {
|
|
return code;
|
|
}
|
|
// Clear any timers from a prior smoke page before a fresh server binds the port.
|
|
if let Err(code) = cdp_tab_goto("about:blank") {
|
|
return code;
|
|
}
|
|
let _server = match start_html_examples_server(&addr) {
|
|
Ok(server) => server,
|
|
Err(code) => return code,
|
|
};
|
|
|
|
let checks = [
|
|
("progress", PROGRESS_SMOKE),
|
|
("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),
|
|
("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}");
|
|
}
|
|
|
|
// Re-load the runtime with IntersectionObserver disabled to exercise the
|
|
// deterministic revealed fallback path in a real browser. req: convention/014 req: test/006
|
|
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(
|
|
"revealed fallback without IntersectionObserver",
|
|
REVEALED_FALLBACK_SMOKE,
|
|
) {
|
|
return code;
|
|
}
|
|
println!("html_examples smoke ok: revealed fallback without IntersectionObserver");
|
|
|
|
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> {
|
|
// Each interaction must be handled by hemx without full-page navigation or
|
|
// reload; failures here catch native form fallback sneaking into the smoke. req: test/014
|
|
let guarded_script = format!(
|
|
r#"(async()=>{{
|
|
const __hemxSmokeUrl = location.href;
|
|
const __hemxSmokeMarker = String(Date.now()) + Math.random();
|
|
const __hemxSmokeNavCount = performance.getEntriesByType("navigation").length;
|
|
window.__hemxSmokeNoReload = __hemxSmokeMarker;
|
|
const __hemxSmokeResult = await ({script});
|
|
if ("{name}" !== "active search" && location.href !== __hemxSmokeUrl) throw new Error("page navigated during smoke interaction");
|
|
if (window.__hemxSmokeNoReload !== __hemxSmokeMarker) throw new Error("page reloaded during smoke interaction");
|
|
if (performance.getEntriesByType("navigation").length !== __hemxSmokeNavCount) throw new Error("navigation entry changed during smoke interaction");
|
|
return __hemxSmokeResult;
|
|
}})()"#
|
|
);
|
|
let output = Command::new("cdp-browser")
|
|
.args(["js", &guarded_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="qwdqdqwd@"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"@"})); await new Promise(r=>setTimeout(r,700)); if(document.body.textContent.includes("qwdqdqwd@ is valid")) throw new Error("partial email was accepted"); if(input.value!=="qwdqdqwd@") throw new Error("inline validation reset partial email"); 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 a name and dotted domain")&&!afterGood.includes("Email needs a name and dotted domain")&&afterGood.includes("xyz@example.com is valid")&&input.value==="xyz@example.com")) throw new Error("inline validation did not recover"); return true;})()"#;
|
|
const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const input=document.querySelector('form input[name="q"]'); if(!input||!input.form) throw new Error("search form not found"); input.value="beta"; input.dispatchEvent(new Event("input",{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(!(location.search.includes("q=beta")&&results.length===1&&results[0]==="Beta")) throw new Error("active search did not filter URL-state 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 f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); const panel=()=>f.nextElementSibling; const before=panel().textContent.trim(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=panel().textContent.trim(); if(!(after.includes("Lazy content loaded by server update #")&&after!==before)) throw new Error("lazy load did not visibly 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 REVEALED_FALLBACK_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const runtime=document.querySelector('script[src*="hemx"][src$=".js"]'); if(!runtime) throw new Error("runtime script not found"); Object.defineProperty(window,"IntersectionObserver",{value:undefined,configurable:true}); const reloaded=document.createElement("script"); reloaded.src=runtime.src; document.head.appendChild(reloaded); for(let i=0;i<20;i++){if(text().includes("Lazy content loaded by server update #")&&text().includes("Loaded row 4")&&text().includes("Loaded row 6")) return true; await new Promise(r=>setTimeout(r,150));} throw new Error("revealed fallback did not dispatch lazy/infinite forms without a click");})()"#;
|
|
const PROGRESS_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Tick progress")); if(f.hasAttribute("data-hemx-interval")) throw new Error("progress form should wait for an explicit click"); const text=()=>document.querySelector("progress").parentElement.textContent.replace(/\s+/g," ").trim(); const before=text(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=text(); if(!(before.includes("0% complete")&&after.includes("25% complete"))) throw new Error("progress click did not visibly tick from 0% to 25%"); 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") {
|
|
"new" | "create" => run_app_new(operands),
|
|
"help" | "--help" | "-h" => {
|
|
print_help();
|
|
ExitCode::SUCCESS
|
|
}
|
|
other => {
|
|
eprintln!("unknown app command `{other}`\n");
|
|
print_help();
|
|
ExitCode::from(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_app_new(operands: &[String]) -> ExitCode {
|
|
// req: ceremony/005 req: ceremony/006 req: canonical_authoring/003
|
|
let (mobile, destination) = match operands {
|
|
[destination] => (false, destination.as_str()),
|
|
[flag, destination] if flag == "--mobile" => (true, destination.as_str()),
|
|
[destination, flag] if flag == "--mobile" => (true, destination.as_str()),
|
|
_ => {
|
|
eprintln!("usage: cargo run -p hemx-xtask -- app new [--mobile] PATH");
|
|
return ExitCode::from(2);
|
|
}
|
|
};
|
|
let destination = PathBuf::from(destination);
|
|
if destination.exists() {
|
|
eprintln!(
|
|
"{} already exists; choose an empty path",
|
|
destination.display()
|
|
);
|
|
return ExitCode::from(2);
|
|
}
|
|
let result = if mobile {
|
|
create_mobile_app_scaffold(&destination)
|
|
} else {
|
|
create_app_scaffold(&destination)
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
println!(
|
|
"{}\tpath={}",
|
|
if mobile { "app-new-mobile" } else { "app-new" },
|
|
destination.display()
|
|
);
|
|
println!("next\tcd {}", destination.display());
|
|
if mobile {
|
|
println!("next\t./hemx-app test");
|
|
println!("next\t./hemx-app build");
|
|
println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release");
|
|
println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify");
|
|
} else {
|
|
println!("next\tcargo test");
|
|
println!("next\tcargo run");
|
|
println!("next\tcargo build --release");
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(err) => {
|
|
eprintln!("failed to create app at {}: {err}", destination.display());
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode {
|
|
// req: examples/001 req: examples/006
|
|
match command.unwrap_or("dev") {
|
|
"new" | "create" => run_workout_new(operand),
|
|
"dev" | "run" => Step::new(
|
|
"workout-dev-server",
|
|
["run", "--bin", "hemx-workout-example"],
|
|
)
|
|
.run(&Budget::detect())
|
|
.map(|()| ExitCode::SUCCESS)
|
|
.unwrap_or_else(|code| code),
|
|
"test" => Step::new("workout-test", ["test", "-p", "hemx-workout-example"])
|
|
.run(&Budget::detect())
|
|
.map(|()| ExitCode::SUCCESS)
|
|
.unwrap_or_else(|code| code),
|
|
"build" | "release-build" => run_workout_release_build("workout-release-build"),
|
|
"mobile-release" => run_workout_mobile_release(),
|
|
"mobile-verify" | "verify" => run_workout_mobile_verify(),
|
|
"doctor" => {
|
|
let config = WorkoutMobileConfig::from_env();
|
|
let blockers = mobile_external_blockers(&config);
|
|
print_mobile_doctor(&config, &blockers);
|
|
ExitCode::SUCCESS
|
|
}
|
|
"help" | "--help" | "-h" => {
|
|
print_help();
|
|
ExitCode::SUCCESS
|
|
}
|
|
other => {
|
|
eprintln!("unknown workout command `{other}`\n");
|
|
print_help();
|
|
ExitCode::from(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_workout_new(destination: Option<&str>) -> ExitCode {
|
|
// req: examples/001 req: examples/006
|
|
let Some(destination) = destination else {
|
|
eprintln!("usage: cargo run -p hemx-xtask -- workout new PATH");
|
|
return ExitCode::from(2);
|
|
};
|
|
let destination = PathBuf::from(destination);
|
|
if destination.exists() {
|
|
eprintln!(
|
|
"{} already exists; choose an empty path",
|
|
destination.display()
|
|
);
|
|
return ExitCode::from(2);
|
|
}
|
|
match create_workout_app(&destination) {
|
|
Ok(()) => {
|
|
println!("workout-new\tpath={}", destination.display());
|
|
println!(
|
|
"next\tcargo run --manifest-path {}/Cargo.toml --bin workout-app",
|
|
destination.display()
|
|
);
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(err) => {
|
|
eprintln!(
|
|
"failed to create Workout app at {}: {err}",
|
|
destination.display()
|
|
);
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
|
|
let root = repo_root()?;
|
|
let hemplate = hemplate_checkout(&root)?;
|
|
copy_dir(&root.join("examples/v0"), destination)?;
|
|
let cargo_toml = destination.join("Cargo.toml");
|
|
let manifest = fs::read_to_string(&cargo_toml)?;
|
|
fs::write(
|
|
&cargo_toml,
|
|
manifest
|
|
.replace("name = \"hemx-v0-examples\"", "name = \"hemx-app\"")
|
|
.replace("version.workspace = true", "version = \"0.1.0\"")
|
|
.replace("edition.workspace = true", "edition = \"2021\"")
|
|
.replace(
|
|
"path = \"../../../hemplate/hemplate\"",
|
|
&format!("path = \"{}\"", hemplate.display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx\"",
|
|
&format!("path = \"{}\"", root.join("hemx").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-axum\"",
|
|
&format!("path = \"{}\"", root.join("hemx-axum").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-test\"",
|
|
&format!("path = \"{}\"", root.join("hemx-test").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-build\"",
|
|
&format!("path = \"{}\"", root.join("hemx-build").display()),
|
|
),
|
|
)?;
|
|
replace_in_tree(destination, "hemx_v0_examples", "hemx_app")?;
|
|
replace_in_tree(destination, "hemx-v0-examples", "hemx-app")?;
|
|
replace_in_tree(destination, "hemx v0 browser examples", "hemx app")?;
|
|
fs::write(
|
|
destination.join("README.md"),
|
|
"# hemx app\n\nThis app was created by `cargo run -p hemx-xtask -- app new PATH`. It is the generic checked-hypermedia starter: one page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing. req: ceremony/005\n\n## Run\n\n```sh\ncargo run\n```\n\nOpen <http://127.0.0.1:3000>.\n\n## Test and build\n\n```sh\ncargo test\ncargo build --release\n```\n\n## Edit\n\n- `templates/todos.heml` owns the reusable partials and generated target names.\n- `src/main.rs` owns handlers and app state.\n- `src/lib.rs` re-exports generated `ui` helpers.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003\n",
|
|
)?;
|
|
fs::write(
|
|
destination.join("CREATED.md"),
|
|
"# Created hemx app\n\nUse one app-owned command surface from this directory:\n\n```sh\ncargo test\ncargo run\ncargo build --release\n```\n\nThis scaffold is the generic checked-hypermedia starting point: one page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003 req: ceremony/005\n",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_mobile_app_scaffold(destination: &Path) -> std::io::Result<()> {
|
|
// req: ceremony/006 req: examples/006
|
|
create_workout_app(destination)?;
|
|
fs::rename(
|
|
destination.join("hemx-workout"),
|
|
destination.join("hemx-app"),
|
|
)?;
|
|
for (from, to) in [
|
|
("hemx_workout_app", "hemx_app"),
|
|
("hemx-workout-app", "hemx-app"),
|
|
("workout-app", "hemx-app"),
|
|
("./hemx-workout", "./hemx-app"),
|
|
("hemx-workout", "hemx-app"),
|
|
("HEMX_WORKOUT_", "HEMX_APP_"),
|
|
("target/hemx-mobile/workout", "target/hemx-mobile/app"),
|
|
("com.hemx.workout", "com.hemx.app"),
|
|
("hemx Workout Copilot", "hemx App"),
|
|
(
|
|
"# Workout mobile external blockers",
|
|
"# hemx app mobile external blockers",
|
|
),
|
|
("workout-mobile", "app-mobile"),
|
|
] {
|
|
replace_in_tree(destination, from, to)?;
|
|
}
|
|
fs::write(
|
|
destination.join("README.md"),
|
|
"# hemx mobile app\n\nThis app was created by `cargo run -p hemx-xtask -- app new --mobile PATH`. It is a phone-first hemx starter with a real page/form/keyed partial/notice flow, typed host haptics/share calls, app-owned command/event/projection recovery truth, and inspectable mobile release-kit commands. The starter uses the Workout product flow as the first concrete app, but the command surface is owned by this app. req: ceremony/006\n\n## Run\n\n```sh\n./hemx-app dev\n```\n\nOpen the printed local URL. Set `HEMX_APP_ADDR=127.0.0.1:3030` if the default port is busy.\n\n## Test and build\n\n```sh\n./hemx-app test\n./hemx-app build\n./hemx-app doctor\n```\n\n## Mobile release kit\n\n```sh\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify\n```\n\nThe release kit writes inspectable Android/iOS metadata under `target/hemx-mobile/app`, records runtime/cache/offline/host capability policy, and reports external blockers for SDKs, signing, and store accounts without storing secrets or predicting store approval.\n\n## Boundaries\n\nUse this path for Rust-owned hypermedia apps that need installability, recovery, and a few explicit host capabilities. Do not treat it as a native UI framework, client store, plugin marketplace, or store-submission bot.\n",
|
|
)?;
|
|
fs::write(
|
|
destination.join("CREATED.md"),
|
|
"# Created hemx mobile app\n\nUse one app-owned command surface from this directory:\n\n```sh\n./hemx-app dev\n./hemx-app test\n./hemx-app build\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify\n./hemx-app doctor\n```\n\nThis app owns command/event/projection state and keeps Android/iOS SDKs, store submission targets, and signing outside the repo. req: ceremony/006 req: examples/006\n",
|
|
)?;
|
|
fs::write(
|
|
destination.join("MOBILE_STARTER.md"),
|
|
"# Mobile hemx starter\n\nThis starter is the phone-first path exposed through `app new --mobile`: it has a real page/form/keyed partial/notice flow, typed host haptics/share calls returning through Rust handlers, app-owned command/event/projection recovery truth, and app-owned `hemx-app mobile-release` / `hemx-app mobile-verify` release-kit commands.\n\nThe starter uses the Workout product flow as a concrete first app, but its command surface, crate name, binary name, release output, and environment variables are owned by the created app. It deliberately does not add a hemx mobile framework, client store, signing secret owner, or store submission bot. req: ceremony/006 req: examples/006\n",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_workout_app(destination: &Path) -> std::io::Result<()> {
|
|
let root = repo_root()?;
|
|
let hemplate = hemplate_checkout(&root)?;
|
|
copy_dir(&root.join("examples/workout"), destination)?;
|
|
let cargo_toml = destination.join("Cargo.toml");
|
|
let manifest = fs::read_to_string(&cargo_toml)?;
|
|
fs::write(
|
|
&cargo_toml,
|
|
manifest
|
|
.replace(
|
|
"name = \"hemx-workout-example\"",
|
|
"name = \"hemx-workout-app\"",
|
|
)
|
|
.replace(
|
|
"[[bin]]\nname = \"hemx-workout-app\"",
|
|
"[[bin]]\nname = \"workout-app\"",
|
|
)
|
|
.replace("version.workspace = true", "version = \"0.1.0\"")
|
|
.replace("edition.workspace = true", "edition = \"2021\"")
|
|
.replace(
|
|
"path = \"../../../hemplate/hemplate\"",
|
|
&format!("path = \"{}\"", hemplate.display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx\"",
|
|
&format!("path = \"{}\"", root.join("hemx").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-axum\"",
|
|
&format!("path = \"{}\"", root.join("hemx-axum").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-host\"",
|
|
&format!("path = \"{}\"", root.join("hemx-host").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-test\"",
|
|
&format!("path = \"{}\"", root.join("hemx-test").display()),
|
|
)
|
|
.replace(
|
|
"path = \"../../hemx-build\"",
|
|
&format!("path = \"{}\"", root.join("hemx-build").display()),
|
|
),
|
|
)?;
|
|
replace_in_tree(destination, "hemx_workout_example", "hemx_workout_app")?;
|
|
replace_in_tree(destination, "hemx-workout-example", "workout-app")?;
|
|
write_workout_app_command(destination)?;
|
|
fs::write(
|
|
destination.join("CREATED.md"),
|
|
"# Created Workout app\n\nUse one command surface from this directory:\n\n```sh\n./hemx-workout dev\n./hemx-workout test\n./hemx-workout build\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-release\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-verify\n./hemx-workout doctor\n```\n\nThis app owns command/event/projection state and keeps Android/iOS SDKs, store submission targets, and signing outside the repo. req: examples/006\n",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn repo_root() -> std::io::Result<PathBuf> {
|
|
let cwd = env::current_dir()?;
|
|
if cwd.join("examples/workout").exists() {
|
|
return Ok(cwd);
|
|
}
|
|
Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.to_path_buf())
|
|
}
|
|
|
|
fn hemplate_checkout(root: &Path) -> std::io::Result<PathBuf> {
|
|
let hemplate = root.join("../hemplate/hemplate");
|
|
if hemplate.join("Cargo.toml").exists() {
|
|
Ok(hemplate)
|
|
} else {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
format!(
|
|
"hemplate checkout not found at {}; clone hemplate next to hemx or adjust the generated Cargo.toml after creation",
|
|
hemplate.display()
|
|
),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> {
|
|
let contents = fs::read_to_string(path)?;
|
|
fs::write(path, contents.replace(from, to))
|
|
}
|
|
|
|
fn replace_in_tree(path: &Path, from: &str, to: &str) -> std::io::Result<()> {
|
|
if path.is_dir() {
|
|
for entry in fs::read_dir(path)? {
|
|
replace_in_tree(&entry?.path(), from, to)?;
|
|
}
|
|
} else {
|
|
replace_in_file(path, from, to)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_workout_app_command(destination: &Path) -> std::io::Result<()> {
|
|
let script_path = destination.join("hemx-workout");
|
|
fs::write(&script_path, workout_app_command_script())?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut permissions = fs::metadata(&script_path)?.permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(&script_path, permissions)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn workout_app_command_script() -> String {
|
|
format!(
|
|
r#"#!/usr/bin/env sh
|
|
set -eu
|
|
APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
CMD=${{1:-dev}}
|
|
OUT=${{HEMX_WORKOUT_MOBILE_OUT:-"$APP_DIR/target/hemx-mobile/workout"}}
|
|
ORIGIN=${{HEMX_WORKOUT_ORIGIN:-https://workout.example.invalid}}
|
|
APP_ID=${{HEMX_WORKOUT_APP_ID:-com.hemx.workout}}
|
|
APP_NAME=${{HEMX_WORKOUT_APP_NAME:-hemx Workout Copilot}}
|
|
VERSION=${{HEMX_WORKOUT_VERSION:-0.1.0}}
|
|
RUNTIME_PATH='{runtime_path}'
|
|
RUNTIME_SHA='{runtime_sha}'
|
|
|
|
write_blockers() {{
|
|
mkdir -p "$OUT"
|
|
{{
|
|
echo '# Workout mobile external blockers'
|
|
echo
|
|
echo 'The release kit is generated, but these external inputs are still required for signed store artifacts:'
|
|
echo
|
|
[ -n "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ] || echo '- Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts'
|
|
command -v java >/dev/null 2>&1 || echo '- Java runtime not found: Android packaging requires a JDK'
|
|
[ -n "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ] || echo '- Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing'
|
|
[ -n "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ] || echo '- Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app'
|
|
command -v xcodebuild >/dev/null 2>&1 || echo '- Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS'
|
|
[ -n "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ] || echo '- iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export'
|
|
[ -n "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ] || echo '- App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app'
|
|
}} > "$OUT/BLOCKERS.md"
|
|
}}
|
|
|
|
write_blockers_json() {{
|
|
first=1
|
|
if [ -z "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts'; first=0; fi
|
|
if ! command -v java >/dev/null 2>&1; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Java runtime not found: Android packaging requires a JDK'; first=0; fi
|
|
if [ -z "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing'; first=0; fi
|
|
if [ -z "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app'; first=0; fi
|
|
if ! command -v xcodebuild >/dev/null 2>&1; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS'; first=0; fi
|
|
if [ -z "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export'; first=0; fi
|
|
if [ -z "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app'; fi
|
|
}}
|
|
|
|
check_blocker() {{
|
|
grep -F "$1" "$OUT/BLOCKERS.md" >/dev/null || {{ echo "BLOCKERS.md missing external blocker: $1" >&2; exit 1; }}
|
|
grep -F "$1" "$OUT/release-manifest.json" >/dev/null || {{ echo "release-manifest.json missing external blocker: $1" >&2; exit 1; }}
|
|
}}
|
|
|
|
check_external_blockers() {{
|
|
[ -n "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ] || check_blocker 'Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts'
|
|
command -v java >/dev/null 2>&1 || check_blocker 'Java runtime not found: Android packaging requires a JDK'
|
|
[ -n "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ] || check_blocker 'Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing'
|
|
[ -n "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ] || check_blocker 'Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app'
|
|
command -v xcodebuild >/dev/null 2>&1 || check_blocker 'Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS'
|
|
[ -n "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ] || check_blocker 'iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export'
|
|
[ -n "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ] || check_blocker 'App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app'
|
|
}}
|
|
|
|
case "$CMD" in
|
|
dev|run)
|
|
exec cargo run --manifest-path "$APP_DIR/Cargo.toml" --bin workout-app
|
|
;;
|
|
test)
|
|
exec cargo test --manifest-path "$APP_DIR/Cargo.toml"
|
|
;;
|
|
build)
|
|
exec cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app
|
|
;;
|
|
mobile-release)
|
|
cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app
|
|
mkdir -p "$OUT/android" "$OUT/ios"
|
|
write_blockers
|
|
printf 'path\talgorithm\tdigest\n%s\tsha256\t%s\n' "$RUNTIME_PATH" "$RUNTIME_SHA" > "$OUT/asset-integrity.tsv"
|
|
cat > "$OUT/release-manifest.json" <<EOF
|
|
{{
|
|
"app_id": "$APP_ID",
|
|
"name": "$APP_NAME",
|
|
"version": "$VERSION",
|
|
"origin": "$ORIGIN",
|
|
"server_binary": "$APP_DIR/target/release/workout-app",
|
|
"runtime_asset_path": "$RUNTIME_PATH",
|
|
"runtime_asset_sha256": "$RUNTIME_SHA",
|
|
"asset_integrity": "asset-integrity.tsv",
|
|
"cache_policy": "cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth",
|
|
"state_policy": "app-owned command/event/projection records",
|
|
"host_result_kinds": ["denied", "timeout", "unavailable", "error"],
|
|
"environment_boundary": "public mobile shell config lives here; secrets and signing credentials stay outside the app repo",
|
|
"rollback": "redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs",
|
|
"android": "android/twa-release.json",
|
|
"ios": "ios/webview-release.json",
|
|
"external_blockers": [$(write_blockers_json)]
|
|
}}
|
|
EOF
|
|
cat > "$OUT/android/twa-release.json" <<EOF
|
|
{{"package":"$APP_ID","name":"$APP_NAME","start_url":"$ORIGIN/","host_capabilities":["share","haptics"],"host_result_kinds":["denied","timeout","unavailable","error"],"signing":"external Android keystore; never commit credentials"}}
|
|
EOF
|
|
cat > "$OUT/ios/webview-release.json" <<EOF
|
|
{{"bundle_id":"$APP_ID","name":"$APP_NAME","start_url":"$ORIGIN/","host_capabilities":["share","haptics"],"host_result_kinds":["denied","timeout","unavailable","error"],"signing":"external Apple team/provisioning profile; never commit credentials"}}
|
|
EOF
|
|
echo "workout-mobile\tout=$OUT"
|
|
;;
|
|
mobile-verify|verify)
|
|
"$0" test
|
|
test -f "$APP_DIR/target/release/workout-app" || {{ echo 'missing target/release/workout-app; run mobile-release first' >&2; exit 1; }}
|
|
case "$ORIGIN" in https://*) ;; *) echo 'HEMX_WORKOUT_ORIGIN must be HTTPS' >&2; exit 1;; esac
|
|
for f in release-manifest.json asset-integrity.tsv android/twa-release.json ios/webview-release.json BLOCKERS.md; do test -f "$OUT/$f" || {{ echo "missing $OUT/$f" >&2; exit 1; }}; done
|
|
grep -F "$RUNTIME_PATH" "$OUT/release-manifest.json" >/dev/null
|
|
grep -F "$RUNTIME_SHA" "$OUT/asset-integrity.tsv" >/dev/null
|
|
grep -F 'app-owned command/event/projection records' "$OUT/release-manifest.json" >/dev/null
|
|
grep -F 'host_result_kinds' "$OUT/android/twa-release.json" >/dev/null
|
|
grep -F 'host_result_kinds' "$OUT/ios/webview-release.json" >/dev/null
|
|
check_external_blockers
|
|
echo "workout-mobile-verified\tout=$OUT"
|
|
;;
|
|
doctor)
|
|
write_blockers
|
|
cat "$OUT/BLOCKERS.md"
|
|
;;
|
|
*)
|
|
echo 'usage: ./hemx-workout dev|test|build|mobile-release|mobile-verify|doctor' >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
"#,
|
|
runtime_path = hemx_js::RUNTIME_JS_PATH,
|
|
runtime_sha = hemx_js::RUNTIME_JS_HASH,
|
|
)
|
|
}
|
|
|
|
fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
|
|
fs::create_dir_all(destination)?;
|
|
for entry in fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
let source_path = entry.path();
|
|
let destination_path = destination.join(entry.file_name());
|
|
if source_path.is_dir() {
|
|
copy_dir(&source_path, &destination_path)?;
|
|
} else {
|
|
fs::copy(&source_path, &destination_path)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_workout_mobile(command: Option<&str>) -> ExitCode {
|
|
match command.unwrap_or("release") {
|
|
"release" => run_workout_mobile_release(),
|
|
"verify" => run_workout_mobile_verify(),
|
|
"doctor" => {
|
|
let config = WorkoutMobileConfig::from_env();
|
|
let blockers = mobile_external_blockers(&config);
|
|
print_mobile_doctor(&config, &blockers);
|
|
ExitCode::SUCCESS
|
|
}
|
|
"help" | "--help" | "-h" => {
|
|
print_help();
|
|
ExitCode::SUCCESS
|
|
}
|
|
other => {
|
|
eprintln!("unknown workout-mobile command `{other}`\n");
|
|
print_help();
|
|
ExitCode::from(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_workout_release_build(label: &'static str) -> ExitCode {
|
|
let budget = Budget::detect();
|
|
budget.report();
|
|
Step::new(
|
|
label,
|
|
["build", "--release", "--bin", "hemx-workout-example"],
|
|
)
|
|
.run(&budget)
|
|
.map(|()| ExitCode::SUCCESS)
|
|
.unwrap_or_else(|code| code)
|
|
}
|
|
|
|
fn run_workout_mobile_release() -> ExitCode {
|
|
// req: examples/001 req: examples/006 req: host/002 req: local/001
|
|
let code = run_workout_release_build("workout-mobile-server-release");
|
|
if code != ExitCode::SUCCESS {
|
|
return code;
|
|
}
|
|
|
|
let config = WorkoutMobileConfig::from_env();
|
|
let blockers = mobile_external_blockers(&config);
|
|
match write_workout_mobile_release(&config, &blockers) {
|
|
Ok(()) => {
|
|
println!(
|
|
"workout-mobile\tout={}\tblockers={}",
|
|
config.out_dir.display(),
|
|
blockers.len()
|
|
);
|
|
for blocker in &blockers {
|
|
println!("workout-mobile-blocker\t{}", blocker);
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(err) => {
|
|
eprintln!("failed to write Workout mobile release kit: {err}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_workout_mobile_verify() -> ExitCode {
|
|
// req: examples/001 req: examples/006 req: host/002 req: local/001
|
|
let budget = Budget::detect();
|
|
budget.report();
|
|
if let Err(code) = Step::new(
|
|
"workout-mobile-product-gate",
|
|
["test", "-p", "hemx-workout-example"],
|
|
)
|
|
.run(&budget)
|
|
{
|
|
return code;
|
|
}
|
|
|
|
let config = WorkoutMobileConfig::from_env();
|
|
let failures = verify_workout_mobile_release(&config, true);
|
|
let blockers = mobile_external_blockers(&config);
|
|
if failures.is_empty() {
|
|
println!(
|
|
"workout-mobile-verified\tout={}\tblockers={}",
|
|
config.out_dir.display(),
|
|
blockers.len()
|
|
);
|
|
for blocker in &blockers {
|
|
println!("workout-mobile-blocker\t{}", blocker);
|
|
}
|
|
ExitCode::SUCCESS
|
|
} else {
|
|
eprintln!("Workout mobile release kit is not verifiable:");
|
|
for failure in failures {
|
|
eprintln!("- {failure}");
|
|
}
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct WorkoutMobileConfig {
|
|
app_id: String,
|
|
app_name: String,
|
|
version: String,
|
|
origin: String,
|
|
android_package: String,
|
|
ios_bundle_id: String,
|
|
out_dir: PathBuf,
|
|
}
|
|
|
|
impl WorkoutMobileConfig {
|
|
fn from_env() -> Self {
|
|
let app_id = env::var("HEMX_WORKOUT_APP_ID").unwrap_or_else(|_| "com.hemx.workout".into());
|
|
Self {
|
|
android_package: env::var("HEMX_WORKOUT_ANDROID_PACKAGE")
|
|
.unwrap_or_else(|_| app_id.clone()),
|
|
ios_bundle_id: env::var("HEMX_WORKOUT_IOS_BUNDLE_ID")
|
|
.unwrap_or_else(|_| app_id.clone()),
|
|
app_id,
|
|
app_name: env::var("HEMX_WORKOUT_APP_NAME")
|
|
.unwrap_or_else(|_| "hemx Workout Copilot".into()),
|
|
version: env::var("HEMX_WORKOUT_VERSION")
|
|
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
|
|
origin: env::var("HEMX_WORKOUT_ORIGIN")
|
|
.unwrap_or_else(|_| "https://workout.example.invalid".into()),
|
|
out_dir: env::var_os("HEMX_WORKOUT_MOBILE_OUT")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("target/hemx-mobile/workout")),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mobile_external_blockers(config: &WorkoutMobileConfig) -> Vec<String> {
|
|
let mut blockers = Vec::new();
|
|
if !config.origin.starts_with("https://") {
|
|
blockers.push("HEMX_WORKOUT_ORIGIN must be the production HTTPS origin used by Android and iOS shells".into());
|
|
}
|
|
if env::var_os("ANDROID_HOME").is_none() && env::var_os("ANDROID_SDK_ROOT").is_none() {
|
|
blockers.push("Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts".into());
|
|
}
|
|
if !has_command("java") {
|
|
blockers.push("Java runtime not found: Android packaging requires a JDK".into());
|
|
}
|
|
if env::var_os("HEMX_WORKOUT_ANDROID_KEYSTORE").is_none() {
|
|
blockers.push("Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing".into());
|
|
}
|
|
if env::var_os("HEMX_WORKOUT_GOOGLE_PLAY_TRACK").is_none() {
|
|
blockers.push("Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this repo".into());
|
|
}
|
|
if !has_command("xcodebuild") {
|
|
blockers.push(
|
|
"Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS"
|
|
.into(),
|
|
);
|
|
}
|
|
if env::var_os("HEMX_WORKOUT_IOS_TEAM_ID").is_none() {
|
|
blockers.push("iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export".into());
|
|
}
|
|
if env::var_os("HEMX_WORKOUT_APP_STORE_CONNECT_TEAM").is_none() {
|
|
blockers.push("App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this repo".into());
|
|
}
|
|
blockers
|
|
}
|
|
|
|
fn print_mobile_doctor(config: &WorkoutMobileConfig, blockers: &[String]) {
|
|
println!("Workout mobile release doctor");
|
|
println!(
|
|
"app_id={} version={} origin={}",
|
|
config.app_id, config.version, config.origin
|
|
);
|
|
if blockers.is_empty() {
|
|
println!("ready: Android SDK/signing and iOS Xcode/signing inputs are visible");
|
|
} else {
|
|
println!("blocked external steps:");
|
|
for blocker in blockers {
|
|
println!("- {blocker}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_workout_mobile_release(
|
|
config: &WorkoutMobileConfig,
|
|
blockers: &[String],
|
|
) -> std::io::Result<()> {
|
|
let android_dir = config.out_dir.join("android");
|
|
let ios_dir = config.out_dir.join("ios");
|
|
fs::create_dir_all(&android_dir)?;
|
|
fs::create_dir_all(&ios_dir)?;
|
|
fs::write(
|
|
config.out_dir.join("release-manifest.json"),
|
|
workout_mobile_manifest(config, blockers),
|
|
)?;
|
|
fs::write(
|
|
config.out_dir.join("asset-integrity.tsv"),
|
|
workout_asset_integrity(),
|
|
)?;
|
|
fs::write(
|
|
config.out_dir.join("BLOCKERS.md"),
|
|
workout_mobile_blockers_md(blockers),
|
|
)?;
|
|
fs::write(
|
|
android_dir.join("twa-release.json"),
|
|
android_twa_release_json(config),
|
|
)?;
|
|
fs::write(
|
|
android_dir.join("README.md"),
|
|
android_release_readme(config),
|
|
)?;
|
|
fs::write(
|
|
ios_dir.join("webview-release.json"),
|
|
ios_webview_release_json(config),
|
|
)?;
|
|
fs::write(ios_dir.join("README.md"), ios_release_readme(config))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_workout_mobile_release(
|
|
config: &WorkoutMobileConfig,
|
|
require_server_binary: bool,
|
|
) -> Vec<String> {
|
|
let mut failures = mobile_policy_failures(config);
|
|
if require_server_binary && !Path::new("target/release/hemx-workout-example").exists() {
|
|
failures.push(
|
|
"target/release/hemx-workout-example is missing; run workout-mobile release first"
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
let manifest_path = config.out_dir.join("release-manifest.json");
|
|
let android_path = config.out_dir.join("android/twa-release.json");
|
|
let ios_path = config.out_dir.join("ios/webview-release.json");
|
|
let blockers_path = config.out_dir.join("BLOCKERS.md");
|
|
let integrity_path = config.out_dir.join("asset-integrity.tsv");
|
|
for path in [
|
|
&manifest_path,
|
|
&android_path,
|
|
&ios_path,
|
|
&blockers_path,
|
|
&integrity_path,
|
|
] {
|
|
if !path.exists() {
|
|
failures.push(format!("{} is missing", path.display()));
|
|
}
|
|
}
|
|
|
|
check_file_contains(
|
|
&manifest_path,
|
|
&[
|
|
&config.app_id,
|
|
&config.version,
|
|
&config.origin,
|
|
hemx_js::RUNTIME_JS_PATH,
|
|
hemx_js::RUNTIME_JS_HASH,
|
|
"asset-integrity.tsv",
|
|
"app-owned command/event/projection records",
|
|
"secrets and signing credentials stay outside the repo",
|
|
"rollback",
|
|
"android/twa-release.json",
|
|
"ios/webview-release.json",
|
|
"denied",
|
|
"timeout",
|
|
"unavailable",
|
|
"error",
|
|
],
|
|
&mut failures,
|
|
);
|
|
check_file_contains(
|
|
&android_path,
|
|
&[
|
|
&config.android_package,
|
|
&config.app_name,
|
|
origin_host(&config.origin),
|
|
"external Android keystore",
|
|
"share",
|
|
"haptics",
|
|
"denied",
|
|
"timeout",
|
|
"unavailable",
|
|
"error",
|
|
],
|
|
&mut failures,
|
|
);
|
|
check_file_contains(
|
|
&ios_path,
|
|
&[
|
|
&config.ios_bundle_id,
|
|
&config.app_name,
|
|
"share",
|
|
"haptics",
|
|
"external Apple team",
|
|
"denied",
|
|
"timeout",
|
|
"unavailable",
|
|
"error",
|
|
],
|
|
&mut failures,
|
|
);
|
|
|
|
check_file_contains(
|
|
&integrity_path,
|
|
&[hemx_js::RUNTIME_JS_PATH, hemx_js::RUNTIME_JS_HASH, "sha256"],
|
|
&mut failures,
|
|
);
|
|
|
|
for blocker in mobile_external_blockers(config) {
|
|
check_file_contains(&manifest_path, &[&blocker], &mut failures);
|
|
check_file_contains(&blockers_path, &[&blocker], &mut failures);
|
|
}
|
|
|
|
failures
|
|
}
|
|
|
|
fn mobile_policy_failures(config: &WorkoutMobileConfig) -> Vec<String> {
|
|
let mut failures = Vec::new();
|
|
if !config.origin.starts_with("https://") {
|
|
failures.push(
|
|
"HEMX_WORKOUT_ORIGIN must be a production HTTPS origin before mobile verification can pass"
|
|
.into(),
|
|
);
|
|
}
|
|
failures
|
|
}
|
|
|
|
fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec<String>) {
|
|
let Ok(contents) = fs::read_to_string(path) else {
|
|
return;
|
|
};
|
|
for needle in needles {
|
|
if !contents.contains(needle) {
|
|
failures.push(format!("{} does not contain `{}`", path.display(), needle));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn workout_asset_integrity() -> String {
|
|
format!(
|
|
"path\talgorithm\tdigest\n{}\tsha256\t{}\n",
|
|
hemx_js::RUNTIME_JS_PATH,
|
|
hemx_js::RUNTIME_JS_HASH
|
|
)
|
|
}
|
|
|
|
fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String {
|
|
format!(
|
|
"{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"{}\",\n \"runtime_asset_sha256\": \"{}\",\n \"asset_integrity\": \"asset-integrity.tsv\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n",
|
|
json_escape(&config.app_id),
|
|
json_escape(&config.app_name),
|
|
json_escape(&config.version),
|
|
json_escape(&config.origin),
|
|
json_escape(hemx_js::RUNTIME_JS_PATH),
|
|
json_escape(hemx_js::RUNTIME_JS_HASH),
|
|
json_string_list(blockers),
|
|
)
|
|
}
|
|
|
|
fn android_twa_release_json(config: &WorkoutMobileConfig) -> String {
|
|
format!(
|
|
"{{\n \"package\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Android keystore; never commit credentials\"\n}}\n",
|
|
json_escape(&config.android_package),
|
|
json_escape(&config.app_name),
|
|
json_escape(config.origin.trim_end_matches('/')),
|
|
json_escape(origin_host(&config.origin)),
|
|
json_escape(&config.version),
|
|
)
|
|
}
|
|
|
|
fn ios_webview_release_json(config: &WorkoutMobileConfig) -> String {
|
|
format!(
|
|
"{{\n \"bundle_id\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Apple team/provisioning profile; never commit credentials\"\n}}\n",
|
|
json_escape(&config.ios_bundle_id),
|
|
json_escape(&config.app_name),
|
|
json_escape(config.origin.trim_end_matches('/')),
|
|
json_escape(&config.version),
|
|
)
|
|
}
|
|
|
|
fn workout_mobile_blockers_md(blockers: &[String]) -> String {
|
|
if blockers.is_empty() {
|
|
"# Workout mobile external blockers\n\nNo external blocker was detected locally. Store submission still remains a human/vendor step.\n".into()
|
|
} else {
|
|
let mut out = String::from("# Workout mobile external blockers\n\nThe hemx release kit is generated, but these external inputs are still required for signed store artifacts:\n\n");
|
|
for blocker in blockers {
|
|
out.push_str("- ");
|
|
out.push_str(blocker);
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn android_release_readme(config: &WorkoutMobileConfig) -> String {
|
|
format!(
|
|
"# Workout Android release\n\nUse `twa-release.json` as the Android shell authority for `{}`. Build the hemx server with the same release and serve `{}/` over HTTPS. Android SDK, Java, signing credentials, Play Console account state, and submission track are external inputs; this repository does not own them.\n",
|
|
config.android_package, config.origin
|
|
)
|
|
}
|
|
|
|
fn ios_release_readme(config: &WorkoutMobileConfig) -> String {
|
|
format!(
|
|
"# Workout iOS release\n\nUse `webview-release.json` as the iOS shell authority for `{}`. Archive with Xcode against `{}/` and route share/haptics through the typed host adapter. Apple team IDs, provisioning profiles, and App Store submission are external inputs; this repository does not own them.\n",
|
|
config.ios_bundle_id, config.origin
|
|
)
|
|
}
|
|
|
|
fn json_string_list(values: &[String]) -> String {
|
|
values
|
|
.iter()
|
|
.map(|value| format!("\"{}\"", json_escape(value)))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn json_escape(value: &str) -> String {
|
|
value
|
|
.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', "\\n")
|
|
}
|
|
|
|
fn origin_host(origin: &str) -> &str {
|
|
origin
|
|
.strip_prefix("https://")
|
|
.or_else(|| origin.strip_prefix("http://"))
|
|
.unwrap_or(origin)
|
|
.split('/')
|
|
.next()
|
|
.unwrap_or(origin)
|
|
}
|
|
|
|
fn run_test_plan() -> ExitCode {
|
|
// req: test/004
|
|
let budget = Budget::detect();
|
|
budget.report();
|
|
|
|
let mut steps = vec![
|
|
Step::new(
|
|
"workspace-no-techdemo",
|
|
["test", "--workspace", "--exclude", "hemx-techdemo"],
|
|
),
|
|
Step::new(
|
|
"techdemo-unit-http",
|
|
["test", "-p", "hemx-techdemo", "--test", "e2e"],
|
|
),
|
|
Step::new("redgate", ["refs"]).tool("redgate"),
|
|
];
|
|
|
|
if !budget.skip_browser {
|
|
steps.insert(
|
|
2,
|
|
Step::new(
|
|
"techdemo-browser",
|
|
["test", "-p", "hemx-techdemo", "--test", "browser_e2e"],
|
|
)
|
|
.test_threads(1),
|
|
);
|
|
}
|
|
|
|
for step in steps {
|
|
if let Err(code) = step.run(&budget) {
|
|
return code;
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn run_bench_plan() -> ExitCode {
|
|
// req: test/004
|
|
let budget = Budget::detect();
|
|
budget.report();
|
|
eprintln!(
|
|
"hemx-ci: benchmarking small safe test slices from 1 to {} job(s)",
|
|
budget.jobs
|
|
);
|
|
|
|
let steps = [
|
|
Step::new("bench-xtask", ["test", "-p", "hemx-xtask"]),
|
|
Step::new("bench-runtime", ["test", "-p", "hemx-js"]),
|
|
];
|
|
|
|
for jobs in bench_values(budget.jobs) {
|
|
let bench_budget = budget.with_jobs(jobs);
|
|
for step in &steps {
|
|
let started = Instant::now();
|
|
if let Err(code) = step.run(&bench_budget) {
|
|
return code;
|
|
}
|
|
println!(
|
|
"bench\t{}\tjobs={}\ttest_threads={}\telapsed_ms={}",
|
|
step.name,
|
|
bench_budget.jobs,
|
|
bench_budget.test_threads,
|
|
started.elapsed().as_millis()
|
|
);
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Budget {
|
|
cpus: usize,
|
|
mem_gib: Option<usize>,
|
|
jobs: usize,
|
|
test_threads: usize,
|
|
skip_browser: bool,
|
|
}
|
|
|
|
impl Budget {
|
|
fn detect() -> Self {
|
|
let cpus = std::thread::available_parallelism()
|
|
.map_or(1, usize::from)
|
|
.max(1);
|
|
let mem_gib = available_mem_gib();
|
|
let skip_browser = env::var_os("HEMX_CI_SKIP_BROWSER").is_some()
|
|
|| env::var_os("CI_NO_BROWSER").is_some()
|
|
|| !has_command("geckodriver");
|
|
Self::from_resources(
|
|
cpus,
|
|
mem_gib,
|
|
env_usize("HEMX_CI_JOBS"),
|
|
env_usize("HEMX_CI_TEST_THREADS"),
|
|
skip_browser,
|
|
)
|
|
}
|
|
|
|
fn from_resources(
|
|
cpus: usize,
|
|
mem_gib: Option<usize>,
|
|
jobs_override: Option<usize>,
|
|
test_threads_override: Option<usize>,
|
|
skip_browser: bool,
|
|
) -> Self {
|
|
let cpus = cpus.max(1);
|
|
let mem_jobs = mem_gib.map_or(cpus, |gib| (gib / 2).max(1));
|
|
let auto_jobs = cpus.min(mem_jobs).clamp(1, 6);
|
|
let jobs = jobs_override.unwrap_or(auto_jobs).clamp(1, auto_jobs);
|
|
let max_test_threads = jobs.min(4);
|
|
let test_threads = test_threads_override
|
|
.unwrap_or(max_test_threads)
|
|
.clamp(1, max_test_threads);
|
|
Self {
|
|
cpus,
|
|
mem_gib,
|
|
jobs,
|
|
test_threads,
|
|
skip_browser,
|
|
}
|
|
}
|
|
|
|
fn report(&self) {
|
|
eprintln!(
|
|
"hemx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}",
|
|
self.cpus,
|
|
self.mem_gib
|
|
.map(|mem| mem.to_string())
|
|
.unwrap_or_else(|| "unknown".into()),
|
|
self.jobs,
|
|
self.test_threads,
|
|
if self.skip_browser { "skip" } else { "run" }
|
|
);
|
|
}
|
|
|
|
fn with_jobs(self, jobs: usize) -> Self {
|
|
let jobs = jobs.clamp(1, self.jobs);
|
|
Self {
|
|
jobs,
|
|
test_threads: self.test_threads.min(jobs.min(4)).max(1),
|
|
..self
|
|
}
|
|
}
|
|
}
|
|
|
|
fn bench_values(limit: usize) -> Vec<usize> {
|
|
let limit = limit.max(1);
|
|
let mut values = Vec::new();
|
|
let mut value = 1;
|
|
while value < limit {
|
|
values.push(value);
|
|
value *= 2;
|
|
}
|
|
values.push(limit);
|
|
values.dedup();
|
|
values
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Step {
|
|
name: &'static str,
|
|
tool: &'static str,
|
|
args: Vec<&'static str>,
|
|
test_threads: Option<usize>,
|
|
}
|
|
|
|
impl Step {
|
|
fn new<const N: usize>(name: &'static str, args: [&'static str; N]) -> Self {
|
|
Self {
|
|
name,
|
|
tool: "cargo",
|
|
args: args.into(),
|
|
test_threads: None,
|
|
}
|
|
}
|
|
|
|
fn tool(mut self, tool: &'static str) -> Self {
|
|
self.tool = tool;
|
|
self
|
|
}
|
|
|
|
fn test_threads(mut self, threads: usize) -> Self {
|
|
self.test_threads = Some(threads);
|
|
self
|
|
}
|
|
|
|
fn run(&self, budget: &Budget) -> Result<(), ExitCode> {
|
|
eprintln!("\n==> {}", self.name);
|
|
let mut command = Command::new(self.tool);
|
|
command.current_dir(workspace_root()).args(&self.args);
|
|
if self.tool == "cargo" {
|
|
command.env("CARGO_BUILD_JOBS", budget.jobs.to_string());
|
|
command.env(
|
|
"RUST_TEST_THREADS",
|
|
self.test_threads.unwrap_or(budget.test_threads).to_string(),
|
|
);
|
|
}
|
|
let status = command.status().map_err(|err| {
|
|
eprintln!("failed to run {}: {err}", self.name);
|
|
ExitCode::FAILURE
|
|
})?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
eprintln!("{} failed with {status}", self.name);
|
|
Err(ExitCode::from(status.code().unwrap_or(1) as u8))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn workspace_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.expect("hemx-xtask must be a workspace member")
|
|
.to_owned()
|
|
}
|
|
|
|
fn env_usize(key: &str) -> Option<usize> {
|
|
env::var(key).ok()?.parse().ok()
|
|
}
|
|
|
|
fn available_mem_gib() -> Option<usize> {
|
|
const GIB: u64 = 1024 * 1024 * 1024;
|
|
let bytes = available_mem_bytes()?;
|
|
Some(((bytes / GIB) as usize).max(1))
|
|
}
|
|
|
|
fn available_mem_bytes() -> Option<u64> {
|
|
let mut candidates = Vec::new();
|
|
if let Some(bytes) = proc_mem_available_bytes() {
|
|
candidates.push(bytes);
|
|
}
|
|
if let Some(bytes) = cgroup_mem_available_bytes() {
|
|
candidates.push(bytes);
|
|
}
|
|
candidates.into_iter().min()
|
|
}
|
|
|
|
fn proc_mem_available_bytes() -> Option<u64> {
|
|
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
|
|
let kb = meminfo.lines().find_map(|line| {
|
|
let rest = line.strip_prefix("MemAvailable:")?;
|
|
rest.split_whitespace().next()?.parse::<u64>().ok()
|
|
})?;
|
|
kb.checked_mul(1024)
|
|
}
|
|
|
|
fn cgroup_mem_available_bytes() -> Option<u64> {
|
|
cgroup_mem_available_v2().or_else(cgroup_mem_available_v1)
|
|
}
|
|
|
|
fn cgroup_mem_available_v2() -> Option<u64> {
|
|
let limit = read_cgroup_limit("/sys/fs/cgroup/memory.max")?;
|
|
let current = read_u64_file("/sys/fs/cgroup/memory.current").unwrap_or(0);
|
|
Some(limit.saturating_sub(current).max(1))
|
|
}
|
|
|
|
fn cgroup_mem_available_v1() -> Option<u64> {
|
|
let limit = read_cgroup_limit("/sys/fs/cgroup/memory/memory.limit_in_bytes")?;
|
|
let current = read_u64_file("/sys/fs/cgroup/memory/memory.usage_in_bytes").unwrap_or(0);
|
|
Some(limit.saturating_sub(current).max(1))
|
|
}
|
|
|
|
fn read_cgroup_limit(path: &str) -> Option<u64> {
|
|
let raw = std::fs::read_to_string(path).ok()?;
|
|
let trimmed = raw.trim();
|
|
if trimmed == "max" {
|
|
return None;
|
|
}
|
|
let value = trimmed.parse::<u64>().ok()?;
|
|
if value >= (1 << 60) {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
}
|
|
|
|
fn read_u64_file(path: &str) -> Option<u64> {
|
|
std::fs::read_to_string(path).ok()?.trim().parse().ok()
|
|
}
|
|
|
|
fn has_command(name: &str) -> bool {
|
|
let Some(paths) = env::var_os("PATH") else {
|
|
return false;
|
|
};
|
|
env::split_paths(&paths).any(|dir| is_executable(dir.join(name)))
|
|
}
|
|
|
|
fn is_executable(path: impl AsRef<Path>) -> bool {
|
|
path.as_ref().is_file()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
|
|
create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release,
|
|
workout_mobile_manifest, workspace_root, write_workout_mobile_release, Budget,
|
|
WorkoutMobileConfig,
|
|
};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn verification_steps_resolve_the_workspace_independent_of_caller_directory() {
|
|
assert!(workspace_root().join("Cargo.toml").is_file()); // req: test/004
|
|
}
|
|
|
|
#[test]
|
|
fn budget_is_capped_by_available_memory() {
|
|
// req: test/004
|
|
let budget = Budget::from_resources(22, Some(1), None, None, true);
|
|
|
|
assert_eq!(budget.jobs, 1);
|
|
assert_eq!(budget.test_threads, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn budget_caps_large_machines_by_default() {
|
|
let budget = Budget::from_resources(64, Some(128), None, None, true);
|
|
|
|
assert_eq!(budget.jobs, 6);
|
|
assert_eq!(budget.test_threads, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn budget_clamps_explicit_overrides_to_resource_budget() {
|
|
let budget = Budget::from_resources(2, Some(2), Some(8), Some(7), true);
|
|
|
|
assert_eq!(budget.jobs, 1);
|
|
assert_eq!(budget.test_threads, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn bench_values_grow_gradually_and_include_limit() {
|
|
assert_eq!(super::bench_values(1), vec![1]);
|
|
assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]);
|
|
}
|
|
|
|
#[test]
|
|
fn app_new_creates_generated_helper_scaffold() {
|
|
// req: ceremony/005 req: canonical_authoring/003
|
|
let destination = PathBuf::from("target/test-hemx-app-new");
|
|
let _ = fs::remove_dir_all(&destination);
|
|
|
|
create_app_scaffold(&destination).expect("create app scaffold");
|
|
let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest");
|
|
let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs");
|
|
let template =
|
|
fs::read_to_string(destination.join("templates/todos.heml")).expect("template");
|
|
let readme = fs::read_to_string(destination.join("README.md")).expect("readme");
|
|
let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs");
|
|
|
|
assert!(manifest.contains("name = \"hemx-app\""));
|
|
assert!(manifest.contains("edition = \"2021\""));
|
|
assert!(manifest.contains("hemx-build"));
|
|
assert!(main_rs.contains("hemx_app::ui"));
|
|
assert!(!main_rs.contains("hemx_v0_examples"));
|
|
assert!(template.contains("data-hemx-form=\"new_todo\""));
|
|
assert!(template.contains("data-hemx-slot=\"notice\""));
|
|
assert!(template.contains("data-hemx-slot=\"todo_row\""));
|
|
assert!(template.contains("h-key=\"row.id\""));
|
|
assert!(readme.contains("# hemx app"));
|
|
assert!(readme.contains("cargo run"));
|
|
assert!(readme.contains("cargo build --release"));
|
|
assert!(readme.contains("templates/todos.heml"));
|
|
assert!(!readme.contains("cargo run -p hemx-app"));
|
|
assert!(created.contains("keyed row partial"));
|
|
assert!(created.contains("generated append/replace/remove/dynamic-batch effects"));
|
|
assert!(destination.join("src/lib.rs").exists());
|
|
let _ = fs::remove_dir_all(&destination);
|
|
}
|
|
|
|
#[test]
|
|
fn app_new_mobile_creates_phone_first_release_starter() {
|
|
// req: ceremony/006 req: examples/006
|
|
let destination = PathBuf::from("target/test-hemx-app-new-mobile");
|
|
let _ = fs::remove_dir_all(&destination);
|
|
|
|
create_mobile_app_scaffold(&destination).expect("create mobile app scaffold");
|
|
let readme = fs::read_to_string(destination.join("README.md")).expect("readme");
|
|
let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs");
|
|
let mobile_readme =
|
|
fs::read_to_string(destination.join("MOBILE_STARTER.md")).expect("mobile docs");
|
|
let command = fs::read_to_string(destination.join("hemx-app")).expect("command");
|
|
let lib_rs = fs::read_to_string(destination.join("src/lib.rs")).expect("lib rs");
|
|
let template =
|
|
fs::read_to_string(destination.join("templates/workout.heml")).expect("template");
|
|
|
|
assert!(readme.contains("# hemx mobile app"));
|
|
assert!(readme.contains("./hemx-app dev"));
|
|
assert!(
|
|
readme.contains("HEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release")
|
|
);
|
|
assert!(readme.contains("Do not treat it as a native UI framework"));
|
|
assert!(!readme.contains("cargo run -p hemx-xtask -- workout"));
|
|
assert!(!readme.contains("hemx-workout"));
|
|
assert!(created.contains("# Created hemx mobile app"));
|
|
assert!(created.contains("./hemx-app doctor"));
|
|
assert!(!created.contains("Created Workout app"));
|
|
assert!(mobile_readme.contains("phone-first path"));
|
|
assert!(mobile_readme.contains("typed host haptics/share calls"));
|
|
assert!(mobile_readme.contains("command/event/projection recovery truth"));
|
|
assert!(mobile_readme.contains("app-owned `hemx-app mobile-release`"));
|
|
assert!(mobile_readme.contains("does not add a hemx mobile framework"));
|
|
assert!(command.contains("mobile-release"));
|
|
assert!(command.contains("mobile-verify"));
|
|
assert!(command.contains("HEMX_APP_ORIGIN"));
|
|
assert!(command.contains("target/hemx-mobile/app"));
|
|
assert!(!command.contains("HEMX_WORKOUT_"));
|
|
assert!(command.contains("app-owned command/event/projection records"));
|
|
assert!(command.contains("external_blockers"));
|
|
assert!(lib_rs.contains("HostCall::Share"));
|
|
assert!(lib_rs.contains("WorkoutCommand"));
|
|
assert!(lib_rs.contains("WorkoutEvent"));
|
|
assert!(template.contains("<form"));
|
|
assert!(template.contains("data-hemx-slot"));
|
|
assert!(destination.join("tests/e2e.rs").exists());
|
|
assert!(destination.join("hemx-app").exists());
|
|
assert!(!destination.join("hemx-workout").exists());
|
|
let _ = fs::remove_dir_all(&destination);
|
|
}
|
|
|
|
#[test]
|
|
fn workout_new_creates_standalone_app_manifest() {
|
|
// req: examples/001 req: examples/006
|
|
let destination = PathBuf::from("target/test-workout-new-app");
|
|
let _ = fs::remove_dir_all(&destination);
|
|
|
|
create_workout_app(&destination).expect("create app");
|
|
let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest");
|
|
|
|
assert!(manifest.contains("name = \"hemx-workout-app\""));
|
|
assert!(manifest.contains("name = \"workout-app\""));
|
|
assert!(manifest.contains("edition = \"2021\""));
|
|
assert!(manifest.contains("hemx-build"));
|
|
let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs");
|
|
let e2e_rs = fs::read_to_string(destination.join("tests/e2e.rs")).expect("e2e rs");
|
|
assert!(main_rs.contains("hemx_workout_app"));
|
|
assert!(e2e_rs.contains("hemx_workout_app"));
|
|
assert!(e2e_rs.contains("CARGO_BIN_EXE_workout-app"));
|
|
assert!(!main_rs.contains("hemx_workout_example"));
|
|
assert!(!e2e_rs.contains("hemx_workout_example"));
|
|
assert!(!e2e_rs.contains("hemx-workout-example"));
|
|
let command = fs::read_to_string(destination.join("hemx-workout")).expect("command");
|
|
assert!(command.contains("mobile-release"));
|
|
assert!(command.contains("mobile-verify"));
|
|
assert!(command.contains(hemx_js::RUNTIME_JS_HASH));
|
|
assert!(command.contains("app-owned command/event/projection records"));
|
|
assert!(command.contains("external_blockers"));
|
|
assert!(command.contains("check_external_blockers"));
|
|
assert!(command.contains("BLOCKERS.md missing external blocker"));
|
|
assert!(destination.join("src/lib.rs").exists());
|
|
assert!(destination.join("templates/workout.heml").exists());
|
|
let _ = fs::remove_dir_all(&destination);
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_manifest_names_production_boundaries() {
|
|
// req: examples/001 req: local/001 req: host/002
|
|
let config = workout_mobile_config("https://workout.example.com");
|
|
let blockers = vec!["Android signing key not configured".to_string()];
|
|
let manifest = workout_mobile_manifest(&config, &blockers);
|
|
|
|
assert!(manifest.contains("target/release/hemx-workout-example"));
|
|
assert!(manifest.contains(hemx_js::RUNTIME_JS_PATH));
|
|
assert!(manifest.contains(hemx_js::RUNTIME_JS_HASH));
|
|
assert!(manifest.contains("asset-integrity.tsv"));
|
|
assert!(manifest.contains("app-owned command/event/projection records"));
|
|
assert!(manifest.contains(
|
|
"\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"
|
|
));
|
|
assert!(manifest.contains("secrets and signing credentials stay outside the repo"));
|
|
assert!(manifest.contains("Android signing key not configured"));
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_release_uses_https_origin_and_host_metadata() {
|
|
// req: examples/001 req: host/002
|
|
let config = workout_mobile_config("https://workout.example.com/app");
|
|
let android = android_twa_release_json(&config);
|
|
|
|
assert_eq!(origin_host(&config.origin), "workout.example.com");
|
|
assert!(android.contains("\"start_url\": \"https://workout.example.com/app/\""));
|
|
assert!(android.contains("\"host\": \"workout.example.com\""));
|
|
assert!(android.contains("\"host_capabilities\": [\"share\", \"haptics\"]"));
|
|
assert!(android.contains(
|
|
"\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_doctor_rejects_non_https_production_origin() {
|
|
// req: examples/001
|
|
let config = workout_mobile_config("http://workout.example.com");
|
|
let blockers = mobile_external_blockers(&config);
|
|
|
|
assert!(blockers
|
|
.iter()
|
|
.any(|blocker| blocker.contains("production HTTPS origin")));
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_verify_fails_closed_on_non_https_origin() {
|
|
// req: examples/011
|
|
let config = workout_mobile_config_at(
|
|
"http://workout.example.com",
|
|
"target/test-workout-mobile-non-https",
|
|
);
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
write_workout_mobile_release(&config, &mobile_external_blockers(&config))
|
|
.expect("write release kit");
|
|
|
|
let failures = verify_workout_mobile_release(&config, false);
|
|
|
|
assert!(failures
|
|
.iter()
|
|
.any(|failure| failure.contains("production HTTPS origin")));
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_verify_accepts_generated_release_kit() {
|
|
// req: examples/011
|
|
let config = workout_mobile_config_at(
|
|
"https://workout.example.com",
|
|
"target/test-workout-mobile-accepts",
|
|
);
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
write_workout_mobile_release(&config, &mobile_external_blockers(&config))
|
|
.expect("write release kit");
|
|
|
|
assert!(verify_workout_mobile_release(&config, false).is_empty());
|
|
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_verify_rejects_unacknowledged_external_blockers() {
|
|
// req: examples/011
|
|
let config = workout_mobile_config_at(
|
|
"https://workout.example.com",
|
|
"target/test-workout-mobile-unacknowledged-blockers",
|
|
);
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
write_workout_mobile_release(&config, &[]).expect("write release kit");
|
|
|
|
let failures = verify_workout_mobile_release(&config, false);
|
|
|
|
assert!(failures.iter().any(|failure| {
|
|
failure.contains("release-manifest.json") && failure.contains("Android")
|
|
}));
|
|
assert!(failures
|
|
.iter()
|
|
.any(|failure| failure.contains("BLOCKERS.md") && failure.contains("Android")));
|
|
assert!(failures
|
|
.iter()
|
|
.any(|failure| failure.contains("Google Play")));
|
|
assert!(failures
|
|
.iter()
|
|
.any(|failure| failure.contains("App Store Connect")));
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
}
|
|
|
|
#[test]
|
|
fn workout_mobile_verify_rejects_missing_release_kit() {
|
|
// req: examples/011
|
|
let config = workout_mobile_config_at(
|
|
"https://workout.example.com",
|
|
"target/test-workout-mobile-missing",
|
|
);
|
|
let _ = fs::remove_dir_all(&config.out_dir);
|
|
|
|
let failures = verify_workout_mobile_release(&config, false);
|
|
|
|
assert!(failures
|
|
.iter()
|
|
.any(|failure| failure.contains("release-manifest.json is missing")));
|
|
}
|
|
|
|
fn workout_mobile_config(origin: &str) -> WorkoutMobileConfig {
|
|
workout_mobile_config_at(origin, "target/test-workout-mobile")
|
|
}
|
|
|
|
fn workout_mobile_config_at(origin: &str, out_dir: &str) -> WorkoutMobileConfig {
|
|
WorkoutMobileConfig {
|
|
app_id: "com.hemx.workout".into(),
|
|
app_name: "hemx Workout Copilot".into(),
|
|
version: "1.2.3".into(),
|
|
origin: origin.into(),
|
|
android_package: "com.hemx.workout".into(),
|
|
ios_bundle_id: "com.hemx.workout".into(),
|
|
out_dir: PathBuf::from(out_dir),
|
|
}
|
|
}
|
|
}
|