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:
+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