Files
hemx/hemx-wasm/tests/browser.rs
T
2026-07-13 15:11:08 +02:00

1184 lines
44 KiB
Rust

use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use thirtyfour::prelude::*;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
#[tokio::test]
async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> {
// req: client_local/005 req: client_local/009 req: client_local/010
// req: client_local/011 req: client_local/012
// test: client_local/014
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.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, "client_local", None);
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)); return true",
Vec::new(),
)
.await?;
let network_before = resource_count(&driver).await?;
driver
.execute(
r#"
window.__resolveClientRuns = [];
const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => {
window.__resolveClientRuns.push(() => resolve(actual(...args)));
}));
window.__actualClientHandler = actual;
return true;
"#,
Vec::new(),
)
.await?;
let button = driver
.find(By::Css("[data-hemx-client='increment']"))
.await?;
button.click().await?;
button.click().await?;
wait_until(&driver, "return window.__resolveClientRuns.length === 2").await?;
driver
.execute(
"window.__resolveClientRuns[0](); return true",
Vec::new(),
)
.await?;
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
driver
.find(By::Css("[data-sid]"))
.await?
.prop("textContent")
.await?
.unwrap_or_default(),
"idle",
"superseded completion applied stale effects"
);
driver
.execute(
"window.__resolveClientRuns[1](); return true",
Vec::new(),
)
.await?;
wait_until(
&driver,
"return document.querySelector('[data-sid]').textContent.includes('updated by Rust/WASM (click, count=3)')",
)
.await?;
assert_eq!(
resource_count(&driver).await?,
network_before,
"client handler made a network request"
);
driver
.execute(
"window.hemx.registerClientHandler('increment', window.__actualClientHandler); return true",
Vec::new(),
)
.await?;
driver
.execute(
"document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true",
Vec::new(),
)
.await?;
driver
.find(By::Css("[data-hemx-client='increment']"))
.await?
.click()
.await?;
wait_until(&driver, "return window.__clientErrors.length === 1").await?;
assert!(
driver
.execute("return window.__clientErrors[0].message", Vec::new())
.await?
.json()
.as_str()
.unwrap_or_default()
.contains("unsupported client-local state ABI version 2; expected 1"),
"invalid state must produce an actionable client-local diagnostic"
);
assert_eq!(
resource_count(&driver).await?,
network_before + 1,
"declared server fallback was not requested"
);
assert!(
driver
.execute(
"return !document.querySelector('[data-hemx-client]').classList.contains('is-pending')",
Vec::new(),
)
.await?
.json()
.as_bool()
.unwrap_or(false),
"invalid input must restore pending UI"
);
driver
.execute(
r#"
window.__resolveUnmount = null;
const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => {
window.__resolveUnmount = () => resolve(actual(...args));
}));
document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '1');
return true;
"#,
Vec::new(),
)
.await?;
driver
.find(By::Css("[data-hemx-client='increment']"))
.await?
.click()
.await?;
driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); window.__removedRoot = root; root.remove(); window.__resolveUnmount(); return true",
Vec::new(),
)
.await?;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
driver
.execute(
"return !window.__removedRoot.textContent.includes('updated by Rust/WASM')",
Vec::new(),
)
.await?
.json()
.as_bool()
.unwrap_or(false),
"unmounted root accepted a late effect"
);
Ok::<(), WebDriverError>(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()>
{
// test req: local/001 req: local/002 req: local/003 req: local/004
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 mut server = StaticServer::start(
package,
runtime,
bootstrap,
rendered,
"kanban_client",
Some(kanban_app_assets(&workspace)),
);
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,
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
)
.await?;
driver
.execute(
r#"
const root = document.querySelector('[data-hemx-root]');
window.__persistedCommand = null;
root.addEventListener('kanban:command-persisted', (event) => {
window.__persistedCommand = {
detail: event.detail,
order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'),
};
}, { once: true });
return true;
"#,
Vec::new(),
)
.await?;
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
)
.await?;
let persisted = driver
.execute(
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(persisted["persisted"]["order"], "1|2");
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 1);
assert_eq!(persisted["persisted"]["detail"]["causal"], 1);
let actor = persisted["persisted"]["detail"]["actor"]
.as_str()
.expect("persisted actor");
let session = persisted["persisted"]["detail"]["session"]
.as_str()
.expect("persisted session");
assert!(!actor.is_empty());
assert!(!session.is_empty());
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
assert_eq!(persisted["count"], "1");
let offline_shell = driver
.execute_async(
"const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(offline_shell["controlled"], true, "{offline_shell}");
assert_eq!(offline_shell["shell"], true, "{offline_shell}");
assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1");
server.stop();
assert!(!server.is_reachable(), "fixture server must be unreachable");
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until(
&driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
)
.await?;
let restored = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(restored["order"], "2|1");
assert_eq!(restored["count"], "1");
assert!(restored["error"].is_null());
assert_eq!(restored["notice"], "Moved 1 with click");
driver
.execute(
r#"
window.__futureCommandStored = false;
const request = indexedDB.open('hemx-kanban-v1', 1);
request.onsuccess = () => {
const tx = request.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'future:2', schemaVersion: 2, actor: 'future', session: 'future',
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
});
tx.oncomplete = () => { window.__futureCommandStored = true; };
};
return true;
"#,
Vec::new(),
)
.await?;
wait_until(&driver, "return window.__futureCommandStored === true").await?;
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until(
&driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
)
.await?;
let rejected = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(
rejected["error"],
"restore: unsupported durable command future:2"
);
assert_eq!(rejected["ready"], false);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverResult<()> {
// test req: security/007 req: local/003
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",
Some(kanban_app_assets(&workspace)),
);
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,
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
)
.await?;
let controls = driver
.execute(
"return [...document.querySelectorAll('[data-kanban-command-action]')].map((button) => ({ tag: button.tagName, action: button.getAttribute('data-kanban-command-action'), text: button.textContent.trim() }))",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(controls.as_array().map(Vec::len), Some(3));
for control in controls.as_array().expect("recovery controls") {
assert_eq!(control["tag"], "BUTTON");
assert!(!control["text"].as_str().unwrap_or_default().is_empty());
}
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'",
)
.await?;
let first_export = export_commands(&driver).await?.json().clone();
assert_eq!(first_export["schemaVersion"], 1);
assert_eq!(first_export["commands"].as_array().map(Vec::len), Some(1));
assert_eq!(first_export["commands"][0]["schemaVersion"], 1);
assert_eq!(first_export["commands"][0]["kind"], "reorder_card");
assert_eq!(first_export["commands"][0]["cardId"], "1");
let mut exported_keys = first_export["commands"][0]
.as_object()
.expect("exported command")
.keys()
.map(String::as_str)
.collect::<Vec<_>>();
exported_keys.sort_unstable();
assert_eq!(
exported_keys,
[
"actor",
"cardId",
"causal",
"eventKind",
"id",
"key",
"kind",
"schemaVersion",
"session",
]
);
let first_actor = first_export["commands"][0]["actor"]
.as_str()
.expect("first actor")
.to_owned();
driver
.execute("window.__reloadPending = true", Vec::new())
.await?;
let delete = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete.click().await?;
assert_eq!(delete.text().await?, "Confirm delete commands");
assert_eq!(
driver
.find(By::Css("[data-hemx-root]"))
.await?
.attr("data-kanban-command-count")
.await?
.as_deref(),
Some("1")
);
delete.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.getAttribute('data-kanban-command-count') === '0'",
)
.await?;
let after_delete = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), error: root.getAttribute('data-kanban-command-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(after_delete["order"], "1|2");
assert!(after_delete["error"].is_null());
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'",
)
.await?;
let after_delete_export = export_commands(&driver).await?.json().clone();
assert_eq!(after_delete_export["commands"][0]["actor"], first_actor);
assert_eq!(after_delete_export["commands"][0]["causal"], 2);
driver
.execute("window.__reloadPending = true", Vec::new())
.await?;
let reset = driver
.find(By::Css("[data-kanban-command-action='reset']"))
.await?;
reset.click().await?;
assert_eq!(reset.text().await?, "Confirm reset local data");
assert_eq!(
driver
.find(By::Css("[data-hemx-root]"))
.await?
.attr("data-kanban-command-count")
.await?
.as_deref(),
Some("1")
);
reset.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready') && root.getAttribute('data-kanban-command-count') === '0'",
)
.await?;
let after_reset = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|')",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(after_reset, "1|2");
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'",
)
.await?;
let after_reset_export = export_commands(&driver).await?.json().clone();
assert_eq!(after_reset_export["commands"][0]["causal"], 1);
assert_ne!(after_reset_export["commands"][0]["actor"], first_actor);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriverResult<()> {
// test req: sync/015
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",
Some(kanban_app_assets(&workspace)),
);
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,
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
)
.await?;
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'",
)
.await?;
occupy_next_command_id(&driver).await?;
driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterFault = false; window.__commandFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterFault = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__commandFailure = event.detail; }, { once: true }); return true;",
Vec::new(),
)
.await?;
driver.find(By::Css("[data-card-id='2']")).await?.click().await?;
wait_until(&driver, "return window.__commandFailure !== null").await?;
let failed = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), notice: root.querySelector('[role=status]').textContent, count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), persisted: window.__persistedAfterFault, detail: window.__commandFailure }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(failed["order"], "2|1");
assert_eq!(failed["notice"], "Moved 1 with click");
assert_eq!(failed["count"], "1");
assert_eq!(failed["stage"], "persist");
assert_eq!(failed["code"], "ConstraintError");
assert_eq!(failed["detail"]["stage"], "persist");
assert_eq!(failed["detail"]["code"], "ConstraintError");
assert_eq!(failed["persisted"], false);
driver
.execute("window.__reloadPending = true", Vec::new())
.await?;
let delete = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete.click().await?;
delete.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.getAttribute('data-kanban-command-count') === '0'",
)
.await?;
let recovered = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(recovered["order"], "1|2");
assert!(recovered["error"].is_null());
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'",
)
.await?;
let after_recovery = export_commands(&driver).await?.json().clone();
assert_eq!(after_recovery["commands"].as_array().map(Vec::len), Some(1));
assert_eq!(after_recovery["commands"][0]["causal"], 2);
Ok(())
}
.await;
let quit = driver.quit().await;
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",
Some(kanban_app_assets(&workspace)),
);
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)
.args([
"build",
"-p",
"hemx-client-local-example",
"--target",
"wasm32-unknown-unknown",
])
.status()
.expect("run wasm cargo build");
assert!(status.success(), "WASM build failed");
let output = workspace.join("target/client-local-bindgen");
fs::create_dir_all(&output).expect("create wasm-bindgen output");
let status = Command::new("wasm-bindgen")
.current_dir(workspace)
.arg("--target")
.arg("web")
.arg("--out-name")
.arg("client_local")
.arg("--out-dir")
.arg(&output)
.arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_client_local_example.wasm"))
.status()
.expect("run wasm-bindgen");
assert!(status.success(), "wasm-bindgen failed");
let rendered = Command::new("cargo")
.current_dir(workspace)
.args([
"run",
"-q",
"-p",
"hemx-client-local-example",
"--features",
"fixture",
"--bin",
"fixture",
])
.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"),
"hemx-client-local-example-",
);
(
output,
bootstrap,
String::from_utf8(rendered.stdout).expect("fixture is UTF-8"),
)
}
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(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())
.expect("generated hemx client bootstrap")
}
struct AppAssets {
module: PathBuf,
service_worker: PathBuf,
}
fn kanban_app_assets(workspace: &Path) -> AppAssets {
AppAssets {
module: workspace.join("examples/kanban/static/command-log.js"),
service_worker: workspace.join("examples/kanban/static/offline.js"),
}
}
struct StaticServer {
address: String,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl StaticServer {
fn start(
package: PathBuf,
runtime: PathBuf,
bootstrap: PathBuf,
rendered: String,
asset_stem: &'static str,
app_assets: Option<AppAssets>,
) -> 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();
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => serve(
stream,
&package,
&runtime,
&bootstrap,
&rendered,
asset_stem,
app_assets.as_ref(),
),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10))
}
Err(error) => panic!("fixture accept failed: {error}"),
}
}
});
Self {
address,
stop,
thread: Some(thread),
}
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
thread.join().expect("stop browser fixture");
}
}
fn is_reachable(&self) -> bool {
TcpStream::connect(&self.address).is_ok()
}
}
impl Drop for StaticServer {
fn drop(&mut self) {
self.stop();
}
}
fn serve(
mut stream: TcpStream,
package: &Path,
runtime: &Path,
bootstrap: &Path,
rendered: &str,
asset_stem: &str,
app_assets: Option<&AppAssets>,
) {
let mut request = [0_u8; 2048];
let length = stream.read(&mut request).unwrap_or(0);
let first = String::from_utf8_lossy(&request[..length]);
let path = first.split_whitespace().nth(1).unwrap_or("/");
let (content_type, body) = match path {
"/" => (
"text/html; charset=utf-8",
fixture_html(rendered, app_assets.is_some()).into_bytes(),
),
"/hemx.js" => (
"text/javascript; charset=utf-8",
fs::read(runtime).expect("read runtime"),
),
path if path == format!("/{asset_stem}.js") => (
"text/javascript; charset=utf-8",
fs::read(package.join(format!("{asset_stem}.js"))).expect("read bindings"),
),
path if path == format!("/{asset_stem}_bg.wasm") => (
"application/wasm",
fs::read(package.join(format!("{asset_stem}_bg.wasm"))).expect("read wasm"),
),
"/hemx.client.js" => (
"text/javascript; charset=utf-8",
fs::read(bootstrap).expect("read generated client bootstrap"),
),
"/app.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
),
"/offline.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").service_worker)
.expect("read service worker"),
),
_ => ("text/plain", b"not found".to_vec()),
};
let status = if path == "/"
|| path == "/hemx.js"
|| path == "/hemx.client.js"
|| ((path == "/app.js" || path == "/offline.js") && app_assets.is_some())
|| path.starts_with(&format!("/{asset_stem}"))
{
"200 OK"
} else {
"404 Not Found"
};
if write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).is_err() {
return;
}
let _ = stream.write_all(&body);
}
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
let app_module = if has_app_module {
"<script type=\"module\" src=\"/app.js\"></script>"
} else {
""
};
format!(
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"
)
}
async fn occupy_next_command_id(driver: &WebDriver) -> WebDriverResult<()> {
let occupied = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1);
open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => {
const tx = open.result.transaction(['commands', 'meta'], 'readwrite');
const commands = tx.objectStore('commands');
const meta = tx.objectStore('meta');
let actor;
let causal;
let pending = 2;
const addCollision = () => {
pending -= 1;
if (pending !== 0) return;
const next = causal + 1;
commands.add({
id: `${actor}:${next}`,
schemaVersion: 1,
actor,
session: 'fault-injection',
causal: next,
kind: 'reorder_card',
cardId: 'fault-injection',
eventKind: 'click',
key: null,
});
};
const actorRequest = meta.get('actor');
actorRequest.onsuccess = () => { actor = actorRequest.result; addCollision(); };
const causalRequest = meta.get('causal');
causalRequest.onsuccess = () => { causal = causalRequest.result; addCollision(); };
tx.oncomplete = () => done({ id: `${actor}:${causal + 1}` });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(
occupied["error"].is_null(),
"failed to occupy command id: {occupied}"
);
assert!(
occupied["id"].as_str().is_some(),
"missing occupied id: {occupied}"
);
Ok(())
}
async fn export_commands(driver: &WebDriver) -> WebDriverResult<ScriptRet> {
driver
.execute(
"window.__exported = null; document.querySelector('[data-hemx-root]').addEventListener('kanban:commands-exported', (event) => { window.__exported = event.detail; }, { once: true }); return true;",
Vec::new(),
)
.await?;
driver
.find(By::Css("[data-kanban-command-action='export']"))
.await?
.click()
.await?;
wait_until(driver, "return window.__exported !== null").await?;
driver.execute("return window.__exported", Vec::new()).await
}
async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
loop {
if driver
.execute(script, Vec::new())
.await?
.json()
.as_bool()
.unwrap_or(false)
{
return Ok(());
}
if Instant::now() >= deadline {
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;
}
}
async fn resource_count(driver: &WebDriver) -> WebDriverResult<u64> {
Ok(driver
.execute(
"return performance.getEntriesByType('resource').length",
Vec::new(),
)
.await?
.json()
.as_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 {
fn start(mut command: Command, address: &str) -> Self {
let child = command.spawn().expect("start geckodriver");
let deadline = Instant::now() + STARTUP_TIMEOUT;
while std::net::TcpStream::connect(address).is_err() {
assert!(Instant::now() < deadline, "timed out starting geckodriver");
thread::sleep(Duration::from_millis(50));
}
Self(child)
}
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}