fix(kanban): fail closed on persistence errors

req: sync/015
This commit is contained in:
slhx agent
2026-07-13 15:11:08 +02:00
parent d946975f2d
commit 47715e3ba3
3 changed files with 184 additions and 9 deletions
+163 -2
View File
@@ -528,6 +528,111 @@ async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverRe
result.and(quit)
}
#[tokio::test]
async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriverResult<()> {
// test req: sync/015
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.to_owned();
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js");
let server = StaticServer::start(
package,
runtime,
bootstrap,
rendered,
"kanban_client",
Some(kanban_app_assets(&workspace)),
);
let webdriver_port = available_port();
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
)
.await?;
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'",
)
.await?;
occupy_next_command_id(&driver).await?;
driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterFault = false; window.__commandFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterFault = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__commandFailure = event.detail; }, { once: true }); return true;",
Vec::new(),
)
.await?;
driver.find(By::Css("[data-card-id='2']")).await?.click().await?;
wait_until(&driver, "return window.__commandFailure !== null").await?;
let failed = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), notice: root.querySelector('[role=status]').textContent, count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), persisted: window.__persistedAfterFault, detail: window.__commandFailure }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(failed["order"], "2|1");
assert_eq!(failed["notice"], "Moved 1 with click");
assert_eq!(failed["count"], "1");
assert_eq!(failed["stage"], "persist");
assert_eq!(failed["code"], "ConstraintError");
assert_eq!(failed["detail"]["stage"], "persist");
assert_eq!(failed["detail"]["code"], "ConstraintError");
assert_eq!(failed["persisted"], false);
driver
.execute("window.__reloadPending = true", Vec::new())
.await?;
let delete = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete.click().await?;
delete.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.getAttribute('data-kanban-command-count') === '0'",
)
.await?;
let recovered = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(recovered["order"], "1|2");
assert!(recovered["error"].is_null());
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'",
)
.await?;
let after_recovery = export_commands(&driver).await?.json().clone();
assert_eq!(after_recovery["commands"].as_array().map(Vec::len), Some(1));
assert_eq!(after_recovery["commands"][0]["causal"], 2);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()>
{
@@ -922,8 +1027,10 @@ fn serve(
} else {
"404 Not Found"
};
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).expect("write fixture headers");
stream.write_all(&body).expect("write fixture body");
if write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).is_err() {
return;
}
let _ = stream.write_all(&body);
}
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
@@ -937,6 +1044,60 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String {
)
}
async fn occupy_next_command_id(driver: &WebDriver) -> WebDriverResult<()> {
let occupied = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1);
open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => {
const tx = open.result.transaction(['commands', 'meta'], 'readwrite');
const commands = tx.objectStore('commands');
const meta = tx.objectStore('meta');
let actor;
let causal;
let pending = 2;
const addCollision = () => {
pending -= 1;
if (pending !== 0) return;
const next = causal + 1;
commands.add({
id: `${actor}:${next}`,
schemaVersion: 1,
actor,
session: 'fault-injection',
causal: next,
kind: 'reorder_card',
cardId: 'fault-injection',
eventKind: 'click',
key: null,
});
};
const actorRequest = meta.get('actor');
actorRequest.onsuccess = () => { actor = actorRequest.result; addCollision(); };
const causalRequest = meta.get('causal');
causalRequest.onsuccess = () => { causal = causalRequest.result; addCollision(); };
tx.oncomplete = () => done({ id: `${actor}:${causal + 1}` });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(
occupied["error"].is_null(),
"failed to occupy command id: {occupied}"
);
assert!(
occupied["id"].as_str().is_some(),
"missing occupied id: {occupied}"
);
Ok(())
}
async fn export_commands(driver: &WebDriver) -> WebDriverResult<ScriptRet> {
driver
.execute(