feat(kanban): prove accessible local reorder

req: client_local/013\nreq: accessibility/002\nreq: accessibility/003\nreq: accessibility/004\nreq: accessibility/006\nreq: milestone/001
This commit is contained in:
slhx agent
2026-07-13 14:29:21 +02:00
parent dd5ea0827f
commit 7eb65d77c8
11 changed files with 407 additions and 48 deletions
+3 -3
View File
@@ -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
+24 -5
View File
@@ -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"
@@ -0,0 +1,3 @@
fn main() {
print!("{}", hemx_kanban_example::render_client_fixture());
}
+60
View File
@@ -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::<Vec<_>>();
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<ClientCard>,
}
#[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};
+1 -1
View File
@@ -6,7 +6,7 @@
<select name="column" required="required">{+= self.options =+}</select>
<button type="submit">Add card</button>
</form>
<p data-hemx-slot="notice">Ready</p>
<p id="kanban-status" data-hemx-slot="notice" role="status" aria-live="polite">Ready</p>
</header>
<div data-hemx-slot="board">{+= self.board =+}</div>
@@ -0,0 +1,9 @@
<section data-hemx-root="kanban_client" data-hemx-st="1|2" data-hemx-client-state-version="1" data-hemx-client-module="/kanban_client.js">
<p id="kanban-status" data-hemx-slot="client_notice" role="status" aria-live="polite">Ready</p>
<ul data-hemx-slot="client_cards">
<template h-for="card in &self.cards" h-key="card.id">
{+ card +}
</template>
</ul>
<div data-hemx-handle="reorder_card" data-hemx-on="drop" data-hemx-client="reorder_card" data-hemx-client-event="drop" data-hemx-client-policy="latest">Drop card</div>
</section>
@@ -0,0 +1,4 @@
<li class="card" +data-key="self.id" draggable="true" data-hemx-handle="client_card" data-hemx-on="dragstart">
<span>{+ self.title +}</span>
<button type="button" data-hemx-handle="client_move_right" data-hemx-on="click keydown" data-hemx-client="reorder_card" data-hemx-client-event="click keydown" data-hemx-client-policy="latest" +data-card-id="self.id" aria-describedby="kanban-status">Move right</button>
</li>
+12 -5
View File
@@ -704,6 +704,13 @@ impl Resources {
out.push_str(&format!("{inner}{{\n"));
out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot<K, T>) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n"));
out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!(
"{inner}impl<K: ::std::string::ToString, T, C> KeyedSlotTarget<K, T, C> {{\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<T> 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<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }}
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> 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<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
@@ -3027,7 +3034,7 @@ fn main() {{
),
(
"event",
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
r#"<button data-hemx-handle="save" data-hemx-on="blur">Save</button>"#,
"data-hemx-on",
"click",
),
@@ -3181,7 +3188,7 @@ mod hemx {{
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }}
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> 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<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
+16
View File
@@ -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)]
+36 -10
View File
@@ -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();
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);
+238 -23
View File
@@ -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<u64> {
.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 {