fix(kanban): recover from quota exhaustion

req: sync/015
This commit is contained in:
slhx agent
2026-07-13 15:31:07 +02:00
parent bf10d1e3ce
commit 9b80444484
3 changed files with 184 additions and 10 deletions
+166
View File
@@ -528,6 +528,149 @@ async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverRe
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
@@ -1260,6 +1403,29 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String {
)
}
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(