diff --git a/PLAN.md b/PLAN.md index 546d3d3..26319e0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,24 +50,24 @@ 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 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. +- [x] **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:** Complete. - **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, 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: 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. +- **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 receives 404 for `/app.js`, `/offline.js`, `/sync-demo`, and `/sync.js` and asserts that its only scripts are generated/client runtime plus framework sync runtime. The former app-authored sync UI remains reachable only behind the explicit `HEMX_KANBAN_LEGACY_SYNC_FIXTURE=1` test-fixture boundary so its recovery/accessibility regression suite remains available without becoming milestone surface. `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. ## 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 legacy `/sync.js` route. +- **State:** Active; Slice 5 is complete. - **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. - **Proof:** end-to-end test survives process restart and mixed deployment, proves allowed/denied/rolled-back mutations and redacted diagnostics, and maps each framework-owned ASVS-relevant control to a failing/passing case. +Execution cursor: evolve the existing SaaS reference so one authenticated project mutation is authorized, origin/CSRF checked, transactionally durable across process restart, and proven alongside its denied and rolled-back cases through the public server-first entry point. + ## Slice 7 — v1 compatibility and closure - [ ] **User value:** maintainers and adopters receive a reproducible, migration-aware v1 with no known material contradiction and no hidden publication side effect. diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index 5aca3f7..f68003b 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -375,7 +375,7 @@ struct AppShell { } #[derive(Hemplate)] -struct SyncShell { +struct LegacySyncFixture { runtime_src: &'static str, } @@ -504,13 +504,20 @@ async fn main() { .route("/events", get(events)) .route("/sync/broadcast", get(sync_broadcast)) .route("/sync/ack", get(sync_ack)) - .route("/sync-demo", get(sync_demo)) - .route("/sync.js", get(sync_js)) .route("/sync/context", get(sync_context)) .route("/sync/commands", post(sync_command)) .route("/sync/snapshot", get(sync_snapshot)) .route(runtime_js_path(), get(runtime)) .layer(middleware::from_fn(ordinary_handler_timeout)); + let ordinary_routes = if std::env::var_os("HEMX_KANBAN_LEGACY_SYNC_FIXTURE").as_deref() + == Some(std::ffi::OsStr::new("1")) + { + ordinary_routes + .route("/sync-demo", get(legacy_sync_fixture)) + .route("/sync.js", get(legacy_sync_fixture_js)) + } else { + ordinary_routes + }; let app = ordinary_routes .merge(Router::new().route("/sync/acknowledgements", get(sync_acknowledgements))) .with_state(state); @@ -589,9 +596,9 @@ async fn runtime() -> impl IntoResponse { runtime_js() } -async fn sync_demo() -> impl IntoResponse { +async fn legacy_sync_fixture() -> impl IntoResponse { PageResponse::full( - ui::page(&SyncShell { + ui::page(&LegacySyncFixture { runtime_src: runtime_js_path(), }) .into_string(), @@ -600,10 +607,10 @@ async fn sync_demo() -> impl IntoResponse { .fingerprint(ui::BUILD_FINGERPRINT) } -async fn sync_js() -> impl IntoResponse { +async fn legacy_sync_fixture_js() -> impl IntoResponse { ( [("content-type", "text/javascript; charset=utf-8")], - include_str!("../static/sync.js"), + include_str!("../tests/fixtures/legacy-sync.js"), ) } diff --git a/examples/kanban/templates/sync_shell.heml b/examples/kanban/templates/legacy_sync_fixture.heml similarity index 100% rename from examples/kanban/templates/sync_shell.heml rename to examples/kanban/templates/legacy_sync_fixture.heml diff --git a/examples/kanban/tests/browser_e2e.rs b/examples/kanban/tests/browser_e2e.rs index e25d3d4..05bc049 100644 --- a/examples/kanban/tests/browser_e2e.rs +++ b/examples/kanban/tests/browser_e2e.rs @@ -7,9 +7,15 @@ use thirtyfour::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); +fn app_command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + command.env("HEMX_KANBAN_LEGACY_SYNC_FIXTURE", "1"); + command +} + #[tokio::test] async fn server_first_route_does_not_load_optional_client_assets() -> WebDriverResult<()> { - // test req: performance/006 + // test req: performance/006 req: v1_release/002 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); @@ -42,6 +48,7 @@ async fn server_first_route_does_not_load_optional_client_assets() -> WebDriverR clientRoots: document.querySelectorAll('[data-hemx-client-module]').length, serviceWorkers: registrations.length, databases: databases.map((database) => database.name), + legacyFixtureStatuses: await Promise.all(['/sync-demo', '/sync.js'].map((path) => fetch(path).then((response) => response.status))), }); }).catch((error) => done({ error: String(error) })); "#, @@ -55,6 +62,10 @@ async fn server_first_route_does_not_load_optional_client_assets() -> WebDriverR assert_eq!(loaded["clientRoots"], 0); assert_eq!(loaded["serviceWorkers"], 0); assert_eq!(loaded["databases"].as_array().map(Vec::len), Some(0)); + assert_eq!( + loaded["legacyFixtureStatuses"], + serde_json::json!([404, 404]) + ); let scripts = loaded["scripts"].as_array().expect("document scripts"); assert_eq!(scripts.len(), 1); let runtime_path = scripts[0]["src"].as_str().expect("runtime script path"); @@ -95,7 +106,7 @@ async fn idempotent_server_command_is_acknowledged_after_reconnect() -> WebDrive // test req: sync/008 req: sync/012 req: sync/013 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app = app_command(); app.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start ready hemx-kanban"); @@ -210,7 +221,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack( // test req: sync/004 req: sync/009 req: sync/010 req: sync/016 req: sync/017 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app = app_command(); app.env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_FAIL_FIRST_SYNC", "1"); let _app = TestProcess::start(app, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) @@ -377,7 +388,7 @@ async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebD // test req: sync/020 req: security/004 req: auth/005 req: operations/002 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env( @@ -602,7 +613,7 @@ async fn canonical_snapshot_and_history_are_tenant_scoped() -> WebDriverResult<( // test req: sync/007 req: security/004 req: auth/005 req: performance/004 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env( @@ -725,7 +736,7 @@ async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverRe // test req: sync/004 req: sync/010 req: sync/014 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_FAILURES", "3"); @@ -887,7 +898,7 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD // test req: sync/009 req: sync/010 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start ready hemx-kanban"); @@ -1015,7 +1026,7 @@ async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> Web // test req: sync/017 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start ready hemx-kanban"); @@ -1124,7 +1135,7 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl // test req: sync/018 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_FAILURES", "3"); @@ -1295,7 +1306,7 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr // test req: sync/004 req: sync/010 req: sync/011 req: sync/014 req: sync/016 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_FAILURES", "3"); @@ -1421,7 +1432,7 @@ async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() - // test req: sync/007 req: sync/010 req: sync/011 req: sync/020 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_RETAINED_AFTER", "1"); @@ -1715,7 +1726,7 @@ async fn redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material( // test req: operations/001 req: operations/005 req: security/002 req: sync/016 req: sync/021 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_FAILURES", "3"); @@ -1840,7 +1851,7 @@ async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> Web // test req: sync/009 req: sync/010 req: sync/011 req: sync/016 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_RETAINED_AFTER", "1"); @@ -2086,7 +2097,7 @@ async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> We // test req: security/005 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2206,7 +2217,7 @@ async fn canonical_acknowledgement_updates_generated_atom_over_ordinary_batch( // test req: sync/006 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2296,7 +2307,7 @@ async fn typed_presence_join_leave_updates_generated_atom_over_sse() -> WebDrive // test req: sync/001 req: sync/004 req: sync/005 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2377,7 +2388,7 @@ async fn ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide() -> // test req: operations/003 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2454,7 +2465,7 @@ async fn acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancell // test req: operations/004 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_ACK_HEARTBEAT_MS", "25"); @@ -2560,7 +2571,7 @@ async fn sync_requests_timeout_and_cancel_on_pagehide() -> WebDriverResult<()> { // test req: operations/003 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2635,7 +2646,7 @@ async fn identical_sync_inputs_reconcile_deterministically() -> WebDriverResult< // test req: sync/022 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut app_command = app_command(); app_command.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start hemx-kanban"); @@ -2725,7 +2736,7 @@ async fn canonical_acknowledgement_survives_server_restart() -> WebDriverResult< )); let _ = fs::remove_file(&store); - let mut first_app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut first_app_command = app_command(); first_app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_STORE", &store); @@ -2772,7 +2783,7 @@ async fn canonical_acknowledgement_survives_server_restart() -> WebDriverResult< assert!(persisted.contains("\"tenant\": \"demo\"")); drop(first_app); - let mut second_app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + let mut second_app_command = app_command(); second_app_command .env("HEMX_KANBAN_ADDR", &app_addr) .env("HEMX_KANBAN_SYNC_STORE", &store); diff --git a/examples/kanban/static/sync.js b/examples/kanban/tests/fixtures/legacy-sync.js similarity index 100% rename from examples/kanban/static/sync.js rename to examples/kanban/tests/fixtures/legacy-sync.js diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 23f48a3..f0c3e73 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -505,7 +505,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri .execute_async( r#" const done = arguments[arguments.length - 1]; - Promise.all(['/app.js', '/offline.js'].map((path) => fetch(path).then((response) => response.status))) + Promise.all(['/app.js', '/offline.js', '/sync-demo', '/sync.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) })); "#, @@ -514,7 +514,10 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri .await? .json() .clone(); - assert_eq!(framework_only["statuses"], serde_json::json!([404, 404])); + assert_eq!( + framework_only["statuses"], + serde_json::json!([404, 404, 404, 404]) + ); assert_eq!( framework_only["scripts"], serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"])