feat(kanban): bound durable replay
req: sync/014\nreq: performance/005
This commit is contained in:
@@ -636,6 +636,118 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
|
||||
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);
|
||||
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
|
||||
@@ -1148,6 +1260,48 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
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', 1);
|
||||
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: 1,
|
||||
actor: 'replay',
|
||||
session: 'replay',
|
||||
causal,
|
||||
kind: 'reorder_card',
|
||||
cardId: '1',
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user