c793de8224
req: v1_release/002
2172 lines
87 KiB
Rust
2172 lines
87 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::common::capabilities::firefox::FirefoxPreferences;
|
|
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 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 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-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 mut no_script_preferences = FirefoxPreferences::new();
|
|
no_script_preferences.set("javascript.enabled", false)?;
|
|
let mut no_script_caps = DesiredCapabilities::firefox();
|
|
no_script_caps.set_headless()?;
|
|
no_script_caps.set_preferences(no_script_preferences)?;
|
|
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 mut caps = DesiredCapabilities::firefox();
|
|
caps.set_headless()?;
|
|
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 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"], 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::<Vec<_>>();
|
|
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 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?;
|
|
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 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?;
|
|
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 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"],
|
|
"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
|
|
const REPLAY_LIMIT: u64 = 64;
|
|
const REPLAY_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 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-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 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?;
|
|
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 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: Option<PathBuf>,
|
|
service_worker: Option<PathBuf>,
|
|
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<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).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() => {
|
|
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
|
}
|
|
Some(_) => "<script type=\"module\" src=\"/hemx-sync.js\"></script>",
|
|
None => "",
|
|
};
|
|
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 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<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();
|
|
}
|
|
}
|