feat(kanban): add local recovery controls

req: local/003\nreq: security/007
This commit is contained in:
slhx agent
2026-07-13 14:59:05 +02:00
parent 9a3c0335eb
commit d946975f2d
4 changed files with 290 additions and 5 deletions
+194
View File
@@ -350,6 +350,184 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
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"], 1);
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,
[
"actor",
"cardId",
"causal",
"eventKind",
"id",
"key",
"kind",
"schemaVersion",
"session",
]
);
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_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()>
{
@@ -759,6 +937,22 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String {
)
}
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 {