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:
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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>);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user