feat(kanban): add local recovery controls
req: local/003\nreq: security/007
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user