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::common::capabilities::firefox::{FirefoxCapabilities, FirefoxPreferences}; use thirtyfour::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); fn headless_firefox_capabilities(javascript_enabled: bool) -> WebDriverResult { let mut preferences = FirefoxPreferences::new(); for preference in [ "app.normandy.enabled", "app.shield.optoutstudies.enabled", "app.update.enabled", "browser.newtabpage.activity-stream.feeds.telemetry", "browser.newtabpage.activity-stream.telemetry", "datareporting.healthreport.uploadEnabled", "datareporting.policy.dataSubmissionEnabled", "extensions.systemAddon.update.enabled", "network.captive-portal-service.enabled", "network.connectivity-service.enabled", "toolkit.telemetry.enabled", ] { preferences.set(preference, false)?; } preferences.set("javascript.enabled", javascript_enabled)?; preferences.set("services.settings.server", "data:,")?; let mut capabilities = DesiredCapabilities::firefox(); capabilities.set_headless()?; capabilities.set_preferences(preferences)?; Ok(capabilities) } #[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 caps = headless_firefox_capabilities(true)?; 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 flat_patch_persists_offline_then_uploads_with_same_operation_identity( ) -> WebDriverResult<()> { // test req: sync/002 req: sync/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 caps = headless_firefox_capabilities(true)?; 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-hemx-client-ready') && root.hasAttribute('data-hemx-sync-ready')", ) .await?; let rejected_inputs = driver .execute_async( r#" const done = arguments[arguments.length - 1]; (async () => { const { reorder_card: handler } = await import('/kanban_client.js'); const errors = {}; for (const [name, args] of [ ['unknown-version', [99, 'click', null, null, null, 1, '1|2']], ['oversized-kind', [1, 'x'.repeat(257), null, null, null, 1, '1|2']], ['oversized-value', [1, 'click', 'x'.repeat(64 * 1024 + 1), null, null, 1, '1|2']], ['oversized-key', [1, 'keydown', null, null, 'x'.repeat(1025), 1, '1|2']], ['unknown-state-version', [1, 'click', null, null, null, 99, '1|2']], ['oversized-state', [1, 'click', null, null, null, 1, 'x'.repeat(1024 * 1024 + 1)]], ]) { try { await handler(...args); errors[name] = null; } catch (error) { errors[name] = String(error); } } done(errors); })().catch((error) => done({ harness: String(error) })); "#, Vec::new(), ) .await? .json() .clone(); assert!( rejected_inputs .as_object() .expect("client rejection record") .values() .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), "malformed client-local input reached the handler: {rejected_inputs}" ); // req: security/005 let rejected_sync = driver .execute_async( r#" const done = arguments[arguments.length - 1]; (async () => { const root = document.querySelector('[data-hemx-root]'); const before = document.querySelector('#kanban-status').textContent; const errors = {}; for (const [name, detail] of [ ['malformed', '{'], ['unknown-version', JSON.stringify({ schemaVersion: 99, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'done' })], ['invalid-key', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'value', value: 'done' })], ['oversized-value', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'x'.repeat(4097) })], ]) { root.removeAttribute('data-hemx-sync-error'); document.dispatchEvent(new CustomEvent('hemx:sync-patch', { detail })); await new Promise((resolve) => setTimeout(resolve, 10)); errors[name] = root.getAttribute('data-hemx-sync-error'); } done({ errors, pending: root.getAttribute('data-hemx-sync-pending'), unchanged: before === document.querySelector('#kanban-status').textContent }); })().catch((error) => done({ harness: String(error) })); "#, Vec::new(), ) .await? .json() .clone(); assert!( rejected_sync["errors"] .as_object() .expect("sync rejection record") .values() .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), "malformed sync patch reached durable storage: {rejected_sync}" ); assert_eq!(rejected_sync["pending"], "0", "{rejected_sync}"); assert_eq!(rejected_sync["unchanged"], true, "{rejected_sync}"); driver .execute( "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', event => window.__clientErrors.push(event.detail)); Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); document.querySelector('[data-hemx-client-event=drop]').dispatchEvent(new Event('drop', { bubbles: true })); return true", Vec::new(), ) .await?; wait_until( &driver, "return document.querySelector('[data-hemx-root]').getAttribute('data-hemx-sync-pending') === '1'", ) .await?; assert!( driver .find(By::Css("#kanban-status")) .await? .text() .await? .contains("Moved 1 with drop"), "ordinary EffectBatch did not apply alongside the sync patch" ); assert_eq!( driver .execute( "return performance.getEntriesByType('resource').filter(entry => entry.name.endsWith('/sync/patches')).length", Vec::new(), ) .await? .json(), &serde_json::json!(0), "offline patch attempted a network request" ); driver .execute( "Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => true }); window.dispatchEvent(new Event('online')); return true", Vec::new(), ) .await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')", ) .await?; let identity = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { ack: root.getAttribute('data-hemx-sync-ack'), uuid: /^[0-9a-f-]{36}$/.test(root.getAttribute('data-hemx-sync-ack')) }", Vec::new(), ) .await?; assert_eq!(identity.json()["uuid"], true, "{identity:?}"); Ok(()) } .await; let _ = driver.quit().await; result } #[tokio::test] async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> { // test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 req: v1_release/002 // test req: accessibility/001 req: accessibility/002 // test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root") .to_owned(); let host_build = Command::new("cargo") .current_dir(&workspace) .args([ "build", "-p", "hemx-kanban-example", "--bin", "hemx-kanban-example", ]) .status() .expect("build kanban host server"); assert!(host_build.success(), "build kanban host server"); let target_dir = std::env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) .unwrap_or_else(|| workspace.join("target")); let host_binary = target_dir.join("debug/hemx-kanban-example"); let host_port = available_port(); let host_addr = format!("127.0.0.1:{host_port}"); let mut host_command = Command::new(&host_binary); host_command.env("HEMX_KANBAN_ADDR", &host_addr); let _host = ProcessGuard::start(host_command, &host_addr); let host_url = format!("http://{host_addr}"); 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(framework_sync_assets(&workspace)), ); let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); let webdriver_url = format!("http://{webdriver_addr}"); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg(webdriver_port.to_string()); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let no_script_caps = headless_firefox_capabilities(false)?; let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?; no_script_driver.goto(&host_url).await?; no_script_driver .find(By::XPath( "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", )) .await? .click() .await?; let moved_without_script = no_script_driver .find(By::XPath( "//section[contains(@class,'column')][h2='Doing']//strong[text()='Write requirements']", )) .await; let _ = no_script_driver.quit().await; moved_without_script?; let caps = headless_firefox_capabilities(true)?; let driver = WebDriver::new(&webdriver_url, caps).await?; let result = async { driver.goto(&host_url).await?; wait_until(&driver, "return Boolean(window.hemx)").await?; let optional_asset = driver .execute_async( r#" const done = arguments[arguments.length - 1]; fetch('/optional-avatar.webp') .then((response) => done({ status: response.status, runtime: Boolean(window.hemx) })) .catch((error) => done({ error: String(error) })); "#, Vec::new(), ) .await? .json() .clone(); assert_eq!(optional_asset["status"], 404); assert_eq!(optional_asset["runtime"], true); driver .find(By::XPath( "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", )) .await? .send_keys(Key::Enter) .await?; wait_until( &driver, "return [...document.querySelectorAll('.column')].find((column) => column.querySelector('h2')?.textContent === 'Done')?.textContent.includes('Write requirements')", ) .await?; let presence = driver .execute_async( r#" const done = arguments[arguments.length - 1]; const root = document.querySelector('[data-hemx-root]'); const source = new EventSource('/sync/broadcast?channel=board&action=join&member=milestone-peer'); const timeout = setTimeout(() => { source.close(); done({ error: 'presence timed out' }); }, 5000); source.addEventListener('hemx', (event) => { clearTimeout(timeout); const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); window.hemx.applyBatch(bytes.buffer, root); source.close(); done({ text: document.body.textContent }); }); source.onerror = () => { clearTimeout(timeout); source.close(); done({ error: 'presence failed' }); }; "#, Vec::new(), ) .await? .json() .clone(); assert!(presence["error"].is_null(), "presence failed: {presence}"); assert!( presence["text"].as_str().unwrap_or_default().contains("tick #1"), "presence projection was not applied: {presence}" ); driver.goto(&server.url()).await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-hemx-sync-ready') === true", ) .await?; let framework_only = driver .execute_async( r#" const done = arguments[arguments.length - 1]; Promise.all(['/app.js', '/offline.js', '/sync-demo', '/sync.js'].map((path) => fetch(path).then((response) => response.status))) .then((statuses) => done({ statuses, scripts: [...document.scripts].map((script) => script.getAttribute('src')) })) .catch((error) => done({ error: String(error) })); "#, Vec::new(), ) .await? .json() .clone(); assert_eq!( framework_only["statuses"], serde_json::json!([404, 404, 404, 404]) ); assert_eq!( framework_only["scripts"], serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"]) ); driver .execute( "window.__durablePatch = null; document.addEventListener('hemx:sync-patch', (event) => { window.__durablePatch = event.detail; }, { once: true }); document.querySelector('[data-hemx-root]').setAttribute('data-sync-endpoint', '/unavailable'); return true", Vec::new(), ) .await?; driver .execute( "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, "const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && window.__durablePatch !== null && root.getAttribute('data-hemx-sync-pending') === '1' && root.getAttribute('data-hemx-sync-error') === 'upload-404'", ) .await?; let command_id = driver .execute( "return JSON.parse(window.__durablePatch).patch.idempotencyKey", Vec::new(), ) .await? .json() .as_str() .expect("durable interaction identity") .to_owned(); driver.refresh().await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')", ) .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('|'), notice: root.querySelector('[data-sid]').textContent, error: root.getAttribute('data-hemx-sync-error') }", Vec::new(), ) .await? .json() .clone(); assert_eq!(restored["order"], "2|1"); assert_eq!(restored["notice"], "Moved 1 with drop"); assert!(restored["error"].is_null()); let app_addr = server.address.to_string(); server.stop(); assert!(!server.is_reachable(), "fixture server must be stopped before app restart"); let mut app_command = Command::new(&host_binary); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = ProcessGuard::start(app_command, &app_addr); let duplicate_script = format!( r#" const done = arguments[arguments.length - 1]; const commandId = {command_id:?}; (async () => {{ const initialResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }}); const initial = await initialResponse.json(); const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }}); const duplicate = await duplicateResponse.json(); const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }}); const conflict = await conflictResponse.json(); const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }}); const rejection = await rejectionResponse.json(); const peerResponse = await fetch('/sync/commands?command_id=peer%3A1&card_id=2&column=done', {{ method: 'POST' }}); const peer = await peerResponse.json(); const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json(); const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text(); done({{ initialStatus: initialResponse.status, initial, duplicateStatus: duplicateResponse.status, duplicate, conflictStatus: conflictResponse.status, conflict, rejectionStatus: rejectionResponse.status, rejection, peerStatus: peerResponse.status, peer, snapshot, history, }}); }})().catch((error) => done({{ error: String(error), stack: error.stack }})); "# ); let convergence = driver .execute_async(&duplicate_script, Vec::new()) .await? .json() .clone(); assert!( convergence.get("error").is_none(), "sync convergence failed: {convergence}" ); assert_eq!(convergence["initialStatus"], 200); assert_eq!(convergence["initial"]["commandId"], command_id); assert_eq!(convergence["initial"]["serverSequence"], 1); assert_eq!(convergence["duplicateStatus"], 200); assert_eq!(convergence["duplicate"]["commandId"], command_id); assert_eq!(convergence["duplicate"]["serverSequence"], 1); assert_eq!(convergence["conflictStatus"], 409); assert_eq!(convergence["conflict"]["kind"], "command-conflict"); assert_eq!( convergence["conflict"]["error"], "command_id was already used for a different payload" ); assert_eq!(convergence["rejectionStatus"], 400); assert_eq!(convergence["rejection"]["kind"], "invalid-command"); assert_eq!(convergence["rejection"]["error"], "unknown card_id"); assert_eq!(convergence["peerStatus"], 200); assert_eq!(convergence["peer"]["commandId"], "peer:1"); assert_eq!(convergence["peer"]["serverSequence"], 2); assert_eq!(convergence["snapshot"]["serverSequence"], 2); assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1); assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done"); assert_eq!(convergence["snapshot"]["cards"][1]["id"], 2); assert_eq!(convergence["snapshot"]["cards"][1]["column"], "done"); assert_eq!( convergence["history"] .as_str() .expect("acknowledgement history") .matches(&command_id) .count(), 1, "duplicate replay emitted another acknowledgement: {convergence}" ); 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 caps = headless_firefox_capabilities(true)?; 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"], 2); 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::>(); exported_keys.sort_unstable(); assert_eq!( exported_keys, [ "accountPartition", "actor", "cardId", "causal", "eventKind", "id", "key", "kind", "queuedAt", "schemaVersion", "session", "targetColumn", ] ); 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_queued_status_precedes_durable_projection_within_budget() -> WebDriverResult<()> { // test req: accessibility/004 req: client_local/013 req: performance/003 const INTERACTION_BUDGET_MS: f64 = 100.0; 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 caps = headless_firefox_capabilities(true)?; 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?; hold_command_transaction(&driver).await?; driver .execute( r#" const root = document.querySelector('[data-hemx-root]'); window.__queuedTiming = { input: null, queued: null, persisted: false }; root.addEventListener('click', () => { window.__queuedTiming.input = performance.now(); }, { capture: true, once: true }); root.addEventListener('kanban:command-queued', () => { window.__queuedTiming.queued = performance.now(); }, { once: true }); root.addEventListener('kanban:command-persisted', () => { window.__queuedTiming.persisted = true; }, { once: true }); return true; "#, Vec::new(), ) .await?; driver.find(By::Css("[data-card-id='1']")).await?.click().await?; wait_until( &driver, "return window.__queuedTiming.queued !== null && document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-phase') === 'queued'", ) .await?; let queued = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { latency: window.__queuedTiming.queued - window.__queuedTiming.input, persisted: window.__queuedTiming.persisted, phase: root.getAttribute('data-kanban-command-phase'), busy: root.getAttribute('aria-busy'), status: root.querySelector('[role=status]').textContent, order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), count: root.getAttribute('data-kanban-command-count') }", Vec::new(), ) .await? .json() .clone(); assert!( queued["latency"] .as_f64() .is_some_and(|latency| latency <= INTERACTION_BUDGET_MS), "queued status missed interaction budget: {queued}" ); assert_eq!(queued["persisted"], false); assert_eq!(queued["phase"], "queued"); assert_eq!(queued["busy"], "true"); assert_eq!(queued["status"], "Queued card 1; saving for offline use."); assert_eq!(queued["order"], "1|2"); assert_eq!(queued["count"], "0"); release_command_transaction(&driver).await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return window.__queuedTiming.persisted && root.getAttribute('data-kanban-command-phase') === 'durable' && [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", ) .await?; let durable = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { phase: root.getAttribute('data-kanban-command-phase'), busy: root.hasAttribute('aria-busy'), count: root.getAttribute('data-kanban-command-count'), status: root.querySelector('[role=status]').textContent }", Vec::new(), ) .await? .json() .clone(); assert_eq!(durable["phase"], "durable"); assert_eq!(durable["busy"], false); assert_eq!(durable["count"], "1"); assert_eq!(durable["status"], "Moved 1 with click"); Ok(()) } .await; let quit = driver.quit().await; result.and(quit) } #[tokio::test] async fn kanban_quota_failure_is_fail_closed_and_recoverable() -> 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 caps = headless_firefox_capabilities(true)?; 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?; inject_quota_failure(&driver).await?; driver .execute( "const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterQuota = false; window.__quotaFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterQuota = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__quotaFailure = event.detail; }, { once: true }); return true;", Vec::new(), ) .await?; driver.find(By::Css("[data-card-id='1']")).await?.click().await?; wait_until(&driver, "return window.__quotaFailure !== 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('|'), count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), notice: root.querySelector('[role=status]').textContent, persisted: window.__persistedAfterQuota, controls: [...root.querySelectorAll('[data-kanban-command-action]')].map((button) => ({ action: button.dataset.kanbanCommandAction, disabled: button.disabled })) }", Vec::new(), ) .await? .json() .clone(); assert_eq!(failed["order"], "1|2"); assert_eq!(failed["count"], "0"); assert_eq!(failed["stage"], "persist"); assert_eq!(failed["code"], "QuotaExceededError"); assert_eq!(failed["persisted"], false); assert_eq!( failed["notice"], "Local command persist failed (QuotaExceededError). Recovery controls remain available." ); assert!( failed["controls"] .as_array() .is_some_and(|controls| controls.len() == 3 && controls.iter().all(|control| control["disabled"] == false)), "recovery controls unavailable: {failed}" ); let empty_export = export_commands(&driver).await?.json().clone(); assert_eq!(empty_export["commands"].as_array().map(Vec::len), Some(0)); 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?; 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_commands(&driver).await?.json().clone(); assert_eq!(after_delete["commands"].as_array().map(Vec::len), Some(1)); assert_eq!(after_delete["commands"][0]["causal"], 1); inject_quota_failure(&driver).await?; driver .execute( "window.__quotaFailure = null; document.querySelector('[data-hemx-root]').addEventListener('kanban:command-error', (event) => { window.__quotaFailure = 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.__quotaFailure !== null").await?; let second_failure = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), count: root.getAttribute('data-kanban-command-count'), code: root.getAttribute('data-kanban-command-error-code') }", Vec::new(), ) .await? .json() .clone(); assert_eq!(second_failure["order"], "2|1"); assert_eq!(second_failure["count"], "1"); assert_eq!(second_failure["code"], "QuotaExceededError"); driver .execute("window.__reloadPending = true", Vec::new()) .await?; let reset = driver .find(By::Css("[data-kanban-command-action='reset']")) .await?; reset.click().await?; 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 { 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!(after_reset["order"], "1|2"); assert!(after_reset["error"].is_null()); 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 caps = headless_firefox_capabilities(true)?; 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"], "Local command persist failed (ConstraintError). Recovery controls remain available." ); 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_replay_is_bounded_and_within_budget() -> WebDriverResult<()> { // test req: sync/014 req: performance/005 req: performance/007 const REPLAY_LIMIT: u64 = 64; const REPLAY_BUDGET_MS: f64 = 250.0; 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 caps = headless_firefox_capabilities(true)?; 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-kanban-command-ready')", ) .await?; store_replay_commands(&driver, 1, REPLAY_LIMIT).await?; driver .execute("window.__reloadPending = true; location.reload()", Vec::new()) .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') === '64'", ) .await?; let within_bound = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), limit: root.getAttribute('data-kanban-replay-limit'), elapsed: Number(root.getAttribute('data-kanban-replay-ms')), budget: Number(root.getAttribute('data-kanban-replay-budget-ms')), over: root.hasAttribute('data-kanban-replay-over-budget') }", Vec::new(), ) .await? .json() .clone(); assert_eq!(within_bound["order"], "2|1"); assert_eq!(within_bound["limit"], REPLAY_LIMIT.to_string()); assert_eq!(within_bound["budget"], REPLAY_BUDGET_MS); assert_eq!(within_bound["over"], false, "{within_bound}"); assert!( within_bound["elapsed"].as_f64().is_some_and(|elapsed| elapsed <= REPLAY_BUDGET_MS), "replay exceeded budget: {within_bound}" ); store_replay_commands(&driver, REPLAY_LIMIT + 1, REPLAY_LIMIT + 1).await?; driver .execute("window.__reloadPending = true; location.reload()", Vec::new()) .await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.getAttribute('data-kanban-command-error-code') === 'ReplayLimitError'", ) .await?; let refused = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), ready: root.hasAttribute('data-kanban-command-ready'), error: root.getAttribute('data-kanban-command-error'), stage: root.getAttribute('data-kanban-command-error-stage') }", Vec::new(), ) .await? .json() .clone(); assert_eq!(refused["order"], "1|2"); assert_eq!(refused["ready"], false); assert_eq!( refused["error"], "restore: durable replay limit exceeded: 65 > 64" ); assert_eq!(refused["stage"], "restore"); let recovery_export = export_commands(&driver).await?.json().clone(); assert_eq!( recovery_export["commands"].as_array().map(Vec::len), Some(65) ); 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?; Ok(()) } .await; let quit = driver.quit().await; result.and(quit) } #[tokio::test] async fn kanban_corrupt_command_refuses_projection_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 caps = headless_firefox_capabilities(true)?; 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?; store_malformed_command(&driver).await?; driver .execute("window.__reloadPending = true; location.reload()", Vec::new()) .await?; wait_until( &driver, "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-error')", ) .await?; let refused = driver .execute( "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), ready: root.hasAttribute('data-kanban-command-ready'), error: root.getAttribute('data-kanban-command-error'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), notice: root.querySelector('[role=status]').textContent }", Vec::new(), ) .await? .json() .clone(); assert_eq!(refused["order"], "1|2"); assert_eq!(refused["ready"], false); assert_eq!( refused["error"], "restore: invalid durable command corrupt:1: cardId" ); assert_eq!(refused["stage"], "restore"); assert_eq!(refused["code"], "Error"); assert_eq!( refused["notice"], "Local command restore failed (Error). Recovery controls remain available." ); let recovery_export = export_commands(&driver).await?.json().clone(); assert_eq!(recovery_export["schemaVersion"], 1); assert_eq!(recovery_export["commands"].as_array().map(Vec::len), Some(1)); assert_eq!(recovery_export["commands"][0]["id"], "corrupt:1"); assert_eq!(recovery_export["commands"][0]["cardId"], ""); 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()); let empty_export = export_commands(&driver).await?.json().clone(); assert_eq!(empty_export["commands"].as_array().map(Vec::len), Some(0)); 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 caps = headless_firefox_capabilities(true)?; 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: Option, service_worker: Option, sync_runtime: PathBuf, } fn kanban_app_assets(workspace: &Path) -> AppAssets { AppAssets { module: Some(workspace.join("examples/kanban/static/command-log.js")), service_worker: Some(workspace.join("examples/kanban/static/offline.js")), sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"), } } fn framework_sync_assets(workspace: &Path) -> AppAssets { AppAssets { module: None, service_worker: None, sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"), } } struct StaticServer { address: String, stop: Arc, thread: Option>, } impl StaticServer { fn start( package: PathBuf, runtime: PathBuf, bootstrap: PathBuf, rendered: String, asset_stem: &'static str, app_assets: Option, ) -> 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).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 .and_then(|assets| assets.module.as_ref()) .is_some() => { ( "text/javascript; charset=utf-8", fs::read( app_assets .and_then(|assets| assets.module.as_ref()) .expect("checked app module"), ) .expect("read app module"), ) } "/offline.js" if app_assets .and_then(|assets| assets.service_worker.as_ref()) .is_some() => { ( "text/javascript; charset=utf-8", fs::read( app_assets .and_then(|assets| assets.service_worker.as_ref()) .expect("checked service worker"), ) .expect("read service worker"), ) } "/hemx-sync.js" if app_assets.is_some() => ( "text/javascript; charset=utf-8", fs::read(&app_assets.expect("checked app assets").sync_runtime) .expect("read sync runtime"), ), "/sync/patches" if app_assets.is_some() => { let body = first.split("\r\n\r\n").nth(1).unwrap_or(""); let patch: serde_json::Value = serde_json::from_str(body).expect("valid flat patch"); let idempotency_key = patch["idempotencyKey"] .as_str() .expect("flat patch idempotency key"); let operation_id = patch["operationId"] .as_str() .expect("flat patch operation id"); assert_eq!( operation_id, idempotency_key, "interaction operation and idempotency identities diverged" ); ( "application/json; charset=utf-8", format!( r#"{{"idempotencyKey":"{idempotency_key}","operationId":"{operation_id}"}}"# ) .into_bytes(), ) } "/sync/context" if app_assets.is_some() => ( "application/json; charset=utf-8", br#"{"accountPartition":"demo:demo"}"#.to_vec(), ), _ => ("text/plain", b"not found".to_vec()), }; let status = if path == "/" || path == "/hemx.js" || path == "/hemx.client.js" || (((path == "/app.js" && app_assets .and_then(|assets| assets.module.as_ref()) .is_some()) || (path == "/offline.js" && app_assets .and_then(|assets| assets.service_worker.as_ref()) .is_some()) || path == "/hemx-sync.js" || path == "/sync/context" || path == "/sync/patches") && 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, app_assets: Option<&AppAssets>) -> String { let app_module = match app_assets { Some(assets) if assets.module.is_some() => { "" } Some(_) => "", None => "", }; format!( "{rendered}{app_module}" ) } async fn hold_command_transaction(driver: &WebDriver) -> WebDriverResult<()> { driver .execute( r#" window.__releaseCommandTransaction = false; window.__commandTransactionHeld = false; const open = indexedDB.open('hemx-kanban-v1'); open.onsuccess = () => { const tx = open.result.transaction('commands', 'readwrite'); const commands = tx.objectStore('commands'); window.__commandTransactionHeld = true; const keepAlive = () => { if (window.__releaseCommandTransaction) return; const request = commands.count(); request.onsuccess = keepAlive; }; keepAlive(); }; return true; "#, Vec::new(), ) .await?; wait_until(driver, "return window.__commandTransactionHeld === true").await } async fn release_command_transaction(driver: &WebDriver) -> WebDriverResult<()> { driver .execute( "window.__releaseCommandTransaction = true; return true;", Vec::new(), ) .await?; Ok(()) } async fn inject_quota_failure(driver: &WebDriver) -> WebDriverResult<()> { let injected = driver .execute( r#" if (!window.__kanbanOriginalAdd) window.__kanbanOriginalAdd = IDBObjectStore.prototype.add; IDBObjectStore.prototype.add = function(value) { if (this.name === 'commands' && value && value.kind === 'reorder_card') { IDBObjectStore.prototype.add = window.__kanbanOriginalAdd; throw new DOMException('Injected storage quota exhaustion', 'QuotaExceededError'); } return window.__kanbanOriginalAdd.call(this, value); }; return IDBObjectStore.prototype.add !== window.__kanbanOriginalAdd; "#, Vec::new(), ) .await? .json() .clone(); assert_eq!(injected, true, "failed to inject quota error"); Ok(()) } async fn store_replay_commands(driver: &WebDriver, first: u64, last: u64) -> WebDriverResult<()> { let stored = driver .execute_async( r#" const first = arguments[0]; const last = arguments[1]; const done = arguments[arguments.length - 1]; const open = indexedDB.open('hemx-kanban-v1'); open.onerror = () => done({ error: open.error && open.error.name }); open.onsuccess = () => { const tx = open.result.transaction('commands', 'readwrite'); const commands = tx.objectStore('commands'); for (let causal = first; causal <= last; causal += 1) { commands.add({ id: `replay:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'replay', session: 'replay', causal, queuedAt: Date.now(), kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null, }); } tx.oncomplete = () => done({ count: last - first + 1 }); tx.onabort = () => done({ error: tx.error && tx.error.name }); }; "#, vec![first.into(), last.into()], ) .await? .json() .clone(); assert_eq!( stored["count"], last - first + 1, "failed to store replay commands: {stored}" ); Ok(()) } async fn store_malformed_command(driver: &WebDriver) -> WebDriverResult<()> { let stored = driver .execute_async( r#" const done = arguments[arguments.length - 1]; const open = indexedDB.open('hemx-kanban-v1'); open.onerror = () => done({ error: open.error && open.error.name }); open.onsuccess = () => { const tx = open.result.transaction('commands', 'readwrite'); tx.objectStore('commands').add({ id: 'corrupt:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'corrupt', session: 'corrupt', causal: 1, queuedAt: Date.now(), kind: 'reorder_card', cardId: '', targetColumn: 'done', eventKind: 'click', key: null, }); tx.oncomplete = () => done({ stored: true }); tx.onabort = () => done({ error: tx.error && tx.error.name }); }; "#, Vec::new(), ) .await? .json() .clone(); assert_eq!( stored["stored"], true, "failed to store malformed command: {stored}" ); Ok(()) } 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'); 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: 2, accountPartition: 'demo:demo', actor, session: 'fault-injection', causal: next, queuedAt: Date.now(), kind: 'reorder_card', cardId: 'fault-injection', targetColumn: 'done', eventKind: 'click', key: null, }); }; const actorRequest = meta.get('actor:demo:demo'); actorRequest.onsuccess = () => { actor = actorRequest.result; addCollision(); }; const causalRequest = meta.get('causal:demo:demo'); 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 { 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 { 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(); } }