feat(kanban): restore commands offline

req: local/001\nreq: local/002\nreq: local/003\nreq: local/004
This commit is contained in:
slhx agent
2026-07-13 14:52:51 +02:00
parent 7298405e27
commit 9a3c0335eb
4 changed files with 112 additions and 20 deletions
+68 -18
View File
@@ -201,13 +201,13 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
.to_owned();
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js");
let server = StaticServer::start(
let mut server = StaticServer::start(
package,
runtime,
bootstrap,
rendered,
"kanban_client",
Some(workspace.join("examples/kanban/static/command-log.js")),
Some(kanban_app_assets(&workspace)),
);
let webdriver_port = available_port();
@@ -222,7 +222,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
driver.goto(&server.url()).await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
)
.await?;
driver
@@ -268,11 +268,26 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
assert!(!session.is_empty());
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
assert_eq!(persisted["count"], "1");
let offline_shell = driver
.execute_async(
"const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(offline_shell["controlled"], true, "{offline_shell}");
assert_eq!(offline_shell["shell"], true, "{offline_shell}");
assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1");
driver.refresh().await?;
server.stop();
assert!(!server.is_reachable(), "fixture server must be unreachable");
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
)
.await?;
let restored = driver
@@ -307,10 +322,12 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
)
.await?;
wait_until(&driver, "return window.__futureCommandStored === true").await?;
driver.refresh().await?;
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
)
.await?;
let rejected = driver
@@ -350,7 +367,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
bootstrap,
rendered,
"kanban_client",
Some(workspace.join("examples/kanban/static/command-log.js")),
Some(kanban_app_assets(&workspace)),
);
let webdriver_port = available_port();
@@ -592,9 +609,22 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
.expect("generated hemx client bootstrap")
}
struct AppAssets {
module: PathBuf,
service_worker: PathBuf,
}
fn kanban_app_assets(workspace: &Path) -> AppAssets {
AppAssets {
module: workspace.join("examples/kanban/static/command-log.js"),
service_worker: workspace.join("examples/kanban/static/offline.js"),
}
}
struct StaticServer {
address: String,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl StaticServer {
@@ -604,14 +634,14 @@ impl StaticServer {
bootstrap: PathBuf,
rendered: String,
asset_stem: &'static str,
app_module: Option<PathBuf>,
app_assets: Option<AppAssets>,
) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
listener.set_nonblocking(true).expect("nonblocking fixture");
let address = listener.local_addr().expect("fixture address").to_string();
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
thread::spawn(move || {
let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => serve(
@@ -621,7 +651,7 @@ impl StaticServer {
&bootstrap,
&rendered,
asset_stem,
app_module.as_deref(),
app_assets.as_ref(),
),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10))
@@ -630,17 +660,32 @@ impl StaticServer {
}
}
});
Self { address, stop }
Self {
address,
stop,
thread: Some(thread),
}
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
thread.join().expect("stop browser fixture");
}
}
fn is_reachable(&self) -> bool {
TcpStream::connect(&self.address).is_ok()
}
}
impl Drop for StaticServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.stop();
}
}
@@ -651,7 +696,7 @@ fn serve(
bootstrap: &Path,
rendered: &str,
asset_stem: &str,
app_module: Option<&Path>,
app_assets: Option<&AppAssets>,
) {
let mut request = [0_u8; 2048];
let length = stream.read(&mut request).unwrap_or(0);
@@ -660,7 +705,7 @@ fn serve(
let (content_type, body) = match path {
"/" => (
"text/html; charset=utf-8",
fixture_html(rendered, app_module.is_some()).into_bytes(),
fixture_html(rendered, app_assets.is_some()).into_bytes(),
),
"/hemx.js" => (
"text/javascript; charset=utf-8",
@@ -678,16 +723,21 @@ fn serve(
"text/javascript; charset=utf-8",
fs::read(bootstrap).expect("read generated client bootstrap"),
),
"/app.js" if app_module.is_some() => (
"/app.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(app_module.expect("checked app module")).expect("read app module"),
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
),
"/offline.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").service_worker)
.expect("read service worker"),
),
_ => ("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 == "/app.js" || path == "/offline.js") && app_assets.is_some())
|| path.starts_with(&format!("/{asset_stem}"))
{
"200 OK"