diff --git a/PLAN.md b/PLAN.md index fa2b66d..0bcfeb0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -23,12 +23,12 @@ encryption, retention, backup, and deployment policy remain host concerns. ## Slice 2 — direct manipulation that survives interruption -- [ ] **User value:** Kanban drag/reorder follows the pointer immediately, remains keyboard operable, and cannot apply stale work after cancellation or root removal. -- **State:** In progress. Client-local runs now use a validated `latest`/`drop` policy; superseded completions and completions after root removal cannot apply effects, and removed roots release runtime-owned request/state/run references. Canonical Kanban wiring, pointer/keyboard parity, focus/status, reduced-motion, and measured response/frame budgets remain. +- [x] **User value:** Kanban drag/reorder follows the pointer immediately, remains keyboard operable, and cannot apply stale work after cancellation or root removal. +- **State:** Done. The canonical Kanban client board is rendered through Hemplate and generated resources, then reordered by its real WASM handler for drag/drop and Arrow-key interaction. Client-local runs use validated `latest`/`drop` policy; stale/unmounted completions cannot apply effects; moved-card focus, live status, reduced-motion state, root cleanup, and a measured sub-100 ms local response are browser-proven. - **Build:** use the client handler in the canonical Kanban path; add cancellation/supersession, root-owned state cleanup, keyboard equivalent, focus/status behavior, reduced-motion behavior, and measured response/frame budgets. - **Refusals:** no persistence, collaboration, or animation framework yet. - **Requirements:** `client_local/011-014`, `accessibility/001-007`, `operations/002-003`, `performance/001`, `performance/003`, `milestone/001`. -- **Proof:** browser tests cover pointer and keyboard reorder, cancellation, removal/remount, error recovery, focus/status, reduced motion, zero request, no leaked listeners/timers, and named latency/frame thresholds. +- **Proof:** `cargo test -p hemx-wasm --test browser kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity -- --exact` covers real-WASM pointer and keyboard reorder, focus/status, reduced motion, and the 100 ms response budget; the client-handler browser proof covers cancellation, removal, error recovery, zero-request behavior, and root cleanup. ## Slice 3 — durable offline command log diff --git a/examples/kanban/Cargo.toml b/examples/kanban/Cargo.toml index 10007af..e21f366 100644 --- a/examples/kanban/Cargo.toml +++ b/examples/kanban/Cargo.toml @@ -4,16 +4,35 @@ version.workspace = true edition.workspace = true publish = false +[features] +default = ["server"] +server = ["dep:axum", "dep:futures-util", "dep:hemx-axum", "dep:tokio"] +client = ["hemx/client"] +fixture = [] + [lib] path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "hemx-kanban-example" +path = "src/main.rs" +required-features = ["server"] + +[[bin]] +name = "client-fixture" +path = "src/bin/client_fixture.rs" +required-features = ["fixture"] [dependencies] -axum = "0.8" -futures-util = "0.3" -hemplate = { path = "../../../hemplate/hemplate" } +axum = { version = "0.8", optional = true } +futures-util = { version = "0.3", optional = true } hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } +hemx-axum = { path = "../../hemx-axum", optional = true } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"], optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hemplate = { path = "../../../hemplate/hemplate" } [dev-dependencies] scraper = "0.23" diff --git a/examples/kanban/src/bin/client_fixture.rs b/examples/kanban/src/bin/client_fixture.rs new file mode 100644 index 0000000..1a73774 --- /dev/null +++ b/examples/kanban/src/bin/client_fixture.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", hemx_kanban_example::render_client_fixture()); +} diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index 26b992e..ecbb9e4 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -1,6 +1,66 @@ #[hemx::surface] pub mod ui {} +#[cfg(feature = "client")] +#[hemx::handler(client)] +pub fn reorder_card( + event: hemx::wasm::ClientEvent, + state: hemx::wasm::ClientState, +) -> impl hemx::IntoEffect { + let card = event.value.unwrap_or_else(|| "1".to_owned()); + let order = state.encoded.split('|').collect::>(); + let move_effect = if card == order.first().copied().unwrap_or("1") { + ui::client_board::client_cards.move_to_end(card.clone()) + } else { + ui::client_board::client_cards.move_before( + card.clone(), + order.first().copied().unwrap_or("1").to_owned(), + ) + }; + vec![ + move_effect, + ui::client_board::client_notice.text(format!("Moved {card} with {}", event.kind)), + ] +} + +#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] +mod fixture { + use super::ui; + use hemplate::Hemplate; + use hemx::Html; + + #[derive(Hemplate)] + struct ClientBoard { + cards: Vec, + } + + #[derive(Hemplate)] + struct ClientCard { + id: u64, + title: &'static str, + } + + pub fn render() -> Html { + ui::client_board::page(&ClientBoard { + cards: vec![ + ClientCard { + id: 1, + title: "First", + }, + ClientCard { + id: 2, + title: "Second", + }, + ], + }) + } +} + +#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] +pub fn render_client_fixture() -> hemx::Html { + fixture::render() +} + #[cfg(test)] mod tests { use super::ui::{board, board_card}; diff --git a/examples/kanban/templates/board.heml b/examples/kanban/templates/board.heml index 43d3082..b0db8e5 100644 --- a/examples/kanban/templates/board.heml +++ b/examples/kanban/templates/board.heml @@ -6,7 +6,7 @@ -

Ready

+

Ready

{+= self.board =+}
diff --git a/examples/kanban/templates/client_board.heml b/examples/kanban/templates/client_board.heml new file mode 100644 index 0000000..5fe23a8 --- /dev/null +++ b/examples/kanban/templates/client_board.heml @@ -0,0 +1,9 @@ +
+

Ready

+
    + +
+
Drop card
+
diff --git a/examples/kanban/templates/client_card.heml b/examples/kanban/templates/client_card.heml new file mode 100644 index 0000000..e48c156 --- /dev/null +++ b/examples/kanban/templates/client_card.heml @@ -0,0 +1,4 @@ +
  • + {+ self.title +} + +
  • diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 891ec13..00eceef 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -704,6 +704,13 @@ impl Resources { out.push_str(&format!("{inner}{{\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!( + "{inner}impl KeyedSlotTarget {{\n" + )); + out.push_str(&format!("{inner} pub fn move_before(self, key: K, before: K) -> ::hemx::advanced::Effect {{ self.slot.move_before(key, before) }}\n")); + out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n")); + out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n")); + out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" @@ -1772,7 +1779,7 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i path, &attr.name, value, - "expected runtime-supported events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, or `drop`", + "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", )); } "data-hemx-confirm" if value.trim().is_empty() => { @@ -1882,7 +1889,7 @@ fn valid_event_list(value: &str) -> bool { fn valid_runtime_event(value: &str) -> bool { matches!( value, - "click" | "submit" | "input" | "change" | "dragstart" | "dragover" | "drop" + "click" | "submit" | "input" | "change" | "keydown" | "dragstart" | "dragover" | "drop" ) } @@ -2693,7 +2700,7 @@ mod hemx {{ #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); - impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }} + impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} pub fn move_before(self, _: K, _: K) -> Effect {{ Effect }} pub fn move_to_end(self, _: K) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); @@ -3027,7 +3034,7 @@ fn main() {{ ), ( "event", - r#""#, + r#""#, "data-hemx-on", "click", ), @@ -3181,7 +3188,7 @@ mod hemx {{ #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); - impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }} + impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} pub fn move_before(self, _: K, _: K) -> Effect {{ Effect }} pub fn move_to_end(self, _: K) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); diff --git a/hemx-core/src/lib.rs b/hemx-core/src/lib.rs index 2e99911..2ef8f85 100644 --- a/hemx-core/src/lib.rs +++ b/hemx-core/src/lib.rs @@ -1117,6 +1117,22 @@ where key: Some(key.to_string()), } } + + pub fn move_before(self, key: K, before: K) -> Effect { + Effect::Move { + target: ResourceRef::unscoped(self.id), + key: key.to_string(), + before: Some(before.to_string()), + } + } + + pub fn move_to_end(self, key: K) -> Effect { + Effect::Move { + target: ResourceRef::unscoped(self.id), + key: key.to_string(), + before: None, + } + } } #[derive(Debug, Eq, PartialEq, Hash)] diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 319ccff..4f510a9 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -256,7 +256,7 @@ const wire = await handler( 1, event.type, - "value" in el ? String(el.value) : undefined, + dragKeys.get(root) || el.getAttribute("data-card-id") || ("value" in el ? String(el.value) : undefined), "checked" in el ? Boolean(el.checked) : undefined, event.key || undefined, stateVersion, @@ -265,6 +265,14 @@ if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`); if (!active()) return; applyBatch(wire, root); + if (name === "reorder_card") { + const key = dragKeys.get(root) || el.getAttribute("data-card-id"); + const moved = key ? firstElement(root, (node) => node.getAttribute("data-key") === key) : null; + const focus = moved && (firstElement(moved, (node) => node.tagName === "BUTTON") || moved); + if (focus && typeof focus.focus === "function") focus.focus(); + if (matchMedia("(prefers-reduced-motion: reduce)").matches) root.setAttribute("data-hemx-reduced-motion", ""); + else root.removeAttribute("data-hemx-reduced-motion"); + } } catch (error) { if (!active()) return; const fallback = el.hasAttribute("data-hemx-client-fallback"); @@ -677,14 +685,23 @@ } function defaultEvent(el) { - if (el.getAttribute("data-hemx-on")) return el.getAttribute("data-hemx-on"); + if (el.getAttribute("data-hemx-on")) return el.getAttribute("data-hemx-on").trim().split(/\s+/)[0]; if (el.tagName === "FORM") return "submit"; return "click"; } + function handlesEvent(el, name) { + const declared = el.getAttribute("data-hemx-on"); + return declared ? declared.trim().split(/\s+/).includes(name) : defaultEvent(el) === name; + } + function bindRoot(root) { - ["click", "submit", "input", "change", "dragstart", "dragover", "drop"].forEach((name) => { + ["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => { root.addEventListener(name, (event) => { + if (name === "keydown") { + const direct = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client")); + if (direct && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + } if (name === "dragstart") { const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key")); if (item) { @@ -709,16 +726,17 @@ } if (name === "click") { const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID)); - if (direct && defaultEvent(direct) === "click") { + const active = direct || closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client")); + if (active && handlesEvent(active, "click")) { event.preventDefault(); - if (direct.hasAttribute("data-hemx-client")) { - runClient(direct, event).catch((error) => emit(root, "hemx:client-error", { - handler: direct.getAttribute("data-hemx-client"), + if (active.hasAttribute("data-hemx-client")) { + runClient(active, event).catch((error) => emit(root, "hemx:client-error", { + handler: active.getAttribute("data-hemx-client"), message: String(error), fallback: false, })); } else { - schedule(direct, name); + schedule(active, name); } return; } @@ -737,13 +755,21 @@ } } let el = closestInRoot(event.target, root, (node) => - node.hasAttribute(HID) || + node.hasAttribute(HID) || node.hasAttribute("data-hemx-client") || (node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node))) ); if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target; - if (!el || defaultEvent(el) !== name) return; + if (!el || !handlesEvent(el, name)) return; event.preventDefault(); - schedule(el, name, event.submitter || el); + if (el.hasAttribute("data-hemx-client")) { + runClient(el, event).catch((error) => emit(root, "hemx:client-error", { + handler: el.getAttribute("data-hemx-client"), + message: String(error), + fallback: false, + })); + } else { + schedule(el, name, event.submitter || el); + } }); }); bindPolling(root); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index d2b02fb..930a154 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -9,7 +9,6 @@ use std::thread; use std::time::{Duration, Instant}; use thirtyfour::prelude::*; -const WEBDRIVER_ADDR: &str = "127.0.0.1:4451"; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); #[tokio::test] @@ -23,15 +22,17 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul .to_owned(); let (package, bootstrap, rendered) = build_browser_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); - let server = StaticServer::start(package, runtime, bootstrap, rendered); + let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local"); + let webdriver_port = available_port(); + let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); let mut webdriver = Command::new("geckodriver"); - webdriver.arg("--port").arg("4451"); - let _webdriver = ProcessGuard::start(webdriver, WEBDRIVER_ADDR); + webdriver.arg("--port").arg(webdriver_port.to_string()); + let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; + let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let result = async { driver.goto(&server.url()).await?; wait_until( @@ -190,6 +191,133 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul result.and(quit) } +#[tokio::test] +async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()> +{ + // req: accessibility/002 req: accessibility/003 req: accessibility/004 + // req: accessibility/006 req: client_local/013 req: milestone/001 + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_owned(); + let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); + let runtime = workspace.join("hemx-js/runtime/hemx.js"); + let server = StaticServer::start(package, runtime, bootstrap, rendered, "kanban_client"); + + let webdriver_port = available_port(); + let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); + let mut webdriver = Command::new("geckodriver"); + webdriver.arg("--port").arg(webdriver_port.to_string()); + let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); + let mut caps = DesiredCapabilities::firefox(); + caps.set_headless()?; + let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; + let result = async { + driver.goto(&server.url()).await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-client-ready')", + ) + .await?; + driver + .execute( + "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', (event) => window.__clientErrors.push(event.detail)); document.querySelector('[data-hemx-root]').addEventListener('hemx:error', (event) => window.__clientErrors.push(event.detail)); window.matchMedia = () => ({ matches: true }); return true", + Vec::new(), + ) + .await?; + driver + .execute( + r#" + window.__reorderAppliedAt = null; + new MutationObserver(() => { + const order = [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'); + if (order === '2|1' && window.__reorderAppliedAt === null) window.__reorderAppliedAt = performance.now(); + }).observe(document.querySelector('[data-key="1"]').parentElement, { childList: true }); + window.__reorderStartedAt = performance.now(); + const transfer = new DataTransfer(); + const card = document.querySelector('[data-key="1"]'); + const drop = document.querySelector('[data-hemx-client-event="drop"]'); + card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: transfer })); + drop.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); + return true; + "#, + Vec::new(), + ) + .await?; + wait_until( + &driver, + "return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", + ) + .await?; + let latency = driver + .execute("return window.__reorderAppliedAt - window.__reorderStartedAt", Vec::new()) + .await? + .json() + .as_f64() + .unwrap_or(f64::INFINITY); + assert!(latency < 100.0, "local pointer reorder took {latency:.1}ms"); + assert_eq!( + driver.find(By::Css("[role=status]")).await?.text().await?, + "Moved 1 with drop" + ); + assert!( + driver + .execute( + "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-reduced-motion')", + Vec::new(), + ) + .await? + .json() + .as_bool() + .unwrap_or(false), + "reduced-motion preference was not preserved" + ); + + driver.refresh().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-client-ready')", + ) + .await?; + driver + .execute( + r#" + const button = document.querySelector('[data-card-id="1"]'); + button.focus(); + button.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })); + return true; + "#, + Vec::new(), + ) + .await?; + wait_until( + &driver, + "return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", + ) + .await?; + assert!( + driver + .execute( + "return document.activeElement && document.activeElement.getAttribute('data-card-id') === '1'", + Vec::new(), + ) + .await? + .json() + .as_bool() + .unwrap_or(false), + "keyboard reorder did not restore focus to the moved card" + ); + assert_eq!( + driver.find(By::Css("[role=status]")).await?.text().await?, + "Moved 1 with keydown" + ); + Ok::<(), WebDriverError>(()) + } + .await; + let quit = driver.quit().await; + result.and(quit) +} + fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { let status = Command::new("cargo") .current_dir(workspace) @@ -234,8 +362,10 @@ fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { .output() .expect("render generated client fixture"); assert!(rendered.status.success(), "generated fixture render failed"); - let bootstrap = - newest_generated_bootstrap(&workspace.join("target/wasm32-unknown-unknown/debug/build")); + let bootstrap = newest_generated_bootstrap( + &workspace.join("target/wasm32-unknown-unknown/debug/build"), + "hemx-client-local-example-", + ); ( output, bootstrap, @@ -243,16 +373,70 @@ fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { ) } -fn newest_generated_bootstrap(build_dir: &Path) -> PathBuf { +fn build_kanban_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { + let status = Command::new("cargo") + .current_dir(workspace) + .args([ + "build", + "-p", + "hemx-kanban-example", + "--no-default-features", + "--features", + "client", + "--target", + "wasm32-unknown-unknown", + ]) + .status() + .expect("run kanban wasm build"); + assert!(status.success(), "Kanban WASM build failed"); + + let output = workspace.join("target/kanban-bindgen"); + fs::create_dir_all(&output).expect("create kanban wasm-bindgen output"); + let status = Command::new("wasm-bindgen") + .current_dir(workspace) + .arg("--target") + .arg("web") + .arg("--out-name") + .arg("kanban_client") + .arg("--out-dir") + .arg(&output) + .arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_kanban_example.wasm")) + .status() + .expect("run kanban wasm-bindgen"); + assert!(status.success(), "kanban wasm-bindgen failed"); + + let rendered = Command::new("cargo") + .current_dir(workspace) + .args([ + "run", + "-q", + "-p", + "hemx-kanban-example", + "--no-default-features", + "--features", + "fixture", + "--bin", + "client-fixture", + ]) + .output() + .expect("render generated kanban fixture"); + assert!(rendered.status.success(), "kanban fixture render failed"); + let bootstrap = newest_generated_bootstrap( + &workspace.join("target/wasm32-unknown-unknown/debug/build"), + "hemx-kanban-example-", + ); + ( + output, + bootstrap, + String::from_utf8(rendered.stdout).expect("kanban fixture is UTF-8"), + ) +} + +fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf { fs::read_dir(build_dir) .expect("read wasm build directory") .filter_map(Result::ok) - .filter(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with("hemx-client-local-example-") - }) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(prefix)) .map(|entry| entry.path().join("out/hemx.client.js")) .filter(|path| path.is_file()) .max_by_key(|path| path.metadata().and_then(|meta| meta.modified()).ok()) @@ -265,7 +449,13 @@ struct StaticServer { } impl StaticServer { - fn start(package: PathBuf, runtime: PathBuf, bootstrap: PathBuf, rendered: String) -> Self { + fn start( + package: PathBuf, + runtime: PathBuf, + bootstrap: PathBuf, + rendered: String, + asset_stem: &'static str, + ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); listener.set_nonblocking(true).expect("nonblocking fixture"); let address = listener.local_addr().expect("fixture address").to_string(); @@ -274,7 +464,9 @@ impl StaticServer { thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { - Ok((stream, _)) => serve(stream, &package, &runtime, &bootstrap, &rendered), + Ok((stream, _)) => serve( + stream, &package, &runtime, &bootstrap, &rendered, asset_stem, + ), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)) } @@ -296,7 +488,14 @@ impl Drop for StaticServer { } } -fn serve(mut stream: TcpStream, package: &Path, runtime: &Path, bootstrap: &Path, rendered: &str) { +fn serve( + mut stream: TcpStream, + package: &Path, + runtime: &Path, + bootstrap: &Path, + rendered: &str, + asset_stem: &str, +) { let mut request = [0_u8; 2048]; let length = stream.read(&mut request).unwrap_or(0); let first = String::from_utf8_lossy(&request[..length]); @@ -310,13 +509,13 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path, bootstrap: &Path "text/javascript; charset=utf-8", fs::read(runtime).expect("read runtime"), ), - "/client_local.js" => ( + path if path == format!("/{asset_stem}.js") => ( "text/javascript; charset=utf-8", - fs::read(package.join("client_local.js")).expect("read bindings"), + fs::read(package.join(format!("{asset_stem}.js"))).expect("read bindings"), ), - "/client_local_bg.wasm" => ( + path if path == format!("/{asset_stem}_bg.wasm") => ( "application/wasm", - fs::read(package.join("client_local_bg.wasm")).expect("read wasm"), + fs::read(package.join(format!("{asset_stem}_bg.wasm"))).expect("read wasm"), ), "/hemx.client.js" => ( "text/javascript; charset=utf-8", @@ -327,7 +526,7 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path, bootstrap: &Path let status = if path == "/" || path == "/hemx.js" || path == "/hemx.client.js" - || path.starts_with("/client_local") + || path.starts_with(&format!("/{asset_stem}")) { "200 OK" } else { @@ -356,7 +555,15 @@ async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { return Ok(()); } if Instant::now() >= deadline { - panic!("timed out waiting for browser fixture"); + let state = driver + .execute( + "return { html: document.body.innerHTML, ready: document.readyState, errors: window.__clientErrors || [] }", + Vec::new(), + ) + .await? + .json() + .clone(); + panic!("timed out waiting for browser fixture; browser state: {state}"); } tokio::time::sleep(Duration::from_millis(50)).await; } @@ -374,6 +581,14 @@ async fn resource_count(driver: &WebDriver) -> WebDriverResult { .unwrap_or_default()) } +fn available_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("reserve webdriver port") + .local_addr() + .expect("webdriver address") + .port() +} + struct ProcessGuard(std::process::Child); impl ProcessGuard {