From 3bad745da93132b5197fe8dc53f6336f6a753c30 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 14 Jul 2026 00:17:20 +0200 Subject: [PATCH] feat(sync): replay durable projections from Rust req: ms/001 req: ms/002 req: ms/003 --- PLAN.md | 8 +- examples/kanban/src/lib.rs | 16 +- hemx-js/runtime/hemx.js | 7 +- hemx-sync/runtime/hemx-sync.js | 54 ++++++- hemx-sync/src/lib.rs | 41 +++++ hemx-wasm/tests/browser.rs | 269 ++++++++++++++------------------- 6 files changed, 217 insertions(+), 178 deletions(-) diff --git a/PLAN.md b/PLAN.md index 20340af..546d3d3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -51,18 +51,18 @@ encryption, retention, backup, and deployment policy remain host concerns. ## Slice 5 — local-first multiplayer Kanban milestone - [ ] **User value:** the complete north-star app demonstrates SSR-first startup, direct manipulation, offline durability, optimistic projection, reconciliation, and live presence as one comprehensible workflow. -- **State:** In progress — the single named milestone journey passes; closure still requires replacing the example-owned `offline.js`, `command-log.js`, and `sync.js` behavior because `v1_release/002` forbids app-authored JavaScript. +- **State:** In progress — the named milestone now performs durable projection/replay/upload with generated Rust/WASM plus the framework sync runtime and loads no example-authored JavaScript; the app binary still exposes the legacy app-authored `/sync.js` demo route, so `v1_release/002` cannot close yet. - **Build:** connect the previous slices in the canonical Kanban example; keep native server-rendered fallback; add presence and server-canonical conflict presentation; exercise deploy fingerprint recovery and accessible online/offline/conflict state. - **Refusals:** no demo-only runtime, hidden app JS, proprietary service, or requirement to load collaboration code for server-first apps. - **Requirements:** `ms/001-003`, `v1_release/001-002`, `accessibility/001-007`, `operations/006`, `performance/006`. -- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, durable command queue, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload durability, concurrent peer mutation and canonical convergence, presence projection, rejection and corrupt-queue recovery with deletion, mixed-version reload, and optional-asset isolation through the real public entry points. This does not yet close `v1_release/002`: the app still loads example-authored JavaScript for offline queue and sync orchestration. +- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, framework-owned durable sync runtime, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload projection replay and upload, concurrent peer mutation and canonical convergence, presence projection, typed rejection recovery, mixed-version reload, and optional-asset isolation. The journey explicitly receives 404 for `/app.js` and `/offline.js` and asserts that its only scripts are generated/client runtime plus framework sync runtime. `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally preserves the legacy flat durable-record upgrade path. This proves the durable milestone path but does not yet close `v1_release/002` while the separate legacy `/sync.js` demo route remains in the app binary. -Execution cursor: move the app-owned durable command/event/projection orchestration behind the generated Rust/WASM boundary and rerun the named milestone journey with no example-authored JavaScript loaded. +Execution cursor: delete or replace the legacy app-authored `/sync.js` demo route with the public framework sync mechanism, preserving its recovery/accessibility proof without bespoke example JavaScript. ## Slice 6 — production integration reference - [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy. -- **State:** Blocked by the remaining Slice 5 app-authored JavaScript gap. +- **State:** Blocked by the remaining Slice 5 legacy `/sync.js` route. - **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery. - **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework. - **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts. diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index 26a3002..9076068 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -87,11 +87,15 @@ pub fn reorder_card( Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0), None => ui::client_board::client_cards.move_to_end(card.clone()), }; - ( - move_effect, - ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)), - hemx_sync::SyncEffect::send_patch(patch), - ) + let projection = hemx::IntoEffect::into_batch( + ( + move_effect, + ui::client_board::client_notice + .text(format!("Moved {card} with {}", projected.input_kind)), + ), + ui::BUILD_FINGERPRINT, + ); + hemx_sync::SyncEffect::durable(patch, projection) } #[cfg(all(test, feature = "client"))] @@ -115,7 +119,7 @@ mod client_tests { .into_batch(ui::BUILD_FINGERPRINT); assert_eq!(batch.ops.len(), 3); assert!( - matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction")) + matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":[")) ); } } diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 7f22bd1..4f72f29 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -503,13 +503,16 @@ } else if (op.kind === "emit") { let payload = op.payload; if (currentOperationId && op.name === "hemx:sync-patch") { - const patch = JSON.parse(payload); + const event = JSON.parse(payload); + const patch = event && Object.getPrototypeOf(event) === Object.prototype && "patch" in event + ? event.patch + : event; if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") { throw new Error("hemx sync patch is missing its interaction identity"); } patch.idempotencyKey = currentOperationId; patch.operationId = currentOperationId; - payload = JSON.stringify(patch); + payload = JSON.stringify(event); } handleRuntimeEvent(scope, op.name, payload); emit(scope, op.name, payload); diff --git a/hemx-sync/runtime/hemx-sync.js b/hemx-sync/runtime/hemx-sync.js index a80f291..3289592 100644 --- a/hemx-sync/runtime/hemx-sync.js +++ b/hemx-sync/runtime/hemx-sync.js @@ -62,6 +62,28 @@ function validatePatch(patch) { return patch; } +function validateProjection(projection) { + if (!Array.isArray(projection) + || projection.length > 1024 * 1024 + || projection.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + throw new Error("invalid durable projection"); + } + return projection; +} + +function normalizeEvent(payload) { + if (payload && Object.getPrototypeOf(payload) === Object.prototype && "patch" in payload) { + const keys = Object.keys(payload).sort(); + if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "projection") { + throw new Error("durable patch fields do not match schema"); + } + const patch = validatePatch(payload.patch); + return { idempotencyKey: patch.idempotencyKey, patch, projection: validateProjection(payload.projection) }; + } + const patch = validatePatch(payload); + return { idempotencyKey: patch.idempotencyKey, patch, projection: null }; +} + async function allPatches() { const transaction = database.transaction(STORE, "readonly"); const done = transactionDone(transaction); @@ -70,10 +92,21 @@ async function allPatches() { return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey)); } -async function persist(patch) { +function normalizeStoredRecord(stored) { + if (stored.patch) { + return { + patch: validatePatch(stored.patch), + projection: stored.projection === null ? null : validateProjection(stored.projection), + }; + } + const { queuedAt: _queuedAt, ...legacyPatch } = stored; + return { patch: validatePatch(legacyPatch), projection: null }; +} + +async function persist(record) { const transaction = database.transaction(STORE, "readwrite"); const done = transactionDone(transaction); - transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() }); + transaction.objectStore(STORE).add({ ...record, queuedAt: Date.now() }); await done; root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length)); } @@ -90,7 +123,7 @@ async function pump() { pumping = true; try { for (const stored of await allPatches()) { - const { queuedAt: _queuedAt, ...patch } = stored; + const { patch } = normalizeStoredRecord(stored); const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches"; const response = await fetch(endpoint, { method: "POST", @@ -122,18 +155,25 @@ async function pump() { async function start() { if (!root) return; database = await openDatabase(); - root.setAttribute("data-hemx-sync-ready", ""); - root.setAttribute("data-hemx-sync-pending", String((await allPatches()).length)); + const pending = await allPatches(); + for (const stored of pending) { + const { projection } = normalizeStoredRecord(stored); + if (projection) { + window.hemx?.applyBatch(Uint8Array.from(projection).buffer, root); + } + } document.addEventListener(EVENT, async (event) => { try { - const patch = validatePatch(JSON.parse(event.detail)); - await persist(patch); + const record = normalizeEvent(JSON.parse(event.detail)); + await persist(record); await pump(); } catch (error) { root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error)); } }); window.addEventListener("online", () => pump()); + root.setAttribute("data-hemx-sync-pending", String(pending.length)); + root.setAttribute("data-hemx-sync-ready", ""); await pump(); } diff --git a/hemx-sync/src/lib.rs b/hemx-sync/src/lib.rs index 31c089d..a380575 100644 --- a/hemx-sync/src/lib.rs +++ b/hemx-sync/src/lib.rs @@ -417,6 +417,29 @@ impl SyncEffect { payload: patch.payload(), }]) } + + /// Apply an optimistic projection now and carry the same ordinary batch in + /// the durable patch event so the framework sync runtime can replay it + /// after reload before acknowledgement. + pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self { + patch.validate().expect("FlatPatch must remain valid"); + let projection_wire = projection + .to_wire() + .iter() + .map(u8::to_string) + .collect::>() + .join(","); + let payload = format!( + r#"{{"patch":{},"projection":[{projection_wire}]}}"#, + patch.payload() + ); + let mut ops = projection.ops; + ops.push(Effect::Emit { + name: PATCH_EVENT.to_owned(), + payload, + }); + Self(ops) + } } impl IntoEffect for SyncEffect { @@ -429,6 +452,24 @@ impl IntoEffect for SyncEffect { mod tests { use super::*; + #[test] + fn durable_patch_carries_and_applies_ordinary_projection_batch() { + let patch = + FlatPatch::for_interaction("cardColumn", PatchValue::String("done".into())).unwrap(); + let projection = Effect::Emit { + name: "projected".into(), + payload: "card:1".into(), + } + .into_batch(hemx_core::BuildFingerprint(7)); + let batch = + SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7)); + assert_eq!(batch.ops.len(), 2); + assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected")); + assert!( + matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction")) + ); + } + #[test] fn acknowledgement_updates_atom_and_emits_queue_signal() { let atom = Atom::::new(17); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 21ecdd6..23f48a3 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -361,7 +361,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity( #[tokio::test] async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> { - // test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 + // test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 req: v1_release/002 // test req: accessibility/001 req: accessibility/002 // test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -399,7 +399,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri bootstrap, rendered, "kanban_client", - Some(kanban_app_assets(&workspace)), + Some(framework_sync_assets(&workspace)), ); let webdriver_port = available_port(); @@ -498,22 +498,30 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri 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')", + "const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-hemx-sync-ready') === true", ) .await?; + let framework_only = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + Promise.all(['/app.js', '/offline.js'].map((path) => fetch(path).then((response) => response.status))) + .then((statuses) => done({ statuses, scripts: [...document.scripts].map((script) => script.getAttribute('src')) })) + .catch((error) => done({ error: String(error) })); + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(framework_only["statuses"], serde_json::json!([404, 404])); + assert_eq!( + framework_only["scripts"], + serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"]) + ); 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; - "#, + "window.__durablePatch = null; document.addEventListener('hemx:sync-patch', (event) => { window.__durablePatch = event.detail; }, { once: true }); document.querySelector('[data-hemx-root]').setAttribute('data-sync-endpoint', '/unavailable'); return true", Vec::new(), ) .await?; @@ -525,84 +533,50 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri .await?; wait_until( &driver, - "return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'", + "const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && window.__durablePatch !== null && root.getAttribute('data-hemx-sync-pending') === '1' && root.getAttribute('data-hemx-sync-error') === 'upload-404'", ) .await?; - let persisted = driver + let command_id = driver .execute( - "return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }", + "return JSON.parse(window.__durablePatch).patch.idempotencyKey", Vec::new(), ) .await? .json() - .clone(); - assert_eq!(persisted["persisted"]["order"], "1|2"); - assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 2); - assert_eq!(persisted["persisted"]["detail"]["targetColumn"], "done"); - 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"); - 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"); - - server.stop(); - assert!(!server.is_reachable(), "fixture server must be unreachable"); - driver - .execute("window.__reloadPending = true; location.reload()", Vec::new()) - .await?; + .expect("durable interaction identity") + .to_owned(); + driver.refresh().await?; wait_until( &driver, - "return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true", + "const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')", ) .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 }", + "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), notice: root.querySelector('[data-sid]').textContent, error: root.getAttribute('data-hemx-sync-error') }", 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 drop"); + assert!(restored["error"].is_null()); let app_addr = server.address.to_string(); + server.stop(); + assert!(!server.is_reachable(), "fixture server must be stopped before app restart"); let mut app_command = Command::new(&host_binary); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = ProcessGuard::start(app_command, &app_addr); - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'", - ) - .await?; - let command_id = persisted["persisted"]["detail"]["id"] - .as_str() - .expect("persisted command id"); let duplicate_script = format!( r#" const done = arguments[arguments.length - 1]; const commandId = {command_id:?}; (async () => {{ + const initialResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }}); + const initial = await initialResponse.json(); const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }}); const duplicate = await duplicateResponse.json(); const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }}); @@ -613,23 +587,20 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri const peer = await peerResponse.json(); const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json(); const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text(); - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => {{ - const count = open.result.transaction('commands', 'readonly').objectStore('commands').count(); - count.onsuccess = () => done({{ - duplicateStatus: duplicateResponse.status, - duplicate, - conflictStatus: conflictResponse.status, - conflict, - rejectionStatus: rejectionResponse.status, - rejection, - peerStatus: peerResponse.status, - peer, - snapshot, - history, - queueCount: count.result, - }}); - }}; + done({{ + initialStatus: initialResponse.status, + initial, + duplicateStatus: duplicateResponse.status, + duplicate, + conflictStatus: conflictResponse.status, + conflict, + rejectionStatus: rejectionResponse.status, + rejection, + peerStatus: peerResponse.status, + peer, + snapshot, + history, + }}); }})().catch((error) => done({{ error: String(error), stack: error.stack }})); "# ); @@ -642,6 +613,9 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri convergence.get("error").is_none(), "sync convergence failed: {convergence}" ); + assert_eq!(convergence["initialStatus"], 200); + assert_eq!(convergence["initial"]["commandId"], command_id); + assert_eq!(convergence["initial"]["serverSequence"], 1); assert_eq!(convergence["duplicateStatus"], 200); assert_eq!(convergence["duplicate"]["commandId"], command_id); assert_eq!(convergence["duplicate"]["serverSequence"], 1); @@ -657,7 +631,6 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri assert_eq!(convergence["peerStatus"], 200); assert_eq!(convergence["peer"]["commandId"], "peer:1"); assert_eq!(convergence["peer"]["serverSequence"], 2); - assert_eq!(convergence["queueCount"], 0); assert_eq!(convergence["snapshot"]["serverSequence"], 2); assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1); assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done"); @@ -667,70 +640,11 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri convergence["history"] .as_str() .expect("acknowledgement history") - .matches(command_id) + .matches(&command_id) .count(), 1, "duplicate replay emitted another acknowledgement: {convergence}" ); - - driver.goto(&format!("http://{app_addr}/")).await?; - wait_until( - &driver, - "return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true", - ) - .await?; - driver - .execute( - r#" - window.__futureCommandStored = false; - const request = indexedDB.open('hemx-kanban-v1'); - request.onsuccess = () => { - const tx = request.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'future:2', schemaVersion: 3, accountPartition: 'demo:demo', actor: 'future', session: 'future', - causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => { window.__futureCommandStored = true; }; - }; - return true; - "#, - Vec::new(), - ) - .await?; - wait_until(&driver, "return window.__futureCommandStored === true").await?; - driver - .execute("window.__reloadPending = true; location.reload()", Vec::new()) - .await?; - wait_until( - &driver, - "return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-error') === true", - ) - .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); - - let delete_commands = driver - .find(By::Css("[data-kanban-command-action='delete']")) - .await?; - delete_commands.click().await?; - assert_eq!(delete_commands.text().await?, "Confirm delete commands"); - delete_commands.click().await?; - wait_until( - &driver, - "const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-kanban-command-ready') === true && root.getAttribute('data-kanban-command-count') === '0' && !root.hasAttribute('data-kanban-command-error')", - ) - .await?; Ok(()) } .await; @@ -1744,15 +1658,23 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf { } struct AppAssets { - module: PathBuf, - service_worker: PathBuf, + module: Option, + service_worker: Option, sync_runtime: 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"), + module: Some(workspace.join("examples/kanban/static/command-log.js")), + service_worker: Some(workspace.join("examples/kanban/static/offline.js")), + sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"), + } +} + +fn framework_sync_assets(workspace: &Path) -> AppAssets { + AppAssets { + module: None, + service_worker: None, sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"), } } @@ -1841,7 +1763,7 @@ fn serve( let (content_type, body) = match path { "/" => ( "text/html; charset=utf-8", - fixture_html(rendered, app_assets.is_some()).into_bytes(), + fixture_html(rendered, app_assets).into_bytes(), ), "/hemx.js" => ( "text/javascript; charset=utf-8", @@ -1859,15 +1781,36 @@ fn serve( "text/javascript; charset=utf-8", fs::read(bootstrap).expect("read generated client bootstrap"), ), - "/app.js" if app_assets.is_some() => ( - "text/javascript; charset=utf-8", - 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) + "/app.js" + if app_assets + .and_then(|assets| assets.module.as_ref()) + .is_some() => + { + ( + "text/javascript; charset=utf-8", + fs::read( + app_assets + .and_then(|assets| assets.module.as_ref()) + .expect("checked app module"), + ) + .expect("read app module"), + ) + } + "/offline.js" + if app_assets + .and_then(|assets| assets.service_worker.as_ref()) + .is_some() => + { + ( + "text/javascript; charset=utf-8", + fs::read( + app_assets + .and_then(|assets| assets.service_worker.as_ref()) + .expect("checked service worker"), + ) .expect("read service worker"), - ), + ) + } "/hemx-sync.js" if app_assets.is_some() => ( "text/javascript; charset=utf-8", fs::read(&app_assets.expect("checked app assets").sync_runtime) @@ -1903,8 +1846,14 @@ fn serve( let status = if path == "/" || path == "/hemx.js" || path == "/hemx.client.js" - || ((path == "/app.js" - || path == "/offline.js" + || (((path == "/app.js" + && app_assets + .and_then(|assets| assets.module.as_ref()) + .is_some()) + || (path == "/offline.js" + && app_assets + .and_then(|assets| assets.service_worker.as_ref()) + .is_some()) || path == "/hemx-sync.js" || path == "/sync/context" || path == "/sync/patches") @@ -1921,11 +1870,13 @@ fn serve( let _ = stream.write_all(&body); } -fn fixture_html(rendered: &str, has_app_module: bool) -> String { - let app_module = if has_app_module { - "" - } else { - "" +fn fixture_html(rendered: &str, app_assets: Option<&AppAssets>) -> String { + let app_module = match app_assets { + Some(assets) if assets.module.is_some() => { + "" + } + Some(_) => "", + None => "", }; format!( "{rendered}{app_module}"