feat(kanban): persist local reorder commands
req: local/001\nreq: local/002\nreq: local/003\nreq: local/004\nreq: sync/009
This commit is contained in:
+173
-6
@@ -22,7 +22,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
||||
.to_owned();
|
||||
let (package, bootstrap, rendered) = build_browser_artifact(&workspace);
|
||||
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||
let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local");
|
||||
let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local", None);
|
||||
|
||||
let webdriver_port = available_port();
|
||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||
@@ -191,6 +191,148 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()>
|
||||
{
|
||||
// test req: local/001 req: local/002 req: local/003 req: local/004
|
||||
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(workspace.join("examples/kanban/static/command-log.js")),
|
||||
);
|
||||
|
||||
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?;
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
const root = document.querySelector('[data-hemx-root]');
|
||||
window.__persistedCommand = null;
|
||||
root.addEventListener('kanban:command-persisted', (event) => {
|
||||
window.__persistedCommand = {
|
||||
detail: event.detail,
|
||||
order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'),
|
||||
};
|
||||
}, { once: true });
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
|
||||
)
|
||||
.await?;
|
||||
let persisted = driver
|
||||
.execute(
|
||||
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(persisted["persisted"]["order"], "1|2");
|
||||
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 1);
|
||||
assert_eq!(persisted["persisted"]["detail"]["causal"], 1);
|
||||
let actor = persisted["persisted"]["detail"]["actor"]
|
||||
.as_str()
|
||||
.expect("persisted actor");
|
||||
let session = persisted["persisted"]["detail"]["session"]
|
||||
.as_str()
|
||||
.expect("persisted session");
|
||||
assert!(!actor.is_empty());
|
||||
assert!(!session.is_empty());
|
||||
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
|
||||
assert_eq!(persisted["count"], "1");
|
||||
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
||||
)
|
||||
.await?;
|
||||
let restored = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(restored["order"], "2|1");
|
||||
assert_eq!(restored["count"], "1");
|
||||
assert!(restored["error"].is_null());
|
||||
assert_eq!(restored["notice"], "Moved 1 with click");
|
||||
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
window.__futureCommandStored = false;
|
||||
const request = indexedDB.open('hemx-kanban-v1', 1);
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'future:2', schemaVersion: 2, actor: 'future', session: 'future',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => { window.__futureCommandStored = true; };
|
||||
};
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
|
||||
)
|
||||
.await?;
|
||||
let rejected = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
rejected["error"],
|
||||
"restore: unsupported durable command future:2"
|
||||
);
|
||||
assert_eq!(rejected["ready"], false);
|
||||
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<()>
|
||||
{
|
||||
@@ -202,7 +344,14 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
|
||||
.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");
|
||||
let server = StaticServer::start(
|
||||
package,
|
||||
runtime,
|
||||
bootstrap,
|
||||
rendered,
|
||||
"kanban_client",
|
||||
Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||
);
|
||||
|
||||
let webdriver_port = available_port();
|
||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||
@@ -455,6 +604,7 @@ impl StaticServer {
|
||||
bootstrap: PathBuf,
|
||||
rendered: String,
|
||||
asset_stem: &'static str,
|
||||
app_module: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
|
||||
listener.set_nonblocking(true).expect("nonblocking fixture");
|
||||
@@ -465,7 +615,13 @@ impl StaticServer {
|
||||
while !thread_stop.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => serve(
|
||||
stream, &package, &runtime, &bootstrap, &rendered, asset_stem,
|
||||
stream,
|
||||
&package,
|
||||
&runtime,
|
||||
&bootstrap,
|
||||
&rendered,
|
||||
asset_stem,
|
||||
app_module.as_deref(),
|
||||
),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10))
|
||||
@@ -495,6 +651,7 @@ fn serve(
|
||||
bootstrap: &Path,
|
||||
rendered: &str,
|
||||
asset_stem: &str,
|
||||
app_module: Option<&Path>,
|
||||
) {
|
||||
let mut request = [0_u8; 2048];
|
||||
let length = stream.read(&mut request).unwrap_or(0);
|
||||
@@ -503,7 +660,7 @@ fn serve(
|
||||
let (content_type, body) = match path {
|
||||
"/" => (
|
||||
"text/html; charset=utf-8",
|
||||
fixture_html(rendered).into_bytes(),
|
||||
fixture_html(rendered, app_module.is_some()).into_bytes(),
|
||||
),
|
||||
"/hemx.js" => (
|
||||
"text/javascript; charset=utf-8",
|
||||
@@ -521,11 +678,16 @@ fn serve(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(bootstrap).expect("read generated client bootstrap"),
|
||||
),
|
||||
"/app.js" if app_module.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(app_module.expect("checked app module")).expect("read app module"),
|
||||
),
|
||||
_ => ("text/plain", b"not found".to_vec()),
|
||||
};
|
||||
let status = if path == "/"
|
||||
|| path == "/hemx.js"
|
||||
|| path == "/hemx.client.js"
|
||||
|| (path == "/app.js" && app_module.is_some())
|
||||
|| path.starts_with(&format!("/{asset_stem}"))
|
||||
{
|
||||
"200 OK"
|
||||
@@ -536,9 +698,14 @@ fn serve(
|
||||
stream.write_all(&body).expect("write fixture body");
|
||||
}
|
||||
|
||||
fn fixture_html(rendered: &str) -> String {
|
||||
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||
let app_module = if has_app_module {
|
||||
"<script type=\"module\" src=\"/app.js\"></script>"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script></body></html>"
|
||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user