diff --git a/.agents/skills/idiomatic-hemx/SKILL.md b/.agents/skills/idiomatic-hemx/SKILL.md deleted file mode 100644 index a7d55ae..0000000 --- a/.agents/skills/idiomatic-hemx/SKILL.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: idiomatic-hemx -description: >- - Use when a user explicitly asks how to design, implement, review, - productionize, or scale an application built with hemx and hemplate, - including deciding whether or where microservices belong. Preserve hemx's - generated-resource and server-owned effect model, then choose the smallest - evidence-backed scale stage. Do not use for generic Rust/Axum architecture, - generic microservice advice, or development of the hemx framework itself. ---- - -# Idiomatic hemx - -## One job - -Choose, explain, or implement the smallest production architecture that keeps a -hemx/hemplate application boring as it grows. The high-leverage move is usually -to preserve one direction of travel: - -```text -semantic .heml surface - -> generated typed resources - -> ordinary Rust domain/application code - -> generated partial/page effects - -> tiny browser runtime -``` - -Scale providers and deployment topology around that loop. Do not replace it -with selectors, a client state graph, raw wire operations, or speculative -services. - -## Trigger boundary - -Load this skill for explicit hemx/hemplate application usage, architecture, -production-readiness, scaling, or a review of those decisions. It also applies -when the user asks whether a hemx application should become microservices. - -Do not load it for: - -- generic Rust, Axum, HTML, CSS, database, or microservice questions with no hemx - application decision; -- visual design alone; -- changing hemx/hemplate internals rather than using their public model; -- a tiny obvious application patch where no authoring or scaling judgment is at - stake. - -## Authority before taste - -Inside the hemx repository, read `AGENTS.md`, then -`docs/v1-product-evidence.md` and `REQUIREMENTS.md`. Use `PLAN.md` only as the -mutable implementation cursor. Inspect only the nearest authoritative material -needed for the decision: - -- `docs/hemplate-syntax.md` for real `.heml` syntax; -- `docs/tutorial-saas.md` for the production-shaped application boundary; -- `docs/recipes/reusable-partials.md` for composition; -- the auth, persistence, observability, deploy/versioning, offline, and local - command-log recipes for their named concerns; -- the nearest maintained example and current public Rust API before naming an - API in code. - -Project requirements and observed code outrank this skill. Outside this -repository, establish the application's versions and elected contracts instead -of assuming current-main APIs. If auth, storage, transport, replay, deployment, -or service contracts are absent, identify the missing decision; do not invent a -provider or abstraction that makes the system look complete. - -When invoked for a Hemx application repository, inspect its elected `AGENTS.md` -or equivalent instruction surface. Ensure it explicitly requires all HTML -surfaces and fragments to be authored as semantic `.heml` templates rendered -through Hemplate's generated typed resources, and explicitly forbids constructing, -concatenating, interpolating, or formatting HTML in Rust. If the current request -authorizes repository edits, patch that instruction surface before application -implementation; otherwise report the missing policy as a blocker. Do not copy the -rest of this skill into project instructions. - -## Decision loop - -### 1. Start with the user job and one load-bearing path - -Name the concrete request, mutation, navigation, push update, or recovery path -that must work. Trace it end to end before discussing topology: - -```text -HTTP/browser input - -> normal auth, CSRF, and typed validation - -> application command/query - -> authoritative state transition - -> rendered generated target/page - -> EffectBatch response or canonical push bytes - -> root-scoped runtime application - -> visible success or explicit recovery -``` - -If this path is unclear, architecture diagrams and service boundaries are -premature. - -### 2. Use the canonical authoring level - -Default to: - -- semantic `.heml` templates plus ordinary Rust; never construct, concatenate, - interpolate, or format HTML strings in Rust, including fragments for source - rendering, Markdown, errors, or test-facing pages; -- `#[hemx::app]`, plain `#[hemx::handler]`, generated components/resources, - typed form models, view wrappers, render/page helpers, and `IntoEffect`; -- generated target, form, control, keyed-slot, and event helpers; -- typed partial swaps expressed as generated target + rendered partial + swap - kind; -- real links and GET forms for navigation and history; -- `data-hemx-revealed-ahead` for viewport-ahead loading while the observed sentinel remains in normal document flow; never move the observed target with CSS to fake prefetch distance; -- plain CSS/SCSS for appearance; -- shared runtime paths and Axum adapters supplied by `hemx-axum`. - -Keep domain types, authorization, persistence, routing, sessions, flags, -observability, and transport policy in the application or integration crate. -Keep `hemx-core` about typed resources and the closed effect/wire contract. -Compose reusable UI from templates, generated components, and ordinary Rust -functions rather than creating a client component framework. - -Use advanced layers only when the job proves the need: - -- opaque island JavaScript is a leaf adapter for high-frequency local behavior; -- client-local/wasm handlers are explicit opt-ins, not the default state model; -- atoms are addressable bootstrap/sync resources, not a general reactive store; -- SSE/WebSocket transport carries canonical versioned `EffectBatch` bytes; it - does not define domain policy. - -### 3. Keep truth on the right side of the boundary - -The browser DOM, stored HTML patches, and stored `EffectBatch` values are not -business truth. Keep authoritative state in ordinary application/domain models -and durable providers. Derive UI effects from that state. - -For local/offline products, use explicit commands, events, and projections. -Replay, deletion, conflict resolution, reconciliation, and export semantics are -product contracts; stop when they have not been chosen. For server products, -normal HTTP security semantics remain authoritative even when interactions are -enhanced. - -Expected failures must become typed, local, useful outcomes: generated field -errors/focus for validation, a root-scoped error outlet for recoverable request -failure, and fail-closed handling for malformed or incompatible responses. -Never turn infrastructure failure into a success-looking empty effect. - -Prefer a **functional core with an imperative shell**. Keep validation, -normalization, authorization decisions, command application, state transitions, -and view-model/projection derivation as deterministic functions over explicit -inputs where that is honest. A useful shape is `state + command -> outcome` or -`facts -> view model`, with typed errors rather than hidden mutation. This makes -the largest behavior space cheap to unit-test and mutation-test. - -Keep HTTP extraction, sessions, database I/O, clocks, randomness/IDs, queues, -push connections, and host capabilities in a thin handler/application shell. -Read their results once, pass ordinary values into the core, persist the returned -outcome, then render generated effects. Pass time, identity, or policy as data -when only the value matters; do not create a trait for every function merely to -mock it. Use a real adapter boundary when ownership or side effects are real. -Purity is a locality tool, not a religion: orchestration and I/O are inherently -effectful, and `EffectBatch` remains UI output rather than domain state. - -### 4. Scale one pressure at a time - -Use this ladder. Enter a stage only when measured load, availability goals, -ownership, compliance, or a distinct failure/resource profile requires it. - -| Stage | Default shape | Required proof before moving on | -| --- | --- | --- | -| One process | One deployable; app-owned adapters; simplest durable store that meets the product contract | The real product path, restart behavior, backup/recovery needs, and current bottleneck are known | -| Production monolith | One release unit for server, generated output, and runtime asset; external durable database/session providers as required | Integration tests cover auth, persistence, failures, migration, fingerprint mismatch, and rollback/reload behavior | -| Horizontal web tier | Stateless request replicas around shared authoritative providers; process memory is cache/ephemeral only unless affinity and loss semantics are explicit | Load evidence shows replica scale helps; migrations, readiness, draining, cache invalidation, and session/CSRF behavior work across replicas | -| Push/fan-out tier | Server-owned ongoing SSE/WebSocket connections; add a broker/backplane only when updates must cross processes | Reconnect, ordering, duplicate, authorization, backpressure, and replay expectations are explicit and tested at the required level | -| Worker or read-model split | Isolate a measured CPU, latency, queue, or failure domain while the application remains one understandable product | Job ownership, idempotency, timeout, retry, deduplication, observability, and recovery contracts exist | -| Microservices | Split an independently owned bounded capability with its own release/scaling/SLO pressure and explicit data/API/event contract | The boundary removes a demonstrated constraint and its distributed failure modes are cheaper than the monolith | - -Prefer vertical resource tuning, query/index fixes, caching with explicit -freshness, bounded concurrency, and horizontal replicas before service -splitting. A large codebase is a module-boundary problem before it is a network -boundary problem. - -### 5. Preserve the hemx boundary across services - -When microservices are justified: - -- keep hemplate rendering, generated resources, and UI `EffectBatch` creation in - the presentation/application edge that owns the page; -- exchange typed domain requests, responses, and events across services—not CSS - selectors, DOM instructions, raw hemx opcodes, or app-authored JSON versions - of the hemx wire format; -- name one authority for each write model and do not let services casually share - mutation ownership; -- version service contracts independently, while deploying each hemx server, - its generated metadata, build fingerprint, and runtime asset as a compatible - release unit; -- preserve end-user credentials, authorization, CSRF, tenancy, deadlines, and - trace context explicitly at each real trust boundary; -- make partial failure visible. Define timeout, idempotency, retry, - deduplication, ordering, compensation, and replay only where the chosen - interaction requires them—never as generic middleware theater. - -Do not put a network hop between a handler and its renderer merely to claim -microservices. Do not use `EffectBatch` as a business event bus or durable event -log. - -### 6. Organize by cohesive product responsibility - -Start small; do not pre-create an architecture directory tree. A production app -usually needs only these durable seams: - -```text -build.rs # hemx-build/global code generation only -src/main.rs # config, concrete providers, process/bootstrap -src/lib.rs # app/router composition and a testable app surface -src// # add only when a feature is already a real seam -templates/app_shell.heml # document shell -templates/.heml # feature surface -templates/partials/ # genuinely reused or independently swapped pieces -tests/.rs # process/integration behavior -tests/browser_.rs # only browser-dependent behavior -``` - -Treat that as a responsibility map, not mandatory scaffolding. A small cohesive -app can remain in `lib.rs`; file count is not architecture. When a feature has -its own state/commands, handlers, rendering, and tests, move that whole seam -together. Keep its domain model, typed input, application operation, handler, -and generated view calls near one another. Do not spread every request across -generic `controllers/`, `services/`, `repositories/`, `dto/`, and `utils/` -directories. - -Keep `main.rs` boring and hard to test because it contains almost no policy. -Expose app construction or mounting from `lib.rs` so integration tests can use -the real router with controlled concrete providers. Put normal routing, -auth/session, persistence, queues, and transport adapters at the app boundary; -do not move them into hemx core or generated template modules. Keep generated -artifacts in the build output rather than copying them into source control. - -Put tiny unit tests beside the responsible module, especially around pure -transitions, validation, authorization decisions, and projections. Put -cross-module HTTP, persistence, and process tests in `tests/`, named for behavior -rather than implementation. Keep browser journeys few and load-bearing. Extract -a shared partial or helper only after actual reuse or independent swap identity -appears. -If a microservice boundary becomes real, that service owns its contract, -provider/migrations, operational entry point, and contract tests; do not mirror -hypothetical services in the source tree first. - -### 7. Prove behavior at the cheapest authoritative boundary - -Choose the tool by what must be observed: - -| Boundary | Default tool | What it proves | -| --- | --- | --- | -| Domain/state | Rust `#[test]` / `#[tokio::test]` | Parsing, invariants, commands, projections, and failure classes | -| Template/build | Compiler plus hemx-build diagnostics | Real `.heml` syntax, generated resources/forms, and cross-file references | -| Handler/effect | `hemx_test` | Generated targets/handles, rendered partials, form bodies, and effect batches without raw ids | -| Router/integration | Real Axum router, usually `tower::ServiceExt`, plus concrete test providers | HTTP status/headers/body, auth, CSRF, sessions, persistence, and malformed requests | -| Static rendered HTML | Rust `scraper` crate (HTML parser + CSS-selector queries) | Escaping, semantic structure, links/forms/attributes, and server-rendered fragments without launching a browser | -| Process lifecycle | `hemx_test::TestProcess` | Readiness, real sockets, child cleanup, restart, and production-binary behavior | -| Browser runtime | Rust `thirtyfour` crate (WebDriver client) with the repository-owned browser smoke | Delegated events, history, focus, polling/revealed bindings, keyed DOM identity, SSE, and recovery | -| Test quality/release | hemx xtask mutation/full-test commands | Mutation resistance and the elected bounded workspace verification path | - -`scraper` is a Rust HTML parsing and CSS-selector library, not a browser or web -framework. Use it when inspecting final server-rendered HTML is enough. It does -not run JavaScript, apply `EffectBatch`, maintain focus/history, or prove -SSE/runtime behavior. `thirtyfour` is a Rust WebDriver client crate; use it only -when an actual browser semantic is the subject; -do not turn every handler assertion into a WebDriver journey. In this repository -`thirtyfour` is the elected Rust browser adapter, so do not add Playwright, -Fantoccini, or another competing browser stack merely from preference. Outside -this repository, preserve the application's existing runner unless a concrete -missing capability justifies migration. - -For WebDriver tests, start the real process through the RAII `TestProcess` -harness, keep selectors in test adapters, and prefer generated-resource or -stable semantic helpers over copied implementation selectors. Browser selector -helpers are not authoring APIs. Avoid sleeps when the runner can wait for the -observable condition. - -Match proof to risk: - -1. Let compilation reject broken templates and generated contracts. -2. Unit-test ordinary domain parsing and state transitions without a browser. -3. Test handlers through `hemx_test` with useful generated-resource assertions. -4. Test route/auth/session/CSRF/persistence and wire failure behavior at the app - integration boundary. -5. Parse static HTML with `scraper` only for facts that do not require runtime - execution. -6. Use focused repository-owned browser smoke for dynamic attributes, - navigation/history, keyed reconciliation, polling/revealed behavior, - no-reload interaction, and runtime recovery. -7. Add compatibility, rolling-deploy, process-restart, multi-replica, load, or - fault tests only when the selected scale stage makes those behaviors part of - the contract. - -In this repository, prefer the stable commands named by `AGENTS.md`, especially -focused crate tests and `cargo run -p hemx-xtask -- test`; use -`cargo run -p hemx-xtask -- html-examples-smoke` for the pattern gallery and -runtime behavior, and the xtask mutation command rather than direct `mutest`. -Do not substitute a passing literal lowering fixture, static HTML parse, or -mocks-only test for proof at the rendered/runtime consumer boundary. - -## Refuse fake sophistication - -Do not introduce: - -- handwritten resource ids, selector retargeting, raw effect constructors, raw - registries, runtime opcodes, manual wire parsing, or hard-coded runtime URLs in - normal app code; -- a VDOM, client router, general client state graph, expression runtime, - per-node listeners, or handwritten JavaScript for ordinary forms/lists/swaps; -- core-owned auth, sessions, persistence, routing, multipart, transport, sync, - analytics, flags, or provider policy; -- a generic repository/service/provider interface before a concrete second use - or required external contract exists; -- microservices by entity name, team aspiration, file count, or hypothetical - scale; -- shared process memory presented as durable or cross-replica state; -- retries, caches, queues, brokers, sagas, event sourcing, CQRS, Kubernetes, or a - service mesh without a named failure/load contract and verification path. - -“Suckless” here means fewer authorities and mechanisms, not fewer safety checks. -The elegant design keeps HTML semantics, typed boundaries, explicit ownership, -and failure truth while deleting accidental layers. - -## Handoff - -For architecture or review requests, report compactly: - -- the user-visible path and current bottleneck/risk; -- the selected scale stage and why the previous stage is insufficient; -- state, rendering, transport, and service ownership; -- the smallest end-to-end change; -- complexity explicitly refused; -- proof run and any unresolved provider/product contract. - -For implementation, make that slice reachable and verify it; do not leave a -“scalable” abstraction that no real path uses. diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml deleted file mode 100644 index 084b7e8..0000000 --- a/.cargo/mutants.toml +++ /dev/null @@ -1,58 +0,0 @@ -# Explicit infrastructure/invariant classifications for the package-native release gate. -# - test_process_try_wait: OS process-status failures cannot be injected portably. -# - test_process_poll_delay: poll cadence is operational; readiness and timeout are integration-proven. -# - Drop for TestProcess: mutating reaping leaks helper processes beyond the test lifecycle. -# - inspection_fingerprint: deliberately unobservable test-harness metadata. -# - BuildFingerprint::from_parts loop-progress mutations: syntactically valid but -# non-terminating const-loop mutants; deterministic hash outputs are asserted. -# - Infallible header parsing and multipart byte collection: adjacent public tests -# prove exact ETag/runtime headers and streamed multipart errors; unwrap mutants -# are behaviorally equivalent at these validated boundaries. -# - hemx-build source inspection delegates to hemplate's currently infallible -# Surface parser; file I/O and invalid Rust-context errors remain explicitly proven. -# The direct surface_for_heml_source unwrap mutant is equivalent for the same seam. -# - AppBuilder reuses that same parser seam. Directory-open and recursive errors are -# proven, while a per-entry readdir fault cannot be injected portably after a -# successful read_dir; its unwrap mutant is classified as infrastructure-only. -# - write_if_changed propagates non-NotFound read errors; for ordinary filesystem -# paths, attempting the same write returns the same OS error, so the guard mutant -# is externally equivalent while create/update/no-op behavior is mutation-proven. -# - stylesheet_class_tokens loop-progress mutants are deterministically -# non-terminating; sorted, deduplicated, boundary-aware outputs are asserted. -# - context path words are filtered non-empty before extracting their first char; -# `?` and `unwrap` are equivalent under that local iterator invariant. -# - Rust-fact named fields always carry identifiers by syn's type contract. Per-entry -# and recursive read_dir errors cannot be injected portably after the parent opens; -# parent-open, source-read, and parse failures remain explicitly proven. -# - Registry-helper syntax is emitted entirely from quote-owned static tokens. Its -# parse succeeds by construction; expect/unwrap and expect-message mutations are -# equivalent, while exact generated registration and public diagnostics are proven. -exclude_re = [ - "test_process_try_wait", - "test_process_poll_delay", - "delete statement std::thread::sleep\\(Duration::from_millis\\(25\\)\\)", - "::drop", - "inspection_fingerprint", - "replace \\+= with \\*= in BuildFingerprint::from_parts", - "replace 1 with 0 in BuildFingerprint::from_parts", - "replace field \\.bytes\\(\\) \\.await \\.map_err.* with field.bytes\\(\\).await.map_err.*unwrap\\(\\) in InteractionForm::parse_multipart", - "replace String::from_utf8.* with String::from_utf8.*unwrap\\(\\) in InteractionForm::parse_multipart", - "replace HeaderValue::from_str.*runtime_js_hash.* with HeaderValue::from_str.*unwrap\\(\\) in ::into_response", - 'replace "runtime hash is a valid ETag" with "" in ::into_response', - "replace build_ast.* with build_ast.*unwrap\\(\\) in surface_for_heml_source", - "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in diagnostics_for_heml_source", - "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in generated_targets_for_heml_source", - "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in template_context_facts_for_heml_source", - "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in AppBuilder::run", - "replace entry\\? with entry.unwrap\\(\\) in collect_input_files_into", - "replace match guard error.kind\\(\\) == io::ErrorKind::NotFound with true in write_if_changed", - "replace \\+= with (?:-=|\\*=) in stylesheet_class_tokens", - "replace 1 with 0 in stylesheet_class_tokens", - "replace chars.next\\(\\)\\? with chars.next\\(\\).unwrap\\(\\) in context_type_for_heml_path", - "replace entry\\? with entry.unwrap\\(\\) in collect_rust_struct_facts", - "replace collect_rust_struct_facts.*\\? with collect_rust_struct_facts.*unwrap\\(\\) in collect_rust_struct_facts", - 'replace syn::parse2.* with syn::parse2.*unwrap\(\) in add_app_registry_helper', - 'replace "generated app registry helper parses" with "" in add_app_registry_helper', - 'replace "generated component register helper parses" with "" in add_component_register_helper', - 'replace "generated component state register helper parses" with "" in add_component_register_helper', -] diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index 56e8944..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -msg_file="$1" -# Require scope if REQs exist -if [ -f REQUIREMENTS.md ]; then - if ! grep -qE '^[a-z]+(\(.+\))?:' "$msg_file"; then - echo "error: commit requires scope — e.g. feat(parser): ..." - exit 1 - fi -fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index b9e4d6f..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -changed=$(git diff --cached --name-only) -# fail only when this commit changes REQs without reviewing AGENTS.md; -# do not block unrelated commits just because an earlier commit changed REQs. -if echo "$changed" | grep -q '^REQUIREMENTS.md$' && ! echo "$changed" | grep -q '^AGENTS.md$'; then - echo "error: REQUIREMENTS.md changed without AGENTS.md — review AGENTS.md or run: redgate agents > AGENTS.md" - exit 1 -fi diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 825cb48..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,12 +0,0 @@ -# Tool Registry - -| Tool | Description | -|------|-------------| -| redgate | Requirements-first governance: list, refs, health, agents | - -## redgate usage - -- `redgate list` — TSV of all requirements -- `redgate refs` — find req: citations in source -- `redgate health` — ok/uncited per requirement -- `redgate agents` — render AGENTS.md from REQUIREMENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a6a9db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo test --workspace --all-targets --all-features + - run: cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check + command-arguments: licenses sources diff --git a/.gitignore b/.gitignore index b83d222..a17a894 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target/ +**/target/ +**/node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a7dbcd1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Thomas Hain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/compat/paste/Cargo.toml b/compat/paste/Cargo.toml deleted file mode 100644 index 01c994c..0000000 --- a/compat/paste/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "paste" -version = "1.0.15" -edition = "2021" -rust-version = "1.56" -publish = false -license = "MIT OR Apache-2.0" -description = "Workspace compatibility alias from paste to its maintained successor pastey" - -[dependencies] -pastey = "=0.2.3" diff --git a/compat/paste/src/lib.rs b/compat/paste/src/lib.rs deleted file mode 100644 index f720531..0000000 --- a/compat/paste/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -#![forbid(unsafe_code)] - -//! Compatibility export for dependencies that still name the unmaintained -//! `paste` crate. New code should depend on `pastey` directly. - -pub use pastey::paste; diff --git a/compat/spin/Cargo.toml b/compat/spin/Cargo.toml deleted file mode 100644 index ec3bf93..0000000 --- a/compat/spin/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "spin" -version = "0.9.8" -edition = "2021" -rust-version = "1.71" -publish = false -license = "MIT" -description = "Workspace compatibility alias from yanked spin 0.9 to maintained spin 0.12" - -[features] -default = [] -once = ["spin_next/once"] -spin_mutex = ["spin_next/spin_mutex"] - -[dependencies] -spin_next = { package = "spin", version = "=0.12.2", default-features = false, features = ["once"] } diff --git a/compat/spin/src/lib.rs b/compat/spin/src/lib.rs deleted file mode 100644 index f9eded8..0000000 --- a/compat/spin/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -#![forbid(unsafe_code)] - -//! Compatibility export for dependencies that still require yanked `spin 0.9`. -//! New code should depend on the maintained `spin` release directly. - -pub use spin_next::*; diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..50ea12f --- /dev/null +++ b/deny.toml @@ -0,0 +1,29 @@ +[graph] +all-features = true + +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.8 +unused-allowed-license = "allow" + +[licenses.private] +ignore = false + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docs/diagnostics.md b/docs/diagnostics.md deleted file mode 100644 index dd32eb0..0000000 --- a/docs/diagnostics.md +++ /dev/null @@ -1,89 +0,0 @@ -# Diagnostics guide - -hemx diagnostics should tell a Rust developer which template fact, generated -helper, or handler shape is wrong, and what to change next. They should not teach -raw ids, selector targeting, runtime opcodes, or Cargo internals in the normal -path. req: diagnostics/001 req: diagnostics/002 req: diagnostics/003 - -Use this guide as the v1 checklist for common mistakes in beginner and -production-shaped apps. Structured `hemx-build` diagnostics expose a file path, -directive, target, expected template fact, and repair action so an optional -editor overlay can share compiler authority without becoming a custom editor -framework. - -## Where errors happen - -- **Template/build diagnostics** come from `hemx_build::app().run()` while reading - `.heml` files and CSS. Fix the template or generated-surface convention. -- **Derive/compile diagnostics** come from `#[hemx::surface]`, `#[hemx::form]`, - `#[hemx::handler]`, `#[hemx::component]`, and `#[hemx::app]`. Fix Rust code so - it matches the generated surface. -- **Runtime diagnostics** come from the tiny browser runtime when a deployed page - and response are incompatible or a target cannot be applied. Fix deployment or - recover with a full page response. req: failure/005 - -## Common mistakes and fixes - -| Mistake | Diagnostic shape | Fix | -| --- | --- | --- | -| Handler has no matching template handle | `unknown hemx handle \`save\`; add \`data-hemx-handle="save"\`` | Add the handle to the template, rename the function, or put the handler in the matching component. | -| Component is missing a generated handler | `#[hemx::component] missing handler implementation(s): delete` | Add a `#[hemx::handler] fn delete(...)` in that component, or remove the template handle. | -| Handler name is ambiguous across components | `ambiguous generated handle name(s): save` | Scope the component with `#[hemx::component("todos")]` or rename handles so the generated path is unique. | -| Handler misses generated params | `hemx handler \`show\` is missing generated param argument(s): mode` | Add typed handler arguments for every `data-hemx-param-*` fact generated by the template. | -| Form handler omits the form argument | `handles a generated form and must accept a typed form argument` | Accept `Form`/`hemx::Form`/integration equivalent and derive `#[hemx::form("...")]` for the type. | -| Form struct misses a control | `hemx form \`new_todo\` is missing field \`title\`` | Add a Rust field matching the form control name, or rename the template control. | -| Required/multiple form control has wrong Rust shape | `required ... must not be Option<_>` or `accepts multiple values and must be Vec<_>` | Match HTML required/multiple semantics with `T`, `Option`, or `Vec` as appropriate. | -| Form field type cannot parse submitted values | compiler mentions `T: FormValue` / `T: hemx::FormValue` | Implement `FromStr`/the expected form value trait for the domain newtype, or use a parseable domain type. | -| Generated resources are unavailable | `could not find generated hemx module` / `could not find generated hemx symbols` plus `add hemx_build::app().run()? to build.rs` | Add or fix `build.rs`, then rerun `cargo check`; do not copy `$OUT_DIR` paths into app code. | -| Unknown `data-hemx-*` attribute | `unknown hemx attribute ... check the spelling or use a non-hemx data-* attribute` | Fix the spelling, use the supported hemx attribute, or rename app metadata to a non-hemx `data-*` attribute. | -| Selector-style targeting | ``data-hemx-target` is selector-style targeting; hemx uses generated resources` | Put `data-hemx-slot` on the local target and return a generated slot/page/form effect. | -| Generated target appears in a loop without a stable key | `inside an h-for without h-key; add a stable h-key="item.id"` | Add a stable `h-key` to the owning loop; use generated keyed helpers for row updates. | -| Page/SSE attributes are on the wrong element | `expected a real ` / `expected placement on the same element as data-hemx-root` | Keep page navigation on anchors and put root-scoped runtime attributes on the root element. | -| Result handler error type is not mappable | compiler reports the error type does not satisfy `IntoHandlerFailure` | Implement `IntoHandlerFailure` for the app error, or keep expected validation as generated UI effects instead of `Err`. req: failure/004 | -| Old page talks to a new server/runtime | runtime refuses the partial update on fingerprint mismatch | Serve a self-consistent release or fall back to full page reload/navigation. See `docs/recipes/deploy-versioning.md`. req: abi/004 | -| Missing runtime target | runtime emits a missing-target diagnostic in development and fails/no-ops according to target kind | Fix the template/generated helper mismatch; do not retarget with selectors. req: failure/001 | - -## What a good diagnostic should include - -A v1-quality diagnostic should include: - -- the user-facing name: handle, form, slot, key, param, class, event, or template -- the source area: template path, Rust item, or deployment/runtime boundary -- the concrete expected shape, not an internal representation -- one next action that preserves generated helpers and the tiny runtime - -Avoid beginner-facing messages that suggest `ResourceId`, `ResourceRef`, raw -`Effect`, manual registries, selector strings, or runtime opcodes. If an advanced -escape hatch is genuinely required, say that it is advanced and name the safer -normal path first. req: public_api/002 req: public_api/005 - -## Editor overlay boundary - -A `.heml` editor overlay is optional and subordinate to the compiler. It may read -`docs/hemplate-syntax.md`, run or reuse `hemx-build` diagnostics, and present -compiler-shaped diagnostics, completion, hover, and navigation for documented -syntax and generated targets. It must not define a second template language, -formatter, selector targeting model, JavaScript expression layer, or custom editor -framework. If editor feedback disagrees with `hemx-build`, `hemx-build` wins. -req: diagnostics/004 - -## Verification anchors - -Current recurring checks cover the most common classes: - -- `cargo test -p hemx-build` covers template/build diagnostics such as unknown - hemx attributes, selector-style targeting, invalid runtime attribute values, - missing keys, and invalid page/SSE placement. -- `cargo test -p hemx-derive --test compile_fail` covers derive/compile - diagnostics for missing handlers, form mismatch, params, missing generated - files, unknown scoped components, ambiguous handles, and generated resource - lookup. -- `cargo test -p hemx-js` covers root-scoped runtime behavior, selectorless - targeting, fingerprint mismatch refusal, SSE application, and recoverable - runtime events. -- `cargo test -p hemx-test --test examples_contract` keeps public examples from - teaching forbidden normal-path constructs. - -Before claiming the diagnostics story is closed for v1, run those gates plus -`cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and -`redgate refs` on a clean tree. The installed CLI's `health` mode additionally requires every historical row to use its newer prescriptive wording, which is not the elected compatibility gate for this corpus. req: test/003 req: test/004 diff --git a/docs/editor-support.md b/docs/editor-support.md deleted file mode 100644 index 5d56cec..0000000 --- a/docs/editor-support.md +++ /dev/null @@ -1,168 +0,0 @@ -# `.heml` editor support - -`.heml` authoring should feel like HTML first: keep normal HTML highlighting, -formatting, tag matching, and tree-sitter queries, then layer hemx compiler -feedback on top. The shared authority is `hemx-build` diagnostics plus -`docs/hemplate-syntax.md`; editors must not carry separate parser rules for the -hemplate language. req: diagnostics/004 req: diagnostics/005 - -## Shared language service - -`hemx-lsp` owns editor protocol behavior; `hemx-xtask` stays a project workflow -runner, not the language-service home. - -From the repo, run the stdio language service: - -```sh -cargo run -p hemx-lsp -- lsp -``` - -Or install the same binary and run it directly: - -```sh -cargo install --path hemx-lsp -hemx-lsp lsp -``` - -It speaks standard LSP framing over stdin/stdout. Today it supports open/change/save -text synchronization, compiler-backed `textDocument/publishDiagnostics`, and -small completion/hover entries for documented `.heml` constructs from -`docs/hemplate-syntax.md`. Generated targets discovered by `hemx-build` in an -open document are offered as `ui::target` completions. For derive-known template -contexts, `self.` field completion/hover and simple `h-for` locals such as -`exercise in &self.plan` come from hemx-owned Rust struct facts, not an editor -parser or rust-analyzer proxy. It intentionally does not format templates, parse -JavaScript, parse arbitrary Rust expressions, or replace HTML tooling. - -For scripts and editor wrappers that only need one-shot diagnostics, run: - -```sh -cargo run -p hemx-lsp -- diagnostics path/to/file.heml -``` - -The one-shot command prints a JSON object shaped like LSP -`textDocument/publishDiagnostics` parameters: - -```json -{ - "uri": "file:///absolute/path/to/file.heml", - "diagnostics": [ - { - "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, - "severity": 1, - "source": "hemx-build", - "code": "unkeyed-generated-target", - "message": "data-hemx-slot=\"todo_row\" is inside h-for=\"todo in &self.todos\" without h-key", - "data": { - "directive": "data-hemx-slot", - "target": "todo_row", - "expected": "a stable template h-key on h-for=\"todo in &self.todos\" so generated keyed helpers such as ui::todo_row.replace(row) can target this partial", - "repair": "add h-key=\"todo.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets" - } - } - ] -} -``` - -The diagnostic payload comes from `hemx-build`; editor integrations should display -it as-is instead of recreating the rule. - -## Highlighting boundary - -Repo-owned `.heml` highlighting is an HTML overlay, not a new language. Normal -HTML highlighting owns tags, attributes, strings, comments, folding, and tag -matching. The hemplate overlay may highlight only documented syntax tokens from -`docs/hemplate-syntax.md`: - -- escaped text delimiters and expression regions: `{+` and `+}`; -- trusted/rendered HTML delimiters and expression regions: `{+=` and `=+}`; -- dynamic attribute prefixes such as `+class`, `+disabled`, and `+aria-label`; -- structural directives: `h-if`, `h-for`, `h-key`, `h-match`, and `h-case`; -- hemx facts recorded as ordinary attributes: `data-hemx-root`, - `data-hemx-slot`, `data-hemx-form`, `data-hemx-handle`, and other checked - `data-hemx-*` authoring attributes. - -Highlighting must not own diagnostics, completion, hover, formatting, Rust -expression parsing, selector behavior, generated Rust facts, or build -validation. Those remain with `hemx-build`, `hemx-lsp`, normal HTML tooling, and -Rust tooling. Repo tests for highlighting should therefore be fixture/query tests -for captures over these token classes; provider packaging or visual editor smoke -is a separate release slice and cannot become syntax authority. The current -repo-owned fixture and golden capture contract live in -`docs/fixtures/hemplate-highlighting/`. req: diagnostics/004 req: diagnostics/008 - -## VS Code and Cursor - -Use the shared repo extension in `editors/vscode-hemx` for VS Code and Cursor. -It sets `.heml` to the built-in HTML language mode, starts `hemx-lsp`, and maps -LSP diagnostics/completion/hover into the editor without adding a separate grammar. -Hovering a generated root, slot, form, or handle value reports its resource kind -and generated `ui::` Rust symbol from the current template. -req: diagnostics/005 req: diag/010 - -When the workspace root is this repository, the extension starts: - -```sh -cargo run -p hemx-lsp -- lsp -``` - -In app workspaces, install `hemx-lsp` and the extension starts: - -```sh -hemx-lsp lsp -``` - -If you do not use the extension, keep the same HTML association manually so HTML -syntax highlighting, completion, folding, and tag matching keep working: - -```json -{ - "files.associations": { - "*.heml": "html" - } -} -``` - -Use the one-shot diagnostics command only as a fallback task if your editor cannot -launch a stdio LSP server. Do not copy hemplate syntax into a VS Code/Cursor-only -grammar. - -## Neovim - -Use HTML filetype and tree-sitter HTML highlighting for `.heml`: - -```lua -vim.filetype.add({ extension = { heml = "html" } }) -``` - -If you use nvim-treesitter, this keeps `.heml` on the HTML parser. Start the -shared LSP service with Neovim's built-in client: - -```lua -vim.lsp.start({ - name = "hemx-heml", - cmd = { "cargo", "run", "-p", "hemx-lsp", "--", "lsp" }, - root_dir = vim.fs.root(0, { "Cargo.toml", ".git" }) or vim.fn.getcwd(), -}) -``` - -Use `cargo run -p hemx-lsp -- diagnostics %` only as a fallback if LSP is -unavailable. Do not add a separate `.heml` tree-sitter grammar unless HTML -injection can no longer represent the documented syntax in -`docs/hemplate-syntax.md`. - -## Known limits and boundary - -If `hemx-lsp` is missing, crashes, or cannot be started by the editor, `.heml` -files should still open as HTML and keep normal highlighting/tag tooling; use the -one-shot diagnostics command until the service is available. - -This foundation intentionally supports diagnostics, completion, and hover/help. -It does not yet implement broad go-to-definition/reference navigation, formatting, -refactoring, semantic Rust analysis, arbitrary Rust expression parsing, or a -`.heml` tree-sitter parser fork. - -Editor support may add startup glue, diagnostics display, completion, hover/help, -and navigation over documented `.heml` facts. It must not add a second template -language, editor-owned formatter, selector targeting model, JavaScript expression -layer, or editor-specific diagnostics that disagree with `hemx-build`. diff --git a/docs/fixtures/hemplate-highlighting/captures.tsv b/docs/fixtures/hemplate-highlighting/captures.tsv deleted file mode 100644 index 08db87c..0000000 --- a/docs/fixtures/hemplate-highlighting/captures.tsv +++ /dev/null @@ -1,17 +0,0 @@ -capture literal -@attribute.hemx data-hemx-root -@attribute.hemx data-hemx-form -@attribute.hemx data-hemx-handle -@attribute.hemx data-hemx-slot -@attribute.dynamic.hemplate +class -@keyword.control.hemplate h-if -@keyword.control.hemplate h-for -@keyword.control.hemplate h-key -@keyword.control.hemplate h-match -@keyword.control.hemplate h-case -@punctuation.special.hemplate.escaped.open {+ -@punctuation.special.hemplate.escaped.close +} -@punctuation.special.hemplate.trusted.open {+= -@punctuation.special.hemplate.trusted.close =+} -@embedded.rust.hemplate result.title -@embedded.rust.hemplate result.summary_html diff --git a/docs/fixtures/hemplate-highlighting/hemplate.heml b/docs/fixtures/hemplate-highlighting/hemplate.heml deleted file mode 100644 index 1506b1d..0000000 --- a/docs/fixtures/hemplate-highlighting/hemplate.heml +++ /dev/null @@ -1,18 +0,0 @@ -
-
- -
- -
- -
- - -
diff --git a/docs/hemplate-syntax.md b/docs/hemplate-syntax.md deleted file mode 100644 index 22d6f88..0000000 --- a/docs/hemplate-syntax.md +++ /dev/null @@ -1,81 +0,0 @@ -# `.heml` syntax surface - -`.heml` files are ordinary HTML plus the small hemplate surface below. Use normal -HTML tooling first; hemx/hemplate adds checks for the few template facts that -Rust code generation needs. req: diagnostics/001 req: diagnostics/002 - -## Text and HTML - -- `{+ expr +}` inserts escaped text. -- `{+= expr =+}` inserts trusted/rendered HTML. Use it only for values already - represented as trusted HTML in Rust. - -```html -

{+ self.title +}

-
{+= self.body_html =+}
-``` - -## Dynamic attributes - -Prefix an HTML attribute with `+` when its value is a Rust expression. - -```html -
{+ self.label +} - -``` - -Dynamic attributes render HTML. They do not replace template facts such as -`h-key` on a loop or `data-hemx-slot` names used by generated helpers. - -## Control flow - -```html -
Welcome back
- -
  • - {+ todo.title +} -
  • - -
    -

    Loading

    -

    Ready

    -

    Unknown

    -
    -``` - -`h-key` is required when generated targets live inside `h-for`; it must be the -stable template fact on the loop that owns the repeated target. `+data-key` on a -child is just rendered HTML and is not enough for generated keyed helpers. - -## Generated hemx targets - -Generated targets are named in templates and used from Rust through generated -helpers. Do not target them with CSS selectors or raw ids in normal app code. - -```html -
    -
    - -
    - -

    {+ self.notice +}

    - -
      -
    • - {+ row.title +} -
    • -
    -
    -``` - -Rust handlers then use generated helpers such as -`ui::notice.set("Saved")`, `ui::todo_row.replace(row)`, and composed -`IntoEffect` batches. The template owns target names; Rust owns state, commands, -events, and projections. - -## Boundary - -This file defines the stable public authoring surface for hemx examples and -beginner docs. It does not introduce a client component framework, custom editor -framework, JavaScript expression language, selector targeting model, or stored DOM -truth. diff --git a/docs/recipes/auth-session-csrf.md b/docs/recipes/auth-session-csrf.md deleted file mode 100644 index 21be46c..0000000 --- a/docs/recipes/auth-session-csrf.md +++ /dev/null @@ -1,239 +0,0 @@ -# Recipe: auth/session and CSRF boundary for the SaaS tutorial - -This recipe turns the `examples/saas` demo session into a production-shaped -application boundary without adding authentication, authorization, session, or -CSRF policy to hemx core. hemx receives a typed context and generated form -values; Axum/Tower middleware and extractors own cookies, credentials, and -rejection policy. req: laws/002 req: auth/001 - -Use this alongside `docs/recipes/sqlx-persistence.md`: authenticate the request, -verify CSRF for mutations, then call the application store and return generated -UI effects. req: auth/002 req: auth/004 - -## Boundary rule - -Keep these concerns outside hemx crates: - -- password or OAuth provider selection -- session cookie format, signing, storage, rotation, and expiration -- CSRF token minting, binding, and verification -- redirect vs HTTP error policy for non-enhanced requests -- role/permission checks - -Keep these concerns inside normal app code: - -- typed extractors such as `CurrentSession` -- app state such as `AppContext { session, store }` -- generated hemx form fields such as hidden `csrf` -- `Result` mapping for enhanced failures - -The handler should read like ordinary Rust domain code, not framework magic. - -## Axum state and session extractor - -A real app would use a provider crate such as `tower-sessions`, `async-session`, -`axum-login`, or a custom signed-cookie middleware. The hemx boundary is the -same either way: produce a typed session before the handler runs. - -```rust -use axum::extract::{FromRequestParts, State}; -use axum::http::request::Parts; -use axum::response::{IntoResponse, Redirect, Response}; -use std::sync::Arc; - -#[derive(Clone)] -pub struct SecurityState { - sessions: Arc, - csrf: Arc, -} - -#[derive(Clone, Debug)] -pub struct CurrentSession { - pub user_id: UserId, - pub email: String, - pub csrf: CsrfToken, -} - -pub struct AuthRequired; - -impl IntoResponse for AuthRequired { - fn into_response(self) -> Response { - Redirect::to("/login").into_response() - } -} - -#[axum::async_trait] -impl FromRequestParts for CurrentSession { - type Rejection = AuthRequired; - - async fn from_request_parts( - parts: &mut Parts, - state: &AppState, - ) -> Result { - let cookie = parts - .headers - .get(axum::http::header::COOKIE) - .and_then(|value| value.to_str().ok()) - .ok_or(AuthRequired)?; - - state - .security - .sessions - .load(cookie) - .await - .ok_or(AuthRequired) - } -} -``` - -`CurrentSession` is an app extractor. It can be used in normal Axum routes, in -middleware, or copied into `AppContext` before dispatching hemx interactions. -hemx does not need to know how the session was loaded. req: auth/002 - -## CSRF token in the template - -The template stays ordinary HTML: a hidden field plus normal cookie semantics. -The token value is a Rust field rendered by hemplate and parsed by the generated -form type. req: auth/003 req: auth/004 req: auth/005 - -```heml -
    - - - -

    -
    -``` - -```rust -#[derive(Clone, Debug)] -#[hemx::form("new_project")] -pub struct NewProject { - csrf: CsrfToken, - name: ProjectName, -} -``` - -The browser submits the same form with or without the hemx runtime. Cookies, -SameSite behavior, and credential inclusion remain browser/framework concerns. -`hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms; -apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation -route. Media-type and size checks run before dispatch, while CSRF remains the -explicit application or middleware check shown below. req: security/003 - -## Mutation handler - -Verify the session and CSRF token before persistence. Expected validation -returns a generated form effect; auth/CSRF failures return an application error -that maps to a generated UI effect or an HTTP response depending on the route. -req: form/001 req: failure/004 - -```rust -#[hemx::handler] -async fn create_project( - State(ctx): State, - Form(form): Form, -) -> Result { - let session = ctx.session().ok_or(AppError::MissingSession)?; - ctx.csrf.verify(&session, &form.csrf)?; - - if form.name.as_str().is_empty() { - return Err(AppError::Validation("Project name required")); - } - - let project = ctx.store.insert(form.name, &session).await?; - let total = ctx.store.list().await?.len(); - - Ok(( - dashboard::project_row.append(ProjectRow::from(project)), - dashboard::summary.set(project_summary(total)), - dashboard::new_project.clear(), - dashboard::flash.set("Project created"), - )) -} -``` - -## Failure mapping - -Keep policy in the app error type. Enhanced requests can render generated UI; -non-enhanced routes can redirect or return an HTTP status before hemx dispatch. - -```rust -pub enum AppError { - MissingSession, - CsrfRejected, - Validation(&'static str), - StoreUnavailable, -} - -impl IntoHandlerFailure for AppError { - fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { - match self { - Self::MissingSession => HandlerFailure::response( - axum::http::StatusCode::UNAUTHORIZED, - "Sign in to continue", - ), - Self::CsrfRejected => HandlerFailure::effects( - dashboard::flash.set("Refresh the page before trying again"), - context, - ), - Self::Validation(message) => HandlerFailure::effects( - ( - dashboard::new_project.error("name", message), - dashboard::new_project.focus("name"), - ), - context, - ), - Self::StoreUnavailable => HandlerFailure::effects( - dashboard::flash.set("Project storage is temporarily unavailable"), - context, - ), - } - } -} -``` - -This keeps error policy explicit while preserving the same handler shape as the -local tutorial skeleton. - -## Route wiring - -For full-page routes, extract the session before rendering. For enhanced -interaction routes, build the app context from the extracted session and shared -application state, then dispatch the generated registry. - -```rust -async fn home( - State(app): State, - session: CurrentSession, -) -> impl IntoResponse { - Html(home_page(&AppContext::new(session, app.store.clone())).into_string()) -} - -async fn interact( - State(app): State, - session: CurrentSession, - request: InteractionRequest, -) -> Result { - let ctx = AppContext::new(session, app.store.clone()); - request.dispatch_async(registry(ctx)).await -} -``` - -The same `AppContext` can contain a SQLx-backed store, an in-memory test store, -or a fake store for unit tests. hemx only observes the typed handler inputs and -the generated effects returned by the handler. - -## Tests - -Keep provider checks at the application boundary: - -- request without a valid session is rejected before mutation -- stale CSRF token does not call the store -- valid session + CSRF stores the project and returns generated row/summary/form - effects -- validation failures target generated form errors, not selectors - -`examples/saas` already has the local-store version of these checks; a provider -app should run the same interaction assertions with its real session/CSRF -middleware and store adapter. req: examples/001 req: test/001 diff --git a/docs/recipes/deploy-versioning.md b/docs/recipes/deploy-versioning.md deleted file mode 100644 index dc7997b..0000000 --- a/docs/recipes/deploy-versioning.md +++ /dev/null @@ -1,153 +0,0 @@ -# Recipe: deploy and version compatibility - -This recipe describes the production deployment boundary for a hemx app. The -server binary, generated Rust helpers, generated symbol/fingerprint metadata, and -JavaScript runtime asset must be treated as one release unit. hemx core provides -the ABI/fingerprint checks; the application and platform own rollout, caching, -observability, and rollback policy. req: abi/001 req: abi/002 req: runtime/004 - -Use this for `examples/saas`-style apps before putting multiple app versions -behind a load balancer or CDN. - -## Release unit - -A compatible release contains: - -- the Rust server binary built from the same checkout as `build.rs` -- generated `hemx.generated.rs` and symbols produced during that build -- the `hemx-js` runtime asset served by that server or deployed with the same - release -- templates, CSS, island JavaScript, migrations, and app config for that release - -Do not mix a newly built server with an old runtime asset, old generated output, -or old cached page shell. Build fingerprints are derived from Surface/schema/ABI -parts, so mismatches are detected and partial updates fail closed instead of -mutating the wrong DOM. req: abi/003 req: abi/004 req: failure/005 - -## Asset serving - -Serve the embedded runtime at the helper-provided fingerprinted path from the -same release as the server: - -```rust -use axum::{routing::get, Router}; -use hemx_axum::{runtime_js, runtime_js_path}; - -let app = Router::new().route(runtime_js_path(), get(runtime)); - -async fn runtime() -> impl axum::response::IntoResponse { - runtime_js() -} -``` - -Render page shells with that same `runtime_js_path()` value: - -```html - -``` - -`runtime_js()` is safe for long-lived caching because the public path includes a -hash of the embedded runtime bytes and the response carries immutable cache -headers. Do not publish app-owned version query strings or a long-lived -unversioned runtime URL. CSS and explicit island scripts should follow the same -release path policy. - -## Rolling deploys - -Rolling deploys are safe when every response serves a self-consistent release. -The easiest policy is sticky-by-release routing: - -- page HTML, interaction POSTs, SSE/polling endpoints, and the - `runtime_js_path()` asset come from the same server revision -- a load balancer cookie or platform routing key keeps an active browser on one - revision during the rollout window -- old revisions stay alive until active SSE connections and in-flight forms have - drained - -If sticky routing is not available, make the mismatch behavior user-safe: - -- keep full page GETs compatible across one adjacent version when practical -- allow interaction responses to fail closed on fingerprint mismatch -- prefer redirect/reload fallback over best-effort partial mutation -- report mismatch counts so rollouts can be paused quickly - -The runtime must not grow a negotiation protocol or compatibility shim in core; -capability negotiation belongs to optional integration crates. req: runtime/004 - -## Fingerprint and mismatch behavior - -Initial roots carry the build fingerprint, and effect responses carry the -fingerprint for the batch. The runtime compares them before applying effects. -On mismatch, the app should recover by reloading or navigating to a full page -owned by the current server revision. req: abi/003 req: abi/004 - -Recommended app behavior: - -```text -fingerprint mismatch - -> record metric: hemx.fingerprint_mismatch - -> show a short-lived "Updating…" notice if possible - -> perform full page reload/navigation -``` - -Never ignore a mismatch to preserve a partial update. Resource ids are stable -within a build and best-effort across compatible symbol paths, but they are not a -persistence or cross-version addressing contract. req: abi/005 - -## Semver policy for v1 apps - -For v1, document changes in three buckets: - -- **Beginner API:** generated helpers, `#[hemx::app]`, `#[hemx::component]`, - `#[hemx::handler]`, `#[hemx::form]`, generated page-boundary rendering, tuple - `IntoEffect`, and `Result` mapping. Breaking changes - require a major version or an explicit migration note. -- **Wire/runtime ABI:** EffectBatch schema, runtime ABI version, and fingerprint - inputs. Incompatible changes must bump ABI versions and fail closed at runtime. -- **Advanced escape hatches:** raw effects, manual registries, low-level ids, - raw render/target construction, runtime hooks, SSE internals, and island - internals. These may evolve faster, but must remain named as advanced and must - not leak into beginner docs. req: public_api/002 req: public_api/005 - -Upgrade notes should explain what changed, whether generated code must be -regenerated, whether the helper-provided runtime asset must be rolled with the -server, and what fallback users see if an old page talks to a new server. Use -`docs/versioning.md` as the release-policy checklist. - -## Deployment checklist - -Before promoting a release: - -```sh -cargo run -p hemx-xtask -- test -cargo check --workspace -redgate refs -``` - -Then verify deployment-specific behavior: - -- page HTML includes the intended `runtime_js_path()`, CSS, and island asset - release paths -- interaction endpoints return the same build fingerprint as the initial root -- SSE/polling endpoints stream batches from the same revision -- a stale page talking to the new server reloads or navigates instead of applying - a partial update -- fingerprint mismatch metrics/logs are visible to the platform team -- rollback serves a self-consistent old server/runtime pair - -These checks belong in the app/platform pipeline. hemx should provide the small -runtime handshake and clear failure boundary, not a deployment platform. - -## Observability hooks - -Track deployment compatibility as app/platform metrics: - -- `hemx.fingerprint_mismatch` -- `hemx.effect_decode_error` -- `hemx.missing_target` -- `hemx.sse_reconnect` -- `hemx.full_reload_fallback` - -The metric names are suggestions, not core API. The important behavior is that a -team can see mismatches, pause a rollout, and recover with a full page response -without weakening the runtime's tiny, selectorless contract. req: failure/001 req: failure/005 diff --git a/docs/recipes/host-capabilities.md b/docs/recipes/host-capabilities.md deleted file mode 100644 index c89e589..0000000 --- a/docs/recipes/host-capabilities.md +++ /dev/null @@ -1,69 +0,0 @@ -# Recipe: typed host capabilities - -`hemx-host` is the boundary between a hemx app and a browser, PWA, -WebView, or native shell. It is not a mobile framework and it is not a new UI -runtime. A host adapter can perform explicit host side effects or return facts; -app code still owns domain decisions and returns normal hemx effects. req: host/001 req: host/002 - -## Contract - -Declare the capability shape the app may use: - -```rust -use hemx_host::{Capability, CapabilityManifest, CapabilityShape, CapabilityUse}; - -let manifest = CapabilityManifest::new([ - CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), - CapabilityUse::new(Capability::Share, CapabilityShape::Request), -]); -``` - -Check the manifest against the concrete host profile before executing calls: - -```rust -use hemx_host::{HostProfile, HostCheckError}; - -let host = HostProfile::new( - "web", - [CapabilityUse::new(Capability::Share, CapabilityShape::Request)], -); - -let result: Result<(), HostCheckError> = manifest.check(&host); -``` - -Permission-sensitive capabilities such as microphone, camera, notifications, -secure storage, file picker, and geolocation need a user-facing reason in the -manifest before standard host checks pass. req: host/003 req: host/004 - -## Browser/PWA adapter - -`hemx-host::BROWSER_HOST_JS` is an optional tiny browser adapter. It exposes -`window.hemxBrowserHost.perform(call)`, accepts the serde JSON shape of -`HostCall`, calls browser APIs such as `navigator.share` or `navigator.vibrate`, -and returns the serde JSON shape of `HostEvent`. It does not query, patch, or -own the DOM; the app consumes the host event and returns ordinary hemx effects. -req: host/001 req: host/002 req: host/005 - -## Event flow - -Host events are facts, not app mutations. Denied, timeout, unavailable, and -error cases all use `HostEvent::Failed(HostFailure { kind, ... })`, so app code -handles one typed result shape before producing UI effects: - -```text -HostEvent -→ app/domain command -→ domain validation and optional persistence -→ projection/rendering -→ generated UI effects -``` - -Adapters must not mutate DOM, append domain events, or write application state -on behalf of the app. req: host/002 req: host/005 - -## Mobile - -iOS and Android shells are thin host adapters around a WebView. They implement -manifest-backed calls such as haptics, share, microphone streams, secure -storage, notifications, and explicit custom capabilities; hemx still owns UI -effects and the app still owns state. req: host/001 req: host/002 diff --git a/docs/recipes/local-command-log.md b/docs/recipes/local-command-log.md deleted file mode 100644 index 1725874..0000000 --- a/docs/recipes/local-command-log.md +++ /dev/null @@ -1,55 +0,0 @@ -# Recipe: local command log - -A hemx app may feel local-first without making hemx core a client database or -sync framework. The local artifact is an app-owned command/event log plus a -projection; hemx effects are rendered output, not stored truth. req: local/001 -req: local/002 - -## Decision: no `hemx-local` crate yet - -`hemx local` remains an app/recipe pattern for now, not a reusable hemx layer. -The host capability path proves that thin typed contracts work when the shared -semantics are obvious: manifest, call, event, and host-check failure. The local -exemplar proves a safer boundary for offline work: command, domain event, -projection, then generated UI effects. It does not yet prove common storage, -reconciliation, export, deletion, or conflict semantics across apps, so a crate -would freeze product policy too early. req: local/002 req: local/003 req: -local/004 - -A future reusable layer must first prove at least two independent apps share the -same command-log contract without sharing domain policy, storage provider, sync -provider, or conflict rules. Until then, recipes and app-owned integrations are -more honest and easier to delete. req: local/002 req: local/003 - -## Shape - -```text -user intent -→ LocalCommand -→ domain validation -→ LocalEvent -→ Projection -→ generated UI effects -``` - -The log may live in memory, IndexedDB, SQLite, a native host store, or another -app-chosen persistence layer. That storage choice is not hemx core. req: local/002 - -## Replay and sync - -Replaying local work to a server, remote AI/STT gateway, backup target, or peer -sync engine is explicit product policy. The app decides what can be queued, -exported, deleted, reconciled, retried, rejected, or redacted. A local projection -can render immediate feedback while those decisions remain pending. req: local/003 - -## Boundary - -Do not persist DOM patches as truth. Do not persist generated UI effect payloads -as the local application log. Those are render instructions produced after -app/domain code accepts commands and projects events. req: local/001 req: -local/004 - -Use `hemx-host` only when the local log needs device or shell capabilities such -as secure storage, files, haptics, microphone, or notifications. The host still -returns facts; app code still owns the command/event/projection policy. req: -host/002 req: local/003 diff --git a/docs/recipes/mobile-release.md b/docs/recipes/mobile-release.md deleted file mode 100644 index b369fb5..0000000 --- a/docs/recipes/mobile-release.md +++ /dev/null @@ -1,119 +0,0 @@ -# Recipe: Workout mobile release - -The Workout exemplar is the production-shaped mobile path for hemx. It stays -boring on purpose: hemx builds the server app and writes mobile shell metadata; -Android/iOS SDKs, store signing, provisioning, and submission remain external -vendor work. req: examples/011 - -## Command surface - -Create a standalone phone-first starter from the public app command when you want -this path outside the repository: - -```sh -cargo run -p hemx-xtask -- app new --mobile PATH -``` - -The created app includes app-owned `hemx-app mobile-release` and -`hemx-app mobile-verify` commands, plus `MOBILE_STARTER.md` naming the host, -recovery, and release-kit boundary. req: ceremony/006 - -## When to use this path - -Use hemx mobile when the app is still a Rust-owned hypermedia product: forms, -lists, keyed partial updates, server-verified actions, installability, offline or -host recovery from app-owned command/event/projection truth, and a few explicit -host capabilities such as share, haptics, clipboard, notifications, or file -picking. The payoff is fewer moving parts: no client component runtime, no native -UI abstraction, no plugin marketplace, and no hidden mobile state graph. req: -ceremony/006 req: examples/011 - -Do not use hemx mobile as a replacement for apps whose product center is heavy -native UI, games, deep OS integration, camera-heavy capture/editing, complex -native navigation stacks, background services, or complex multi-device offline -sync. For those, keep hemx as a server/API surface or use an explicit native -shell/island where the browser should not own the interaction. req: host/002 - -For the in-repository exemplar: - -```sh -cargo run -p hemx-xtask -- workout dev -cargo run -p hemx-xtask -- workout test -cargo run -p hemx-xtask -- workout build -cargo run -p hemx-xtask -- workout mobile-release -cargo run -p hemx-xtask -- workout mobile-verify -cargo run -p hemx-xtask -- workout doctor -``` - -`workout mobile-release` builds `target/release/hemx-workout-example` and writes -a release kit under `target/hemx-mobile/workout` by default: - -```text -target/hemx-mobile/workout/ - release-manifest.json - BLOCKERS.md - android/twa-release.json - android/README.md - ios/webview-release.json - ios/README.md -``` - -Use `workout mobile-verify` as the store-readiness product gate: it runs the -Workout product tests, then checks the generated kit and release binary. It -fails on broken app value/recovery/host-boundary tests, a non-HTTPS production -origin, missing/inconsistent Android or iOS metadata, or external -toolchain/signing blockers that were not written into the manifest and -`BLOCKERS.md`. Use `workout doctor` when you only want to see missing external -inputs. - -## Production configuration - -Set these explicitly for a real app release: - -```sh -HEMX_WORKOUT_APP_ID=com.example.workout -HEMX_WORKOUT_APP_NAME="Workout Copilot" -HEMX_WORKOUT_VERSION=1.0.0 -HEMX_WORKOUT_ORIGIN=https://workout.example.com -HEMX_WORKOUT_ANDROID_PACKAGE=com.example.workout -HEMX_WORKOUT_IOS_BUNDLE_ID=com.example.workout -HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout -``` - -The generated manifest records: - -- app identity and version; -- the production HTTPS origin used by Android and iOS shells; -- `target/release/hemx-workout-example` as the server artifact; -- the exact runtime asset path and SHA-256 digest served by the same release; -- `asset-integrity.tsv` as a plain-text integrity receipt for mobile shell review; -- cache policy: release-scoped HTML/CSS/runtime assets only; -- offline truth policy: app-owned command/event/projection records, never DOM - patches or UI effect payloads; -- host capability policy: Android and iOS shell metadata declare share/haptics and - the same denied, timeout, unavailable, and error result kinds handled by app - code before UI effects; -- environment/secrets boundary: public shell config in the kit, signing secrets - outside the repo; -- rollback: redeploy the previous server binary and rebuild store artifacts from - the previous shell metadata/signing inputs. req: local/001 req: host/002 - -## Android and iOS artifacts - -The command writes release-ready metadata, not store-signed binaries. That is the -honest boundary: producing `.aab`/`.apk` and `.ipa` files requires vendor SDKs, -signing credentials, and store accounts on the release machine. - -Android blockers are reported when the Android SDK/JDK/signing key or Play -Console submission target are not visible. iOS blockers are reported when Xcode, -the Apple signing team, or the App Store Connect submission team are not visible. -These blockers are copied into `BLOCKERS.md` so the release kit can be reviewed -without guessing what is still external. req: examples/006 - -## What this does not add - -This is not a `hemx-mobile` framework, sync layer, client database, or native UI -runtime. The mobile shells load the production Workout web app and route host -capabilities such as share/haptics through the typed host boundary before UI -effects are produced; denied, timeout, unavailable, and error cases share the -same host result shape. req: host/002 req: local/002 diff --git a/docs/recipes/observability-flags.md b/docs/recipes/observability-flags.md deleted file mode 100644 index 6a00374..0000000 --- a/docs/recipes/observability-flags.md +++ /dev/null @@ -1,206 +0,0 @@ -# Recipe: observability, feature flags, and killswitches - -This recipe shows where production telemetry and rollout controls belong in a -hemx app. Metrics, traces, feature flags, A/B assignment, and killswitches are -application/platform integrations, not hemx core features. hemx should expose a -small effect boundary, preserve normal HTTP behavior, and leave provider choice -to the app. req: laws/002 req: laws/004 - -Use this with `examples/saas` after the auth/session, CSRF, persistence, and -deploy/versioning boundaries are in place. - -## Boundary rule - -Keep these concerns outside hemx crates: - -- metrics/tracing providers such as OpenTelemetry, Datadog, Prometheus, Honeycomb, - or platform logs -- feature flag providers and assignment stores -- A/B test bucketing and analytics destinations -- rollout and killswitch policy -- alerting, dashboards, and incident response - -Keep these concerns in app/integration code: - -- route and handler spans -- effect-response counters -- provider-specific labels and sampling policy -- generated UI effects that show degraded or disabled states -- app-owned flags passed through typed state or extractors - -The normal handler shape remains typed Rust returning generated effects. - -## Instrument routes and dispatch, not the runtime - -Instrument the server boundary around ordinary Axum routes and hemx interaction -dispatch. The browser runtime should not become an analytics SDK. - -```rust -async fn interact( - State(app): State, - session: CurrentSession, - request: InteractionRequest, -) -> Result { - let handle_id = request.handle_id(); - let span = tracing::info_span!( - "hemx.interaction", - handle_id, - user_id = %session.user_id, - release = %app.release_id, - ); - - async move { - let ctx = AppContext::new(session, app.store.clone(), app.flags.clone()); - let result = request.dispatch_async(registry(ctx)).await; - - match &result { - Ok(_) => metrics::counter!("hemx.interaction.ok").increment(1), - Err(_) => metrics::counter!("hemx.interaction.error").increment(1), - } - - result - } - .instrument(span) - .await -} -``` - -The exact crates are app choices. The important part is that observability wraps -routes, handlers, and provider adapters instead of adding client-side state or -selector-based probes. req: runtime/003 req: runtime/004 - -## Feature flags as typed app state - -Flags should be ordinary typed state. Handlers read the flag and return generated -UI effects or normal HTTP responses. - -```rust -#[derive(Clone)] -pub struct FeatureFlags { - project_creation: bool, - beta_metrics_island: bool, -} - -#[derive(Clone)] -pub struct AppContext { - session: CurrentSession, - store: ProjectStore, - flags: FeatureFlags, -} - -#[hemx::handler] -async fn create_project( - State(ctx): State, - Form(form): Form, -) -> Result { - if !ctx.flags.project_creation { - return Ok(( - dashboard::flash.set("Project creation is temporarily disabled"), - dashboard::new_project.disable_while_pending(), - )); - } - - ctx.verify_csrf(&form.csrf)?; - let project = ctx.store.insert(form.name, &ctx.session).await?; - - Ok(( - dashboard::project_row.append(ProjectRow::from(project)), - dashboard::new_project.clear(), - dashboard::flash.set("Project created"), - )) -} -``` - -A flag provider may refresh `FeatureFlags` from a database, config service, or -static file. hemx does not need a flag API; the generated helpers are enough to -show enabled, disabled, or degraded UI. - -## Killswitches - -A killswitch is a product decision at the application boundary. Prefer explicit -failure or degraded UI over silently dropping effects. - -Good killswitch targets: - -- disable one mutation handler while leaving page rendering intact -- switch from enhanced interaction to full-page form response -- disable an island or live status stream while keeping the server-rendered page - usable -- pause SSE/polling and show a generated status message - -Example for an SSE/live-status killswitch: - -```rust -pub fn live_status(ctx: &AppContext) -> impl IntoEffect { - if !ctx.flags.live_status { - return dashboard::live_status.set("Live status is paused"); - } - - dashboard::live_status.set(format!("heartbeat: {} projects", ctx.projects().len())) -} -``` - -Do not add a generic client-side killswitch to the runtime. The runtime applies -checked effects; the app decides which effects to produce. req: failure/004 - -## A/B tests and analytics - -A/B assignment belongs in auth/session or request context: - -```rust -pub struct ExperimentContext { - variant: &'static str, -} - -#[hemx::handler] -async fn open_settings( - State(ctx): State, -) -> impl IntoEffect { - let panel = if ctx.experiments.variant == "compact" { - SettingsPage::compact() - } else { - SettingsPage::full() - }; - - ( - dashboard::page_panel.put(&panel), - dashboard::nav.set("Settings"), - hemx::push("/settings"), - ) -} -``` - -Analytics can be emitted server-side when the handler runs or through explicit -native events returned by the handler. Avoid hidden DOM scraping or selector -listeners as the normal path. - -## Metrics to track - -Suggested app/platform metrics: - -- `hemx.interaction.ok` -- `hemx.interaction.error` -- `hemx.form.parse_error` -- `hemx.handler.failure` -- `hemx.fingerprint_mismatch` -- `hemx.missing_target` -- `hemx.sse.reconnect` -- `hemx.killswitch.active` - -Provider names, label sets, sampling, and retention are platform decisions. Do -not bake them into hemx core. - -## Tests - -Keep tests at the app boundary: - -- flag disabled: handler does not call the store and returns a generated disabled - or flash effect -- flag enabled: handler follows the normal generated-helper path -- killswitch active: live status or island is paused with generated UI feedback -- provider failure: app maps the failure through `AppError` without panicking -- metrics wrapper records ok/error paths without changing effect contents - -`examples/saas` can exercise those checks with an in-memory fake flag provider; -a real deployment can use the same tests around a provider-backed `FeatureFlags` -loader. req: examples/001 req: test/001 diff --git a/docs/recipes/pwa-offline.md b/docs/recipes/pwa-offline.md deleted file mode 100644 index 6d351fa..0000000 --- a/docs/recipes/pwa-offline.md +++ /dev/null @@ -1,133 +0,0 @@ -# Recipe: optional PWA/offline adapter boundary - -This recipe describes how a hemx app can add a cached shell or offline queue -without turning core hemx into a client app framework. Offline/PWA support is -opt-in adapter territory: reuse generated targets and server-canonical effects, -but keep service workers, queues, conflict policy, and local storage outside -`hemx`, `hemx-core`, `hemx-build`, `hemx-derive`, `hemx-axum`, and the tiny -runtime. req: canonical_authoring/008 req: canonical_authoring/018 req: canonical_authoring/019 req: runtime/003 req: runtime/004 - -Use this only after the normal server-first path works. A hemx app is allowed to -fail interactions while offline and recover with a full page once the network is -back. - -## Boundary rule - -Keep these concerns outside hemx core: - -- service worker registration and cache policy -- local persistence stores such as IndexedDB -- offline mutation queues -- background sync, retry, and conflict resolution -- CRDTs or collaborative sync engines -- analytics for offline queue health - -Keep these concerns in app/integration code: - -- deciding which pages/assets are safe to cache -- deciding which mutations may be queued -- serializing a domain command for later replay -- reconciling queued commands with server-canonical effect responses -- showing generated UI feedback such as "offline", "queued", "synced", or - "conflict" - -The normal path remains server-first typed handlers and generated effects. - -## Cached shell - -A PWA shell may cache page HTML, CSS, the matching `runtime_js_path()` asset, and -explicit island scripts for one release. It must obey the same release-unit -policy as `docs/recipes/deploy-versioning.md`: cached server HTML and cached -runtime assets must be compatible with the server that receives later -interactions. req: abi/002 req: abi/004 - -Recommended behavior: - -- cache only content-addressed or release-scoped assets -- evict cached shells on release/fingerprint mismatch -- fall back to a full page GET when unsure -- do not patch cached DOM with selector retargeting - -The service worker is app code. hemx core should not register or own it. - -## Offline mutation queue - -If a mutation is safe to queue, store an app-domain command, not a raw DOM patch -or runtime opcode: - -```rust -#[derive(serde::Serialize, serde::Deserialize)] -pub enum OfflineCommand { - CreateProject { csrf: CsrfToken, name: ProjectName }, -} -``` - -When the browser is offline, the adapter can add the command to an IndexedDB -queue and show generated UI feedback from the app shell: - -```rust -pub fn queued_project_notice() -> impl IntoEffect { - ( - dashboard::flash.set("Project will be created when you are back online"), - dashboard::live_status.set("Offline: 1 change queued"), - ) -} -``` - -When the network returns, replay the command to the normal server endpoint. The -server still runs auth/session, CSRF, validation, persistence, and returns the -canonical generated effects. req: auth/002 req: auth/004 req: failure/004 - -Do not store `EffectBatch` as the source of truth for later replay. Effects are -UI outcomes for a server decision; queued commands are user intent that the -server must validate again. - -## Reconciliation - -The server is authoritative. A replay may succeed, fail validation, fail auth, -fail CSRF, or conflict with newer state. The adapter should apply the returned -server effects when compatible and otherwise navigate/reload to server-rendered -truth. - -Suggested outcomes: - -- **success:** apply generated append/replace/remove/summary effects from the - server response -- **validation failure:** apply generated form error/focus effects -- **auth or CSRF failure:** discard or pause the queue and navigate to sign-in or - refresh the page -- **conflict:** ask the server for the current page/partial and replace a - generated target, or show a generated conflict notice -- **fingerprint mismatch:** reload/navigate instead of applying queued effects - -This keeps conflict policy in the app and keeps core runtime selectorless. req: failure/005 - -## Optional sync crate shape - -A future `hemx-sync` or app-local adapter may provide helpers around this model, -but it should remain optional and explicit: - -```rust -pub trait OfflineQueue { - async fn push(&self, command: OfflineCommand) -> Result<(), QueueError>; - async fn drain(&self, session: CurrentSession) -> Result<(), QueueError>; -} -``` - -Such an adapter may reuse generated slots, forms, and keyed resources, but it -must not make every app value a client-side atom or introduce a mandatory local -state graph. req: sync/001 req: sync/007 - -## Tests - -Keep tests at the adapter boundary: - -- offline command is stored as a domain command, not a raw effect -- queued command replays through the same handler route as an online submit -- server validation and CSRF checks still run during replay -- fingerprint/runtime mismatch causes reload/navigation instead of partial apply -- conflict response uses generated UI feedback or full page refresh -- no selector targeting or client app store is required for normal forms/lists - -For the current v1 tutorial, `examples/saas` remains the server-first canonical -path. Offline/PWA is an optional recipe, not required app scaffolding. req: examples/001 req: test/001 diff --git a/docs/recipes/reusable-partials.md b/docs/recipes/reusable-partials.md deleted file mode 100644 index 9c5eedc..0000000 --- a/docs/recipes/reusable-partials.md +++ /dev/null @@ -1,41 +0,0 @@ -# Where are my components? - -In hemx, the reusable UI unit is a **checked hemplate partial plus generated Rust -helpers**, not a client component instance. You still get reuse and composition; -the ownership moves to places Rust apps can inspect and test. req: canonical_authoring/002 req: canonical_authoring/003 - -| Framework component job | hemx home | -| --- | --- | -| Markup and local UI shape | A `.heml` partial rendered from a Rust view struct. | -| Props | The view struct fields passed into the partial/helper. | -| Stable child identity | `h-key` on repeated partials, exposed through generated keyed helpers. | -| Events | Real forms, links, handles, and explicit generated events. | -| State | App-owned Rust state, commands/events/projections, or integration-owned stores. | -| Updating the UI | Generated commands such as `ui::todo_row.replace(row)`. | -| Composition | `impl IntoEffect`: tuples for fixed mixed batches, arrays for fixed repeated batches, and `Vec` for dynamic repeated batches. | -| Client-only widgets | Explicit islands or Web Components at leaf boundaries. | - -A reusable row should be one partial used in both places: initial render and later -updates. The handler builds domain state, converts it to a view value, and returns -generated commands: - -```rust -( - rows - .into_iter() - .map(|row| ui::todo_row.replace(row)) - .collect::>(), - ui::summary.set(summary), - ui::notice.set("Saved"), -) -``` - -That is the component story: the row partial is reusable; the generated helper -knows the target and swap kind; `IntoEffect` composes the update without a client -component runtime, selector lookup, raw ids, raw opcodes, or manual registry -plumbing. req: public_api/005 - -Use an island only when the browser must own high-frequency local behavior, such -as a chart, map, editor, or media widget. The island is an explicit leaf; it can -emit facts back through generated handles/events, but the app still changes -server-owned UI through normal hemx effects. req: interop/003 diff --git a/docs/recipes/sqlx-persistence.md b/docs/recipes/sqlx-persistence.md deleted file mode 100644 index b75aa73..0000000 --- a/docs/recipes/sqlx-persistence.md +++ /dev/null @@ -1,174 +0,0 @@ -# Recipe: SQLx persistence for the SaaS tutorial - -This recipe replaces the tutorial app's in-memory `LocalProjectStore` with an -application-owned SQLx adapter. SQLx is deliberately a recipe dependency, not a -hemx core dependency: hemx still sees ordinary Rust domain values, typed forms, -and generated UI commands. req: laws/002 req: laws/004 req: auth/001 - -Use this when the `examples/saas` flow is ready to persist projects outside the -process. Keep auth/session and CSRF checks in middleware/extractors or app state, -then call the store from the handler only after those checks pass. req: auth/002 req: auth/004 - -## Cargo feature in the app, not hemx - -Add SQLx to the application crate that owns persistence: - -```toml -# examples/saas/Cargo.toml or your app crate -[dependencies] -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate"] } -``` - -Do not add SQLx to `hemx`, `hemx-core`, `hemx-build`, `hemx-derive`, or -`hemx-axum`. Persistence is app/domain policy, not a UI runtime primitive. - -## Schema - -```sql --- migrations/0001_projects.sql -CREATE TABLE projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - owner_email TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); -``` - -## Adapter - -The adapter has the same shape as `LocalProjectStore`: insert a domain command, -return a domain record, and let the handler convert that record into the -hemplate view type used by generated helpers. req: examples/001 req: canonical_authoring/002 - -```rust -use sqlx::{Row, SqlitePool}; - -#[derive(Clone)] -pub struct SqlxProjectStore { - pool: SqlitePool, -} - -impl SqlxProjectStore { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - pub async fn insert( - &self, - name: ProjectName, - session: &Session, - ) -> Result { - let row = sqlx::query( - r#" - INSERT INTO projects (name, owner_email) - VALUES (?, ?) - RETURNING id, name, owner_email - "#, - ) - .bind(name.as_str()) - .bind(&session.email) - .fetch_one(&self.pool) - .await - .map_err(AppError::from_sqlx)?; - - Ok(ProjectRecord { - id: ProjectId(row.try_get::("id").map_err(AppError::from_sqlx)? as u64), - name: row.try_get("name").map_err(AppError::from_sqlx)?, - owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?, - }) - } - - pub async fn list(&self) -> Result, AppError> { - let rows = sqlx::query( - r#" - SELECT id, name, owner_email - FROM projects - ORDER BY id - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(AppError::from_sqlx)?; - - rows.into_iter() - .map(|row| { - Ok(ProjectRecord { - id: ProjectId(row.try_get::("id").map_err(AppError::from_sqlx)? as u64), - name: row.try_get("name").map_err(AppError::from_sqlx)?, - owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?, - }) - }) - .collect() - } -} -``` - -Keep SQLx errors in the app error type and map them through the existing -`Result` boundary. Expected validation remains a -form UI effect; unexpected persistence failure becomes an app failure effect or -HTTP response. req: failure/004 - -```rust -impl AppError { - fn from_sqlx(error: sqlx::Error) -> Self { - eprintln!("project store failed: {error}"); - AppError::StoreUnavailable - } -} -``` - -## Handler boundary - -The handler shape does not change. Only the store implementation changes. - -```rust -#[hemx::handler] -async fn create_project( - State(ctx): State, - Form(form): Form, -) -> Result { - ctx.require_session()?; - ctx.verify_csrf(&form.csrf)?; - - if form.name.as_str().is_empty() { - return Err(AppError::Validation("Project name required")); - } - - let project = ctx.store.insert(form.name, &ctx.session).await?; - let total = ctx.store.list().await?.len(); - - Ok(( - dashboard::project_row.append(ProjectRow::from(project)), - dashboard::summary.set(project_summary(total)), - dashboard::new_project.clear(), - dashboard::flash.set("Project created"), - )) -} -``` - -The important invariant is that SQLx never appears in templates, generated -helpers, the JavaScript runtime, or hemx core. It is an application adapter -behind ordinary Rust state. req: invariant/005 - -## Test shape - -Prefer an app-level integration test with an in-memory SQLite pool and migrations: - -```rust -let pool = SqlitePool::connect("sqlite::memory:").await?; -sqlx::migrate!("./migrations").run(&pool).await?; -let ctx = AppContext::with_store(Session::demo(), SqlxProjectStore::new(pool)); - -let response = InteractionRequest::from(form( - dashboard::create_project, - &[("csrf", "demo-csrf"), ("name", "Launch checklist")], -)) -.dispatch_async(registry(ctx.clone())) -.await?; - -let effects = inspect_batch(response.batch); -assert!(effects.inserts_html_containing(dashboard::project_row, "1", "Launch checklist")); -``` - -This proves the same generated form/slot/keyed-row behavior as the local adapter -while exercising a real provider at the application boundary. req: examples/001 req: test/001 diff --git a/docs/tutorial-saas.md b/docs/tutorial-saas.md deleted file mode 100644 index d40392c..0000000 --- a/docs/tutorial-saas.md +++ /dev/null @@ -1,230 +0,0 @@ -# Tutorial: production-shaped SaaS app - -This walkthrough explains the canonical v1 tutorial path in `examples/saas`. -It is intentionally provider-light: the app proves auth/session shape, -CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain -CSS, and one explicit island without moving SQL, auth, flags, deploy, or -observability providers into hemx core. req: examples/001 req: laws/002 - -Run it: - -```sh -cargo run -p hemx-saas-example -cargo test -p hemx-saas-example -``` - -## What you are building - -The tutorial app is a small project dashboard: - -- a full page shell rendered by Rust and hemplate -- a `Dashboard` template with a project creation form -- typed domain inputs: `CsrfToken`, `ProjectName`, and `ProjectId` -- an app-owned `LocalProjectStore` persistence adapter -- an auth/session-shaped `AppContext` -- a CSRF-checked mutation handler -- generated form, summary, flash, keyed row, page-panel, and live-status effects -- an SSE/polling-shaped live status endpoint -- plain CSS and one explicit metrics island script - -The important point is not the project domain; it is the boundary: templates -declare the UI surface, Rust owns domain state, handlers return generated UI -commands, and the browser runtime only applies checked effects. req: canonical_authoring/001 req: modes/001 - -## Files to read first - -- `examples/saas/templates/dashboard.heml` — the UI contract -- `examples/saas/src/lib.rs` — domain types, app context, handlers, and tests -- `examples/saas/src/main.rs` — Axum route wiring and runtime/static assets -- `examples/saas/templates/app.css` — plain CSS -- `examples/saas/templates/metrics.js` — explicit leaf-island JavaScript -- `examples/saas/README.md` — scope and provider boundaries - -## 1. Declare the surface in hemplate - -The dashboard template names only facts that hemx can check and generate: - -```heml -
    -
    - - -

    -
    - -

    {+ self.flash +}

    -

    {+ self.summary +}

    - -
      - -
    -
    -``` - -There are no selectors, numeric ids, raw targets, or runtime opcodes in the -template. The `h-key` gives the keyed row target enough information for generated -append/replace/remove helpers. `{+ row +}` renders the child hemplate partial; -`{+= html =+}` is only for already-trusted HTML. req: canonical_authoring/002 req: list/001 - -## 2. Keep domain types ordinary - -The form type is Rust domain code, not a generated DTO: - -```rust -#[derive(Clone, Debug)] -#[hemx::form("new_project")] -pub struct NewProject { - csrf: CsrfToken, - name: ProjectName, -} -``` - -`ProjectName` trims submitted input via `FromStr`; `CsrfToken` is a typed value; -`ProjectId` implements `Display` for stable keyed row ids. The generated form -contract checks that the Rust shape matches the HTML controls. req: form/001 req: codegen/004 - -## 3. Put platform boundaries in app state - -`AppContext` carries the authenticated session and persistence adapter: - -```rust -#[derive(Clone)] -pub struct AppContext { - session: Session, - store: LocalProjectStore, -} -``` - -The local store is deliberately small and testable. Production providers are -recipes, not core dependencies: - -- SQLx: `docs/recipes/sqlx-persistence.md` -- auth/session and CSRF middleware: `docs/recipes/auth-session-csrf.md` -- observability, feature flags, and killswitches: - `docs/recipes/observability-flags.md` -- deploy/runtime compatibility: `docs/recipes/deploy-versioning.md` - -This keeps hemx focused on the UI contract while the app owns platform choices. -req: auth/001 req: laws/004 - -## 4. Write one boring handler - -The create handler checks session/CSRF, validates input, persists a record, and -returns generated UI commands: - -```rust -#[hemx::handler] -async fn create_project( - State(ctx): State, - Form(form): Form, -) -> Result { - if form.csrf != ctx.session.csrf { - return Err(AppError::CsrfRejected); - } - if form.name.as_str().is_empty() { - return Err(AppError::Validation("Project name required")); - } - - let project = ctx.store.insert(form.name, &ctx.session)?; - let total = ctx.projects().len(); - - Ok(( - dashboard::project_row.append(ProjectRow::from(project)), - dashboard::summary.set(project_summary(total)), - dashboard::new_project.clear(), - dashboard::flash.set("Project created"), - dashboard::live_status.set(format!("{total} projects persisted locally")), - )) -} -``` - -The handler does not choose targets with CSS selectors, construct raw effects, -parse raw forms, or call the runtime. It returns intent through generated helpers -and tuple composition. req: canonical_authoring/003 req: dx/007 - -## 5. Map failures explicitly - -Expected validation and platform failures cross one app error boundary: - -```rust -impl IntoHandlerFailure for AppError { - fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { - match self { - AppError::Validation(message) => HandlerFailure::effects( - ( - dashboard::new_project.error("name", message), - dashboard::new_project.focus("name"), - ), - context, - ), - other => HandlerFailure::effects(dashboard::flash.set(other.message()), context), - } - } -} -``` - -That keeps user mistakes visible in the generated form error target and keeps -infrastructure failures out of the normal success path. req: failure/004 - -## 6. Add page and push shape without a frontend app - -The settings handler swaps a generated page panel and pushes history: - -```rust -( - dashboard::page_panel.put(&SettingsPage { message: "..." }), - dashboard::nav.set("Settings"), - hemx::push("/settings"), -) -``` - -The live-status endpoint sends generated effect batches over SSE/polling-shaped -transport. Routing, auth, and connection policy stay in Axum/app code; hemx does -not become a router or transport framework. req: page_swap/002 req: push/003 - -## 7. Keep CSS and islands explicit - -Appearance is plain CSS in `templates/app.css`. The metrics widget is an opaque -leaf island declared with `data-hemx-island="metrics"` and implemented by -`templates/metrics.js`. The island may inspect its own leaf DOM; ordinary forms, -lists, page swaps, and live status do not require handwritten JavaScript. req: canonical_authoring/007 req: dx/008 - -## 8. Test at the product boundary - -`cargo test -p hemx-saas-example` proves the tutorial shape: - -- the page contains the root, generated form, CSRF field, SSE marker, island, - CSS, and island asset -- stale CSRF does not mutate the store and maps to generated UI -- validation maps to a generated form error -- valid mutation persists locally and returns generated keyed row, summary, form, - and live-status effects -- page swap and push shape use generated targets - -These tests are intentionally app-level. They prove behavior without browser -provider setup or external database side effects. req: test/001 req: examples/001 - -## 9. Productionize by swapping adapters, not changing hemx - -To move from the local tutorial skeleton to production: - -1. Replace `LocalProjectStore` with a SQLx adapter from - `docs/recipes/sqlx-persistence.md`. -2. Replace the demo `Session` with an Axum/Tower extractor and CSRF service from - `docs/recipes/auth-session-csrf.md`. -3. Wrap routes/handlers with app-owned metrics, flags, and killswitches from - `docs/recipes/observability-flags.md`. -4. Add optional PWA/offline behavior only through the adapter boundary in - `docs/recipes/pwa-offline.md`. -5. Deploy server, generated output, and the helper-provided runtime asset as one - release unit following `docs/recipes/deploy-versioning.md`. -6. Follow `docs/versioning.md` for semver and upgrade notes. -7. Use `docs/diagnostics.md` when a template/build/derive/runtime mistake fails - the app. - -The handler and template model should stay recognizable throughout those swaps. -If productionizing requires raw ids, selector retargeting, manual registries, or -client app state, treat that as a design smell and either add a named advanced -escape hatch or keep the provider integration outside the beginner path. req: public_api/005 req: runtime/003 diff --git a/docs/v1-product-evidence.md b/docs/v1-product-evidence.md deleted file mode 100644 index 5b0ab94..0000000 --- a/docs/v1-product-evidence.md +++ /dev/null @@ -1,166 +0,0 @@ -# Hemx v1 product evidence - -This document records external evidence used to sharpen the hemx v1 requirements. -It is not authority over `REQUIREMENTS.md`, and precedent does not prove demand. -The product decision remains: checked hypermedia for Rust, with server-first as the -simple default and client-local/offline execution as explicit opt-in layers over -the same generated-resource and effect contract. - -Research checked on 2026-07-13. - -## User job and alternatives - -The target user is a Rust team building an interaction-heavy web application that -wants server-rendered HTML and ordinary Rust domain logic without accepting a -second selector/string contract or a component/VDOM runtime. Today that team can: - -- use server-only hypermedia and accept round-trip latency; -- add handwritten JavaScript and own two state/effect models; -- adopt React/Vue or another client framework for local interaction; -- use LiveView/Turbo-style server-driven interaction; or -- build a local-first sync engine directly. - -Those alternatives work. Hemx v1 is justified only if execution location can be -an opt-in handler choice while generated resources, `EffectBatch`, failure -semantics, and server authority stay coherent. - -## Evidence and decisions - -### Linear: local responsiveness requires a real sync architecture - -Sources: - -- [Scaling the Linear Sync Engine](https://linear.app/now/scaling-the-linear-sync-engine) -- [Linear Method](https://linear.app/method/introduction) -- [Linear Security](https://linear.app/security) -- [How Linear uses Google Cloud databases](https://cloud.google.com/blog/products/databases/product-workflow-tool-linear-uses-google-cloud-databases) - -Linear materializes fast local interaction with a client-side data model and a -server replication/sync system rather than hiding latency behind cosmetic -loading states. Its published architecture discusses initial synchronization, -real-time updates, database change capture, and scaling work; its product method -also values deliberate, opinionated workflows. Its security page treats access, -encryption, backups, monitoring, incident handling, and independent assurance as -operational systems rather than UI features. - -**Use in hemx:** client-local work must be genuinely local; offline/sync must have -durable identities, bounded queues, resumable acknowledgement, migration, -conflict/rejection behavior, and observable recovery. Production proof must cover -operations and failures, not only the happy-path API. - -**Do not copy:** hemx is a framework, not Linear's product. It must not grow issue -tracking, workspace policy, SSO/SCIM, a hosted database, or a mandatory global -client graph. Authentication, authorization, encryption policy, backups, and -retention remain application/platform concerns; hemx integrations must expose -boundaries that let applications enforce and test them. - -### Local-first: offline is a data-ownership and recovery promise - -Source: [Local-first software: You own your data, in spite of the cloud](https://www.inkandswitch.com/essay/local-first/). - -The local-first work identifies availability without a network, multi-device -coordination, ownership, longevity, and collaboration as distinct properties. A -cache or optimistic DOM patch does not establish them. - -**Use in hemx:** persisted commands/domain events are truth; DOM effects are -projections. Queue durability, export/deletion, schema upgrades, conflict policy, -and recovery from corruption/quota failure must be explicit. “Offline capable” -cannot mean only that a shell loads. - -**Do not copy:** CRDTs are not the default. Hemx v1 keeps the server authoritative -and requires explicit opt-in policy where collaboration semantics differ. - -### Hypermedia and live-server systems: preserve browser and deploy semantics - -Sources: - -- [HTMX documentation](https://htmx.org/docs/) -- [Phoenix LiveView deployments](https://hexdocs.pm/phoenix_live_view/deployments.html) -- [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction) - -These systems demonstrate progressive enhancement, history-aware navigation, -request synchronization, server-driven DOM updates, reconnect/deployment -concerns, and the value of preserving ordinary links and forms. - -**Use in hemx:** real `href`/form fallback, back/forward correctness, stale-request -suppression, deploy fingerprint refusal, reconnect behavior, and clear full-page -recovery are release requirements. - -**Do not copy:** selector mini-languages, implicit component lifecycles, and a -router owned by core remain outside hemx. - -### Platform primitives: use the browser's durable and accessible contracts - -Sources: - -- [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) -- [Using Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers) -- [WCAG 2.2 quick reference](https://www.w3.org/WAI/WCAG22/quickref/) -- [RAIL performance model](https://web.dev/articles/rail) - -IndexedDB provides transactional browser storage; service workers provide an -HTTPS-bound offline/network interception lifecycle. WCAG 2.2 makes keyboard -operation, visible focus, status/error communication, and programmatic -name/role/value release concerns. RAIL treats roughly 100 ms as the response -window in which direct manipulation feels immediate. - -**Use in hemx:** optional adapters reuse platform storage/service-worker -primitives; storage failures and upgrades are recoverable. Generated interaction -must preserve semantic HTML, keyboard operation, focus, status/error -announcements, and reduced-motion preferences. Client-local interaction gets an -observable latency/frame budget rather than a “fast” adjective. - -**Do not copy:** hemx core does not mandate IndexedDB, a service worker, or an -application cache policy. - -### Security and release discipline: framework controls need testable boundaries - -Sources: - -- [OWASP Application Security Verification Standard 5.0](https://owasp.org/www-project-application-security-verification-standard/) -- [Cargo SemVer compatibility](https://doc.rust-lang.org/cargo/reference/semver.html) -- [cargo-audit](https://github.com/rust-secure-code/cargo-audit) - -ASVS provides a test-oriented baseline for web controls such as encoding, -injection prevention, session/access boundaries, validation, and logging. Cargo's -SemVer guidance shows that public Rust items, traits, features, MSRV, and runtime -behavior all carry compatibility risk. `cargo-audit` checks the committed lockfile -against RustSec advisories. - -**Use in hemx:** unsafe HTML stays type-gated; integrations make origin/CSRF, -authorization, limits, and security logging testable; replay never bypasses -current authorization. v1 has an explicit public/generated/wire/runtime -compatibility policy, migration evidence, MSRV/browser support, and a pinned -lockfile advisory audit before release approval. - -**Do not copy:** hemx does not claim application-level ASVS compliance. It proves -only controls and boundaries it owns. Publishing remains a separate explicit -human decision. - -## Product thesis - -Hemx v1 should feel like boring server-rendered HTML with typed, selectorless -partial swaps, while letting a team opt one handler into local WASM or durable -sync without changing the resource/effect language. The smallest coherent -mechanism is: - -1. hemplate Surface facts and generated resources; -2. one handler shape with explicit execution placement; -3. one versioned `EffectBatch` application contract; -4. server-first by default; -5. explicit local state ownership; -6. optional durable command-log/reconciliation adapters; and -7. fail-closed versioning plus native-browser recovery. - -## Kill tests - -Reshape or drop client-local/sync work if any slice requires: - -- a second effect protocol or selector target language; -- hidden global state or a component lifecycle; -- bespoke JavaScript per application handler; -- persisted DOM/effect payloads as domain truth; -- a mandatory browser database, service worker, CRDT, or conflict policy; -- authorization decisions cached across replay without server revalidation; or -- inaccessible interaction or failure states that cannot preserve native HTML - fallback. diff --git a/docs/v1-readiness.md b/docs/v1-readiness.md deleted file mode 100644 index 97020a8..0000000 --- a/docs/v1-readiness.md +++ /dev/null @@ -1,192 +0,0 @@ -# v1 readiness audit - -This audit records the proven server-first/page-enhanced baseline. It is not a -marketing release announcement and no longer claims the full v1 north star is -closed. The product evidence in `docs/v1-product-evidence.md` and current -requirements add client-local WASM, durable offline/sync, accessibility, -security, operations, performance, compatibility, and production-reference -closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: public_api/001 req: v1_release/001 - -## Current status - -- Server-first and page-enhanced baseline: proven by the evidence below. -- Client-local WASM: real generated-resource browser/WASM execution proven. -- Durable offline/sync and multiplayer milestone: framework-owned replay, - acknowledgement, convergence, presence, recovery, and accessibility proven. -- Production reference: authenticated mutation, origin/CSRF denial, atomic - rollback-safe persistence, restart recovery, health/readiness, diagnostics, - metrics, CSP, and mixed-build fail-closed recovery proven. -- V1 closure matrix: not closed. The recorded local workspace, browser, - performance, docs, and example gates pass, warning-denied vulnerability and - source audits are clean, and the mutation-applicable library/proc-macro matrix - has no unexplained survivors. Strict license closure still awaits an owner-chosen - license for 20 currently unlicensed workspace packages and an allowlist decision - for Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT, - Unicode-3.0, and Unlicense dependencies; this blocks a production-ready claim. - req: test/020 req: test/021 req: v1_release/006 -- Publishing and deployment: explicitly unauthorized. - -## Baseline evidence - -### Canonical tutorial app - -Status: satisfied. - -Evidence: - -- `examples/saas` is a compile-tested tutorial app with typed domain values, - `#[hemx::form("new_project")]`, auth/session-shaped `AppContext`, CSRF-safe - mutation, local persistence adapter, generated keyed row/form/summary/page/live - effects, full-page route fallback for settings, enhanced page-panel swap, - SSE/polling shape, plain CSS, one explicit metrics island, and tests. -- `docs/tutorial-saas.md` walks through the app from template to production - provider handoff. -- `docs/recipes/sqlx-persistence.md` shows how to replace `LocalProjectStore` - with an app-owned SQLx adapter without moving SQLx into core. - -Release decision: - -- The supported v1 production boundary is the compile-tested local persistence - adapter plus provider-explicit recipes. SQLx/auth/observability/deploy/PWA stay - app integrations rather than required workspace dependencies, so the tutorial - remains runnable in CI without credentials or external services. - -### Beginner API stability - -Status: satisfied for the current v1 goal. - -Evidence: - -- Normal path is documented around `app`, `component`, `handler`, `form`, - `page`, generated helpers, tuple `IntoEffect`, and `Result` - mapping. -- `docs/versioning.md` defines stable beginner API vs wire/runtime ABI vs - advanced escape hatches. -- `examples/v0` and `examples/saas` exercise the normal path without manual - registries or raw ids in app authoring. - -### Advanced APIs isolated - -Status: satisfied. - -Evidence: - -- `README.md`, `docs/versioning.md`, and `docs/diagnostics.md` identify raw - effects, ids, render/target construction, manual registries, runtime hooks, - SSE internals, and island internals as advanced. -- Public examples label `v0` as beginner, `examples/saas` as the tutorial app, - `kanban` as advanced/north-star, and `techdemo` as advanced. -- Forbidden-normal-path scans only hit explicit route/static asset serving, - deploy/versioning text, or the `examples/saas` metrics island. - -### Docs explain the model in one sitting - -Status: satisfied. - -Evidence: - -- `README.md` explains render → slot/key → effect → runtime, forms/errors, - pages/push, CSS/islands, production boundaries, escape hatches, and - deploy/version compatibility. -- `docs/tutorial-saas.md` provides the product walkthrough. -- Recipes cover SQLx, auth/session + CSRF, observability/flags/killswitches, - deploy/versioning, and optional PWA/offline. -- `docs/diagnostics.md` and `docs/versioning.md` cover failure and release - policy. - -### Diagnostics - -Status: satisfied for the current v1 goal. - -Evidence: - -- `docs/diagnostics.md` names common mistakes and desired fixes in author - language. -- Existing gates cover build diagnostics, derive compile-fail diagnostics, - runtime root/fingerprint behavior, result-handler mapping, and example - contract checks. -- Final diagnostics gates include `cargo test -p hemx-build`, - `cargo test -p hemx-derive --test compile_fail`, `cargo test -p hemx-js`, and - `cargo test -p hemx-test --test examples_contract`. - -### Production recipes - -Status: satisfied. - -Evidence: - -- SQLx: `docs/recipes/sqlx-persistence.md` -- auth/session + CSRF: `docs/recipes/auth-session-csrf.md` -- observability/metrics + feature flags/killswitches: - `docs/recipes/observability-flags.md` -- deploy/versioning: `docs/recipes/deploy-versioning.md` -- mobile release: `docs/recipes/mobile-release.md` -- optional PWA/offline: `docs/recipes/pwa-offline.md` - -### Public examples - -Status: satisfied. - -Evidence: - -- `examples/v0/README.md` is the beginner entry. -- `examples/saas/README.md` identifies the production-shaped tutorial app. -- `examples/kanban/README.md` identifies Kanban as advanced/north-star. -- `examples/techdemo/README.md` identifies Techdemo as advanced. -- Contract tests guard against browser JavaScript and low-level resource plumbing - in canonical examples. - -### Runtime remains tiny and selectorless - -Status: satisfied. - -Evidence: - -- `README.md`, `docs/versioning.md`, `docs/recipes/deploy-versioning.md`, - `docs/recipes/observability-flags.md`, and `docs/recipes/pwa-offline.md` keep - runtime scope to checked effect application and reject VDOM/hydration/client - store/selector-retargeting growth. -- `examples/saas/templates/metrics.js` uses selectors only inside an explicit - leaf island, not for normal hemx targeting. -- `cargo test -p hemx-js` covers runtime root/fingerprint behavior. - -### Versioning explicit - -Status: satisfied. - -Evidence: - -- `docs/versioning.md` defines semver tiers, wire/runtime ABI policy, advanced - escape-hatch policy, upgrade-note template, and release checklist. -- `docs/recipes/deploy-versioning.md` documents release units, asset caching, - rolling deploy behavior, fingerprint mismatch behavior, and rollback checks. - -## Final closure gates - -The following baseline commands remain required. They are insufficient for full -v1 closure until the browser/WASM/offline/multiplayer, accessibility, security, -performance, compatibility, and production-reference proofs in `v1_release/*` -also pass. Run them only as local validation; none publishes or deploys. - -Run these on the final tree before GOAL_DONE: - -```sh -cargo run -p hemx-xtask -- test -cargo run -p hemx-xtask -- mutation -cargo check --workspace -cargo test -p hemx-saas-example -cargo test -p hemx-v0-examples -cargo test -p hemx-build -cargo test -p hemx-js -cargo test -p hemx-derive --test compile_fail -cargo test -p hemx-test --test examples_contract -redgate list -redgate refs -redgate health -git diff --check -``` - -Also run the forbidden-normal-path scan over `README.md`, `docs/`, `examples/v0`, -`examples/saas`, and the public advanced example READMEs. Expected remaining hits -are explicit route/static asset serving, deploy/versioning docs, or explicit -leaf-island JavaScript. diff --git a/docs/versioning.md b/docs/versioning.md deleted file mode 100644 index e15e813..0000000 --- a/docs/versioning.md +++ /dev/null @@ -1,173 +0,0 @@ -# v1 versioning and upgrade policy - -hemx v1 should be boring to upgrade: beginner apps can rely on the generated -helper and handler model, while advanced escape hatches remain explicitly named -and easier to audit. This policy defines what must be stable for v1 and how to -ship breaking changes without hiding incompatibility behind runtime magic. req: abi/001 req: public_api/001 - -## Stability tiers - -### Stable beginner API - -These are the v1 normal path and require semver-major treatment for breaking -changes: - -- `#[hemx::surface]`, `#[hemx::app]`, `#[hemx::component]`, `#[hemx::handler]`, - and `#[hemx::form]` -- generated component helpers for slots, keyed partials, forms, handles, page - targets, page-boundary rendering, class tokens, and events -- `hemx::page(...)` only at explicit server shell boundaries -- tuple `IntoEffect` composition -- `Result` handlers with `IntoHandlerFailure` -- generated form commands such as `clear`, `reset`, `error`, and `focus` -- generated keyed commands such as `append`, `replace`, and `remove` - -A change is breaking if a production-shaped app like `examples/saas` must rewrite -normal handler/template code that was using those APIs correctly. req: examples/001 req: canonical_authoring/006 - -### Stable compatibility contract - -These must remain explicit and fail closed when incompatible: - -- Surface schema version consumed by `hemx_build` -- generated symbols and deterministic resource allocation inputs -- EffectBatch wire/schema ABI -- JavaScript runtime ABI -- build fingerprint inputs and mismatch behavior - -An incompatible wire/runtime change must bump the relevant ABI version and cause -old pages or old runtimes to refuse partial updates rather than silently applying -wrong effects. req: abi/002 req: abi/003 req: abi/004 req: failure/005 - -### Supported compatibility matrix - -The v1 support claim is deliberately narrow: - -| Boundary | Supported | Fails closed when | -|---|---|---| -| Rust toolchain | stable Rust, workspace edition 2021 | an unsupported compiler cannot build the workspace | -| Browser/WASM | Firefox browser suite plus the generated real-WASM path | WASM/bootstrap cannot load or bind | -| Effect wire | ABI `1` only | decoding preserves the version, `is_compatible()` is false, and runtimes refuse application | -| Generated resources | one matching build fingerprint | a stale fingerprint receives reload recovery instead of mutation | -| Durable sync | schema `1`; legacy flat schema-1 records upgrade in place | unknown schema or malformed projection is rejected | -| Runtime set | same-tree `hemx-js`, `hemx-wasm`, generated bindings, and framework sync runtime | mismatched assets have no compatibility guarantee | -| Canonical examples | `v0`, Kanban, client-local, and SaaS workspace packages | an example no longer builds or its focused proof fails | - -No support claim is made for untested browser engines, future wire/schema versions, or arbitrary cross-release runtime mixing. req: abi/001 req: abi/003 req: public_api/003 req: v1_release/007 - -### Advanced escape hatches - -These are public but advanced. They may evolve faster, but every change still -needs a migration note and must not leak into beginner docs: - -- raw effects and batches -- manual registries -- low-level resource ids and raw targets -- raw HTML/render/target construction -- runtime hooks and SSE internals -- island internals and custom integration glue - -Advanced APIs are for integration crates, tests, migrations, or explicit leaf -boundaries. They are not a second beginner API. req: public_api/002 req: public_api/005 - -## What counts as breaking - -Breaking for the beginner API: - -- renaming generated helper methods or changing their return contracts -- requiring manual registry wiring for canonical apps -- requiring user-authored JavaScript or selector targeting for ordinary forms, - partial swaps, page swaps, or SSE/polling -- moving validation/error UI off generated form helpers -- changing handler argument inference so existing valid handlers stop compiling -- changing `Result` mapping so app errors no longer map at - the integration boundary - -Breaking for compatibility: - -- changing effect wire encoding without an ABI bump -- changing runtime target lookup semantics without a fingerprint/ABI bump -- changing generated id allocation inputs without a fingerprint change -- allowing mismatched server/runtime builds to apply partial updates - -Not breaking: - -- improving diagnostics while keeping spans and fixes user-facing -- adding generated helpers that are aliases around existing behavior when they - remove real friction -- adding new advanced escape hatches that are clearly named and isolated -- adding production recipes for providers outside core -- changing examples to better express the canonical path, when the documented API - remains compatible - -## Upgrade note template - -Every release with public API, generated ABI, runtime, or recipe changes should -include upgrade notes with this shape: - -````md -## Upgrade to hemx X.Y.Z - -### Who is affected -- Beginner app code: yes/no -- Generated helpers: yes/no -- Wire/runtime ABI: yes/no -- Advanced escape hatches: yes/no -- Recipes/examples only: yes/no - -### Required actions -- Regenerate generated code with `cargo check` or your normal build. -- Deploy server and the helper-provided runtime asset from the same release if - ABI/fingerprint changed. -- Update any renamed helpers or advanced calls listed below. - -### Compatibility behavior -- Old page + new server: reload/fail closed/compatible -- New page + old server: reload/fail closed/compatible -- Rolling deploy requirement: sticky release routing / normal routing - -### Migrations -- Before: ... -- After: ... - -### Verification -```sh -cargo run -p hemx-xtask -- test -cargo check --workspace -redgate refs -``` -```` - -## Release checklist - -Before tagging a v1-compatible release: - -- `examples/v0`, `examples/client_local`, `examples/kanban`, and `examples/saas` - compile and their package tests pass; v0 and SaaS remain the canonical public - surface examples without raw ids, raw effects, - selector targeting, manual registries, raw render/lower calls, or user-authored - UI JavaScript in the normal path. req: examples/004 req: examples/005 -- `docs/diagnostics.md` describes any new common error class in user language. - req: diag/001 req: diag/002 -- The canonical local release gate is `cargo run -p hemx-xtask -- test`; there - are no separate `public-api` or `ownership-check` xtask subcommands. -- `docs/recipes/deploy-versioning.md` remains accurate for runtime asset and - fingerprint behavior. -- Any incompatible generated ABI/runtime change bumps the relevant ABI/fingerprint - inputs and has tests for fail-closed behavior. req: abi/005 -- The checked-in ABI-v1 byte fixture in `hemx-core/tests/effect_batch.rs`, the - legacy flat durable-record browser migration, and canonical example package - tests all pass. req: abi/001 req: abi/003 req: v1_release/007 -- Advanced APIs touched by the release are still named as escape hatches in docs. -- Upgrade notes state whether users must regenerate code, redeploy the - helper-provided runtime asset, or change app code. - -## Policy for v1 cutover - -v1 is ready to cut only when the normal path can stay stable for the canonical -SaaS tutorial: hemplate templates, typed handlers, generated helpers, tuple -effects, result error mapping, page/push shape, explicit provider adapters, -plain CSS, and one island boundary. If stabilizing one of those surfaces would -require adding runtime negotiation, selector retargeting, a client state store, or -provider-specific core code, defer the feature or keep it advanced instead of -weakening the v1 contract. req: runtime/003 req: runtime/004 req: laws/004 diff --git a/editors/vscode-hemx/README.md b/editors/vscode-hemx/README.md deleted file mode 100644 index 3cccc63..0000000 --- a/editors/vscode-hemx/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Hemx HEML for VS Code and Cursor - -This extension keeps `.heml` files in VS Code's HTML language mode and layers the -shared `hemx-lsp` service on top for diagnostics, completion, and hover. It does -not define a separate grammar, formatter, selector model, or editor-only parser. -req: diagnostics/004 req: diagnostics/005 - -## Run from a hemx checkout - -Open the repository in VS Code/Cursor and use this extension from source. The -extension detects `hemx-lsp/Cargo.toml` at the workspace root and starts: - -```sh -cargo run -p hemx-lsp -- lsp -``` - -## Run with an installed binary - -Install the shared service and open any app workspace: - -```sh -cargo install --path hemx-lsp -``` - -The extension then starts: - -```sh -hemx-lsp lsp -``` - -If your binary lives elsewhere, set `hemx.heml.lspCommand` and -`hemx.heml.lspArgs` in VS Code/Cursor settings. - -## Behavior - -- `.heml` defaults to VS Code's `html` language mode. -- Diagnostics are displayed from `hemx-build` via `hemx-lsp`. -- Completion and hover come from `hemx-lsp` and `docs/hemplate-syntax.md`. -- If the language service cannot start, normal HTML highlighting still works. diff --git a/editors/vscode-hemx/extension.js b/editors/vscode-hemx/extension.js deleted file mode 100644 index e30e9f9..0000000 --- a/editors/vscode-hemx/extension.js +++ /dev/null @@ -1,320 +0,0 @@ -'use strict'; - -const cp = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const vscode = require('vscode'); - -let client; -let diagnostics; - -function activate(context) { - diagnostics = vscode.languages.createDiagnosticCollection('hemx-build'); - context.subscriptions.push(diagnostics); - - client = new HemxLspClient(context, diagnostics); - context.subscriptions.push({ dispose: () => client.dispose() }); - client.start(); - - const selector = [ - { scheme: 'file', pattern: '**/*.heml' }, - { scheme: 'untitled', pattern: '**/*.heml' } - ]; - - context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(doc => client.didOpen(doc))); - context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(event => client.didChange(event.document))); - context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(doc => client.didSave(doc))); - context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(doc => client.didClose(doc))); - - context.subscriptions.push(vscode.languages.registerCompletionItemProvider(selector, { - provideCompletionItems(document, position) { - return client.completion(document, position); - } - }, 'h', '+', 'd', '=')); - - context.subscriptions.push(vscode.languages.registerHoverProvider(selector, { - provideHover(document, position) { - return client.hover(document, position); - } - })); - - for (const doc of vscode.workspace.textDocuments) { - client.didOpen(doc); - } -} - -function deactivate() { - if (client) { - client.dispose(); - } -} - -class HemxLspClient { - constructor(context, diagnosticCollection) { - this.context = context; - this.diagnosticCollection = diagnosticCollection; - this.proc = undefined; - this.buffer = Buffer.alloc(0); - this.nextId = 1; - this.pending = new Map(); - this.opened = new Set(); - this.ready = Promise.resolve(false); - this.warned = false; - } - - start() { - const spec = lspCommandSpec(); - try { - this.proc = cp.spawn(spec.command, spec.args, { - cwd: spec.cwd, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }); - } catch (err) { - this.warnOnce(`failed to start hemx-lsp: ${err.message}`); - this.ready = Promise.resolve(false); - return; - } - - this.proc.on('error', err => this.warnOnce(`failed to start hemx-lsp: ${err.message}`)); - this.proc.stderr.on('data', data => { - const text = data.toString('utf8').trim(); - if (text) { - console.error(`[hemx-lsp] ${text}`); - } - }); - this.proc.stdout.on('data', data => this.readMessages(data)); - this.proc.on('exit', code => { - if (code !== 0 && code !== null) { - this.warnOnce(`hemx-lsp exited with status ${code}; .heml files keep normal HTML support`); - } - }); - - this.ready = this.request('initialize', { - processId: process.pid, - rootUri: workspaceRootUri(), - capabilities: {} - }).then(() => { - this.notify('initialized', {}); - return true; - }).catch(err => { - this.warnOnce(`hemx-lsp initialize failed: ${err.message}`); - return false; - }); - } - - dispose() { - this.diagnosticCollection.clear(); - if (this.proc && !this.proc.killed) { - this.request('shutdown', {}).catch(() => undefined).finally(() => { - this.notify('exit', {}); - this.proc.kill(); - }); - } - } - - async didOpen(document) { - if (!isHeml(document)) return; - if (!await this.ready) return; - this.opened.add(document.uri.toString()); - this.notify('textDocument/didOpen', { - textDocument: textDocumentItem(document) - }); - } - - async didChange(document) { - if (!isHeml(document)) return; - if (!await this.ready) return; - if (!this.opened.has(document.uri.toString())) { - return this.didOpen(document); - } - this.notify('textDocument/didChange', { - textDocument: versionedTextDocumentIdentifier(document), - contentChanges: [{ text: document.getText() }] - }); - } - - async didSave(document) { - if (!isHeml(document)) return; - if (!await this.ready) return; - this.notify('textDocument/didSave', { - textDocument: textDocumentIdentifier(document), - text: document.getText() - }); - } - - async didClose(document) { - if (!isHeml(document)) return; - this.opened.delete(document.uri.toString()); - this.diagnosticCollection.delete(document.uri); - if (!await this.ready) return; - this.notify('textDocument/didClose', { - textDocument: textDocumentIdentifier(document) - }); - } - - async completion(document, position) { - if (!isHeml(document) || !await this.ready) return undefined; - const response = await this.request('textDocument/completion', { - textDocument: textDocumentIdentifier(document), - position: lspPosition(position) - }); - const items = Array.isArray(response) ? response : response && response.items; - if (!Array.isArray(items)) return undefined; - return items.map(toCompletionItem); - } - - async hover(document, position) { - if (!isHeml(document) || !await this.ready) return undefined; - const response = await this.request('textDocument/hover', { - textDocument: textDocumentIdentifier(document), - position: lspPosition(position) - }); - if (!response || response === null || !response.contents) return undefined; - return new vscode.Hover(markdownFromLsp(response.contents)); - } - - request(method, params) { - const id = this.nextId++; - this.send({ jsonrpc: '2.0', id, method, params }); - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - }); - } - - notify(method, params) { - this.send({ jsonrpc: '2.0', method, params }); - } - - send(message) { - if (!this.proc || !this.proc.stdin.writable) return; - const body = Buffer.from(JSON.stringify(message), 'utf8'); - this.proc.stdin.write(`Content-Length: ${body.length}\r\n\r\n`); - this.proc.stdin.write(body); - } - - readMessages(data) { - this.buffer = Buffer.concat([this.buffer, data]); - while (true) { - const headerEnd = this.buffer.indexOf('\r\n\r\n'); - if (headerEnd < 0) return; - const header = this.buffer.slice(0, headerEnd).toString('ascii'); - const match = /content-length:\s*(\d+)/i.exec(header); - if (!match) { - this.buffer = this.buffer.slice(headerEnd + 4); - continue; - } - const length = Number(match[1]); - const start = headerEnd + 4; - const end = start + length; - if (this.buffer.length < end) return; - const body = this.buffer.slice(start, end).toString('utf8'); - this.buffer = this.buffer.slice(end); - this.handleMessage(JSON.parse(body)); - } - } - - handleMessage(message) { - if (message.id !== undefined && this.pending.has(message.id)) { - const pending = this.pending.get(message.id); - this.pending.delete(message.id); - if (message.error) pending.reject(new Error(message.error.message || 'LSP request failed')); - else pending.resolve(message.result); - return; - } - if (message.method === 'textDocument/publishDiagnostics') { - this.publishDiagnostics(message.params || {}); - } - } - - publishDiagnostics(params) { - const uri = vscode.Uri.parse(params.uri); - const mapped = (params.diagnostics || []).map(diag => { - const range = new vscode.Range( - diag.range.start.line, - diag.range.start.character, - diag.range.end.line, - diag.range.end.character - ); - const item = new vscode.Diagnostic(range, diag.message, toDiagnosticSeverity(diag.severity)); - item.source = diag.source || 'hemx-build'; - item.code = diag.code; - return item; - }); - this.diagnosticCollection.set(uri, mapped); - } - - warnOnce(message) { - if (this.warned) return; - this.warned = true; - vscode.window.showWarningMessage(message); - } -} - -function isHeml(document) { - return document.uri.scheme === 'file' && document.fileName.endsWith('.heml'); -} - -function lspCommandSpec() { - const config = vscode.workspace.getConfiguration('hemx.heml'); - const configuredCommand = config.get('lspCommand', ''); - const configuredArgs = config.get('lspArgs', []); - const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; - const cwd = folder ? folder.uri.fsPath : process.cwd(); - if (configuredCommand) { - return { command: configuredCommand, args: configuredArgs, cwd }; - } - if (fs.existsSync(path.join(cwd, 'hemx-lsp', 'Cargo.toml'))) { - return { command: 'cargo', args: ['run', '-p', 'hemx-lsp', '--', 'lsp'], cwd }; - } - return { command: 'hemx-lsp', args: ['lsp'], cwd }; -} - -function workspaceRootUri() { - const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; - return folder ? folder.uri.toString() : null; -} - -function textDocumentItem(document) { - return { - uri: document.uri.toString(), - languageId: document.languageId, - version: document.version, - text: document.getText() - }; -} - -function textDocumentIdentifier(document) { - return { uri: document.uri.toString() }; -} - -function versionedTextDocumentIdentifier(document) { - return { uri: document.uri.toString(), version: document.version }; -} - -function lspPosition(position) { - return { line: position.line, character: position.character }; -} - -function toCompletionItem(item) { - const completion = new vscode.CompletionItem(item.label, vscode.CompletionItemKind.Property); - completion.detail = item.detail; - completion.insertText = item.insertText || item.label; - if (item.documentation) { - completion.documentation = markdownFromLsp(item.documentation); - } - return completion; -} - -function markdownFromLsp(contents) { - if (typeof contents === 'string') return new vscode.MarkdownString(contents); - if (contents && typeof contents.value === 'string') return new vscode.MarkdownString(contents.value); - if (Array.isArray(contents)) return new vscode.MarkdownString(contents.map(part => typeof part === 'string' ? part : part.value || '').join('\n\n')); - return new vscode.MarkdownString(''); -} - -function toDiagnosticSeverity(severity) { - return severity === 1 ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning; -} - -module.exports = { activate, deactivate }; diff --git a/editors/vscode-hemx/package.json b/editors/vscode-hemx/package.json deleted file mode 100644 index dbac440..0000000 --- a/editors/vscode-hemx/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "hemx-heml", - "displayName": "Hemx HEML", - "description": "Compiler-backed .heml diagnostics, completion, and hover while preserving VS Code HTML tooling.", - "version": "0.1.0", - "publisher": "hemx", - "engines": { - "vscode": "^1.80.0" - }, - "categories": [ - "Programming Languages" - ], - "activationEvents": [ - "workspaceContains:**/*.heml", - "onLanguage:html", - "onLanguage:heml" - ], - "main": "./extension.js", - "contributes": { - "configurationDefaults": { - "files.associations": { - "*.heml": "html" - } - }, - "configuration": { - "title": "Hemx HEML", - "properties": { - "hemx.heml.lspCommand": { - "type": "string", - "default": "", - "description": "Command used to start hemx-lsp. Empty means: use `cargo run -p hemx-lsp -- lsp` inside the hemx repo, otherwise `hemx-lsp lsp`." - }, - "hemx.heml.lspArgs": { - "type": "array", - "default": [], - "items": { - "type": "string" - }, - "description": "Arguments for hemx.heml.lspCommand. Leave empty to use the automatic repo/installed-binary defaults." - } - } - } - } -} diff --git a/examples/client_local/Cargo.toml b/examples/client_local/Cargo.toml deleted file mode 100644 index c7d2a27..0000000 --- a/examples/client_local/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "hemx-client-local-example" -version.workspace = true -edition.workspace = true -publish = false - -[features] -default = [] -fixture = [] - -[lib] -crate-type = ["cdylib", "rlib"] - -[[bin]] -name = "fixture" -path = "src/bin/fixture.rs" -required-features = ["fixture"] - -[dependencies] -hemx = { path = "../../hemx", features = ["client"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -hemplate = { path = "../../../hemplate/hemplate" } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/client_local/build.rs b/examples/client_local/build.rs deleted file mode 100644 index 01192a4..0000000 --- a/examples/client_local/build.rs +++ /dev/null @@ -1,5 +0,0 @@ -fn main() { - hemx_build::app() - .run() - .expect("compile client-local template"); -} diff --git a/examples/client_local/src/bin/fixture.rs b/examples/client_local/src/bin/fixture.rs deleted file mode 100644 index 7314ca9..0000000 --- a/examples/client_local/src/bin/fixture.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", hemx_client_local_example::render_fixture()); -} diff --git a/examples/client_local/src/lib.rs b/examples/client_local/src/lib.rs deleted file mode 100644 index 382a7b9..0000000 --- a/examples/client_local/src/lib.rs +++ /dev/null @@ -1,22 +0,0 @@ -#[hemx::surface] -pub mod ui {} - -#[cfg(not(target_arch = "wasm32"))] -#[derive(hemplate::Hemplate)] -pub struct ClientLocal; - -#[cfg(not(target_arch = "wasm32"))] -pub fn render_fixture() -> hemx::Html { - ui::client_local::render(&ClientLocal) -} - -#[hemx::handler(client)] -pub fn increment( - event: hemx::wasm::ClientEvent, - state: hemx::wasm::ClientState, -) -> impl hemx::IntoEffect { - ui::client_local::counter_panel.text(format!( - "updated by Rust/WASM ({}, {})", - event.kind, state.encoded - )) -} diff --git a/examples/client_local/templates/client_local.heml b/examples/client_local/templates/client_local.heml deleted file mode 100644 index 70d6feb..0000000 --- a/examples/client_local/templates/client_local.heml +++ /dev/null @@ -1,4 +0,0 @@ -
    -
    idle
    - -
    diff --git a/examples/cloudflare_do/Cargo.toml b/examples/cloudflare_do/Cargo.toml deleted file mode 100644 index f23f880..0000000 --- a/examples/cloudflare_do/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "hemx-cloudflare-do-example" -version.workspace = true -edition.workspace = true -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -hemplate = { package = "hemplate-runtime", path = "../../hemplate-runtime" } -hemplate-derive = { path = "../../../hemplate/hemplate-derive" } -hemx = { path = "../../hemx" } -hemx-js = { path = "../../hemx-js" } -serde = { version = "1", features = ["derive"] } -worker = { version = "0.7.5", features = ["http", "queue"] } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } - -[dev-dependencies] -postcard = { version = "1", features = ["alloc"] } diff --git a/examples/cloudflare_do/README.md b/examples/cloudflare_do/README.md deleted file mode 100644 index c9344f7..0000000 --- a/examples/cloudflare_do/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# hemx on Cloudflare Durable Objects - -This proof keeps the hemx authoring and wire model intact while Cloudflare owns room placement, persistence, and hibernating WebSockets: - -```text -room.heml -> hemx-build generated resources -> Rust RoomState - -> generated counter partial -> canonical EffectBatch bytes - -> Durable Object hibernating sockets -> hemx browser runtime -``` - -The Durable Object stores only the counter. It never stores HTML, DOM patches, or `EffectBatch` values as domain truth. - -## Local commands - -```sh -cargo test -p hemx-cloudflare-do-example -cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example -cd examples/cloudflare_do -npx wrangler dev -``` - -Open the printed local URL in two tabs. Incrementing in either tab should update both without reload. Stop and restart `wrangler dev`; the room counter should remain. - -A hosted deployment requires an authorized Cloudflare account. Production auth, CSRF, tenancy, jurisdiction, reconnect replay, and deployment policy are deliberately outside this proof. diff --git a/examples/cloudflare_do/build.rs b/examples/cloudflare_do/build.rs deleted file mode 100644 index 1f1e64e..0000000 --- a/examples/cloudflare_do/build.rs +++ /dev/null @@ -1,10 +0,0 @@ -fn main() { - let templates = std::path::PathBuf::from( - std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"), - ) - .join("templates"); - hemx_build::app() - .template_dir(templates) - .run() - .expect("compile cloudflare_do hemx surfaces"); -} diff --git a/examples/cloudflare_do/src/lib.rs b/examples/cloudflare_do/src/lib.rs deleted file mode 100644 index 52b8c30..0000000 --- a/examples/cloudflare_do/src/lib.rs +++ /dev/null @@ -1,220 +0,0 @@ -use hemplate::Hemplate; -use hemplate_derive::Hemplate; -use hemx::advanced::EffectBatch; -use hemx::IntoEffect; -use serde::{Deserialize, Serialize}; -use worker::*; - -#[hemx::surface] -pub mod ui {} - -const ROOMS_BINDING: &str = "ROOMS"; -const COUNT_KEY: &str = "count"; - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -struct RoomState { - count: u64, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RoomCommand { - Increment, -} - -impl RoomState { - fn apply(self, command: RoomCommand) -> Result { - match command { - RoomCommand::Increment => self - .count - .checked_add(1) - .map(|count| Self { count }) - .ok_or_else(|| Error::RustError("room counter overflowed".into())), - } - } -} - -#[derive(Hemplate)] -struct Room { - count: u64, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct CounterView { - count: u64, -} - -fn counter_batch(state: RoomState) -> EffectBatch { - ui::room::put( - ui::room::advanced::slots::counter, - &CounterView { count: state.count }, - ) - .into_batch(ui::BUILD_FINGERPRINT) -} - -fn room_page(state: RoomState) -> String { - ui::room::page(&Room { count: state.count }).to_string() -} - -fn effect_bytes(state: RoomState) -> Result> { - Ok(counter_batch(state).to_wire()) -} - -fn response_bytes(bytes: Vec, content_type: &str) -> Result { - let headers = Headers::new(); - headers.set("content-type", content_type)?; - Response::from_bytes(bytes).map(|response| response.with_headers(headers)) -} - -fn response_html(html: String) -> Result { - response_bytes(html.into_bytes(), "text/html; charset=utf-8") -} - -#[event(fetch)] -pub async fn fetch(request: Request, env: Env, _ctx: Context) -> Result { - match request.path().as_str() { - "/hemx.js" => response_bytes( - hemx_js::RUNTIME_JS.as_bytes().to_vec(), - "text/javascript; charset=utf-8", - ), - path if path.starts_with("/rooms/") => { - let room_name = - room_name(path).ok_or_else(|| Error::RustError("missing room name".into()))?; - let stub = env.durable_object(ROOMS_BINDING)?.get_by_name(room_name)?; - stub.fetch_with_request(request).await - } - "/" => Response::redirect("/rooms/demo".parse()?), - _ => Response::error("not found", 404), - } -} - -fn room_name(path: &str) -> Option<&str> { - path.strip_prefix("/rooms/")? - .split('/') - .next() - .filter(|name| !name.is_empty()) -} - -#[durable_object] -pub struct DurableRoom { - state: State, -} - -impl DurableObject for DurableRoom { - fn new(state: State, _env: Env) -> Self { - Self { state } - } - - async fn fetch(&self, request: Request) -> Result { - match (request.method(), request.path().rsplit('/').next()) { - (Method::Get, Some("socket")) => self.accept_socket(request), - (Method::Post, Some("increment")) => self.increment().await, - (Method::Get, _) => response_html(self.page().await?), - _ => Response::error("not found", 404), - } - } - - async fn websocket_message( - &self, - _ws: WebSocket, - _message: WebSocketIncomingMessage, - ) -> Result<()> { - Err(Error::RustError( - "room commands use ordinary HTTP; WebSocket is server push only".into(), - )) - } - - async fn websocket_close( - &self, - _ws: WebSocket, - _code: usize, - _reason: String, - _was_clean: bool, - ) -> Result<()> { - Ok(()) - } - - async fn websocket_error(&self, _ws: WebSocket, error: Error) -> Result<()> { - Err(error) - } -} - -impl DurableRoom { - async fn load(&self) -> Result { - Ok(RoomState { - count: self.state.storage().get(COUNT_KEY).await?.unwrap_or(0), - }) - } - - async fn page(&self) -> Result { - let body = room_page(self.load().await?); - Ok(format!( - "Durable hemx room{body}" - )) - } - - fn accept_socket(&self, request: Request) -> Result { - if request.headers().get("upgrade")?.as_deref() != Some("websocket") { - return Response::error("expected WebSocket upgrade", 426); - } - let pair = WebSocketPair::new()?; - self.state.accept_web_socket(&pair.server); - Response::from_websocket(pair.client) - } - - async fn increment(&self) -> Result { - let next = self.load().await?.apply(RoomCommand::Increment)?; - self.state.storage().put(COUNT_KEY, next.count).await?; - let bytes = effect_bytes(next)?; - let mut failures = Vec::new(); - for socket in self.state.get_websockets() { - if let Err(error) = socket.send_with_bytes(bytes.clone()) { - failures.push(error.to_string()); - } - } - if failures.is_empty() { - response_bytes(bytes, "application/x-hemx-effects") - } else { - Err(Error::RustError(format!( - "counter persisted but WebSocket broadcast failed: {}", - failures.join("; ") - ))) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_updates_domain_state_without_storing_ui_output() { - // req: push/010 req: state/001 - assert_eq!( - RoomState { count: 4 } - .apply(RoomCommand::Increment) - .unwrap(), - RoomState { count: 5 } - ); - } - - #[test] - fn generated_target_and_template_render_survive_the_cloudflare_boundary() { - // req: canonical_authoring/001 req: push/010 - let state = RoomState { count: 7 }; - let page = room_page(state); - assert!(page.contains("data-sid=\""), "rendered page: {page}"); - assert!(page.contains("Count: 7"), "rendered page: {page}"); - assert!(page.contains("data-hemx-ws=\"/rooms/demo/socket\"")); - - let decoded = EffectBatch::from_wire(&effect_bytes(state).unwrap()).unwrap(); - assert_eq!(decoded, counter_batch(state)); - } - - #[test] - fn stable_room_names_are_extracted_without_inventing_global_discovery() { - assert_eq!(room_name("/rooms/demo/socket"), Some("demo")); - assert_eq!(room_name("/rooms/team-a"), Some("team-a")); - assert_eq!(room_name("/rooms/"), None); - } -} diff --git a/examples/cloudflare_do/templates/partials/counter_view.heml b/examples/cloudflare_do/templates/partials/counter_view.heml deleted file mode 100644 index 3b78022..0000000 --- a/examples/cloudflare_do/templates/partials/counter_view.heml +++ /dev/null @@ -1 +0,0 @@ -Count: {+ self.count +} diff --git a/examples/cloudflare_do/templates/room.heml b/examples/cloudflare_do/templates/room.heml deleted file mode 100644 index fa36191..0000000 --- a/examples/cloudflare_do/templates/room.heml +++ /dev/null @@ -1,11 +0,0 @@ -
    -

    Durable hemx room

    -

    One Durable Object owns this room. Open it in two tabs.

    -
    - Count: {+ self.count +} -
    -
    - -
    -

    -
    diff --git a/examples/cloudflare_do/wrangler.jsonc b/examples/cloudflare_do/wrangler.jsonc deleted file mode 100644 index 70b853f..0000000 --- a/examples/cloudflare_do/wrangler.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "hemx-cloudflare-do-poc", - "main": "build/worker/shim.mjs", - "compatibility_date": "2026-08-13", - "durable_objects": { - "bindings": [ - { "name": "ROOMS", "class_name": "DurableRoom" } - ] - }, - "migrations": [ - { "tag": "v1", "new_sqlite_classes": ["DurableRoom"] } - ] -} diff --git a/examples/html_examples/Cargo.toml b/examples/html_examples/Cargo.toml deleted file mode 100644 index b6c510c..0000000 --- a/examples/html_examples/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "hemx-html-examples" -version.workspace = true -edition.workspace = true -publish = false - -[lib] -path = "src/lib.rs" - -[dependencies] -axum = "0.8" -hemplate = { path = "../../../hemplate/hemplate" } -hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } - -[dev-dependencies] -scraper = "0.25" -hemx-test = { path = "../../hemx-test" } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md deleted file mode 100644 index 04053b0..0000000 --- a/examples/html_examples/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# hemx HTML examples - -A copy-pasteable pattern gallery for the boring HTML UX patterns popularized by -htmx. The point is not to clone htmx attributes; it is to show the hemx idiom: -plain `.heml`, generated resources, server-owned Rust state, keyed partials, and -tiny runtime behavior. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/001 - -Run it: - -```sh -cargo run -p hemx-html-examples -``` - -Open . - -The active-search example is URL state rather than an interaction handle: its -GET form serializes the visible `q` control into the page URL, live input uses -`data-hemx-history="replace"`, and the explicit submit button uses -`data-hemx-history="push"`. Reload, bookmark, and browser back/forward therefore -ask the same server route to re-render the filtered gallery instead of restoring -client-owned search state. req: page_swap/009 req: page_swap/010 - -## Pattern matrix - -Names match the htmx example URL slug exactly, e.g. `modal-custom` from -`https://htmx.org/examples/modal-custom/`. Rust resource names use normal -identifier spelling only where the language requires it. - -Status legend: - -- **implemented**: copyable `.heml` and server handlers exist in this example. -- **integration-owned**: use hemx generated resources plus app/host/browser policy; - do not grow hemx core for the policy. -- **refused**: would clone htmx/client framework behavior or a third-party UI kit. -- **deferred**: useful, but needs a later vertical slice and proof before becoming - a copyable hemx pattern. - -| htmx example slug | Status | hemx idiom / boundary | Proof anchor | -| --- | --- | --- | --- | -| `click-to-edit` | implemented | A read view and edit form are the same generated `contact_card` partial; the server toggles `editing` and returns `gallery::contact_card.replace(...)`. | `templates/partials/contact_card.heml`, `contact_card_handlers::edit_contact`, `save_contact` | -| `bulk-update` | deferred | Same generated-form path as inline validation, but needs a real multi-row selection/write slice so batch semantics are tested instead of claimed. | Next slice should add keyed batch rows plus one server-owned bulk command. | -| `click-to-load` | implemented | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` | -| `delete-row` | implemented | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` | -| `edit-row` | implemented | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` | -| `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` | -| `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` | -| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows; `data-hemx-revealed-ahead` opts into viewport-ahead loading without moving the observed element. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `infinite_row` | -| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` | -| `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` | -| `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` | -| `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. | -| `file-upload` | integration-owned | Upload transport, size limits, progress, storage, and security are app/integration policy. | Needs product-owned upload route before becoming copyable. | -| `file-upload-input` | integration-owned | Preserving file inputs after errors is browser/security policy; hemx should not fake file state in core effects. | Use app-owned upload form policy. | -| `reset-user-input` | implemented | A generated form updates status and returns `.clear()` after successful submission. | `gallery_handlers::reset_message` | -| `dialogs` | integration-owned | Browser `alert/confirm/prompt` are app policy; hemx can expose event boundaries but should not own dialog UX. | Use native controls or host/app code. | -| `modal-uikit` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. | -| `modal-bootstrap` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. | -| `modal-custom` | deferred | A custom modal can be a generated partial plus focus/escape policy, but needs accessibility proof before copy/paste. | Later slice should include keyboard/focus tests. | -| `tabs-hateoas` | deferred | Good hemx fit: server-owned selected tab and generated tab panel replacement; needs a focused slice. | Later slice should add one tab group. | -| `tabs-javascript` | refused | Client-owned tab state is exactly what generated server-owned state is meant to avoid unless a product needs it. | Prefer `tabs-hateoas`. | -| `keyboard-shortcuts` | integration-owned | Keyboard policy belongs to the app/host; hemx should only receive explicit events. | Use generated app-level events / app JS when needed. | -| `sortable` | integration-owned | Drag/drop ordering needs a browser library or pointer policy plus server reorder command. | Keep Sortable.js as app-owned integration until proven reusable. | -| `update-other-content` | implemented | Generated effects can update multiple slots from one handler; validation and search already update status plus rows/errors. | `validate_email`, `search` handlers. | -| `confirm` | integration-owned | Confirmation wording and irreversible-action policy belong to the app; hemx should not own a global confirm system. | Use native confirm/app dialog around generated delete forms. | -| `async-auth` | integration-owned | Token refresh/auth sessions belong to auth/session integration, not hemx core. | See auth/session recipe boundary. | -| `web-components` | integration-owned | Shadow DOM/custom elements are host integration; hemx can emit events but should not pierce component internals. | Use app-owned web component adapters. | -| `move-before` | refused | Experimental DOM preservation API is not a stable hemx contract. | Avoid until browser support and a product need make it boring. | - -## Boundary - -Implemented rows must remain runnable hemx behavior. Deferred/integration-owned/refused -rows are not failures; they prevent a trophy checklist from turning hemx into a -client framework. Promote a deferred row only when the slice proves a reusable, -boring contract with `.heml`, generated resources, server-owned state, and tests. diff --git a/examples/html_examples/build.rs b/examples/html_examples/build.rs deleted file mode 100644 index 99fa6f3..0000000 --- a/examples/html_examples/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - hemx_build::app().run().unwrap(); -} diff --git a/examples/html_examples/src/lib.rs b/examples/html_examples/src/lib.rs deleted file mode 100644 index c21c7c1..0000000 --- a/examples/html_examples/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[hemx::surface] -pub mod ui {} diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs deleted file mode 100644 index 50e49dc..0000000 --- a/examples/html_examples/src/main.rs +++ /dev/null @@ -1,1007 +0,0 @@ -use axum::body::Body; -use axum::extract::State; -use axum::http::Uri; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; -use axum::Router; -use hemplate::Hemplate; -use hemx::{Html, IntoEffect}; -use hemx_axum::{ - interactions, runtime_js, runtime_js_path, EffectResponse, Form, InteractionHandlers, - InteractionRequest, PageRequest, -}; -use hemx_html_examples::ui; -use hemx_html_examples::ui::{contact_card, editable_row, gallery}; -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; - -#[derive(Default)] -struct GalleryState { - contacts: Mutex>, - rows: Mutex>, - loaded_count: Mutex, - infinite_count: Mutex, - lazy_loads: Mutex, - progress: Mutex, - email: Mutex, - email_status: Mutex, - category: Mutex, - value: Mutex, - reset_status: Mutex, -} - -#[derive(Clone)] -struct ContactRecord { - id: u64, - name: String, - email: String, - editing: bool, -} - -#[derive(Clone)] -struct RowRecord { - id: u64, - title: String, - editing: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct Id(u64); - -impl std::str::FromStr for Id { - type Err = &'static str; - - fn from_str(value: &str) -> Result { - value.parse::().map(Id).map_err(|_| "invalid id") - } -} - -impl std::fmt::Display for Id { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -#[hemx::form("edit_contact")] -struct EditContact { - id: Id, -} - -#[hemx::form("edit_row")] -struct EditRow { - id: Id, -} - -#[hemx::form("delete_row")] -struct DeleteRow { - id: Id, -} - -#[hemx::form("load_more")] -struct LoadMore { - request: String, -} - -#[hemx::form("infinite_scroll")] -struct InfiniteScroll { - request: String, -} - -#[hemx::form("save_contact")] -struct SaveContact { - id: Id, - name: String, - email: String, -} - -#[hemx::form("save_row")] -struct SaveRow { - id: Id, - title: String, -} - -#[hemx::form("lazy_load")] -struct LazyLoad { - request: String, -} - -#[hemx::form("tick_progress")] -struct TickProgress { - request: String, -} - -#[hemx::form("validate_email")] -struct ValidateEmail { - email: String, -} - -#[hemx::form("choose_category")] -struct ChooseCategory { - category: String, -} - -#[hemx::form("reset_message")] -struct ResetMessage { - message: String, -} - -#[derive(Hemplate)] -struct AppShell { - runtime_src: &'static str, - body: Html, -} - -#[derive(Hemplate)] -struct Gallery { - contacts: Vec, - rows: Vec, - lazy_panel: String, - loaded_rows: Vec, - load_status: String, - infinite_rows: Vec, - infinite_status: String, - progress: u8, - progress_label: String, - email: String, - email_status: String, - value_options: Vec, - reset_status: String, - query: String, - search_status: String, - search_results: Vec, -} - -#[derive(Hemplate, Clone)] -struct ContactCard { - id: Id, - name: String, - email: String, - editing: bool, -} - -impl hemx::KeyedPartial for ContactCard { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -struct ProgressMeter { - percent: u8, -} - -impl Hemplate for ProgressMeter { - fn render_into(&self, out: &mut String) -> Result<(), hemplate::error::HemplateError> { - use std::fmt::Write as _; - write!( - out, - " {}% complete", - self.percent, self.percent, self.percent - ) - .expect("write to String cannot fail"); - Ok(()) - } -} - -#[derive(Hemplate, Clone)] -struct EditableRow { - id: Id, - title: String, - editing: bool, -} - -impl hemx::KeyedPartial for EditableRow { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[derive(Hemplate, Clone)] -struct LoadedRow { - id: Id, - title: String, -} - -impl hemx::KeyedPartial for LoadedRow { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[derive(Hemplate, Clone)] -struct ValueOption { - id: Id, - value: String, - label: String, - selected: bool, -} - -impl hemx::KeyedPartial for ValueOption { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[derive(Hemplate, Clone)] -struct SearchResult { - id: Id, - label: String, -} - -impl hemx::KeyedPartial for SearchResult { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[tokio::main] -async fn main() { - let state = Arc::new(GalleryState::seeded()); - let app = Router::new() - .route("/", get(home).post(interact)) - .route(runtime_js_path(), get(runtime)) - .route("/app.css", get(css)) - .with_state(state); - - let port = std::env::var("HEMX_HTML_EXAMPLES_PORT") - .unwrap_or_else(|_| "3029".to_string()) - .parse::() - .expect("HEMX_HTML_EXAMPLES_PORT must be a valid u16"); - let addr = SocketAddr::from(([127, 0, 0, 1], port)); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - println!("hemx HTML examples: http://{addr}"); - axum::serve(listener, app).await.unwrap(); -} - -impl GalleryState { - fn seeded() -> Self { - Self { - contacts: Mutex::new(vec![ContactRecord { - id: 1, - name: "Ada Lovelace".into(), - email: "ada@example.com".into(), - editing: false, - }]), - rows: Mutex::new(vec![ - RowRecord { - id: 1, - title: "Write boring HTML".into(), - editing: false, - }, - RowRecord { - id: 2, - title: "Keep state on the server".into(), - editing: false, - }, - ]), - loaded_count: Mutex::new(2), - infinite_count: Mutex::new(3), - lazy_loads: Mutex::new(0), - progress: Mutex::new(0), - email: Mutex::new(String::new()), - email_status: Mutex::new("Waiting for an email".into()), - category: Mutex::new("letters".into()), - value: Mutex::new("alpha".into()), - reset_status: Mutex::new("No message sent yet".into()), - } - } -} - -async fn home( - State(state): State>, - uri: Uri, - request: PageRequest, -) -> impl IntoResponse { - let query = query_param(uri.query(), "q"); - request - .page_html(ui::page(&gallery_view(&state, &query)), shell) - .title("hemx HTML examples") - .fingerprint(ui::BUILD_FINGERPRINT) -} - -async fn interact( - State(state): State>, - request: InteractionRequest, -) -> Result { - request.dispatch_async(handlers(state)).await -} - -async fn runtime() -> impl IntoResponse { - runtime_js() -} - -async fn css() -> Response { - Response::builder() - .header("content-type", "text/css; charset=utf-8") - .body(Body::from(include_str!("../templates/app.css"))) - .expect("css response") -} - -#[hemx::app(gallery_handlers, contact_card_handlers, editable_row_handlers)] -fn handlers(state: Arc) -> InteractionHandlers { - interactions(ui::BUILD_FINGERPRINT) -} - -fn shell(body: Html) -> Html { - ui::page(&AppShell { - runtime_src: runtime_js_path(), - body, - }) -} - -fn gallery_view(state: &GalleryState, query: &str) -> Gallery { - let contacts = state - .contacts - .lock() - .unwrap() - .iter() - .cloned() - .map(contact_card_view) - .collect(); - let rows = state - .rows - .lock() - .unwrap() - .iter() - .cloned() - .map(editable_row_view) - .collect(); - let loaded_count = *state.loaded_count.lock().unwrap(); - let infinite_count = *state.infinite_count.lock().unwrap(); - let lazy_loads = *state.lazy_loads.lock().unwrap(); - let progress = *state.progress.lock().unwrap(); - let email = state.email.lock().unwrap().clone(); - let email_status = state.email_status.lock().unwrap().clone(); - let category = state.category.lock().unwrap().clone(); - let value = state.value.lock().unwrap().clone(); - let reset_status = state.reset_status.lock().unwrap().clone(); - let query = query.trim().to_owned(); - let search_results = search_results_for(&query); - Gallery { - contacts, - rows, - lazy_panel: lazy_panel_text(lazy_loads), - loaded_rows: loaded_rows(loaded_count), - load_status: format!("Showing {loaded_count} rows"), - infinite_rows: loaded_rows(infinite_count), - infinite_status: format!("Showing {infinite_count} rows"), - progress, - progress_label: format!("{progress}% complete"), - email, - email_status, - value_options: value_options_for(&category, &value), - reset_status, - query: query.clone(), - search_status: if query.is_empty() { - "Showing all results".into() - } else { - format!("Results for {query}") - }, - search_results, - } -} - -fn lazy_panel_text(loads: usize) -> String { - if loads == 0 { - "Waiting to be revealed".into() - } else { - format!("Lazy content loaded by server update #{loads}") - } -} - -fn is_demo_email(email: &str) -> bool { - let Some((local, domain)) = email.split_once('@') else { - return false; - }; - !local.is_empty() - && domain - .split('.') - .filter(|part| !part.is_empty()) - .take(2) - .count() - >= 2 -} - -fn contact_card_view(record: ContactRecord) -> ContactCard { - ContactCard { - id: Id(record.id), - name: record.name, - email: record.email, - editing: record.editing, - } -} - -fn editable_row_view(record: RowRecord) -> EditableRow { - EditableRow { - id: Id(record.id), - title: record.title, - editing: record.editing, - } -} - -fn loaded_rows(count: usize) -> Vec { - (1..=count) - .map(|id| LoadedRow { - id: Id(id as u64), - title: format!("Loaded row {id}"), - }) - .collect() -} - -fn value_options_for(category: &str, selected: &str) -> Vec { - let values = match category { - "numbers" => [(1, "one", "One"), (2, "two", "Two")], - _ => [(1, "alpha", "Alpha"), (2, "beta", "Beta")], - }; - values - .into_iter() - .map(|(id, value, label)| ValueOption { - id: Id(id), - value: value.into(), - label: label.into(), - selected: value == selected, - }) - .collect() -} - -fn search_results_for(query: &str) -> Vec { - ["Alpha", "Beta", "Gamma", "Delta"] - .into_iter() - .enumerate() - .filter(|(_, label)| { - query.is_empty() || label.to_lowercase().contains(&query.to_lowercase()) - }) - .map(|(index, label)| SearchResult { - id: Id(index as u64 + 1), - label: label.into(), - }) - .collect() -} - -fn query_param(query: Option<&str>, name: &str) -> String { - query - .unwrap_or("") - .split('&') - .filter_map(|pair| pair.split_once('=')) - .find_map(|(key, value)| (key == name).then(|| form_decode(value))) - .unwrap_or_default() -} - -fn form_decode(value: &str) -> String { - let mut bytes = Vec::with_capacity(value.len()); - let mut input = value.as_bytes().iter().copied(); - while let Some(byte) = input.next() { - match byte { - b'+' => bytes.push(b' '), - b'%' => { - let high = input.next().and_then(hex_value); - let low = input.next().and_then(hex_value); - if let (Some(high), Some(low)) = (high, low) { - bytes.push((high << 4) | low); - } - } - byte => bytes.push(byte), - } - } - String::from_utf8_lossy(&bytes).into_owned() -} - -fn hex_value(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -#[hemx::component("gallery")] -mod gallery_handlers { - use super::*; - - #[hemx::handler] - async fn lazy_load( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let _ = input.request; - let mut lazy_loads = state.lazy_loads.lock().unwrap(); - *lazy_loads += 1; - gallery::lazy_panel.set(lazy_panel_text(*lazy_loads)) - } - - #[hemx::handler] - async fn tick_progress( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let _ = input.request; - let mut progress = state.progress.lock().unwrap(); - *progress = (*progress + 25).min(100); - gallery::progress_meter.replace(&ProgressMeter { percent: *progress }) - } - - #[hemx::handler] - async fn choose_category( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let category = if input.category == "numbers" { - "numbers" - } else { - "letters" - } - .to_owned(); - let value = if category == "numbers" { - "one" - } else { - "alpha" - } - .to_owned(); - *state.category.lock().unwrap() = category.clone(); - *state.value.lock().unwrap() = value.clone(); - value_options_for(&category, &value) - .into_iter() - .map(|option| gallery::value_option.replace(option)) - .collect::>() - } - - #[hemx::handler] - async fn reset_message( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let status = if input.message.trim().is_empty() { - "Nothing to send".to_owned() - } else { - format!("Sent: {}", input.message.trim()) - }; - *state.reset_status.lock().unwrap() = status.clone(); - vec![ - gallery::reset_status.set(status), - gallery::reset_message_form.clear(), - ] - } - - #[hemx::handler] - async fn infinite_scroll( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let _ = input.request; - let mut count = state.infinite_count.lock().unwrap(); - let first_new = *count + 1; - *count += 3; - let rows = loaded_rows(*count) - .into_iter() - .filter(|row| row.id.0 >= first_new as u64) - .map(|row| gallery::infinite_row.append(row)) - .collect::>(); - let mut effects = rows; - effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count))); - effects - } - - #[hemx::handler] - async fn validate_email( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let email = input.email.trim().to_owned(); - if !is_demo_email(&email) { - *state.email.lock().unwrap() = email; - *state.email_status.lock().unwrap() = "Email needs a name and dotted domain".into(); - return vec![ - gallery::validate_email_form.focus("email"), - gallery::validate_email_form.error("email", "Use a real email address"), - gallery::email_status.set("Email needs a name and dotted domain"), - ]; - } - *state.email.lock().unwrap() = email.clone(); - *state.email_status.lock().unwrap() = format!("{email} is valid"); - vec![ - gallery::validate_email_form.error("email", ""), - gallery::email_status.set(format!("{email} is valid")), - ] - } - - #[hemx::handler] - async fn load_more( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let _ = input.request; - let mut loaded = state.loaded_count.lock().unwrap(); - let first_new = *loaded + 1; - *loaded += 2; - let rows = loaded_rows(*loaded) - .into_iter() - .filter(|row| row.id.0 >= first_new as u64) - .map(|row| gallery::loaded_row.append(row)) - .collect::>(); - let mut effects = rows; - effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); - effects - } -} - -#[hemx::component("contact_card")] -mod contact_card_handlers { - use super::*; - - #[hemx::handler] - async fn edit_contact( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let mut contacts = state.contacts.lock().unwrap(); - if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) { - contact.editing = true; - return Some(gallery::contact_card.replace(&contact_card_view(contact.clone()))); - } - None - } - - #[hemx::handler] - async fn save_contact( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - if input.name.trim().is_empty() || !input.email.contains('@') { - return Some(contact_card::save_contact_form.focus("name")); - } - let mut contacts = state.contacts.lock().unwrap(); - if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) { - contact.name = input.name; - contact.email = input.email; - contact.editing = false; - return Some(gallery::contact_card.replace(&contact_card_view(contact.clone()))); - } - None - } -} - -#[hemx::component("editable_row")] -mod editable_row_handlers { - use super::*; - - #[hemx::handler] - async fn edit_row( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let mut rows = state.rows.lock().unwrap(); - if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) { - row.editing = true; - return Some(gallery::editable_row.replace(editable_row_view(row.clone()))); - } - None - } - - #[hemx::handler] - async fn save_row( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - if input.title.trim().is_empty() { - return Some(editable_row::save_row_form.focus("title")); - } - let mut rows = state.rows.lock().unwrap(); - if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) { - row.title = input.title; - row.editing = false; - return Some(gallery::editable_row.replace(editable_row_view(row.clone()))); - } - None - } - - #[hemx::handler] - async fn delete_row( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let mut rows = state.rows.lock().unwrap(); - let before = rows.len(); - rows.retain(|row| row.id != input.id.0); - (rows.len() != before).then(|| gallery::editable_row.remove(input.id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx_test::inspect_batch; - - fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm { - hemx_axum::InteractionForm::for_handle( - handle, - fields - .iter() - .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), - ) - } - - #[test] - fn inline_validation_form_uses_input_event_for_revalidation() { - let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state, "").render().expect("render gallery"); - assert!( - html.contains("data-hemx-on=\"input\""), - "inline validation form must listen on input events" - ); - } - - #[test] - fn active_search_is_reconstructed_from_url_query() { - // req: page_swap/009 req: page_swap/010 - let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state, &query_param(Some("q=ga"), "q")) - .render() - .expect("render gallery"); - assert!(html.contains("name=\"q\" value=\"ga\"")); - assert!(html.contains("Results for ga")); - assert!(html.contains("Gamma")); - assert_eq!( - search_results_for("ga") - .into_iter() - .map(|result| result.label) - .collect::>(), - ["Gamma"] - ); - } - - #[test] - fn active_search_form_marks_live_replace_and_submit_push() { - // req: page_swap/009 - let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state, "").render().expect("render gallery"); - assert!(html.contains("method=\"get\"")); - assert!(html.contains("data-hemx-history=\"replace\"")); - assert!(html.contains("data-hemx-on=\"input\"")); - assert!(html.contains("data-hemx-history=\"push\"")); - assert!(!html.contains("data-hemx-handle=\"search\"")); - assert!(!html.contains("data-hemx-form=\"search\"")); - assert!(!html.contains("name=\"__h\"")); - } - - #[test] - fn revealed_forms_preserve_data_hemx_revealed_attribute() { - // req: convention/005 - let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state, "").render().expect("render gallery"); - assert!( - html.contains("data-hemx-revealed=\"true\""), - "lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute" - ); - } - - #[test] - fn readme_maps_every_htmx_example_slug() { - let readme = include_str!("../README.md"); - for slug in [ - "click-to-edit", - "bulk-update", - "click-to-load", - "delete-row", - "edit-row", - "lazy-load", - "inline-validation", - "infinite-scroll", - "active-search", - "progress-bar", - "value-select", - "animations", - "file-upload", - "file-upload-input", - "reset-user-input", - "dialogs", - "modal-uikit", - "modal-bootstrap", - "modal-custom", - "tabs-hateoas", - "tabs-javascript", - "keyboard-shortcuts", - "sortable", - "update-other-content", - "confirm", - "async-auth", - "web-components", - "move-before", - ] { - assert!( - readme.contains(&format!("`{slug}`")), - "missing htmx example slug {slug}" - ); - } - } - - #[tokio::test] - async fn gallery_covers_core_html_patterns_with_generated_resources() { - let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state, "").render().expect("render gallery"); - assert!(html.contains("data-hemx-root=\"gallery\"")); - for slug in [ - "click-to-edit", - "edit-row", - "delete-row", - "lazy-load", - "inline-validation", - "infinite-scroll", - "click-to-load", - "progress-bar", - "value-select", - "reset-user-input", - "active-search", - ] { - assert!( - html.contains(&format!("data-htmx-example=\"{slug}\"")), - "missing exact htmx slug {slug}" - ); - } - assert!(html.contains("data-hemx-form=\"validate_email\"")); - assert!(html.contains("data-hemx-revealed=\"true\"")); - assert!(html.contains("data-hemx-handle=\"tick_progress\"")); - assert!(!html.contains("data-hemx-interval=\"1000\"")); - assert!(html.contains("0% complete")); - assert!(html.contains("data-hemx-slot=\"loaded_row\"")); - assert!(html.contains("data-hemx-slot=\"infinite_row\"")); - assert!(html.contains("data-hemx-slot=\"value_option\"")); - assert!(html.contains("data-hemx-slot=\"search_result\"")); - - let edit = inspect_batch( - InteractionRequest::from(form(contact_card::edit_contact, &[("id", "1")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - // Component-level rendering now lowers contact_card-local handles, so the - // effect contains the lowered handle id, not the source name. - edit.assert_updates_html_containing( - gallery::contact_card, - &format!("data-hid=\"{}\"", contact_card::save_contact), - ); - - let save = inspect_batch( - InteractionRequest::from(form( - contact_card::save_contact, - &[("id", "1"), ("name", "Ada"), ("email", "ada@hemx.test")], - )) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - save.assert_updates_html_containing(gallery::contact_card, "ada@hemx.test"); - - let lazy = inspect_batch( - InteractionRequest::from(form(gallery::lazy_load, &[("request", "lazy")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - lazy.assert_updates_text_containing( - gallery::lazy_panel, - "Lazy content loaded by server update #1", - ); - - let more = inspect_batch( - InteractionRequest::from(form(gallery::load_more, &[("request", "more")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(more.payload_contains("Loaded row 3")); - assert!(more.payload_contains("Loaded row 4")); - assert!(more.updates_text(gallery::load_status)); - - let invalid = inspect_batch( - InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(invalid.payload_contains("Use a real email address")); - assert!(invalid.payload_contains("Email needs a name and dotted domain")); - assert!(invalid.updates_text(gallery::email_status)); - - let partial = inspect_batch( - InteractionRequest::from(form(gallery::validate_email, &[("email", "xyz@")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(partial.payload_contains("Email needs a name and dotted domain")); - assert!(!partial.payload_contains("xyz@ is valid")); - - let valid = inspect_batch( - InteractionRequest::from(form( - gallery::validate_email, - &[("email", "xyz@example.com")], - )) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(valid.payload_contains("xyz@example.com is valid")); - assert!(!valid.payload_contains("Use a real email address")); - assert!(!valid.payload_contains("hemx:form-reset")); - assert!(valid.updates_text(gallery::email_status)); - - let infinite = inspect_batch( - InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(infinite.payload_contains("Loaded row 4")); - assert!(infinite.payload_contains("Loaded row 6")); - - let progress = inspect_batch( - InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(progress.payload_contains("25% complete")); - - let values = inspect_batch( - InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(values.replaces_keyed_html_containing(gallery::value_option, "1", "One")); - - let reset = inspect_batch( - InteractionRequest::from(form(gallery::reset_message, &[("message", "hello")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(reset.updates_text(gallery::reset_status)); - assert!(reset.resets_form(gallery::reset_message_form)); - - let load = inspect_batch( - InteractionRequest::from(form(gallery::load_more, &[("request", "more")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(load.payload_contains("Loaded row 6")); - - let row_save = inspect_batch( - InteractionRequest::from(form( - editable_row::save_row, - &[("id", "1"), ("title", "Write dynamic HTML")], - )) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(row_save.replaces_keyed_html_containing( - gallery::editable_row, - "1", - "Write dynamic HTML" - )); - - let delete = inspect_batch( - InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")])) - .dispatch_async(handlers(state)) - .await - .unwrap() - .batch, - ); - assert!(delete.removes_key(gallery::editable_row, "1")); - } -} diff --git a/examples/html_examples/templates/app.css b/examples/html_examples/templates/app.css deleted file mode 100644 index a52f706..0000000 --- a/examples/html_examples/templates/app.css +++ /dev/null @@ -1,124 +0,0 @@ -:root { - --bg: #f5f5f5; - --surface: #ffffff; - --ink: #222222; - --muted: #666666; - --border: #d4d4d4; - --accent: #3465a4; - --accent-hover: #29528a; - --danger: #c0392b; - --danger-hover: #a93226; - --radius: 6px; - --space: 1.25rem; - --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; -} -* { box-sizing: border-box; } -body { - margin: 0; - padding: var(--space); - font-family: var(--font-body); - background: var(--bg); - color: var(--ink); - line-height: 1.55; -} -main { - max-width: 820px; - margin: 0 auto; -} -header { - margin-bottom: calc(var(--space) * 1.5); -} -header p:first-child { - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.75rem; - color: var(--muted); - margin: 0 0 0.25rem; -} -h1 { - font-family: var(--font-mono); - font-size: 1.75rem; - margin: 0 0 0.5rem; -} -header p:last-child { - color: var(--muted); - margin: 0; -} -section { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: calc(var(--space) * 1.25); - margin-bottom: var(--space); -} -section h2 { - font-family: var(--font-mono); - font-size: 1.15rem; - margin: 0 0 var(--space); - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border); -} -p { margin: 0 0 var(--space); } -form { margin: 0 0 var(--space); } -label { font-weight: 500; } -article label { display: block; margin-bottom: 0.75rem; } -article label input { display: block; width: 100%; margin-top: 0.3rem; } -input, select, button { - font: inherit; - padding: 0.45rem 0.65rem; - border-radius: var(--radius); - border: 1px solid var(--border); -} -input, select { width: 100%; max-width: 360px; } -input:focus, select:focus, button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -button { - background: var(--accent); - color: #fff; - border-color: var(--accent); - cursor: pointer; - font-weight: 500; -} -button:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); -} -[data-hemx-handle="delete_row"] button, -[data-hemx-handle*="delete"] button, -.danger { - background: var(--danger); - border-color: var(--danger); -} -[data-hemx-handle="delete_row"] button:hover, -[data-hemx-handle*="delete"] button:hover, -.danger:hover { - background: var(--danger-hover); - border-color: var(--danger-hover); -} -td form { display: inline-block; margin-right: 0.4rem; } -table { - width: 100%; - border-collapse: collapse; - margin: var(--space) 0; -} -th, td { - text-align: left; - padding: 0.5rem; - border-bottom: 1px solid var(--border); -} -th { font-weight: 600; color: var(--muted); } -ul { padding-left: 1.25rem; margin: 0 0 var(--space); } -progress { - width: 100%; - height: 1rem; - accent-color: var(--accent); -} -[data-hemx-error-for] { - color: var(--danger); - font-size: 0.9rem; - margin: 0.25rem 0 0; -} -.htmx-indicator { opacity: 0.5; } diff --git a/examples/html_examples/templates/app_shell.heml b/examples/html_examples/templates/app_shell.heml deleted file mode 100644 index 903cae8..0000000 --- a/examples/html_examples/templates/app_shell.heml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - hemx HTML examples - - - - - {+= self.body =+} - - diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml deleted file mode 100644 index 74aca36..0000000 --- a/examples/html_examples/templates/gallery.heml +++ /dev/null @@ -1,130 +0,0 @@ -
    -
    -

    Copy-pasteable hemx HTML patterns

    -

    HTML UX pattern gallery

    -

    Server-owned Rust state, boring .heml, generated resources, and tiny runtime behavior.

    -
    - -
    -

    click-to-edit

    -
    - -
    -
    - -
    -

    edit-row

    -

    delete-row uses the same keyed row partial and a generated remove effect.

    - - - - - - - -
    TaskActions
    -
    - -
    -

    inline-validation

    -
    - - -

    -

    {+ self.email_status +}

    -
    -
    - -
    -

    lazy-load

    -
    - - -
    -
    {+ self.lazy_panel +}
    -
    - -
    -

    click-to-load

    -
      - -
    -
    - - -
    -

    {+ self.load_status +}

    -
    - -
    -

    infinite-scroll

    -
      - -
    -
    - - -
    -

    {+ self.infinite_status +}

    -
    - -
    -

    progress-bar

    -
    - - -
    -

    - {+ self.progress_label +} -

    -
    - -
    -

    value-select

    -
    - - -
    - -
    - -
    -

    reset-user-input

    -
    - - -
    -

    {+ self.reset_status +}

    -
    - - -
    diff --git a/examples/html_examples/templates/partials/contact_card.heml b/examples/html_examples/templates/partials/contact_card.heml deleted file mode 100644 index a54d65b..0000000 --- a/examples/html_examples/templates/partials/contact_card.heml +++ /dev/null @@ -1,15 +0,0 @@ -
    -
    -

    {+ self.name +}

    -

    {+ self.email +}

    -
    - -
    -
    -
    - - - - -
    -
    diff --git a/examples/html_examples/templates/partials/editable_row.heml b/examples/html_examples/templates/partials/editable_row.heml deleted file mode 100644 index 943de4f..0000000 --- a/examples/html_examples/templates/partials/editable_row.heml +++ /dev/null @@ -1,14 +0,0 @@ - - {+ self.title +} - -
    -
    - - -
    - - - -
    - - diff --git a/examples/html_examples/templates/partials/loaded_row.heml b/examples/html_examples/templates/partials/loaded_row.heml deleted file mode 100644 index ff9ac98..0000000 --- a/examples/html_examples/templates/partials/loaded_row.heml +++ /dev/null @@ -1 +0,0 @@ -
  • {+ self.title +}
  • diff --git a/examples/html_examples/templates/partials/search_result.heml b/examples/html_examples/templates/partials/search_result.heml deleted file mode 100644 index bb9938c..0000000 --- a/examples/html_examples/templates/partials/search_result.heml +++ /dev/null @@ -1 +0,0 @@ -
  • {+ self.label +}
  • diff --git a/examples/html_examples/templates/partials/value_option.heml b/examples/html_examples/templates/partials/value_option.heml deleted file mode 100644 index e89abea..0000000 --- a/examples/html_examples/templates/partials/value_option.heml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/kanban.md b/examples/kanban.md deleted file mode 100644 index 110f2b2..0000000 --- a/examples/kanban.md +++ /dev/null @@ -1,343 +0,0 @@ -# Milestone: Local-first Multiplayer Kanban - -A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, -offline queue, conflict reconciliation, live presence, and SSR-first rendering — -all without React/Vue/VDOM, in a single typed Rust codebase. - -This is an explicitly advanced/low-level north-star boundary sketch for hemx + hemplate + hemx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design. req: milestone/001 req: milestone/002 req: milestone/003 - ---- - -## 1. Template: `board.heml` - -```html -
    -
    -

    {+ self.title +}

    - -
    - - - -
    -
    - -
    - -
    - - -
    -``` - -Notes on keyed scopes: - -- `h-for="column in &self.columns" h-key="column.id"` — **required** for hemx-addressable nodes inside -- `h-for="card in &column.cards" h-key="card.id"` — **required** -- A slot inside a keyed loop is addressed as a generated keyed resource, never by selector strings or positional DOM targeting -- Without `h-key`, hemx rejects the build — no runtime selector fallback - ---- - -## 2. What hemplate exports - -hemplate does **not** interpret `data-hemx-*`. It records raw facts: - -```rust -Node { - id: NodeId(12), - element: "article", - attrs: [ - ("class", "card"), - ("data-hemx-slot", "card"), - ("data-hemx-handle", "drag_card"), - ("data-card-id", "{card.id}"), - ("draggable", "true"), - ], - scope: ScopeId(For { binding: "card", key_expr: "card.id" }), -} - -FormSurface { - handle_attr: Some("create_card"), - controls: [ - Control { name: "title", kind: Text, required: true }, - Control { name: "column", kind: Select, required: true }, - ], -} -``` - -hemx reads this from hemplate Surface facts and generates scoped typed resources: - -```rust -use ui::board::{forms, targets}; - -targets::card.replace(card.id, &CardView::from(card)); -forms::create_card.clear("title"); -ui::page(&BoardView::from(board)); -``` - -No string desync. No manual ids. The generated module owns the names. - ---- - -## 3. App State - -```rust -#[hemx::app] -pub struct BoardApp { - pub board: Atom, - pub drag: Atom>, - pub online_users: Atom>, -} -``` - -The same struct runs on server (SSR) and in WASM (client-local effects). - ---- - -## 4. Normal Form: Server-first - -```rust -#[derive(HemxForm)] -pub struct CreateCardForm { - pub title: String, - pub column: ColumnId, -} - -#[hemx::handler] -pub fn create_card( - form: Form, - app: &mut BoardApp, -) -> impl IntoEffect { - let card = Card { id: CardId::new(), title: form.title, assignee: "Thomas".into() }; - - app.board.update(|board| board.insert_card(form.column, card.clone())); - - ( - targets::card.append(card.id, &CardView::from(card)), - forms::create_card.clear("title"), - // hemx-sync: queue atomic board state diff for sync - SyncEffect::send_patch(atoms::board, Patch::insert_card(form.column, card)), - ) -} -``` - -HTML submits as usual. Server returns a typed update batch. Browser applies DOM ops. - ---- - -## 5. Drag: 60fps client-local WASM - -```rust -#[hemx::handler(client)] -pub fn drag_card( - event: DragEvent, - app: &mut BoardApp, -) -> impl IntoEffect { - app.drag.set(Some(DragState { - card_id: event.card_id, - from_column: event.column_id, - pointer_x: event.x, - pointer_y: event.y, - })); - - // Client-local extension APIs stay typed by generated resources; - // names below are illustrative until hemx-sync lands. - Effect::batch(( - Effect::class_keyed(slots::CARD, event.card_id, "dragging", true), - Effect::transform_keyed( - slots::CARD, - event.card_id, - Transform::translate(event.x, event.y), - ), - )) -} -``` - -Zero round-trip. Zero custom JS. Pure Rust → typed updates → DOM. - ---- - -## 6. Drop: optimistic update + sync - -```rust -#[hemx::handler(client)] -pub fn drop_card( - event: DropEvent, - app: &mut BoardApp, -) -> impl IntoEffect { - let patch = app.board.update(|board| { - board.move_card(event.card_id, event.to_column, event.before_card) - }); - - app.drag.set(None); - - // Client-local extension APIs stay typed by generated resources; - // names below are illustrative until hemx-sync lands. - Effect::batch(( - Effect::move_keyed( - slots::CARD, - event.card_id, - slots::COLUMN, - event.to_column, - InsertBefore(event.before_card), - ), - Effect::class_keyed(slots::CARD, event.card_id, "dragging", false), - // hemx-sync: queue patch, send when online - SyncEffect::send_patch(atoms::BOARD, patch), - )) -} -``` - -A pure htmx+SSR app cannot model this: 60fps pointer → local transient drag → optimistic update → offline queue → reconciliation. You'd need custom JS or a parallel React/Vue layer. - -hemx models it in one type graph. - ---- - -## 7. Server reconciliation - -```rust -#[hemx_sync::handler] -pub fn apply_board_patch( - patch: BoardPatch, - app: &mut BoardApp, - user: UserId, -) -> impl IntoEffect { - let result = app.board.update(|board| board.apply_patch_from(user, patch)); - - match result { - PatchResult::Accepted { changed_cards } => Effect::batch(( - Effect::ack(atoms::BOARD), - Effect::broadcast( - Channel::Board(app.board.id()), - Effect::batch(changed_cards.into_iter().map(|c| - targets::card.replace(c.id, &CardView::from(c)) - )), - ), - )), - - PatchResult::Conflict { canonical_board } => Effect::batch(( - Effect::set(atoms::BOARD, canonical_board.clone()), - targets::board.put(&BoardView::from(canonical_board)), - )), - } -} -``` - -Server-authoritative on conflict. No Redux sagas. No React Query cache fades. - ---- - -## 8. Presence - -```rust -#[hemx_sync::presence] -pub fn user_joined(user: UserPresence) -> impl IntoEffect { - targets::presence_user.append(user.id, &PresenceBadge::from(user)) -} -``` - -Browser receives typed update bytes over WebSocket/SSE: - -```text -append keyed presence user -remove keyed presence user -``` - -The runtime does not know "presence". It executes generated DOM updates. - ---- - -## 9. What app authors write; what the browser receives - -Initial SSR stays an ordinary rendered template with symbolic hemx attributes at -the authoring boundary: - -```html -
    - ... -
    - {+ card.title +} -
    - ... -
    - -``` - -The compiler lowers those symbols to compact runtime metadata, but that metadata -is not an app-authoring contract. Runtime attachment: the helper-provided runtime -asset installs delegated root listeners for forms, clicks, and pointer/drag -events. App authors keep composing generated resources; they do not attach -per-node listeners, copy numeric ids, or write selector glue. - -No framework download. No VDOM. No hydration. No game loop. - ---- - -## 10. Why this is not a React/Vue/htmx app - -| Concern | React/Vue | htmx+SSR | hemx | -|---|---|---|---| -| SSR | RSC/Vue SSR | native | native (hemplate) | -| 60fps drag | 100ms re-render + React-DnD | custom JS | WASM handler, typed update | -| Optimistic update | useOptimistic | impossible | `board.update` → `SyncEffect::send_patch` | -| Offline support | Service Worker + custom | impossible | patch queue in `hemx-sync` | -| Conflict resolution | manual / Yjs CRDT | impossible | server-authoritative patch | -| Presence | WebSocket + custom state | SSE possible | `Effect::broadcast` over channel | -| Keyed DOM | React key | not a concern | `KeyedSlot` compile-time | -| Forms | React Hook Form | HTML native, but no validation bridge | `Form` derived from `.heml` surface | -| Routing | React Router / Vue Router | HTML links, but no state routing | `Effect::navigate` with scroll/title | -| Total JS shipped | ~300KB+ | ~20KB htmx + custom | ~3KB hemx.js interpreter | - ---- - -## 11. The claim - -```text -A local-first multiplayer board where all high-frequency UI runs as Rust/WASM effects, -all durable state syncs through hemx-sync, -all HTML is hemplate-rendered, -and the browser runtime only executes typed postcard DOM ops. -``` - -Not: - -```text -server Rust here -client TypeScript there -shared schema somewhere -validation duplicated -DOM identity by positional DOM lookup -state sync by convention -``` - -But: - -```text -Rust owns types. -hemplate owns structure. -hemx owns interaction. -browser executes ops. -``` diff --git a/examples/kanban/Cargo.toml b/examples/kanban/Cargo.toml deleted file mode 100644 index 1815297..0000000 --- a/examples/kanban/Cargo.toml +++ /dev/null @@ -1,47 +0,0 @@ -[package] -name = "hemx-kanban-example" -version.workspace = true -edition.workspace = true -publish = false - -[features] -default = ["server"] -server = ["dep:axum", "dep:futures-util", "dep:hemx-axum", "dep:serde", "dep:serde_json", "dep:tokio"] -client = ["hemx/client"] -fixture = [] - -[lib] -path = "src/lib.rs" -crate-type = ["cdylib", "rlib"] - -[[bin]] -name = "hemx-kanban-example" -path = "src/main.rs" -required-features = ["server"] - -[[bin]] -name = "client-fixture" -path = "src/bin/client_fixture.rs" -required-features = ["fixture"] - -[dependencies] -axum = { version = "0.8", optional = true } -futures-util = { version = "0.3", optional = true } -hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum", optional = true } -hemx-sync = { path = "../../hemx-sync" } -serde = { version = "1", features = ["derive"], optional = true } -serde_json = { version = "1", optional = true } -tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "time"], optional = true } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -hemplate = { path = "../../../hemplate/hemplate" } - -[dev-dependencies] -hemx-test = { path = "../../hemx-test" } -scraper = "0.25" -thirtyfour = "0.35" -tower = { version = "0.5", features = ["util"] } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/kanban/README.md b/examples/kanban/README.md deleted file mode 100644 index 4745c31..0000000 --- a/examples/kanban/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# hemx Kanban advanced milestone example - -This is an explicitly advanced/low-level north-star boundary sketch, not beginner-facing guidance. It exercises the product boundary described in `../kanban.md`; use `examples/v0` for the canonical beginner path. - -Run: - - cargo run -p hemx-kanban-example - -Open . - -The example is a server-first Kanban board with: - -- add-card form -- move-left / move-right card controls -- delete-card controls -- generated target objects for checked slot updates -- tuple-composed `IntoEffect` responses -- SSE presence updates - -It intentionally uses buttons instead of custom JavaScript drag-and-drop; drag/local-first sync remain north-star features in `examples/kanban.md`. diff --git a/examples/kanban/build.rs b/examples/kanban/build.rs deleted file mode 100644 index 99fa6f3..0000000 --- a/examples/kanban/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - hemx_build::app().run().unwrap(); -} diff --git a/examples/kanban/src/bin/client_fixture.rs b/examples/kanban/src/bin/client_fixture.rs deleted file mode 100644 index 1a73774..0000000 --- a/examples/kanban/src/bin/client_fixture.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", hemx_kanban_example::render_client_fixture()); -} diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs deleted file mode 100644 index 7622bb2..0000000 --- a/examples/kanban/src/lib.rs +++ /dev/null @@ -1,243 +0,0 @@ -#[hemx::surface] -pub mod ui {} - -#[cfg(feature = "client")] -use hemx_sync::SyncEffect as DurableSync; - -#[cfg(feature = "client")] -#[derive(Clone, Debug, Eq, PartialEq)] -struct CardId(String); - -#[cfg(feature = "client")] -struct ReorderCommand { - card: CardId, - input_kind: String, -} - -#[cfg(feature = "client")] -struct CardReordered { - card: CardId, - input_kind: String, -} - -#[cfg(feature = "client")] -struct BoardProjection { - first: CardId, -} - -#[cfg(feature = "client")] -struct ProjectedReorder { - card: CardId, - before: Option, - input_kind: String, -} - -#[cfg(feature = "client")] -impl ReorderCommand { - fn from_client(event: hemx::wasm::ClientEvent) -> Self { - Self { - card: CardId( - event - .value - .filter(|card| !card.is_empty()) - .unwrap_or_else(|| "1".into()), - ), - input_kind: event.kind, - } - } - - fn decide(self) -> CardReordered { - CardReordered { - card: self.card, - input_kind: self.input_kind, - } - } -} - -#[cfg(feature = "client")] -impl BoardProjection { - fn restore(state: hemx::wasm::ClientState) -> Self { - Self { - first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()), - } - } - - fn apply(self, event: CardReordered) -> ProjectedReorder { - let before = (event.card != self.first).then_some(self.first); - ProjectedReorder { - card: event.card, - before, - input_kind: event.input_kind, - } - } -} - -#[cfg(feature = "client")] -#[hemx::handler(client)] -pub fn reorder_card( - event: hemx::wasm::ClientEvent, - state: hemx::wasm::ClientState, -) -> impl hemx::IntoEffect { - let projected = - BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide()); - let card = projected.card.0; - let patch = hemx_sync::FlatPatch::for_interaction( - "cardColumn", - hemx_sync::PatchValue::String("done".to_owned()), - ) - .expect("generated Kanban patch is valid"); - let move_effect = match projected.before { - Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0), - None => ui::client_board::client_cards.move_to_end(card.clone()), - }; - DurableSync::durable( - patch, - ( - move_effect, - ui::client_board::client_notice - .text(format!("Moved {card} with {}", projected.input_kind)), - ), - ui::BUILD_FINGERPRINT, - ) -} - -#[cfg(all(test, feature = "client"))] -mod client_tests { - use super::*; - use hemx::IntoEffect; - - #[test] - fn client_reorder_carries_flat_patch_in_ordinary_effect_batch() { - let batch = reorder_card( - hemx::wasm::ClientEvent { - kind: "drop".to_owned(), - value: None, - checked: None, - key: None, - }, - hemx::wasm::ClientState { - encoded: "1|2".to_owned(), - }, - ) - .into_batch(ui::BUILD_FINGERPRINT); - assert_eq!(batch.ops.len(), 3); - let wire = String::from_utf8_lossy(&batch.to_wire()).into_owned(); - assert!(wire.contains(hemx_sync::PATCH_EVENT)); - assert!(wire.contains("$hemx-interaction")); - assert!(wire.contains("\"projection\":[")); - } -} - -#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] -mod fixture { - use super::ui; - use hemplate::Hemplate; - use hemx::Html; - - #[derive(Hemplate)] - struct ClientBoard { - cards: Vec, - } - - #[derive(Hemplate)] - struct ClientCard { - id: u64, - title: &'static str, - } - - pub fn render() -> Html { - ui::client_board::page(&ClientBoard { - cards: vec![ - ClientCard { - id: 1, - title: "First", - }, - ClientCard { - id: 2, - title: "Second", - }, - ], - }) - } -} - -#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] -pub fn render_client_fixture() -> hemx::Html { - fixture::render() -} - -#[cfg(test)] -mod tests { - use super::ui::{board, board_card}; - use hemplate::Hemplate; - use hemx::IntoEffect; - use hemx_test::inspect; - use scraper::{Html, Selector}; - - #[derive(Hemplate)] - #[hemplate = "partials"] - struct BoardColumns { - columns: Vec, - } - - #[allow(dead_code)] - #[derive(Clone, Debug)] - #[hemx::form("create_card")] - struct CreateCard { - title: String, - column: String, - } - - // req: examples/001 req: codegen/002 req: list/003 - #[test] - fn kanban_board_updates_generated_slot() { - fn render_board() -> impl IntoEffect { - board::board.put(&empty_board()) - } - - let effect = inspect(render_board()); - assert!(effect.updates_html(board::board)); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn kanban_board_test_payload_is_rendered_by_a_hemplate_view() { - let html = super::ui::page(&empty_board()); - let document = Html::parse_fragment(html.as_str()); - assert_eq!(document.select(&selector(".columns")).count(), 1); - } - - fn empty_board() -> BoardColumns { - // req: html_safety/002 req: view/001 - BoardColumns { - columns: Vec::new(), - } - } - - fn selector(value: &str) -> Selector { - Selector::parse(value).expect("test selector parses") - } - - // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 - #[test] - fn kanban_form_handler_is_checked_against_hemplate_form() { - #[hemx::handler] - fn create_card(_form: hemx::Form) -> impl IntoEffect { - board::notice.text("queued") - } - - let effect = inspect(create_card(CreateCard::FORM)); - - assert!(effect.updates_text(board::notice)); - } - - // req: examples/001 req: form/002 req: codegen/003 - #[test] - fn kanban_template_exports_form_and_card_handles() { - assert_ne!(board::create_card.id(), board_card::move_right.id()); - assert_eq!( - board::create_card_form.field("title").resource, - board::create_card_form.id() - ); - } -} diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs deleted file mode 100644 index 63e88fc..0000000 --- a/examples/kanban/src/main.rs +++ /dev/null @@ -1,1409 +0,0 @@ -use axum::extract::{Form, Query, Request, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::middleware::{self, Next}; -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::response::{IntoResponse, Redirect, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use futures_util::{stream, StreamExt}; -use hemplate::Hemplate; -use hemx::{Html, IntoEffect}; -use hemx_axum::{ - interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection, - EffectResponse, InteractionRequest, PageRequest, PageResponse, -}; -use hemx_kanban_example::ui::board::{self as board}; -use hemx_kanban_example::ui::board_card as card_board; -use hemx_kanban_example::ui::{self, board as board_ui}; -use hemx_sync::{ - Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect as FrameworkSync, -}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::convert::Infallible; -use std::fs::{self, OpenOptions}; -use std::future::Future; -use std::io::Write; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")]; -const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT: usize = 64; -const ORDINARY_HANDLER_TIMEOUT: Duration = Duration::from_secs(10); -const STARTUP_REPLAY_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_SYNC_STORE_BYTES: usize = 1024 * 1024; -const ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); -const ACKNOWLEDGEMENT_RECONNECT_BACKOFF: [Duration; 3] = [ - Duration::from_millis(100), - Duration::from_millis(250), - Duration::from_millis(500), -]; - -#[derive(Default)] -struct AppState { - board: Mutex, - sync: Mutex, - sync_store: Option, - sync_sessions: SyncSessionTokens, - acknowledgement_heartbeat_interval: Duration, - presence: Mutex>, -} - -#[derive(Default)] -struct SyncSessionTokens { - alice_alpha_editor: Option, - bob_alpha_viewer: Option, - carol_beta_editor: Option, -} - -impl SyncSessionTokens { - fn from_env() -> Self { - fn token(name: &str) -> Option { - std::env::var(name).ok().filter(|value| !value.is_empty()) - } - Self { - alice_alpha_editor: token("HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR"), - bob_alpha_viewer: token("HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER"), - carol_beta_editor: token("HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR"), - } - } - - fn is_configured(&self) -> bool { - self.alice_alpha_editor.is_some() - || self.bob_alpha_viewer.is_some() - || self.carol_beta_editor.is_some() - } - - fn matches(expected: &Option, candidate: &str) -> bool { - let Some(expected) = expected else { - return false; - }; - if expected.len() != candidate.len() { - return false; - } - expected - .bytes() - .zip(candidate.bytes()) - .fold(0_u8, |difference, (left, right)| { - difference | (left ^ right) - }) - == 0 - } -} - -#[derive(Clone)] -struct SyncStore(PathBuf); - -#[derive(Default)] -struct SyncState { - next_sequence: u64, - acknowledgements: BTreeMap, - retained_after: u64, - reconnects: BTreeMap, - transient_failure_limit: u8, - transient_failures: BTreeMap, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct CommandId(String); - -impl CommandId { - fn parse(value: Option<&String>) -> Result { - Self::parse_str(value.map(String::as_str).unwrap_or_default()) - } - - fn parse_str(value: &str) -> Result { - if value.is_empty() - || value.len() > 128 - || !value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.') - }) - { - return Err(SyncRejection::BadRequest("invalid command_id")); - } - Ok(Self(value.to_owned())) - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -enum CanonicalColumn { - Todo, - Doing, - Done, -} - -impl CanonicalColumn { - fn parse(value: Option<&String>) -> Result { - match value.map(String::as_str).unwrap_or("done") { - "todo" => Ok(Self::Todo), - "doing" => Ok(Self::Doing), - "done" => Ok(Self::Done), - _ => Err(SyncRejection::BadRequest("invalid column")), - } - } - - fn index(self) -> usize { - match self { - Self::Todo => 0, - Self::Doing => 1, - Self::Done => 2, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct SyncAcknowledgement { - command_id: String, - server_sequence: u64, - card_id: u64, - canonical_column: CanonicalColumn, - status: &'static str, - #[serde(skip_serializing)] - tenant: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SyncSnapshot { - schema_version: u8, - server_sequence: u64, - cards: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SnapshotCard { - id: u64, - column: CanonicalColumn, -} - -#[derive(Debug)] -enum SyncRejection { - BadRequest(&'static str), - Unauthorized(&'static str), - Forbidden(&'static str), - Conflict(&'static str), - Transient, - Storage, -} - -impl IntoResponse for SyncRejection { - fn into_response(self) -> axum::response::Response { - let (status, kind, error) = match self { - Self::BadRequest(error) => (StatusCode::BAD_REQUEST, "invalid-command", error), - Self::Unauthorized(error) => (StatusCode::UNAUTHORIZED, "authorization-denial", error), - Self::Forbidden(error) => (StatusCode::FORBIDDEN, "authorization-denial", error), - Self::Conflict(error) => (StatusCode::CONFLICT, "command-conflict", error), - Self::Transient => ( - StatusCode::SERVICE_UNAVAILABLE, - "transport-failure", - "transient sync failure", - ), - Self::Storage => ( - StatusCode::INTERNAL_SERVER_ERROR, - "storage-failure", - "sync storage failed", - ), - }; - ( - status, - Json(serde_json::json!({ "kind": kind, "error": error })), - ) - .into_response() - } -} - -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PersistedSync { - schema_version: u8, - next_sequence: u64, - acknowledgements: Vec, -} - -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PersistedAcknowledgement { - command_id: String, - server_sequence: u64, - card_id: u64, - canonical_column: CanonicalColumn, - #[serde(default)] - tenant: Option, -} - -impl SyncStore { - async fn load(&self) -> Result { - if !self.0.exists() { - return Ok(SyncState { - next_sequence: 1, - ..SyncState::default() - }); - } - let bytes = run_with_timeout( - STARTUP_REPLAY_TIMEOUT, - "startup sync-store read/replay", - tokio::fs::read(&self.0), - ) - .await? - .map_err(|error| format!("read {}: {error}", self.0.display()))?; - if bytes.len() > MAX_SYNC_STORE_BYTES { - return Err(format!( - "sync store {} exceeds {MAX_SYNC_STORE_BYTES} bytes", - self.0.display() - )); - } - let persisted: PersistedSync = serde_json::from_slice(&bytes) - .map_err(|error| format!("decode {}: {error}", self.0.display()))?; - if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 { - return Err(format!("unsupported sync store {}", self.0.display())); - } - let mut acknowledgements = BTreeMap::new(); - for stored in persisted.acknowledgements { - let command_id = CommandId::parse_str(&stored.command_id) - .map_err(|_| format!("invalid command id in {}", self.0.display()))?; - if stored.server_sequence == 0 || stored.card_id == 0 { - return Err(format!("invalid acknowledgement in {}", self.0.display())); - } - let tenant = if persisted.schema_version == 1 { - "demo".to_owned() - } else { - stored - .tenant - .filter(|tenant| matches!(tenant.as_str(), "demo" | "alpha" | "beta")) - .ok_or_else(|| { - format!("invalid acknowledgement tenant in {}", self.0.display()) - })? - }; - let acknowledgement = SyncAcknowledgement { - command_id: stored.command_id, - server_sequence: stored.server_sequence, - card_id: stored.card_id, - canonical_column: stored.canonical_column, - status: "accepted", - tenant, - }; - if acknowledgements - .insert(command_id, acknowledgement) - .is_some() - { - return Err(format!("duplicate command id in {}", self.0.display())); - } - } - Ok(SyncState { - next_sequence: persisted.next_sequence, - acknowledgements, - ..SyncState::default() - }) - } - - fn persist( - &self, - sync: &SyncState, - acknowledgement: &SyncAcknowledgement, - ) -> Result<(), String> { - let mut acknowledgements = sync - .acknowledgements - .values() - .map(PersistedAcknowledgement::from) - .collect::>(); - acknowledgements.push(PersistedAcknowledgement::from(acknowledgement)); - acknowledgements.sort_by_key(|item| item.server_sequence); - let persisted = PersistedSync { - schema_version: 2, - next_sequence: sync.next_sequence + 1, - acknowledgements, - }; - let bytes = serde_json::to_vec_pretty(&persisted) - .map_err(|error| format!("encode {}: {error}", self.0.display()))?; - if let Some(parent) = self.0.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("create {}: {error}", parent.display()))?; - } - let temporary = self.0.with_extension("tmp"); - let mut file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&temporary) - .map_err(|error| format!("open {}: {error}", temporary.display()))?; - file.write_all(&bytes) - .and_then(|()| file.sync_all()) - .map_err(|error| format!("write {}: {error}", temporary.display()))?; - fs::rename(&temporary, &self.0) - .map_err(|error| format!("replace {}: {error}", self.0.display()))?; - if let Some(parent) = self.0.parent() { - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("sync {}: {error}", parent.display()))?; - } - Ok(()) - } -} - -impl From<&SyncAcknowledgement> for PersistedAcknowledgement { - fn from(value: &SyncAcknowledgement) -> Self { - Self { - command_id: value.command_id.clone(), - server_sequence: value.server_sequence, - card_id: value.card_id, - canonical_column: value.canonical_column, - tenant: Some(value.tenant.clone()), - } - } -} - -#[derive(Default, Clone)] -struct BoardState { - next_id: u64, - cards: Vec, -} - -#[derive(Clone)] -struct Card { - id: u64, - title: String, - column: usize, -} - -#[derive(Hemplate)] -struct AppShell { - runtime_src: &'static str, - body: Html, -} - -#[derive(Hemplate)] -struct LegacySyncFixture { - runtime_src: &'static str, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct BoardColumns { - columns: Vec, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct BoardColumn { - title: &'static str, - cards: Vec, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct BoardCard { - id: u64, - title: String, - left_disabled: bool, - right_disabled: bool, -} - -#[derive(Hemplate)] -struct Board { - options: Html, - board: Html, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct ColumnOptions { - options: Vec, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct ColumnOption { - id: &'static str, - title: &'static str, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct Presence { - count: u64, -} - -async fn run_with_timeout( - duration: Duration, - operation: &'static str, - future: F, -) -> Result -where - F: Future, -{ - tokio::time::timeout(duration, future) - .await - .map_err(|_| format!("{operation} timed out after {} ms", duration.as_millis())) -} - -async fn bounded_handler(duration: Duration, request: Request, next: Next) -> Response { - match run_with_timeout(duration, "ordinary request", next.run(request)).await { - Ok(response) => response, - Err(message) => (StatusCode::GATEWAY_TIMEOUT, message).into_response(), - } -} - -async fn ordinary_handler_timeout(request: Request, next: Next) -> Response { - bounded_handler(ORDINARY_HANDLER_TIMEOUT, request, next).await -} - -#[tokio::main] -async fn main() { - let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE") - .map(PathBuf::from) - .map(SyncStore); - let mut sync = if let Some(store) = sync_store.as_ref() { - store - .load() - .await - .unwrap_or_else(|error| panic!("cannot start with sync store: {error}")) - } else { - SyncState { - next_sequence: 1, - ..SyncState::default() - } - }; - sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or_default(); - sync.transient_failure_limit = std::env::var("HEMX_KANBAN_SYNC_FAILURES") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or_else(|| u8::from(std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some())); - let mut board = initial_board(); - for acknowledgement in sync.acknowledgements.values() { - if let Some(card) = board - .cards - .iter_mut() - .find(|card| card.id == acknowledgement.card_id) - { - card.column = acknowledgement.canonical_column.index(); - } - } - let state = Arc::new(AppState { - board: Mutex::new(board), - sync: Mutex::new(sync), - sync_store, - sync_sessions: SyncSessionTokens::from_env(), - acknowledgement_heartbeat_interval: std::env::var("HEMX_KANBAN_ACK_HEARTBEAT_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|milliseconds| *milliseconds > 0) - .map(Duration::from_millis) - .unwrap_or(ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL), - presence: Mutex::new(PresenceTracker::default()), - }); - - let ordinary_routes = Router::new() - .route("/", get(home).post(interact)) - .route("/move", post(move_card_without_script)) - .route("/events", get(events)) - .route("/sync/broadcast", get(sync_broadcast)) - .route("/sync/ack", get(sync_ack)) - .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); - - let addr = std::env::var("HEMX_KANBAN_ADDR") - .map(|value| value.parse::().expect("valid HEMX_KANBAN_ADDR")) - .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3001))); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - println!("hemx Kanban example: http://{addr}"); - axum::serve(listener, app).await.unwrap(); -} - -fn initial_board() -> BoardState { - BoardState { - next_id: 4, - cards: vec![ - Card { - id: 1, - title: "Write requirements".into(), - column: 0, - }, - Card { - id: 2, - title: "Build browser example".into(), - column: 1, - }, - Card { - id: 3, - title: "Verify with HTTP".into(), - column: 2, - }, - ], - } -} - -// req: examples/001 req: component/003 -async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { - let board = state.board.lock().unwrap().clone(); - request - .page_html(page_html(&board), shell) - .title("hemx Kanban") - .fingerprint(ui::BUILD_FINGERPRINT) -} - -#[derive(Deserialize)] -#[serde(rename_all = "lowercase")] -enum MoveDirection { - Left, - Right, -} - -#[derive(Deserialize)] -struct MoveCardForm { - card_id: u64, - direction: MoveDirection, -} - -// req: accessibility/001 req: ms/001 -async fn move_card_without_script( - State(state): State>, - Form(command): Form, -) -> Result { - let mut board = state.board.lock().unwrap(); - let moved = update_card(&mut board, Some(command.card_id), |card| { - card.column = match command.direction { - MoveDirection::Left => card.column.saturating_sub(1), - MoveDirection::Right => (card.column + 1).min(COLUMNS.len() - 1), - }; - }); - moved - .then(|| Redirect::to("/")) - .ok_or(StatusCode::BAD_REQUEST) -} - -async fn runtime() -> impl IntoResponse { - runtime_js() -} - -async fn legacy_sync_fixture() -> impl IntoResponse { - PageResponse::full( - ui::page(&LegacySyncFixture { - runtime_src: runtime_js_path(), - }) - .into_string(), - ) - .title("hemx Kanban sync") - .fingerprint(ui::BUILD_FINGERPRINT) -} - -async fn legacy_sync_fixture_js() -> impl IntoResponse { - ( - [("content-type", "text/javascript; charset=utf-8")], - include_str!("../tests/fixtures/legacy-sync.js"), - ) -} - -async fn interact( - State(state): State>, - request: InteractionRequest, -) -> Result { - request.dispatch(registry(state)) -} - -// req: push/001 req: push/003 req: examples/001 -struct PresenceSignal { - channel: Channel, - count: usize, -} - -impl PresenceScope for PresenceSignal { - fn presence_channel(&self) -> Channel { - self.channel.clone() - } -} - -#[hemx_sync::presence] -fn presence_changed(signal: PresenceSignal) -> impl hemx::IntoEffect { - board::presence.put(&Presence { - count: u64::try_from(signal.count).expect("presence count fits u64"), - }) -} - -async fn sync_ack( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> Result { - let principal = current_sync_principal(&headers, &state.sync_sessions)?; - let command_id = CommandId::parse(params.get("command_id"))?; - let acknowledgement = { - let sync = state.sync.lock().unwrap(); - let acknowledgement = - sync.acknowledgements - .get(&command_id) - .ok_or(SyncRejection::Conflict( - "command has no canonical acknowledgement", - ))?; - if !visible_acknowledgement(principal, acknowledgement) { - return Err(SyncRejection::Forbidden( - "current tenant cannot access command acknowledgement", - )); - } - acknowledgement.clone() - }; - let batch = ( - FrameworkSync::ack(board::atoms::sync_ack), - board::sync_status.text(format!( - "Canonical acknowledgement {} at server sequence {}", - acknowledgement.command_id, acknowledgement.server_sequence - )), - ) - .into_batch(ui::BUILD_FINGERPRINT); - Ok(sse(stream::iter([Ok::<_, Infallible>(batch)]).boxed()).into_response()) -} - -async fn sync_broadcast( - State(state): State>, - Query(params): Query>, -) -> Response { - let Some(channel) = params - .get("channel") - .and_then(|channel| Channel::new(channel).ok()) - else { - return (StatusCode::BAD_REQUEST, "missing or invalid sync channel").into_response(); - }; - if channel.as_str() != "board" { - return StatusCode::NOT_FOUND.into_response(); - } - let member = params.get("member").cloned(); - let count = { - let mut presence = state.presence.lock().unwrap(); - match (params.get("action").map(String::as_str), member.as_ref()) { - (Some("join"), Some(member)) => presence.join(channel.clone(), member.clone()).count, - (Some("leave"), Some(member)) => presence.leave(&channel, member).count, - (None | Some("snapshot"), None) => presence.count(&channel), - _ => { - return (StatusCode::BAD_REQUEST, "invalid presence action or member") - .into_response(); - } - } - }; - let broadcast = - presence_changed(PresenceSignal { channel, count }).into_broadcast(ui::BUILD_FINGERPRINT); - let (_channel, effect_batch) = broadcast.into_parts(); - sse(stream::iter([Ok::<_, Infallible>(effect_batch)]).boxed()).into_response() -} - -async fn events(Query(params): Query>) -> impl IntoResponse { - if params.contains_key("once") { - let effect = board::presence.put(&Presence { count: 1 }); - return sse(stream::iter([Ok::<_, Infallible>( - effect.into_batch(ui::BUILD_FINGERPRINT), - )]) - .boxed()); - } - - let batches = stream::unfold(1_u64, |count| async move { - tokio::time::sleep(Duration::from_secs(4)).await; - let effect = board::presence.put(&Presence { count }); - Some(( - Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), - count + 1, - )) - }) - .boxed(); - sse(batches) -} - -#[derive(Clone, Copy)] -struct CurrentSyncPrincipal { - principal: &'static str, - tenant: &'static str, - can_replay: bool, -} - -fn current_sync_principal( - headers: &HeaderMap, - sessions: &SyncSessionTokens, -) -> Result { - let session = headers - .get(axum::http::header::COOKIE) - .and_then(|value| value.to_str().ok()) - .and_then(|cookies| { - cookies.split(';').find_map(|cookie| { - cookie - .trim() - .strip_prefix("hemx_kanban_session=") - .map(str::trim) - }) - }); - let Some(session) = session else { - if sessions.is_configured() { - return Err(SyncRejection::Unauthorized( - "current sync session is required", - )); - } - return Ok(CurrentSyncPrincipal { - principal: "demo", - tenant: "demo", - can_replay: true, - }); - }; - if SyncSessionTokens::matches(&sessions.alice_alpha_editor, session) { - return Ok(CurrentSyncPrincipal { - principal: "alice", - tenant: "alpha", - can_replay: true, - }); - } - if SyncSessionTokens::matches(&sessions.bob_alpha_viewer, session) { - return Ok(CurrentSyncPrincipal { - principal: "bob", - tenant: "alpha", - can_replay: false, - }); - } - if SyncSessionTokens::matches(&sessions.carol_beta_editor, session) { - return Ok(CurrentSyncPrincipal { - principal: "carol", - tenant: "beta", - can_replay: true, - }); - } - Err(SyncRejection::Unauthorized( - "current sync session is invalid", - )) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SyncContext { - account_partition: String, -} - -// req: sync/020 req: auth/005 -async fn sync_context( - State(state): State>, - headers: HeaderMap, -) -> Result, SyncRejection> { - let principal = current_sync_principal(&headers, &state.sync_sessions)?; - Ok(Json(SyncContext { - account_partition: format!("{}:{}", principal.tenant, principal.principal), - })) -} - -fn visible_card(principal: CurrentSyncPrincipal, card_id: u64) -> bool { - principal.tenant == "demo" - || (principal.tenant == "beta" && card_id == 2) - || (principal.tenant == "alpha" && card_id != 2) -} - -fn visible_acknowledgement( - principal: CurrentSyncPrincipal, - acknowledgement: &SyncAcknowledgement, -) -> bool { - principal.tenant == "demo" || acknowledgement.tenant == principal.tenant -} - -fn authorize_sync_replay( - principal: CurrentSyncPrincipal, - card_id: u64, -) -> Result<(), SyncRejection> { - if principal.principal == "demo" && principal.tenant == "demo" { - return Ok(()); - } - if !principal.can_replay { - return Err(SyncRejection::Forbidden( - "current principal cannot replay commands", - )); - } - let card_tenant = if card_id == 2 { "beta" } else { "alpha" }; - if principal.tenant != card_tenant { - return Err(SyncRejection::Forbidden( - "current tenant cannot access command target", - )); - } - Ok(()) -} - -// req: sync/001 req: sync/008 req: sync/012 req: sync/019 req: security/004 -async fn sync_command( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> Result, SyncRejection> { - let command_id = CommandId::parse(params.get("command_id"))?; - let card_id = params - .get("card_id") - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .ok_or(SyncRejection::BadRequest("invalid card_id"))?; - let canonical_column = CanonicalColumn::parse(params.get("column"))?; - let principal = current_sync_principal(&headers, &state.sync_sessions)?; - authorize_sync_replay(principal, card_id)?; - - let mut sync = state.sync.lock().unwrap(); - if let Some(existing) = sync.acknowledgements.get(&command_id) { - if !visible_acknowledgement(principal, existing) { - return Err(SyncRejection::Forbidden( - "current tenant cannot access command acknowledgement", - )); - } - if existing.card_id != card_id || existing.canonical_column != canonical_column { - return Err(SyncRejection::Conflict( - "command_id was already used for a different payload", - )); - } - return Ok(Json(existing.clone())); - } - let transient_failure_limit = sync.transient_failure_limit; - if transient_failure_limit > 0 { - let failures = sync - .transient_failures - .entry(command_id.clone()) - .or_default(); - if *failures < transient_failure_limit { - *failures += 1; - return Err(SyncRejection::Transient); - } - } - - let mut board = state.board.lock().unwrap(); - let card_index = board - .cards - .iter() - .position(|card| card.id == card_id) - .ok_or(SyncRejection::BadRequest("unknown card_id"))?; - let server_sequence = sync - .acknowledgements - .values() - .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) - .map(|acknowledgement| acknowledgement.server_sequence) - .max() - .unwrap_or_default() - + 1; - let acknowledgement = SyncAcknowledgement { - command_id: command_id.0.clone(), - server_sequence, - card_id, - canonical_column, - status: "accepted", - tenant: principal.tenant.to_owned(), - }; - if let Some(store) = &state.sync_store { - store.persist(&sync, &acknowledgement).map_err(|error| { - eprintln!("sync persistence failed: {error}"); - SyncRejection::Storage - })?; - } - board.cards[card_index].column = canonical_column.index(); - sync.next_sequence += 1; - sync.acknowledgements - .insert(command_id, acknowledgement.clone()); - Ok(Json(acknowledgement)) -} - -// req: sync/007 req: sync/020 req: auth/005 req: security/004 -async fn sync_snapshot( - State(state): State>, - headers: HeaderMap, -) -> Result, SyncRejection> { - let principal = current_sync_principal(&headers, &state.sync_sessions)?; - let board = state.board.lock().unwrap(); - let sync = state.sync.lock().unwrap(); - let cards = board - .cards - .iter() - .filter(|card| visible_card(principal, card.id)) - .map(|card| SnapshotCard { - id: card.id, - column: canonical_column(card.column), - }) - .collect(); - let server_sequence = sync - .acknowledgements - .values() - .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) - .map(|acknowledgement| acknowledgement.server_sequence) - .max() - .unwrap_or_default(); - Ok(Json(SyncSnapshot { - schema_version: 1, - server_sequence, - cards, - })) -} - -fn canonical_column(column: usize) -> CanonicalColumn { - match column { - 1 => CanonicalColumn::Doing, - 2 => CanonicalColumn::Done, - _ => CanonicalColumn::Todo, - } -} - -// req: sync/005 req: sync/006 req: sync/007 req: sync/013 req: auth/005 req: security/004 -async fn sync_acknowledgements( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> Result>>, SyncRejection> { - let principal = current_sync_principal(&headers, &state.sync_sessions)?; - let after = params - .get("after") - .and_then(|value| value.parse::().ok()) - .unwrap_or_default(); - let reconnect_key = params - .get("reconnect") - .filter(|value| !value.is_empty()) - .cloned(); - let persistent_stream = reconnect_key.is_some(); - - let mut sync = state.sync.lock().unwrap(); - if let Some(key) = reconnect_key { - let attempts = sync.reconnects.entry(key).or_default(); - *attempts += 1; - if let Some(backoff) = usize::try_from(*attempts - 1) - .ok() - .and_then(|index| ACKNOWLEDGEMENT_RECONNECT_BACKOFF.get(index)) - { - return Ok(Sse::new( - stream::iter([Ok(Event::default() - .comment(format!("reconnect-attempt-{attempts}")) - .retry(*backoff))]) - .boxed(), - ) - .keep_alive(KeepAlive::new().interval(state.acknowledgement_heartbeat_interval))); - } - } - let first_available = sync - .acknowledgements - .values() - .filter(|acknowledgement| { - visible_acknowledgement(principal, acknowledgement) - && acknowledgement.server_sequence > sync.retained_after - }) - .map(|acknowledgement| acknowledgement.server_sequence) - .min(); - let latest = sync - .acknowledgements - .values() - .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) - .map(|acknowledgement| acknowledgement.server_sequence) - .max() - .unwrap_or_default(); - let history_missing = after < latest - && first_available.is_none_or(|first_sequence| after.saturating_add(1) < first_sequence); - let pending_count = sync - .acknowledgements - .values() - .filter(|acknowledgement| { - visible_acknowledgement(principal, acknowledgement) - && acknowledgement.server_sequence > after - && acknowledgement.server_sequence > sync.retained_after - }) - .count(); - let slow_consumer = pending_count > ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT; - let events = if history_missing || slow_consumer { - vec![Ok(Event::default() - .id(latest.to_string()) - .event("snapshot-required") - .json_data(serde_json::json!({ - "after": after, - "firstAvailable": first_available, - "latest": latest, - "snapshotUrl": "/sync/snapshot", - "reason": if slow_consumer { "slow-consumer" } else { "missing-history" }, - "pendingCount": pending_count, - "bufferLimit": ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT, - })) - .expect("serializable missing history event"))] - } else { - sync.acknowledgements - .values() - .filter(|acknowledgement| { - visible_acknowledgement(principal, acknowledgement) - && acknowledgement.server_sequence > after - && acknowledgement.server_sequence > sync.retained_after - }) - .map(|acknowledgement| { - Ok(Event::default() - .id(acknowledgement.server_sequence.to_string()) - .event("acknowledgement") - .json_data(acknowledgement) - .expect("serializable acknowledgement")) - }) - .collect::>>() - }; - drop(sync); - let heartbeat_interval = state.acknowledgement_heartbeat_interval; - let event_stream = stream::iter(events).boxed(); - let response_stream = if persistent_stream { - let heartbeat = stream::unfold(heartbeat_interval, |interval| async move { - tokio::time::sleep(interval).await; - Some(( - Ok(Event::default() - .event("heartbeat") - .data("{\"status\":\"ok\"}")), - interval, - )) - }); - event_stream.chain(heartbeat).boxed() - } else { - event_stream - }; - Ok(Sse::new(response_stream).keep_alive( - KeepAlive::new() - .interval(heartbeat_interval) - .text("heartbeat"), - )) -} - -fn registry(state: Arc) -> impl DispatchRegistry { - interactions(ui::BUILD_FINGERPRINT) - .on(board::create_card, { - let state = state.clone(); - move |form| { - // req: examples/001 req: form/002 - let title = form.value("title").unwrap_or("").trim(); - let column = parse_column(form.value("column")); - let mut board = state.board.lock().unwrap(); - if !title.is_empty() { - let id = board.next_id; - board.next_id += 1; - board.cards.push(Card { - id, - title: title.into(), - column, - }); - } - board_effects(&board, "Card added") - } - }) - .on(card_board::move_left, { - let state = state.clone(); - move |form| { - // req: examples/001 req: list/003 - let mut board = state.board.lock().unwrap(); - let moved = update_card(&mut board, form.parse("card_id"), |card| { - card.column = card.column.saturating_sub(1); - }); - board_effects( - &board, - if moved { - "Card moved left" - } else { - "Card not found" - }, - ) - } - }) - .on(card_board::move_right, { - let state = state.clone(); - move |form| { - // req: examples/001 req: list/003 - let mut board = state.board.lock().unwrap(); - let moved = update_card(&mut board, form.parse("card_id"), |card| { - card.column = (card.column + 1).min(COLUMNS.len() - 1); - }); - board_effects( - &board, - if moved { - "Card moved right" - } else { - "Card not found" - }, - ) - } - }) - .on(card_board::delete_card, { - let state = state.clone(); - move |form| { - // req: examples/001 req: list/003 - let mut board = state.board.lock().unwrap(); - let before = board.cards.len(); - if let Some(id) = form.parse::("card_id") { - board.cards.retain(|card| card.id != id); - } - board_effects( - &board, - if board.cards.len() < before { - "Card deleted" - } else { - "Card not found" - }, - ) - } - }) -} - -fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect { - ( - board::board.put(&board_view(board)), - board::notice.text(notice), - board::create_card_form.clear(), - ) -} - -fn update_card( - board: &mut BoardState, - card_id: Option, - update: impl FnOnce(&mut Card), -) -> bool { - let Some(id) = card_id else { - return false; - }; - let Some(card) = board.cards.iter_mut().find(|card| card.id == id) else { - return false; - }; - update(card); - true -} - -fn parse_column(value: Option<&str>) -> usize { - let id = value.unwrap_or(COLUMNS[0].0); - COLUMNS - .iter() - .position(|(column_id, _)| *column_id == id) - .unwrap_or(0) -} - -fn page_html(board: &BoardState) -> Html { - // req: html_safety/002 req: view/001 - board_ui::page(&Board { - options: render_options(), - board: ui::page(&board_view(board)), - }) -} - -fn shell(body: Html) -> Html { - // req: html_safety/001 req: html_safety/002 req: axum_integration/001 - ui::page(&AppShell { - runtime_src: runtime_js_path(), - body, - }) -} - -fn render_options() -> Html { - // req: html_safety/002 req: view/001 - ui::page(&ColumnOptions { - options: COLUMNS - .iter() - .map(|(id, title)| ColumnOption { id, title }) - .collect(), - }) -} - -fn board_view(board: &BoardState) -> BoardColumns { - // req: html_safety/002 req: view/001 - BoardColumns { - columns: COLUMNS - .iter() - .enumerate() - .map(|(idx, (_, title))| BoardColumn { - title, - cards: board - .cards - .iter() - .filter(|card| card.column == idx) - .map(render_card) - .collect(), - }) - .collect(), - } -} - -fn render_card(card: &Card) -> BoardCard { - BoardCard { - id: card.id, - title: card.title.clone(), - left_disabled: card.column == 0, - right_disabled: card.column + 1 == COLUMNS.len(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx_test::{ - class_child_selector, disabled_button_selector, element_class_selector, - escaped_markup_selector, form_selector, keyed_selector, root_element_selector, - select_options_selector, small_text_selector, strong_text_selector, - }; - use scraper::{Html, Selector}; - use std::sync::atomic::{AtomicBool, Ordering}; - use tower::ServiceExt; - - struct CancelProof(Arc); - - impl Drop for CancelProof { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - // req: operations/003 - #[tokio::test] - async fn ordinary_handlers_timeout_and_cancel_inflight_work() { - let cancelled = Arc::new(AtomicBool::new(false)); - let proof = Arc::clone(&cancelled); - let app = Router::new() - .route( - "/slow", - get(move || { - let proof = Arc::clone(&proof); - async move { - let _cancel_proof = CancelProof(proof); - std::future::pending::().await - } - }), - ) - .layer(middleware::from_fn(|request, next| async move { - bounded_handler(Duration::from_millis(20), request, next).await - })); - let response = app - .oneshot( - Request::get("/slow") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); - assert!(cancelled.load(Ordering::SeqCst)); - } - - // req: operations/003 - #[tokio::test] - async fn startup_replay_timeout_cancels_inflight_work_with_a_named_error() { - let cancelled = Arc::new(AtomicBool::new(false)); - let proof = Arc::clone(&cancelled); - let error = run_with_timeout(Duration::from_millis(20), "startup replay", async move { - let _cancel_proof = CancelProof(proof); - std::future::pending::<()>().await; - }) - .await - .expect_err("startup replay must time out"); - assert_eq!(error, "startup replay timed out after 20 ms"); - assert!(cancelled.load(Ordering::SeqCst)); - } - - #[tokio::test] - async fn startup_replay_rejects_oversized_store_before_decoding() { - let path = std::env::temp_dir().join(format!( - "hemx-kanban-oversized-store-{}.json", - std::process::id() - )); - fs::write(&path, vec![b' '; MAX_SYNC_STORE_BYTES + 1]).unwrap(); - let error = match SyncStore(path.clone()).load().await { - Ok(_) => panic!("oversized store must be rejected"), - Err(error) => error, - }; - let _ = fs::remove_file(path); - assert!(error.contains("exceeds 1048576 bytes"), "{error}"); - } - - fn selector(value: &str) -> Selector { - Selector::parse(value).expect("test selector parses") - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn kanban_page_is_composed_by_a_hemplate_view() { - let html = page_html(&BoardState::default()); - assert!(!html.as_str().contains("__OPTIONS__")); - assert!(!html.as_str().contains("__BOARD__")); - - let document = Html::parse_fragment(html.as_str()); - assert_eq!( - document - .select(&selector(&root_element_selector("section", "kanban"))) - .count(), - 1 - ); - assert_eq!( - document - .select(&selector(&select_options_selector("column"))) - .count(), - 3 - ); - assert_eq!( - document.select(&selector(&form_selector("header"))).count(), - 1 - ); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn board_payload_is_rendered_by_a_hemplate_view() { - let board = BoardState { - next_id: 2, - cards: vec![Card { - id: 1, - title: "Compile checked".to_owned(), - column: 0, - }], - }; - - let html = ui::page(&board_view(&board)); - let document = Html::parse_fragment(html.as_str()); - assert_eq!( - document - .select(&selector(&class_child_selector( - "columns", "section", "column" - ))) - .count(), - 3 - ); - let card = document - .select(&selector(&keyed_selector("article.card", 1))) - .next() - .expect("card renders"); - let title = card - .select(&selector(strong_text_selector())) - .next() - .expect("card title renders"); - assert_eq!(title.text().collect::(), "Compile checked"); - assert!(card - .select(&selector(&escaped_markup_selector("b"))) - .next() - .is_none()); - assert_eq!( - card.select(&selector(disabled_button_selector())).count(), - 1 - ); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn presence_payload_is_rendered_by_a_hemplate_view() { - let html = ui::page(&Presence { count: 7 }); - let document = Html::parse_fragment(html.as_str()); - assert_eq!( - document - .select(&selector(&element_class_selector("span", "presence"))) - .count(), - 2 - ); - assert_eq!( - document - .select(&selector(small_text_selector())) - .next() - .map(|small| small.text().collect::()), - Some("tick #7".to_owned()) - ); - } -} diff --git a/examples/kanban/static/command-log.js b/examples/kanban/static/command-log.js deleted file mode 100644 index 4af375d..0000000 --- a/examples/kanban/static/command-log.js +++ /dev/null @@ -1,404 +0,0 @@ -const DATABASE = "hemx-kanban-v1"; -const DATABASE_VERSION = 3; -const COMMANDS = "commands"; -const META = "meta"; -const ACCOUNT_INDEX = "byAccountPartition"; -const COMMAND_SCHEMA = 2; -const LEGACY_COMMAND_SCHEMA = 1; -const MIGRATION_KEY = "commandSchemaMigration"; -const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1"; -const EXPORT_SCHEMA = 1; -const MAX_REPLAY_COMMANDS = 64; -const REPLAY_BUDGET_MS = 250; // req: performance/007 -const SESSION = "hemx-kanban-session-v1"; -const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]'; - -function result(request) { - return new Promise((resolve, reject) => { - request.addEventListener("success", () => resolve(request.result), { once: true }); - request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true }); - }); -} - -function completed(transaction) { - return new Promise((resolve, reject) => { - transaction.addEventListener("complete", resolve, { once: true }); - transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true }); - transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true }); - }); -} - -function migrateCommandLog(request, oldVersion) { - const database = request.result; - const commands = database.objectStoreNames.contains(COMMANDS) - ? request.transaction.objectStore(COMMANDS) - : database.createObjectStore(COMMANDS, { keyPath: "id" }); - if (!commands.indexNames.contains(ACCOUNT_INDEX)) commands.createIndex(ACCOUNT_INDEX, "accountPartition"); - if (!database.objectStoreNames.contains(META)) database.createObjectStore(META); - if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return; - const transaction = request.transaction; - const meta = transaction.objectStore(META); - const all = commands.getAll(); - all.addEventListener("success", () => { - const legacy = all.result; - if (legacy.some((command) => command.schemaVersion !== LEGACY_COMMAND_SCHEMA && command.schemaVersion !== COMMAND_SCHEMA)) { - transaction.abort(); - return; - } - for (const command of legacy) { - commands.put({ - ...command, - schemaVersion: COMMAND_SCHEMA, - targetColumn: command.targetColumn || "done", - accountPartition: command.accountPartition || "demo:demo", - queuedAt: Number.isSafeInteger(command.queuedAt) ? command.queuedAt : Date.now(), - }); - } - meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY); - }, { once: true }); -} - -function openCommandLog() { - const request = indexedDB.open(DATABASE, DATABASE_VERSION); - request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion)); - return result(request); -} - -async function currentAccountPartition() { - let response; - try { - response = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" }); - } catch (error) { - const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION); - if (cached) return cached; - throw error; - } - if (!response.ok) { - const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION); - if (response.status === 404 && cached) return cached; - throw new Error(`account context failed with ${response.status}`); - } - const context = await response.json(); - if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) { - throw new Error("account context omitted accountPartition"); - } - sessionStorage.setItem(ACCOUNT_PARTITION_SESSION, context.accountPartition); - return context.accountPartition; -} - -function clientReady(root) { - if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve(); - return new Promise((resolve) => { - const observer = new MutationObserver(() => { - if (!root.hasAttribute("data-hemx-client-ready")) return; - observer.disconnect(); - resolve(); - }); - observer.observe(root, { attributes: true, attributeFilter: ["data-hemx-client-ready"] }); - }); -} - -async function prepareOfflineShell(root) { - if (!("serviceWorker" in navigator)) throw new Error("service workers are unavailable"); - await navigator.serviceWorker.register("/offline.js", { scope: "/" }); - await navigator.serviceWorker.ready; - if (!navigator.serviceWorker.controller) { - await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); - } - root.setAttribute("data-kanban-offline-ready", ""); -} - -function stableSession() { - let session = sessionStorage.getItem(SESSION); - if (!session) { - session = crypto.randomUUID(); - sessionStorage.setItem(SESSION, session); - } - return session; -} - -async function appendReorder(database, accountPartition, wire) { - const transaction = database.transaction([COMMANDS, META], "readwrite"); - const done = completed(transaction); - const completion = done.then( - () => null, - (error) => error, - ); - const meta = transaction.objectStore(META); - const commands = transaction.objectStore(COMMANDS); - const actorKey = `actor:${accountPartition}`; - const causalKey = `causal:${accountPartition}`; - const actorRequest = result(meta.get(actorKey)); - const causalRequest = result(meta.get(causalKey)); - const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]); - const actor = storedActor || crypto.randomUUID(); - const causal = (storedCausal || 0) + 1; - const command = { - id: `${actor}:${causal}`, - schemaVersion: COMMAND_SCHEMA, - accountPartition, - actor, - session: stableSession(), - causal, - queuedAt: Date.now(), - kind: "reorder_card", - cardId: String(wire[2] || "1"), - targetColumn: "done", - eventKind: String(wire[1] || "click"), - key: wire[4] ? String(wire[4]) : null, - }; - let append; - let counted; - try { - meta.put(actor, actorKey); - meta.put(causal, causalKey); - append = result(commands.add(command)); - counted = result(commands.index(ACCOUNT_INDEX).count(accountPartition)); - } catch (error) { - transaction.abort(); - await completion; - throw error; - } - try { - const [, count] = await Promise.all([append, counted]); - const transactionError = await completion; - if (transactionError) throw transactionError; - return { command, count }; - } catch (error) { - await completion; - throw error; - } -} - -async function storedCommands(database, accountPartition) { - const transaction = database.transaction(COMMANDS, "readonly"); - const done = completed(transaction); - const commands = await result(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition)); - await done; - return commands.sort((left, right) => left.causal - right.causal); -} - -class ReplayLimitError extends Error { - constructor(actual) { - super(`durable replay limit exceeded: ${actual} > ${MAX_REPLAY_COMMANDS}`); - this.name = "ReplayLimitError"; - } -} - -function invalidCommand(command, field) { - const id = command && typeof command.id === "string" && command.id ? command.id : "record"; - throw new Error(`invalid durable command ${id}: ${field}`); -} - -function validate(command) { - if (!command || typeof command !== "object") invalidCommand(command, "record"); - if (!Number.isSafeInteger(command.schemaVersion)) invalidCommand(command, "schemaVersion"); - if (command.schemaVersion !== COMMAND_SCHEMA) { - throw new Error(`unsupported durable command ${command.id || "record"}`); - } - if (typeof command.id !== "string" || !command.id) invalidCommand(command, "id"); - if (typeof command.accountPartition !== "string" || !command.accountPartition) invalidCommand(command, "accountPartition"); - if (typeof command.actor !== "string" || !command.actor) invalidCommand(command, "actor"); - if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session"); - if (!Number.isSafeInteger(command.causal) || command.causal < 1) invalidCommand(command, "causal"); - if (!Number.isSafeInteger(command.queuedAt) || command.queuedAt < 0) invalidCommand(command, "queuedAt"); - if (command.id !== `${command.actor}:${command.causal}`) invalidCommand(command, "id"); - if (command.kind !== "reorder_card") invalidCommand(command, "kind"); - if (typeof command.cardId !== "string" || !command.cardId) invalidCommand(command, "cardId"); - if (command.targetColumn !== "done") invalidCommand(command, "targetColumn"); - if (typeof command.eventKind !== "string" || !command.eventKind) invalidCommand(command, "eventKind"); - if (command.key !== null && typeof command.key !== "string") invalidCommand(command, "key"); - return command; -} - -async function project(root, wasmHandler, command) { - const checked = validate(command); - const batch = await wasmHandler( - 1, - checked.eventKind, - checked.cardId, - undefined, - checked.key || undefined, - 1, - root.getAttribute("data-hemx-st") || "", - ); - if (!(batch instanceof Uint8Array)) throw new Error("reorder_card returned an invalid effect batch"); - return batch; -} - -function report(root, stage, error) { - const code = error && typeof error.name === "string" ? error.name : "Error"; - const message = error instanceof Error ? error.message : String(error); - root.setAttribute("data-kanban-command-phase", "failed"); - root.removeAttribute("aria-busy"); - root.setAttribute("data-kanban-command-error", `${stage}: ${message}`); - root.setAttribute("data-kanban-command-error-stage", stage); - root.setAttribute("data-kanban-command-error-code", code); - announce(root, `Local command ${stage} failed (${code}). Recovery controls remain available.`); - root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, code, message } })); -} - -function announce(root, message) { - const status = root.querySelector('[role="status"]'); - if (status) status.textContent = message; -} - -function exportCommands(root, commands) { - const payload = { schemaVersion: EXPORT_SCHEMA, commands }; - const json = JSON.stringify(payload, null, 2); - const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); - const download = document.createElement("a"); - download.href = url; - download.download = "hemx-kanban-commands.json"; - download.hidden = true; - document.body.append(download); - download.click(); - download.remove(); - setTimeout(() => URL.revokeObjectURL(url), 0); - announce(root, `Exported ${commands.length} command${commands.length === 1 ? "" : "s"}.`); - root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload })); -} - -async function clearCommands(database, accountPartition) { - const transaction = database.transaction(COMMANDS, "readwrite"); - const done = completed(transaction); - const commands = transaction.objectStore(COMMANDS); - const cursor = commands.index(ACCOUNT_INDEX).openKeyCursor(IDBKeyRange.only(accountPartition)); - cursor.addEventListener("success", () => { - if (!cursor.result) return; - commands.delete(cursor.result.primaryKey); - cursor.result.continue(); - }); - await done; -} - -async function resetLocalData(database) { - database.close(); - await result(indexedDB.deleteDatabase(DATABASE)); - sessionStorage.removeItem(SESSION); - await Promise.all((await caches.keys()).filter((name) => name.startsWith("hemx-kanban-shell-")).map((name) => caches.delete(name))); - await Promise.all((await navigator.serviceWorker.getRegistrations()).map((registration) => registration.unregister())); -} - -function disarmRecoveryControls(controls) { - for (const control of controls) { - if (!control.dataset.confirmLabel) continue; - control.textContent = control.dataset.confirmLabel; - delete control.dataset.confirmLabel; - } -} - -function installRecoveryControls(root, database, accountPartition) { - const controls = [...root.querySelectorAll("[data-kanban-command-action]")]; - for (const control of controls) { - control.addEventListener("click", async () => { - const action = control.getAttribute("data-kanban-command-action"); - if ((action === "delete" || action === "reset") && !control.dataset.confirmLabel) { - disarmRecoveryControls(controls); - control.dataset.confirmLabel = control.textContent; - control.textContent = `Confirm ${control.textContent.toLowerCase()}`; - announce(root, `${control.dataset.confirmLabel} requires confirmation.`); - return; - } - if (action === "export") disarmRecoveryControls(controls); - controls.forEach((item) => { item.disabled = true; }); - try { - if (action === "export") { - exportCommands(root, await storedCommands(database, accountPartition)); - controls.forEach((item) => { item.disabled = false; }); - return; - } - if (action === "delete") { - await clearCommands(database, accountPartition); - root.dispatchEvent(new CustomEvent("kanban:commands-deleted")); - } else if (action === "reset") { - await resetLocalData(database); - root.dispatchEvent(new CustomEvent("kanban:local-data-reset")); - } else { - throw new Error(`unsupported recovery action ${action}`); - } - location.reload(); - } catch (error) { - controls.forEach((item) => { item.disabled = false; }); - disarmRecoveryControls(controls); - report(root, action || "recovery", error); - } - }); - } -} - -async function start() { - const root = document.querySelector(ROOT); - if (!root) return; - root.setAttribute("data-kanban-load-id", crypto.randomUUID()); - const accountPartition = await currentAccountPartition(); - root.setAttribute("data-kanban-account-partition", accountPartition); - const databasePromise = openCommandLog(); - const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error)); - await clientReady(root); - let wasmHandler; - const durableHandler = async (...wire) => { - const queuedCard = String(wire[2] || "1"); - root.setAttribute("data-kanban-command-phase", "queued"); - root.setAttribute("aria-busy", "true"); - announce(root, `Queued card ${queuedCard}; saving for offline use.`); - root.dispatchEvent(new CustomEvent("kanban:command-queued", { detail: { cardId: queuedCard } })); - let command; - let count; - try { - ({ command, count } = await appendReorder(await databasePromise, accountPartition, wire)); - } catch (error) { - report(root, "persist", error); - throw error; - } - root.setAttribute("data-kanban-command-phase", "durable"); - root.removeAttribute("aria-busy"); - root.setAttribute("data-kanban-command-count", String(count)); - root.dispatchEvent(new CustomEvent("kanban:command-persisted", { - detail: { - id: command.id, - schemaVersion: command.schemaVersion, - actor: command.actor, - session: command.session, - causal: command.causal, - targetColumn: command.targetColumn, - }, - })); - try { - return await project(root, wasmHandler, command); - } catch (error) { - report(root, "project", error); - throw error; - } - }; - - wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler); - if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered"); - const database = await databasePromise; - installRecoveryControls(root, database, accountPartition); - root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS)); - try { - const commands = await storedCommands(database, accountPartition); - if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length); - commands.forEach(validate); - const replayStarted = performance.now(); - const batches = await Promise.all(commands.map((command) => project(root, wasmHandler, command))); - for (const batch of batches) window.hemx.applyBatch(batch, root); - const replayMs = performance.now() - replayStarted; - root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3)); - root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS)); - root.toggleAttribute("data-kanban-replay-over-budget", replayMs > REPLAY_BUDGET_MS); - root.setAttribute("data-kanban-command-count", String(commands.length)); - root.setAttribute("data-kanban-command-ready", ""); - await offlineReady; - } catch (error) { - report(root, "restore", error); - throw error; - } -} - -start().catch((error) => { - const root = document.querySelector(ROOT); - if (root && !root.hasAttribute("data-kanban-command-error")) report(root, "open", error); - console.error("kanban durable command log failed", error); -}); diff --git a/examples/kanban/static/offline.js b/examples/kanban/static/offline.js deleted file mode 100644 index 157c51e..0000000 --- a/examples/kanban/static/offline.js +++ /dev/null @@ -1,30 +0,0 @@ -const CACHE = "hemx-kanban-shell-v1"; -const SHELL = [ - "/", - "/hemx.js", - "/hemx.client.js", - "/kanban_client.js", - "/kanban_client_bg.wasm", - "/app.js", -]; - -self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys() - .then((names) => Promise.all(names.filter((name) => name.startsWith("hemx-kanban-shell-") && name !== CACHE).map((name) => caches.delete(name)))) - .then(() => self.clients.claim()), - ); -}); - -self.addEventListener("fetch", (event) => { - if (event.request.method !== "GET") return; - const url = new URL(event.request.url); - if (url.origin !== self.location.origin || !SHELL.includes(url.pathname)) return; - event.respondWith( - caches.match(event.request, { ignoreSearch: true }).then((cached) => cached || fetch(event.request)), - ); -}); diff --git a/examples/kanban/templates/app_shell.heml b/examples/kanban/templates/app_shell.heml deleted file mode 100644 index 0b8940e..0000000 --- a/examples/kanban/templates/app_shell.heml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - hemx Kanban - - - -{+= self.body =+} - diff --git a/examples/kanban/templates/board.heml b/examples/kanban/templates/board.heml deleted file mode 100644 index b805a54..0000000 --- a/examples/kanban/templates/board.heml +++ /dev/null @@ -1,17 +0,0 @@ -
    -
    -

    hemx Kanban

    -
    - - - -
    -

    Ready

    -
    - -
    {+= self.board =+}
    - - pending - Waiting for acknowledgement… - -
    diff --git a/examples/kanban/templates/client_board.heml b/examples/kanban/templates/client_board.heml deleted file mode 100644 index 8a96080..0000000 --- a/examples/kanban/templates/client_board.heml +++ /dev/null @@ -1,15 +0,0 @@ -
    -

    Ready

    -
      - -
    -
    - Offline commands - - - -
    -
    Drop card
    -
    diff --git a/examples/kanban/templates/client_card.heml b/examples/kanban/templates/client_card.heml deleted file mode 100644 index e48c156..0000000 --- a/examples/kanban/templates/client_card.heml +++ /dev/null @@ -1,4 +0,0 @@ -
  • - {+ self.title +} - -
  • diff --git a/examples/kanban/templates/legacy_sync_fixture.heml b/examples/kanban/templates/legacy_sync_fixture.heml deleted file mode 100644 index 1b49ba8..0000000 --- a/examples/kanban/templates/legacy_sync_fixture.heml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - hemx Kanban sync - - -
    -

    Sync status

    -

    Waiting for pending commands.

    - - - - - -
    - - - - diff --git a/examples/kanban/templates/partials/board_card.heml b/examples/kanban/templates/partials/board_card.heml deleted file mode 100644 index a8074ba..0000000 --- a/examples/kanban/templates/partials/board_card.heml +++ /dev/null @@ -1,16 +0,0 @@ -
    - {+ self.title +} - - -
    - - -
    - -
    - - -
    - -
    -
    diff --git a/examples/kanban/templates/partials/board_column.heml b/examples/kanban/templates/partials/board_column.heml deleted file mode 100644 index d0134e4..0000000 --- a/examples/kanban/templates/partials/board_column.heml +++ /dev/null @@ -1,6 +0,0 @@ -
    -

    {+ self.title +}

    - -
    diff --git a/examples/kanban/templates/partials/board_columns.heml b/examples/kanban/templates/partials/board_columns.heml deleted file mode 100644 index 8ee9740..0000000 --- a/examples/kanban/templates/partials/board_columns.heml +++ /dev/null @@ -1,5 +0,0 @@ -
    - -
    diff --git a/examples/kanban/templates/partials/column_option.heml b/examples/kanban/templates/partials/column_option.heml deleted file mode 100644 index c5f60d5..0000000 --- a/examples/kanban/templates/partials/column_option.heml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/kanban/templates/partials/column_options.heml b/examples/kanban/templates/partials/column_options.heml deleted file mode 100644 index 64560dc..0000000 --- a/examples/kanban/templates/partials/column_options.heml +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/examples/kanban/templates/partials/presence.heml b/examples/kanban/templates/partials/presence.heml deleted file mode 100644 index 3650d9c..0000000 --- a/examples/kanban/templates/partials/presence.heml +++ /dev/null @@ -1 +0,0 @@ -Ada online Grace online tick #{+ self.count +} diff --git a/examples/kanban/tests/browser_e2e.rs b/examples/kanban/tests/browser_e2e.rs deleted file mode 100644 index 05bc049..0000000 --- a/examples/kanban/tests/browser_e2e.rs +++ /dev/null @@ -1,2901 +0,0 @@ -use hemx_test::TestProcess; -use std::fs; -use std::net::TcpListener; -use std::process::Command; -use std::time::Duration; -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 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")); - app.env("HEMX_KANBAN_ADDR", &app_addr); - let _app = TestProcess::start(app, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - driver.find(By::Css("section[data-hemx-root='kanban']")).await?; - let loaded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - navigator.serviceWorker.getRegistrations().then(async (registrations) => { - const databases = indexedDB.databases ? await indexedDB.databases() : []; - done({ - resources: performance.getEntriesByType('resource').map((entry) => new URL(entry.name).pathname), - scripts: [...document.scripts].map((script) => ({ src: new URL(script.src).pathname, type: script.type })), - 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) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - - assert!(loaded["error"].is_null(), "browser inspection failed: {loaded}"); - 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"); - assert!( - runtime_path.starts_with("/hemx.") && runtime_path.ends_with(".js"), - "unexpected server runtime asset: {loaded}" - ); - assert_eq!(scripts[0]["type"], ""); - let resources = loaded["resources"] - .as_array() - .expect("resource timing entries") - .iter() - .filter_map(|value| value.as_str()) - .collect::>(); - assert!(resources.contains(&runtime_path), "runtime was not loaded: {loaded}"); - for optional in [ - "/hemx.client.js", - "/kanban_client.js", - "/kanban_client_bg.wasm", - "/app.js", - "/offline.js", - ] { - assert!( - !resources.contains(&optional), - "server-first route loaded optional client asset {optional}: {loaded}" - ); - } - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn idempotent_server_command_is_acknowledged_after_reconnect() -> WebDriverResult<()> { - // test req: sync/001 req: sync/005 req: sync/006 req: sync/007 - // 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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - driver.find(By::Css("section[data-hemx-root='kanban']")).await?; - driver - .execute( - r#" - window.__syncProof = { ready: false, opens: 0, events: [], error: null }; - (async () => { - const endpoint = '/sync/commands?command_id=actor-1%3A1&card_id=1'; - const firstResponse = await fetch(endpoint, { method: 'POST' }); - const first = await firstResponse.json(); - const duplicateResponse = await fetch(endpoint, { method: 'POST' }); - const duplicate = await duplicateResponse.json(); - const conflictResponse = await fetch('/sync/commands?command_id=actor-1%3A1&card_id=2', { method: 'POST' }); - const conflict = await conflictResponse.json(); - window.__syncProof.command = { - firstStatus: firstResponse.status, - duplicateStatus: duplicateResponse.status, - first, - duplicate, - conflictStatus: conflictResponse.status, - conflict, - }; - const source = new EventSource('/sync/acknowledgements?after=0&reconnect=browser-proof'); - source.onopen = () => { window.__syncProof.opens += 1; }; - source.addEventListener('acknowledgement', (event) => { - window.__syncProof.events.push({ id: event.lastEventId, acknowledgement: JSON.parse(event.data) }); - window.__syncProof.ready = true; - source.close(); - }); - source.onerror = () => { - if (source.readyState === EventSource.CLOSED && !window.__syncProof.ready) { - window.__syncProof.error = 'acknowledgement stream closed'; - } - }; - })().catch((error) => { window.__syncProof.error = String(error); }); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until( - &driver, - "return window.__syncProof.ready === true || window.__syncProof.error !== null", - ) - .await?; - let proof = driver - .execute("return window.__syncProof", Vec::new()) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "sync failed: {proof}"); - assert!( - proof["opens"].as_u64().is_some_and(|opens| opens >= 2), - "transport did not reconnect: {proof}" - ); - assert_eq!(proof["command"]["firstStatus"], 200); - assert_eq!(proof["command"]["duplicateStatus"], 200); - assert_eq!(proof["command"]["first"], proof["command"]["duplicate"]); - assert_eq!(proof["command"]["first"]["commandId"], "actor-1:1"); - assert_eq!(proof["command"]["first"]["serverSequence"], 1); - assert_eq!(proof["command"]["first"]["cardId"], 1); - assert_eq!(proof["command"]["first"]["canonicalColumn"], "done"); - assert_eq!(proof["command"]["first"]["status"], "accepted"); - assert_eq!(proof["command"]["conflictStatus"], 409); - assert_eq!( - proof["command"]["conflict"]["error"], - "command_id was already used for a different payload" - ); - assert_eq!(proof["events"].as_array().map(Vec::len), Some(1)); - assert_eq!(proof["events"][0]["id"], "1"); - assert_eq!( - proof["events"][0]["acknowledgement"], - proof["command"]["first"] - ); - - driver.refresh().await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack( -) -> WebDriverResult<()> { - // 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 = 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) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onerror = () => done({ error: open.error && open.error.name }); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session', - causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true, "failed to seed durable command: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'acknowledged' || document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'failed'", - ) - .await?; - let proof = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), pending: root.getAttribute('data-sync-pending-count'), pendingBeforeAck: root.getAttribute('data-sync-pending-before-ack'), attempts: root.getAttribute('data-sync-attempts'), maxAttempts: root.getAttribute('data-sync-max-attempts'), backoffBase: root.getAttribute('data-sync-last-backoff-base-ms'), backoff: root.getAttribute('data-sync-last-backoff-ms'), opens: root.getAttribute('data-sync-transport-opens'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), ackSequence: root.getAttribute('data-sync-ack-sequence'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(proof["phase"], "acknowledged", "sync failed: {proof}"); - assert_eq!(proof["pending"], "0"); - assert_eq!(proof["pendingBeforeAck"], "1"); - assert_eq!(proof["attempts"], "2", "unexpected retry state: {proof}"); - assert_eq!(proof["maxAttempts"], "3"); - assert_eq!(proof["backoffBase"], "25"); - assert!( - proof["backoff"] - .as_str() - .and_then(|value| value.parse::().ok()) - .is_some_and(|delay| (25..50).contains(&delay)), - "retry jitter left its bounded interval: {proof}" - ); - assert!( - proof["opens"].as_str().and_then(|value| value.parse::().ok()).is_some_and(|opens| opens >= 2), - "transport did not reconnect: {proof}" - ); - assert_eq!(proof["uploadSequence"], "1"); - assert_eq!(proof["ackSequence"], "1"); - assert_eq!(proof["canonicalColumn"], "done"); - assert_eq!(proof["status"], "Queued change acknowledged in done."); - assert!(proof["error"].is_null()); - - let queue_count = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const request = open.result.transaction('commands', 'readonly').objectStore('commands').count(); - request.onsuccess = () => done(request.result); - request.onerror = () => done({ error: request.error && request.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(queue_count, 0); - - let rejected_seed = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session', - causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(rejected_seed["seeded"], true, "failed to seed rejected command"); - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'rejected'", - ) - .await?; - let rejected = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), error: root.getAttribute('data-sync-error'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(rejected["pending"], "1"); - assert_eq!(rejected["attempts"], "1"); - assert_eq!(rejected["error"], "sync upload failed with 409"); - assert_eq!( - rejected["status"], - "Command sync-actor:1 was permanently rejected (409: command_id was already used for a different payload); 1 durable command remains queued for review." - ); - - driver.goto(&format!("http://{app_addr}/")).await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebDriverResult<()> { - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env( - "HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR", - "test-token-alice-alpha-editor", - ) - .env( - "HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER", - "test-token-bob-alpha-viewer", - ) - .env( - "HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR", - "test-token-carol-beta-editor", - ) - .env("HEMX_KANBAN_SYNC_FAILURES", "3"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - document.cookie = 'hemx_kanban_session=test-token-carol-beta-editor; Path=/; SameSite=Strict'; - const open = indexedDB.open('hemx-kanban-v1', 2); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'auth:1', schemaVersion: 2, accountPartition: 'alpha:alice', actor: 'alice-device', session: 'enqueue-session', - causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', - eventKind: 'click', key: null, enqueuedPrincipal: 'alice', enqueuedTenant: 'alpha', - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true, "failed to seed auth queue: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'", - ) - .await?; - let cross_tenant = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(cross_tenant["phase"], "idle"); - assert_eq!(cross_tenant["account"], "beta:carol"); - assert_eq!(cross_tenant["pending"], "0"); - assert!(cross_tenant["attempts"].is_null()); - assert_eq!(cross_tenant["leakedId"], false); - assert_eq!(cross_tenant["status"], "No pending commands."); - assert_eq!(command_count(&driver).await?, 1); - - driver - .execute( - "document.cookie = 'hemx_kanban_session=; Path=/; Max-Age=0; SameSite=Strict'; return true;", - Vec::new(), - ) - .await?; - driver.refresh().await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'failed'", - ) - .await?; - let signed_out = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { error: root.getAttribute('data-sync-error'), pending: root.getAttribute('data-sync-pending-count'), account: root.getAttribute('data-sync-account-partition') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(signed_out["error"], "account context failed with 401"); - assert!(signed_out["pending"].is_null()); - assert!(signed_out["account"].is_null()); - assert_eq!(command_count(&driver).await?, 1); - - let before_authorized = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - fetch('/').then((response) => response.text()).then((html) => { - const page = new DOMParser().parseFromString(html, 'text/html'); - done(page.querySelector('[data-key="1"]').closest('section').querySelector('h2').textContent); - }).catch((error) => done(`error:${error}`)); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(before_authorized, "Backlog"); - - driver - .execute( - "document.cookie = 'hemx_kanban_session=test-token-bob-alpha-viewer; Path=/; SameSite=Strict'; return true;", - Vec::new(), - ) - .await?; - driver.refresh().await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'", - ) - .await?; - let switched_user = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(switched_user["account"], "alpha:bob"); - assert_eq!(switched_user["pending"], "0"); - assert!(switched_user["attempts"].is_null()); - assert_eq!(switched_user["leakedId"], false); - assert_eq!(command_count(&driver).await?, 1); - - let export_boundary = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); const button = root.querySelector('[data-sync-export]'); button.click(); return { exportDisabled: button.disabled, exported: root.getAttribute('data-sync-exported-count'), leakedId: document.body.textContent.includes('auth:1') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(export_boundary["exportDisabled"], true); - assert!(export_boundary["exported"].is_null()); - assert_eq!(export_boundary["leakedId"], false); - - driver - .execute( - "document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;", - Vec::new(), - ) - .await?; - 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') === 'offline' && root?.getAttribute('data-sync-pending-count') === '1'", - ) - .await?; - driver - .execute( - "window.__exportPayload = null; const create = URL.createObjectURL; URL.createObjectURL = (blob) => { blob.text().then((text) => { window.__exportPayload = JSON.parse(text); }); return create(blob); }; return true;", - Vec::new(), - ) - .await?; - driver.find(By::Css("[data-sync-export]")).await?.click().await?; - wait_until(&driver, "return window.__exportPayload !== null").await?; - let owner_export = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { payload: window.__exportPayload, exported: root.getAttribute('data-sync-exported-count') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(owner_export["exported"], "1"); - assert_eq!(owner_export["payload"]["accountPartition"], "alpha:alice"); - assert_eq!(owner_export["payload"]["commands"].as_array().unwrap().len(), 1); - assert_eq!(owner_export["payload"]["commands"][0]["id"], "auth:1"); - assert_eq!(owner_export["payload"]["commands"][0]["accountPartition"], "alpha:alice"); - - driver.find(By::Css("[data-sync-retry]")).await?.click().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 authorized = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { sequence: root.getAttribute('data-sync-ack-sequence'), column: root.getAttribute('data-sync-canonical-column'), pending: root.getAttribute('data-sync-pending-count') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(authorized["sequence"], "1"); - assert_eq!(authorized["column"], "done"); - assert_eq!(authorized["pending"], "0"); - assert_eq!(command_count(&driver).await?, 0); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env( - "HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR", - "test-token-alice-alpha-editor", - ) - .env( - "HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER", - "test-token-bob-alpha-viewer", - ) - .env( - "HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR", - "test-token-carol-beta-editor", - ); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const session = (token) => { document.cookie = `hemx_kanban_session=${token}; Path=/; SameSite=Strict`; }; - const command = (id, card) => fetch(`/sync/commands?command_id=${encodeURIComponent(id)}&card_id=${card}&column=done`, { method: 'POST' }); - (async () => { - session('test-token-alice-alpha-editor'); - const aliceWrite = await command('alice-history:1', 1); - session('test-token-carol-beta-editor'); - const carolWrite = await command('carol-history:1', 2); - const betaSnapshotResponse = await fetch('/sync/snapshot', { cache: 'no-store' }); - const betaSnapshot = await betaSnapshotResponse.json(); - const betaHistoryResponse = await fetch('/sync/acknowledgements?after=0', { headers: { Accept: 'text/event-stream' }, cache: 'no-store' }); - const betaHistory = await betaHistoryResponse.text(); - session('test-token-bob-alpha-viewer'); - const alphaSnapshotResponse = await fetch('/sync/snapshot', { cache: 'no-store' }); - const alphaSnapshot = await alphaSnapshotResponse.json(); - const alphaHistoryResponse = await fetch('/sync/acknowledgements?after=0', { headers: { Accept: 'text/event-stream' }, cache: 'no-store' }); - const alphaHistory = await alphaHistoryResponse.text(); - document.cookie = 'hemx_kanban_session=; Path=/; Max-Age=0; SameSite=Strict'; - const signedOutSnapshot = await fetch('/sync/snapshot', { cache: 'no-store' }); - const signedOutHistory = await fetch('/sync/acknowledgements?after=0', { headers: { Accept: 'text/event-stream' }, cache: 'no-store' }); - done({ - aliceWrite: aliceWrite.status, - carolWrite: carolWrite.status, - betaSnapshotStatus: betaSnapshotResponse.status, - betaSnapshot, - betaHistoryStatus: betaHistoryResponse.status, - betaHistory, - alphaSnapshotStatus: alphaSnapshotResponse.status, - alphaSnapshot, - alphaHistoryStatus: alphaHistoryResponse.status, - alphaHistory, - signedOutSnapshotStatus: signedOutSnapshot.status, - signedOutHistoryStatus: signedOutHistory.status, - }); - })().catch((error) => done({ error: String(error), stack: error.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof.get("error").is_none(), "browser proof failed: {proof}"); - assert_eq!(proof["aliceWrite"], 200); - assert_eq!(proof["carolWrite"], 200); - assert_eq!(proof["betaSnapshotStatus"], 200); - assert_eq!( - proof["betaSnapshot"], - serde_json::json!({ - "schemaVersion": 1, - "serverSequence": 1, - "cards": [{ "id": 2, "column": "done" }] - }) - ); - assert_eq!(proof["betaHistoryStatus"], 200); - let beta_history = proof["betaHistory"].as_str().unwrap(); - assert!(beta_history.contains("carol-history:1")); - assert!(!beta_history.contains("alice-history:1")); - assert!(!beta_history.contains("\"cardId\":1")); - - assert_eq!(proof["alphaSnapshotStatus"], 200); - assert_eq!( - proof["alphaSnapshot"], - serde_json::json!({ - "schemaVersion": 1, - "serverSequence": 1, - "cards": [ - { "id": 1, "column": "done" }, - { "id": 3, "column": "done" } - ] - }) - ); - assert_eq!(proof["alphaHistoryStatus"], 200); - let alpha_history = proof["alphaHistory"].as_str().unwrap(); - assert!(alpha_history.contains("alice-history:1")); - assert!(!alpha_history.contains("carol-history:1")); - assert!(!alpha_history.contains("\"cardId\":2")); - assert_eq!(proof["signedOutSnapshotStatus"], 401); - assert_eq!(proof["signedOutHistoryStatus"], 401); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverResult<()> { - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_FAILURES", "3"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1', 1); - open.onupgradeneeded = () => { - const database = open.result; - database.createObjectStore('commands', { keyPath: 'id' }); - database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction(['commands', 'meta'], 'readwrite'); - const commands = tx.objectStore('commands'); - for (const [causal, cardId, eventKind, key] of [[1, '1', 'click', null], [2, '2', 'keydown', 'Enter'], [3, '3', 'click', null]]) { - commands.add({ - id: `upgrade:${causal}`, schemaVersion: 1, actor: 'upgrade', session: 'legacy-session', - causal, kind: 'reorder_card', cardId, eventKind, key, - }); - } - tx.objectStore('meta').put(3, 'causal'); - tx.oncomplete = () => done({ version: open.result.version }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["version"], 1, "failed to seed legacy queue: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'", - ) - .await?; - let migrated = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const root = document.querySelector('[data-kanban-sync]'); - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const database = open.result; - const tx = database.transaction(['commands', 'meta'], 'readonly'); - const commands = tx.objectStore('commands').getAll(); - const receipt = tx.objectStore('meta').get('commandSchemaMigration'); - tx.oncomplete = () => done({ - databaseVersion: database.version, - commandSchema: root.getAttribute('data-sync-command-schema'), - migrationFrom: root.getAttribute('data-sync-migration-from'), - migrationTo: root.getAttribute('data-sync-migration-to'), - migratedCount: root.getAttribute('data-sync-migrated-count'), - pending: root.getAttribute('data-sync-pending-count'), - receipt: receipt.result, - commands: commands.result.sort((a, b) => a.causal - b.causal).map(({ id, schemaVersion, cardId, targetColumn, eventKind, key }) => ({ id, schemaVersion, cardId, targetColumn, eventKind, key })), - }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(migrated["databaseVersion"], 3); - assert_eq!(migrated["commandSchema"], "2"); - assert_eq!(migrated["migrationFrom"], "1"); - assert_eq!(migrated["migrationTo"], "3"); - assert_eq!(migrated["migratedCount"], "3"); - assert_eq!(migrated["pending"], "3"); - assert_eq!( - migrated["receipt"], - serde_json::json!({ "from": 1, "to": 3, "migrated": 3 }) - ); - assert_eq!( - migrated["commands"], - serde_json::json!([ - { "id": "upgrade:1", "schemaVersion": 2, "cardId": "1", "targetColumn": "done", "eventKind": "click", "key": null }, - { "id": "upgrade:2", "schemaVersion": 2, "cardId": "2", "targetColumn": "done", "eventKind": "keydown", "key": "Enter" }, - { "id": "upgrade:3", "schemaVersion": 2, "cardId": "3", "targetColumn": "done", "eventKind": "click", "key": null } - ]) - ); - - for expected_pending in [2, 1] { - driver.find(By::Css("[data-sync-retry]")).await?.click().await?; - wait_until( - &driver, - &format!( - "const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'offline' && root?.getAttribute('data-sync-pending-count') === '{expected_pending}'" - ), - ) - .await?; - } - driver.find(By::Css("[data-sync-retry]")).await?.click().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?; - - driver - .execute( - r#" - window.__upgradeReplay = []; - const source = new EventSource('/sync/acknowledgements?after=0'); - source.addEventListener('acknowledgement', (event) => { - window.__upgradeReplay.push({ id: event.lastEventId, body: JSON.parse(event.data) }); - if (window.__upgradeReplay.length === 3) source.close(); - }); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until(&driver, "return window.__upgradeReplay.length === 3").await?; - let replay = driver - .execute("return window.__upgradeReplay", Vec::new()) - .await? - .json() - .clone(); - assert_eq!( - replay, - serde_json::json!([ - { "id": "1", "body": { "commandId": "upgrade:1", "cardId": 1, "canonicalColumn": "done", "serverSequence": 1, "status": "accepted" } }, - { "id": "2", "body": { "commandId": "upgrade:2", "cardId": 2, "canonicalColumn": "done", "serverSequence": 2, "status": "accepted" } }, - { "id": "3", "body": { "commandId": "upgrade:3", "cardId": 3, "canonicalColumn": "done", "serverSequence": 3, "status": "accepted" } } - ]) - ); - assert_eq!(command_count(&driver).await?, 0); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebDriverResult<()> { - // 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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - for (const [causal, cardId] of [[1, '1'], [2, '999'], [3, '2']]) { - commands.add({ - id: `mixed:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'mixed', session: 'mixed-session', - causal, kind: 'reorder_card', cardId, targetColumn: 'done', eventKind: 'click', key: null, - }); - } - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true, "failed to seed mixed queue: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'rejected'", - ) - .await?; - tokio::time::sleep(Duration::from_millis(150)).await; - let rejected = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); const retry = root.querySelector('[data-sync-retry]'); return { phase: root.getAttribute('data-sync-phase'), pending: root.getAttribute('data-sync-pending-count'), uploaded: root.getAttribute('data-sync-uploaded-total'), ackSequence: root.getAttribute('data-sync-ack-sequence'), attempts: root.getAttribute('data-sync-attempts'), inFlight: root.getAttribute('data-sync-in-flight'), maxInFlight: root.getAttribute('data-sync-max-observed-in-flight'), kind: root.getAttribute('data-sync-error-kind'), errorStatus: root.getAttribute('data-sync-error-status'), reason: root.getAttribute('data-sync-error-reason'), rejectedId: root.getAttribute('data-sync-rejected-command-id'), retryDisabled: retry.disabled, status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(rejected["phase"], "rejected"); - assert_eq!(rejected["pending"], "2"); - assert_eq!(rejected["uploaded"], "1"); - assert_eq!(rejected["ackSequence"], "1"); - assert_eq!(rejected["attempts"], "1"); - assert_eq!(rejected["inFlight"], "0"); - assert_eq!(rejected["maxInFlight"], "1"); - assert_eq!(rejected["kind"], "permanent-rejection"); - assert_eq!(rejected["errorStatus"], "400"); - assert_eq!(rejected["reason"], "unknown card_id"); - assert_eq!(rejected["rejectedId"], "mixed:2"); - assert_eq!(rejected["retryDisabled"], true); - assert_eq!( - rejected["status"], - "Command mixed:2 was permanently rejected (400: unknown card_id); 2 durable commands remain queued for review." - ); - - let queued = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll(); - request.onsuccess = () => done(request.result.sort((a, b) => a.causal - b.causal).map(({ id, cardId }) => ({ id, cardId }))); - request.onerror = () => done({ error: request.error && request.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!( - queued, - serde_json::json!([ - { "id": "mixed:2", "cardId": "999" }, - { "id": "mixed:3", "cardId": "2" } - ]) - ); - - driver.goto(&format!("http://{app_addr}/")).await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[1]["title"], "Doing"); - assert_eq!(canonical[1]["cards"], serde_json::json!(["2"])); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> WebDriverResult<()> { - // test req: sync/017 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - for (let causal = 1; causal <= 3; causal += 1) { - commands.add({ - id: `pressure:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'pressure', session: 'pressure-session', - causal, kind: 'reorder_card', cardId: String(causal), targetColumn: 'done', eventKind: 'click', key: null, - }); - } - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true, "failed to seed pending work: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'backpressured'", - ) - .await?; - let bounded = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), pending: root.getAttribute('data-sync-pending-count'), limit: root.getAttribute('data-sync-upload-limit'), uploaded: root.getAttribute('data-sync-uploaded-this-run'), total: root.getAttribute('data-sync-uploaded-total'), maxInFlight: root.getAttribute('data-sync-max-observed-in-flight'), sequence: root.getAttribute('data-sync-ack-sequence'), manual: root.getAttribute('data-sync-manual-retry'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(bounded["phase"], "backpressured"); - assert_eq!(bounded["pending"], "1"); - assert_eq!(bounded["limit"], "2"); - assert_eq!(bounded["uploaded"], "2"); - assert_eq!(bounded["total"], "2"); - assert_eq!(bounded["maxInFlight"], "1"); - assert_eq!(bounded["sequence"], "2"); - assert_eq!(bounded["manual"], "available"); - assert_eq!( - bounded["status"], - "Upload limit 2 reached; 1 durable command remains queued. Retry now to continue." - ); - assert_eq!(command_count(&driver).await?, 1); - - driver.find(By::Css("[data-sync-retry]")).await?.click().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 recovered = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { pending: root.getAttribute('data-sync-pending-count'), uploaded: root.getAttribute('data-sync-uploaded-this-run'), total: root.getAttribute('data-sync-uploaded-total'), maxInFlight: root.getAttribute('data-sync-max-observed-in-flight'), sequence: root.getAttribute('data-sync-ack-sequence'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(recovered["pending"], "0"); - assert_eq!(recovered["uploaded"], "1"); - assert_eq!(recovered["total"], "3"); - assert_eq!(recovered["maxInFlight"], "1"); - assert_eq!(recovered["sequence"], "3"); - assert_eq!(recovered["status"], "Queued change acknowledged in done."); - assert_eq!(command_count(&driver).await?, 0); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application( -) -> WebDriverResult<()> { - // test req: sync/018 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_FAILURES", "3"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'tabs:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'tabs', session: 'tabs-session', - causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true); - - driver - .execute( - "sessionStorage.setItem('hemx-kanban-sync-tab-id', 'leader-seed'); return true;", - Vec::new(), - ) - .await?; - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'", - ) - .await?; - let leader = driver.window().await?; - let follower = driver.new_tab().await?; - driver.switch_to_window(follower.clone()).await?; - driver.goto(&format!("http://{app_addr}/")).await?; - driver - .execute( - "sessionStorage.setItem('hemx-kanban-sync-tab-id', 'follower-seed'); return true;", - Vec::new(), - ) - .await?; - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'standby'", - ) - .await?; - let standby = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), leader: root.getAttribute('data-sync-leader'), attempts: root.getAttribute('data-sync-attempts'), owner: root.getAttribute('data-sync-lease-owner'), tab: root.getAttribute('data-sync-tab-id'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(standby["phase"], "standby"); - assert_eq!(standby["leader"], "false"); - assert!(standby["attempts"].is_null()); - assert_ne!(standby["owner"], standby["tab"]); - assert_eq!(standby["status"], "Another tab owns sync; waiting for lease takeover."); - - driver.switch_to_window(leader).await?; - let first = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), leader: root.getAttribute('data-sync-leader'), attempts: root.getAttribute('data-sync-attempts'), pending: root.getAttribute('data-sync-pending-count'), owner: root.getAttribute('data-sync-lease-owner'), tab: root.getAttribute('data-sync-tab-id') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(first["phase"], "offline", "leader lost ownership: {first}"); - assert_eq!(first["leader"], "true"); - assert_eq!(first["attempts"], "3"); - assert_eq!(first["pending"], "1"); - driver.close_window().await?; - - driver.switch_to_window(follower).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'acknowledged'", - ) - .await?; - let takeover = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), attempts: root.getAttribute('data-sync-attempts'), pending: root.getAttribute('data-sync-pending-count'), sequence: root.getAttribute('data-sync-ack-sequence'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(takeover["phase"], "acknowledged"); - assert_eq!(takeover["attempts"], "1"); - assert_eq!(takeover["pending"], "0"); - assert_eq!(takeover["sequence"], "1"); - assert_eq!(takeover["status"], "Queued change acknowledged in done."); - assert_eq!(command_count(&driver).await?, 0); - - driver - .execute( - r#" - window.__tabReplay = []; - const source = new EventSource('/sync/acknowledgements?after=0'); - source.addEventListener('acknowledgement', (event) => { - window.__tabReplay.push({ id: event.lastEventId, body: JSON.parse(event.data) }); - setTimeout(() => source.close(), 25); - }); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until(&driver, "return window.__tabReplay.length === 1").await?; - tokio::time::sleep(Duration::from_millis(50)).await; - let replay = driver - .execute("return window.__tabReplay", Vec::new()) - .await? - .json() - .clone(); - assert_eq!(replay.as_array().map(Vec::len), Some(1)); - assert_eq!(replay[0]["id"], "1"); - assert_eq!(replay[0]["body"]["commandId"], "tabs:1"); - - driver.goto(&format!("http://{app_addr}/")).await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDriverResult<()> { - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_FAILURES", "3"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'offline-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'offline-actor', session: 'offline-session', - causal: 1, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true, "failed to seed offline command: {seeded}"); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'", - ) - .await?; - let exhausted = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), connection: root.getAttribute('data-sync-connection'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), maxAttempts: root.getAttribute('data-sync-max-attempts'), manual: root.getAttribute('data-sync-manual-retry'), error: root.getAttribute('data-sync-error'), status: root.querySelector('[role=status]').textContent, retry: root.querySelector('[data-sync-retry]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(exhausted["phase"], "offline"); - assert_eq!(exhausted["connection"], "offline"); - assert_eq!(exhausted["pending"], "1"); - assert_eq!(exhausted["attempts"], "3"); - assert_eq!(exhausted["maxAttempts"], "3"); - assert_eq!(exhausted["manual"], "available"); - assert_eq!(exhausted["error"], "sync upload failed with 503"); - assert_eq!( - exhausted["status"], - "Sync is offline after bounded retries; the durable command remains queued. Retry now when ready." - ); - assert_eq!(exhausted["retry"], "Retry sync now"); - let queued = command_count(&driver).await?; - assert_eq!(queued, 1); - - driver - .find(By::Css("[data-sync-retry]")) - .await? - .click() - .await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'acknowledged'", - ) - .await?; - let converged = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { connection: root.getAttribute('data-sync-connection'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), sequence: root.getAttribute('data-sync-ack-sequence'), column: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(converged["connection"], "online"); - assert_eq!(converged["pending"], "0"); - assert_eq!(converged["attempts"], "1"); - assert_eq!(converged["sequence"], "1"); - assert_eq!(converged["column"], "done"); - assert_eq!(converged["status"], "Queued change acknowledged in done."); - assert!(converged["error"].is_null()); - assert_eq!(command_count(&driver).await?, 0); - - driver.goto(&format!("http://{app_addr}/")).await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["2", "3"])); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() -> WebDriverResult<()> -{ - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_RETAINED_AFTER", "1"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded_server = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - fetch('/sync/commands?command_id=history%3A1&card_id=1', { method: 'POST' }) - .then(async (response) => done({ status: response.status, body: await response.json() })) - .catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded_server["status"], 200); - assert_eq!(seeded_server["body"]["serverSequence"], 1); - - let seeded_local = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onupgradeneeded = () => { - const database = open.result; - if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' }); - if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'history:2', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session', - causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded_local["seeded"], true); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'rebased'", - ) - .await?; - let fallback = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), ackSequence: root.getAttribute('data-sync-ack-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), snapshotSchema: root.getAttribute('data-sync-snapshot-schema'), snapshotCards: root.getAttribute('data-sync-snapshot-card-count'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), decision: root.getAttribute('data-sync-rebase-decision'), reason: root.getAttribute('data-sync-rebase-reason'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(fallback["phase"], "rebased"); - assert_eq!(fallback["uploadSequence"], "2"); - assert_eq!(fallback["ackSequence"], "2"); - assert_eq!(fallback["snapshotSequence"], "2"); - assert_eq!(fallback["snapshotSchema"], "1"); - assert_eq!(fallback["snapshotCards"], "3"); - assert_eq!(fallback["pending"], "0"); - assert_eq!(fallback["rebasePending"], "1", "unexpected rebase state: {fallback}"); - assert_eq!(fallback["decision"], "converged"); - assert_eq!(fallback["reason"], "intent-already-canonical"); - assert_eq!(fallback["canonicalColumn"], "done"); - assert_eq!( - fallback["status"], - "Canonical snapshot 2 already satisfies history:2; committed and removed the pending command." - ); - assert!(fallback["error"].is_null()); - assert_eq!(command_count(&driver).await?, 0); - let committed = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const tx = open.result.transaction('meta', 'readonly'); - const meta = tx.objectStore('meta'); - const cursor = meta.get('acknowledgementCursor'); - const snapshot = meta.get('canonicalSnapshot'); - tx.oncomplete = () => done({ cursor: cursor.result, snapshot: snapshot.result }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(committed["cursor"], 2); - assert_eq!(committed["snapshot"]["schemaVersion"], 1); - assert_eq!(committed["snapshot"]["serverSequence"], 2); - - driver.goto(&format!("http://{app_addr}/")).await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "2", "3"])); - - let divergent_server = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const local = await fetch('/sync/commands?command_id=history%3A3&card_id=2&column=done', { method: 'POST' }); - const remote = await fetch('/sync/commands?command_id=remote%3A4&card_id=2&column=doing', { method: 'POST' }); - done({ - local: { status: local.status, body: await local.json() }, - remote: { status: remote.status, body: await remote.json() }, - }); - })().catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(divergent_server["local"]["status"], 200); - assert_eq!(divergent_server["local"]["body"]["serverSequence"], 3); - assert_eq!(divergent_server["remote"]["status"], 200); - assert_eq!(divergent_server["remote"]["body"]["serverSequence"], 4); - assert_eq!(divergent_server["remote"]["body"]["canonicalColumn"], "doing"); - - let seeded_conflict = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - commands.add({ - id: 'history:3', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session', - causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - commands.add({ - id: 'history:4', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session', - causal: 4, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded_conflict["seeded"], true); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'conflicted'", - ) - .await?; - let conflicted = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), decision: root.getAttribute('data-sync-rebase-decision'), reason: root.getAttribute('data-sync-rebase-reason'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), resolutionDisabled: root.querySelector('[data-sync-use-canonical]').disabled, diagnosticConflicts: root.getAttribute('data-sync-diag-conflicts'), diagnosticVisible: root.querySelector('[data-sync-diagnostics]').textContent, status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(conflicted["phase"], "conflicted", "unexpected conflict state: {conflicted}"); - assert_eq!(conflicted["uploadSequence"], "3"); - assert_eq!(conflicted["snapshotSequence"], "4"); - assert_eq!(conflicted["pending"], "2"); - assert_eq!(conflicted["rebasePending"], "2"); - assert_eq!(conflicted["decision"], "conflicted"); - assert_eq!(conflicted["reason"], "canonical-state-diverged"); - assert_eq!(conflicted["canonicalColumn"], "doing"); - assert_eq!(conflicted["resolutionDisabled"], false); - assert_eq!(conflicted["diagnosticConflicts"], "1"); - assert!(conflicted["diagnosticVisible"].as_str().unwrap().ends_with("conflicts 1; rejections 0.")); - assert_eq!( - conflicted["status"], - "Canonical snapshot 4 conflicts with history:3 (canonical-state-diverged); the pending command remains queued." - ); - assert!(conflicted["error"].is_null()); - assert_eq!(command_count(&driver).await?, 2); - let preserved = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const tx = open.result.transaction(['commands', 'meta'], 'readonly'); - const commands = tx.objectStore('commands').getAll(); - const cursor = tx.objectStore('meta').get('acknowledgementCursor'); - const snapshot = tx.objectStore('meta').get('canonicalSnapshot'); - tx.oncomplete = () => done({ commands: commands.result.sort((left, right) => left.causal - right.causal), cursor: cursor.result, snapshot: snapshot.result }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(preserved["commands"][0]["id"], "history:3"); - assert_eq!(preserved["commands"][1]["id"], "history:4"); - assert_eq!(preserved["cursor"], 2); - assert_eq!(preserved["snapshot"]["serverSequence"], 2); - - driver.goto(&format!("http://{app_addr}/")).await?; - let divergent_board = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(divergent_board[1]["title"], "Doing"); - assert_eq!(divergent_board[1]["cards"], serde_json::json!(["2"])); - assert_eq!(divergent_board[2]["cards"], serde_json::json!(["1", "3"])); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'conflicted'", - ) - .await?; - driver - .find(By::Css("[data-sync-use-canonical]")) - .await? - .click() - .await?; - wait_until( - &driver, - "const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'rebased' && root?.getAttribute('data-sync-pending-count') === '0'", - ) - .await?; - let resolved = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { resolution: root.getAttribute('data-sync-conflict-resolution'), resolvedCommand: root.getAttribute('data-sync-resolved-command-id'), pending: root.getAttribute('data-sync-pending-count'), ackSequence: root.getAttribute('data-sync-ack-sequence'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(resolved["resolution"], "used-canonical-state"); - assert_eq!(resolved["resolvedCommand"], "history:3"); - assert_eq!(resolved["pending"], "0"); - assert_eq!(resolved["ackSequence"], "5"); - assert_eq!(resolved["canonicalColumn"], "done"); - assert_eq!( - resolved["status"], - "Canonical snapshot 5 already satisfies history:4; committed and removed the pending command." - ); - assert_eq!(command_count(&driver).await?, 0); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material( -) -> WebDriverResult<()> { - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_FAILURES", "3"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1', 3); - open.onupgradeneeded = () => { - const database = open.result; - const commands = database.createObjectStore('commands', { keyPath: 'id' }); - commands.createIndex('byAccountPartition', 'accountPartition'); - database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'diag-secret-command', schemaVersion: 2, accountPartition: 'demo:demo', - actor: 'private-actor', session: 'super-secret-session-token', causal: 1, - queuedAt: Date.now() - 15000, kind: 'reorder_card', cardId: '1', targetColumn: 'done', - eventKind: 'click', key: null, privatePayload: 'customer-secret-payload', - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded["seeded"], true); - - 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') === 'offline' && root?.getAttribute('data-sync-pending-count') === '1'", - ) - .await?; - let queued = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); const names = ['data-sync-diag-queue-count','data-sync-diag-oldest-age-bucket','data-sync-diag-cursor','data-sync-diag-ack-latency-bucket','data-sync-diag-conflicts','data-sync-diag-rejections']; const diagnostics = Object.fromEntries(names.map((name) => [name, root.getAttribute(name)])); return { diagnostics, visible: root.querySelector('[data-sync-diagnostics]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!( - queued["diagnostics"], - serde_json::json!({ - "data-sync-diag-queue-count": "1", - "data-sync-diag-oldest-age-bucket": "10s-1m", - "data-sync-diag-cursor": "0", - "data-sync-diag-ack-latency-bucket": "none", - "data-sync-diag-conflicts": "0", - "data-sync-diag-rejections": "0" - }) - ); - assert!(queued["visible"].as_str().unwrap().contains("Queue 1; oldest 10s-1m; cursor 0; acknowledgement none")); - - driver.find(By::Css("[data-sync-retry]")).await?.click().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 proof = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); const names = ['data-sync-diag-queue-count','data-sync-diag-oldest-age-bucket','data-sync-diag-cursor','data-sync-diag-ack-latency-bucket','data-sync-diag-conflicts','data-sync-diag-rejections']; const diagnostics = Object.fromEntries(names.map((name) => [name, root.getAttribute(name)])); return { diagnostics, visible: root.querySelector('[data-sync-diagnostics]').textContent, rootHtml: root.outerHTML, cookies: document.cookie }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(proof["diagnostics"]["data-sync-diag-queue-count"], "0"); - assert_eq!(proof["diagnostics"]["data-sync-diag-oldest-age-bucket"], "empty"); - assert_eq!(proof["diagnostics"]["data-sync-diag-cursor"], "1"); - assert_eq!(proof["diagnostics"]["data-sync-diag-conflicts"], "0"); - assert_eq!(proof["diagnostics"]["data-sync-diag-rejections"], "0"); - assert!(matches!( - proof["diagnostics"]["data-sync-diag-ack-latency-bucket"].as_str(), - Some("lt-50ms" | "50ms-250ms" | "250ms-1s" | "gte-1s") - )); - let visible = proof["visible"].as_str().unwrap(); - assert!(visible.contains("Queue 0; oldest empty; cursor 1; acknowledgement")); - assert!(visible.len() < 120); - let exposed = format!("{}\n{}\n{}\n{}", queued["visible"], proof["visible"], proof["rootHtml"], proof["cookies"]); - for secret in [ - "diag-secret-command", - "private-actor", - "super-secret-session-token", - "customer-secret-payload", - "cardId", - "privatePayload", - ] { - assert!(!exposed.contains(secret), "diagnostics leaked {secret}: {exposed}"); - } - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> WebDriverResult<()> { - // 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 = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_RETAINED_AFTER", "1"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start ready hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let seeded_server = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const command = (id, card, column = 'done') => fetch(`/sync/commands?command_id=${encodeURIComponent(id)}&card_id=${card}&column=${column}`, { method: 'POST' }); - (async () => { - const statuses = []; - statuses.push((await command('keep:1', 1)).status); - statuses.push((await command('keep:2', 2)).status); - statuses.push((await command('keep:3', 2)).status); - statuses.push((await command('keep:external', 2, 'doing')).status); - done({ statuses }); - })().catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded_server["statuses"], serde_json::json!([200, 200, 200, 200])); - - let seeded_local = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1', 3); - open.onupgradeneeded = () => { - const database = open.result; - const commands = database.createObjectStore('commands', { keyPath: 'id' }); - commands.createIndex('byAccountPartition', 'accountPartition'); - database.createObjectStore('meta'); - }; - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - commands.add({ - id: 'keep:3', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'keep', session: 'keep-session', - causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, - }); - commands.add({ - id: 'keep:4', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'keep', session: 'keep-session', - causal: 4, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null, - }); - tx.oncomplete = () => done({ seeded: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(seeded_local["seeded"], true); - - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'conflicted'", - ) - .await?; - driver - .execute( - r#" - window.__keepRejections = 0; - window.__keepFailures = 0; - const originalFetch = window.fetch.bind(window); - window.fetch = (input, init = {}) => { - const url = new URL(typeof input === 'string' ? input : input.url, location.href); - if (url.pathname === '/sync/commands' && url.searchParams.get('command_id') === 'keep:3:keep:4') { - if (window.__keepRejections < 1) { - window.__keepRejections += 1; - return Promise.resolve(new Response(JSON.stringify({ kind: 'command-conflict', error: 'injected stale keep-local decision' }), { - status: 409, - headers: { 'content-type': 'application/json' }, - })); - } - if (window.__keepFailures < 3) { - window.__keepFailures += 1; - return Promise.resolve(new Response(JSON.stringify({ kind: 'transport-failure', error: 'injected keep-local retry' }), { - status: 503, - headers: { 'content-type': 'application/json' }, - })); - } - } - return originalFetch(input, init); - }; - return true; - "#, - Vec::new(), - ) - .await?; - driver - .find(By::Css("[data-sync-keep-local]")) - .await? - .click() - .await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'resolution-rejected'", - ) - .await?; - let rejected = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const root = document.querySelector('[data-kanban-sync]'); - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll(); - request.onsuccess = () => done({ - resolution: root.getAttribute('data-sync-conflict-resolution'), - resolutionDisabled: root.querySelector('[data-sync-keep-local]').disabled, - pending: root.getAttribute('data-sync-pending-count'), - diagnosticRejections: root.getAttribute('data-sync-diag-rejections'), - diagnosticVisible: root.querySelector('[data-sync-diagnostics]').textContent, - rejections: window.__keepRejections, - commands: request.result.sort((left, right) => left.causal - right.causal).map(({ id, causal, cardId }) => ({ id, causal, cardId })), - }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(rejected["resolution"], "keep-local-rejected"); - assert_eq!(rejected["resolutionDisabled"], false); - assert_eq!(rejected["pending"], "2"); - assert_eq!(rejected["diagnosticRejections"], "1"); - assert!(rejected["diagnosticVisible"].as_str().unwrap().ends_with("conflicts 1; rejections 1.")); - assert_eq!(rejected["rejections"], 1); - assert_eq!( - rejected["commands"], - serde_json::json!([ - { "id": "keep:3", "causal": 3, "cardId": "2" }, - { "id": "keep:4", "causal": 4, "cardId": "1" } - ]) - ); - - driver - .find(By::Css("[data-sync-keep-local]")) - .await? - .click() - .await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'", - ) - .await?; - let retrying = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const root = document.querySelector('[data-kanban-sync]'); - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll(); - request.onsuccess = () => done({ - phase: root.getAttribute('data-sync-phase'), - resolution: root.getAttribute('data-sync-conflict-resolution'), - resolutionCommand: root.getAttribute('data-sync-resolution-command-id'), - resolvedCommand: root.getAttribute('data-sync-resolved-command-id'), - attempts: root.getAttribute('data-sync-attempts'), - pending: root.getAttribute('data-sync-pending-count'), - manualRetry: root.getAttribute('data-sync-manual-retry'), - failures: window.__keepFailures, - commands: request.result.sort((left, right) => left.causal - right.causal).map(({ id, causal, cardId }) => ({ id, causal, cardId })), - }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(retrying["phase"], "offline"); - assert_eq!(retrying["resolution"], "keep-local-pending"); - assert_eq!(retrying["resolutionCommand"], "keep:3:keep:4"); - assert_eq!(retrying["resolvedCommand"], "keep:3"); - assert_eq!(retrying["attempts"], "3"); - assert_eq!(retrying["pending"], "2"); - assert_eq!(retrying["manualRetry"], "available"); - assert_eq!(retrying["failures"], 3); - assert_eq!( - retrying["commands"], - serde_json::json!([ - { "id": "keep:3", "causal": 3, "cardId": "2" }, - { "id": "keep:4", "causal": 4, "cardId": "1" } - ]) - ); - - driver.find(By::Css("[data-sync-retry]")).await?.click().await?; - wait_until( - &driver, - "const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'rebased' && root?.getAttribute('data-sync-pending-count') === '0'", - ) - .await?; - let resolved = driver - .execute( - "const root = document.querySelector('[data-kanban-sync]'); return { resolution: root.getAttribute('data-sync-conflict-resolution'), resolvedCommand: root.getAttribute('data-sync-resolved-command-id'), pending: root.getAttribute('data-sync-pending-count'), ackSequence: root.getAttribute('data-sync-ack-sequence'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(resolved["resolution"], "kept-local-change"); - assert_eq!(resolved["resolvedCommand"], "keep:3"); - assert_eq!(resolved["pending"], "0"); - assert_eq!(resolved["ackSequence"], "6"); - assert_eq!(resolved["canonicalColumn"], "done"); - assert_eq!( - resolved["status"], - "Canonical snapshot 6 already satisfies keep:4; committed and removed the pending command." - ); - assert_eq!(command_count(&driver).await?, 0); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> WebDriverResult<()> { - // test req: security/005 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "const root = document.querySelector('[data-kanban-sync]'); return window.hemx && root?.getAttribute('data-sync-phase') === 'idle'", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const root = document.querySelector('[data-kanban-sync]'); - const before = root.outerHTML; - const validEmpty = new Uint8Array([ - 72, 69, 77, 88, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, - ]); - const invalidKind = new Uint8Array([...validEmpty.slice(0, 16), 1, 0, 0, 0, 255]); - const unknownVersion = validEmpty.slice(); - unknownVersion[4] = 99; - const cases = [ - ["malformed", new Uint8Array([0, 1, 2, 3])], - ["truncated", validEmpty.slice(0, -1)], - ["trailing", new Uint8Array([...validEmpty, 1])], - ["unknown-version", unknownVersion], - ["invalid-kind", invalidKind], - ["oversized", new Uint8Array(1024 * 1024 + 1)], - ]; - const batchErrors = Object.fromEntries(cases.map(([name, bytes]) => { - try { - window.hemx.decodeBatch(bytes.buffer); - return [name, null]; - } catch (error) { - return [name, String(error)]; - } - })); - const stateErrors = {}; - for (const [name, encoded] of [ - ["truncated", "AQ"], - ["trailing", "AAA"], - ["oversized", "A".repeat(Math.ceil((1024 * 1024) * 4 / 3) + 8)], - ]) { - try { - window.hemx.decodeAtomState(encoded); - stateErrors[name] = null; - } catch (error) { - stateErrors[name] = String(error); - } - } - const { validateQueuedCommand } = await import('/sync.js'); - const command = { - id: 'actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'actor', - session: 'session', causal: 1, queuedAt: 1, kind: 'reorder_card', cardId: '1', - targetColumn: 'done', eventKind: 'click', key: null, - }; - const commandErrors = {}; - for (const [name, candidate] of [ - ["unknown-version", { ...command, schemaVersion: 99 }], - ["invalid-kind", { ...command, kind: 'execute_script' }], - ["oversized-id", { ...command, id: 'x'.repeat(257) }], - ]) { - try { - validateQueuedCommand(candidate); - commandErrors[name] = null; - } catch (error) { - commandErrors[name] = String(error); - } - } - done({ - batchErrors, - stateErrors, - commandErrors, - unchanged: before === root.outerHTML, - }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "adversarial proof failed: {proof}"); - for group in ["batchErrors", "stateErrors", "commandErrors"] { - let errors = proof[group].as_object().expect("error group"); - assert!( - errors.values().all(|error| error.as_str().is_some_and(|message| !message.is_empty())), - "{group} accepted an adversarial input: {proof}" - ); - } - assert_eq!(proof["unchanged"], true, "input rejection mutated the UI: {proof}"); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn canonical_acknowledgement_updates_generated_atom_over_ordinary_batch( -) -> WebDriverResult<()> { - // test req: sync/006 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - wait_until( - &driver, - "return document.querySelector('#sync-ack')?.textContent.trim() === 'pending'", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const commandId = 'ack-proof:1'; - const accepted = await fetch(`/sync/commands?command_id=${encodeURIComponent(commandId)}&card_id=1&column=done`, { method: 'POST' }); - const canonical = await accepted.json(); - const root = document.querySelector('[data-hemx-root]'); - let acknowledgementEvent; - root.addEventListener('hemx:sync-ack', (event) => { acknowledgementEvent = event.detail; }, { once: true }); - const source = new EventSource(`/sync/ack?command_id=${encodeURIComponent(commandId)}`); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - source.close(); - reject(new Error('ack batch timed out')); - }, 5000); - source.addEventListener('hemx', (event) => { - clearTimeout(timeout); - const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); - const raw = atob(padded); - const bytes = Uint8Array.from(raw, (character) => character.charCodeAt(0)); - window.hemx.applyBatch(bytes.buffer, root); - source.close(); - resolve(); - }); - source.onerror = () => { - clearTimeout(timeout); - source.close(); - reject(new Error('ack batch failed')); - }; - }); - done({ - acceptedStatus: accepted.status, - canonical, - atom: document.querySelector('#sync-ack').textContent.trim(), - status: document.body.textContent, - acknowledgementEvent, - }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "typed acknowledgement failed: {proof}"); - assert_eq!(proof["acceptedStatus"], 200); - assert_eq!(proof["canonical"]["commandId"], "ack-proof:1"); - assert_eq!(proof["canonical"]["serverSequence"], 1); - assert_eq!(proof["atom"], "acknowledged"); - assert!(proof["status"] - .as_str() - .is_some_and(|status| status.contains("ack-proof:1 at server sequence 1"))); - assert!(proof["acknowledgementEvent"].as_str().is_some_and(|payload| payload.contains("atomId"))); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn typed_presence_join_leave_updates_generated_atom_over_sse() -> WebDriverResult<()> { - // 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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - wait_until(&driver, "return document.body.textContent.includes('tick #0')").await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const root = document.querySelector('[data-hemx-root]'); - const apply = (url) => new Promise((resolve, reject) => { - const source = new EventSource(url); - const timeout = setTimeout(() => { - source.close(); - reject(new Error(`presence event timed out: ${url}`)); - }, 5000); - source.addEventListener('hemx', (event) => { - clearTimeout(timeout); - const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); - const raw = atob(padded); - const bytes = Uint8Array.from(raw, (character) => character.charCodeAt(0)); - window.hemx.applyBatch(bytes.buffer, root); - source.close(); - resolve(document.body.textContent); - }); - source.onerror = () => { - clearTimeout(timeout); - source.close(); - reject(new Error(`presence event failed: ${url}`)); - }; - }); - const joinedAda = await apply('/sync/broadcast?channel=board&action=join&member=ada'); - const duplicateAda = await apply('/sync/broadcast?channel=board&action=join&member=ada'); - const joinedGrace = await apply('/sync/broadcast?channel=board&action=join&member=grace'); - const leftAda = await apply('/sync/broadcast?channel=board&action=leave&member=ada'); - done({ - joinedAda: joinedAda.includes('tick #1'), - duplicateAda: duplicateAda.includes('tick #1'), - joinedGrace: joinedGrace.includes('tick #2'), - leftAda: leftAda.includes('tick #1'), - }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "typed presence failed: {proof}"); - assert_eq!(proof["joinedAda"], true, "{proof}"); - assert_eq!(proof["duplicateAda"], true, "{proof}"); - assert_eq!(proof["joinedGrace"], true, "{proof}"); - assert_eq!(proof["leftAda"], true, "{proof}"); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn ordinary_browser_request_exposes_deadline_and_cancels_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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - wait_until( - &driver, - "return document.querySelector('[data-hemx-root]')?.getAttribute('data-hemx-request-timeout-ms') === '10000'", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const root = document.querySelector('[data-hemx-root]'); - const form = root.querySelector('form'); - form.querySelector('[name=title]').value = 'cancel me'; - let started = false; - let abortDetail; - window.fetch = (_url, init) => new Promise((_resolve, reject) => { - started = true; - init.signal.addEventListener('abort', () => { - abortDetail = { name: init.signal.reason.name, message: init.signal.reason.message }; - reject(init.signal.reason); - }, { once: true }); - }); - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - while (!started) await new Promise((resolve) => setTimeout(resolve, 1)); - window.dispatchEvent(new PageTransitionEvent('pagehide')); - while (!abortDetail || form.hasAttribute('data-hemx-pending')) { - await new Promise((resolve) => setTimeout(resolve, 1)); - } - done({ - abortDetail, - timeoutMs: root.getAttribute('data-hemx-request-timeout-ms'), - pending: form.hasAttribute('data-hemx-pending'), - }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "ordinary cancellation failed: {proof}"); - assert_eq!(proof["timeoutMs"], "10000"); - assert_eq!(proof["abortDetail"]["name"], "AbortError"); - assert!(proof["abortDetail"]["message"] - .as_str() - .is_some_and(|message| message.contains("cancelled because page is hidden"))); - assert_eq!(proof["pending"], false); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation( -) -> WebDriverResult<()> { - // test req: operations/004 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - let mut app_command = app_command(); - app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_ACK_HEARTBEAT_MS", "25"); - let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) - .expect("start hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - for (let index = 1; index <= 65; index += 1) { - const query = new URLSearchParams({ - command_id: `buffer:${index}`, - card_id: '1', - column: 'done', - }); - const response = await fetch(`/sync/commands?${query}`, { method: 'POST' }); - if (!response.ok) throw new Error(`command ${index} failed with ${response.status}`); - } - - const observe = (url, eventName, timeoutMs = 5000) => new Promise((resolve, reject) => { - const started = performance.now(); - const source = new EventSource(url); - let opens = 0; - let errors = 0; - const timeout = setTimeout(() => { - source.close(); - reject(new Error(`${eventName} timed out after ${timeoutMs} ms`)); - }, timeoutMs); - source.addEventListener('open', () => { opens += 1; }); - source.addEventListener('error', () => { errors += 1; }); - source.addEventListener(eventName, (event) => { - clearTimeout(timeout); - const data = JSON.parse(event.data); - source.close(); - resolve({ - data, - opens, - errors, - elapsedMs: performance.now() - started, - cancelled: source.readyState === EventSource.CLOSED, - }); - }); - }); - - const slowConsumer = await observe( - '/sync/acknowledgements?after=0&reconnect=slow-consumer-proof', - 'snapshot-required', - ); - const heartbeat = await observe( - '/sync/acknowledgements?after=65&reconnect=heartbeat-proof', - 'heartbeat', - ); - done({ slowConsumer, heartbeat }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "stream bounds failed: {proof}"); - assert_eq!(proof["slowConsumer"]["data"]["reason"], "slow-consumer"); - assert_eq!(proof["slowConsumer"]["data"]["pendingCount"], 65); - assert_eq!(proof["slowConsumer"]["data"]["bufferLimit"], 64); - assert_eq!(proof["slowConsumer"]["opens"], 4); - assert!(proof["slowConsumer"]["errors"].as_u64().is_some_and(|errors| errors >= 3)); - assert!(proof["slowConsumer"]["elapsedMs"] - .as_f64() - .is_some_and(|elapsed| (700.0..5_000.0).contains(&elapsed))); - assert_eq!(proof["slowConsumer"]["cancelled"], true); - assert_eq!(proof["heartbeat"]["data"]["status"], "ok"); - assert_eq!(proof["heartbeat"]["opens"], 4); - assert!(proof["heartbeat"]["errors"].as_u64().is_some_and(|errors| errors >= 3)); - assert_eq!(proof["heartbeat"]["cancelled"], true); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const { fetchWithTimeout } = await import('/sync.js'); - const pendingFetch = (_input, init) => new Promise((_resolve, reject) => { - init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); - }); - const started = performance.now(); - let timeout; - try { - await fetchWithTimeout('/never-timeout', {}, pendingFetch, 40); - } catch (error) { - timeout = { name: error.name, message: error.message, elapsedMs: performance.now() - started }; - } - const cancellationPromise = fetchWithTimeout('/never-pagehide', {}, pendingFetch, 10_000) - .then(() => ({ resolved: true })) - .catch((error) => ({ name: error.name, message: error.message })); - window.dispatchEvent(new PageTransitionEvent('pagehide')); - done({ timeout, cancellation: await cancellationPromise }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "bounded request failed: {proof}"); - assert_eq!(proof["timeout"]["name"], "TimeoutError", "{proof}"); - assert!(proof["timeout"]["message"] - .as_str() - .is_some_and(|message| message.contains("40 ms"))); - assert!(proof["timeout"]["elapsedMs"] - .as_f64() - .is_some_and(|elapsed| (35.0..1_000.0).contains(&elapsed))); - assert_eq!(proof["cancellation"]["name"], "AbortError", "{proof}"); - assert_eq!( - proof["cancellation"]["message"], - "sync cancelled because page is hidden" - ); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -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 = 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"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/sync-demo")).await?; - wait_until( - &driver, - "return document.querySelector('[data-kanban-sync]')?.hasAttribute('data-sync-database-version')", - ) - .await?; - let proof = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const acceptedResponse = await fetch('/sync/commands?command_id=deterministic%3A1&card_id=1&column=done', { method: 'POST' }); - const accepted = await acceptedResponse.json(); - const snapshotResponse = await fetch('/sync/snapshot'); - const snapshot = await snapshotResponse.json(); - const commands = [{ id: accepted.commandId, cardId: String(accepted.cardId), kind: 'reorder_card' }]; - const results = [accepted]; - const before = JSON.stringify({ snapshot, commands, results }); - const { reconcileServerAuthoritative } = await import('/sync.js'); - const first = reconcileServerAuthoritative(snapshot, commands, results); - const second = reconcileServerAuthoritative( - structuredClone(snapshot), - structuredClone(commands), - structuredClone(results), - ); - done({ - acceptedStatus: acceptedResponse.status, - snapshotStatus: snapshotResponse.status, - first, - second, - inputsUnchanged: before === JSON.stringify({ snapshot, commands, results }), - }); - })().catch((error) => done({ error: String(error), stack: error?.stack })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(proof["error"].is_null(), "reconciliation failed: {proof}"); - assert_eq!(proof["acceptedStatus"], 200); - assert_eq!(proof["snapshotStatus"], 200); - assert_eq!(proof["first"], proof["second"]); - assert_eq!(proof["inputsUnchanged"], true); - assert_eq!(proof["first"]["model"], "server-authoritative-v1"); - assert_eq!(proof["first"]["snapshotSequence"], 1); - assert_eq!(proof["first"]["serverResultCursor"], 1); - assert_eq!(proof["first"]["serverResultCount"], 1); - assert_eq!(proof["first"]["commandCount"], 1); - assert_eq!(proof["first"]["retainedCommandCount"], 0); - assert_eq!(proof["first"]["decision"]["kind"], "converged"); - assert_eq!( - proof["first"]["decision"]["reason"], - "intent-already-canonical" - ); - assert_eq!(proof["first"]["decision"]["canonicalColumn"], "done"); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn canonical_acknowledgement_survives_server_restart() -> WebDriverResult<()> { - // test req: sync/001 req: sync/005 req: sync/007 req: sync/008 req: sync/013 - let app_port = available_port(); - let app_addr = format!("127.0.0.1:{app_port}"); - let store = std::env::temp_dir().join(format!( - "hemx-kanban-sync-{}-{app_port}.json", - std::process::id() - )); - let _ = fs::remove_file(&store); - - let mut first_app_command = app_command(); - first_app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_STORE", &store); - let first_app = TestProcess::start( - first_app_command, - "hemx-kanban-first", - &app_addr, - STARTUP_TIMEOUT, - ) - .expect("start first hemx-kanban"); - - 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 = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{app_addr}/")).await?; - let accepted = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - fetch('/sync/commands?command_id=restart-proof%3A1&card_id=1', { method: 'POST' }) - .then(async (response) => done({ status: response.status, body: await response.json() })) - .catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(accepted["status"], 200); - assert_eq!(accepted["body"]["serverSequence"], 1); - assert_eq!(accepted["body"]["canonicalColumn"], "done"); - assert!(store.is_file(), "server did not materialize sync store"); - let persisted = fs::read_to_string(&store).expect("read sync store"); - assert!(persisted.contains("restart-proof:1")); - assert!(persisted.contains("\"schemaVersion\": 2")); - assert!(persisted.contains("\"tenant\": \"demo\"")); - - drop(first_app); - let mut second_app_command = app_command(); - second_app_command - .env("HEMX_KANBAN_ADDR", &app_addr) - .env("HEMX_KANBAN_SYNC_STORE", &store); - let second_app = TestProcess::start( - second_app_command, - "hemx-kanban-second", - &app_addr, - STARTUP_TIMEOUT, - ) - .expect("restart hemx-kanban from durable sync store"); - - let after_restart = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - fetch('/sync/commands?command_id=restart-proof%3A1&card_id=1', { method: 'POST' }) - .then(async (response) => done({ status: response.status, body: await response.json() })) - .catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(after_restart["status"], 200); - assert_eq!(after_restart["body"], accepted["body"]); - - driver - .execute( - r#" - window.__restartReplay = null; - const source = new EventSource('/sync/acknowledgements?after=0'); - source.addEventListener('acknowledgement', (event) => { - window.__restartReplay = { id: event.lastEventId, body: JSON.parse(event.data) }; - source.close(); - }); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until(&driver, "return window.__restartReplay !== null").await?; - let replay = driver - .execute("return window.__restartReplay", Vec::new()) - .await? - .json() - .clone(); - assert_eq!(replay["id"], "1"); - assert_eq!(replay["body"], accepted["body"]); - - driver.refresh().await?; - let canonical = driver - .execute( - "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(canonical[2]["title"], "Done"); - assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); - drop(second_app); - Ok(()) - } - .await; - let quit = driver.quit().await; - let _ = fs::remove_file(&store); - result.and(quit) -} - -async fn command_count(driver: &WebDriver) -> WebDriverResult { - let count = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const request = open.result.transaction('commands', 'readonly').objectStore('commands').count(); - request.onsuccess = () => done(request.result); - request.onerror = () => done({ error: request.error && request.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - Ok(count.as_u64().expect("durable command count")) -} - -async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { - for _ in 0..200 { - if driver.execute(script, Vec::new()).await?.json().as_bool() == Some(true) { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - let snapshot = driver - .execute( - "const sync = document.querySelector('[data-kanban-sync]'); return { url: location.href, body: document.body.textContent, sync: sync ? Object.fromEntries([...sync.attributes].map((attribute) => [attribute.name, attribute.value])) : null }", - Vec::new(), - ) - .await? - .json() - .clone(); - panic!("browser condition timed out: {script}; snapshot: {snapshot}"); -} - -fn available_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("reserve browser test port") - .local_addr() - .expect("browser test address") - .port() -} diff --git a/examples/kanban/tests/fixtures/legacy-sync.js b/examples/kanban/tests/fixtures/legacy-sync.js deleted file mode 100644 index 4ae4f8f..0000000 --- a/examples/kanban/tests/fixtures/legacy-sync.js +++ /dev/null @@ -1,755 +0,0 @@ -const DATABASE = "hemx-kanban-v1"; -const DATABASE_VERSION = 3; -const COMMANDS = "commands"; -const ACCOUNT_INDEX = "byAccountPartition"; -const COMMAND_SCHEMA = 2; -const LEGACY_COMMAND_SCHEMA = 1; -const MIGRATION_KEY = "commandSchemaMigration"; -const MAX_ATTEMPTS = 3; -const BACKOFF_MS = [25, 50]; -const REQUEST_TIMEOUT_MS = 1_000; -const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT = 64; -const root = document.querySelector("[data-kanban-sync]"); -const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID(); -const LEASE_MS = 5000; -const LEASE_POLL_MS = 100; -let database; -let accountPartition; -let uploadLimit; -let retryTimer; -let leaseTimer; -let acknowledgementSource; -const activeRequests = new Set(); -let synchronizing = false; -let uploadsThisRun = 0; -let uploadedTotal = 0; -let inFlightUploads = 0; -let maxObservedInFlight = 0; -let acknowledgementStartedAt; -let conflictCount = 0; -let rejectionCount = 0; -let activeConflict; -let manualRetryCommand; -let stopped = false; - -class UploadError extends Error { - constructor(status, retryable, kind, reason) { - super(`sync upload failed with ${status}`); - this.name = "UploadError"; - this.status = status; - this.retryable = retryable; - this.kind = kind; - this.reason = reason; - } -} - -// req: operations/003 -export async function fetchWithTimeout( - input, - init = {}, - fetchImplementation = fetch, - timeoutMs = REQUEST_TIMEOUT_MS, -) { - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { - throw new TypeError("sync request timeout must be a positive integer"); - } - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(new DOMException(`sync request timed out after ${timeoutMs} ms`, "TimeoutError")), - timeoutMs, - ); - activeRequests.add(controller); - try { - return await fetchImplementation(input, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - activeRequests.delete(controller); - } -} - -function requestResult(request) { - return new Promise((resolve, reject) => { - request.addEventListener("success", () => resolve(request.result), { once: true }); - request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true }); - }); -} - -function transactionDone(transaction) { - return new Promise((resolve, reject) => { - transaction.addEventListener("complete", resolve, { once: true }); - transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true }); - transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true }); - }); -} - -function migrateCommandLog(request, oldVersion) { - const database = request.result; - const commands = database.objectStoreNames.contains(COMMANDS) - ? request.transaction.objectStore(COMMANDS) - : database.createObjectStore(COMMANDS, { keyPath: "id" }); - if (!commands.indexNames.contains(ACCOUNT_INDEX)) commands.createIndex(ACCOUNT_INDEX, "accountPartition"); - if (!database.objectStoreNames.contains("meta")) database.createObjectStore("meta"); - if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return; - const transaction = request.transaction; - const meta = transaction.objectStore("meta"); - const all = commands.getAll(); - all.addEventListener("success", () => { - const legacy = all.result; - if (legacy.some((command) => command.schemaVersion !== LEGACY_COMMAND_SCHEMA && command.schemaVersion !== COMMAND_SCHEMA)) { - transaction.abort(); - return; - } - for (const command of legacy) { - commands.put({ - ...command, - schemaVersion: COMMAND_SCHEMA, - targetColumn: command.targetColumn || "done", - accountPartition: command.accountPartition || "demo:demo", - queuedAt: Number.isSafeInteger(command.queuedAt) ? command.queuedAt : Date.now(), - }); - } - meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY); - }, { once: true }); -} - -async function openLog() { - const request = indexedDB.open(DATABASE, DATABASE_VERSION); - request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion)); - return requestResult(request); -} - -export function validateQueuedCommand(command) { - if (!command || Object.getPrototypeOf(command) !== Object.prototype) { - throw new TypeError("queued command must be an object"); - } - if (command.schemaVersion !== COMMAND_SCHEMA) { - throw new RangeError(`unsupported queued command schema version ${command.schemaVersion}`); - } - const boundedString = (field, maximum) => { - const value = command[field]; - if (typeof value !== "string" || value.length === 0 || value.length > maximum) { - throw new TypeError(`queued command ${field} is invalid`); - } - }; - boundedString("id", 256); - boundedString("accountPartition", 128); - boundedString("actor", 128); - boundedString("session", 128); - boundedString("cardId", 128); - if (!Number.isSafeInteger(command.causal) || command.causal < 1) { - throw new TypeError("queued command causal is invalid"); - } - const queuedAt = command.queuedAt === undefined ? 0 : command.queuedAt; - if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) { - throw new TypeError("queued command queuedAt is invalid"); - } - if (command.kind !== "reorder_card") throw new TypeError(`unknown queued command kind ${command.kind}`); - if (command.targetColumn !== "done") throw new TypeError(`unknown queued command target ${command.targetColumn}`); - if (!["click", "drop", "keydown"].includes(command.eventKind)) { - throw new TypeError(`unknown queued command event kind ${command.eventKind}`); - } - if (command.key !== null && (typeof command.key !== "string" || command.key.length > 64)) { - throw new TypeError("queued command key is invalid"); - } - return command.queuedAt === undefined ? { ...command, queuedAt } : command; -} - -async function pendingCommands(database) { - const transaction = database.transaction(COMMANDS, "readonly"); - const done = transactionDone(transaction); - const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition)); - await done; - return commands.map(validateQueuedCommand).sort((left, right) => left.causal - right.causal); -} - -async function removePendingCommand(database, commandId) { - const transaction = database.transaction(COMMANDS, "readwrite"); - const done = transactionDone(transaction); - transaction.objectStore(COMMANDS).delete(commandId); - await done; -} - -function decideRebase(snapshot, command) { - const canonical = snapshot.cards.find((card) => String(card.id) === command.cardId); - if (!canonical) return { kind: "conflicted", reason: "card-missing", canonicalColumn: "missing" }; - if (command.kind === "reorder_card" && canonical.column === "done") { - return { kind: "converged", reason: "intent-already-canonical", canonicalColumn: canonical.column }; - } - return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column }; -} - -// The built-in policy is deliberately a named module export: applications that -// need custom merge or CRDT semantics must import and wire a different policy. -export function reconcileServerAuthoritative(snapshot, commandSequence, serverResults) { - if (!snapshot || !Array.isArray(snapshot.cards) || !Number.isSafeInteger(snapshot.serverSequence)) { - throw new TypeError("reconciliation snapshot is invalid"); - } - if (!Array.isArray(commandSequence) || !Array.isArray(serverResults)) { - throw new TypeError("reconciliation commands and server results must be arrays"); - } - const resultCursor = serverResults.reduce((cursor, result) => { - if (!result || !Number.isSafeInteger(result.serverSequence)) { - throw new TypeError("reconciliation server result is invalid"); - } - return Math.max(cursor, result.serverSequence); - }, 0); - if (resultCursor > snapshot.serverSequence) { - throw new RangeError("reconciliation server result is newer than the canonical snapshot"); - } - const command = commandSequence[0]; - const decision = command - ? decideRebase(snapshot, command) - : { kind: "idle", reason: "no-pending-command", canonicalColumn: "unchanged" }; - return { - model: "server-authoritative-v1", - snapshotSequence: snapshot.serverSequence, - serverResultCursor: resultCursor, - serverResultCount: serverResults.length, - commandCount: commandSequence.length, - retainedCommandCount: decision.kind === "converged" - ? Math.max(0, commandSequence.length - 1) - : commandSequence.length, - decision, - }; -} - -async function claimUploaderLease(database) { - const transaction = database.transaction("meta", "readwrite"); - const done = transactionDone(transaction); - const meta = transaction.objectStore("meta"); - const now = Date.now(); - const leaseKey = `uploaderLease:${accountPartition}`; - const current = await requestResult(meta.get(leaseKey)); - if (current && current.owner !== TAB_ID && current.expiresAt > now) { - await done; - return { leader: false, owner: current.owner, expiresAt: current.expiresAt }; - } - const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS }; - meta.put(lease, leaseKey); - await done; - return { leader: true, ...lease }; -} - -async function releaseUploaderLease(database) { - const transaction = database.transaction("meta", "readwrite"); - const done = transactionDone(transaction); - const meta = transaction.objectStore("meta"); - const leaseKey = `uploaderLease:${accountPartition}`; - const current = await requestResult(meta.get(leaseKey)); - if (current?.owner === TAB_ID) meta.delete(leaseKey); - await done; -} - -function publishLease(lease) { - root.setAttribute("data-sync-tab-id", TAB_ID); - root.setAttribute("data-sync-leader", String(lease.leader)); - root.setAttribute("data-sync-lease-owner", lease.owner || TAB_ID); - root.setAttribute("data-sync-lease-expires", String(lease.expiresAt)); -} - -async function commitConvergedRebase(database, snapshot, command) { - const transaction = database.transaction([COMMANDS, "meta"], "readwrite"); - const done = transactionDone(transaction); - transaction.objectStore("meta").put(snapshot, "canonicalSnapshot"); - transaction.objectStore("meta").put(snapshot.serverSequence, "acknowledgementCursor"); - transaction.objectStore(COMMANDS).delete(command.queueCommandId || command.id); - await done; -} - -function setPhase(phase, message) { - root.setAttribute("data-sync-phase", phase); - root.querySelector('[role="status"]').textContent = message; -} - -function ageBucket(milliseconds) { - if (milliseconds < 1000) return "lt-1s"; - if (milliseconds < 10000) return "1s-10s"; - if (milliseconds < 60000) return "10s-1m"; - return "gte-1m"; -} - -function latencyBucket(milliseconds) { - if (milliseconds < 50) return "lt-50ms"; - if (milliseconds < 250) return "50ms-250ms"; - if (milliseconds < 1000) return "250ms-1s"; - return "gte-1s"; -} - -function publishDiagnostics(commands) { - const queued = Array.isArray(commands) ? commands : []; - const oldest = queued.reduce((value, command) => { - return Number.isSafeInteger(command.queuedAt) ? Math.min(value, command.queuedAt) : value; - }, Date.now()); - root.setAttribute("data-sync-diag-queue-count", String(queued.length)); - root.setAttribute("data-sync-diag-oldest-age-bucket", queued.length === 0 ? "empty" : ageBucket(Date.now() - oldest)); - root.setAttribute("data-sync-diag-cursor", root.getAttribute("data-sync-ack-sequence") || "0"); - root.setAttribute("data-sync-diag-conflicts", String(conflictCount)); - root.setAttribute("data-sync-diag-rejections", String(rejectionCount)); - const diagnostics = root.querySelector("[data-sync-diagnostics]"); - diagnostics.textContent = `Queue ${queued.length}; oldest ${root.getAttribute("data-sync-diag-oldest-age-bucket")}; cursor ${root.getAttribute("data-sync-diag-cursor")}; acknowledgement ${root.getAttribute("data-sync-diag-ack-latency-bucket") || "none"}; conflicts ${conflictCount}; rejections ${rejectionCount}.`; -} - -function validatePending(command) { - if (!command || command.schemaVersion !== COMMAND_SCHEMA || command.accountPartition !== accountPartition || command.kind !== "reorder_card" || typeof command.id !== "string" || !command.id || typeof command.cardId !== "string" || !command.cardId || command.targetColumn !== "done") { - throw new Error("invalid pending command"); - } - return command; -} - -function setOnline(online) { - root.setAttribute("data-sync-connection", online ? "online" : "offline"); -} - -function setManualRetryAvailable(available) { - const retry = root.querySelector("[data-sync-retry]"); - retry.disabled = !available; - if (available) root.setAttribute("data-sync-manual-retry", "available"); - else root.removeAttribute("data-sync-manual-retry"); -} - -function setExportAvailable(available) { - root.querySelector("[data-sync-export]").disabled = !available; -} - -function setConflictResolutionAvailable(available) { - root.querySelector("[data-sync-use-canonical]").disabled = !available; - root.querySelector("[data-sync-keep-local]").disabled = !available; -} - -async function keepLocalChange() { - if (!activeConflict) return; - const { command, snapshot } = activeConflict; - const retryCommand = { - ...command, - id: `${command.id}:keep:${snapshot.serverSequence}`, - queueCommandId: command.id, - conflictResolution: "keep-local-change", - basedOnServerSequence: snapshot.serverSequence, - }; - setConflictResolutionAvailable(false); - root.setAttribute("data-sync-conflict-resolution", "keep-local-pending"); - root.setAttribute("data-sync-resolution-command-id", retryCommand.id); - root.setAttribute("data-sync-resolved-command-id", command.id); - synchronizing = false; - clearTimeout(leaseTimer); - await releaseUploaderLease(database); - root.setAttribute("data-sync-leader", "false"); - await synchronize(retryCommand); -} - -async function useCanonicalState() { - if (!activeConflict) return; - const { command, snapshot } = activeConflict; - setConflictResolutionAvailable(false); - await removePendingCommand(database, command.id); - const remaining = await pendingCommands(database); - root.setAttribute("data-sync-conflict-resolution", "used-canonical-state"); - root.setAttribute("data-sync-resolved-command-id", command.id); - root.setAttribute("data-sync-pending-count", String(remaining.length)); - setExportAvailable(remaining.length > 0); - setPhase("conflict-resolved", `Used canonical snapshot ${snapshot.serverSequence}; removed ${command.id} and retained ${remaining.length} queued command${remaining.length === 1 ? "" : "s"}.`); - activeConflict = undefined; - uploadsThisRun = 0; - synchronizing = false; - clearTimeout(leaseTimer); - await releaseUploaderLease(database); - root.setAttribute("data-sync-leader", "false"); - await continuePendingWork(); -} - -async function exportPendingWork() { - const commands = await pendingCommands(database); - if (commands.length === 0) return; - const payload = JSON.stringify({ accountPartition, commands }, null, 2); - const url = URL.createObjectURL(new Blob([payload], { type: "application/json" })); - const link = document.createElement("a"); - link.href = url; - link.download = "hemx-kanban-queue.json"; - link.click(); - URL.revokeObjectURL(url); - root.setAttribute("data-sync-exported-count", String(commands.length)); -} - -function scheduleManualRetry(command, error) { - clearTimeout(retryTimer); - manualRetryCommand = command; - root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); - setManualRetryAvailable(true); - setPhase("offline", "Sync is offline after bounded retries; the durable command remains queued. Retry now when ready."); - root.dispatchEvent(new CustomEvent("kanban:sync-exhausted", { detail: { commandId: command.id, attempts: MAX_ATTEMPTS } })); -} - -async function upload(command) { - root.setAttribute("data-sync-max-attempts", String(MAX_ATTEMPTS)); - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { - root.setAttribute("data-sync-attempts", String(attempt)); - setPhase(attempt === 1 ? "uploading" : "retrying", `Uploading ${command.id} (attempt ${attempt} of ${MAX_ATTEMPTS}).`); - try { - const query = new URLSearchParams({ command_id: command.id, card_id: command.cardId, column: command.targetColumn }); - const response = await fetchWithTimeout(`/sync/commands?${query}`, { method: "POST" }); - if (response.status === 503 && attempt < MAX_ATTEMPTS) { - const base = BACKOFF_MS[attempt - 1]; - const delay = base + Math.floor(Math.random() * base); - root.setAttribute("data-sync-last-backoff-base-ms", String(base)); - root.setAttribute("data-sync-last-backoff-ms", String(delay)); - root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } })); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - if (!response.ok) { - const problem = await response.json().catch(() => ({})); - const kind = typeof problem.kind === "string" ? problem.kind : "unclassified-rejection"; - const reason = typeof problem.error === "string" ? problem.error : "unclassified rejection"; - throw new UploadError(response.status, response.status >= 500, kind, reason); - } - return response.json(); - } catch (error) { - if (error instanceof UploadError && !error.retryable) throw error; - if (attempt === MAX_ATTEMPTS) throw error; - const base = BACKOFF_MS[attempt - 1]; - const delay = base + Math.floor(Math.random() * base); - root.setAttribute("data-sync-last-backoff-base-ms", String(base)); - root.setAttribute("data-sync-last-backoff-ms", String(delay)); - root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } })); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error("sync retry limit exhausted"); -} - -function beginUpload() { - inFlightUploads += 1; - maxObservedInFlight = Math.max(maxObservedInFlight, inFlightUploads); - root.setAttribute("data-sync-in-flight", String(inFlightUploads)); - root.setAttribute("data-sync-max-observed-in-flight", String(maxObservedInFlight)); -} - -function finishUpload() { - inFlightUploads -= 1; - root.setAttribute("data-sync-in-flight", String(inFlightUploads)); -} - -async function continuePendingWork() { - const commands = await pendingCommands(database); - root.setAttribute("data-sync-pending-count", String(commands.length)); - publishDiagnostics(commands); - setExportAvailable(commands.length > 0); - if (commands.length === 0) return; - if (uploadsThisRun >= uploadLimit) { - setManualRetryAvailable(true); - setPhase("backpressured", `Upload limit ${uploadLimit} reached; ${commands.length} durable command${commands.length === 1 ? " remains" : "s remain"} queued. Retry now to continue.`); - return; - } - setTimeout(() => synchronize(validatePending(commands[0])).catch(failPermanently), 0); -} - -async function renewOfflineLease(command) { - if (stopped || root.getAttribute("data-sync-phase") !== "offline") return; - const lease = await claimUploaderLease(database); - publishLease(lease); - if (!lease.leader) { - setPhase("standby", "Another tab owns sync; waiting for lease takeover."); - leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS); - return; - } - leaseTimer = setTimeout( - () => renewOfflineLease(command).catch(failPermanently), - LEASE_MS / 2, - ); -} - -async function synchronize(command) { - if (synchronizing) return; - synchronizing = true; - const lease = await claimUploaderLease(database); - publishLease(lease); - if (!lease.leader) { - synchronizing = false; - setPhase("standby", "Another tab owns sync; waiting for lease takeover."); - return; - } - clearTimeout(leaseTimer); - leaseTimer = setTimeout(() => { - if (stopped || root.getAttribute("data-sync-phase") === "acknowledged") return; - if (root.getAttribute("data-sync-phase") === "offline") { - renewOfflineLease(command).catch(failPermanently); - } else { - synchronize(command).catch(failPermanently); - } - }, LEASE_MS / 2); - root.removeAttribute("data-sync-error"); - root.removeAttribute("data-sync-manual-retry"); - setOnline(navigator.onLine); - try { - beginUpload(); - let acknowledgement; - try { - acknowledgement = await upload(command); - } finally { - finishUpload(); - } - setOnline(true); - root.setAttribute("data-sync-upload-sequence", String(acknowledgement.serverSequence)); - acknowledgementStartedAt = performance.now(); - setPhase("awaiting-ack", `Command ${command.id} uploaded; awaiting canonical acknowledgement.`); - - const reconnect = command.session || command.actor || "kanban"; - const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`); - acknowledgementSource = source; - let opens = 0; - source.addEventListener("open", () => { - opens += 1; - root.setAttribute("data-sync-transport-opens", String(opens)); - root.setAttribute("data-sync-stream-state", "open"); - }); - source.addEventListener("heartbeat", () => { - const heartbeats = Number(root.getAttribute("data-sync-heartbeats") || "0") + 1; - root.setAttribute("data-sync-heartbeats", String(heartbeats)); - root.setAttribute("data-sync-stream-state", "healthy"); - }); - source.addEventListener("error", () => { - const reconnects = Number(root.getAttribute("data-sync-reconnects") || "0") + 1; - root.setAttribute("data-sync-reconnects", String(reconnects)); - root.setAttribute("data-sync-stream-state", "reconnecting"); - }); - source.addEventListener("acknowledgement", async (event) => { - const canonical = JSON.parse(event.data); - if (canonical.commandId !== command.id) return; - source.close(); - if (acknowledgementSource === source) acknowledgementSource = undefined; - root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length)); - const queueCommandId = command.queueCommandId || command.id; - await removePendingCommand(database, queueCommandId); - manualRetryCommand = undefined; - if (command.conflictResolution === "keep-local-change") { - activeConflict = undefined; - setConflictResolutionAvailable(false); - root.setAttribute("data-sync-conflict-resolution", "kept-local-change"); - root.setAttribute("data-sync-resolved-command-id", queueCommandId); - } - uploadsThisRun += 1; - uploadedTotal += 1; - root.setAttribute("data-sync-uploaded-this-run", String(uploadsThisRun)); - root.setAttribute("data-sync-uploaded-total", String(uploadedTotal)); - const pendingAfterAck = (await pendingCommands(database)).length; - root.setAttribute("data-sync-pending-count", String(pendingAfterAck)); - setExportAvailable(pendingAfterAck > 0); - root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence)); - root.setAttribute("data-sync-diag-cursor", String(canonical.serverSequence)); - root.setAttribute("data-sync-diag-ack-latency-bucket", latencyBucket(performance.now() - acknowledgementStartedAt)); - root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn); - publishDiagnostics(await pendingCommands(database)); - setPhase("acknowledged", `Queued change acknowledged in ${canonical.canonicalColumn}.`); - root.dispatchEvent(new CustomEvent("kanban:sync-acknowledged", { detail: canonical })); - synchronizing = false; - clearTimeout(leaseTimer); - await releaseUploaderLease(database); - root.setAttribute("data-sync-leader", "false"); - await continuePendingWork(); - }); - source.addEventListener("snapshot-required", async (event) => { - const missing = JSON.parse(event.data); - const response = await fetchWithTimeout(missing.snapshotUrl); - if (!response.ok) throw new Error(`snapshot failed with ${response.status}`); - const snapshot = await response.json(); - const queued = await pendingCommands(database); - const reconciliation = reconcileServerAuthoritative(snapshot, queued, [{ - status: "snapshot-required", - serverSequence: missing.latest, - }]); - const decision = reconciliation.decision; - const converged = decision.kind === "converged"; - root.setAttribute("data-sync-reconciliation-model", reconciliation.model); - root.setAttribute("data-sync-reconciliation-result-cursor", String(reconciliation.serverResultCursor)); - root.setAttribute("data-sync-reconciliation-retained-count", String(reconciliation.retainedCommandCount)); - root.setAttribute("data-sync-snapshot-sequence", String(snapshot.serverSequence)); - root.setAttribute("data-sync-snapshot-schema", String(snapshot.schemaVersion)); - root.setAttribute("data-sync-snapshot-card-count", String(snapshot.cards.length)); - root.setAttribute("data-sync-rebase-pending-count", String(queued.length)); - root.setAttribute("data-sync-rebase-decision", decision.kind); - root.setAttribute("data-sync-rebase-reason", decision.reason); - root.setAttribute("data-sync-canonical-column", decision.canonicalColumn); - if (converged) { - activeConflict = undefined; - manualRetryCommand = undefined; - setConflictResolutionAvailable(false); - await commitConvergedRebase(database, snapshot, command); - if (command.conflictResolution === "keep-local-change") { - root.setAttribute("data-sync-conflict-resolution", "kept-local-change"); - root.setAttribute("data-sync-resolved-command-id", command.queueCommandId); - } - root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length)); - root.setAttribute("data-sync-ack-sequence", String(snapshot.serverSequence)); - setPhase("rebased", `Canonical snapshot ${snapshot.serverSequence} already satisfies ${command.id}; committed and removed the pending command.`); - root.dispatchEvent(new CustomEvent("kanban:sync-rebased", { detail: { snapshot, command, decision } })); - } else { - conflictCount += 1; - activeConflict = { command, snapshot, decision }; - publishDiagnostics(await pendingCommands(database)); - setConflictResolutionAvailable(true); - setPhase("conflicted", `Canonical snapshot ${snapshot.serverSequence} conflicts with ${command.id} (${decision.reason}); the pending command remains queued.`); - root.dispatchEvent(new CustomEvent("kanban:sync-conflicted", { detail: { snapshot, command, decision } })); - } - synchronizing = false; - source.close(); - if (acknowledgementSource === source) acknowledgementSource = undefined; - clearTimeout(leaseTimer); - await releaseUploaderLease(database); - root.setAttribute("data-sync-leader", "false"); - if (converged) await continuePendingWork(); - }); - } catch (error) { - synchronizing = false; - if (error instanceof UploadError && !error.retryable) { - setOnline(true); - clearTimeout(leaseTimer); - root.setAttribute("data-sync-error", error.message); - root.setAttribute("data-sync-error-status", String(error.status)); - const remaining = await pendingCommands(database); - setManualRetryAvailable(false); - rejectionCount += 1; - publishDiagnostics(remaining); - if (command.conflictResolution === "keep-local-change") { - manualRetryCommand = undefined; - setConflictResolutionAvailable(true); - root.setAttribute("data-sync-error-kind", error.kind); - root.setAttribute("data-sync-error-reason", error.reason); - root.setAttribute("data-sync-pending-count", String(remaining.length)); - root.setAttribute("data-sync-conflict-resolution", "keep-local-rejected"); - setPhase("resolution-rejected", `Keep-local command ${command.id} was rejected (${error.status}: ${error.reason}); the conflicted command and ${remaining.length - 1} queued suffix command${remaining.length === 2 ? "" : "s"} remain in order.`); - } else if (error.kind === "authorization-denial") { - root.setAttribute("data-sync-error-kind", "authorization-denial"); - root.setAttribute("data-sync-pending-count", "redacted"); - root.setAttribute("data-sync-redacted-pending", "true"); - root.removeAttribute("data-sync-error-reason"); - root.removeAttribute("data-sync-rejected-command-id"); - setPhase("authorization-denied", "Current session cannot access local queued work. Sign back into the owning account to continue."); - } else { - root.setAttribute("data-sync-error-kind", "permanent-rejection"); - root.setAttribute("data-sync-error-reason", error.reason); - root.setAttribute("data-sync-rejected-command-id", command.id); - root.setAttribute("data-sync-pending-count", String(remaining.length)); - setPhase("rejected", `Command ${command.id} was permanently rejected (${error.status}: ${error.reason}); ${remaining.length} durable command${remaining.length === 1 ? " remains" : "s remain"} queued for review.`); - } - await releaseUploaderLease(database); - root.setAttribute("data-sync-leader", "false"); - return; - } - setOnline(false); - scheduleManualRetry(command, error); - } -} - -async function runLeaseLoop(command) { - if (stopped) return; - const phase = root.getAttribute("data-sync-phase"); - if (phase === "acknowledged" || phase === "rebased" || phase === "conflicted" || phase === "failed") return; - if (root.getAttribute("data-sync-leader") === "true") { - await synchronize(command); - return; - } - const lease = await claimUploaderLease(database); - publishLease(lease); - if (lease.leader) { - await synchronize(command); - return; - } - setPhase("standby", "Another tab owns sync; waiting for lease takeover."); - leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS); -} - -async function start() { - if (!root) return; - root.setAttribute("data-sync-request-timeout-ms", String(REQUEST_TIMEOUT_MS)); - root.setAttribute("data-sync-stream-buffer-limit", String(ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT)); - const contextResponse = await fetchWithTimeout("/sync/context", { credentials: "same-origin", cache: "no-store" }); - if (!contextResponse.ok) throw new Error(`account context failed with ${contextResponse.status}`); - const context = await contextResponse.json(); - if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) { - throw new Error("account context omitted accountPartition"); - } - accountPartition = context.accountPartition; - root.setAttribute("data-sync-account-partition", accountPartition); - uploadLimit = Number.parseInt(root.getAttribute("data-sync-upload-limit"), 10); - if (!Number.isSafeInteger(uploadLimit) || uploadLimit < 1) throw new Error("data-sync-upload-limit must be a positive integer"); - database = await openLog(); - const migration = await requestResult(database.transaction("meta", "readonly").objectStore("meta").get(MIGRATION_KEY)); - root.setAttribute("data-sync-database-version", String(database.version)); - root.setAttribute("data-sync-command-schema", String(COMMAND_SCHEMA)); - if (migration) { - root.setAttribute("data-sync-migration-from", String(migration.from)); - root.setAttribute("data-sync-migration-to", String(migration.to)); - root.setAttribute("data-sync-migrated-count", String(migration.migrated)); - } - const commands = await pendingCommands(database); - root.setAttribute("data-sync-uploaded-this-run", "0"); - root.setAttribute("data-sync-uploaded-total", "0"); - root.setAttribute("data-sync-in-flight", "0"); - root.setAttribute("data-sync-max-observed-in-flight", "0"); - root.setAttribute("data-sync-pending-count", String(commands.length)); - publishDiagnostics(commands); - root.setAttribute("data-sync-diag-ack-latency-bucket", "none"); - setExportAvailable(commands.length > 0); - setConflictResolutionAvailable(false); - setManualRetryAvailable(false); - if (commands.length === 0) { - setPhase("idle", "No pending commands."); - return; - } - const command = validatePending(commands[0]); - root.addEventListener("click", async (event) => { - if (event.target.closest("[data-sync-keep-local]")) { - await keepLocalChange(); - return; - } - if (event.target.closest("[data-sync-use-canonical]")) { - await useCanonicalState(); - return; - } - if (event.target.closest("[data-sync-export]")) { - await exportPendingWork(); - return; - } - if (!event.target.closest("[data-sync-retry]")) return; - uploadsThisRun = 0; - root.setAttribute("data-sync-uploaded-this-run", "0"); - setManualRetryAvailable(false); - const [next] = await pendingCommands(database); - const retry = manualRetryCommand || (next && validatePending(next)); - if (retry) synchronize(retry).catch(failPermanently); - }); - window.addEventListener("online", async () => { - if (root.getAttribute("data-sync-phase") !== "offline") return; - setManualRetryAvailable(false); - const [next] = await pendingCommands(database); - const retry = manualRetryCommand || (next && validatePending(next)); - if (retry) synchronize(retry).catch(failPermanently); - }); - await runLeaseLoop(command); -} - -window.addEventListener("pagehide", () => { - stopped = true; - clearTimeout(leaseTimer); - clearTimeout(retryTimer); - if (acknowledgementSource) { - acknowledgementSource.close(); - root.setAttribute("data-sync-stream-state", "cancelled"); - } - acknowledgementSource = undefined; - for (const controller of activeRequests) { - controller.abort(new DOMException("sync cancelled because page is hidden", "AbortError")); - } - if (database) releaseUploaderLease(database).catch(() => {}); -}); - -function failPermanently(error) { - synchronizing = false; - root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); - setPhase("failed", "Sync failed; the durable command remains queued."); -} - -start().catch((error) => { - if (!root) return; - failPermanently(error); -}); diff --git a/examples/saas/Cargo.toml b/examples/saas/Cargo.toml deleted file mode 100644 index 42714b2..0000000 --- a/examples/saas/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "hemx-saas-example" -version.workspace = true -edition.workspace = true -publish = false - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "hemx-saas-example" -path = "src/main.rs" - -[dependencies] -axum = "0.8" -futures-util = "0.3" -hemplate = { path = "../../../hemplate/hemplate" } -hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } - -[dev-dependencies] -scraper = "0.25" -hemx-test = { path = "../../hemx-test" } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/saas/README.md b/examples/saas/README.md deleted file mode 100644 index aa20167..0000000 --- a/examples/saas/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# hemx SaaS tutorial app - -This is the compile-tested v1 production-shaped tutorial app. It intentionally uses an equivalent local persistence adapter and provider recipes as the supported v1 production boundary: auth/session, CSRF, SQLx persistence, deploy, metrics, flags, offline behavior, and islands are explicit app integrations, not hemx core services. Read the walkthrough in `../../docs/tutorial-saas.md`. req: examples/001 req: auth/001 - -What it proves: - -- typed form/newtype inputs for project creation -- auth/session context passed through normal Rust state -- CSRF-safe mutation checked before persistence -- local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider -- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, CSRF token, and matching generated build fingerprint when supplied -- `/health/live`, dependency-aware `/health/ready`, and aggregate `/metrics` endpoints with secret-free structured diagnostics -- generated form, slot, keyed row, page-swap, and live-status commands -- page shell with plain CSS and one explicit metrics island script -- compile-time surface generation plus interaction tests - -For provider-explicit boundaries, see `../../docs/recipes/sqlx-persistence.md`, `../../docs/recipes/auth-session-csrf.md`, `../../docs/recipes/observability-flags.md`, `../../docs/recipes/deploy-versioning.md`, and `../../docs/recipes/pwa-offline.md`. - -What it deliberately keeps out of the tutorial crate: - -- a vendored SQL/auth/metrics/flags/deploy provider dependency -- provider credentials, external services, migrations, or browser automation -- billing, account administration, or other SaaS platform scope - -Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009 - -Run: - -```sh -HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example -cargo test -p hemx-saas-example --test production_reference -cargo test -p hemx-saas-example -``` diff --git a/examples/saas/build.rs b/examples/saas/build.rs deleted file mode 100644 index 99fa6f3..0000000 --- a/examples/saas/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - hemx_build::app().run().unwrap(); -} diff --git a/examples/saas/src/lib.rs b/examples/saas/src/lib.rs deleted file mode 100644 index 64c6f7b..0000000 --- a/examples/saas/src/lib.rs +++ /dev/null @@ -1,742 +0,0 @@ -#[hemx::surface] -pub mod ui {} - -use hemplate::Hemplate; -use hemx::{Html, IntoEffect}; -use hemx_axum::{ - interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, - Registry, State, -}; -use std::convert::Infallible; -use std::fmt::Display; -use std::fs; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use ui::dashboard; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SessionId(u64); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Session { - session_id: SessionId, - user_id: UserId, - email: String, - csrf: CsrfToken, - origin: String, - bearer: String, -} - -impl Session { - pub fn demo() -> Self { - Self { - session_id: SessionId(1), - user_id: UserId(42), - email: "founder@example.com".to_owned(), - csrf: CsrfToken("demo-csrf".to_owned()), - origin: "http://127.0.0.1:3000".to_owned(), - bearer: "Bearer demo-session".to_owned(), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct UserId(u64); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CsrfToken(String); - -impl FromStr for CsrfToken { - type Err = Infallible; - - fn from_str(value: &str) -> Result { - Ok(Self(value.to_owned())) - } -} - -impl Display for CsrfToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ProjectName(String); - -impl ProjectName { - fn as_str(&self) -> &str { - &self.0 - } -} - -impl FromStr for ProjectName { - type Err = Infallible; - - fn from_str(value: &str) -> Result { - Ok(Self(value.trim().to_owned())) - } -} - -#[derive(Clone, Debug)] -#[hemx::form("new_project")] -pub struct NewProject { - csrf: CsrfToken, - name: ProjectName, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ProjectId(u64); - -impl Display for ProjectId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ProjectRecord { - id: ProjectId, - name: String, - owner: String, -} - -impl ProjectRecord { - fn encode(&self) -> String { - format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name) - } - - fn decode(line: &str) -> io::Result { - let mut fields = line.splitn(3, '\t'); - let id = fields - .next() - .and_then(|value| value.parse().ok()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?; - let owner = fields - .next() - .filter(|value| !value.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?; - let name = fields - .next() - .filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t'])) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?; - Ok(Self { - id: ProjectId(id), - name: name.to_owned(), - owner: owner.to_owned(), - }) - } -} - -#[derive(Clone, Default)] -pub struct LocalProjectStore { - projects: Arc>>, - path: Option>, -} - -impl LocalProjectStore { - pub fn durable(path: impl Into) -> io::Result { - let path = path.into(); - let projects = match fs::read_to_string(&path) { - Ok(contents) => contents - .lines() - .map(ProjectRecord::decode) - .collect::>>()?, - Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), - Err(error) => return Err(error), - }; - Ok(Self { - projects: Arc::new(Mutex::new(projects)), - path: Some(Arc::new(path)), - }) - } - - pub fn insert(&self, name: ProjectName, session: &Session) -> Result { - if name.as_str() == "fail-store" { - return Err(AppError::StoreUnavailable); - } - - let mut projects = self.projects.lock().unwrap(); - let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1)); - let record = ProjectRecord { - id, - name: name.as_str().to_owned(), - owner: session.email.clone(), - }; - let mut next = projects.clone(); - next.push(record.clone()); - if let Some(path) = self.path.as_deref() { - persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?; - } - *projects = next; - Ok(record) - } - - pub fn list(&self) -> Vec { - self.projects.lock().unwrap().clone() - } - - fn ready(&self) -> bool { - let Some(path) = self.path.as_deref() else { - return true; - }; - if path.exists() && !path.is_file() { - return false; - } - path.parent().unwrap_or_else(|| Path::new(".")).is_dir() - } -} - -fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let temporary = path.with_extension("tmp"); - let mut file = fs::File::create(&temporary)?; - for project in projects { - file.write_all(project.encode().as_bytes())?; - } - file.sync_all()?; - if let Err(error) = fs::rename(&temporary, path) { - let _ = fs::remove_file(temporary); - return Err(error); - } - #[cfg(unix)] - fs::File::open(parent)?.sync_all()?; - Ok(()) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RequestCorrelationId(String); - -impl Display for RequestCorrelationId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone)] -pub struct MutationDiagnostic { - pub request_id: RequestCorrelationId, - pub session_id: SessionId, - pub user_id: UserId, - pub outcome: &'static str, - pub duration_micros: u64, -} - -pub trait DiagnosticSink: Send + Sync { - fn record(&self, diagnostic: MutationDiagnostic); -} - -struct StderrDiagnosticSink; - -impl DiagnosticSink for StderrDiagnosticSink { - fn record(&self, diagnostic: MutationDiagnostic) { - eprintln!( - "event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}", - diagnostic.request_id, - diagnostic.session_id.0, - diagnostic.user_id.0, - diagnostic.outcome, - diagnostic.duration_micros - ); - } -} - -#[derive(Default)] -struct MutationMetrics { - attempts: AtomicU64, - succeeded: AtomicU64, - denied: AtomicU64, - invalid: AtomicU64, - mismatch: AtomicU64, - failed: AtomicU64, - duration_micros: AtomicU64, - next_request_id: AtomicU64, -} - -#[derive(Clone)] -pub struct AppContext { - session: Session, - store: LocalProjectStore, - metrics: Arc, - diagnostics: Arc, -} - -impl AppContext { - pub fn demo() -> Self { - Self { - session: Session::demo(), - store: LocalProjectStore::default(), - metrics: Arc::default(), - diagnostics: Arc::new(StderrDiagnosticSink), - } - } - - pub fn durable(path: impl Into, origin: impl Into) -> io::Result { - let mut session = Session::demo(); - session.origin = origin.into(); - Ok(Self { - session, - store: LocalProjectStore::durable(path)?, - metrics: Arc::default(), - diagnostics: Arc::new(StderrDiagnosticSink), - }) - } - - pub fn authorize_mutation( - &self, - bearer: &str, - csrf: &CsrfToken, - origin: &str, - ) -> Result<(), AppError> { - if self.session.email.is_empty() || bearer != self.session.bearer { - return Err(AppError::MissingSession); - } - if csrf != &self.session.csrf { - return Err(AppError::CsrfRejected); - } - if origin != self.session.origin { - return Err(AppError::OriginRejected); - } - Ok(()) - } - - pub fn csrf(&self) -> &CsrfToken { - &self.session.csrf - } - - pub fn projects(&self) -> Vec { - self.store.list() - } - - pub fn ready(&self) -> bool { - self.store.ready() - } - - pub fn with_diagnostic_sink(mut self, diagnostics: Arc) -> Self { - self.diagnostics = diagnostics; - self - } - - pub fn next_request_id(&self) -> RequestCorrelationId { - let sequence = self - .metrics - .next_request_id - .fetch_add(1, Ordering::Relaxed) - .saturating_add(1); - RequestCorrelationId(format!("req-{}-{sequence}", std::process::id())) - } - - pub fn record_mutation( - &self, - request_id: RequestCorrelationId, - outcome: &'static str, - duration: Duration, - ) { - self.metrics.attempts.fetch_add(1, Ordering::Relaxed); - match outcome { - "succeeded" => &self.metrics.succeeded, - "denied" => &self.metrics.denied, - "invalid" => &self.metrics.invalid, - "mismatch" => &self.metrics.mismatch, - _ => &self.metrics.failed, - } - .fetch_add(1, Ordering::Relaxed); - let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64; - self.metrics - .duration_micros - .fetch_add(duration_micros, Ordering::Relaxed); - self.diagnostics.record(MutationDiagnostic { - request_id, - session_id: self.session.session_id, - user_id: self.session.user_id, - outcome, - duration_micros, - }); - } - - pub fn metrics_json(&self) -> String { - format!( - "{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}", - self.metrics.attempts.load(Ordering::Relaxed), - self.metrics.succeeded.load(Ordering::Relaxed), - self.metrics.denied.load(Ordering::Relaxed), - self.metrics.invalid.load(Ordering::Relaxed), - self.metrics.mismatch.load(Ordering::Relaxed), - self.metrics.failed.load(Ordering::Relaxed), - self.metrics.duration_micros.load(Ordering::Relaxed), - ) - } - - pub fn create_project_authorized( - &self, - name: &str, - bearer: &str, - csrf: &str, - origin: &str, - ) -> Result { - let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings"); - self.authorize_mutation(bearer, &csrf, origin)?; - self.create_project( - ProjectName::from_str(name).expect("project names are infallible strings"), - ) - } - - fn create_project(&self, name: ProjectName) -> Result { - if name.as_str().is_empty() { - return Err(AppError::Validation("Project name required")); - } - if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) { - return Err(AppError::Validation("Project name is invalid")); - } - self.store.insert(name, &self.session) - } -} - -#[derive(Debug)] -pub enum AppError { - MissingSession, - CsrfRejected, - OriginRejected, - StoreUnavailable, - Validation(&'static str), -} - -impl AppError { - fn message(&self) -> &'static str { - match self { - Self::MissingSession => "Sign in to continue", - Self::CsrfRejected => "Refresh the page before creating another project", - Self::OriginRejected => "Origin verification failed", - Self::StoreUnavailable => "Project storage is temporarily unavailable", - Self::Validation(message) => message, - } - } -} - -impl Display for AppError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.message()) - } -} - -impl std::error::Error for AppError {} - -impl IntoHandlerFailure for AppError { - fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { - match self { - Self::Validation(message) => HandlerFailure::effects( - ( - dashboard::new_project.error("name", message), - dashboard::new_project.focus("name"), - ), - context, - ), - other => HandlerFailure::effects(dashboard::flash.set(other.message()), context), - } - } -} - -#[derive(Hemplate)] -pub struct Dashboard { - csrf: CsrfToken, - flash: String, - summary: String, - rows: Vec, - project_count: usize, - show_projects: bool, - settings: SettingsPage, -} - -impl Dashboard { - pub fn from_context(ctx: &AppContext) -> Self { - let projects = ctx.projects(); - Self { - csrf: ctx.csrf().clone(), - flash: "Signed in with a demo session".to_owned(), - summary: project_summary(projects.len()), - project_count: projects.len(), - show_projects: true, - settings: SettingsPage::production_boundaries(), - rows: projects.into_iter().map(ProjectRow::from).collect(), - } - } - - pub fn settings(ctx: &AppContext) -> Self { - let mut dashboard = Self::from_context(ctx); - dashboard.show_projects = false; - dashboard.flash.clear(); - dashboard - } -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -pub struct ProjectRow { - id: ProjectId, - name: String, - owner: String, -} - -impl From for ProjectRow { - fn from(record: ProjectRecord) -> Self { - Self { - id: record.id, - name: record.name, - owner: record.owner, - } - } -} - -impl hemx::KeyedPartial for ProjectRow { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -pub struct SettingsPage { - message: &'static str, -} - -impl SettingsPage { - fn production_boundaries() -> Self { - Self { - message: "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.", - } - } -} - -#[derive(Hemplate)] -pub struct AppShell { - title: &'static str, - runtime_src: &'static str, - body: Html, -} - -pub fn home_page(ctx: &AppContext) -> Html { - ui::page(&AppShell { - title: "hemx SaaS tutorial", - runtime_src: runtime_js_path(), - body: ui::page(&Dashboard::from_context(ctx)), - }) -} - -pub fn settings_page(ctx: &AppContext) -> Html { - ui::page(&AppShell { - title: "hemx SaaS tutorial settings", - runtime_src: runtime_js_path(), - body: ui::page(&Dashboard::settings(ctx)), - }) -} - -#[hemx::app(dashboard_handlers)] -pub fn registry(ctx: AppContext) -> Registry { - interactions(ui::BUILD_FINGERPRINT) -} - -#[hemx::component("dashboard")] -mod dashboard_handlers { - use super::*; - - #[hemx::handler] - pub async fn create_project( - State(ctx): State, - Form(form): Form, - ) -> Result { - if ctx.session.email.is_empty() { - return Err(AppError::MissingSession); - } - if form.csrf != ctx.session.csrf { - return Err(AppError::CsrfRejected); - } - let project = ctx.create_project(form.name)?; - let total = ctx.projects().len(); - Ok(( - dashboard::project_row.append(ProjectRow::from(project)), - dashboard::summary.set(project_summary(total)), - dashboard::new_project.clear(), - dashboard::flash.set("Project created"), - dashboard::live_status.set(format!("{total} projects persisted locally")), - )) - } -} - -pub fn live_status(projects: usize) -> impl IntoEffect { - dashboard::live_status.set(format!("heartbeat: {projects} projects")) -} - -fn project_summary(total: usize) -> String { - match total { - 0 => "No projects yet".to_owned(), - 1 => "1 project".to_owned(), - total => format!("{total} projects"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx_axum::{InteractionForm, InteractionRequest}; - use hemx_test::{any_root_selector, inspect, inspect_batch, target_selector}; - use scraper::{Html as ParsedHtml, Selector}; - - fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> InteractionForm { - InteractionForm::for_handle( - handle, - fields - .iter() - .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), - ) - } - - fn selector(value: &str) -> Selector { - Selector::parse(value).expect("test selector parses") - } - - #[derive(Default)] - struct RecordingDiagnostics(Mutex>); - - impl DiagnosticSink for RecordingDiagnostics { - fn record(&self, diagnostic: MutationDiagnostic) { - self.0.lock().unwrap().push(diagnostic); - } - } - - #[test] - fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() { - // req: operations/003 req: operations/005 - let diagnostics = Arc::new(RecordingDiagnostics::default()); - let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone()); - let request_id = ctx.next_request_id(); - ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7)); - - let recorded = diagnostics.0.lock().unwrap(); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0].request_id, request_id); - assert_eq!(recorded[0].session_id, SessionId(1)); - assert_eq!(recorded[0].user_id, UserId(42)); - assert_eq!(recorded[0].outcome, "denied"); - assert_eq!(recorded[0].duration_micros, 7); - } - - #[test] - fn home_page_documents_the_production_app_boundaries() { - // req: examples/001 req: auth/001 req: auth/004 req: interop/003 - let ctx = AppContext::demo(); - let html = home_page(&ctx); - let document = ParsedHtml::parse_document(html.as_str()); - - assert_eq!(document.select(&selector(any_root_selector())).count(), 1); - assert_eq!( - document - .select(&selector(&format!( - "form{}", - target_selector(dashboard::new_project) - ))) - .count(), - 1 - ); - assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1); - assert_eq!( - document - .select(&selector("[data-hemx-sse='/events']")) - .count(), - 1 - ); - assert_eq!( - document - .select(&selector("[data-hemx-island='metrics']")) - .count(), - 1 - ); - assert!(html.as_str().contains("/app.css")); - assert!(html.as_str().contains("/metrics.js")); - } - - #[test] - fn settings_page_renders_the_full_page_fallback() { - // req: examples/001 req: page_swap/002 - let ctx = AppContext::demo(); - let html = settings_page(&ctx); - let document = ParsedHtml::parse_document(html.as_str()); - - assert_eq!(document.select(&selector(any_root_selector())).count(), 1); - assert_eq!( - document - .select(&selector(&format!( - "{} .settings-page", - target_selector(dashboard::page_panel) - ))) - .count(), - 1 - ); - assert!(html.as_str().contains("explicit app integrations")); - assert!(!html - .as_str() - .contains("form data-hemx-handle=\"create_project\"")); - } - - #[tokio::test] - async fn create_project_is_auth_csrf_checked_and_persisted_locally() { - // req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004 - let ctx = AppContext::demo(); - - let rejected = inspect_batch( - InteractionRequest::from(form( - dashboard::create_project, - &[("csrf", "stale"), ("name", "Launch checklist")], - )) - .dispatch_async(registry(ctx.clone())) - .await - .unwrap() - .batch, - ); - assert!(ctx.projects().is_empty()); - assert!(rejected.updates_text(dashboard::flash)); - assert!(rejected.payload_contains("Refresh the page")); - - let validation = inspect_batch( - InteractionRequest::from(form( - dashboard::create_project, - &[("csrf", "demo-csrf"), ("name", " ")], - )) - .dispatch_async(registry(ctx.clone())) - .await - .unwrap() - .batch, - ); - assert!(ctx.projects().is_empty()); - assert!(validation.payload_contains("Project name required")); - - let created = inspect_batch( - InteractionRequest::from(form( - dashboard::create_project, - &[("csrf", "demo-csrf"), ("name", "Launch checklist")], - )) - .dispatch_async(registry(ctx.clone())) - .await - .unwrap() - .batch, - ); - assert_eq!(ctx.projects()[0].name, "Launch checklist"); - assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist")); - assert!(created.updates_text(dashboard::summary)); - assert!(created.resets_form(dashboard::new_project)); - assert!(created.updates_text(dashboard::live_status)); - } - - #[test] - fn live_status_uses_the_generated_dashboard_target() { - // req: push/003 req: examples/014 - let ctx = AppContext::demo(); - let heartbeat = inspect(live_status(ctx.projects().len())); - assert!(heartbeat.updates_text(dashboard::live_status)); - assert!(heartbeat.payload_contains("heartbeat")); - } -} diff --git a/examples/saas/src/main.rs b/examples/saas/src/main.rs deleted file mode 100644 index 999e677..0000000 --- a/examples/saas/src/main.rs +++ /dev/null @@ -1,228 +0,0 @@ -use axum::body::Body; -use axum::extract::{DefaultBodyLimit, Form, Query, Request, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; -use axum::middleware::{self, Next}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::Router; -use futures_util::{stream, StreamExt}; -use hemx::IntoEffect; -use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest}; -use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext}; -use std::collections::BTreeMap; -use std::convert::Infallible; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned()); - let store = std::env::var_os("HEMX_SAAS_STORE") - .map(PathBuf::from) - .unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv")); - let app = app(AppContext::durable(store, format!("http://{address}"))?); - let listener = tokio::net::TcpListener::bind(&address).await?; - axum::serve(listener, app).await?; - Ok(()) -} - -fn app(ctx: AppContext) -> Router { - Router::new() - .route("/", get(home).post(interact)) - .route("/settings", get(settings)) - .route("/projects", post(create_project)) - .route("/health/live", get(health_live)) - .route("/health/ready", get(health_ready)) - .route("/metrics", get(metrics)) - .route("/events", get(events)) - .route(runtime_js_path(), get(runtime)) - .route("/app.css", get(css)) - .route("/metrics.js", get(metrics_js)) - .layer(DefaultBodyLimit::max(8 * 1024)) - .layer(middleware::from_fn(security_headers)) - .with_state(ctx) -} - -// req: security/006 req: security/009 -async fn security_headers(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - let headers = response.headers_mut(); - headers.insert( - "content-security-policy", - HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"), - ); - headers.insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - headers.insert( - "referrer-policy", - HeaderValue::from_static("strict-origin-when-cross-origin"), - ); - response -} - -async fn home(State(ctx): State) -> impl IntoResponse { - axum::response::Html(home_page(&ctx).into_string()) -} - -async fn settings(State(ctx): State) -> impl IntoResponse { - axum::response::Html(settings_page(&ctx).into_string()) -} - -async fn interact( - State(ctx): State, - request: InteractionRequest, -) -> Result { - request.dispatch_async(registry(ctx)).await -} - -async fn events( - Query(params): Query>, - State(ctx): State, -) -> impl IntoResponse { - // The production reference exposes an ongoing server-owned stream; `once` - // keeps a bounded probe for package tests without changing the public path. - // req: examples/014 - let event = |ctx: &AppContext| { - Ok::<_, Infallible>(live_status(ctx.projects().len()).into_batch(ui::BUILD_FINGERPRINT)) - }; - let initial = stream::once(std::future::ready(event(&ctx))); - if params.contains_key("once") { - return sse(initial.left_stream()); - } - - let updates = stream::unfold(ctx, move |ctx| async move { - tokio::time::sleep(Duration::from_secs(15)).await; - Some((event(&ctx), ctx)) - }); - sse(initial.chain(updates).right_stream()) -} - -// req: auth/001 req: auth/002 req: auth/004 -// req: security/004 req: v1_release/003 -async fn create_project( - State(ctx): State, - headers: HeaderMap, - Form(form): Form>, -) -> Response { - let started = Instant::now(); - let request_id = ctx.next_request_id(); - let bearer = headers - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - let origin = headers - .get("origin") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - let name = form.get("name").map(String::as_str).unwrap_or_default(); - let csrf = form.get("csrf").map(String::as_str).unwrap_or_default(); - if let Some(client_fingerprint) = headers - .get("x-hemx-fingerprint") - .and_then(|value| value.to_str().ok()) - { - let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string(); - if client_fingerprint != current_fingerprint { - ctx.record_mutation(request_id.clone(), "mismatch", started.elapsed()); - return Response::builder() - .status(StatusCode::CONFLICT) - .header("content-type", "application/problem+json") - .header("x-hemx-recovery", "reload") - .header("x-hemx-fingerprint", current_fingerprint) - .header("x-request-id", request_id.to_string()) - .body(Body::from("{\"code\":\"deployment-mismatch\"}")) - .expect("deployment mismatch response"); - } - } - let (outcome, mut response) = match ctx.create_project_authorized(name, bearer, csrf, origin) { - Ok(_) => ( - "succeeded", - (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(), - ), - Err( - hemx_saas_example::AppError::MissingSession - | hemx_saas_example::AppError::CsrfRejected - | hemx_saas_example::AppError::OriginRejected, - ) => ( - "denied", - problem(StatusCode::FORBIDDEN, "authorization-denied"), - ), - Err(hemx_saas_example::AppError::Validation(_)) => ( - "invalid", - problem(StatusCode::BAD_REQUEST, "invalid-project"), - ), - Err(_) => ( - "failed", - problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"), - ), - }; - ctx.record_mutation(request_id.clone(), outcome, started.elapsed()); - response.headers_mut().insert( - "x-request-id", - HeaderValue::from_str(&request_id.to_string()).expect("generated request ID is a header"), - ); - response -} - -fn problem(status: StatusCode, code: &'static str) -> Response { - Response::builder() - .status(status) - .header("content-type", "application/problem+json") - .body(Body::from(format!("{{\"code\":\"{code}\"}}"))) - .expect("problem response") -} - -// req: operations/007 -async fn health_live() -> Response { - json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned()) -} - -// req: operations/007 -async fn health_ready(State(ctx): State) -> Response { - if ctx.ready() { - json_response( - StatusCode::OK, - format!( - "{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}", - ui::BUILD_FINGERPRINT.0 - ), - ) - } else { - json_response( - StatusCode::SERVICE_UNAVAILABLE, - "{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(), - ) - } -} - -// req: operations/005 req: operations/007 -async fn metrics(State(ctx): State) -> Response { - json_response(StatusCode::OK, ctx.metrics_json()) -} - -fn json_response(status: StatusCode, body: String) -> Response { - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Body::from(body)) - .expect("JSON response") -} - -async fn runtime() -> impl IntoResponse { - runtime_js() -} - -async fn css() -> Response { - Response::builder() - .header("content-type", "text/css; charset=utf-8") - .body(Body::from(include_str!("../templates/app.css"))) - .expect("css response") -} - -async fn metrics_js() -> Response { - Response::builder() - .header("content-type", "text/javascript; charset=utf-8") - .body(Body::from(include_str!("../templates/metrics.js"))) - .expect("metrics js response") -} diff --git a/examples/saas/templates/app.css b/examples/saas/templates/app.css deleted file mode 100644 index 23bcf96..0000000 --- a/examples/saas/templates/app.css +++ /dev/null @@ -1,16 +0,0 @@ -:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; } -body { margin: 0; background: #f7f4ee; color: #201b16; } -.dashboard { max-width: 960px; margin: 0 auto; padding: 2rem; } -.hero, .panel, .status-row { background: white; border: 1px solid #e6ded2; border-radius: 18px; padding: 1.25rem; box-shadow: 0 12px 40px rgba(34, 24, 8, 0.08); } -.eyebrow { color: #8a5a00; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } -.lede { max-width: 56rem; color: #5d5147; } -.tabs, .project-form, .status-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } -.tabs { margin: 1rem 0; } -button, input { font: inherit; } -button { border: 0; border-radius: 999px; background: #1f5eff; color: white; padding: .65rem 1rem; } -input { border: 1px solid #cfc4b8; border-radius: 10px; padding: .55rem .7rem; } -.field-error, .flash { color: #a02b12; font-weight: 700; } -.summary { color: #516034; } -.project-list { display: grid; gap: .7rem; padding: 0; list-style: none; } -.project-row { display: flex; justify-content: space-between; border: 1px solid #eee0cb; border-radius: 12px; padding: .75rem; } -.metrics-island { min-width: 18rem; border-left: 4px solid #1f5eff; padding-left: 1rem; } diff --git a/examples/saas/templates/app_shell.heml b/examples/saas/templates/app_shell.heml deleted file mode 100644 index 740f323..0000000 --- a/examples/saas/templates/app_shell.heml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - {+ self.title +} - - - - - - {+= self.body =+} - - diff --git a/examples/saas/templates/dashboard.heml b/examples/saas/templates/dashboard.heml deleted file mode 100644 index bc2efdf..0000000 --- a/examples/saas/templates/dashboard.heml +++ /dev/null @@ -1,46 +0,0 @@ -
    -
    -

    Production-shaped SaaS path

    -

    Projects

    -

    Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.

    -
    - - - -
    -
    -
    - - - -

    -
    - -

    {+ self.flash +}

    -

    {+ self.summary +}

    - -
      - -
    -
    -
    - {+ self.settings +} -
    -
    - -
    -

    Waiting for status…

    -
    -

    Metrics island

    - -

    Waiting for island script…

    -
    -
    -
    diff --git a/examples/saas/templates/metrics.js b/examples/saas/templates/metrics.js deleted file mode 100644 index 5ca3c44..0000000 --- a/examples/saas/templates/metrics.js +++ /dev/null @@ -1,14 +0,0 @@ -(() => { - function render(island) { - const count = island.getAttribute("data-project-count") || "0"; - const readout = island.querySelector("[data-island-readout]"); - if (readout) readout.textContent = `${count} persisted project${count === "1" ? "" : "s"}`; - } - - function boot() { - for (const island of document.querySelectorAll('[data-hemx-island="metrics"]')) render(island); - } - - document.addEventListener("DOMContentLoaded", boot); - document.addEventListener("hemx:after-settle", boot); -})(); diff --git a/examples/saas/templates/partials/project_row.heml b/examples/saas/templates/partials/project_row.heml deleted file mode 100644 index 9f700f5..0000000 --- a/examples/saas/templates/partials/project_row.heml +++ /dev/null @@ -1,4 +0,0 @@ -
  • - {+ self.name +} - {+ self.owner +} -
  • diff --git a/examples/saas/templates/partials/settings_page.heml b/examples/saas/templates/partials/settings_page.heml deleted file mode 100644 index e8e5c16..0000000 --- a/examples/saas/templates/partials/settings_page.heml +++ /dev/null @@ -1,4 +0,0 @@ -
    -

    Settings

    -

    {+ self.message +}

    -
    diff --git a/examples/saas/tests/production_reference.rs b/examples/saas/tests/production_reference.rs deleted file mode 100644 index f027ed9..0000000 --- a/examples/saas/tests/production_reference.rs +++ /dev/null @@ -1,311 +0,0 @@ -use hemx_test::TestProcess; -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); - -fn available_address() -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port"); - let address = listener.local_addr().expect("test address"); - drop(listener); - address.to_string() -} - -fn test_path(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock") - .as_nanos(); - std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id())) -} - -fn start(address: &str, store: &Path) -> TestProcess { - let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example")); - command - .env("HEMX_SAAS_ADDR", address) - .env("HEMX_SAAS_STORE", store); - TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app") -} - -fn request( - address: &str, - method: &str, - path: &str, - headers: &[(&str, &str)], - body: &str, -) -> String { - let mut stream = TcpStream::connect(address).expect("connect to SaaS app"); - write!( - stream, - "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n", - body.len() - ) - .expect("write request line"); - for (name, value) in headers { - write!(stream, "{name}: {value}\r\n").expect("write request header"); - } - write!(stream, "\r\n{body}").expect("finish request"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("read response"); - response -} - -fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String { - create_at_version(address, name, bearer, csrf, origin, None) -} - -fn create_at_version( - address: &str, - name: &str, - bearer: &str, - csrf: &str, - origin: &str, - fingerprint: Option<&str>, -) -> String { - let mut headers = vec![ - ("Authorization", bearer), - ("Origin", origin), - ("Content-Type", "application/x-www-form-urlencoded"), - ]; - if let Some(fingerprint) = fingerprint { - headers.push(("x-hemx-fingerprint", fingerprint)); - } - request( - address, - "POST", - "/projects", - &headers, - &format!("name={name}&csrf={csrf}"), - ) -} - -fn response_header<'a>(response: &'a str, name: &str) -> &'a str { - response - .lines() - .find_map(|line| { - let (header_name, value) = line.split_once(':')?; - header_name.eq_ignore_ascii_case(name).then(|| value.trim()) - }) - .unwrap_or_else(|| panic!("missing {name} response header")) -} - -fn ready_fingerprint(response: &str) -> &str { - let marker = "\"fingerprint\":\""; - let start = response.find(marker).expect("readiness fingerprint") + marker.len(); - let end = response[start..].find('"').expect("fingerprint end") + start; - &response[start..end] -} - -#[test] -fn authenticated_project_mutation_is_atomic_and_survives_restart() { - // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006 - // test req: security/009 req: operations/001 req: operations/006 req: v1_release/003 - let address = available_address(); - let origin = format!("http://{address}"); - let store = test_path("durable"); - - { - let _app = start(&address, &store); - let home = request(&address, "GET", "/", &[], ""); - let csp = response_header(&home, "content-security-policy"); - assert!(csp.contains("default-src 'self'"), "{csp}"); - assert!(csp.contains("script-src 'self'"), "{csp}"); - assert!(csp.contains("object-src 'none'"), "{csp}"); - assert!(csp.contains("form-action 'self'"), "{csp}"); - assert!(!csp.contains("unsafe-inline"), "{csp}"); - assert!(!csp.contains("unsafe-eval"), "{csp}"); - assert_eq!(response_header(&home, "x-content-type-options"), "nosniff"); - assert_eq!( - response_header(&home, "referrer-policy"), - "strict-origin-when-cross-origin" - ); - assert!(!home.contains(" - - - - -{+= self.body =+} - diff --git a/examples/techdemo/templates/control_center.css b/examples/techdemo/templates/control_center.css deleted file mode 100644 index cea9e0a..0000000 --- a/examples/techdemo/templates/control_center.css +++ /dev/null @@ -1,12 +0,0 @@ -.lane { min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; } -.lane h3 { margin:0 0 4px; } -.lane p { color:var(--muted); margin:0 0 12px; font-size:13px; } -.lane.drop-ready { border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); } -.work-card { border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; } -.work-card:active { cursor:grabbing; } -.work-card.is-selected { border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); } -.island-card { position:relative; overflow:hidden; } -.island-card::before { content:""; position:absolute; inset:-30% -20%; background:radial-gradient(circle at 35% 30%, rgba(68,231,255,.22), transparent 35%), radial-gradient(circle at 70% 70%, rgba(184,255,90,.14), transparent 34%); pointer-events:none; } -.island-card h2, .island-card canvas, .island-card p { position:relative; z-index:1; } -.island-card canvas { width:100%; height:auto; display:block; margin:10px 0; border:1px solid rgba(255,255,255,.14); border-radius:20px; background:#06121f; box-shadow:inset 0 1px 0 rgba(255,255,255,.08), 0 18px 50px rgba(0,0,0,.2); } -.island-card p { margin:0; color:var(--muted); font-size:13px; } diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml deleted file mode 100644 index 4b3ba98..0000000 --- a/examples/techdemo/templates/control_center.heml +++ /dev/null @@ -1,69 +0,0 @@ -
    -
    -
    -

    hemx Control Plane

    -

    A Linear-class work system without a frontend framework.

    -

    Create, inspect, advance, and stream work through native HTML, generated typed resources, and compact update batches. The UI feels app-grade; the model stays server-owned and boring.

    -
    -
    {+= self.hero =+}
    -
    - - - -
    - - -
    -
    - Generated slots + keyed cards - generated resources, no selectors -
    -
    {+= self.board =+}
    -
    -
    - -
    -
    -

    Update inspector

    -
    {+= self.inspector =+}
    -
    -
    -

    Opaque island bridge

    - -

    Waiting for Rust snapshot…

    -
    -
    -

    Activity stream

    -
    {+= self.activity =+}
    -
    -
    -

    Server push

    -
    Waiting for SSE heartbeat…
    -
    -
    - -
    diff --git a/examples/techdemo/templates/island.js b/examples/techdemo/templates/island.js deleted file mode 100644 index 1c6c25e..0000000 --- a/examples/techdemo/templates/island.js +++ /dev/null @@ -1,122 +0,0 @@ -// Opaque leaf-widget island. hemx talks to it only with native CustomEvent payloads. -// req: interop/001 req: examples/001 -(() => { - const roots = new WeakMap(); - - function forEachElement(scope, visit) { - for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) { - visit(node); - forEachElement(node, visit); - } - } - - function firstElement(scope, predicate) { - let found = null; - forEachElement(scope, (el) => { - if (!found && predicate(el)) found = el; - }); - return found; - } - - function rootOf(node) { - for (let el = node; el; el = el.parentElement) { - if (el.hasAttribute && el.hasAttribute("data-hemx-root")) return el; - } - return document.documentElement; - } - - function parseSnapshot(raw) { - const parts = String(raw || "0|0|0|waiting for Rust").split("|"); - return { - power: Number(parts[0] || 0) || 0, - cards: Number(parts[1] || 0) || 0, - impact: Number(parts[2] || 0) || 0, - label: parts.slice(3).join("|") || "waiting for Rust", - }; - } - - function render(canvas, readout, state) { - const ctx = canvas && canvas.getContext && canvas.getContext("2d"); - if (!ctx) return; - const w = canvas.width; - const h = canvas.height; - const t = state.frame / 48; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#06121f"; - ctx.fillRect(0, 0, w, h); - - const cx = w / 2; - const cy = h / 2; - const radius = 38 + Math.min(52, state.snapshot.impact * 2); - ctx.strokeStyle = "rgba(68,231,255,.45)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(cx, cy, radius * 1.55, radius * 0.72, -0.18, 0, Math.PI * 2); - ctx.stroke(); - - ctx.fillStyle = "rgba(184,255,90,.95)"; - for (let i = 0; i < Math.max(1, state.snapshot.cards); i += 1) { - const angle = t + (Math.PI * 2 * i / Math.max(1, state.snapshot.cards)); - const x = cx + Math.cos(angle) * radius * 1.55; - const y = cy + Math.sin(angle) * radius * 0.72; - ctx.beginPath(); - ctx.arc(x, y, 4 + (state.snapshot.power % 4), 0, Math.PI * 2); - ctx.fill(); - } - - ctx.fillStyle = "#fff"; - ctx.font = "700 16px system-ui, sans-serif"; - ctx.fillText("hemx island", 18, 30); - ctx.font = "12px system-ui, sans-serif"; - ctx.fillStyle = "rgba(255,255,255,.74)"; - ctx.fillText(`cards ${state.snapshot.cards} · impact ${state.snapshot.impact} · boost ${state.snapshot.power}`, 18, 50); - if (readout) readout.textContent = state.snapshot.label; - } - - function boot(island) { - if (roots.has(island)) return; - const canvas = firstElement(island, (el) => el.tagName === "CANVAS"); - const readout = firstElement(island, (el) => el.hasAttribute("data-island-readout")); - const state = { frame: 0, snapshot: parseSnapshot(island.getAttribute("data-island-snapshot")) }; - roots.set(island, state); - - const root = rootOf(island); - const update = (event) => { - state.snapshot = parseSnapshot(event.detail); - island.setAttribute("data-island-snapshot", event.detail); - }; - root.addEventListener("hemx:island-orbit", update); - - function tick() { - if (!document.contains(island)) { - root.removeEventListener("hemx:island-orbit", update); - return; - } - state.frame += 1; - render(canvas, readout, state); - requestAnimationFrame(tick); - } - tick(); - } - - function scan() { - forEachElement(document, (el) => { - if (el.hasAttribute("data-hemx-island")) boot(el); - }); - } - - function bootAddedIslands(records) { - for (const record of records) { - for (const node of record.addedNodes) { - if (node.nodeType === 1) { - scan(); - return; - } - } - } - } - - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan); - else scan(); - if (typeof MutationObserver !== "undefined") new MutationObserver(bootAddedIslands).observe(document.documentElement, { childList: true, subtree: true }); -})(); diff --git a/examples/techdemo/templates/partials/activity_feed.heml b/examples/techdemo/templates/partials/activity_feed.heml deleted file mode 100644 index 5f72f9a..0000000 --- a/examples/techdemo/templates/partials/activity_feed.heml +++ /dev/null @@ -1,3 +0,0 @@ -
      -
    1. {+ item +}
    2. -
    diff --git a/examples/techdemo/templates/partials/architecture_activity.heml b/examples/techdemo/templates/partials/architecture_activity.heml deleted file mode 100644 index 196f8cb..0000000 --- a/examples/techdemo/templates/partials/architecture_activity.heml +++ /dev/null @@ -1,5 +0,0 @@ -
      -
    1. Clicked a real anchor
    2. -
    3. Fetched HTML with X-HEMX-Partial
    4. -
    5. Preserved native fallback semantics
    6. -
    diff --git a/examples/techdemo/templates/partials/architecture_board.heml b/examples/techdemo/templates/partials/architecture_board.heml deleted file mode 100644 index a7d2579..0000000 --- a/examples/techdemo/templates/partials/architecture_board.heml +++ /dev/null @@ -1,5 +0,0 @@ -
    -

    hemplate

    Owns syntax and Surface facts.

    -

    hemx-build

    Generates resources and lowering tables.

    -

    runtime

    Executes compact typed DOM ops.

    -
    diff --git a/examples/techdemo/templates/partials/architecture_hero.heml b/examples/techdemo/templates/partials/architecture_hero.heml deleted file mode 100644 index 5139e37..0000000 --- a/examples/techdemo/templates/partials/architecture_hero.heml +++ /dev/null @@ -1,5 +0,0 @@ -
    -
    1template source of truth
    -
    0CSS selectors in handlers
    -
    ∞typed composition through tuples
    -
    diff --git a/examples/techdemo/templates/partials/architecture_inspector.heml b/examples/techdemo/templates/partials/architecture_inspector.heml deleted file mode 100644 index d587e74..0000000 --- a/examples/techdemo/templates/partials/architecture_inspector.heml +++ /dev/null @@ -1 +0,0 @@ -
    Page swap
    This route was fetched as a partial and rendered through the same root.
    diff --git a/examples/techdemo/templates/partials/board_lanes.heml b/examples/techdemo/templates/partials/board_lanes.heml deleted file mode 100644 index b522a3f..0000000 --- a/examples/techdemo/templates/partials/board_lanes.heml +++ /dev/null @@ -1,5 +0,0 @@ -
    - -
    diff --git a/examples/techdemo/templates/partials/fast_metric.heml b/examples/techdemo/templates/partials/fast_metric.heml deleted file mode 100644 index 80bd5f0..0000000 --- a/examples/techdemo/templates/partials/fast_metric.heml +++ /dev/null @@ -1 +0,0 @@ -{+ self.label +} diff --git a/examples/techdemo/templates/partials/hero_metrics.heml b/examples/techdemo/templates/partials/hero_metrics.heml deleted file mode 100644 index ed63960..0000000 --- a/examples/techdemo/templates/partials/hero_metrics.heml +++ /dev/null @@ -1,6 +0,0 @@ -
    -
    {+ self.resource_count +}generated resources on this page
    -
    {+ self.active_count +}active typed work items
    -
    {+ self.shipped_count +}shipped without app JS
    -
    {+ self.impact_score +}aggregate impact score
    -
    diff --git a/examples/techdemo/templates/partials/host_panel.heml b/examples/techdemo/templates/partials/host_panel.heml deleted file mode 100644 index 11cd24d..0000000 --- a/examples/techdemo/templates/partials/host_panel.heml +++ /dev/null @@ -1,10 +0,0 @@ -
    -

    Typed host boundary
    {+ self.status +}

    -
    - - - - -
    - {+ self.boundary +} -
    diff --git a/examples/techdemo/templates/partials/inspector_empty.heml b/examples/techdemo/templates/partials/inspector_empty.heml deleted file mode 100644 index 8672ad1..0000000 --- a/examples/techdemo/templates/partials/inspector_empty.heml +++ /dev/null @@ -1,3 +0,0 @@ -
    - No issue selected -
    diff --git a/examples/techdemo/templates/partials/inspector_panel.heml b/examples/techdemo/templates/partials/inspector_panel.heml deleted file mode 100644 index fd7dfa6..0000000 --- a/examples/techdemo/templates/partials/inspector_panel.heml +++ /dev/null @@ -1,4 +0,0 @@ -{+= self.selected =+} -
    What happened
    {+ self.spotlight +}
    -
    Update path
    generated targets → tuple updates → DOM
    -
    Runtime
    Root-scoped delegated listeners; generated targets only.
    diff --git a/examples/techdemo/templates/partials/inspector_selected.heml b/examples/techdemo/templates/partials/inspector_selected.heml deleted file mode 100644 index c10611f..0000000 --- a/examples/techdemo/templates/partials/inspector_selected.heml +++ /dev/null @@ -1,5 +0,0 @@ -
    - Selected issue - {+ self.title +} - {+ self.lane +} · {+ self.stage +} · impact {+ self.impact +} -
    diff --git a/examples/techdemo/templates/partials/issue_card.heml b/examples/techdemo/templates/partials/issue_card.heml deleted file mode 100644 index 2d75d1e..0000000 --- a/examples/techdemo/templates/partials/issue_card.heml +++ /dev/null @@ -1,9 +0,0 @@ -
    -
    {+ self.title +}{+ self.stage +}
    -
    -
    - - - -
    -
    diff --git a/examples/techdemo/templates/partials/issue_lane.heml b/examples/techdemo/templates/partials/issue_lane.heml deleted file mode 100644 index 1255063..0000000 --- a/examples/techdemo/templates/partials/issue_lane.heml +++ /dev/null @@ -1,7 +0,0 @@ -
    -

    {+ self.title +}

    -

    {+ self.description +}

    - -
    diff --git a/examples/techdemo/templates/partials/live_feed.heml b/examples/techdemo/templates/partials/live_feed.heml deleted file mode 100644 index 18f67f3..0000000 --- a/examples/techdemo/templates/partials/live_feed.heml +++ /dev/null @@ -1 +0,0 @@ -
    SSE tick #{+ self.tick +}
    Server streamed a generated update into the live feed target.
    diff --git a/examples/techdemo/templates/partials/local_panel.heml b/examples/techdemo/templates/partials/local_panel.heml deleted file mode 100644 index 9aff4c9..0000000 --- a/examples/techdemo/templates/partials/local_panel.heml +++ /dev/null @@ -1,8 +0,0 @@ -
    -

    Local command log
    {+ self.status +}

    -

    {+ self.projection +}

    -
    - -
    - {+ self.boundary +} -
    diff --git a/examples/techdemo/tests/browser_e2e.rs b/examples/techdemo/tests/browser_e2e.rs deleted file mode 100644 index 7ea3375..0000000 --- a/examples/techdemo/tests/browser_e2e.rs +++ /dev/null @@ -1,167 +0,0 @@ -use hemx_techdemo::ui::control_center::{self as control, launch_work, simulate_push}; -use hemx_test::{ - any_root_selector, document_body_selector, handle_button_selector, handle_selector, - island_probe_script, island_readout_selector, keyed_selector, nav_link_selector, - scoped_island_readout_selector, target_selector, TestProcess, -}; -use std::process::Command; -use std::time::{Duration, Instant}; -use thirtyfour::prelude::*; - -const APP_ADDR: &str = "127.0.0.1:3012"; -const WEBDRIVER_ADDR: &str = "127.0.0.1:4445"; -const STARTUP_TIMEOUT: Duration = Duration::from_secs(8); - -#[tokio::test] -async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { - // req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003 - let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-techdemo")); - app.env("HEMX_TECHDEMO_ADDR", APP_ADDR); - let _app = TestProcess::start(app, "hemx-techdemo", APP_ADDR, STARTUP_TIMEOUT) - .expect("start ready hemx-techdemo"); - - let mut webdriver = Command::new("geckodriver"); - webdriver.arg("--port").arg("4445"); - let _webdriver = TestProcess::start(webdriver, "geckodriver", WEBDRIVER_ADDR, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; - - let result = async { - driver.goto(&format!("http://{APP_ADDR}/")).await?; - assert_text( - &driver, - "A Linear-class work system without a frontend framework", - ) - .await?; - assert_text(&driver, "Compile checked handles").await?; - assert_text( - &driver, - "No selectors. Generated resources address every target.", - ) - .await?; - assert_text(&driver, "Opaque island bridge").await?; - wait_for_runtime(&driver).await?; - insert_probe_island(&driver).await?; - wait_for_text( - &driver, - &scoped_island_readout_selector("#probe-island"), - "probe live", - ) - .await?; - - driver - .find(By::Css(handle_selector(simulate_push))) - .await? - .click() - .await?; - wait_for_text(&driver, &target_selector(control::notice), "Push simulated").await?; - wait_for_text(&driver, island_readout_selector(), "activity rows").await?; - - driver - .find(By::Css(handle_button_selector(launch_work))) - .await? - .click() - .await?; - wait_for_text( - &driver, - &keyed_selector(".work-card", 4), - "Ship typed updates", - ) - .await?; - assert_text(&driver, "width:77%").await?; - - driver - .find(By::Css(handle_selector(simulate_push))) - .await? - .click() - .await?; - wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?; - - driver - .find(By::Css(nav_link_selector("/architecture"))) - .await? - .click() - .await?; - wait_for_text(&driver, &target_selector(control::inspector), "Page swap").await?; - wait_for_text(&driver, island_readout_selector(), "activity rows").await?; - assert!(driver - .current_url() - .await? - .as_str() - .ends_with("/architecture")); - - driver.goto(&format!("http://{APP_ADDR}/")).await?; - wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?; - - Ok::<(), WebDriverError>(()) - } - .await; - - let quit = driver.quit().await; - result.and(quit) -} - -async fn insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> { - let root = driver.find(By::Css(any_root_selector())).await?; - let script = island_probe_script( - "probe-island", - "orbit", - "1|1|1|probe waiting", - "7|2|8|probe live", - ); - driver.execute(&script, vec![root.to_json()?]).await?; - Ok(()) -} - -async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { - let deadline = Instant::now() + Duration::from_secs(8); - loop { - let loaded = driver - .execute( - "return !!window.hemx && window.hemx.roots().length > 0", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false); - if loaded { - return Ok(()); - } - if Instant::now() >= deadline { - panic!("timed out waiting for hemx runtime"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> { - wait_for_text(driver, document_body_selector(), text).await -} - -async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> { - let deadline = Instant::now() + Duration::from_secs(8); - let by = By::Css(selector); - loop { - if let Ok(element) = driver.find(by.clone()).await { - let content = element.text().await.unwrap_or_default(); - let html = element.inner_html().await.unwrap_or_default(); - if content.contains(text) || html.contains(text) { - return Ok(()); - } - } - if Instant::now() >= deadline { - let body = driver - .find(By::Css(document_body_selector())) - .await? - .text() - .await - .unwrap_or_default(); - panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} diff --git a/examples/techdemo/tests/e2e.rs b/examples/techdemo/tests/e2e.rs deleted file mode 100644 index 7c04410..0000000 --- a/examples/techdemo/tests/e2e.rs +++ /dev/null @@ -1,529 +0,0 @@ -use hemx_axum::runtime_js_path; -use hemx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push}; -use hemx_techdemo::ui::host_panel::{ - record_browser_share, record_native_haptic_ack, request_browser_share, request_native_haptic, -}; -use hemx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work}; -use hemx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle; -use hemx_techdemo::ui::local_panel::queue_local_set; -use hemx_techdemo::ui::BUILD_FINGERPRINT; -use hemx_test::{ - class_descendant_selector, class_selector, handle_form_body, inspect_wire, - island_attribute_name, island_event_name, island_selector, island_snapshot_marker, - root_selector, sse_endpoint_marker, strong_text_selector, unknown_handle_form_body, - EffectInspector, -}; -use scraper::{Html, Selector}; -use std::io::{Read, Write}; -use std::net::TcpStream; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -const ADDR: &str = "127.0.0.1:3002"; - -struct Server { - child: Child, -} - -impl Server { - fn start() -> Self { - let bin = env!("CARGO_BIN_EXE_hemx-techdemo"); - let mut child = Command::new(bin) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("start hemx-techdemo"); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if TcpStream::connect(ADDR).is_ok() { - return Self { child }; - } - std::thread::sleep(Duration::from_millis(25)); - } - let _ = child.kill(); - let _ = child.wait(); - panic!("hemx-techdemo did not listen on {ADDR}"); - } -} - -impl Drop for Server { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -#[test] -fn product_is_e2e_working_over_http() { - // req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003 - let _server = Server::start(); - - let home = get("/"); - assert_eq!(home.status, 200); - assert!(home.header("content-type").contains("text/html")); - let document = Html::parse_document(home.text()); - assert_text( - &document, - "A Linear-class work system without a frontend framework", - ); - assert_text(&document, "Compile checked handles"); - assert_text(&document, "Stream typed presence"); - assert_selector_count_at_least(&document, &root_selector("techdemo"), 1); - assert_work_card_count_at_least(&document, 3); - assert_selector_count_at_least(&document, &island_selector("orbit"), 1); - assert_text(&document, "Opaque island bridge"); - assert!(home.text().contains(island_snapshot_marker())); - assert!(home.text().contains(&sse_endpoint_marker("/events"))); - assert!(home.text().contains("/island.js")); - - let favicon = get("/favicon.ico"); - assert_eq!(favicon.status, 204); - - let runtime = get(runtime_js_path()); - assert_eq!(runtime.status, 200); - assert!(runtime.header("content-type").contains("javascript")); - - let island = get("/island.js"); - assert_eq!(island.status, 200); - assert!(island.header("content-type").contains("javascript")); - assert!(island.text().contains(&island_event_name("orbit"))); - assert!(island.text().contains(island_attribute_name())); - assert!(island.text().contains("MutationObserver")); - assert!(island.text().contains("removeEventListener")); - - let architecture = request("GET", "/architecture", &[("X-HEMX-Partial", "1")], ""); - assert_eq!(architecture.status, 200); - assert!(architecture.header("x-hemx-partial").contains("true")); - let architecture_doc = Html::parse_document(architecture.text()); - assert_text(&architecture_doc, "Page swap"); - assert_text(&architecture_doc, "hemx-build"); - - let launch = post( - "/", - &handle_form_body( - launch_work, - &[ - ("title", "Design hero moment"), - ("lane", "product"), - ("impact", "9"), - ], - ), - ); - assert_effect_response(&launch); - let launch_batch = launch.effects(); - assert_payload_contains(&launch_batch, "Design hero moment"); - assert_card( - &launch_batch, - "Design hero moment", - "Product", - "Draft", - "width:99%", - ); - assert_payload_contains(&launch_batch, "Launch accepted"); - assert_payload_contains(&launch_batch, "Launched card #4"); - assert_emit(&launch_batch, &island_event_name("orbit"), "activity rows"); - assert!( - launch_batch.op_count() >= 6, - "launch should update generated targets and notify the island" - ); - - let default_impact = post( - "/", - &handle_form_body( - launch_work, - &[("title", "Default impact"), ("lane", "compiler")], - ), - ); - assert_effect_response(&default_impact); - let default_impact_batch = default_impact.effects(); - assert_payload_contains(&default_impact_batch, "Default impact"); - assert_card( - &default_impact_batch, - "Default impact", - "Compiler", - "Draft", - "width:55%", - ); - - let low_impact = post( - "/", - &handle_form_body( - launch_work, - &[ - ("title", "Low impact"), - ("lane", "runtime"), - ("impact", "0"), - ], - ), - ); - assert_effect_response(&low_impact); - let low_impact_batch = low_impact.effects(); - assert_card( - &low_impact_batch, - "Low impact", - "Runtime", - "Draft", - "width:11%", - ); - - let high_impact = post( - "/", - &handle_form_body( - launch_work, - &[ - ("title", "High impact"), - ("lane", "runtime"), - ("impact", "99"), - ], - ), - ); - assert_effect_response(&high_impact); - let high_impact_batch = high_impact.effects(); - assert_card( - &high_impact_batch, - "High impact", - "Runtime", - "Draft", - "width:99%", - ); - - let missing_title = post( - "/", - &handle_form_body(launch_work, &[("lane", "runtime"), ("impact", "8")]), - ); - assert_effect_response(&missing_title); - let missing_title_batch = missing_title.effects(); - assert_payload_contains(&missing_title_batch, "Launch accepted"); - assert!(missing_title_batch.payload_excludes_key(8)); - assert_payload_not_contains(&missing_title_batch, "MUTATED"); - - let move_to_lane = post( - "/", - &handle_form_body( - move_to_lane_handle, - &[("work_id", "4"), ("lane", "runtime")], - ), - ); - assert_effect_response(&move_to_lane); - let move_to_lane_batch = move_to_lane.effects(); - assert_card( - &move_to_lane_batch, - "Design hero moment", - "Runtime", - "Active", - "width:99%", - ); - assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted"); - - let inspect = post("/", &handle_form_body(spotlight_work, &[("work_id", "4")])); - assert_effect_response(&inspect); - let inspect_batch = inspect.effects(); - assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime"); - assert_payload_contains(&inspect_batch, "Inspector focused"); - - let advance = post("/", &handle_form_body(advance_work, &[("work_id", "4")])); - assert_effect_response(&advance); - let advance_batch = advance.effects(); - assert_payload_contains(&advance_batch, "Pipeline advanced"); - assert_payload_contains(&advance_batch, "Active"); - - let advance_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")])); - assert_effect_response(&advance_default); - let advance_default_batch = advance_default.effects(); - assert_payload_contains(&advance_default_batch, "Default impact"); - assert_card( - &advance_default_batch, - "Default impact", - "Compiler", - "Active", - "width:55%", - ); - - let ship_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")])); - assert_effect_response(&ship_default); - let ship_default_batch = ship_default.effects(); - assert_card( - &ship_default_batch, - "Default impact", - "Product", - "Shipped", - "width:55%", - ); - - let simulated_push = post("/", &handle_form_body(simulate_push, &[])); - assert_effect_response(&simulated_push); - let push_batch = simulated_push.effects(); - assert_payload_contains(&push_batch, "SSE tick"); - assert_payload_contains(&push_batch, "Push simulated · no client app code"); - assert_payload_contains( - &push_batch, - "Simulated push event produced the same generated update shape", - ); - assert_emit(&push_batch, &island_event_name("orbit"), "activity rows"); - - let browser_host_request = post("/", &handle_form_body(request_browser_share, &[])); - assert_effect_response(&browser_host_request); - let browser_request_batch = browser_host_request.effects(); - assert_payload_contains(&browser_request_batch, "Browser host call requested"); - assert_payload_contains( - &browser_request_batch, - "Browser/PWA share request accepted; waiting for HostEvent", - ); - assert_emit(&browser_request_batch, "hemx:host-call", "browser-share-1"); - - let browser_host_result = post( - "/", - &handle_form_body(record_browser_share, &[("completed", "true")]), - ); - assert_effect_response(&browser_host_result); - let browser_result_batch = browser_host_result.effects(); - assert_payload_contains( - &browser_result_batch, - "Browser/PWA HostEvent became an app command before UI effects.", - ); - assert_payload_contains( - &browser_result_batch, - "Browser share completed through app host pipeline", - ); - - let native_host_request = post("/", &handle_form_body(request_native_haptic, &[])); - assert_effect_response(&native_host_request); - let native_request_batch = native_host_request.effects(); - assert_payload_contains(&native_request_batch, "Native host call requested"); - assert_payload_contains( - &native_request_batch, - "Native-shell haptic request accepted; waiting for host acknowledgment.", - ); - assert_emit(&native_request_batch, "hemx:host-call", "native-haptic-tap"); - - let native_host_result = post("/", &handle_form_body(record_native_haptic_ack, &[])); - assert_effect_response(&native_host_result); - let native_result_batch = native_host_result.effects(); - assert_payload_contains( - &native_result_batch, - "Native-shell HostEvent became an app command before UI effects.", - ); - assert_payload_contains( - &native_result_batch, - "Native haptic acknowledgment accepted by app code", - ); - - let local_command = post("/", &handle_form_body(queue_local_set, &[])); - assert_effect_response(&local_command); - let local_batch = local_command.effects(); - assert_payload_contains(&local_batch, "Local command accepted · projection rendered"); - assert_payload_contains(&local_batch, "1 commands, 1 events, 1 projected sets"); - assert_payload_contains( - &local_batch, - "Projected set 1 with 8 reps from commands/events; no DOM patch or UI update was stored", - ); - assert_payload_contains( - &local_batch, - "Local command #1 became event and projection before UI effects", - ); - - let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")])); - assert_effect_response(&delete_missing); - let delete_missing_batch = delete_missing.effects(); - assert_payload_not_contains(&delete_missing_batch, "Deleted card #999"); - - let delete = post("/", &handle_form_body(delete_work, &[("work_id", "4")])); - assert_effect_response(&delete); - let delete_batch = delete.effects(); - assert_payload_contains(&delete_batch, "Card removed"); - assert_payload_contains(&delete_batch, "Deleted card #4"); - assert!(delete_batch.payload_excludes_key(4)); - assert_payload_contains(&delete_batch, "Default impact"); - - let reset = post("/", &handle_form_body(reset_demo, &[])); - assert_effect_response(&reset); - let reset_batch = reset.effects(); - assert_payload_contains(&reset_batch, "Demo reset from Rust state"); - assert_payload_contains(&reset_batch, "Compile checked handles"); - - let sse = get("/events?once=1"); - assert_eq!(sse.status, 200); - assert!(sse.header("content-type").contains("text/event-stream")); - assert!(sse.text().contains("event: hemx")); - assert!(sse.text().contains("data: ")); - - let unknown = post("/", &unknown_handle_form_body(999999)); - assert_eq!(unknown.status, 404); - assert!(unknown.text().contains("unknown hemx handle id 999999")); -} - -fn assert_effect_response(response: &Response) { - assert_eq!(response.status, 200); - assert!(response.header("content-type").contains("application/hemx")); - assert_eq!( - response.header("x-hemx-fingerprint"), - BUILD_FINGERPRINT.0.to_string() - ); - assert!(!response.effects().is_empty()); -} - -fn assert_payload_contains(batch: &EffectInspector, needle: &str) { - assert!( - batch.payload_contains(needle), - "missing payload {needle:?} in {batch:#?}" - ); -} - -fn assert_emit(batch: &EffectInspector, name: &str, needle: &str) { - assert!( - batch.emits_containing(name, needle), - "missing emit {name:?} containing {needle:?} in {batch:#?}" - ); -} - -fn assert_card(batch: &EffectInspector, title: &str, lane: &str, stage: &str, impact_style: &str) { - let board = batch - .target_html_containing(control::board, "class=\"lanes\"") - .expect("board html payload"); - let document = Html::parse_fragment(board); - let lane_selector = Selector::parse(&class_selector("lane")).unwrap(); - let card_selector = Selector::parse(&class_selector("work-card")).unwrap(); - let strong_selector = Selector::parse(strong_text_selector()).unwrap(); - let stage_selector = Selector::parse(&class_selector("pill")).unwrap(); - let impact_selector = Selector::parse(&class_descendant_selector("impact", "i")).unwrap(); - - for lane_node in document.select(&lane_selector) { - let lane_text = lane_node.text().collect::>().join(" "); - if !lane_text.contains(lane) { - continue; - } - for card in lane_node.select(&card_selector) { - let card_title = card - .select(&strong_selector) - .next() - .map(|node| node.text().collect::()) - .unwrap_or_default(); - if card_title != title { - continue; - } - let card_stage = card - .select(&stage_selector) - .next() - .map(|node| node.text().collect::()) - .unwrap_or_default(); - let style = card - .select(&impact_selector) - .next() - .and_then(|node| node.value().attr("style")) - .unwrap_or(""); - assert_eq!(card_stage, stage); - assert!( - style.contains(impact_style), - "style {style:?} missing {impact_style:?}" - ); - return; - } - } - - panic!("missing card title={title:?} lane={lane:?} in {board}"); -} - -fn assert_payload_not_contains(batch: &EffectInspector, needle: &str) { - assert!( - batch.payload_excludes(needle), - "unexpected payload {needle:?} in {batch:#?}" - ); -} - -fn assert_text(document: &Html, text: &str) { - let body = document.root_element().text().collect::>().join(" "); - assert!(body.contains(text), "missing text {text:?} in {body:?}"); -} - -fn assert_work_card_count_at_least(document: &Html, expected: usize) { - assert_selector_count_at_least(document, &class_selector("work-card"), expected); -} - -fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) { - let selector = Selector::parse(selector).unwrap(); - let count = document.select(&selector).count(); - assert!(count >= expected, "selector count {count} < {expected}"); -} - -fn get(path: &str) -> Response { - request("GET", path, &[], "") -} - -fn post(path: &str, body: &str) -> Response { - request( - "POST", - path, - &[("Content-Type", "application/x-www-form-urlencoded")], - body, - ) -} - -fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response { - let mut stream = TcpStream::connect(ADDR).expect("connect to server"); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - write!( - stream, - "{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n", - body.len() - ) - .unwrap(); - for (name, value) in headers { - write!(stream, "{name}: {value}\r\n").unwrap(); - } - write!(stream, "\r\n{body}").unwrap(); - - let mut raw = Vec::new(); - stream.read_to_end(&mut raw).unwrap(); - Response::parse(raw) -} - -struct Response { - status: u16, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Response { - fn parse(raw: Vec) -> Self { - let split = raw - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("response header terminator"); - let head = String::from_utf8(raw[..split].to_vec()).unwrap(); - let body = raw[split + 4..].to_vec(); - let mut lines = head.lines(); - let status = lines - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|status| status.parse().ok()) - .expect("status code"); - let headers = lines - .filter_map(|line| line.split_once(':')) - .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) - .collect(); - Self { - status, - headers, - body, - } - } - - fn header(&self, name: &str) -> &str { - self.headers - .iter() - .find(|(key, _)| key == &name.to_ascii_lowercase()) - .map(|(_, value)| value.as_str()) - .unwrap_or("") - } - - fn text(&self) -> &str { - std::str::from_utf8(&self.body).unwrap() - } - - fn effects(&self) -> EffectInspector { - inspect_wire(&self.body) - } -} diff --git a/examples/v0/Cargo.toml b/examples/v0/Cargo.toml deleted file mode 100644 index c78c9ba..0000000 --- a/examples/v0/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "hemx-v0-examples" -version.workspace = true -edition.workspace = true -publish = false - -[lib] -path = "src/lib.rs" - -[dependencies] -axum = "0.8" -futures-util = "0.3" -hemplate = { path = "../../../hemplate/hemplate" } -hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } - -[dev-dependencies] -scraper = "0.25" -hemx-test = { path = "../../hemx-test" } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/v0/README.md b/examples/v0/README.md deleted file mode 100644 index d11085b..0000000 --- a/examples/v0/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# hemx v0 browser examples - -Run the examples server: - -```sh -cargo run -p hemx-v0-examples -``` - -Open . - -The page includes working examples for generated target objects and server-first UI commands: - -- counter updates -- todo form submission with one `TodoRow` hemplate partial, a notice slot, handlers, generated row append/replace/remove commands, and dynamic `Vec` row batches reused across initial render and updates -- wizard step updates -- login form feedback -- page swap/navigation -- server-sent notifications diff --git a/examples/v0/build.rs b/examples/v0/build.rs deleted file mode 100644 index 99fa6f3..0000000 --- a/examples/v0/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - hemx_build::app().run().unwrap(); -} diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs deleted file mode 100644 index 18a0976..0000000 --- a/examples/v0/src/lib.rs +++ /dev/null @@ -1,191 +0,0 @@ -#[hemx::surface] -pub mod ui {} - -#[cfg(test)] -mod tests { - use super::ui::{auth, counter, notifications, page_swap, todos, wizard}; - use hemplate::Hemplate; - use hemx::{push, IntoEffect}; - use hemx_test::inspect; - - #[derive(Clone, Debug)] - struct Todo { - id: u64, - title: String, - } - - #[derive(Clone, Debug)] - #[hemx::form("new_todo")] - struct TodoInput { - title: String, - } - - #[derive(Clone, Debug)] - #[hemx::form("wizard_input")] - struct WizardInput { - step: u8, - } - - #[derive(Clone, Debug)] - #[hemx::form("credentials")] - struct Credentials { - email: String, - password: String, - } - - #[derive(Hemplate)] - #[hemplate = "partials"] - struct TodoRow { - id: u64, - title: String, - } - - impl hemx::KeyedPartial for TodoRow { - fn hemx_key(&self) -> String { - self.id.to_string() - } - } - - #[derive(Hemplate)] - #[hemplate = "partials"] - struct DocsContent { - message: &'static str, - } - - // req: examples/001 req: ceremony/001 req: build/001 req: build/005 req: codegen/002 - #[test] - fn counter_updates_a_generated_slot() { - fn increment(count: u64) -> impl IntoEffect { - counter::counter_value.set(count + 1) - } - - let effect = inspect(increment(1)); - - assert!(effect.updates_text(counter::counter_value)); - } - - // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 - #[test] - fn form_handler_is_checked_against_hemplate_form() { - #[hemx::handler] - fn add_todo(_form: hemx::Form) -> impl IntoEffect { - todos::summary.set("queued") - } - - let effect = inspect(add_todo(TodoInput::FORM)); - - assert!(effect.updates_text(todos::summary)); - } - - // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002 - #[test] - fn todos_append_keyed_rows_from_form_input() { - fn add_todo(input: TodoInput) -> impl IntoEffect { - let todo = Todo { - id: 7, - title: input.title, - }; - todos::todo_row.append(TodoRow { - id: todo.id, - title: todo.title, - }) - } - - let effect = inspect(add_todo(TodoInput { - title: "Ship v0".into(), - })); - - assert!(effect.inserts_html_containing(todos::todo_row, 7, "Ship v0")); - } - - // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 - #[test] - fn wizard_form_handler_is_checked_against_hemplate_form() { - #[hemx::handler] - fn next_step(_form: hemx::Form) -> impl IntoEffect { - wizard::wizard_step.set("queued") - } - - let effect = inspect(next_step(WizardInput::FORM)); - - assert!(effect.updates_text(wizard::wizard_step)); - } - - // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004 - #[test] - fn wizard_form_swaps_the_current_step() { - fn next_step(input: WizardInput) -> impl IntoEffect { - wizard::wizard_step.set(format!("Step {}", input.step + 1)) - } - - let effect = inspect(next_step(WizardInput { step: 1 })); - - assert!(effect.updates_text(wizard::wizard_step)); - } - - // req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001 - #[test] - fn page_swap_updates_content_and_history() { - fn load_docs() -> impl IntoEffect { - ( - page_swap::content.put(&DocsContent { - message: "This page was swapped.", - }), - page_swap::title.set("Docs"), - push("/docs"), - ) - } - - let effect = inspect(load_docs()); - - assert!(effect.updates_html_containing(page_swap::content, "This page was swapped.")); - assert!(effect.updates_text(page_swap::title)); - assert!(effect.pushes_to("/docs")); - } - - // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 - #[test] - fn auth_form_handler_is_checked_against_hemplate_form() { - #[hemx::handler] - fn login(_form: hemx::Form) -> impl IntoEffect { - auth::login_status.set("queued") - } - - let effect = inspect(login(Credentials::FORM)); - - assert!(effect.updates_text(auth::login_status)); - } - - // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004 - #[test] - fn auth_flow_reads_typed_input_and_updates_status() { - fn login(input: Credentials) -> impl IntoEffect { - let status = if input.email == "demo@example.com" && !input.password.is_empty() { - "Signed in" - } else { - "Try again" - }; - - auth::login_status.set(status) - } - - let effect = inspect(login(Credentials { - email: "demo@example.com".into(), - password: "secret".into(), - })); - - assert!(effect.updates_text(auth::login_status)); - } - - // req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005 - #[test] - fn sse_notifications_update_a_generated_slot() { - fn notification(message: &str) -> impl IntoEffect { - notifications::notifications.set(message) - } - - let effect = inspect(notification("Build finished")); - - assert!(effect.updates_text(notifications::notifications)); - } -} diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs deleted file mode 100644 index 5507b7e..0000000 --- a/examples/v0/src/main.rs +++ /dev/null @@ -1,788 +0,0 @@ -use axum::extract::{Query, State}; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::Router; -use futures_util::{stream, StreamExt}; -use hemplate::Hemplate; -use hemx::{Html, IntoEffect}; -use hemx_axum::{ - interactions, runtime_js, runtime_js_path, sse, EffectResponse, Form, HandlerErrorContext, - HandlerFailure, InteractionHandlers, InteractionRequest, IntoHandlerFailure, PageRequest, -}; -use hemx_v0_examples::ui; -use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard}; -use std::collections::BTreeMap; -use std::convert::Infallible; -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -#[derive(Default)] -struct ExampleState { - counter: Mutex, - todos: Mutex>, - wizard_step: Mutex, -} - -#[derive(Clone)] -struct TodoRecord { - // Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries. - // req: examples/001 req: view/001 - id: u64, - title: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct TodoId(u64); - -impl std::fmt::Display for TodoId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::str::FromStr for TodoId { - type Err = std::num::ParseIntError; - - fn from_str(value: &str) -> Result { - value.parse().map(Self) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct TodoTitle(String); - -impl TodoTitle { - fn as_str(&self) -> &str { - &self.0 - } - - fn into_string(self) -> String { - self.0 - } -} - -impl std::fmt::Display for TodoTitle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl std::str::FromStr for TodoTitle { - type Err = Infallible; - - fn from_str(value: &str) -> Result { - Ok(Self(value.trim().to_owned())) - } -} - -#[hemx::form("new_todo")] -struct NewTodo { - title: TodoTitle, -} - -#[hemx::form("rename_todo")] -struct RenameTodo { - id: TodoId, - title: TodoTitle, -} - -#[hemx::form("delete_todo")] -struct DeleteTodo { - id: TodoId, -} - -#[hemx::form("wizard_input")] -struct WizardInput { - step: String, -} - -#[hemx::form("credentials")] -struct Credentials { - email: String, - password: String, -} - -#[derive(Debug)] -struct AppError(String); - -impl std::fmt::Display for AppError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for AppError {} - -impl IntoHandlerFailure for AppError { - fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { - HandlerFailure::effects( - notifications::notifications.set(format!("Could not save todo: {}", self.0)), - context, - ) - } -} - -#[derive(Hemplate)] -struct Todos { - summary: String, - notice: String, - rows: Vec, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct TodoRow { - id: TodoId, - title: String, -} - -impl hemx::KeyedPartial for TodoRow { - fn hemx_key(&self) -> String { - self.id.to_string() - } -} - -#[derive(Hemplate)] -struct Counter; - -#[derive(Hemplate)] -struct Wizard; - -#[derive(Hemplate)] -struct Auth; - -#[derive(Hemplate)] -struct Notifications; - -#[derive(Hemplate)] -struct PageSwap { - content: Html, - title: &'static str, -} - -#[derive(Hemplate)] -struct AppShell { - runtime_src: &'static str, - body: Html, -} - -#[derive(Hemplate)] -#[hemplate = "partials"] -struct DocsContent { - message: &'static str, -} - -#[tokio::main] -async fn main() { - let state = Arc::new(ExampleState::default()); - let app = Router::new() - .route("/", get(home).post(interact)) - .route("/docs", get(docs).post(interact)) - .route("/events", get(events)) - .route(runtime_js_path(), get(runtime)) - .with_state(state); - - let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - println!("hemx v0 examples: http://{addr}"); - axum::serve(listener, app).await.unwrap(); -} - -// req: examples/001 -async fn home(request: PageRequest) -> impl IntoResponse { - request - .page_html(all_examples(), shell) - .title("hemx v0 examples") - .fingerprint(ui::BUILD_FINGERPRINT) -} - -// req: page_swap/002, req: examples/001 -async fn docs(request: PageRequest) -> impl IntoResponse { - let partial = render_page_swap("Docs", "This page was swapped without a full reload."); - request - .page_html(partial, shell) - .title("Docs") - .fingerprint(ui::BUILD_FINGERPRINT) -} - -async fn interact( - State(state): State>, - request: InteractionRequest, -) -> Result { - request.dispatch_async(handlers(state)).await -} - -async fn runtime() -> impl IntoResponse { - runtime_js() -} - -// req: push/001, req: push/003, req: examples/001 -async fn events(Query(params): Query>) -> impl IntoResponse { - if params.contains_key("once") { - let effect = notifications::notifications.set("Server event #1"); - return sse(stream::iter([Ok::<_, Infallible>( - effect.into_batch(ui::BUILD_FINGERPRINT), - )]) - .boxed()); - } - - let batches = stream::unfold(1_u64, |count| async move { - tokio::time::sleep(Duration::from_secs(3)).await; - let effect = notifications::notifications.set(format!("Server event #{count}")); - Some(( - Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), - count + 1, - )) - }) - .boxed(); - sse(batches) -} - -#[hemx::app( - counter_handlers, - todo_handlers, - todo_row_handlers, - wizard_handlers, - auth_handlers -)] -fn handlers(state: Arc) -> InteractionHandlers { - interactions(ui::BUILD_FINGERPRINT) -} - -fn all_examples() -> Html { - // req: html_safety/001 req: html_safety/002 req: component/003 - Html::join([ - counter::page(&Counter), - ui::page(&todos_view(&[])), - wizard::page(&Wizard), - auth::page(&Auth), - render_page_swap("Welcome", "Welcome"), - notifications::page(&Notifications), - ]) -} - -fn shell(body: Html) -> Html { - // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 - ui::page(&AppShell { - runtime_src: runtime_js_path(), - body, - }) -} - -#[hemx::component("counter")] -mod counter_handlers { - use super::*; - - #[hemx::handler] - async fn increment(State(state): State>) -> impl IntoEffect { - // req: examples/001 - let mut counter = state.counter.lock().unwrap(); - *counter += 1; - counter::counter_value.set(*counter) - } -} - -#[hemx::component("todos")] -mod todo_handlers { - use super::*; - - #[hemx::handler] - async fn add_todo( - State(state): State>, - Form(form): Form, - ) -> Result { - // req: examples/001 req: canonical_authoring/003 - if form.title.as_str().is_empty() { - return Ok(( - todos::new_todo.error("title", "Title required"), - todos::new_todo.focus("title"), - todos::summary.set(todo_summary(&state.todos.lock().unwrap())), - todos::notice.set("Add a title to create a todo"), - )); - } - if form.title.as_str() == "fail-db" { - return Err(AppError("todo store unavailable".to_owned())); - } - - let mut todos = state.todos.lock().unwrap(); - let id = todos.last().map_or(1, |todo| todo.id + 1); - let title = form.title.into_string(); - todos.push(TodoRecord { - id, - title: title.clone(), - }); - let summary = todo_summary(&todos); - Ok(( - todos::todo_row.append(TodoRow { - id: TodoId(id), - title: title.clone(), - }), - todos::summary.set(summary), - todos::notice.set(format!("Added {title}")), - todos::new_todo.clear(), - )) - } -} - -#[hemx::component("todo_row")] -mod todo_row_handlers { - use super::*; - - #[hemx::handler] - async fn rename_todo( - State(state): State>, - Form(form): Form, - ) -> impl IntoEffect { - rename_todo_effect(state, form) - } - - #[hemx::handler] - async fn delete_todo( - State(state): State>, - Form(form): Form, - ) -> impl IntoEffect { - delete_todo_effect(state, form) - } -} - -#[hemx::component("wizard")] -mod wizard_handlers { - use super::*; - - #[hemx::handler] - async fn next_step( - State(state): State>, - Form(form): Form, - ) -> impl IntoEffect { - // req: examples/001 - let _submitted_step = form.step; - let mut step = state.wizard_step.lock().unwrap(); - *step += 1; - wizard::wizard_step.set(format!("Step {}", *step + 1)) - } -} - -#[hemx::component("auth")] -mod auth_handlers { - use super::*; - - #[hemx::handler] - async fn login( - State(_state): State>, - Form(credentials): Form, - ) -> impl IntoEffect { - // req: examples/001 - let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty(); - auth::login_status.set(if ok { - "Signed in as demo@example.com" - } else { - "Try demo@example.com with any password" - }) - } -} - -fn rename_todo_effect(state: Arc, form: RenameTodo) -> impl IntoEffect { - // req: examples/001 req: canonical_authoring/003 - if form.title.as_str().is_empty() { - return Some(( - todo_row::rename_todo_form.focus("title"), - todos::notice.set("Add a title before renaming"), - )); - } - - let mut todos = state.todos.lock().unwrap(); - todos - .iter_mut() - .find(|todo| todo.id == form.id.0) - .map(|todo| { - todo.title = form.title.into_string(); - ( - todos::todo_row.replace(TodoRow { - id: form.id, - title: todo.title.clone(), - }), - todos::notice.set("Todo renamed"), - ) - }) -} - -fn delete_todo_effect(state: Arc, form: DeleteTodo) -> impl IntoEffect { - // req: examples/001 req: canonical_authoring/003 - let mut todos = state.todos.lock().unwrap(); - let before = todos.len(); - todos.retain(|todo| todo.id != form.id.0); - let deleted = todos.len() != before; - let summary = todo_summary(&todos); - ( - deleted.then(|| todos::todo_row.remove(form.id)), - deleted.then(|| todos::summary.set(summary)), - deleted.then(|| todos::notice.set("Todo deleted")), - ) -} - -fn todos_view(todos: &[TodoRecord]) -> Todos { - // req: html_safety/002 req: view/001 req: canonical_authoring/003 - Todos { - summary: todo_summary(todos), - notice: String::new(), - rows: todo_rows(todos), - } -} - -#[cfg(test)] -fn refresh_todo_rows_effect(todos: &[TodoRecord]) -> impl IntoEffect { - // req: canonical_authoring/003 - let row_updates = todo_rows(todos) - .into_iter() - .map(|row| todos::todo_row.replace(row)) - .collect::>(); - ( - row_updates, - todos::summary.set(todo_summary(todos)), - todos::notice.set("Todos refreshed"), - ) -} - -fn todo_rows(todos: &[TodoRecord]) -> Vec { - todos - .iter() - .map(|todo| TodoRow { - id: TodoId(todo.id), - title: todo.title.clone(), - }) - .collect() -} - -fn todo_summary(todos: &[TodoRecord]) -> String { - match todos.len() { - 0 => "No todos".to_owned(), - 1 => "1 todo".to_owned(), - count => format!("{count} todos"), - } -} - -fn render_page_swap(title: &'static str, message: &'static str) -> Html { - // req: html_safety/002 req: view/001 req: component/003 - page_swap::page(&PageSwap { - content: render_docs_content(message), - title, - }) -} - -fn render_docs_content(message: &'static str) -> Html { - // req: html_safety/002 req: view/001 req: component/003 - ui::page(&DocsContent { message }) -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx_test::{ - article_selector, document_title_selector, escaped_markup_selector, heading_selector, - inspect_batch, keyed_items_selector, keyed_selector, list_item_selector, - page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector, - }; - use scraper::{Html, Selector}; - - fn selector(value: &str) -> Selector { - Selector::parse(value).expect("test selector parses") - } - - fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm { - hemx_axum::InteractionForm::for_handle( - handle, - fields - .iter() - .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), - ) - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn shell_is_rendered_by_a_hemplate_view() { - let html = shell(render_page_swap("Welcome", "Welcome")); - let document = Html::parse_document(html.as_str()); - assert_eq!( - document - .select(&selector(document_title_selector())) - .next() - .map(|title| title.text().collect::()), - Some("hemx v0 examples".to_owned()) - ); - assert_eq!( - document - .select(&selector(runtime_script_selector())) - .count(), - 1 - ); - assert_eq!( - document - .select(&selector(&root_element_selector("main", "docs"))) - .count(), - 1 - ); - assert!( - document - .root_element() - .text() - .all(|text| !text.contains("{+=")), - "hemplate insertion markers must not leak into rendered text" - ); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn docs_content_payload_is_rendered_by_a_hemplate_view() { - let html = render_docs_content("This content came from a generated update response."); - let document = Html::parse_fragment(html.as_str()); - assert_eq!( - document - .select(&selector(&heading_selector("", 1))) - .next() - .map(|heading| heading.text().collect::()), - Some("Docs".to_owned()) - ); - assert_eq!( - document - .select(&selector(&prose_selector(""))) - .next() - .map(|paragraph| paragraph.text().collect::()), - Some("This content came from a generated update response.".to_owned()) - ); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn docs_page_partial_is_rendered_by_a_hemplate_view() { - let html = render_page_swap("Docs", "This page was swapped without a full reload."); - let document = Html::parse_fragment(html.as_str()); - assert_eq!( - document - .select(&selector(&root_element_selector("main", "docs"))) - .count(), - 1 - ); - assert_eq!(document.select(&selector(article_selector())).count(), 1); - assert_eq!( - document - .select(&selector(&page_nav_link_selector("/docs"))) - .count(), - 1 - ); - assert_eq!( - document - .select(&selector(&heading_selector("article", 1))) - .next() - .map(|heading| heading.text().collect::()), - Some("Docs".to_owned()) - ); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn todos_payload_is_rendered_by_a_hemplate_view() { - let todos = vec![TodoRecord { - id: 7, - title: "Ship v0".to_owned(), - }]; - - let html = ui::page(&todos_view(&todos)); - let document = Html::parse_fragment(html.as_str()); - let rows = document - .select(&selector(&keyed_items_selector("li"))) - .collect::>(); - assert_eq!(rows.len(), 1); - let row = document - .select(&selector(&keyed_selector("li", 7))) - .next() - .expect("generated keyed row"); - assert!(row.text().collect::().contains("Ship v0")); - assert!(document - .select(&selector(&escaped_markup_selector("b"))) - .next() - .is_none()); - assert_eq!( - document - .select(&selector("[data-hemx-error-for='title']")) - .count(), - 2, - "add and rename forms expose generated field-error targets" - ); - assert_eq!( - document.select(&selector("[data-hemx-error]")).count(), - 1, - "the app exposes one root-scoped transport error outlet" - ); - } - - // req: canonical_authoring/003 req: test/005 - #[test] - fn reusable_todo_partial_supports_dynamic_replace_batches() { - let todos = vec![ - TodoRecord { - id: 7, - title: "Ship v0".to_owned(), - }, - TodoRecord { - id: 8, - title: "Write docs".to_owned(), - }, - ]; - - let initial = ui::page(&todos_view(&todos)); - let document = Html::parse_fragment(initial.as_str()); - assert_eq!( - document - .select(&selector(&keyed_items_selector("li"))) - .count(), - 2 - ); - - let refresh = - inspect_batch(refresh_todo_rows_effect(&todos).into_batch(ui::BUILD_FINGERPRINT)); - assert_eq!(refresh.op_count(), 4); - assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "7", "Ship v0")); - assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "8", "Write docs")); - assert!(refresh.updates_text(todos::summary)); - assert!(refresh.updates_text(todos::notice)); - } - - // req: html_safety/002 req: view/001 req: test/005 - #[test] - fn empty_todos_payload_is_rendered_by_a_hemplate_view() { - let html = ui::page(&todos_view(&[])); - let document = Html::parse_fragment(html.as_str()); - let rows = document - .select(&selector(&list_item_selector(""))) - .collect::>(); - assert_eq!(rows.len(), 0); - } - - // req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005 - #[tokio::test] - async fn registry_dispatches_generated_keyed_crud_effects() { - let state = Arc::new(ExampleState::default()); - - let counter = inspect_batch( - InteractionRequest::from(form(counter::increment, &[])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert_eq!(*state.counter.lock().unwrap(), 1); - assert!(counter.updates_text(counter::counter_value)); - assert!(counter.payload_contains("1")); - - let validation = inspect_batch( - InteractionRequest::from(form(todos::add_todo, &[("title", " ")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(state.todos.lock().unwrap().is_empty()); - assert!(validation.payload_contains("Title required")); - - let store_failure = inspect_batch( - InteractionRequest::from(form(todos::add_todo, &[("title", "fail-db")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(state.todos.lock().unwrap().is_empty()); - assert!(store_failure.updates_text(notifications::notifications)); - assert!(store_failure.payload_contains("todo store unavailable")); - - let add = inspect_batch( - InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0"); - assert_eq!(add.op_count(), 4); - assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0")); - assert!(add.updates_text(todos::summary)); - assert!(add.updates_text(todos::notice)); - assert!(add.payload_contains("Added Ship v0")); - assert!(add.resets_form(todos::new_todo)); - - let rename_validation = inspect_batch( - InteractionRequest::from(form( - todo_row::rename_todo, - &[("id", "1"), ("title", " ")], - )) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0"); - assert!(rename_validation.payload_contains("Add a title before renaming")); - assert!(rename_validation.updates_text(todos::notice)); - - let rename = inspect_batch( - InteractionRequest::from(form( - todo_row::rename_todo, - &[("id", "1"), ("title", "Ship 1.0")], - )) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0"); - assert_eq!(rename.op_count(), 2); - assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0")); - assert!(rename.updates_text(todos::notice)); - - let delete = inspect_batch( - InteractionRequest::from(form(todo_row::delete_todo, &[("id", "1")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(state.todos.lock().unwrap().is_empty()); - assert_eq!(delete.op_count(), 3); - assert!(delete.removes_key(todos::todo_row, "1")); - assert!(delete.updates_text(todos::summary)); - assert!(delete.updates_text(todos::notice)); - - let missing_delete = inspect_batch( - InteractionRequest::from(form(todo_row::delete_todo, &[("id", "99")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(missing_delete.is_empty()); - - let wizard = inspect_batch( - InteractionRequest::from(form(wizard::next_step, &[("step", "1")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(wizard.updates_text(wizard::wizard_step)); - assert!(wizard.payload_contains("Step 2")); - - let auth = inspect_batch( - InteractionRequest::from(form( - auth::login, - &[("email", "demo@example.com"), ("password", "secret")], - )) - .dispatch_async(handlers(state)) - .await - .unwrap() - .batch, - ); - assert!(auth.updates_text(auth::login_status)); - assert!(auth.payload_contains("Signed in as demo@example.com")); - } -} diff --git a/examples/v0/templates/app_shell.heml b/examples/v0/templates/app_shell.heml deleted file mode 100644 index 6861d98..0000000 --- a/examples/v0/templates/app_shell.heml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - hemx v0 examples - - - - -

    hemx v0 browser examples

    -

    Try the counter, todo form, wizard, login, page swap, and live SSE notifications.

    - {+= self.body =+} - - diff --git a/examples/v0/templates/auth.heml b/examples/v0/templates/auth.heml deleted file mode 100644 index c8c9130..0000000 --- a/examples/v0/templates/auth.heml +++ /dev/null @@ -1,8 +0,0 @@ -
    -
    - - - -
    -

    Signed out

    -
    diff --git a/examples/v0/templates/counter.heml b/examples/v0/templates/counter.heml deleted file mode 100644 index 3da55e5..0000000 --- a/examples/v0/templates/counter.heml +++ /dev/null @@ -1,4 +0,0 @@ -
    - 0 - -
    diff --git a/examples/v0/templates/notifications.heml b/examples/v0/templates/notifications.heml deleted file mode 100644 index 68e329a..0000000 --- a/examples/v0/templates/notifications.heml +++ /dev/null @@ -1,4 +0,0 @@ -
    -

    Notifications

    -
    No notifications
    -
    diff --git a/examples/v0/templates/page_swap.heml b/examples/v0/templates/page_swap.heml deleted file mode 100644 index 736b3d5..0000000 --- a/examples/v0/templates/page_swap.heml +++ /dev/null @@ -1,7 +0,0 @@ -
    - -
    {+= self.content =+}
    - {+ self.title +} -
    diff --git a/examples/v0/templates/partials/docs_content.heml b/examples/v0/templates/partials/docs_content.heml deleted file mode 100644 index 6c5b734..0000000 --- a/examples/v0/templates/partials/docs_content.heml +++ /dev/null @@ -1 +0,0 @@ -

    Docs

    {+ self.message +}

    diff --git a/examples/v0/templates/partials/todo_row.heml b/examples/v0/templates/partials/todo_row.heml deleted file mode 100644 index ad7ecfe..0000000 --- a/examples/v0/templates/partials/todo_row.heml +++ /dev/null @@ -1,15 +0,0 @@ -
  • - {+ self.title +} -
    - - - -

    -
    -
    - -
    -
  • diff --git a/examples/v0/templates/todos.heml b/examples/v0/templates/todos.heml deleted file mode 100644 index cb5b730..0000000 --- a/examples/v0/templates/todos.heml +++ /dev/null @@ -1,15 +0,0 @@ -
    -
    - - -

    -
    - -

    {+ self.summary +}

    -

    {+ self.notice +}

    -
      - -
    -
    diff --git a/examples/v0/templates/wizard.heml b/examples/v0/templates/wizard.heml deleted file mode 100644 index c07618a..0000000 --- a/examples/v0/templates/wizard.heml +++ /dev/null @@ -1,7 +0,0 @@ -
    -
    - -
    Step 1
    - -
    -
    diff --git a/examples/workout/Cargo.toml b/examples/workout/Cargo.toml deleted file mode 100644 index 5ecd40c..0000000 --- a/examples/workout/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "hemx-workout-example" -version.workspace = true -edition.workspace = true -publish = false - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "hemx-workout-example" -path = "src/main.rs" - -[dependencies] -axum = "0.8" -hemplate = { path = "../../../hemplate/hemplate" } -hemx = { path = "../../hemx" } -hemx-axum = { path = "../../hemx-axum" } -hemx-host = { path = "../../hemx-host" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } - -[dev-dependencies] -hemx-test = { path = "../../hemx-test" } -thirtyfour = { version = "0.36", default-features = false, features = ["rustls-tls"] } -tokio = { version = "1", features = ["macros", "process", "time"] } - -[build-dependencies] -hemx-build = { path = "../../hemx-build" } diff --git a/examples/workout/DESIGN.md b/examples/workout/DESIGN.md deleted file mode 100644 index 9ea157f..0000000 --- a/examples/workout/DESIGN.md +++ /dev/null @@ -1,20 +0,0 @@ -# Workout exemplar design - -Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, finished-session export, and an inspectable private ledger. req: examples/001 - -Preserve: -- First viewport shows one obvious action path before logs or host details. -- Controls stay large enough for sweaty one-handed use; forms remain secondary to tap actions. -- Rest/next, progress, undo recovery, invalid input, replay failure, host denial, and finish/export feel like session states, not framework demos. -- Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003 -- Host capabilities read as finish/export product states first, with proof controls tucked behind an explicit panel instead of competing with the user flow. req: host/002 - -Avoid: -- Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state. -- Equal-weight action grids that make “what now?” ambiguous. -- Styling that requires handwritten selector UI JavaScript. - -Accessibility and responsive boundaries: -- Keep semantic buttons/forms/labels and visible focus rings. -- Minimum tap target is defined by `--tap`; do not reduce it for density. -- Narrow screens are the primary material; wide screens may split the command slate and ledger but must not hide the current action. diff --git a/examples/workout/README.md b/examples/workout/README.md deleted file mode 100644 index 8c3d964..0000000 --- a/examples/workout/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Now-first Workout Copilot example - -This is the phone-first local-first product exemplar for hemx. It shows a -complete useful session: next action shown, set completed, rest/next state -entered, progress updated, a mistyped set corrected and undone, workout finished, -final export shared or replayed, and invalid input, replay failure, or host denial -recovered without storing DOM patches or UI update payloads as truth. -req: examples/001 req: local/001 req: local/003 req: local/004 - -## Create - -```sh -cargo run -p hemx-xtask -- workout new /tmp/my-workout-app -cargo check --manifest-path /tmp/my-workout-app/Cargo.toml -``` - -The created app is standalone: it points at local hemx crates, owns its -command/event/projection state, and keeps Android/iOS signing outside the repo. -req: examples/006 - -## Run - -```sh -cargo run -p hemx-xtask -- workout dev -``` - -Open `http://127.0.0.1:3028`. - -If the port is busy, pick another address: - -```sh -HEMX_WORKOUT_ADDR=127.0.0.1:3030 cargo run -p hemx-xtask -- workout dev -``` - -The server prints the URL it bound. A bind failure means another process owns -the address; retry with `HEMX_WORKOUT_ADDR` instead of changing app code. - -## Test - -```sh -cargo run -p hemx-xtask -- workout test -``` - -The E2E test starts the real HTTP binary, loads the page, checks the shared -runtime asset, submits workout interactions, decodes hemx effect responses, and -replays exported events back into a projection. Unit tests cover rest/next, -finish, undo recovery, invalid input, replay failure, export replay, and -host-result boundaries. req: test/001 req: examples/001 - -For the full repository gate: - -```sh -cargo run -p hemx-xtask -- test -``` - -## Deploy shape - -The deployable artifact is the Rust server binary plus the generated hemx -resources embedded by the build script: - -```sh -cargo run -p hemx-xtask -- workout build -HEMX_WORKOUT_ADDR=0.0.0.0:8080 ./target/release/hemx-workout-example -``` - -Put a normal reverse proxy or platform router in front of that port. The app -serves the shared hemx runtime through `hemx-axum`; there is no frontend build, -Node runtime, Expo/Ionic/Tauri shell, or handwritten selector UI JavaScript to -ship. req: axum_integration/005 req: examples/005 - -## Mobile release kit - -Use the canonical release command to build the production server binary and -write Android/iOS shell metadata: - -```sh -HEMX_WORKOUT_ORIGIN=https://workout.example.com \ - cargo run -p hemx-xtask -- workout mobile-release -HEMX_WORKOUT_ORIGIN=https://workout.example.com \ - cargo run -p hemx-xtask -- workout mobile-verify -``` - -The kit lands in `target/hemx-mobile/workout` unless -`HEMX_WORKOUT_MOBILE_OUT` is set. `workout mobile-verify` reruns the Workout -product tests before checking the release kit, so app value, recovery, local -state, and host capability boundaries fail closed with the mobile artifacts. The -kit records app identity, version, production origin, runtime asset policy, -cache/offline state policy, secrets/signing boundaries, rollback expectations, -Android TWA metadata, iOS WebView metadata, and any external blocker such as -missing Android SDK, Xcode, store signing credentials, or Play/App Store -submission account targets. See `docs/recipes/mobile-release.md`. req: -examples/006 - -## Boundaries proven - -- Core workout logging works without network availability once the page/runtime - is loaded: commands/events/projections are app truth, including rest/next, - finish, invalid input, replay failure, and undo recovery. req: local/001 -- Browser/PWA export uses the host capability contract and the host result - returns through app code before UI effects. req: host/001 req: host/005 -- Native-shell-shaped haptic acknowledgment uses the same host call/event path - without giving the shell ownership of workout state. req: host/002 -- Sync, backup, auth, AI/STT, provider policy, and conflict handling are not - hidden in hemx core or the example runtime; they remain explicit app or - integration decisions. req: local/002 req: local/003 diff --git a/examples/workout/build.rs b/examples/workout/build.rs deleted file mode 100644 index 99fa6f3..0000000 --- a/examples/workout/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - hemx_build::app().run().unwrap(); -} diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs deleted file mode 100644 index 02f086c..0000000 --- a/examples/workout/src/lib.rs +++ /dev/null @@ -1,1380 +0,0 @@ -use hemplate::Hemplate; -use hemx::{Html, IntoEffect}; -use hemx_axum::Form; -use hemx_host::{ - browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, - CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, HostFailure, - HostFailureKind, SharePayload as HostShareData, -}; -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; - -#[hemx::surface] -pub mod ui {} - -#[derive(Clone, Debug, PartialEq)] -pub struct ExercisePlan { - pub name: &'static str, - pub target_sets: u8, - pub reps: u8, - pub kg: f32, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WorkoutCommand { - CompleteSet, - StartNextSet, - FinishWorkout, - ChangeWeight { kg: f32 }, - CorrectLastSet { kg: f32 }, - SkipExercise, - RecordNote { text: String }, - UndoLastAction, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WorkoutEvent { - SetCompleted { - exercise: String, - set: u8, - reps: u8, - kg: f32, - }, - RestFinished { - exercise: String, - set: u8, - }, - WeightChanged { - exercise: String, - kg: f32, - }, - SetEdited { - exercise: String, - set: u8, - kg: f32, - }, - ExerciseSkipped { - exercise: String, - }, - NoteRecorded { - text: String, - }, - WorkoutFinished { - completed_sets: u8, - total_sets: u8, - }, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WorkoutPhase { - Ready, - Resting, - ReadyToFinish, - Finished, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct WorkoutProjection { - pub current_exercise: usize, - pub completed_sets_for_current: u8, - pub total_completed_sets: u8, - pub phase: WorkoutPhase, - pub next_action: String, - pub primary_action: String, - pub progress: String, - pub exported: bool, -} - -#[derive(Clone, Debug)] -pub struct WorkoutState { - pub plan: Vec, - pub commands: Vec, - pub events: Vec, - pub projection: WorkoutProjection, - pub activity: VecDeque, - pub host_status: String, -} - -#[derive(Clone)] -pub struct AppState { - workout: Arc>, -} - -impl AppState { - pub fn demo() -> Self { - Self { - workout: Arc::new(Mutex::new(WorkoutState::demo())), - } - } - - pub fn with_workout(&self, f: impl FnOnce(&WorkoutState) -> R) -> R { - let workout = self.workout.lock().unwrap(); - f(&workout) - } - - fn update(&self, f: impl FnOnce(&mut WorkoutState) -> R) -> R { - let mut workout = self.workout.lock().unwrap(); - f(&mut workout) - } -} - -impl WorkoutState { - pub fn demo() -> Self { - let mut state = Self { - plan: demo_plan(), - commands: Vec::new(), - events: Vec::new(), - projection: WorkoutProjection::start(), - activity: VecDeque::new(), - host_status: "Ready to keep the session private until you export.".into(), - }; - state.refresh_next_action(); - state - } - - fn primary_command(&self) -> WorkoutCommand { - match self.projection.phase { - WorkoutPhase::Ready => WorkoutCommand::CompleteSet, - WorkoutPhase::Resting => WorkoutCommand::StartNextSet, - WorkoutPhase::ReadyToFinish | WorkoutPhase::Finished => WorkoutCommand::FinishWorkout, - } - } - - fn accept(&mut self, command: WorkoutCommand) -> Option { - // req: local/001 req: local/004 - let event = self.validate(&command)?; - self.commands.push(command); - self.events.push(event.clone()); - self.project(&event); - Some(event) - } - - fn validate(&self, command: &WorkoutCommand) -> Option { - match command { - WorkoutCommand::CompleteSet if self.projection.phase == WorkoutPhase::Ready => { - let exercise = self.current_exercise()?; - Some(WorkoutEvent::SetCompleted { - exercise: exercise.name.into(), - set: self.projection.completed_sets_for_current + 1, - reps: exercise.reps, - kg: exercise.kg, - }) - } - WorkoutCommand::StartNextSet if self.projection.phase == WorkoutPhase::Resting => { - let (exercise, set) = self.next_ready_target()?; - Some(WorkoutEvent::RestFinished { - exercise: exercise.into(), - set, - }) - } - WorkoutCommand::FinishWorkout - if self.projection.phase == WorkoutPhase::ReadyToFinish => - { - Some(WorkoutEvent::WorkoutFinished { - completed_sets: self.projection.total_completed_sets, - total_sets: self.total_target_sets(), - }) - } - WorkoutCommand::ChangeWeight { kg } if kg.is_finite() && *kg >= 0.0 => { - let index = self.editable_exercise_index()?; - let exercise = self.plan.get(index)?; - Some(WorkoutEvent::WeightChanged { - exercise: exercise.name.into(), - kg: *kg, - }) - } - WorkoutCommand::CorrectLastSet { kg } if kg.is_finite() && *kg >= 0.0 => self - .last_completed_set() - .map(|(exercise, set)| WorkoutEvent::SetEdited { - exercise, - set, - kg: *kg, - }), - WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => { - let exercise = self.current_exercise()?; - Some(WorkoutEvent::ExerciseSkipped { - exercise: exercise.name.into(), - }) - } - WorkoutCommand::RecordNote { text } if !text.trim().is_empty() => { - Some(WorkoutEvent::NoteRecorded { - text: text.trim().into(), - }) - } - _ => None, - } - } - - fn project(&mut self, event: &WorkoutEvent) { - // req: local/001 req: local/004 - match event { - WorkoutEvent::SetCompleted { exercise, set, .. } => { - if let Some(index) = self.exercise_index(exercise) { - self.projection.current_exercise = index; - } - self.projection.completed_sets_for_current = *set; - self.projection.total_completed_sets = self - .projection - .total_completed_sets - .saturating_add(1) - .min(self.total_target_sets()); - self.projection.phase = - if self.projection.total_completed_sets >= self.total_target_sets() { - WorkoutPhase::ReadyToFinish - } else { - WorkoutPhase::Resting - }; - } - WorkoutEvent::RestFinished { exercise, .. } => { - if let Some(index) = self.exercise_index(exercise) { - self.projection.current_exercise = index; - self.projection.completed_sets_for_current = - completed_sets_for(&self.events, exercise); - } - self.projection.phase = WorkoutPhase::Ready; - } - WorkoutEvent::WeightChanged { exercise, kg } - | WorkoutEvent::SetEdited { exercise, kg, .. } => { - if let Some(index) = self.exercise_index(exercise) { - self.plan[index].kg = *kg; - } - } - WorkoutEvent::ExerciseSkipped { exercise } => { - let next = self - .exercise_index(exercise) - .unwrap_or(self.projection.current_exercise) - + 1; - self.projection.current_exercise = next; - self.projection.completed_sets_for_current = 0; - self.projection.phase = if next >= self.plan.len() { - WorkoutPhase::ReadyToFinish - } else { - WorkoutPhase::Ready - }; - } - WorkoutEvent::NoteRecorded { .. } => {} - WorkoutEvent::WorkoutFinished { .. } => { - self.projection.phase = WorkoutPhase::Finished; - } - } - self.refresh_next_action(); - self.activity.push_front(event.summary()); - while self.activity.len() > 5 { - self.activity.pop_back(); - } - } - - fn undo_last_event(&mut self) -> Option { - // req: local/001 req: local/004 - self.commands.push(WorkoutCommand::UndoLastAction); - let removed = self.events.pop()?; - let summary = removed.summary(); - self.rebuild_projection(); - self.activity.push_front(format!("undid {summary}")); - while self.activity.len() > 5 { - self.activity.pop_back(); - } - Some(summary) - } - - fn rebuild_projection(&mut self) { - let events = self.events.clone(); - let host_status = self.host_status.clone(); - let exported = self.projection.exported; - self.plan = demo_plan(); - self.projection = WorkoutProjection::start(); - self.projection.exported = exported; - self.activity.clear(); - self.refresh_next_action(); - for event in &events { - self.project(event); - } - self.host_status = host_status; - } - - fn refresh_next_action(&mut self) { - self.projection.progress = self.progress_text(); - match self.projection.phase { - WorkoutPhase::Ready => { - if let Some(exercise) = self.current_exercise() { - let name = exercise.name; - let target_sets = exercise.target_sets; - let reps = exercise.reps; - let kg = exercise.kg; - self.projection.primary_action = "Complete set".into(); - self.projection.next_action = format!( - "Next: {name} set {}/{} · {reps} reps · {kg} kg", - self.projection.completed_sets_for_current + 1, - target_sets, - ); - } else { - self.projection.phase = WorkoutPhase::ReadyToFinish; - self.refresh_next_action(); - } - } - WorkoutPhase::Resting => { - let target = self - .next_ready_target() - .map(|(exercise, set)| format!("{exercise} set {set}")) - .unwrap_or_else(|| "finish workout".into()); - self.projection.primary_action = format!("Start {target}"); - self.projection.next_action = format!("Rest 90s · next: {target}"); - } - WorkoutPhase::ReadyToFinish => { - self.projection.primary_action = "Finish workout".into(); - self.projection.next_action = - "Workout complete · finish and export your session".into(); - } - WorkoutPhase::Finished => { - self.projection.primary_action = "Session saved".into(); - self.projection.next_action = "Session saved · export or replay anytime".into(); - } - } - } - - fn current_exercise(&self) -> Option<&ExercisePlan> { - self.plan.get(self.projection.current_exercise) - } - - fn editable_exercise_index(&self) -> Option { - match self.projection.phase { - WorkoutPhase::Finished | WorkoutPhase::ReadyToFinish => None, - WorkoutPhase::Resting - if self.current_exercise().is_some_and(|exercise| { - self.projection.completed_sets_for_current >= exercise.target_sets - }) => - { - (self.projection.current_exercise + 1 < self.plan.len()) - .then_some(self.projection.current_exercise + 1) - } - _ => self - .current_exercise() - .map(|_| self.projection.current_exercise), - } - } - - fn exercise_index(&self, name: &str) -> Option { - self.plan.iter().position(|exercise| exercise.name == name) - } - - fn last_completed_set(&self) -> Option<(String, u8)> { - self.events.iter().rev().find_map(|event| match event { - WorkoutEvent::SetCompleted { exercise, set, .. } => Some((exercise.clone(), *set)), - _ => None, - }) - } - - fn next_ready_target(&self) -> Option<(&'static str, u8)> { - let exercise = self.current_exercise()?; - if self.projection.completed_sets_for_current >= exercise.target_sets { - let next = self.plan.get(self.projection.current_exercise + 1)?; - Some((next.name, 1)) - } else { - Some(( - exercise.name, - self.projection.completed_sets_for_current + 1, - )) - } - } - - fn total_target_sets(&self) -> u8 { - self.plan.iter().map(|exercise| exercise.target_sets).sum() - } - - fn progress_text(&self) -> String { - let total = self.total_target_sets().max(1); - let done = self.projection.total_completed_sets.min(total); - let percent = u16::from(done) * 100 / u16::from(total); - format!("{done} of {total} sets · {percent}% complete") - } - - pub fn event_log_text(&self) -> String { - if self.events.is_empty() { - return "No session events yet.".into(); - } - self.events - .iter() - .enumerate() - .map(|(index, event)| format!("{}: {}", index + 1, event.summary())) - .collect::>() - .join("\n") - } - - pub fn export_event_log(&self) -> String { - // req: local/003 - self.events - .iter() - .map(WorkoutEvent::to_export_line) - .collect::>() - .join("\n") - } - - fn replay_export(&mut self, export: &str) -> Result { - // req: local/001 req: local/003 req: local/004 - let lines = export - .lines() - .filter(|line| !line.trim().is_empty()) - .collect::>(); - if lines.is_empty() { - return Err("nothing to replay yet; complete a set first".into()); - } - - let mut events = Vec::with_capacity(lines.len()); - for (index, line) in lines.iter().enumerate() { - let event = WorkoutEvent::from_export_line(line) - .ok_or_else(|| format!("line {} is not a workout event", index + 1))?; - events.push(event); - } - - let mut replayed = WorkoutState::demo(); - for event in &events { - replayed.events.push(event.clone()); - replayed.project(event); - } - replayed.host_status = format!( - "Replayed {} exported events into a fresh projection; app policy still owns sync.", - events.len() - ); - *self = replayed; - Ok(events.len()) - } - - fn recovery_text(&self) -> String { - match self.activity.front() { - Some(last) if last.starts_with("started ") => { - format!("Last action: {last}. If that was a double tap, Undo returns to rest.") - } - Some(last) if last.starts_with("changed ") || last.starts_with("corrected ") => { - format!("Last action: {last}. Undo restores the previous plan.") - } - Some(last) if last.starts_with("skipped ") => { - format!("Last action: {last}. Undo restores the skipped exercise.") - } - Some(last) if last.starts_with("undid ") => { - format!("Recovery complete: {last}. Continue from the restored next action.") - } - Some(last) => format!("Last action: {last}. Undo is available."), - None => "Undo appears here after your first session event.".into(), - } - } -} - -impl WorkoutProjection { - fn start() -> Self { - Self { - current_exercise: 0, - completed_sets_for_current: 0, - total_completed_sets: 0, - phase: WorkoutPhase::Ready, - next_action: String::new(), - primary_action: String::new(), - progress: String::new(), - exported: false, - } - } -} - -fn demo_plan() -> Vec { - vec![ - ExercisePlan { - name: "Goblet squat", - target_sets: 3, - reps: 8, - kg: 24.0, - }, - ExercisePlan { - name: "Push-up", - target_sets: 2, - reps: 10, - kg: 0.0, - }, - ] -} - -fn completed_sets_for(events: &[WorkoutEvent], exercise: &str) -> u8 { - events - .iter() - .filter_map(|event| match event { - WorkoutEvent::SetCompleted { - exercise: event_exercise, - set, - .. - } if event_exercise == exercise => Some(*set), - _ => None, - }) - .max() - .unwrap_or(0) -} - -fn export_field(value: &str) -> String { - value.replace(['\t', '\n', '\r'], " ") -} - -impl WorkoutEvent { - fn summary(&self) -> String { - match self { - Self::SetCompleted { - exercise, - set, - reps, - kg, - } => { - format!("completed {exercise} set {set}: {reps} reps @ {kg} kg") - } - Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"), - Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"), - Self::SetEdited { exercise, set, kg } => { - format!("corrected {exercise} set {set} to {kg} kg") - } - Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"), - Self::NoteRecorded { text } => format!("note: {text}"), - Self::WorkoutFinished { - completed_sets, - total_sets, - } => format!("finished workout: {completed_sets}/{total_sets} sets complete"), - } - } - - fn to_export_line(&self) -> String { - // req: local/003 - match self { - Self::SetCompleted { - exercise, - set, - reps, - kg, - } => format!( - "set_completed\t{}\t{set}\t{reps}\t{kg}", - export_field(exercise) - ), - Self::RestFinished { exercise, set } => { - format!("rest_finished\t{}\t{set}", export_field(exercise)) - } - Self::WeightChanged { exercise, kg } => { - format!("weight_changed\t{}\t{kg}", export_field(exercise)) - } - Self::SetEdited { exercise, set, kg } => { - format!("set_edited\t{}\t{set}\t{kg}", export_field(exercise)) - } - Self::ExerciseSkipped { exercise } => { - format!("exercise_skipped\t{}", export_field(exercise)) - } - Self::NoteRecorded { text } => format!("note_recorded\t{}", export_field(text)), - Self::WorkoutFinished { - completed_sets, - total_sets, - } => format!("workout_finished\t{completed_sets}\t{total_sets}"), - } - } - - fn from_export_line(line: &str) -> Option { - // req: local/003 - let fields = line.split('\t').collect::>(); - match fields.as_slice() { - ["set_completed", exercise, set, reps, kg] => Some(Self::SetCompleted { - exercise: (*exercise).into(), - set: set.parse().ok()?, - reps: reps.parse().ok()?, - kg: kg.parse().ok()?, - }), - ["rest_finished", exercise, set] => Some(Self::RestFinished { - exercise: (*exercise).into(), - set: set.parse().ok()?, - }), - ["weight_changed", exercise, kg] => Some(Self::WeightChanged { - exercise: (*exercise).into(), - kg: kg.parse().ok()?, - }), - ["set_edited", exercise, set, kg] => Some(Self::SetEdited { - exercise: (*exercise).into(), - set: set.parse().ok()?, - kg: kg.parse().ok()?, - }), - ["exercise_skipped", exercise] => Some(Self::ExerciseSkipped { - exercise: (*exercise).into(), - }), - ["note_recorded", text] => Some(Self::NoteRecorded { - text: (*text).into(), - }), - ["workout_finished", completed_sets, total_sets] => Some(Self::WorkoutFinished { - completed_sets: completed_sets.parse().ok()?, - total_sets: total_sets.parse().ok()?, - }), - _ => None, - } - } -} - -#[derive(Hemplate)] -pub struct Workout { - pub plan: Vec, - pub next_action: String, - pub primary_action: String, - pub status: String, - pub progress: String, - pub recovery_status: String, - pub finish_title: String, - pub finish_copy: String, - pub share_action: String, - pub event_log: String, - pub export_payload: String, - pub host_status: String, -} - -#[derive(Hemplate)] -pub struct AppShell { - pub runtime_src: &'static str, - pub body: Html, -} - -pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html { - ui::page(&AppShell { - runtime_src, - body: render(state), - }) -} - -pub fn view(state: &WorkoutState) -> Workout { - let export = state.export_event_log(); - let has_events = !state.events.is_empty(); - let (finish_title, finish_copy, share_action) = match state.projection.phase { - WorkoutPhase::Ready | WorkoutPhase::Resting if !has_events => ( - "Finish unlocks as you train".into(), - "Complete a set to create a replayable local export.".into(), - "Export after first set".into(), - ), - WorkoutPhase::Ready | WorkoutPhase::Resting => ( - "Session log is ready".into(), - "Your progress is already replayable; finish all sets for the final export moment.".into(), - "Share current log".into(), - ), - WorkoutPhase::ReadyToFinish => ( - "All sets complete".into(), - "Finish to save the workout summary, then share or replay the exact local event log.".into(), - "Finish, then share".into(), - ), - WorkoutPhase::Finished if state.projection.exported => ( - "Export shared".into(), - "The shared text is still just a copy; this local event log remains the source.".into(), - "Share again".into(), - ), - WorkoutPhase::Finished => ( - "Workout saved".into(), - "Share the final event log, or replay it locally to prove recovery without hidden browser state.".into(), - "Share final export".into(), - ), - }; - Workout { - plan: state.plan.clone(), - next_action: state.projection.next_action.clone(), - primary_action: state.projection.primary_action.clone(), - status: state.projection.progress.clone(), - progress: state.projection.progress.clone(), - recovery_status: state.recovery_text(), - finish_title, - finish_copy, - share_action, - event_log: state.event_log_text(), - export_payload: if export.is_empty() { - "Complete a set to create a replayable export.".into() - } else { - export - }, - host_status: state.host_status.clone(), - } -} - -pub fn render(state: &WorkoutState) -> Html { - ui::page(&view(state)) -} - -#[derive(Clone, Debug)] -#[hemx::form("change_weight")] -pub struct ChangeWeightInput { - kg: String, -} - -#[derive(Clone, Debug)] -#[hemx::form("correct_last_set")] -pub struct CorrectLastSetInput { - kg: String, -} - -#[derive(Clone, Debug)] -#[hemx::form("record_note")] -pub struct RecordNoteInput { - text: String, -} - -pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry { - // req: codegen/002 req: examples/001 - hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state) - .on_state(ui::workout::complete_set, complete_set) - .on(ui::workout::change_weight, change_weight_form) - .on(ui::workout::correct_last_set, correct_last_set_form) - .on_state(ui::workout::skip_exercise, skip_exercise) - .on(ui::workout::record_note, record_note_form) - .on_state(ui::workout::undo_last_action, undo_last_action) - .on_state(ui::workout::replay_export, replay_export) - .on_state(ui::workout::export_log, export_log) - .on_state(ui::workout::record_share_result, record_share_result) - .on_state(ui::workout::record_share_denied, record_share_denied) - .on_state(ui::workout::record_host_timeout, record_host_timeout) - .on_state(ui::workout::replay_broken_export, replay_broken_export) - .on_state(ui::workout::request_native_haptic, request_native_haptic) - .on_state( - ui::workout::record_native_haptic_ack, - record_native_haptic_ack, - ) -} - -fn effects(state: &WorkoutState, status: impl Into) -> impl IntoEffect { - // req: local/001 req: local/004 - let view = view(state); - ( - ui::workout::next_action.text(&view.next_action), - ui::workout::primary_action.text(&view.primary_action), - ui::workout::status.text(status.into()), - ui::workout::progress.text(&view.progress), - ui::workout::recovery_status.text(&view.recovery_status), - ui::workout::finish_title.text(&view.finish_title), - ui::workout::finish_copy.text(&view.finish_copy), - ui::workout::share_action.text(&view.share_action), - ui::workout::event_log.text(&view.event_log), - ui::workout::export_payload.text(&view.export_payload), - ui::workout::host_status.text(&view.host_status), - ) -} - -pub fn complete_set(app: AppState) -> impl IntoEffect { - app.update(|state| { - let command = state.primary_command(); - let event = state.accept(command); - if matches!(event, Some(WorkoutEvent::SetCompleted { .. })) { - state.host_status = - "Set saved locally; native haptic can acknowledge it without owning state.".into(); - } - effects( - state, - event.map_or_else( - || "Session is already saved; export is ready.".into(), - |event| event.summary(), - ), - ) - }) -} - -pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect { - app.update(|state| { - let event = state.accept(WorkoutCommand::ChangeWeight { kg }); - effects( - state, - event.map_or_else( - || "Enter a valid weight for the current or next exercise.".into(), - |event| event.summary(), - ), - ) - }) -} - -pub fn correct_last_set(app: AppState, kg: f32) -> impl IntoEffect { - app.update(|state| { - let event = state.accept(WorkoutCommand::CorrectLastSet { kg }); - effects( - state, - event.map_or_else( - || "Complete a set before correcting its recorded weight.".into(), - |event| event.summary(), - ), - ) - }) -} - -pub fn skip_exercise(app: AppState) -> impl IntoEffect { - app.update(|state| { - let event = state.accept(WorkoutCommand::SkipExercise); - effects( - state, - event.map_or_else( - || "No exercise available to skip.".into(), - |event| event.summary(), - ), - ) - }) -} - -pub fn undo_last_action(app: AppState) -> impl IntoEffect { - app.update(|state| { - let undone = state.undo_last_event(); - effects( - state, - undone.map_or_else( - || "Nothing to undo yet; complete a set first.".into(), - |summary| format!("Undid: {summary}"), - ), - ) - }) -} - -pub fn change_weight_form(app: AppState, Form(input): Form) -> impl IntoEffect { - app.update(|state| match input.kg.trim().parse::() { - Ok(kg) if kg.is_finite() && kg >= 0.0 => { - let event = state.accept(WorkoutCommand::ChangeWeight { kg }); - effects( - state, - event.map_or_else( - || "Enter a valid weight for the current or next exercise.".into(), - |event| event.summary(), - ), - ) - } - _ => effects( - state, - String::from("Enter a valid non-negative weight; your session is unchanged."), - ), - }) -} - -pub fn correct_last_set_form( - app: AppState, - Form(input): Form, -) -> impl IntoEffect { - app.update(|state| match input.kg.trim().parse::() { - Ok(kg) if kg.is_finite() && kg >= 0.0 => { - let event = state.accept(WorkoutCommand::CorrectLastSet { kg }); - effects( - state, - event.map_or_else( - || "Complete a set before correcting its recorded weight.".into(), - |event| event.summary(), - ), - ) - } - _ => effects( - state, - String::from("Enter a valid corrected weight; your session is unchanged."), - ), - }) -} - -pub fn record_note_form(app: AppState, Form(input): Form) -> impl IntoEffect { - record_note(app, input.text) -} - -pub fn record_note(app: AppState, text: impl Into) -> impl IntoEffect { - app.update(|state| { - let event = state.accept(WorkoutCommand::RecordNote { text: text.into() }); - effects( - state, - event.map_or_else( - || "Ignored empty note; nothing changed.".into(), - |event| event.summary(), - ), - ) - }) -} - -pub fn replay_export(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: local/001 req: local/003 req: local/004 - let export = state.export_event_log(); - match state.replay_export(&export) { - Ok(count) => effects(state, format!("Replayed {count} exported workout events")), - Err(error) => effects( - state, - format!("Replay failed: {error}. Current session is unchanged."), - ), - } - }) -} - -pub fn replay_broken_export(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: local/001 req: local/003 req: local/004 - match state.replay_export("not-a-workout-export") { - Ok(count) => effects(state, format!("Replayed {count} exported workout events")), - Err(error) => effects( - state, - format!("Replay failed: {error}. Current session is unchanged."), - ), - } - }) -} - -pub fn export_log(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/001 req: host/004 req: local/003 - if state.events.is_empty() { - state.host_status = "Complete a set before opening the share sheet.".into(); - return effects(state, "Nothing to export yet"); - } - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Share, - CapabilityShape::Request, - )]); - let call = HostCall::Share { - id: HostCallId::new("workout-export"), - payload: HostShareData::text(state.export_event_log()), - }; - state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) { - Ok(()) => "Share sheet requested; your local event log remains the source.".into(), - Err(error) => format!("Share unavailable: {error}. Export text stays below."), - }; - effects(state, "Share export prepared") - }) -} - -pub fn record_share_result(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/002 req: host/005 req: local/003 - apply_host_event( - state, - HostEvent::ShareCompleted { - id: HostCallId::new("workout-export"), - completed: true, - }, - ); - effects(state, "Share result returned through app code") - }) -} - -pub fn record_share_denied(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/002 req: host/005 req: local/003 - apply_host_event( - state, - HostEvent::Failed( - HostFailure::new(HostFailureKind::PermissionDenied, "share permission denied") - .with_capability(Capability::Share), - ), - ); - effects(state, "Share permission denial returned through app code") - }) -} - -pub fn record_host_timeout(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/002 req: host/005 req: local/003 - apply_host_event( - state, - HostEvent::Failed( - HostFailure::new(HostFailureKind::Timeout, "share timed out") - .with_id(HostCallId::new("workout-export")) - .with_capability(Capability::Share), - ), - ); - effects(state, "Host timeout returned through app code") - }) -} - -pub fn request_native_haptic(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/001 req: host/004 - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Haptics, - CapabilityShape::Fire, - )]); - let call = HostCall::Haptic { - id: HostCallId::new("workout-set-haptic"), - pattern: HapticPattern::Success, - }; - state.host_status = match manifest.validate_call( - &native_shell_host_profile("ios-android-webview-workout"), - &call, - ) { - Ok(()) => "Native-shell haptic requested for the saved set.".into(), - Err(error) => format!("Native haptic unavailable: {error}"), - }; - effects(state, "Native-shell host call checked against manifest") - }) -} - -pub fn record_native_haptic_ack(app: AppState) -> impl IntoEffect { - app.update(|state| { - // req: host/002 req: host/005 - apply_host_event( - state, - HostEvent::Acknowledged { - id: HostCallId::new("workout-set-haptic"), - }, - ); - effects(state, "Native-shell host result accepted by app code") - }) -} - -fn host_failure_label(kind: HostFailureKind) -> &'static str { - match kind { - HostFailureKind::PermissionDenied => "permission denied", - HostFailureKind::Timeout => "timeout", - HostFailureKind::Unavailable => "unavailable", - HostFailureKind::Error => "error", - } -} - -fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { - match event { - HostEvent::ShareCompleted { - completed: true, .. - } => { - state.projection.exported = true; - state.host_status = "Shared. The replayable event log remains local truth.".into(); - } - HostEvent::ShareCompleted { - completed: false, .. - } => { - state.host_status = - "Share cancelled; local event log unchanged. Try Share export when ready.".into(); - } - HostEvent::Failed(failure) - if failure.kind == HostFailureKind::PermissionDenied - && failure.capability == Some(Capability::Share) => - { - state.host_status = - "Share permission denied. Local export text is still ready; try again or copy it." - .into(); - } - HostEvent::Failed(failure) => { - state.host_status = format!( - "Host {}: {}. Keep the local export below and try Share export again.", - host_failure_label(failure.kind), - failure.message - ); - } - HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => { - state.host_status = - "Native haptic acknowledged; workout state stayed app-owned.".into(); - } - _ => { - state.host_status = "Host event ignored by app policy.".into(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx::advanced::{Effect, Payload}; - use hemx_test::run; - - fn contains_payload_text(effects: &hemx_test::EffectInspector, needle: &str) -> bool { - effects.batch().ops.iter().any(|op| { - matches!(op, Effect::Put { payload: Payload::Text(text), .. } if text.contains(needle)) - }) - } - - #[test] - fn local_command_projects_before_ui_effects() { - // req: local/001 req: local/004 - let app = AppState::demo(); - let effects = run(|()| complete_set(app.clone()), ()); - - assert_eq!( - app.with_workout(|state| ( - state.commands.len(), - state.events.len(), - state.projection.completed_sets_for_current, - state.projection.phase.clone(), - )), - (1, 1, 1, WorkoutPhase::Resting) - ); - assert!(contains_payload_text( - &effects, - "completed Goblet squat set 1" - )); - assert!(contains_payload_text(&effects, "Rest 90s")); - assert!(contains_payload_text(&effects, "Start Goblet squat set 2")); - assert_eq!( - app.with_workout(|state| (state.commands.len(), state.events.len())), - (1, 1) - ); - } - - #[test] - fn primary_action_moves_from_rest_to_next_set_and_finish() { - // req: examples/001 req: local/001 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - let rest = run(|()| complete_set(app.clone()), ()); - assert!(contains_payload_text(&rest, "started Goblet squat set 2")); - assert_eq!( - app.with_workout(|state| state.projection.phase.clone()), - WorkoutPhase::Ready - ); - - for _ in 0..7 { - run(|()| complete_set(app.clone()), ()); - } - assert_eq!( - app.with_workout(|state| state.projection.phase.clone()), - WorkoutPhase::ReadyToFinish - ); - let finished = run(|()| complete_set(app.clone()), ()); - assert!(contains_payload_text( - &finished, - "finished workout: 5/5 sets complete" - )); - assert!(contains_payload_text(&finished, "Workout saved")); - assert!(contains_payload_text(&finished, "Share final export")); - assert_eq!( - app.with_workout(|state| state.projection.phase.clone()), - WorkoutPhase::Finished - ); - } - - #[test] - fn double_primary_action_recovers_through_undo() { - // req: examples/001 req: local/001 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - let double_action = run(|()| complete_set(app.clone()), ()); - assert!(contains_payload_text( - &double_action, - "started Goblet squat set 2" - )); - assert_eq!( - app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), - (2, WorkoutPhase::Ready) - ); - - let undone = run(|()| undo_last_action(app.clone()), ()); - assert!(contains_payload_text( - &undone, - "Undid: started Goblet squat set 2" - )); - assert!(contains_payload_text(&undone, "Rest 90s")); - assert_eq!( - app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), - (1, WorkoutPhase::Resting) - ); - } - - #[test] - fn correct_last_set_is_replayable_and_undoable() { - // req: examples/001 req: local/001 req: local/003 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - - let corrected = run(|()| correct_last_set(app.clone(), 26.0), ()); - assert!(contains_payload_text( - &corrected, - "corrected Goblet squat set 1 to 26 kg" - )); - assert_eq!( - app.with_workout(|state| (state.events.len(), state.plan[0].kg)), - (2, 26.0) - ); - let export = app.with_workout(WorkoutState::export_event_log); - assert!(export.contains("set_edited\tGoblet squat\t1\t26")); - - let replay = run(|()| replay_export(app.clone()), ()); - assert!(contains_payload_text( - &replay, - "Replayed 2 exported workout events" - )); - assert_eq!(app.with_workout(|state| state.plan[0].kg), 26.0); - - let undone = run(|()| undo_last_action(app.clone()), ()); - assert!(contains_payload_text( - &undone, - "Undid: corrected Goblet squat set 1 to 26 kg" - )); - assert_eq!(app.with_workout(|state| state.plan[0].kg), 24.0); - } - - #[test] - fn skipped_exercise_recovers_through_undo() { - // req: examples/001 req: local/001 req: local/004 - let app = AppState::demo(); - - let skipped = run(|()| skip_exercise(app.clone()), ()); - assert!(contains_payload_text(&skipped, "skipped Goblet squat")); - assert!(contains_payload_text(&skipped, "Next: Push-up set 1/2")); - assert_eq!( - app.with_workout(|state| ( - state.events.len(), - state.projection.current_exercise, - state.projection.phase.clone(), - )), - (1, 1, WorkoutPhase::Ready) - ); - - let undone = run(|()| undo_last_action(app.clone()), ()); - assert!(contains_payload_text( - &undone, - "Undid: skipped Goblet squat" - )); - assert!(contains_payload_text(&undone, "Next: Goblet squat set 1/3")); - assert_eq!( - app.with_workout(|state| ( - state.events.len(), - state.projection.current_exercise, - state.projection.phase.clone(), - )), - (0, 0, WorkoutPhase::Ready) - ); - } - - #[test] - fn undo_recovers_last_session_action() { - // req: examples/001 req: local/001 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - let undone = run(|()| undo_last_action(app.clone()), ()); - - assert!(contains_payload_text( - &undone, - "Undid: completed Goblet squat set 1" - )); - assert_eq!( - app.with_workout(|state| ( - state.events.len(), - state.projection.completed_sets_for_current, - state.projection.phase.clone(), - )), - (0, 0, WorkoutPhase::Ready) - ); - } - - #[test] - fn invalid_weight_and_replay_failure_keep_session_recoverable() { - // req: examples/001 req: local/001 req: local/003 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - - let invalid = run( - |input| change_weight_form(app.clone(), Form(input)), - ChangeWeightInput { kg: "oops".into() }, - ); - assert!(contains_payload_text(&invalid, "valid non-negative weight")); - assert_eq!( - app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), - (1, WorkoutPhase::Resting) - ); - - let replay = run(|()| replay_broken_export(app.clone()), ()); - assert!(contains_payload_text(&replay, "Replay failed: line 1")); - assert!(contains_payload_text( - &replay, - "Current session is unchanged" - )); - assert_eq!(app.with_workout(|state| state.events.len()), 1); - } - - #[test] - fn export_payload_replays_into_projection_before_ui_effects() { - // req: local/001 req: local/003 req: local/004 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - run(|()| complete_set(app.clone()), ()); - run(|()| change_weight(app.clone(), 28.0), ()); - let export = app.with_workout(WorkoutState::export_event_log); - assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24")); - assert!(export.contains("rest_finished\tGoblet squat\t2")); - assert!(export.contains("weight_changed\tGoblet squat\t28")); - - let replay = run(|()| replay_export(app.clone()), ()); - assert!(contains_payload_text( - &replay, - "Replayed 3 exported workout events" - )); - assert_eq!( - app.with_workout(|state| { - ( - state.events.len(), - state.projection.completed_sets_for_current, - state.plan[0].kg, - state.projection.phase.clone(), - ) - }), - (3, 1, 28.0, WorkoutPhase::Ready) - ); - } - - #[test] - fn host_export_result_returns_through_app_code() { - // req: host/001 req: host/002 req: host/005 req: local/003 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - - let export = run(|()| export_log(app.clone()), ()); - assert!(contains_payload_text(&export, "Share export prepared")); - assert!(contains_payload_text(&export, "Share sheet requested")); - - let shared = run(|()| record_share_result(app.clone()), ()); - assert!(app.with_workout(|state| state.projection.exported)); - assert!(contains_payload_text( - &shared, - "Share result returned through app code" - )); - assert!(contains_payload_text( - &shared, - "replayable event log remains local truth" - )); - } - - #[test] - fn host_failure_results_keep_local_export_recoverable() { - // req: host/002 req: host/005 req: local/003 - let app = AppState::demo(); - run(|()| complete_set(app.clone()), ()); - - let denied = run(|()| record_share_denied(app.clone()), ()); - assert!(contains_payload_text(&denied, "Share permission denial")); - assert!(contains_payload_text( - &denied, - "Local export text is still ready" - )); - assert!(!app.with_workout(|state| state.projection.exported)); - - let timeout = run(|()| record_host_timeout(app.clone()), ()); - assert!(contains_payload_text(&timeout, "Host timeout")); - assert!(contains_payload_text( - &timeout, - "Keep the local export below" - )); - assert_eq!(app.with_workout(|state| state.events.len()), 1); - } - - #[test] - fn native_shell_haptic_result_returns_through_app_code() { - // req: host/001 req: host/002 req: host/005 - let app = AppState::demo(); - - let request = run(|()| request_native_haptic(app.clone()), ()); - assert!(contains_payload_text( - &request, - "Native-shell host call checked against manifest" - )); - assert!(contains_payload_text( - &request, - "Native-shell haptic requested" - )); - - let ack = run(|()| record_native_haptic_ack(app.clone()), ()); - assert!(contains_payload_text( - &ack, - "Native-shell host result accepted by app code" - )); - assert!(contains_payload_text( - &ack, - "workout state stayed app-owned" - )); - } - - #[test] - fn rendered_page_has_phone_first_controls_without_user_js() { - // req: examples/001 req: examples/005 - let html = render(&WorkoutState::demo()).to_string(); - assert!(html.contains("Now-first Workout Copilot")); - assert!(html.contains("Complete set")); - assert!(html.contains("Finish unlocks as you train")); - assert!(html.contains("Export after first set")); - assert!(html.contains("Undo last action")); - assert!(html.contains("Correct last set")); - assert!(html.contains("Host proof panel")); - assert!(html.contains("Simulate share denied")); - assert!(html.contains("Simulate replay failure")); - assert!(!html.contains(&format!("<{}", "script"))); - assert!(!html.contains("querySelector")); - } -} diff --git a/examples/workout/src/main.rs b/examples/workout/src/main.rs deleted file mode 100644 index 8add613..0000000 --- a/examples/workout/src/main.rs +++ /dev/null @@ -1,64 +0,0 @@ -use axum::extract::State; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::Router; -use hemx_axum::{runtime_js, runtime_js_path, EffectResponse, InteractionRequest}; -use hemx_workout_example::{self as workout_app, AppState}; -use std::net::SocketAddr; -use std::str::FromStr; - -#[tokio::main] -async fn main() { - let app = app(AppState::demo()); - let addr = std::env::var("HEMX_WORKOUT_ADDR") - .ok() - .and_then(|value| SocketAddr::from_str(&value).ok()) - .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3028))); - let listener = tokio::net::TcpListener::bind(addr).await.expect( - "bind workout example address; set HEMX_WORKOUT_ADDR=127.0.0.1:3030 if the default is busy", - ); - eprintln!("hemx workout example: http://{addr}"); - axum::serve(listener, app).await.unwrap(); -} - -pub fn app(state: AppState) -> Router { - Router::new() - .route("/", get(page).post(interact)) - .route("/workout.css", get(workout_css)) - .route(runtime_js_path(), get(runtime)) - .with_state(state) -} - -async fn page(State(state): State) -> impl IntoResponse { - axum::response::Html( - state.with_workout(|workout| workout_app::page(runtime_js_path(), workout).into_string()), - ) -} - -async fn interact( - State(state): State, - request: InteractionRequest, -) -> Result { - request.dispatch(workout_app::interactions(state)) -} - -async fn workout_css() -> impl IntoResponse { - ( - [("content-type", "text/css; charset=utf-8")], - include_str!("../templates/workout.css"), - ) -} - -async fn runtime() -> impl IntoResponse { - runtime_js() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn routes_can_be_built_for_run_and_deploy_smoke() { - let _app = app(AppState::demo()); - } -} diff --git a/examples/workout/templates/app_shell.heml b/examples/workout/templates/app_shell.heml deleted file mode 100644 index 419dd44..0000000 --- a/examples/workout/templates/app_shell.heml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - hemx workout example - - - -{+= self.body =+} - diff --git a/examples/workout/templates/workout.css b/examples/workout/templates/workout.css deleted file mode 100644 index 46517bd..0000000 --- a/examples/workout/templates/workout.css +++ /dev/null @@ -1,283 +0,0 @@ -@layer tokens, base, composition, blocks; - -@layer tokens { - :root { - color-scheme: dark; - --surface: #11130f; - --surface-2: #171a14; - --surface-3: #202518; - --ink: #f7f3e8; - --muted: #aaa38f; - --line: #35392d; - --accent: #d7ff4f; - --accent-ink: #172100; - --warn: #ffba52; - --shadow: 0 18px 70px rgb(0 0 0 / 0.38); - --radius: 24px; - --space: clamp(1rem, 4vw, 1.6rem); - --tap: 3.35rem; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } -} - -@layer base { - * { box-sizing: border-box; } - html { background: var(--surface); } - body { - margin: 0; - min-height: 100svh; - color: var(--ink); - background: - linear-gradient(135deg, rgb(215 255 79 / 0.09), transparent 34rem), - radial-gradient(circle at 50% -14rem, rgb(255 186 82 / 0.18), transparent 28rem), - var(--surface); - } - button, - input { - font: inherit; - } - button { - min-height: var(--tap); - border: 1px solid var(--line); - border-radius: 999px; - padding: 0.85rem 1.1rem; - color: var(--ink); - background: var(--surface-2); - font-weight: 800; - letter-spacing: -0.01em; - } - button:focus, - input:focus, - summary:focus, - button:focus-visible, - input:focus-visible, - summary:focus-visible { - outline: 3px solid var(--accent); - outline-offset: 3px; - } - input { - min-width: 0; - width: 100%; - border: 1px solid var(--line); - border-radius: 16px; - padding: 0.8rem 0.9rem; - color: var(--ink); - background: #0c0e0b; - } - h1, - h2, - h3, - p { - margin-block: 0; - } - pre { - margin: 0; - overflow: auto; - white-space: pre-wrap; - font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - } - summary { - cursor: pointer; - min-height: var(--tap); - display: flex; - align-items: center; - color: var(--muted); - font-weight: 850; - } -} - -@layer composition { - .workout-app { - width: min(100%, 31rem); - margin-inline: auto; - padding: var(--space); - display: grid; - gap: 0.9rem; - } - .command-slate, - .action-rail, - .finish-card, - .ledger { - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgb(23 26 20 / 0.92); - box-shadow: var(--shadow); - } -} - -@layer blocks { - .command-slate { - min-height: 44svh; - padding: clamp(1.2rem, 8vw, 2.3rem); - display: flex; - flex-direction: column; - justify-content: end; - gap: 1rem; - background: - linear-gradient(180deg, transparent, rgb(0 0 0 / 0.22)), - repeating-linear-gradient(90deg, rgb(215 255 79 / 0.08) 0 1px, transparent 1px 18px), - #181c12; - } - .eyebrow { - color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.16em; - font-size: 0.78rem; - font-weight: 900; - } - .progress-pill { - width: max-content; - max-width: 100%; - border: 1px solid rgb(215 255 79 / 0.38); - border-radius: 999px; - padding: 0.45rem 0.75rem; - color: var(--accent); - background: rgb(215 255 79 / 0.08); - font-size: 0.82rem; - font-weight: 900; - } - .command-slate h1 { - max-width: 13ch; - font-size: clamp(2.2rem, 13vw, 4.4rem); - line-height: 0.92; - letter-spacing: -0.08em; - } - .session-status, - .recovery-status, - .finish-card p { - color: var(--muted); - font-weight: 750; - } - .action-rail { - position: sticky; - top: 0.6rem; - z-index: 1; - padding: 0.7rem; - display: grid; - gap: 0.65rem; - } - .primary-action { - color: var(--accent-ink); - background: var(--accent); - border-color: var(--accent); - font-size: 1.08rem; - } - .quiet-action { - color: var(--muted); - } - .secondary-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.55rem; - } - .micro-form, - .voice-strip { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.55rem; - align-items: end; - } - .recovery-tools { - border-top: 1px dashed var(--line); - border-bottom: 1px dashed var(--line); - padding-block: 0.15rem; - } - .micro-form label, - .voice-strip label { - display: grid; - gap: 0.35rem; - color: var(--muted); - font-size: 0.78rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; - } - .finish-card, - .ledger { - padding: 1rem; - display: grid; - gap: 0.85rem; - } - .finish-card { - background: - linear-gradient(135deg, rgb(255 186 82 / 0.12), transparent 20rem), - var(--surface-2); - } - .finish-card h2 { - margin-block-start: 0.2rem; - font-size: 1.35rem; - line-height: 1; - letter-spacing: -0.05em; - } - .share-action { - color: var(--accent-ink); - background: var(--warn); - border-color: var(--warn); - } - .host-proof { - border-top: 1px dashed var(--line); - padding-block-start: 0.35rem; - } - .recovery-tools .micro-form { - margin-block-end: 0.55rem; - } - .host-proof p { - margin-block-end: 0.7rem; - font-size: 0.92rem; - } - .host-actions { - display: grid; - gap: 0.55rem; - } - .section-heading { - display: flex; - gap: 0.75rem; - align-items: center; - justify-content: space-between; - } - .ledger h2 { - font-size: 1rem; - letter-spacing: -0.03em; - } - .ledger h3 { - color: var(--muted); - font-size: 0.78rem; - text-transform: uppercase; - letter-spacing: 0.1em; - } - .ledger pre { - border-left: 3px solid var(--accent); - padding: 0.75rem 0.9rem; - background: #0c0e0b; - } - - @media (min-width: 46rem) { - .workout-app { - width: min(100%, 64rem); - grid-template-columns: minmax(20rem, 1.08fr) minmax(20rem, 0.92fr); - align-items: start; - } - .command-slate, - .action-rail { - grid-column: 1; - } - .finish-card, - .ledger { - grid-column: 2; - } - .action-rail { - position: static; - } - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0ms !important; - animation-duration: 0ms !important; - animation-iteration-count: 1 !important; - } -} diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml deleted file mode 100644 index f19107d..0000000 --- a/examples/workout/templates/workout.heml +++ /dev/null @@ -1,78 +0,0 @@ -
    -
    -

    Now-first Workout Copilot

    -

    {+ self.progress +}

    -

    {+ self.next_action +}

    -

    {+ self.status +}

    -
    - -
    -

    Today's plan

    -
      -
    • - {+ exercise.name +} - {+ exercise.target_sets +} × {+ exercise.reps +} @ {+ exercise.kg +}kg -
    • -
    -
    - -
    - -

    {+ self.recovery_status +}

    -
    - - -
    -
    - - -
    -
    - Fix last set -
    - - -
    -
    -
    - - -
    -
    - -
    -
    -

    Finish & recover

    -

    {+ self.finish_title +}

    -
    -

    {+ self.finish_copy +}

    -

    {+ self.host_status +}

    - -
    - Host proof panel -

    Development-only host results return through app code before UI effects.

    -
    - - - - - - -
    -
    -
    - -
    -
    -

    Private event log

    - -
    -
    {+ self.event_log +}
    -

    Export payload

    -
    {+ self.export_payload +}
    -
    -
    diff --git a/examples/workout/tests/browser_e2e.rs b/examples/workout/tests/browser_e2e.rs deleted file mode 100644 index b96ae12..0000000 --- a/examples/workout/tests/browser_e2e.rs +++ /dev/null @@ -1,166 +0,0 @@ -use hemx_test::TestProcess; -use std::process::Command; -use std::time::{Duration, Instant}; -use thirtyfour::prelude::*; - -const APP_ADDR: &str = "127.0.0.1:3037"; -const WEBDRIVER_ADDR: &str = "127.0.0.1:4447"; -const STARTUP_TIMEOUT: Duration = Duration::from_secs(8); - -#[tokio::test] -async fn browser_proves_phone_first_workout_flow() -> WebDriverResult<()> { - // req: examples/001 req: local/001 req: host/002 - let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-workout-example")); - app.env("HEMX_WORKOUT_ADDR", APP_ADDR); - let _app = TestProcess::start(app, "hemx-workout-example", APP_ADDR, STARTUP_TIMEOUT) - .expect("start ready hemx-workout-example"); - - let mut webdriver = Command::new("geckodriver"); - webdriver.arg("--port").arg("4447"); - let _webdriver = TestProcess::start(webdriver, "geckodriver", WEBDRIVER_ADDR, STARTUP_TIMEOUT) - .expect("start ready geckodriver"); - - let mut caps = DesiredCapabilities::firefox(); - caps.set_headless()?; - let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; - - let result = async { - driver.set_window_rect(0, 0, 390, 844).await?; - driver.goto(&format!("http://{APP_ADDR}/")).await?; - wait_for_runtime(&driver).await?; - assert_viewport(&driver, "phone", true).await?; - - driver - .find(By::Css(".primary-action")) - .await? - .click() - .await?; - wait_for_body_text(&driver, "Rest 90s").await?; - wait_for_body_text(&driver, "Start Goblet squat set 2").await?; - wait_for_body_text(&driver, "Undo is available").await?; - assert_viewport(&driver, "phone after rest", true).await?; - - driver - .find(By::Css(".primary-action")) - .await? - .click() - .await?; - wait_for_body_text(&driver, "started Goblet squat set 2").await?; - - driver - .find(By::XPath("//button[contains(., 'Undo last action')]")) - .await? - .click() - .await?; - wait_for_body_text(&driver, "Undid: started Goblet squat set 2").await?; - - let host_panel = driver.find(By::Css(".host-proof")).await?; - assert!(host_panel.attr("open").await?.is_none()); - - driver.set_window_rect(0, 0, 1024, 900).await?; - assert_viewport(&driver, "wide", false).await?; - Ok::<(), WebDriverError>(()) - } - .await; - - let quit = driver.quit().await; - result.and(quit) -} - -async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { - let deadline = Instant::now() + Duration::from_secs(8); - loop { - let loaded = driver - .execute( - "return !!window.hemx && window.hemx.roots().length > 0", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false); - if loaded { - return Ok(()); - } - if Instant::now() >= deadline { - panic!("timed out waiting for hemx runtime"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn wait_for_body_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> { - let deadline = Instant::now() + Duration::from_secs(8); - loop { - let body = driver.find(By::Css("body")).await?.text().await?; - if body.contains(text) { - return Ok(()); - } - if Instant::now() >= deadline { - panic!("timed out waiting for {text:?}; body={body:?}"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn assert_viewport( - driver: &WebDriver, - label: &str, - expect_single_column: bool, -) -> WebDriverResult<()> { - let ok = driver - .execute( - r#" - const app = document.querySelector('.workout-app'); - const primary = document.querySelector('.primary-action'); - const h1 = document.querySelector('h1'); - const host = document.querySelector('.host-proof'); - const appRect = app.getBoundingClientRect(); - const h1Rect = h1.getBoundingClientRect(); - const primaryRect = primary.getBoundingClientRect(); - const primaryStyle = getComputedStyle(primary); - const gridColumns = getComputedStyle(app).gridTemplateColumns.split(' ').length; - const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); - const labels = [...document.querySelectorAll('button,input,summary')] - .filter(visible) - .map((el) => (el.textContent || el.getAttribute('aria-label') || '').trim()); - const primaryIndex = labels.findIndex((label) => label.length > 0 && label === primary.textContent.trim()); - const undoIndex = labels.findIndex((label) => label.includes('Undo last action')); - const hostIndex = labels.findIndex((label) => label.includes('Host proof panel')); - primary.focus(); - const focusedStyle = getComputedStyle(primary); - const focusRingOk = document.activeElement === primary && - focusedStyle.outlineStyle !== 'none' && - parseFloat(focusedStyle.outlineWidth) >= 2; - const zeroDurations = (value) => value.split(',').every((part) => { - const trimmed = part.trim(); - return trimmed === '0s' || trimmed === '0ms'; - }); - const noMotion = [...document.querySelectorAll('*')].every((el) => { - const style = getComputedStyle(el); - return zeroDurations(style.transitionDuration) && zeroDurations(style.animationDuration); - }); - return document.documentElement.scrollWidth <= window.innerWidth && - h1.textContent.trim().length > 0 && - primary.textContent.trim().length > 0 && - h1Rect.top < primaryRect.top && - primaryRect.height >= 48 && - primaryStyle.borderRadius !== '0px' && - focusRingOk && - noMotion && - primaryIndex === 0 && - undoIndex > primaryIndex && - hostIndex > undoIndex && - appRect.width <= window.innerWidth && - host.open === false && - (arguments[0] ? gridColumns === 1 : gridColumns >= 2); - "#, - vec![expect_single_column.into()], - ) - .await? - .json() - .as_bool() - .unwrap_or(false); - assert!(ok, "{label} viewport failed product hierarchy checks"); - Ok(()) -} diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs deleted file mode 100644 index 041a136..0000000 --- a/examples/workout/tests/e2e.rs +++ /dev/null @@ -1,217 +0,0 @@ -use hemx_axum::runtime_js_path; -use hemx_test::{inspect_wire, EffectInspector, TestProcess}; -use hemx_workout_example::ui::BUILD_FINGERPRINT; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::process::Command; -use std::time::Duration; - -struct Server { - _process: TestProcess, - addr: String, -} - -impl Server { - fn start() -> Self { - let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port"); - let addr = listener.local_addr().unwrap().to_string(); - drop(listener); - - let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-workout-example")); - command.env("HEMX_WORKOUT_ADDR", &addr); - let process = TestProcess::start( - command, - "hemx-workout-example", - &addr, - Duration::from_secs(5), - ) - .expect("start ready hemx-workout-example"); - - Self { - _process: process, - addr, - } - } -} - -#[test] -fn workout_is_e2e_working_over_http() { - // req: examples/001 req: local/001 req: local/003 req: local/004 req: host/001 - let server = Server::start(); - - let home = get(&server, "/"); - assert_eq!(home.status, 200); - assert!(home.header("content-type").contains("text/html")); - assert!(home.text().contains("Now-first Workout Copilot")); - assert!(home.text().contains("Finish & recover")); - assert!(home.text().contains("Undo last action")); - assert!(home.text().contains("Fix last set")); - assert!(home.text().contains("Private event log")); - assert!(home.text().contains("Replay export")); - assert!(home - .text() - .contains(" String { - format!("__h={}", handle_id_from_html(html, label)) -} - -fn handle_id_from_html(html: &str, label: &str) -> String { - let label_at = html.find(label).expect("label in html"); - let prefix = &html[..label_at]; - let hid_at = prefix.rfind("data-hid=\"").expect("handle before label") + "data-hid=\"".len(); - let end = prefix[hid_at..].find('"').expect("handle quote"); - prefix[hid_at..hid_at + end].to_owned() -} - -fn assert_effect_response(response: &Response) { - assert_eq!(response.status, 200, "response: {response:?}"); - assert!(response.header("content-type").contains("application/hemx")); - assert_eq!( - response.header("x-hemx-fingerprint"), - BUILD_FINGERPRINT.0.to_string() - ); - assert!(!response.effects().is_empty()); -} - -fn assert_payload_contains(batch: &EffectInspector, needle: &str) { - assert!( - batch.payload_contains(needle), - "missing payload {needle:?} in {batch:#?}" - ); -} - -fn get(server: &Server, path: &str) -> Response { - request(server, "GET", path, "") -} - -fn post(server: &Server, path: &str, body: &str) -> Response { - request(server, "POST", path, body) -} - -fn request(server: &Server, method: &str, path: &str, body: &str) -> Response { - let mut stream = TcpStream::connect(&server.addr).expect("connect workout example"); - let request = format!( - "{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n\r\n{body}", - server.addr, - body.len() - ); - stream.write_all(request.as_bytes()).unwrap(); - - let mut raw = Vec::new(); - stream.read_to_end(&mut raw).unwrap(); - Response::parse(raw) -} - -#[derive(Debug)] -struct Response { - status: u16, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Response { - fn parse(raw: Vec) -> Self { - let split = raw - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("http response"); - let head = String::from_utf8(raw[..split].to_vec()).expect("utf8 headers"); - let body = raw[(split + 4)..].to_vec(); - let mut lines = head.lines(); - let status = lines - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|status| status.parse().ok()) - .expect("status code"); - let headers = lines - .filter_map(|line| line.split_once(':')) - .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned())) - .collect(); - Self { - status, - headers, - body, - } - } - - fn header(&self, name: &str) -> String { - let name = name.to_ascii_lowercase(); - self.headers - .iter() - .find_map(|(key, value)| (key == &name).then(|| value.clone())) - .unwrap_or_default() - } - - fn text(&self) -> String { - String::from_utf8(self.body.clone()).expect("utf8 body") - } - - fn effects(&self) -> EffectInspector { - inspect_wire(&self.body) - } -} diff --git a/hemplate-runtime/Cargo.toml b/hemplate-runtime/Cargo.toml deleted file mode 100644 index d933d7c..0000000 --- a/hemplate-runtime/Cargo.toml +++ /dev/null @@ -1,5 +0,0 @@ -[package] -name = "hemplate-runtime" -version.workspace = true -edition.workspace = true -publish = false diff --git a/hemplate-runtime/src/lib.rs b/hemplate-runtime/src/lib.rs deleted file mode 100644 index f783784..0000000 --- a/hemplate-runtime/src/lib.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::fmt; - -pub mod error { - use std::fmt; - - #[derive(Debug)] - pub struct HemplateError; - - impl fmt::Display for HemplateError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("hemplate rendering failed") - } - } - - impl std::error::Error for HemplateError {} - - impl From for HemplateError { - fn from(_: fmt::Error) -> Self { - Self - } - } -} - -pub trait Hemplate { - fn render_into(&self, buffer: &mut String) -> Result<(), error::HemplateError>; - - fn render(&self) -> String { - let mut buffer = String::new(); - self.render_into(&mut buffer) - .expect("writing a hemplate String cannot fail"); - buffer - } -} - -pub fn render(value: &T) -> Result { - let mut buffer = String::new(); - value.render_into(&mut buffer)?; - Ok(buffer) -} - -struct HtmlEscape(T); - -impl fmt::Display for HtmlEscape { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - for character in self.0.to_string().chars() { - match character { - '&' => formatter.write_str("&")?, - '<' => formatter.write_str("<")?, - '>' => formatter.write_str(">")?, - '"' => formatter.write_str(""")?, - '\'' => formatter.write_str("'")?, - other => fmt::Write::write_char(formatter, other)?, - } - } - Ok(()) - } -} - -pub mod render { - use super::{error::HemplateError, Hemplate, HtmlEscape}; - use std::fmt; - - pub struct Escaped<'a, T: ?Sized>(pub &'a T); - - impl Escaped<'_, T> { - pub fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> { - self.0.render_into(buffer) - } - } - - pub trait EscapedFallback { - fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>; - } - - impl EscapedFallback for Escaped<'_, T> { - fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> { - use fmt::Write; - write!(buffer, "{}", HtmlEscape(self.0))?; - Ok(()) - } - } - - pub struct Raw<'a, T: ?Sized>(pub &'a T); - - impl Raw<'_, T> { - pub fn render_to(&self, _buffer: &mut String) -> Result<(), HemplateError> { - panic!("hemplate: raw interpolation must not be used with Hemplate types") - } - } - - pub trait RawFallback { - fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>; - } - - impl RawFallback for Raw<'_, T> { - fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> { - use fmt::Write; - write!(buffer, "{}", self.0)?; - Ok(()) - } - } - - pub struct RawGuard<'a, T: ?Sized>(pub &'a T); - pub struct RawInterpolationNotAllowedForHemplateTypes; - - impl RawGuard<'_, T> { - pub fn check(&self) -> RawInterpolationNotAllowedForHemplateTypes { - RawInterpolationNotAllowedForHemplateTypes - } - } - - pub trait RawGuardFallback { - fn check(&self); - } - - impl RawGuardFallback for RawGuard<'_, T> { - fn check(&self) {} - } - - impl Hemplate for &T { - fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> { - (*self).render_into(buffer) - } - } - - impl Hemplate for Box { - fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> { - (**self).render_into(buffer) - } - } -} diff --git a/hemx-host/Cargo.toml b/hemx-host/Cargo.toml deleted file mode 100644 index 38ed02f..0000000 --- a/hemx-host/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "hemx-host" -version.workspace = true -edition.workspace = true - -[lib] -path = "src/lib.rs" - -[features] -default = ["std"] -std = ["serde/std"] - -[dependencies] -serde = { version = "1", default-features = false, features = ["alloc", "derive"] } - -[dev-dependencies] -hemx-core = { path = "../hemx-core" } diff --git a/hemx-host/runtime/browser-host.js b/hemx-host/runtime/browser-host.js deleted file mode 100644 index ffd468b..0000000 --- a/hemx-host/runtime/browser-host.js +++ /dev/null @@ -1,49 +0,0 @@ -(() => { - function variant(value) { - if (!value || typeof value !== "object") return null; - const keys = Object.keys(value); - return keys.length === 1 ? { kind: keys[0], data: value[keys[0]] || {} } : null; - } - - function failure(id, capability, kind, message) { - return { Failed: { id: id || null, capability: capability || null, kind, message: String(message) } }; - } - - async function haptic(data) { - if (!navigator.vibrate) return failure(data.id, "Haptics", "Unavailable", "haptics unsupported"); - const pattern = data.pattern === "Warning" || data.pattern === "Error" ? [30, 40, 30] : 20; - navigator.vibrate(pattern); - return { Acknowledged: { id: data.id } }; - } - - async function share(data) { - if (!navigator.share) return failure(data.id, "Share", "Unavailable", "share unsupported"); - const payload = data.payload || {}; - const request = {}; - if (payload.title) request.title = payload.title; - if (payload.text) request.text = payload.text; - if (payload.url) request.url = payload.url; - try { - await navigator.share(request); - return { ShareCompleted: { id: data.id, completed: true } }; - } catch (error) { - if (error && error.name === "AbortError") return { ShareCompleted: { id: data.id, completed: false } }; - return failure(data.id, "Share", "Error", error && error.message ? error.message : error); - } - } - - function supports(capability, shape) { - return (capability === "haptics" && shape === "Fire" && !!navigator.vibrate) - || (capability === "share" && shape === "Request" && !!navigator.share); - } - - async function perform(call) { - const request = variant(call); - if (!request) return failure(null, null, "Error", "invalid host call"); - if (request.kind === "Haptic") return haptic(request.data); - if (request.kind === "Share") return share(request.data); - return failure(request.data.id || null, null, "Unavailable", `unsupported host call ${request.kind}`); - } - - window.hemxBrowserHost = Object.freeze({ name: "browser-pwa", supports, perform }); -})(); diff --git a/hemx-host/src/lib.rs b/hemx-host/src/lib.rs deleted file mode 100644 index 7740605..0000000 --- a/hemx-host/src/lib.rs +++ /dev/null @@ -1,759 +0,0 @@ -#![cfg_attr(not(feature = "std"), no_std)] - -//! Typed host capability contract for hemx integrations. -//! -//! `hemx-host` describes the boundary between a hemx app and the browser, -//! PWA, WebView, or native shell that can perform device/host work. It does -//! not render UI, own application state, or define provider policy. Host -//! results are facts for app code to handle before returning normal hemx -//! effects. req: host/001 req: host/002 - -extern crate alloc; - -/// Optional browser/PWA host adapter source. -/// -/// The adapter exposes `window.hemxBrowserHost.perform(call)` for the serde JSON -/// shape of [`HostCall`] and returns the serde JSON shape of [`HostEvent`]. It -/// only uses browser host APIs such as `navigator.share` and `navigator.vibrate`; -/// it does not inspect or mutate the DOM. req: host/001 req: host/002 req: host/005 -pub const BROWSER_HOST_JS: &str = include_str!("../runtime/browser-host.js"); - -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use serde::{Deserialize, Serialize}; - -/// A stable capability name understood by an app and one or more host adapters. -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub enum Capability { - Haptics, - Microphone, - Camera, - Share, - SecureStorage, - Notifications, - Clipboard, - FilePicker, - Geolocation, - Custom(String), -} - -impl Capability { - pub fn custom(name: impl Into) -> Self { - Self::Custom(name.into()) - } - - pub fn as_str(&self) -> &str { - match self { - Self::Haptics => "haptics", - Self::Microphone => "microphone", - Self::Camera => "camera", - Self::Share => "share", - Self::SecureStorage => "secure_storage", - Self::Notifications => "notifications", - Self::Clipboard => "clipboard", - Self::FilePicker => "file_picker", - Self::Geolocation => "geolocation", - Self::Custom(name) => name.as_str(), - } - } - - /// Capabilities that require a user-facing permission reason in the app - /// manifest before standard hosts may expose them. req: host/003 - pub fn needs_permission_reason(&self) -> bool { - matches!( - self, - Self::Microphone - | Self::Camera - | Self::SecureStorage - | Self::Notifications - | Self::FilePicker - | Self::Geolocation - ) - } -} - -/// The only shapes a host capability may take. -/// -/// Keeping the shape set small prevents host plugins from becoming a second app -/// runtime. req: host/001 req: host/002 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub enum CapabilityShape { - Fire, - Request, - Stream, - Schedule, -} - -/// One declared capability use in an app manifest or host profile. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct CapabilityUse { - pub capability: Capability, - pub shape: CapabilityShape, - pub reason: Option, -} - -impl CapabilityUse { - pub fn new(capability: Capability, shape: CapabilityShape) -> Self { - Self { - capability, - shape, - reason: None, - } - } - - pub fn with_reason(mut self, reason: impl Into) -> Self { - self.reason = Some(reason.into()); - self - } - - fn matches(&self, capability: &Capability, shape: CapabilityShape) -> bool { - self.capability == *capability && self.shape == shape - } -} - -/// App-owned declaration of host capabilities it may request. -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct CapabilityManifest { - pub uses: Vec, -} - -impl CapabilityManifest { - pub fn new(uses: impl Into>) -> Self { - Self { uses: uses.into() } - } - - pub fn allows(&self, capability: &Capability, shape: CapabilityShape) -> bool { - self.uses.iter().any(|use_| use_.matches(capability, shape)) - } - - pub fn check(&self, host: &HostProfile) -> Result<(), HostCheckError> { - for use_ in &self.uses { - if use_.capability.needs_permission_reason() - && use_ - .reason - .as_ref() - .map(|reason| reason.trim().is_empty()) - .unwrap_or(true) - { - return Err(HostCheckError::MissingPermissionReason { - capability: use_.capability.clone(), - }); - } - - if !host.supports(&use_.capability, use_.shape) { - return Err(HostCheckError::UnsupportedCapability { - capability: use_.capability.clone(), - shape: use_.shape, - host: host.name.clone(), - }); - } - } - - Ok(()) - } - - pub fn validate_call(&self, host: &HostProfile, call: &HostCall) -> Result<(), HostCheckError> { - let capability = call.capability(); - let shape = call.shape(); - - if !self.allows(&capability, shape) { - return Err(HostCheckError::UndeclaredCapability { capability, shape }); - } - - if !host.supports(&capability, shape) { - return Err(HostCheckError::UnsupportedCapability { - capability, - shape, - host: host.name.clone(), - }); - } - - Ok(()) - } -} - -/// Capabilities exposed by one concrete host adapter. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct HostProfile { - pub name: String, - pub supports: Vec, -} - -impl HostProfile { - pub fn new(name: impl Into, supports: impl Into>) -> Self { - Self { - name: name.into(), - supports: supports.into(), - } - } - - pub fn supports(&self, capability: &Capability, shape: CapabilityShape) -> bool { - self.supports - .iter() - .any(|use_| use_.matches(capability, shape)) - } -} - -/// Browser/PWA host profile for the optional `BROWSER_HOST_JS` adapter. -/// -/// Runtime feature availability is still checked by the JavaScript adapter; -/// this profile records the contract shapes the adapter owns. req: host/001 -pub fn browser_pwa_host_profile() -> HostProfile { - HostProfile::new( - "browser-pwa", - [ - CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), - CapabilityUse::new(Capability::Share, CapabilityShape::Request), - ], - ) -} - -/// Native-shell-shaped profile used by WebView adapters that expose device APIs -/// through the same host call/event contract. req: host/001 req: host/002 -pub fn native_shell_host_profile(name: impl Into) -> HostProfile { - HostProfile::new( - name, - [ - CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), - CapabilityUse::new(Capability::Share, CapabilityShape::Request), - CapabilityUse::new(Capability::Microphone, CapabilityShape::Stream), - CapabilityUse::new(Capability::Notifications, CapabilityShape::Schedule), - ], - ) -} - -/// A host-check failure that can be reported by build tooling, tests, or a host -/// adapter before executing a capability call. req: host/004 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum HostCheckError { - UndeclaredCapability { - capability: Capability, - shape: CapabilityShape, - }, - UnsupportedCapability { - capability: Capability, - shape: CapabilityShape, - host: String, - }, - MissingPermissionReason { - capability: Capability, - }, -} - -impl core::fmt::Display for HostCheckError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::UndeclaredCapability { capability, shape } => write!( - f, - "host capability `{}` with shape {:?} is used but not declared", - capability.as_str(), - shape - ), - Self::UnsupportedCapability { - capability, - shape, - host, - } => write!( - f, - "host `{host}` does not support capability `{}` with shape {:?}", - capability.as_str(), - shape - ), - Self::MissingPermissionReason { capability } => write!( - f, - "host capability `{}` requires a permission reason", - capability.as_str() - ), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for HostCheckError {} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct HostCallId(pub String); - -impl HostCallId { - pub fn new(value: impl ToString) -> Self { - Self(value.to_string()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct HostStreamId(pub String); - -impl HostStreamId { - pub fn new(value: impl ToString) -> Self { - Self(value.to_string()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub enum HapticPattern { - Selection, - Success, - Warning, - Error, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SharePayload { - pub title: Option, - pub text: Option, - pub url: Option, -} - -impl SharePayload { - pub fn text(text: impl Into) -> Self { - Self { - title: None, - text: Some(text.into()), - url: None, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct MicrophoneConfig { - pub mime_type: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct NotificationRequest { - pub title: String, - pub body: Option, -} - -/// Typed calls that app code may ask a host adapter to perform. -/// -/// Adapters execute these calls and return [`HostEvent`] values. They must not -/// mutate DOM or application/domain state themselves. req: host/002 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum HostCall { - Haptic { - id: HostCallId, - pattern: HapticPattern, - }, - Share { - id: HostCallId, - payload: SharePayload, - }, - StartMicrophone { - stream: HostStreamId, - config: MicrophoneConfig, - }, - StopStream { - stream: HostStreamId, - }, - ScheduleNotification { - id: HostCallId, - notification: NotificationRequest, - }, - Custom { - id: HostCallId, - capability: Capability, - shape: CapabilityShape, - op: String, - payload: Vec, - }, -} - -impl HostCall { - pub fn capability(&self) -> Capability { - match self { - Self::Haptic { .. } => Capability::Haptics, - Self::Share { .. } => Capability::Share, - Self::StartMicrophone { .. } | Self::StopStream { .. } => Capability::Microphone, - Self::ScheduleNotification { .. } => Capability::Notifications, - Self::Custom { capability, .. } => capability.clone(), - } - } - - pub fn shape(&self) -> CapabilityShape { - match self { - Self::Haptic { .. } => CapabilityShape::Fire, - Self::Share { .. } => CapabilityShape::Request, - Self::StartMicrophone { .. } | Self::StopStream { .. } => CapabilityShape::Stream, - Self::ScheduleNotification { .. } => CapabilityShape::Schedule, - Self::Custom { shape, .. } => *shape, - } - } -} - -/// Boring, typed host failure classes that app code can handle before it emits UI. -/// req: host/002 req: host/005 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum HostFailureKind { - PermissionDenied, - Timeout, - Unavailable, - Error, -} - -/// One host failure result shape for denied, timeout, unavailable, and error cases. -/// req: host/002 req: host/005 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct HostFailure { - pub id: Option, - pub capability: Option, - pub kind: HostFailureKind, - pub message: String, -} - -impl HostFailure { - pub fn new(kind: HostFailureKind, message: impl Into) -> Self { - Self { - id: None, - capability: None, - kind, - message: message.into(), - } - } - - pub fn with_id(mut self, id: HostCallId) -> Self { - self.id = Some(id); - self - } - - pub fn with_capability(mut self, capability: Capability) -> Self { - self.capability = Some(capability); - self - } -} - -/// Facts and results produced by a host adapter. -/// -/// App code decides what a host event means for the product/domain before any -/// hemx UI effect is returned. req: host/002 req: host/005 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum HostEvent { - Acknowledged { - id: HostCallId, - }, - Failed(HostFailure), - ShareCompleted { - id: HostCallId, - completed: bool, - }, - StreamChunk { - stream: HostStreamId, - bytes: Vec, - mime_type: Option, - }, - StreamEnded { - stream: HostStreamId, - }, - NotificationFired { - id: HostCallId, - action: Option, - }, - Custom { - id: Option, - capability: Capability, - payload: Vec, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use hemx_core::{event, Effect, IntoEffect, Payload, Slot}; - - fn web_host() -> HostProfile { - browser_pwa_host_profile() - } - - fn native_shell_host() -> HostProfile { - native_shell_host_profile("ios-android-webview-test") - } - - #[test] - fn capability_names_and_standard_profile_identity_are_stable() { - let names = [ - (Capability::Haptics, "haptics"), - (Capability::Microphone, "microphone"), - (Capability::Camera, "camera"), - (Capability::Share, "share"), - (Capability::SecureStorage, "secure_storage"), - (Capability::Notifications, "notifications"), - (Capability::Clipboard, "clipboard"), - (Capability::FilePicker, "file_picker"), - (Capability::Geolocation, "geolocation"), - (Capability::custom("vendor.camera"), "vendor.camera"), - ]; - - for (capability, expected) in names { - assert_eq!(capability.as_str(), expected); - } - assert_eq!(web_host().name, "browser-pwa"); - // req: host/001 req: host/004 - } - - #[test] - fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() { - // req: host/001 req: host/002 req: host/005 - assert!(BROWSER_HOST_JS.contains("window.hemxBrowserHost")); - assert!(BROWSER_HOST_JS.contains("navigator.vibrate")); - assert!(BROWSER_HOST_JS.contains("navigator.share")); - assert!( - BROWSER_HOST_JS.contains("return { ShareCompleted: { id: data.id, completed: true } }") - ); - assert!(BROWSER_HOST_JS.contains("kind, message")); - assert!(BROWSER_HOST_JS.contains("\"Unavailable\"")); - assert!(!BROWSER_HOST_JS.contains("querySelector")); - assert!(!BROWSER_HOST_JS.contains("innerHTML")); - assert!(!BROWSER_HOST_JS.contains("classList")); - assert!(!BROWSER_HOST_JS.contains("dispatchEvent")); - assert!(!BROWSER_HOST_JS.contains("localStorage")); - } - - #[test] - fn manifest_checks_declared_permissions_and_host_support() { - // req: host/003 req: host/004 - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Microphone, - CapabilityShape::Stream, - )]); - - assert_eq!( - manifest.check(&web_host()), - Err(HostCheckError::MissingPermissionReason { - capability: Capability::Microphone, - }) - ); - - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Share, - CapabilityShape::Request, - )]); - - assert_eq!(manifest.check(&web_host()), Ok(())); - } - - #[test] - fn manifest_and_host_support_require_the_exact_capability_shape() { - let wrong_shape_host = HostProfile::new( - "wrong-shape", - [CapabilityUse::new(Capability::Share, CapabilityShape::Fire)], - ); - let wrong_capability_host = HostProfile::new( - "wrong-capability", - [CapabilityUse::new( - Capability::Haptics, - CapabilityShape::Request, - )], - ); - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Share, - CapabilityShape::Request, - )]); - - assert!(!wrong_shape_host.supports(&Capability::Share, CapabilityShape::Request)); - assert!(!wrong_capability_host.supports(&Capability::Share, CapabilityShape::Request)); - assert_eq!( - manifest.check(&wrong_shape_host), - Err(HostCheckError::UnsupportedCapability { - capability: Capability::Share, - shape: CapabilityShape::Request, - host: "wrong-shape".into(), - }) - ); - // req: host/001 req: host/004 - } - - #[test] - fn host_calls_must_be_declared_and_supported() { - // req: host/001 req: host/004 - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Share, - CapabilityShape::Request, - )]); - let payload = SharePayload::text("log"); - assert_eq!( - payload, - SharePayload { - title: None, - text: Some("log".into()), - url: None, - } - ); - let call = HostCall::Share { - id: HostCallId::new("share-1"), - payload, - }; - assert_eq!(manifest.validate_call(&web_host(), &call), Ok(())); - - let unsupported_host = HostProfile::new("offline-shell", []); - let unsupported = manifest - .validate_call(&unsupported_host, &call) - .expect_err("declared calls still require host support"); - assert_eq!( - unsupported, - HostCheckError::UnsupportedCapability { - capability: Capability::Share, - shape: CapabilityShape::Request, - host: "offline-shell".into(), - } - ); - assert_eq!( - unsupported.to_string(), - "host `offline-shell` does not support capability `share` with shape Request" - ); - - let haptic = HostCall::Haptic { - id: HostCallId::new("tap"), - pattern: HapticPattern::Success, - }; - assert_eq!( - manifest.validate_call(&web_host(), &haptic), - Err(HostCheckError::UndeclaredCapability { - capability: Capability::Haptics, - shape: CapabilityShape::Fire, - }) - ); - } - - enum AppCommand { - MarkShared, - MarkHapticAck, - } - - fn handle_host_event(event: HostEvent) -> Option { - match event { - HostEvent::ShareCompleted { - completed: true, .. - } => Some(AppCommand::MarkShared), - HostEvent::Acknowledged { id } if id.0 == "tap" => Some(AppCommand::MarkHapticAck), - _ => None, - } - } - - fn apply_app_command(command: AppCommand) -> Effect { - match command { - AppCommand::MarkShared => Slot::<()>::new(7).text("export shared"), - AppCommand::MarkHapticAck => Slot::<()>::new(8).text("set complete"), - } - } - - #[test] - fn host_failures_use_one_typed_result_shape() { - // req: host/002 req: host/005 - let denied = HostEvent::Failed( - HostFailure::new(HostFailureKind::PermissionDenied, "share denied") - .with_id(HostCallId::new("share-1")) - .with_capability(Capability::Share), - ); - let timeout = HostEvent::Failed( - HostFailure::new(HostFailureKind::Timeout, "share timed out") - .with_id(HostCallId::new("share-1")) - .with_capability(Capability::Share), - ); - let unavailable = HostEvent::Failed( - HostFailure::new(HostFailureKind::Unavailable, "share unsupported") - .with_capability(Capability::Share), - ); - let error = HostEvent::Failed(HostFailure::new(HostFailureKind::Error, "host crashed")); - - for event in [denied, timeout, unavailable, error] { - match event { - HostEvent::Failed(failure) => assert!(!failure.message.is_empty()), - other => panic!("unexpected host event shape: {other:?}"), - } - } - } - - #[test] - fn host_failure_builders_preserve_call_context() { - let failure = HostFailure::new(HostFailureKind::Timeout, "host timed out") - .with_id(HostCallId::new("share-1")) - .with_capability(Capability::Share); - - assert_eq!(failure.id, Some(HostCallId::new("share-1"))); - assert_eq!(failure.capability, Some(Capability::Share)); - assert_eq!(failure.kind, HostFailureKind::Timeout); - assert_eq!(failure.message, "host timed out"); - // req: host/002 req: host/005 - } - - #[test] - fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() { - // req: host/001 req: host/002 req: host/005 - let manifest = CapabilityManifest::new([CapabilityUse::new( - Capability::Share, - CapabilityShape::Request, - )]); - let call = HostCall::Share { - id: HostCallId::new("share-1"), - payload: SharePayload::text("log"), - }; - manifest - .validate_call(&web_host(), &call) - .expect("web/PWA host supports declared share request"); - - let host_event = HostEvent::ShareCompleted { - id: HostCallId::new("share-1"), - completed: true, - }; - - let command = handle_host_event(host_event).expect("host event becomes app command"); - let batch = apply_app_command(command).into_batch(hemx_core::BuildFingerprint(11)); - - assert_eq!(batch.ops.len(), 1); - assert!(matches!( - &batch.ops[0], - Effect::Put { - payload: Payload::Text(text), - .. - } if text == "export shared" - )); - } - - #[test] - fn native_shell_boundary_uses_same_manifest_call_event_path() { - // req: host/001 req: host/002 req: host/005 - let manifest = CapabilityManifest::new([ - CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), - CapabilityUse::new(Capability::Microphone, CapabilityShape::Stream) - .with_reason("Record dictated workout commands"), - ]); - manifest - .check(&native_shell_host()) - .expect("native shell profile supports declared capabilities"); - - let call = HostCall::Haptic { - id: HostCallId::new("tap"), - pattern: HapticPattern::Success, - }; - manifest - .validate_call(&native_shell_host(), &call) - .expect("native shell supports declared haptic fire call"); - - let command = handle_host_event(HostEvent::Acknowledged { - id: HostCallId::new("tap"), - }) - .expect("host ack becomes app command"); - let batch = apply_app_command(command).into_batch(hemx_core::BuildFingerprint(13)); - - assert!(matches!( - &batch.ops[0], - Effect::Put { - payload: Payload::Text(text), - .. - } if text == "set complete" - )); - } - - #[test] - fn host_events_can_emit_to_existing_app_handlers_without_owning_state() { - // req: host/002 req: host/005 - let effect = event("host:share-completed", "share-1"); - let batch = effect.into_batch(hemx_core::BuildFingerprint(12)); - - assert!(matches!( - &batch.ops[0], - Effect::Emit { name, payload } - if name == "host:share-completed" && payload == "share-1" - )); - } -} diff --git a/hemx-lsp/Cargo.toml b/hemx-lsp/Cargo.toml deleted file mode 100644 index 7319d44..0000000 --- a/hemx-lsp/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "hemx-lsp" -version.workspace = true -edition.workspace = true - -[dependencies] -hemx-build = { path = "../hemx-build" } -serde_json = "1" diff --git a/hemx-lsp/src/main.rs b/hemx-lsp/src/main.rs deleted file mode 100644 index 02e8cb5..0000000 --- a/hemx-lsp/src/main.rs +++ /dev/null @@ -1,1371 +0,0 @@ -use std::collections::HashMap; -use std::env; -use std::io::{self, BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use hemx_build::{Diagnostic, DiagnosticCode, DiagnosticSeverity}; - -fn main() -> ExitCode { - let mut args = env::args().skip(1); - match args.next().as_deref() { - None | Some("lsp") | Some("serve") => run_lsp(args.collect::>().as_slice()), - Some("diagnostics") | Some("check") => run_diagnostics(args.collect::>().as_slice()), - Some("help") | Some("--help") | Some("-h") => { - print_help(); - ExitCode::SUCCESS - } - Some(command) => { - eprintln!("unknown hemx-lsp command `{command}`"); - print_help(); - ExitCode::from(2) - } - } -} - -fn print_help() { - println!( - "usage:\n hemx-lsp lsp\n hemx-lsp diagnostics FILE.heml\n\nrepo usage:\n cargo run -p hemx-lsp -- lsp\n cargo run -p hemx-lsp -- diagnostics FILE.heml" - ); -} - -fn run_diagnostics(operands: &[String]) -> ExitCode { - let [file] = operands else { - eprintln!("usage: hemx-lsp diagnostics FILE.heml"); - return ExitCode::from(2); - }; - match hemx_build::diagnostics_for_heml_file(file) { - Ok(diagnostics) => { - let path = canonical_path(file); - let source = std::fs::read_to_string(&path).ok(); - let payload = - publish_diagnostics_payload(&file_uri(&path), &diagnostics, source.as_deref()); - println!( - "{}", - serde_json::to_string_pretty(&payload).expect("json diagnostics") - ); - if diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } - Err(err) => { - eprintln!("{file}: {err}"); - ExitCode::FAILURE - } - } -} - -fn run_lsp(operands: &[String]) -> ExitCode { - if !operands.is_empty() { - eprintln!("usage: hemx-lsp lsp"); - return ExitCode::from(2); - } - let stdin = io::stdin(); - let mut reader = BufReader::new(stdin.lock()); - let stdout = io::stdout(); - let mut writer = stdout.lock(); - match serve_lsp(&mut reader, &mut writer) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("hemx-lsp: {err}"); - ExitCode::FAILURE - } - } -} - -fn serve_lsp(reader: &mut R, writer: &mut W) -> io::Result<()> { - let mut open_documents = HashMap::::new(); - while let Some(message) = read_lsp_message(reader)? { - let method = message - .get("method") - .and_then(|method| method.as_str()) - .unwrap_or_default(); - match (message.get("id"), method) { - (Some(id), "initialize") => write_lsp_message( - writer, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "capabilities": { - "textDocumentSync": { - "openClose": true, - "change": 1, - "save": true - }, - "completionProvider": { - "triggerCharacters": ["h", "+", "d", "="] - }, - "hoverProvider": true - }, - "serverInfo": { - "name": "hemx-lsp", - "version": env!("CARGO_PKG_VERSION") - } - } - }), - )?, - (Some(id), "shutdown") => { - write_lsp_message( - writer, - &serde_json::json!({"jsonrpc": "2.0", "id": id, "result": null}), - )?; - } - (Some(id), "textDocument/completion") => { - let uri = text_document_uri(&message).unwrap_or_default(); - let text = open_documents - .get(&uri) - .map(String::as_str) - .unwrap_or_default(); - let position = text_document_position(&message); - write_lsp_message( - writer, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "isIncomplete": false, - "items": hemplate_completion_items(&uri, text, position) - } - }), - )?; - } - (Some(id), "textDocument/hover") => { - let uri = text_document_uri(&message).unwrap_or_default(); - let text = open_documents - .get(&uri) - .map(String::as_str) - .unwrap_or_default(); - let position = text_document_position(&message); - write_lsp_message( - writer, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": hemplate_hover(&uri, text, position) - }), - )?; - } - (Some(id), unknown) => write_lsp_message( - writer, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32601, "message": format!("unknown hemx LSP request `{unknown}`") } - }), - )?, - (None, "textDocument/didOpen") => { - if let Some((uri, text)) = did_open_document(&message) { - open_documents.insert(uri.clone(), text.clone()); - publish_lsp_diagnostics( - writer, - &uri, - diagnostics_for_uri_source(&uri, text.clone())?, - Some(&text), - )?; - } - } - (None, "textDocument/didChange") => { - if let Some((uri, text)) = did_change_document(&message) { - open_documents.insert(uri.clone(), text.clone()); - publish_lsp_diagnostics( - writer, - &uri, - diagnostics_for_uri_source(&uri, text.clone())?, - Some(&text), - )?; - } - } - (None, "textDocument/didSave") => { - if let Some(uri) = text_document_uri(&message) { - let (diagnostics, source) = if let Some(text) = did_save_text(&message) { - (diagnostics_for_uri_source(&uri, text.clone())?, Some(text)) - } else if let Some(text) = open_documents.get(&uri) { - ( - diagnostics_for_uri_source(&uri, text.clone())?, - Some(text.clone()), - ) - } else { - let path = path_from_file_uri(&uri); - ( - hemx_build::diagnostics_for_heml_file(&path)?, - std::fs::read_to_string(&path).ok(), - ) - }; - publish_lsp_diagnostics(writer, &uri, diagnostics, source.as_deref())?; - } - } - (None, "textDocument/didClose") => { - if let Some(uri) = text_document_uri(&message) { - open_documents.remove(&uri); - publish_lsp_diagnostics(writer, &uri, Vec::new(), None)?; - } - } - (None, "exit") => break, - (None, "initialized") | (None, "") => {} - (None, _) => {} - } - } - Ok(()) -} - -fn read_lsp_message(reader: &mut R) -> io::Result> { - let mut content_length = None; - let mut saw_header = false; - loop { - let mut line = String::new(); - let bytes = reader.read_line(&mut line)?; - if bytes == 0 { - return Ok(None); - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { - break; - } - saw_header = true; - if let Some((name, value)) = trimmed.split_once(':') { - if name.eq_ignore_ascii_case("content-length") { - content_length = Some(value.trim().parse::().map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("bad Content-Length: {err}"), - ) - })?); - } - } - } - if !saw_header { - return Ok(None); - } - let len = content_length.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header") - })?; - let mut body = vec![0; len]; - reader.read_exact(&mut body)?; - serde_json::from_slice(&body) - .map(Some) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) -} - -fn write_lsp_message(writer: &mut W, message: &serde_json::Value) -> io::Result<()> { - let body = serde_json::to_vec(message).expect("serialize LSP message"); - write!(writer, "Content-Length: {}\r\n\r\n", body.len())?; - writer.write_all(&body)?; - writer.flush() -} - -fn publish_lsp_diagnostics( - writer: &mut W, - uri: &str, - diagnostics: Vec, - source: Option<&str>, -) -> io::Result<()> { - write_lsp_message( - writer, - &serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": publish_diagnostics_payload(uri, &diagnostics, source), - }), - ) -} - -fn publish_diagnostics_payload( - uri: &str, - diagnostics: &[Diagnostic], - source: Option<&str>, -) -> serde_json::Value { - serde_json::json!({ - "uri": uri, - "diagnostics": diagnostics - .iter() - .map(|diagnostic| lsp_diagnostic(diagnostic, source)) - .collect::>() - }) -} - -fn lsp_diagnostic(diagnostic: &Diagnostic, source: Option<&str>) -> serde_json::Value { - serde_json::json!({ - "range": diagnostic_range(diagnostic, source), - "severity": diagnostic_lsp_severity(diagnostic.severity), - "source": "hemx-build", - "code": diagnostic_code(diagnostic.code), - "message": diagnostic.message, - "data": { - "file": diagnostic.file.display().to_string(), - "directive": diagnostic.directive, - "target": diagnostic.target, - "expected": diagnostic.expected, - "repair": diagnostic.repair, - } - }) -} - -fn did_open_document(message: &serde_json::Value) -> Option<(String, String)> { - let doc = message.get("params")?.get("textDocument")?; - Some(( - doc.get("uri")?.as_str()?.to_owned(), - doc.get("text")?.as_str()?.to_owned(), - )) -} - -fn did_change_document(message: &serde_json::Value) -> Option<(String, String)> { - let uri = text_document_uri(message)?; - let text = message - .get("params")? - .get("contentChanges")? - .as_array()? - .last()? - .get("text")? - .as_str()? - .to_owned(); - Some((uri, text)) -} - -fn did_save_text(message: &serde_json::Value) -> Option { - message - .get("params")? - .get("text")? - .as_str() - .map(str::to_owned) -} - -fn text_document_uri(message: &serde_json::Value) -> Option { - message - .get("params")? - .get("textDocument")? - .get("uri")? - .as_str() - .map(str::to_owned) -} - -fn text_document_position(message: &serde_json::Value) -> Option<(usize, usize)> { - let position = message.get("params")?.get("position")?; - Some(( - position.get("line")?.as_u64()? as usize, - position.get("character")?.as_u64()? as usize, - )) -} - -fn diagnostics_for_uri_source(uri: &str, source: String) -> io::Result> { - hemx_build::diagnostics_for_heml_source(path_from_file_uri(uri), source) -} - -fn canonical_path(path: &str) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) -} - -fn file_uri(path: &Path) -> String { - format!("file://{}", path.display()) -} - -fn path_from_file_uri(uri: &str) -> PathBuf { - PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri)) -} - -fn hemplate_completion_items( - uri: &str, - text: &str, - position: Option<(usize, usize)>, -) -> Vec { - let mut items = vec![ - completion_item( - "h-for", - "h-for=\"item in &self.items\"", - "Repeat children using a Rust-shaped iterator expression; add `h-key` when generated targets are inside the loop.", - ), - completion_item( - "h-key", - "h-key=\"item.id\"", - "Stable template key for generated targets inside `h-for` loops.", - ), - completion_item( - "h-if", - "h-if=\"self.ready\"", - "Render an element when a Rust-shaped condition is true.", - ), - completion_item( - "h-match", - "h-match=\"&self.state\"", - "Match a Rust-shaped value with child `h-case` arms.", - ), - completion_item( - "h-case", - "h-case=\"State::Ready(value)\"", - "Arm for `h-match`; use `h-case=\"_\"` for the default arm.", - ), - completion_item( - "+attr", - "+class=\"self.class_name()\"", - "Dynamic HTML attribute expression.", - ), - completion_item( - "{+ expr +}", - "{+ self.title +}", - "Escaped text expression.", - ), - completion_item( - "{+= expr =+}", - "{+= trusted_html =+}", - "Trusted/rendered HTML expression; prefer escaped `{+ expr +}` for user content.", - ), - completion_item( - "data-hemx-root", - "data-hemx-root=\"app\"", - "Generated hemx root for delegated runtime behavior.", - ), - completion_item( - "data-hemx-slot", - "data-hemx-slot=\"content\"", - "Generated partial target, e.g. `ui::content.replace(value)`.", - ), - completion_item( - "data-hemx-form", - "data-hemx-form=\"save\"", - "Generated form target wired through hemx build output.", - ), - completion_item( - "data-hemx-handle", - "data-hemx-handle=\"button\"", - "Generated handle target wired through hemx build output.", - ), - ]; - if let Ok(targets) = - hemx_build::generated_targets_for_heml_source(path_from_file_uri(uri), text) - { - for target in targets { - items.push(completion_item( - &format!("ui::{}", target.name), - &format!("ui::{}", target.name), - &format!( - "Generated hemx {} target discovered by hemx-build in the open `.heml` document.", - target.kind - ), - )); - } - } - - if let Ok(Some(facts)) = - hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text) - { - let prefix = position - .and_then(|position| line_prefix(text, position)) - .unwrap_or_default(); - if prefix.ends_with("self.") { - for field in &facts.self_fields { - items.push(field_completion_item(&field.name, &field.type_name, "self")); - } - } - for local in &facts.locals { - if prefix.ends_with(&format!("{}.", local.name)) - && position.is_some_and(|position| active_h_for_local(text, position, &local.name)) - { - for field in &local.fields { - items.push(field_completion_item( - &field.name, - &field.type_name, - &local.name, - )); - } - } - } - } - items -} - -fn field_completion_item(name: &str, type_name: &str, owner: &str) -> serde_json::Value { - serde_json::json!({ - "label": name, - "kind": 5, - "detail": format!("{owner}.{name}: {type_name}"), - "insertText": name, - "documentation": { - "kind": "markdown", - "value": format!("`{owner}.{name}: {type_name}`\n\nSource: hemx-build template context facts.") - } - }) -} - -fn completion_item(label: &str, insert_text: &str, detail: &str) -> serde_json::Value { - serde_json::json!({ - "label": label, - "kind": 10, - "detail": detail, - "insertText": insert_text, - "documentation": { - "kind": "markdown", - "value": format!("{detail}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`.") - } - }) -} - -fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> serde_json::Value { - if let Some((kind, name)) = position.and_then(|position| generated_target_at(text, position)) { - if let Ok(targets) = - hemx_build::generated_targets_for_heml_source(path_from_file_uri(uri), text) - { - if targets - .iter() - .any(|target| target.kind == kind && target.name == name) - { - return hover_markdown(&format!( - "Generated hemx `{kind}` target `{name}`.\n\nRust symbol: `ui::{name}`.\n\nSource: hemx-build facts for the current `.heml` document." - )); - } - } - } - - if let Some((owner, field)) = position.and_then(|position| dotted_name_at(text, position)) { - if let Ok(Some(facts)) = - hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text) - { - if owner == "self" { - if let Some(fact) = facts.self_fields.iter().find(|fact| fact.name == field) { - return hover_markdown(&format!( - "`self.{}: {}`\n\nSource: hemx-build template context facts for `{}`.", - fact.name, fact.type_name, facts.context_type - )); - } - } - if position.is_some_and(|position| active_h_for_local(text, position, &owner)) { - if let Some(local) = facts.locals.iter().find(|local| local.name == owner) { - if let Some(fact) = local.fields.iter().find(|fact| fact.name == field) { - return hover_markdown(&format!( - "`{}.{}: {}`\n\nSource: hemx-build `h-for` local fact from `{}`.", - local.name, fact.name, fact.type_name, local.type_name - )); - } - } - } - } - } - - let line = position - .and_then(|(line, _)| text.lines().nth(line)) - .unwrap_or_default(); - let character = position.map(|(_, character)| character).unwrap_or_default(); - let at = |token: &str| token_at(line, character, token); - let value = if at("data-hemx-root") { - Some("`data-hemx-root` declares the root where the hemx runtime scopes delegated handlers and generated resources.") - } else if at("data-hemx-slot") { - Some("`data-hemx-slot` declares a generated partial target. Inside `h-for`, add template `h-key`; compiler diagnostics come from `hemx-build`.") - } else if at("data-hemx-form") { - Some("`data-hemx-form` declares a generated form target wired through hemx build output.") - } else if at("data-hemx-handle") { - Some("`data-hemx-handle` declares a generated handle target wired through hemx build output.") - } else if at("h-key") { - Some("`h-key` is the stable template key used by generated targets inside `h-for` loops.") - } else if at("h-for") { - Some("`h-for` repeats children from a Rust-shaped iterator expression; generated targets inside the loop require `h-key`.") - } else if at("h-if") { - Some("`h-if` renders an element when a Rust-shaped condition is true.") - } else if at("h-match") || at("h-case") { - Some("`h-match`/`h-case` use Rust-shaped pattern arms; `h-case=\"_\"` is the default arm.") - } else if at("{+=") { - Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.") - } else if at("{+") { - Some("`{+ expr +}` inserts escaped text.") - } else if at("+") { - Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.") - } else { - None - }; - match value { - Some(value) => hover_markdown(&format!( - "{value}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`." - )), - None => serde_json::Value::Null, - } -} - -fn token_at(line: &str, character: usize, token: &str) -> bool { - line.match_indices(token) - .any(|(start, matched)| character >= start && character < start + matched.len()) -} - -fn hover_markdown(value: &str) -> serde_json::Value { - serde_json::json!({ - "contents": { - "kind": "markdown", - "value": value - } - }) -} - -fn line_prefix(text: &str, position: (usize, usize)) -> Option { - let line = text.lines().nth(position.0)?; - Some(line.chars().take(position.1).collect()) -} - -fn generated_target_at(text: &str, position: (usize, usize)) -> Option<(String, String)> { - let line = text.lines().nth(position.0)?; - let cursor = position.1.min(line.len()); - [ - ("root", "data-hemx-root=\""), - ("slot", "data-hemx-slot=\""), - ("form", "data-hemx-form=\""), - ("handle", "data-hemx-handle=\""), - ] - .into_iter() - .find_map(|(kind, prefix)| { - line.match_indices(prefix).find_map(|(attribute_start, _)| { - let value_start = attribute_start + prefix.len(); - let value_end = value_start + line[value_start..].find('"')?; - (cursor >= value_start && cursor <= value_end) - .then(|| (kind.to_owned(), line[value_start..value_end].to_owned())) - }) - }) -} - -fn dotted_name_at(text: &str, position: (usize, usize)) -> Option<(String, String)> { - let line = text.lines().nth(position.0)?; - let chars = line.chars().collect::>(); - let cursor = position.1.min(chars.len()); - let mut start = cursor; - while start > 0 && is_ident_or_dot(chars[start - 1]) { - start -= 1; - } - let mut end = cursor; - while end < chars.len() && is_ident_or_dot(chars[end]) { - end += 1; - } - let token = chars[start..end].iter().collect::(); - let (owner, field) = token.split_once('.')?; - if owner.is_empty() || field.is_empty() || field.contains('.') { - return None; - } - Some((owner.to_owned(), field.to_owned())) -} - -fn is_ident_or_dot(ch: char) -> bool { - ch == '_' || ch == '.' || ch.is_ascii_alphanumeric() -} - -fn active_h_for_local(text: &str, position: (usize, usize), local_name: &str) -> bool { - let Some(offset) = byte_offset_for_position(text, position) else { - return false; - }; - let mut search_from = 0; - while let Some(relative) = text[search_from..].find("h-for=\"") { - let attr_start = search_from + relative; - let value_start = attr_start + "h-for=\"".len(); - let Some(value_end) = text[value_start..].find('"').map(|end| value_start + end) else { - return false; - }; - if let Some((local, _)) = h_for_local_and_self_field(&text[value_start..value_end]) { - if local == local_name && h_for_attribute_scope_contains(text, attr_start, offset) { - return true; - } - } - search_from = value_end + 1; - } - false -} - -fn h_for_attribute_scope_contains(text: &str, attr_start: usize, offset: usize) -> bool { - let Some(tag_start) = text[..attr_start].rfind('<') else { - return false; - }; - if text[tag_start..].starts_with("').map(|end| attr_start + end) else { - return false; - }; - if offset < tag_start || offset < open_end { - return offset >= tag_start && offset <= open_end; - } - if text[..=open_end].ends_with("/>") { - return false; - } - let Some(tag_name) = tag_name_at(text, tag_start) else { - return false; - }; - let close = format!(""); - let Some(close_start) = text[open_end + 1..] - .find(&close) - .map(|relative| open_end + 1 + relative) - else { - return false; - }; - offset <= close_start + close.len() -} - -fn tag_name_at(text: &str, tag_start: usize) -> Option { - let rest = text[tag_start + 1..].trim_start(); - let name = rest - .chars() - .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == ':') - .collect::(); - (!name.is_empty()).then_some(name) -} - -fn byte_offset_for_position(text: &str, position: (usize, usize)) -> Option { - let mut offset = 0; - for (line_index, line) in text.split_inclusive('\n').enumerate() { - let line_without_newline = line.strip_suffix('\n').unwrap_or(line); - if line_index == position.0 { - let column_offset = line_without_newline - .char_indices() - .map(|(index, _)| index) - .chain(std::iter::once(line_without_newline.len())) - .nth(position.1)?; - return Some(offset + column_offset); - } - offset += line.len(); - } - if position.0 == text.lines().count() { - return Some(text.len()); - } - None -} - -fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { - let (local, expr) = value.split_once(" in ")?; - let local = local.trim(); - if local.is_empty() || local.contains(['(', ',', ' ']) { - return None; - } - let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); - let field = expr - .strip_prefix("self.")? - .split(['.', '(', '[']) - .next()? - .trim(); - (!field.is_empty()).then(|| (local.to_owned(), field.to_owned())) -} - -fn diagnostic_code(code: DiagnosticCode) -> &'static str { - match code { - DiagnosticCode::UnkeyedGeneratedTarget => "unkeyed-generated-target", - } -} - -fn diagnostic_range(diagnostic: &Diagnostic, source: Option<&str>) -> serde_json::Value { - let Some(source) = source else { - return zero_width_range(0, 0); - }; - let needle = format!("{}=\"{}\"", diagnostic.directive, diagnostic.target); - source - .find(&needle) - .map(|start| { - let end = start + needle.len(); - serde_json::json!({ - "start": source_position(source, start), - "end": source_position(source, end), - }) - }) - .unwrap_or_else(|| zero_width_range(0, 0)) -} - -fn source_position(source: &str, byte_offset: usize) -> serde_json::Value { - let mut line = 0_usize; - let mut line_start = 0_usize; - for (index, byte) in source.bytes().enumerate() { - if index >= byte_offset { - break; - } - if byte == b'\n' { - line += 1; - line_start = index + 1; - } - } - let character = source[line_start..byte_offset].encode_utf16().count(); - serde_json::json!({ "line": line, "character": character }) -} - -fn zero_width_range(line: usize, character: usize) -> serde_json::Value { - serde_json::json!({ - "start": { "line": line, "character": character }, - "end": { "line": line, "character": character } - }) -} - -fn diagnostic_lsp_severity(severity: DiagnosticSeverity) -> u8 { - match severity { - DiagnosticSeverity::Error => 1, - } -} - -#[cfg(test)] -mod tests { - use super::{ - diagnostics_for_uri_source, file_uri, hemplate_completion_items, hemplate_hover, - lsp_diagnostic, serve_lsp, - }; - - #[test] - fn completion_and_hover_use_template_context_facts() { - // req: diagnostics/006 - let dir = - std::env::temp_dir().join(format!("hemx-lsp-context-facts-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("src")).expect("create src dir"); - std::fs::write( - dir.join("Cargo.toml"), - "[package]\nname = \"facts\"\nversion = \"0.1.0\"\n", - ) - .expect("write manifest"); - std::fs::write( - dir.join("src/lib.rs"), - r#" - pub struct ExercisePlan { pub name: String, pub kg: f32 } - #[derive(Hemplate)] - pub struct Workout { pub plan: Vec, pub progress: String } - "#, - ) - .expect("write lib"); - let template = dir.join("workout.heml"); - let text = r#"

    {+ self. +}

  • {+ exercise. +}{+ exercise.name +}
  • {+ self.progress +} {+ exercise. +} {+ exercise.name +}

    "#; - std::fs::write(&template, text).expect("write heml"); - let uri = format!("file://{}", template.display()); - - let self_items = hemplate_completion_items( - &uri, - text, - Some((0, text.find("self.").unwrap() + "self.".len())), - ); - assert!(self_items - .iter() - .any(|item| item["label"] == "progress" && item["detail"] == "self.progress: String")); - - let exercise_cursor = text.find("exercise. +").unwrap() + "exercise.".len(); - let exercise_items = hemplate_completion_items(&uri, text, Some((0, exercise_cursor))); - assert!(exercise_items - .iter() - .any(|item| item["label"] == "name" && item["detail"] == "exercise.name: String")); - - let progress_hover = - hemplate_hover(&uri, text, Some((0, text.find("progress").unwrap() + 1))); - assert!(progress_hover.to_string().contains("self.progress: String")); - - let inside_name = text.find("exercise.name").unwrap() + "exercise.".len() + 1; - let name_hover = hemplate_hover(&uri, text, Some((0, inside_name))); - assert!(name_hover.to_string().contains("exercise.name: String")); - - let outside_cursor = text.rfind("exercise. +").unwrap() + "exercise.".len(); - let outside_items = hemplate_completion_items(&uri, text, Some((0, outside_cursor))); - assert!(!outside_items - .iter() - .any(|item| item["detail"] == "exercise.name: String")); - - let outside_hover = hemplate_hover(&uri, text, Some((0, text.rfind("name").unwrap() + 1))); - assert!(!outside_hover.to_string().contains("exercise.name: String")); - } - - #[test] - fn completion_items_cover_documented_hemplate_surface() { - // req: diagnostics/006 - let text = r#"
    "#; - let items = hemplate_completion_items("file:///tmp/app.heml", text, Some((0, 6))); - let labels = items - .iter() - .filter_map(|item| item["label"].as_str()) - .collect::>(); - for label in [ - "h-for", - "h-key", - "h-if", - "h-match", - "h-case", - "+attr", - "{+ expr +}", - "{+= expr =+}", - "data-hemx-root", - "data-hemx-slot", - "data-hemx-form", - "data-hemx-handle", - ] { - assert!(labels.contains(label), "missing completion item `{label}`"); - } - assert!(items.iter().all(|item| item["documentation"]["value"] - .as_str() - .unwrap_or_default() - .contains("docs/hemplate-syntax.md"))); - } - - #[test] - fn lsp_completion_returns_documented_items() { - // req: diagnostics/006 - let text = r#"
    {+ self.title +}
    "#; - let uri = "file:///tmp/completion.heml"; - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": {"textDocument": {"uri": uri, "languageId": "heml", "version": 1, "text": text}} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "textDocument/completion", - "params": {"textDocument": {"uri": uri}, "position": {"line": 0, "character": 6}} - })), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let messages = lsp_messages(&output); - let completion = messages - .iter() - .find(|message| message["id"] == 2) - .expect("completion response"); - let completion_items = completion["result"] - .as_array() - .or_else(|| completion["result"]["items"].as_array()) - .expect("completion items"); - let labels = completion_items - .iter() - .filter_map(|item| item["label"].as_str()) - .collect::>(); - assert!(labels.contains("h-if")); - assert!(labels.contains("data-hemx-root")); - assert!(labels.contains("data-hemx-slot")); - assert!(labels.contains("{+ expr +}")); - } - - #[test] - fn hover_items_cover_documented_hemplate_surface() { - // req: diagnostics/007 req: diagnostics/008 - let text = r#"
    "#; - let cases = [ - ("data-hemx-root", "runtime scopes delegated handlers"), - ("h-for", "repeats children"), - ("h-key", "stable template key"), - ("+class", "dynamic HTML attribute"), - ("{+ self.title +}", "escaped text"), - ("{+= trusted =+}", "trusted/rendered HTML"), - ]; - for (needle, expected) in cases { - let character = text.find(needle).expect("fixture needle"); - let hover = hemplate_hover("file:///tmp/hover.heml", text, Some((0, character))); - let value = hover_markdown_value(&hover); - assert!( - value.contains(expected), - "hover for `{needle}` should mention `{expected}`, got {value:?}" - ); - assert!( - value.contains("docs/hemplate-syntax.md"), - "hover for `{needle}` should cite syntax docs, got {value:?}" - ); - } - assert_eq!( - hemplate_hover("file:///tmp/hover.heml", text, Some((0, 0))), - serde_json::Value::Null, - "ordinary HTML text is left to normal editor tooling" - ); - } - - #[test] - fn lsp_hover_returns_documented_markdown() { - // req: diagnostics/007 req: diagnostics/008 - let text = r#"
    "#; - let uri = "file:///tmp/hover.heml"; - let character = text.find("h-key").expect("h-key position"); - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": {"textDocument": {"uri": uri, "languageId": "heml", "version": 1, "text": text}} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "textDocument/hover", - "params": {"textDocument": {"uri": uri}, "position": {"line": 0, "character": character}} - })), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let messages = lsp_messages(&output); - let hover = messages - .iter() - .find(|message| message["id"] == 2) - .expect("hover response"); - let value = hover_markdown_value(&hover["result"]); - assert!(value.contains("stable template key")); - assert!(value.contains("docs/hemplate-syntax.md")); - } - - #[test] - fn hemplate_highlighting_fixture_covers_documented_overlay_tokens() { - // req: diagnostics/004 req: diagnostics/008 - let fixture = include_str!("../../docs/fixtures/hemplate-highlighting/hemplate.heml"); - let captures = include_str!("../../docs/fixtures/hemplate-highlighting/captures.tsv"); - let allowed_captures = [ - "@attribute.hemx", - "@attribute.dynamic.hemplate", - "@keyword.control.hemplate", - "@punctuation.special.hemplate.escaped.open", - "@punctuation.special.hemplate.escaped.close", - "@punctuation.special.hemplate.trusted.open", - "@punctuation.special.hemplate.trusted.close", - "@embedded.rust.hemplate", - ] - .into_iter() - .collect::>(); - let mut seen = std::collections::BTreeSet::new(); - for (index, line) in captures.lines().enumerate().skip(1) { - let (capture, literal) = line - .split_once('\t') - .unwrap_or_else(|| panic!("capture row {index} must be TSV")); - assert!( - allowed_captures.contains(capture), - "unexpected capture `{capture}` in row {index}" - ); - assert!( - fixture.contains(literal), - "highlight fixture missing literal `{literal}` for capture `{capture}`" - ); - seen.insert(capture); - } - assert_eq!( - seen, allowed_captures, - "highlight fixture should exercise every documented overlay capture class" - ); - } - - #[test] - fn workout_template_fields_are_available_from_repo_facts() { - // req: diagnostics/006 - let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap(); - let template = repo.join("examples/workout/templates/workout.heml"); - let text = std::fs::read_to_string(&template).expect("workout template"); - let uri = format!("file://{}", template.display()); - - let self_line = text - .lines() - .position(|line| line.contains("self.progress")) - .expect("self expression line"); - let self_column = - text.lines().nth(self_line).unwrap().find("self.").unwrap() + "self.".len(); - let self_items = hemplate_completion_items(&uri, &text, Some((self_line, self_column))); - assert!(self_items.iter().any(|item| item["label"] == "progress")); - - let exercise_line = text - .lines() - .position(|line| line.contains("exercise.name")) - .expect("exercise local line"); - let exercise_column = text - .lines() - .nth(exercise_line) - .unwrap() - .find("exercise.") - .unwrap() - + "exercise.".len(); - let exercise_items = - hemplate_completion_items(&uri, &text, Some((exercise_line, exercise_column))); - assert!(exercise_items - .iter() - .any(|item| item["detail"] == "exercise.name: &'static str")); - - let hover = hemplate_hover(&uri, &text, Some((exercise_line, exercise_column + 1))); - assert!(hover.to_string().contains("exercise.name: &'static str")); - } - - #[test] - fn lsp_protocol_initialize_shutdown_and_unknown_requests_are_framed() { - // req: diagnostics/008 - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "hemx/unknown", "params": {}})), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown", "params": null})), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let messages = lsp_messages(&output); - assert_eq!( - messages.len(), - 3, - "initialize, unknown request, shutdown responses" - ); - assert_eq!(messages[0]["id"], 1); - assert_eq!( - messages[0]["result"]["capabilities"]["textDocumentSync"]["openClose"], - true - ); - assert_eq!( - messages[0]["result"]["capabilities"]["completionProvider"]["triggerCharacters"][0], - "h" - ); - assert_eq!(messages[0]["result"]["capabilities"]["hoverProvider"], true); - assert_eq!(messages[1]["id"], 2); - assert_eq!(messages[1]["error"]["code"], -32601); - assert!(messages[1]["error"]["message"] - .as_str() - .unwrap_or_default() - .contains("hemx/unknown")); - assert_eq!( - messages[2], - serde_json::json!({"jsonrpc": "2.0", "id": 3, "result": null}) - ); - } - - #[test] - fn hover_reports_generated_target_kind_and_rust_symbol() { - // req: diag/010 - let uri = "file:///tmp/workout.heml"; - let text = r#"
    "#; - let cursor = text.find("progress_panel").expect("fixture target") + 2; - - let hover = hemplate_hover(uri, text, Some((0, cursor))).to_string(); - - assert!(hover.contains("`slot` target `progress_panel`"), "{hover}"); - assert!(hover.contains("`ui::progress_panel`"), "{hover}"); - } - - #[test] - fn source_positions_use_lsp_utf16_columns() { - assert_eq!( - super::source_position("🙂 data-hemx-slot=\"row\"", "🙂 ".len()), - serde_json::json!({ "line": 0, "character": 3 }) - ); - } - - #[test] - fn lsp_publishes_and_clears_build_equivalent_diagnostics() { - // req: diagnostics/004 req: diagnostics/005 - let uri = "file:///tmp/todo.heml"; - let invalid = r#"
    "#; - let valid = r#"
    "#; - let expected = - diagnostics_for_uri_source(uri, invalid.to_string()).expect("build diagnostics"); - assert!( - !expected.is_empty(), - "fixture should exercise build diagnostics" - ); - assert!( - diagnostics_for_uri_source(uri, valid.to_string()) - .expect("valid diagnostics") - .is_empty(), - "valid fixture should clear diagnostics" - ); - - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": {"textDocument": {"uri": uri, "languageId": "heml", "version": 1, "text": invalid}} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didChange", - "params": {"textDocument": {"uri": uri, "version": 2}, "contentChanges": [{"text": valid}]} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didSave", - "params": {"textDocument": {"uri": uri}, "text": invalid} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didClose", - "params": {"textDocument": {"uri": uri}} - })), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let messages = lsp_messages(&output); - let published = messages - .iter() - .filter(|message| message["method"] == "textDocument/publishDiagnostics") - .collect::>(); - assert_eq!(published.len(), 4, "open/change/save/close should publish"); - assert_eq!(published[0]["params"]["uri"], uri); - let offending_attribute = "data-hemx-slot=\"todo_row\""; - let attribute_start = invalid - .find(offending_attribute) - .expect("fixture attribute"); - assert_eq!( - published[0]["params"]["diagnostics"][0]["range"], - serde_json::json!({ - "start": { "line": 0, "character": attribute_start }, - "end": { "line": 0, "character": attribute_start + offending_attribute.len() } - }), - "diagnostic must select the offending generated target" - ); // req: diag/009 - assert_eq!( - published[0]["params"]["diagnostics"], - serde_json::to_value( - expected - .iter() - .map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid))) - .collect::>() - ) - .unwrap(), - "didOpen diagnostics should match build diagnostics" - ); - assert_eq!( - published[1]["params"]["diagnostics"] - .as_array() - .unwrap() - .len(), - 0, - "didChange should clear fixed diagnostics" - ); - assert_eq!( - published[2]["params"]["diagnostics"], published[0]["params"]["diagnostics"], - "didSave text diagnostics should match didOpen/build diagnostics" - ); - assert_eq!( - published[3]["params"]["diagnostics"] - .as_array() - .unwrap() - .len(), - 0, - "didClose should clear diagnostics" - ); - } - - #[test] - fn did_save_without_text_reads_file_when_document_is_not_open() { - // req: diagnostics/004 req: diagnostics/005 - let path = std::env::temp_dir().join(format!( - "hemx-lsp-save-{}-{}.heml", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )); - let invalid = r#"
    "#; - std::fs::write(&path, invalid).expect("write temp heml fixture"); - let uri = file_uri(&path); - let expected = hemx_build::diagnostics_for_heml_file(&path).expect("file diagnostics"); - assert!( - !expected.is_empty(), - "file fixture should produce diagnostics" - ); - - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didSave", - "params": {"textDocument": {"uri": uri}} - })), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let messages = lsp_messages(&output); - let published = messages - .iter() - .find(|message| message["method"] == "textDocument/publishDiagnostics") - .expect("publish diagnostics"); - assert_eq!(published["params"]["uri"], uri); - assert_eq!( - published["params"]["diagnostics"], - serde_json::to_value( - expected - .iter() - .map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid))) - .collect::>() - ) - .unwrap(), - "didSave without text/open document should match file diagnostics" - ); - std::fs::remove_file(path).ok(); - } - - #[test] - fn lsp_serves_compiler_diagnostics_and_completion() { - // req: diagnostics/004 req: diagnostics/005 - let bad_heml = r#"
    "#; - let input = [ - lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": {"textDocument": {"uri": "file:///tmp/todo.heml", "languageId": "heml", "version": 1, "text": bad_heml}} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "textDocument/completion", - "params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 1}} - })), - lsp_message(serde_json::json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "textDocument/hover", - "params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 80}} - })), - lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), - ] - .join(""); - let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut output = Vec::new(); - - serve_lsp(&mut reader, &mut output).expect("serve LSP"); - let output = String::from_utf8(output).expect("utf8 output"); - - assert!(output.contains("completionProvider")); - assert!(output.contains("hoverProvider")); - assert!(output.contains("textDocument/publishDiagnostics")); - assert!(output.contains("unkeyed-generated-target")); - assert!(output.contains("add h-key")); - assert!(output.contains("ui::todo_row")); - assert!(output.contains("docs/hemplate-syntax.md")); - } - - fn hover_markdown_value(hover: &serde_json::Value) -> String { - hover["contents"]["value"] - .as_str() - .unwrap_or_default() - .to_string() - } - - fn lsp_messages(output: &[u8]) -> Vec { - let mut bytes = output; - let mut messages = Vec::new(); - while !bytes.is_empty() { - let header_end = bytes - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("LSP header terminator"); - let header = std::str::from_utf8(&bytes[..header_end]).expect("utf8 header"); - let length = header - .lines() - .find_map(|line| line.strip_prefix("Content-Length: ")) - .expect("content length") - .parse::() - .expect("numeric content length"); - let body_start = header_end + 4; - let body_end = body_start + length; - messages.push(serde_json::from_slice(&bytes[body_start..body_end]).expect("LSP json")); - bytes = &bytes[body_end..]; - } - messages - } - - fn lsp_message(message: serde_json::Value) -> String { - let body = serde_json::to_string(&message).expect("lsp json"); - format!("Content-Length: {}\r\n\r\n{}", body.len(), body) - } -} diff --git a/hemx-sync-macros/Cargo.toml b/hemx-sync-macros/Cargo.toml deleted file mode 100644 index cb337cd..0000000 --- a/hemx-sync-macros/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "hemx-sync-macros" -version.workspace = true -edition.workspace = true - -[lib] -proc-macro = true - -[dependencies] -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } diff --git a/hemx-sync-macros/src/lib.rs b/hemx-sync-macros/src/lib.rs deleted file mode 100644 index e1a7901..0000000 --- a/hemx-sync-macros/src/lib.rs +++ /dev/null @@ -1,150 +0,0 @@ -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Error, FnArg, ItemFn, Pat, ReturnType}; - -#[proc_macro_attribute] -pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream { - expand_presence(attributes.into(), item.into()) - .unwrap_or_else(Error::into_compile_error) - .into() -} - -fn expand_presence(attributes: TokenStream2, item: TokenStream2) -> syn::Result { - if !attributes.is_empty() { - return Err(Error::new( - proc_macro2::Span::call_site(), - "#[hemx_sync::presence] does not accept arguments", - )); - } - - let mut function: ItemFn = syn::parse2(item)?; - if let Some(asyncness) = &function.sig.asyncness { - return Err(Error::new_spanned( - asyncness, - "presence projections must be synchronous", - )); - } - if matches!(function.sig.output, ReturnType::Default) { - return Err(Error::new_spanned( - &function.sig, - "presence projections must return impl IntoEffect", - )); - } - let argument = match function.sig.inputs.first() { - Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument, - _ => { - return Err(Error::new_spanned( - &function.sig.inputs, - "presence projections require exactly one typed presence argument", - )); - } - }; - let argument_name = match argument.pat.as_ref() { - Pat::Ident(argument) => argument.ident.clone(), - pattern => { - return Err(Error::new_spanned( - pattern, - "presence projection argument must be a simple identifier", - )); - } - }; - - let body = function.block; - function.sig.output = syn::parse_quote!(-> impl ::hemx_sync::PresenceUpdate); - function.block = Box::new(syn::parse_quote!({ - let __hemx_sync_channel = - ::hemx_sync::PresenceScope::presence_channel(&#argument_name); - let __hemx_sync_effect = (|| #body)(); - ::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect) - })); - Ok(quote!(#function)) -} - -#[cfg(test)] -mod tests { - use super::expand_presence; - use quote::quote; - - #[test] - fn presence_expansion_enforces_the_typed_projection_contract() { - assert!(expand_presence(quote!(), quote!(not a function)).is_err()); - - for (attributes, item, expected) in [ - ( - quote!(unexpected), - quote!( - fn project(scope: Scope) -> Effect { - effect() - } - ), - "#[hemx_sync::presence] does not accept arguments", - ), - ( - quote!(), - quote!( - async fn project(scope: Scope) -> Effect { - effect() - } - ), - "presence projections must be synchronous", - ), - ( - quote!(), - quote!( - fn project(scope: Scope) {} - ), - "presence projections must return impl IntoEffect", - ), - ( - quote!(), - quote!( - fn project() -> Effect { - effect() - } - ), - "presence projections require exactly one typed presence argument", - ), - ( - quote!(), - quote!( - fn project(a: Scope, b: Scope) -> Effect { - effect() - } - ), - "presence projections require exactly one typed presence argument", - ), - ( - quote!(), - quote!( - fn project((scope,): (Scope,)) -> Effect { - effect() - } - ), - "presence projection argument must be a simple identifier", - ), - ] { - assert_eq!( - expand_presence(attributes, item).unwrap_err().to_string(), - expected - ); - } - - let expanded = expand_presence( - quote!(), - quote!( - pub fn project(scope: Scope) -> Effect { - effect(scope) - } - ), - ) - .unwrap() - .to_string(); - assert!(expanded.contains("pub fn project")); - assert!(expanded.contains("impl :: hemx_sync :: PresenceUpdate")); - assert!(expanded.contains("PresenceScope :: presence_channel (& scope)")); - assert!(expanded.contains("PresenceProjection :: new")); - assert!(expanded.contains("effect (scope)")); - // test req: sync/003 req: sync/005 - } -} diff --git a/hemx-sync-macros/tests/presence.rs b/hemx-sync-macros/tests/presence.rs deleted file mode 100644 index 20be2b2..0000000 --- a/hemx-sync-macros/tests/presence.rs +++ /dev/null @@ -1,62 +0,0 @@ -extern crate self as hemx_sync; - -use hemx_sync_macros::presence; - -pub trait IntoEffect {} - -impl IntoEffect for &'static str {} - -pub trait PresenceScope { - fn presence_channel(&self) -> String; -} - -pub trait PresenceUpdate { - fn channel(&self) -> &str; - fn effect(&self) -> &str; -} - -pub struct PresenceProjection { - channel: String, - effect: Effect, -} - -impl PresenceProjection { - pub fn new(channel: String, effect: Effect) -> Self { - Self { channel, effect } - } -} - -impl PresenceUpdate for PresenceProjection<&'static str> { - fn channel(&self) -> &str { - &self.channel - } - - fn effect(&self) -> &str { - self.effect - } -} - -struct Signal(&'static str); - -impl PresenceScope for Signal { - fn presence_channel(&self) -> String { - self.0.to_owned() - } -} - -#[presence] -fn project(signal: Signal) -> impl IntoEffect { - if signal.0 == "board" { - "joined" - } else { - "left" - } -} - -#[test] -fn public_presence_attribute_preserves_the_item_and_expands_it() { - let update = project(Signal("board")); - assert_eq!(update.channel(), "board"); - assert_eq!(update.effect(), "joined"); - // test req: sync/003 req: sync/005 -} diff --git a/hemx-sync/Cargo.toml b/hemx-sync/Cargo.toml deleted file mode 100644 index 3d14898..0000000 --- a/hemx-sync/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "hemx-sync" -version.workspace = true -edition.workspace = true - -[dependencies] -hemx-core = { path = "../hemx-core" } -hemx-sync-macros = { path = "../hemx-sync-macros" } -serde = { version = "1", features = ["derive"] } - -[dev-dependencies] -serde_json = "1" diff --git a/hemx-sync/runtime/hemx-sync.js b/hemx-sync/runtime/hemx-sync.js deleted file mode 100644 index 3289592..0000000 --- a/hemx-sync/runtime/hemx-sync.js +++ /dev/null @@ -1,180 +0,0 @@ -const DATABASE = "hemx-sync-v1"; -const STORE = "patches"; -const SCHEMA_VERSION = 1; -const EVENT = "hemx:sync-patch"; -const root = document.querySelector("[data-hemx-root]"); -let database; -let pumping = false; - -function requestResult(request) { - return new Promise((resolve, reject) => { - request.addEventListener("success", () => resolve(request.result), { once: true }); - request.addEventListener("error", () => reject(request.error), { once: true }); - }); -} - -function transactionDone(transaction) { - return new Promise((resolve, reject) => { - transaction.addEventListener("complete", resolve, { once: true }); - transaction.addEventListener("abort", () => reject(transaction.error), { once: true }); - transaction.addEventListener("error", () => reject(transaction.error), { once: true }); - }); -} - -async function openDatabase() { - const request = indexedDB.open(DATABASE, 1); - request.addEventListener("upgradeneeded", () => { - if (!request.result.objectStoreNames.contains(STORE)) { - request.result.createObjectStore(STORE, { keyPath: "idempotencyKey" }); - } - }); - return requestResult(request); -} - -function validIdentifier(value) { - return typeof value === "string" && value.length > 0 && value.length <= 128 && /^[A-Za-z0-9:_.-]+$/.test(value); -} - -function validKey(value) { - return typeof value === "string" - && value.length > 0 - && value.length <= 64 - && /^[A-Za-z][A-Za-z0-9_-]*$/.test(value) - && !["schemaVersion", "idempotencyKey", "operationId", "key", "value"].includes(value); -} - -function validatePatch(patch) { - if (!patch || Object.getPrototypeOf(patch) !== Object.prototype) throw new Error("patch must be an object"); - const keys = Object.keys(patch).sort(); - const expected = ["idempotencyKey", "key", "operationId", "schemaVersion", "value"]; - if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { - throw new Error("patch fields do not match schema"); - } - if (patch.schemaVersion !== SCHEMA_VERSION) throw new Error(`unsupported patch schema version ${patch.schemaVersion}`); - if (!validIdentifier(patch.idempotencyKey)) throw new Error("invalid idempotencyKey"); - if (!validIdentifier(patch.operationId)) throw new Error("invalid operationId"); - if (!validKey(patch.key)) throw new Error("invalid patch key"); - if (!["string", "number", "boolean"].includes(typeof patch.value) - || (typeof patch.value === "number" && !Number.isSafeInteger(patch.value)) - || (typeof patch.value === "string" && patch.value.length > 4096)) { - throw new Error("invalid patch value"); - } - 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); - const patches = await requestResult(transaction.objectStore(STORE).getAll()); - await done; - return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey)); -} - -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({ ...record, queuedAt: Date.now() }); - await done; - root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length)); -} - -async function remove(idempotencyKey) { - const transaction = database.transaction(STORE, "readwrite"); - const done = transactionDone(transaction); - transaction.objectStore(STORE).delete(idempotencyKey); - await done; -} - -async function pump() { - if (pumping || !navigator.onLine) return; - pumping = true; - try { - for (const stored of await allPatches()) { - const { patch } = normalizeStoredRecord(stored); - const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches"; - const response = await fetch(endpoint, { - method: "POST", - credentials: "same-origin", - headers: { "content-type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!response.ok) { - root?.setAttribute("data-hemx-sync-error", `upload-${response.status}`); - return; - } - const acknowledgement = await response.json(); - if (acknowledgement.idempotencyKey !== patch.idempotencyKey - || acknowledgement.operationId !== patch.operationId) { - root?.setAttribute("data-hemx-sync-error", "acknowledgement-mismatch"); - return; - } - await remove(patch.idempotencyKey); - root?.setAttribute("data-hemx-sync-ack", acknowledgement.idempotencyKey); - } - root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length)); - } catch { - root?.setAttribute("data-hemx-sync-error", "offline"); - } finally { - pumping = false; - } -} - -async function start() { - if (!root) return; - database = await openDatabase(); - 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 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(); -} - -start().catch((error) => root?.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error))); diff --git a/hemx-sync/src/lib.rs b/hemx-sync/src/lib.rs deleted file mode 100644 index 8bbe6b6..0000000 --- a/hemx-sync/src/lib.rs +++ /dev/null @@ -1,825 +0,0 @@ -use hemx_core::{Atom, BuildFingerprint, Effect, EffectBatch, IntoEffect}; -use serde::{de, Deserialize, Deserializer, Serialize}; -use std::{ - collections::{HashMap, HashSet}, - error::Error, - fmt, - hash::Hash, -}; - -extern crate self as hemx_sync; -pub use hemx_sync_macros::presence; - -pub const PATCH_SCHEMA_VERSION: u16 = 1; -pub const PATCH_EVENT: &str = "hemx:sync-patch"; -pub const ACK_EVENT: &str = "hemx:sync-ack"; -const INTERACTION_ID: &str = "$hemx-interaction"; -pub const BROWSER_RUNTIME: &str = include_str!("../runtime/hemx-sync.js"); - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct Channel(String); - -impl Channel { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() { - return Err(ChannelError::Empty); - } - if value.len() > 128 { - return Err(ChannelError::TooLong); - } - if !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.')) - { - return Err(ChannelError::InvalidCharacter); - } - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ChannelError { - Empty, - TooLong, - InvalidCharacter, -} - -impl fmt::Display for ChannelError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => formatter.write_str("sync channel must not be empty"), - Self::TooLong => formatter.write_str("sync channel is too long"), - Self::InvalidCharacter => { - formatter.write_str("sync channel contains an invalid character") - } - } - } -} - -impl Error for ChannelError {} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PresenceChange { - pub changed: bool, - pub count: usize, -} - -#[derive(Clone, Debug)] -pub struct PresenceTracker { - members: HashMap>, -} - -impl Default for PresenceTracker { - fn default() -> Self { - Self { - members: HashMap::new(), - } - } -} - -impl PresenceTracker -where - Member: Eq + Hash, -{ - pub fn join(&mut self, channel: Channel, member: Member) -> PresenceChange { - let members = self.members.entry(channel).or_default(); - PresenceChange { - changed: members.insert(member), - count: members.len(), - } - } - - pub fn leave(&mut self, channel: &Channel, member: &Member) -> PresenceChange { - let Some(members) = self.members.get_mut(channel) else { - return PresenceChange { - changed: false, - count: 0, - }; - }; - let changed = members.remove(member); - let count = members.len(); - if members.is_empty() { - self.members.remove(channel); - } - PresenceChange { changed, count } - } - - pub fn count(&self, channel: &Channel) -> usize { - self.members.get(channel).map_or(0, HashSet::len) - } -} - -pub trait PresenceScope { - fn presence_channel(&self) -> Channel; -} - -pub struct PresenceProjection { - channel: Channel, - effect: Effect, -} - -impl PresenceProjection { - pub fn new(channel: Channel, effect: Effect) -> Self { - Self { channel, effect } - } -} - -pub trait PresenceUpdate: IntoEffect + Sized { - fn presence_channel(&self) -> &Channel; - fn into_broadcast(self, fingerprint: hemx_core::BuildFingerprint) -> Broadcast; -} - -impl PresenceUpdate for PresenceProjection -where - Effect: IntoEffect, -{ - fn presence_channel(&self) -> &Channel { - &self.channel - } - - fn into_broadcast(self, fingerprint: hemx_core::BuildFingerprint) -> Broadcast { - SyncEffect::broadcast(self.channel, self.effect.into_batch(fingerprint)) - } -} - -impl IntoEffect for PresenceProjection -where - Effect: IntoEffect, -{ - fn append_to(self, ops: &mut Vec) { - self.effect.append_to(ops); - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Broadcast { - channel: Channel, - effect_batch: EffectBatch, -} - -impl Broadcast { - pub fn channel(&self) -> &Channel { - &self.channel - } - - pub fn effect_batch(&self) -> &EffectBatch { - &self.effect_batch - } - - pub fn into_parts(self) -> (Channel, EffectBatch) { - (self.channel, self.effect_batch) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PatchValue { - Boolean(bool), - Integer(i64), - String(String), -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FlatPatch { - // req: sync/003 - schema_version: u16, - idempotency_key: String, - operation_id: String, - key: String, - value: PatchValue, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct FlatPatchWire { - schema_version: u16, - idempotency_key: String, - operation_id: String, - key: String, - value: PatchValue, -} - -impl<'de> Deserialize<'de> for FlatPatch { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let wire = FlatPatchWire::deserialize(deserializer)?; - let patch = Self { - schema_version: wire.schema_version, - idempotency_key: wire.idempotency_key, - operation_id: wire.operation_id, - key: wire.key, - value: wire.value, - }; - patch.validate().map_err(de::Error::custom)?; - Ok(patch) - } -} - -impl FlatPatch { - pub fn for_interaction(key: impl Into, value: PatchValue) -> Result { - let key = key.into(); - validate_key(&key)?; - validate_value(&value)?; - Ok(Self { - schema_version: PATCH_SCHEMA_VERSION, - idempotency_key: INTERACTION_ID.to_owned(), - operation_id: INTERACTION_ID.to_owned(), - key, - value, - }) - } - - pub fn new( - idempotency_key: impl Into, - operation_id: impl Into, - key: impl Into, - value: PatchValue, - ) -> Result { - let patch = Self { - schema_version: PATCH_SCHEMA_VERSION, - idempotency_key: idempotency_key.into(), - operation_id: operation_id.into(), - key: key.into(), - value, - }; - patch.validate()?; - Ok(patch) - } - - pub fn payload(&self) -> String { - let value = match &self.value { - PatchValue::Boolean(value) => value.to_string(), - PatchValue::Integer(value) => value.to_string(), - PatchValue::String(value) => json_string(value), - }; - format!( - r#"{{"schemaVersion":{},"idempotencyKey":{},"operationId":{},"key":{},"value":{value}}}"#, - self.schema_version, - json_string(&self.idempotency_key), - json_string(&self.operation_id), - json_string(&self.key), - ) - } - - pub fn validate(&self) -> Result<(), PatchError> { - if self.schema_version != PATCH_SCHEMA_VERSION { - return Err(PatchError::SchemaVersion(self.schema_version)); - } - if self.idempotency_key != INTERACTION_ID || self.operation_id != INTERACTION_ID { - validate_identifier("idempotency_key", &self.idempotency_key, 128)?; - validate_identifier("operation_id", &self.operation_id, 128)?; - } - validate_key(&self.key)?; - validate_value(&self.value) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum PatchError { - Empty(&'static str), - InvalidCharacter(&'static str), - TooLong(&'static str), - ReservedKey, - SchemaVersion(u16), - ValueTooLong, - IntegerOutOfRange, -} - -impl fmt::Display for PatchError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty(field) => write!(formatter, "{field} must not be empty"), - Self::InvalidCharacter(field) => { - write!(formatter, "{field} contains an invalid character") - } - Self::TooLong(field) => write!(formatter, "{field} is too long"), - Self::ReservedKey => formatter.write_str("patch key is reserved"), - Self::SchemaVersion(version) => { - write!(formatter, "unsupported patch schema version {version}") - } - Self::ValueTooLong => formatter.write_str("patch string value is too long"), - Self::IntegerOutOfRange => { - formatter.write_str("patch integer value exceeds JavaScript's safe range") - } - } - } -} - -impl Error for PatchError {} - -fn json_string(value: &str) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(value.len() + 2); - encoded.push('"'); - for character in value.chars() { - match character { - '"' => encoded.push_str("\\\""), - '\\' => encoded.push_str("\\\\"), - '\n' => encoded.push_str("\\n"), - '\r' => encoded.push_str("\\r"), - '\t' => encoded.push_str("\\t"), - character if character <= '\u{1f}' => { - let byte = character as u8; - encoded.push_str("\\u00"); - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - character => encoded.push(character), - } - } - encoded.push('"'); - encoded -} - -fn validate_value(value: &PatchValue) -> Result<(), PatchError> { - match value { - PatchValue::String(value) if value.len() > 4096 => Err(PatchError::ValueTooLong), - PatchValue::Integer(value) if value.unsigned_abs() > 9_007_199_254_740_991 => { - Err(PatchError::IntegerOutOfRange) - } - _ => Ok(()), - } -} - -fn validate_identifier(field: &'static str, value: &str, maximum: usize) -> Result<(), PatchError> { - if value.is_empty() { - return Err(PatchError::Empty(field)); - } - if value.len() > maximum { - return Err(PatchError::TooLong(field)); - } - if !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.')) - { - return Err(PatchError::InvalidCharacter(field)); - } - Ok(()) -} - -fn validate_key(key: &str) -> Result<(), PatchError> { - if key.is_empty() { - return Err(PatchError::Empty("key")); - } - if key.len() > 64 { - return Err(PatchError::TooLong("key")); - } - if matches!( - key, - "schemaVersion" | "idempotencyKey" | "operationId" | "key" | "value" - ) { - return Err(PatchError::ReservedKey); - } - let mut bytes = key.bytes(); - if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic()) - || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - { - return Err(PatchError::InvalidCharacter("key")); - } - Ok(()) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SyncEffect(Vec); // req: sync/002 - -impl SyncEffect { - // req: sync/004 - pub fn broadcast(channel: Channel, effect_batch: EffectBatch) -> Broadcast { - Broadcast { - channel, - effect_batch, - } - } - - // req: sync/006 - pub fn ack(atom: Atom) -> Self { - Self(vec![ - atom.set("acknowledged"), - Effect::Emit { - name: ACK_EVENT.to_owned(), - payload: format!(r#"{{"atomId":{}}}"#, atom.id().id), - }, - ]) - } - - pub fn send_patch(patch: FlatPatch) -> Self { - Self(vec![Effect::Emit { - name: PATCH_EVENT.to_owned(), - 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: impl IntoEffect, - fingerprint: BuildFingerprint, - ) -> Self { - let projection = projection.into_batch(fingerprint); - 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 { - fn append_to(self, ops: &mut Vec) { - ops.extend(self.0); - } -} - -#[cfg(test)] -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(), - }; - let batch = SyncEffect::durable(patch, projection, hemx_core::BuildFingerprint(7)) - .into_batch(hemx_core::BuildFingerprint(7)); - assert_eq!(batch.ops.len(), 2); - assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected")); - let Effect::Emit { name, payload } = &batch.ops[1] else { - panic!("durable sync must end with its patch event"); - }; - assert_eq!(name, PATCH_EVENT); - let payload: serde_json::Value = serde_json::from_str(payload).unwrap(); - let expected_projection = EffectBatch { - abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, - fingerprint: BuildFingerprint(7), - ops: vec![Effect::Emit { - name: "projected".into(), - payload: "card:1".into(), - }], - } - .to_wire(); - assert_eq!( - payload["projection"], - serde_json::Value::Array( - expected_projection - .into_iter() - .map(serde_json::Value::from) - .collect() - ) - ); - assert_eq!(payload["patch"]["idempotencyKey"], INTERACTION_ID); - } - - #[test] - fn acknowledgement_updates_atom_and_emits_queue_signal() { - let atom = Atom::::new(17); - let batch = SyncEffect::ack(atom).into_batch(hemx_core::BuildFingerprint(3)); - assert!( - matches!(&batch.ops[0], Effect::Put { target, payload: hemx_core::Payload::Text(payload) } if target.resource == atom.id() && payload == "acknowledged") - ); - assert!( - matches!(&batch.ops[1], Effect::Emit { name, payload } if name == ACK_EVENT && payload == r#"{"atomId":17}"#) - ); - } - - #[test] - fn presence_macro_projects_an_ordinary_effect_on_its_channel() { - struct Signal(Channel); - impl PresenceScope for Signal { - fn presence_channel(&self) -> Channel { - self.0.clone() - } - } - #[presence] - fn project(signal: Signal) -> impl IntoEffect { - Effect::Emit { - name: "presence".to_owned(), - payload: signal.0.as_str().to_owned(), - } - } - - let update = project(Signal(Channel::new("board").unwrap())); - assert_eq!(update.presence_channel().as_str(), "board"); - let broadcast = update.into_broadcast(hemx_core::BuildFingerprint(9)); - assert_eq!(broadcast.channel().as_str(), "board"); - assert_eq!(broadcast.effect_batch().ops.len(), 1); - } - - #[test] - fn presence_tracker_is_idempotent_and_channel_scoped() { - let alpha = Channel::new("board:alpha").unwrap(); - let beta = Channel::new("board:beta").unwrap(); - let mut tracker = PresenceTracker::default(); - assert_eq!( - tracker.join(alpha.clone(), "ada"), - PresenceChange { - changed: true, - count: 1 - } - ); - assert_eq!( - tracker.join(alpha.clone(), "ada"), - PresenceChange { - changed: false, - count: 1 - } - ); - assert_eq!(tracker.join(beta.clone(), "ada").count, 1); - assert_eq!(tracker.leave(&alpha, &"ada").count, 0); - assert_eq!(tracker.count(&beta), 1); - } - - #[test] - fn broadcast_preserves_typed_channel_and_ordinary_batch() { - let channel = Channel::new("board:alpha").unwrap(); - let batch = EffectBatch { - abi_version: 1, - fingerprint: hemx_core::BuildFingerprint(7), - ops: vec![], - }; - let broadcast = SyncEffect::broadcast(channel.clone(), batch.clone()); - assert_eq!(broadcast.into_parts(), (channel, batch)); - assert_eq!( - Channel::new("board alpha"), - Err(ChannelError::InvalidCharacter) - ); - } - - #[test] - fn channel_boundary_and_errors_are_explicit() { - let valid = format!("a{}", "x".repeat(127)); - assert_eq!(Channel::new(&valid).unwrap().as_str(), valid); - for (value, expected, message) in [ - ( - "".to_owned(), - ChannelError::Empty, - "sync channel must not be empty", - ), - ( - format!("a{}", "x".repeat(128)), - ChannelError::TooLong, - "sync channel is too long", - ), - ( - "board/alpha".to_owned(), - ChannelError::InvalidCharacter, - "sync channel contains an invalid character", - ), - ] { - let error = Channel::new(value).expect_err("invalid channel must fail closed"); - assert_eq!(error, expected); - assert_eq!(error.to_string(), message); - } - // req: sync/024 test - } - - #[test] - fn presence_leave_and_projection_preserve_observable_state() { - let channel = Channel::new("board").unwrap(); - let mut tracker = PresenceTracker::default(); - assert_eq!( - tracker.leave(&channel, &"missing"), - PresenceChange { - changed: false, - count: 0, - } - ); - tracker.join(channel.clone(), "ada"); - tracker.join(channel.clone(), "grace"); - assert_eq!(tracker.count(&channel), 2); - assert_eq!( - tracker.leave(&channel, &"ada"), - PresenceChange { - changed: true, - count: 1, - } - ); - assert_eq!(tracker.count(&channel), 1); - assert_eq!(tracker.leave(&channel, &"grace").count, 0); - assert_eq!(tracker.count(&channel), 0); - assert!(!tracker.members.contains_key(&channel)); - - let effect = Effect::Emit { - name: "presence".into(), - payload: "joined".into(), - }; - let projection = PresenceProjection::new(channel, effect.clone()); - assert_eq!(projection.into_batch(BuildFingerprint(1)).ops, vec![effect]); - // test req: sync/003 req: sync/005 - } - - #[test] - fn flat_patch_enforces_identifier_key_and_value_boundaries() { - let valid_identifier = format!("a{}", "x".repeat(127)); - let valid_key = format!("a{}", "x".repeat(63)); - let patch = FlatPatch::new( - &valid_identifier, - &valid_identifier, - &valid_key, - PatchValue::String("x".repeat(4096)), - ) - .expect("documented patch limits are inclusive"); - assert_eq!(patch.validate(), Ok(())); - - assert_eq!( - FlatPatch::new("", "operation", "field", PatchValue::Boolean(true)), - Err(PatchError::Empty("idempotency_key")) - ); - assert_eq!( - FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)), - Err(PatchError::Empty("operation_id")) - ); - assert_eq!( - FlatPatch::new( - "actor", - "operation", - format!("a{}", "x".repeat(64)), - PatchValue::Boolean(true), - ), - Err(PatchError::TooLong("key")) - ); - let cases = [ - FlatPatch::new(INTERACTION_ID, "", "field", PatchValue::Boolean(true)), - FlatPatch::new("", INTERACTION_ID, "field", PatchValue::Boolean(true)), - FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)), - FlatPatch::new( - format!("a{}", "x".repeat(128)), - "operation", - "field", - PatchValue::Boolean(true), - ), - FlatPatch::new("actor", "bad operation", "field", PatchValue::Boolean(true)), - FlatPatch::new( - "actor", - format!("a{}", "x".repeat(128)), - "field", - PatchValue::Boolean(true), - ), - FlatPatch::new("actor", "operation", "", PatchValue::Boolean(true)), - FlatPatch::new( - "actor", - "operation", - format!("a{}", "x".repeat(64)), - PatchValue::Boolean(true), - ), - FlatPatch::new("actor", "operation", "1field", PatchValue::Boolean(true)), - FlatPatch::new("actor", "operation", "field.dot", PatchValue::Boolean(true)), - FlatPatch::new( - "actor", - "operation", - "field", - PatchValue::String("x".repeat(4097)), - ), - FlatPatch::new( - "actor", - "operation", - "field", - PatchValue::Integer(9_007_199_254_740_992), - ), - FlatPatch::new( - "actor", - "operation", - "field", - PatchValue::Integer(-9_007_199_254_740_992), - ), - ]; - for result in cases { - assert!(result.is_err(), "invalid patch boundary must fail closed"); - } - assert!(FlatPatch::new( - "actor", - "operation", - "field", - PatchValue::Integer(-9_007_199_254_740_991), - ) - .is_ok()); - assert_eq!( - FlatPatch::for_interaction("", PatchValue::Boolean(true)), - Err(PatchError::Empty("key")) - ); - assert_eq!( - FlatPatch::for_interaction("field", PatchValue::String("x".repeat(4097)),), - Err(PatchError::ValueTooLong) - ); - assert_eq!(PatchError::ReservedKey.to_string(), "patch key is reserved"); - assert_eq!( - PatchError::ValueTooLong.to_string(), - "patch string value is too long" - ); - // req: sync/017 test req: sync/025 test req: sync/026 test req: sync/027 test - } - - #[test] - fn flat_patch_json_round_trips_escaped_values_and_rejects_invalid_input() { - let escaped = "quote:\" slash:\\ newline:\n return:\r tab:\t control:\u{1f}"; - let patch = FlatPatch::new( - "actor:1", - "operation-1", - "field_name", - PatchValue::String(escaped.into()), - ) - .unwrap(); - let payload = patch.payload(); - let decoded: serde_json::Value = serde_json::from_str(&payload).unwrap(); - assert_eq!(decoded["value"], escaped); - assert_eq!(serde_json::from_str::(&payload).unwrap(), patch); - - for (json, expected) in [ - ( - r#"{"schemaVersion":2,"idempotencyKey":"actor","operationId":"op","key":"field","value":true}"#, - "unsupported patch schema version 2", - ), - ( - r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"field","value":true,"extra":1}"#, - "unknown field `extra`", - ), - ( - r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"1field","value":true}"#, - "key contains an invalid character", - ), - ] { - assert!( - serde_json::from_str::(json) - .unwrap_err() - .to_string() - .contains(expected), - "invalid JSON must report {expected}" - ); - } - // req: sync/014 test req: sync/028 test req: sync/029 test - } - - #[test] - fn send_patch_emits_the_canonical_payload() { - let patch = FlatPatch::for_interaction("done", PatchValue::Boolean(true)).unwrap(); - assert_eq!(patch.validate(), Ok(())); - let expected = patch.payload(); - assert_eq!( - SyncEffect::send_patch(patch) - .into_batch(BuildFingerprint(4)) - .ops, - vec![Effect::Emit { - name: PATCH_EVENT.into(), - payload: expected, - }] - ); - // test req: sync/002 req: sync/009 - } - - #[test] - fn schema_is_flat_and_rejects_reserved_keys() { - let patch = FlatPatch::new( - "actor:1", - "move-card-to-done", - "cardColumn", - PatchValue::String("done".to_owned()), - ) - .unwrap(); - assert_eq!( - patch.payload(), - r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move-card-to-done","key":"cardColumn","value":"done"}"# - ); - assert_eq!( - FlatPatch::new("actor:1", "move", "value", PatchValue::Integer(1)), - Err(PatchError::ReservedKey) - ); - assert_eq!( - FlatPatch::new( - "actor:1", - "move", - "rank", - PatchValue::Integer(9_007_199_254_740_992), - ), - Err(PatchError::IntegerOutOfRange) - ); - assert!(serde_json::from_str::( - r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move","key":"rank","value":9007199254740992}"#, - ) - .unwrap_err() - .to_string() - .contains("safe range")); - } -} diff --git a/hemx-wasm/Cargo.toml b/hemx-wasm/Cargo.toml deleted file mode 100644 index 45f1e37..0000000 --- a/hemx-wasm/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "hemx-wasm" -version.workspace = true -edition.workspace = true - -[lib] -path = "src/lib.rs" - -[dependencies] -hemx-core = { path = "../hemx-core" } -wasm-bindgen = "=0.2.125" - -[dev-dependencies] -serde_json = "1" -thirtyfour = "0.36" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/hemx-wasm/src/lib.rs b/hemx-wasm/src/lib.rs deleted file mode 100644 index 9fcf45e..0000000 --- a/hemx-wasm/src/lib.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Opt-in browser boundary for client-local hemx handlers. -//! -//! Application code reaches this crate through the `hemx` `client` feature and -//! `#[hemx::handler(client)]`; server-first applications do not depend on it. - -use hemx_core::{BuildFingerprint, IntoEffect}; - -#[doc(hidden)] -pub use wasm_bindgen::prelude::wasm_bindgen; -#[doc(hidden)] -pub use wasm_bindgen::*; - -pub const CLIENT_EVENT_ABI_VERSION: u32 = 1; -pub const CLIENT_STATE_ABI_VERSION: u32 = 1; -const MAX_CLIENT_EVENT_KIND_BYTES: usize = 256; -const MAX_CLIENT_EVENT_VALUE_BYTES: usize = 64 * 1024; -const MAX_CLIENT_EVENT_KEY_BYTES: usize = 1024; -const MAX_CLIENT_STATE_BYTES: usize = 1024 * 1024; - -/// Versioned browser event accepted by client-local handlers. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ClientEvent { - pub kind: String, - pub value: Option, - pub checked: Option, - pub key: Option, -} - -/// Explicit root-owned state passed to a client-local handler. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ClientState { - pub encoded: String, -} - -/// Validates primitive wasm-bindgen values before application code runs. -/// -/// Primitive arguments keep JavaScript from owning a second event/state codec. -/// The ordinary effect result uses `hemx-core`'s canonical `EffectBatch` codec. -#[doc(hidden)] -#[allow(clippy::too_many_arguments)] -pub fn decode_client_inputs( - event_version: u32, - kind: String, - value: Option, - checked: Option, - key: Option, - state_version: u32, - encoded_state: String, -) -> Result<(ClientEvent, ClientState), String> { - if event_version != CLIENT_EVENT_ABI_VERSION { - return Err(format!( - "unsupported client-local event ABI version {event_version}; expected {CLIENT_EVENT_ABI_VERSION}" - )); - } - if kind.is_empty() || kind.len() > MAX_CLIENT_EVENT_KIND_BYTES { - return Err(format!( - "invalid client-local event payload: event kind must contain 1..={MAX_CLIENT_EVENT_KIND_BYTES} bytes" - )); - } - if value - .as_ref() - .is_some_and(|value| value.len() > MAX_CLIENT_EVENT_VALUE_BYTES) - { - return Err(format!( - "invalid client-local event payload: value exceeds {MAX_CLIENT_EVENT_VALUE_BYTES} bytes" - )); - } - if key - .as_ref() - .is_some_and(|key| key.len() > MAX_CLIENT_EVENT_KEY_BYTES) - { - return Err(format!( - "invalid client-local event payload: key exceeds {MAX_CLIENT_EVENT_KEY_BYTES} bytes" - )); - } - if state_version != CLIENT_STATE_ABI_VERSION { - return Err(format!( - "unsupported client-local state ABI version {state_version}; expected {CLIENT_STATE_ABI_VERSION}" - )); - } - if encoded_state.len() > MAX_CLIENT_STATE_BYTES { - return Err(format!( - "invalid client-local state payload: state exceeds {MAX_CLIENT_STATE_BYTES} bytes" - )); - } - Ok(( - ClientEvent { - kind, - value, - checked, - key, - }, - ClientState { - encoded: encoded_state, - }, - )) -} - -/// Encodes a client handler result with the ordinary hemx effect wire format. -/// -/// Keeping this conversion here gives generated WASM exports one ABI boundary -/// instead of teaching the proc macro a second effect protocol. -#[doc(hidden)] -pub fn encode_handler_effect(effect: impl IntoEffect, fingerprint: BuildFingerprint) -> Vec { - effect.into_batch(fingerprint).to_wire() -} - -#[cfg(test)] -mod tests { - use super::{decode_client_inputs, encode_handler_effect, ClientEvent, ClientState}; - use hemx_core::{BuildFingerprint, EffectBatch, Slot}; - - #[test] - fn client_inputs_are_typed_and_versioned() { - assert_eq!( - decode_client_inputs( - 1, - "click".to_owned(), - None, - None, - None, - 1, - "count=3".to_owned(), - ), - Ok(( - ClientEvent { - kind: "click".to_owned(), - value: None, - checked: None, - key: None, - }, - ClientState { - encoded: "count=3".to_owned(), - }, - )) - ); // req: client_local/005 req: client_local/007 - assert_eq!( - decode_client_inputs( - 1, - "click".to_owned(), - None, - None, - None, - 2, - "count=3".to_owned(), - ) - .expect_err("reject unknown state ABI"), - "unsupported client-local state ABI version 2; expected 1" - ); // req: client_local/008 - } - - #[test] - fn client_input_boundary_accepts_limits_and_preserves_values() { - let kind = "k".repeat(256); - let value = "v".repeat(64 * 1024); - let key = "x".repeat(1024); - let state = "s".repeat(1024 * 1024); - - let (event, decoded_state) = decode_client_inputs( - 1, - kind.clone(), - Some(value.clone()), - Some(true), - Some(key.clone()), - 1, - state.clone(), - ) - .expect("documented client-local limits are inclusive"); - - assert_eq!( - event, - ClientEvent { - kind, - value: Some(value), - checked: Some(true), - key: Some(key), - } - ); - assert_eq!(decoded_state, ClientState { encoded: state }); - // req: client_local/005 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test - } - - #[test] - fn client_input_boundary_rejects_invalid_versions_and_payload_sizes() { - let decode = |event_version, kind, value, key, state_version, state| { - decode_client_inputs(event_version, kind, value, None, key, state_version, state) - }; - - for (result, expected) in [ - ( - decode(0, "click".into(), None, None, 1, String::new()), - "unsupported client-local event ABI version 0; expected 1", - ), - ( - decode(1, String::new(), None, None, 1, String::new()), - "invalid client-local event payload: event kind must contain 1..=256 bytes", - ), - ( - decode(1, "k".repeat(257), None, None, 1, String::new()), - "invalid client-local event payload: event kind must contain 1..=256 bytes", - ), - ( - decode( - 1, - "input".into(), - Some("v".repeat(64 * 1024 + 1)), - None, - 1, - String::new(), - ), - "invalid client-local event payload: value exceeds 65536 bytes", - ), - ( - decode( - 1, - "keydown".into(), - None, - Some("k".repeat(1025)), - 1, - String::new(), - ), - "invalid client-local event payload: key exceeds 1024 bytes", - ), - ( - decode(1, "click".into(), None, None, 0, String::new()), - "unsupported client-local state ABI version 0; expected 1", - ), - ( - decode( - 1, - "click".into(), - None, - None, - 1, - "s".repeat(1024 * 1024 + 1), - ), - "invalid client-local state payload: state exceeds 1048576 bytes", - ), - ] { - assert_eq!( - result.expect_err("invalid client input must fail closed"), - expected - ); - } - // req: client_local/008 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test - } - - #[test] - fn client_handler_uses_the_ordinary_effect_wire_format() { - let fingerprint = BuildFingerprint(17); - let effect = Slot::<()>::new(4).text("local"); - let wire = encode_handler_effect(effect.clone(), fingerprint); - - assert_eq!( - EffectBatch::from_wire(&wire).expect("decode client effect"), - EffectBatch { - abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, - fingerprint, - ops: vec![effect], - } - ); // req: client_local/005 req: client_local/009 - } -} diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs deleted file mode 100644 index 95826d8..0000000 --- a/hemx-wasm/tests/browser.rs +++ /dev/null @@ -1,2183 +0,0 @@ -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; -use thirtyfour::common::capabilities::firefox::{FirefoxCapabilities, FirefoxPreferences}; -use thirtyfour::prelude::*; - -const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); - -fn headless_firefox_capabilities(javascript_enabled: bool) -> WebDriverResult { - let mut preferences = FirefoxPreferences::new(); - for preference in [ - "app.normandy.enabled", - "app.shield.optoutstudies.enabled", - "app.update.enabled", - "browser.newtabpage.activity-stream.feeds.telemetry", - "browser.newtabpage.activity-stream.telemetry", - "datareporting.healthreport.uploadEnabled", - "datareporting.policy.dataSubmissionEnabled", - "extensions.systemAddon.update.enabled", - "network.captive-portal-service.enabled", - "network.connectivity-service.enabled", - "toolkit.telemetry.enabled", - ] { - preferences.set(preference, false)?; - } - preferences.set("javascript.enabled", javascript_enabled)?; - preferences.set("services.settings.server", "data:,")?; - - let mut capabilities = DesiredCapabilities::firefox(); - capabilities.set_headless()?; - capabilities.set_preferences(preferences)?; - Ok(capabilities) -} - -#[tokio::test] -async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { - // req: client_local/005 req: client_local/009 req: client_local/010 - // req: client_local/011 req: client_local/012 - // test: client_local/014 - let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("workspace root") - .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", None); - - 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 caps = headless_firefox_capabilities(true)?; - 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-hemx-client-ready')", - ) - .await?; - driver - .execute( - "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', (event) => window.__clientErrors.push(event.detail)); return true", - Vec::new(), - ) - .await?; - let network_before = resource_count(&driver).await?; - driver - .execute( - r#" - window.__resolveClientRuns = []; - const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => { - window.__resolveClientRuns.push(() => resolve(actual(...args))); - })); - window.__actualClientHandler = actual; - return true; - "#, - Vec::new(), - ) - .await?; - let button = driver - .find(By::Css("[data-hemx-client='increment']")) - .await?; - button.click().await?; - button.click().await?; - wait_until(&driver, "return window.__resolveClientRuns.length === 2").await?; - driver - .execute( - "window.__resolveClientRuns[0](); return true", - Vec::new(), - ) - .await?; - tokio::time::sleep(Duration::from_millis(100)).await; - assert_eq!( - driver - .find(By::Css("[data-sid]")) - .await? - .prop("textContent") - .await? - .unwrap_or_default(), - "idle", - "superseded completion applied stale effects" - ); - driver - .execute( - "window.__resolveClientRuns[1](); return true", - Vec::new(), - ) - .await?; - wait_until( - &driver, - "return document.querySelector('[data-sid]').textContent.includes('updated by Rust/WASM (click, count=3)')", - ) - .await?; - - assert_eq!( - resource_count(&driver).await?, - network_before, - "client handler made a network request" - ); - - driver - .execute( - "window.hemx.registerClientHandler('increment', window.__actualClientHandler); return true", - Vec::new(), - ) - .await?; - driver - .execute( - "document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true", - Vec::new(), - ) - .await?; - driver - .find(By::Css("[data-hemx-client='increment']")) - .await? - .click() - .await?; - wait_until(&driver, "return window.__clientErrors.length === 1").await?; - assert!( - driver - .execute("return window.__clientErrors[0].message", Vec::new()) - .await? - .json() - .as_str() - .unwrap_or_default() - .contains("unsupported client-local state ABI version 2; expected 1"), - "invalid state must produce an actionable client-local diagnostic" - ); - assert_eq!( - resource_count(&driver).await?, - network_before + 1, - "declared server fallback was not requested" - ); - assert!( - driver - .execute( - "return !document.querySelector('[data-hemx-client]').classList.contains('is-pending')", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false), - "invalid input must restore pending UI" - ); - - driver - .execute( - r#" - window.__resolveUnmount = null; - const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => { - window.__resolveUnmount = () => resolve(actual(...args)); - })); - document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '1'); - return true; - "#, - Vec::new(), - ) - .await?; - driver - .find(By::Css("[data-hemx-client='increment']")) - .await? - .click() - .await?; - driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); window.__removedRoot = root; root.remove(); window.__resolveUnmount(); return true", - Vec::new(), - ) - .await?; - tokio::time::sleep(Duration::from_millis(100)).await; - assert!( - driver - .execute( - "return !window.__removedRoot.textContent.includes('updated by Rust/WASM')", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false), - "unmounted root accepted a late effect" - ); - Ok::<(), WebDriverError>(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity( -) -> WebDriverResult<()> { - // test req: sync/002 req: sync/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 caps = headless_firefox_capabilities(true)?; - 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-hemx-client-ready') && root.hasAttribute('data-hemx-sync-ready')", - ) - .await?; - let rejected_inputs = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const { reorder_card: handler } = await import('/kanban_client.js'); - const errors = {}; - for (const [name, args] of [ - ['unknown-version', [99, 'click', null, null, null, 1, '1|2']], - ['oversized-kind', [1, 'x'.repeat(257), null, null, null, 1, '1|2']], - ['oversized-value', [1, 'click', 'x'.repeat(64 * 1024 + 1), null, null, 1, '1|2']], - ['oversized-key', [1, 'keydown', null, null, 'x'.repeat(1025), 1, '1|2']], - ['unknown-state-version', [1, 'click', null, null, null, 99, '1|2']], - ['oversized-state', [1, 'click', null, null, null, 1, 'x'.repeat(1024 * 1024 + 1)]], - ]) { - try { - await handler(...args); - errors[name] = null; - } catch (error) { - errors[name] = String(error); - } - } - done(errors); - })().catch((error) => done({ harness: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!( - rejected_inputs - .as_object() - .expect("client rejection record") - .values() - .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), - "malformed client-local input reached the handler: {rejected_inputs}" - ); // req: security/005 - let rejected_sync = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - (async () => { - const root = document.querySelector('[data-hemx-root]'); - const before = document.querySelector('#kanban-status').textContent; - const errors = {}; - for (const [name, detail] of [ - ['malformed', '{'], - ['unknown-version', JSON.stringify({ schemaVersion: 99, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'done' })], - ['invalid-key', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'value', value: 'done' })], - ['oversized-value', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'x'.repeat(4097) })], - ]) { - root.removeAttribute('data-hemx-sync-error'); - document.dispatchEvent(new CustomEvent('hemx:sync-patch', { detail })); - await new Promise((resolve) => setTimeout(resolve, 10)); - errors[name] = root.getAttribute('data-hemx-sync-error'); - } - done({ errors, pending: root.getAttribute('data-hemx-sync-pending'), unchanged: before === document.querySelector('#kanban-status').textContent }); - })().catch((error) => done({ harness: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!( - rejected_sync["errors"] - .as_object() - .expect("sync rejection record") - .values() - .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), - "malformed sync patch reached durable storage: {rejected_sync}" - ); - assert_eq!(rejected_sync["pending"], "0", "{rejected_sync}"); - assert_eq!(rejected_sync["unchanged"], true, "{rejected_sync}"); - driver - .execute( - "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', event => window.__clientErrors.push(event.detail)); Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); document.querySelector('[data-hemx-client-event=drop]').dispatchEvent(new Event('drop', { bubbles: true })); return true", - Vec::new(), - ) - .await?; - wait_until( - &driver, - "return document.querySelector('[data-hemx-root]').getAttribute('data-hemx-sync-pending') === '1'", - ) - .await?; - assert!( - driver - .find(By::Css("#kanban-status")) - .await? - .text() - .await? - .contains("Moved 1 with drop"), - "ordinary EffectBatch did not apply alongside the sync patch" - ); - assert_eq!( - driver - .execute( - "return performance.getEntriesByType('resource').filter(entry => entry.name.endsWith('/sync/patches')).length", - Vec::new(), - ) - .await? - .json(), - &serde_json::json!(0), - "offline patch attempted a network request" - ); - - driver - .execute( - "Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => true }); window.dispatchEvent(new Event('online')); return true", - Vec::new(), - ) - .await?; - wait_until( - &driver, - "const root = document.querySelector('[data-hemx-root]'); return root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')", - ) - .await?; - let identity = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { ack: root.getAttribute('data-hemx-sync-ack'), uuid: /^[0-9a-f-]{36}$/.test(root.getAttribute('data-hemx-sync-ack')) }", - Vec::new(), - ) - .await?; - assert_eq!(identity.json()["uuid"], true, "{identity:?}"); - Ok(()) - } - .await; - let _ = driver.quit().await; - result -} - -#[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 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")) - .parent() - .expect("workspace root") - .to_owned(); - let host_build = Command::new("cargo") - .current_dir(&workspace) - .args([ - "build", - "-p", - "hemx-kanban-example", - "--bin", - "hemx-kanban-example", - ]) - .status() - .expect("build kanban host server"); - assert!(host_build.success(), "build kanban host server"); - let target_dir = std::env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| workspace.join("target")); - let host_binary = target_dir.join("debug/hemx-kanban-example"); - let host_port = available_port(); - let host_addr = format!("127.0.0.1:{host_port}"); - let mut host_command = Command::new(&host_binary); - host_command.env("HEMX_KANBAN_ADDR", &host_addr); - let _host = ProcessGuard::start(host_command, &host_addr); - let host_url = format!("http://{host_addr}"); - - let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); - let runtime = workspace.join("hemx-js/runtime/hemx.js"); - let mut server = StaticServer::start( - package, - runtime, - bootstrap, - rendered, - "kanban_client", - Some(framework_sync_assets(&workspace)), - ); - - let webdriver_port = available_port(); - let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); - let webdriver_url = format!("http://{webdriver_addr}"); - let mut webdriver = Command::new("geckodriver"); - webdriver.arg("--port").arg(webdriver_port.to_string()); - let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); - - let no_script_caps = headless_firefox_capabilities(false)?; - let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?; - no_script_driver.goto(&host_url).await?; - no_script_driver - .find(By::XPath( - "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", - )) - .await? - .click() - .await?; - let moved_without_script = no_script_driver - .find(By::XPath( - "//section[contains(@class,'column')][h2='Doing']//strong[text()='Write requirements']", - )) - .await; - let _ = no_script_driver.quit().await; - moved_without_script?; - - let caps = headless_firefox_capabilities(true)?; - let driver = WebDriver::new(&webdriver_url, caps).await?; - let result = async { - driver.goto(&host_url).await?; - wait_until(&driver, "return Boolean(window.hemx)").await?; - let optional_asset = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - fetch('/optional-avatar.webp') - .then((response) => done({ status: response.status, runtime: Boolean(window.hemx) })) - .catch((error) => done({ error: String(error) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(optional_asset["status"], 404); - assert_eq!(optional_asset["runtime"], true); - - driver - .find(By::XPath( - "//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']", - )) - .await? - .send_keys(Key::Enter) - .await?; - wait_until( - &driver, - "return [...document.querySelectorAll('.column')].find((column) => column.querySelector('h2')?.textContent === 'Done')?.textContent.includes('Write requirements')", - ) - .await?; - - let presence = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const root = document.querySelector('[data-hemx-root]'); - const source = new EventSource('/sync/broadcast?channel=board&action=join&member=milestone-peer'); - const timeout = setTimeout(() => { source.close(); done({ error: 'presence timed out' }); }, 5000); - source.addEventListener('hemx', (event) => { - clearTimeout(timeout); - const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); - const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); - window.hemx.applyBatch(bytes.buffer, root); - source.close(); - done({ text: document.body.textContent }); - }); - source.onerror = () => { clearTimeout(timeout); source.close(); done({ error: 'presence failed' }); }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!(presence["error"].is_null(), "presence failed: {presence}"); - assert!( - presence["text"].as_str().unwrap_or_default().contains("tick #1"), - "presence projection was not applied: {presence}" - ); - - driver.goto(&server.url()).await?; - wait_until( - &driver, - "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', '/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) })); - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - 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"]) - ); - driver - .execute( - "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?; - driver - .execute( - "const transfer = new DataTransfer(); const card = document.querySelector('[data-key=\"1\"]'); const drop = document.querySelector('[data-hemx-client-event=\"drop\"]'); card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: transfer })); drop.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); return true", - Vec::new(), - ) - .await?; - wait_until( - &driver, - "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 command_id = driver - .execute( - "return JSON.parse(window.__durablePatch).patch.idempotencyKey", - Vec::new(), - ) - .await? - .json() - .as_str() - .expect("durable interaction identity") - .to_owned(); - driver.refresh().await?; - wait_until( - &driver, - "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('|'), 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["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); - 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' }}); - const conflict = await conflictResponse.json(); - const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }}); - const rejection = await rejectionResponse.json(); - const peerResponse = await fetch('/sync/commands?command_id=peer%3A1&card_id=2&column=done', {{ method: 'POST' }}); - 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(); - 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 }})); - "# - ); - let convergence = driver - .execute_async(&duplicate_script, Vec::new()) - .await? - .json() - .clone(); - assert!( - 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); - assert_eq!(convergence["conflictStatus"], 409); - assert_eq!(convergence["conflict"]["kind"], "command-conflict"); - assert_eq!( - convergence["conflict"]["error"], - "command_id was already used for a different payload" - ); - assert_eq!(convergence["rejectionStatus"], 400); - assert_eq!(convergence["rejection"]["kind"], "invalid-command"); - assert_eq!(convergence["rejection"]["error"], "unknown card_id"); - assert_eq!(convergence["peerStatus"], 200); - assert_eq!(convergence["peer"]["commandId"], "peer:1"); - assert_eq!(convergence["peer"]["serverSequence"], 2); - assert_eq!(convergence["snapshot"]["serverSequence"], 2); - assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1); - assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done"); - assert_eq!(convergence["snapshot"]["cards"][1]["id"], 2); - assert_eq!(convergence["snapshot"]["cards"][1]["column"], "done"); - assert_eq!( - convergence["history"] - .as_str() - .expect("acknowledgement history") - .matches(&command_id) - .count(), - 1, - "duplicate replay emitted another acknowledgement: {convergence}" - ); - Ok(()) - } - .await; - let quit = driver.quit().await; - 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 caps = headless_firefox_capabilities(true)?; - 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"], 2); - 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::>(); - exported_keys.sort_unstable(); - assert_eq!( - exported_keys, - [ - "accountPartition", - "actor", - "cardId", - "causal", - "eventKind", - "id", - "key", - "kind", - "queuedAt", - "schemaVersion", - "session", - "targetColumn", - ] - ); - 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_queued_status_precedes_durable_projection_within_budget() -> WebDriverResult<()> { - // test req: accessibility/004 req: client_local/013 req: performance/003 - const INTERACTION_BUDGET_MS: f64 = 100.0; - - 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 caps = headless_firefox_capabilities(true)?; - 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?; - hold_command_transaction(&driver).await?; - driver - .execute( - r#" - const root = document.querySelector('[data-hemx-root]'); - window.__queuedTiming = { input: null, queued: null, persisted: false }; - root.addEventListener('click', () => { window.__queuedTiming.input = performance.now(); }, { capture: true, once: true }); - root.addEventListener('kanban:command-queued', () => { window.__queuedTiming.queued = performance.now(); }, { once: true }); - root.addEventListener('kanban:command-persisted', () => { window.__queuedTiming.persisted = true; }, { once: true }); - return true; - "#, - Vec::new(), - ) - .await?; - driver.find(By::Css("[data-card-id='1']")).await?.click().await?; - wait_until( - &driver, - "return window.__queuedTiming.queued !== null && document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-phase') === 'queued'", - ) - .await?; - let queued = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { latency: window.__queuedTiming.queued - window.__queuedTiming.input, persisted: window.__queuedTiming.persisted, phase: root.getAttribute('data-kanban-command-phase'), busy: root.getAttribute('aria-busy'), status: root.querySelector('[role=status]').textContent, order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), count: root.getAttribute('data-kanban-command-count') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert!( - queued["latency"] - .as_f64() - .is_some_and(|latency| latency <= INTERACTION_BUDGET_MS), - "queued status missed interaction budget: {queued}" - ); - assert_eq!(queued["persisted"], false); - assert_eq!(queued["phase"], "queued"); - assert_eq!(queued["busy"], "true"); - assert_eq!(queued["status"], "Queued card 1; saving for offline use."); - assert_eq!(queued["order"], "1|2"); - assert_eq!(queued["count"], "0"); - - release_command_transaction(&driver).await?; - wait_until( - &driver, - "const root = document.querySelector('[data-hemx-root]'); return window.__queuedTiming.persisted && root.getAttribute('data-kanban-command-phase') === 'durable' && [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", - ) - .await?; - let durable = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { phase: root.getAttribute('data-kanban-command-phase'), busy: root.hasAttribute('aria-busy'), count: root.getAttribute('data-kanban-command-count'), status: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(durable["phase"], "durable"); - assert_eq!(durable["busy"], false); - assert_eq!(durable["count"], "1"); - assert_eq!(durable["status"], "Moved 1 with click"); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn kanban_quota_failure_is_fail_closed_and_recoverable() -> WebDriverResult<()> { - // test req: sync/015 - 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 caps = headless_firefox_capabilities(true)?; - 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?; - inject_quota_failure(&driver).await?; - driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterQuota = false; window.__quotaFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterQuota = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__quotaFailure = event.detail; }, { once: true }); return true;", - Vec::new(), - ) - .await?; - driver.find(By::Css("[data-card-id='1']")).await?.click().await?; - wait_until(&driver, "return window.__quotaFailure !== null").await?; - let failed = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), notice: root.querySelector('[role=status]').textContent, persisted: window.__persistedAfterQuota, controls: [...root.querySelectorAll('[data-kanban-command-action]')].map((button) => ({ action: button.dataset.kanbanCommandAction, disabled: button.disabled })) }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(failed["order"], "1|2"); - assert_eq!(failed["count"], "0"); - assert_eq!(failed["stage"], "persist"); - assert_eq!(failed["code"], "QuotaExceededError"); - assert_eq!(failed["persisted"], false); - assert_eq!( - failed["notice"], - "Local command persist failed (QuotaExceededError). Recovery controls remain available." - ); - assert!( - failed["controls"] - .as_array() - .is_some_and(|controls| controls.len() == 3 - && controls.iter().all(|control| control["disabled"] == false)), - "recovery controls unavailable: {failed}" - ); - let empty_export = export_commands(&driver).await?.json().clone(); - assert_eq!(empty_export["commands"].as_array().map(Vec::len), Some(0)); - - driver - .execute("window.__reloadPending = true", Vec::new()) - .await?; - let delete = driver - .find(By::Css("[data-kanban-command-action='delete']")) - .await?; - delete.click().await?; - 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?; - 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_commands(&driver).await?.json().clone(); - assert_eq!(after_delete["commands"].as_array().map(Vec::len), Some(1)); - assert_eq!(after_delete["commands"][0]["causal"], 1); - - inject_quota_failure(&driver).await?; - driver - .execute( - "window.__quotaFailure = null; document.querySelector('[data-hemx-root]').addEventListener('kanban:command-error', (event) => { window.__quotaFailure = event.detail; }, { once: true }); return true;", - Vec::new(), - ) - .await?; - driver.find(By::Css("[data-card-id='2']")).await?.click().await?; - wait_until(&driver, "return window.__quotaFailure !== null").await?; - let second_failure = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), count: root.getAttribute('data-kanban-command-count'), code: root.getAttribute('data-kanban-command-error-code') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(second_failure["order"], "2|1"); - assert_eq!(second_failure["count"], "1"); - assert_eq!(second_failure["code"], "QuotaExceededError"); - - driver - .execute("window.__reloadPending = true", Vec::new()) - .await?; - let reset = driver - .find(By::Css("[data-kanban-command-action='reset']")) - .await?; - reset.click().await?; - 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 { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(after_reset["order"], "1|2"); - assert!(after_reset["error"].is_null()); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriverResult<()> { - // test req: sync/015 - 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 caps = headless_firefox_capabilities(true)?; - 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?; - driver.find(By::Css("[data-card-id='1']")).await?.click().await?; - wait_until( - &driver, - "return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", - ) - .await?; - occupy_next_command_id(&driver).await?; - driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterFault = false; window.__commandFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterFault = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__commandFailure = event.detail; }, { once: true }); return true;", - Vec::new(), - ) - .await?; - driver.find(By::Css("[data-card-id='2']")).await?.click().await?; - wait_until(&driver, "return window.__commandFailure !== null").await?; - let failed = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), notice: root.querySelector('[role=status]').textContent, count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), persisted: window.__persistedAfterFault, detail: window.__commandFailure }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(failed["order"], "2|1"); - assert_eq!( - failed["notice"], - "Local command persist failed (ConstraintError). Recovery controls remain available." - ); - assert_eq!(failed["count"], "1"); - assert_eq!(failed["stage"], "persist"); - assert_eq!(failed["code"], "ConstraintError"); - assert_eq!(failed["detail"]["stage"], "persist"); - assert_eq!(failed["detail"]["code"], "ConstraintError"); - assert_eq!(failed["persisted"], false); - - driver - .execute("window.__reloadPending = true", Vec::new()) - .await?; - let delete = driver - .find(By::Css("[data-kanban-command-action='delete']")) - .await?; - delete.click().await?; - 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 recovered = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(recovered["order"], "1|2"); - assert!(recovered["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_recovery = export_commands(&driver).await?.json().clone(); - assert_eq!(after_recovery["commands"].as_array().map(Vec::len), Some(1)); - assert_eq!(after_recovery["commands"][0]["causal"], 2); - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> { - // test req: sync/014 req: performance/005 req: performance/007 - const REPLAY_LIMIT: u64 = 64; - const REPLAY_BUDGET_MS: f64 = 250.0; - - 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 caps = headless_firefox_capabilities(true)?; - 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?; - store_replay_commands(&driver, 1, REPLAY_LIMIT).await?; - driver - .execute("window.__reloadPending = true; location.reload()", Vec::new()) - .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') === '64'", - ) - .await?; - let within_bound = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), limit: root.getAttribute('data-kanban-replay-limit'), elapsed: Number(root.getAttribute('data-kanban-replay-ms')), budget: Number(root.getAttribute('data-kanban-replay-budget-ms')), over: root.hasAttribute('data-kanban-replay-over-budget') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(within_bound["order"], "2|1"); - assert_eq!(within_bound["limit"], REPLAY_LIMIT.to_string()); - assert_eq!(within_bound["budget"], REPLAY_BUDGET_MS); - assert_eq!(within_bound["over"], false, "{within_bound}"); - assert!( - within_bound["elapsed"].as_f64().is_some_and(|elapsed| elapsed <= REPLAY_BUDGET_MS), - "replay exceeded budget: {within_bound}" - ); - - store_replay_commands(&driver, REPLAY_LIMIT + 1, REPLAY_LIMIT + 1).await?; - driver - .execute("window.__reloadPending = true; location.reload()", Vec::new()) - .await?; - wait_until( - &driver, - "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.getAttribute('data-kanban-command-error-code') === 'ReplayLimitError'", - ) - .await?; - let refused = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), ready: root.hasAttribute('data-kanban-command-ready'), error: root.getAttribute('data-kanban-command-error'), stage: root.getAttribute('data-kanban-command-error-stage') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(refused["order"], "1|2"); - assert_eq!(refused["ready"], false); - assert_eq!( - refused["error"], - "restore: durable replay limit exceeded: 65 > 64" - ); - assert_eq!(refused["stage"], "restore"); - let recovery_export = export_commands(&driver).await?.json().clone(); - assert_eq!( - recovery_export["commands"].as_array().map(Vec::len), - Some(65) - ); - - driver - .execute("window.__reloadPending = true", Vec::new()) - .await?; - let delete = driver - .find(By::Css("[data-kanban-command-action='delete']")) - .await?; - delete.click().await?; - 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?; - Ok(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -#[tokio::test] -async fn kanban_corrupt_command_refuses_projection_and_recovers() -> WebDriverResult<()> { - // test req: sync/015 - 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 caps = headless_firefox_capabilities(true)?; - 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?; - store_malformed_command(&driver).await?; - driver - .execute("window.__reloadPending = true; location.reload()", Vec::new()) - .await?; - wait_until( - &driver, - "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-error')", - ) - .await?; - let refused = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), ready: root.hasAttribute('data-kanban-command-ready'), error: root.getAttribute('data-kanban-command-error'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), notice: root.querySelector('[role=status]').textContent }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(refused["order"], "1|2"); - assert_eq!(refused["ready"], false); - assert_eq!( - refused["error"], - "restore: invalid durable command corrupt:1: cardId" - ); - assert_eq!(refused["stage"], "restore"); - assert_eq!(refused["code"], "Error"); - assert_eq!( - refused["notice"], - "Local command restore failed (Error). Recovery controls remain available." - ); - - let recovery_export = export_commands(&driver).await?.json().clone(); - assert_eq!(recovery_export["schemaVersion"], 1); - assert_eq!(recovery_export["commands"].as_array().map(Vec::len), Some(1)); - assert_eq!(recovery_export["commands"][0]["id"], "corrupt:1"); - assert_eq!(recovery_export["commands"][0]["cardId"], ""); - - driver - .execute("window.__reloadPending = true", Vec::new()) - .await?; - let delete = driver - .find(By::Css("[data-kanban-command-action='delete']")) - .await?; - delete.click().await?; - 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 recovered = driver - .execute( - "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }", - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(recovered["order"], "1|2"); - assert!(recovered["error"].is_null()); - let empty_export = export_commands(&driver).await?.json().clone(); - assert_eq!(empty_export["commands"].as_array().map(Vec::len), Some(0)); - 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<()> -{ - // req: accessibility/002 req: accessibility/003 req: accessibility/004 - // req: accessibility/006 req: client_local/013 req: milestone/001 - 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 caps = headless_firefox_capabilities(true)?; - 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-hemx-client-ready')", - ) - .await?; - driver - .execute( - "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', (event) => window.__clientErrors.push(event.detail)); document.querySelector('[data-hemx-root]').addEventListener('hemx:error', (event) => window.__clientErrors.push(event.detail)); window.matchMedia = () => ({ matches: true }); return true", - Vec::new(), - ) - .await?; - driver - .execute( - r#" - window.__reorderAppliedAt = null; - new MutationObserver(() => { - const order = [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'); - if (order === '2|1' && window.__reorderAppliedAt === null) window.__reorderAppliedAt = performance.now(); - }).observe(document.querySelector('[data-key="1"]').parentElement, { childList: true }); - window.__reorderStartedAt = performance.now(); - const transfer = new DataTransfer(); - const card = document.querySelector('[data-key="1"]'); - const drop = document.querySelector('[data-hemx-client-event="drop"]'); - card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: transfer })); - drop.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until( - &driver, - "return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", - ) - .await?; - let latency = driver - .execute("return window.__reorderAppliedAt - window.__reorderStartedAt", Vec::new()) - .await? - .json() - .as_f64() - .unwrap_or(f64::INFINITY); - assert!(latency < 100.0, "local pointer reorder took {latency:.1}ms"); - assert_eq!( - driver.find(By::Css("[role=status]")).await?.text().await?, - "Moved 1 with drop" - ); - assert!( - driver - .execute( - "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-reduced-motion')", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false), - "reduced-motion preference was not preserved" - ); - - driver.refresh().await?; - wait_until( - &driver, - "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-client-ready')", - ) - .await?; - driver - .execute( - r#" - const button = document.querySelector('[data-card-id="1"]'); - button.focus(); - button.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })); - return true; - "#, - Vec::new(), - ) - .await?; - wait_until( - &driver, - "return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'", - ) - .await?; - assert!( - driver - .execute( - "return document.activeElement && document.activeElement.getAttribute('data-card-id') === '1'", - Vec::new(), - ) - .await? - .json() - .as_bool() - .unwrap_or(false), - "keyboard reorder did not restore focus to the moved card" - ); - assert_eq!( - driver.find(By::Css("[role=status]")).await?.text().await?, - "Moved 1 with keydown" - ); - Ok::<(), WebDriverError>(()) - } - .await; - let quit = driver.quit().await; - result.and(quit) -} - -fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { - let status = Command::new("cargo") - .current_dir(workspace) - .args([ - "build", - "-p", - "hemx-client-local-example", - "--target", - "wasm32-unknown-unknown", - ]) - .status() - .expect("run wasm cargo build"); - assert!(status.success(), "WASM build failed"); - - let output = workspace.join("target/client-local-bindgen"); - fs::create_dir_all(&output).expect("create wasm-bindgen output"); - let status = Command::new("wasm-bindgen") - .current_dir(workspace) - .arg("--target") - .arg("web") - .arg("--out-name") - .arg("client_local") - .arg("--out-dir") - .arg(&output) - .arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_client_local_example.wasm")) - .status() - .expect("run wasm-bindgen"); - assert!(status.success(), "wasm-bindgen failed"); - - let rendered = Command::new("cargo") - .current_dir(workspace) - .args([ - "run", - "-q", - "-p", - "hemx-client-local-example", - "--features", - "fixture", - "--bin", - "fixture", - ]) - .output() - .expect("render generated client fixture"); - assert!(rendered.status.success(), "generated fixture render failed"); - let bootstrap = newest_generated_bootstrap( - &workspace.join("target/wasm32-unknown-unknown/debug/build"), - "hemx-client-local-example-", - ); - ( - output, - bootstrap, - String::from_utf8(rendered.stdout).expect("fixture is UTF-8"), - ) -} - -fn build_kanban_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { - let status = Command::new("cargo") - .current_dir(workspace) - .args([ - "build", - "-p", - "hemx-kanban-example", - "--no-default-features", - "--features", - "client", - "--target", - "wasm32-unknown-unknown", - ]) - .status() - .expect("run kanban wasm build"); - assert!(status.success(), "Kanban WASM build failed"); - - let output = workspace.join("target/kanban-bindgen"); - fs::create_dir_all(&output).expect("create kanban wasm-bindgen output"); - let status = Command::new("wasm-bindgen") - .current_dir(workspace) - .arg("--target") - .arg("web") - .arg("--out-name") - .arg("kanban_client") - .arg("--out-dir") - .arg(&output) - .arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_kanban_example.wasm")) - .status() - .expect("run kanban wasm-bindgen"); - assert!(status.success(), "kanban wasm-bindgen failed"); - - let rendered = Command::new("cargo") - .current_dir(workspace) - .args([ - "run", - "-q", - "-p", - "hemx-kanban-example", - "--no-default-features", - "--features", - "fixture", - "--bin", - "client-fixture", - ]) - .output() - .expect("render generated kanban fixture"); - assert!(rendered.status.success(), "kanban fixture render failed"); - let bootstrap = newest_generated_bootstrap( - &workspace.join("target/wasm32-unknown-unknown/debug/build"), - "hemx-kanban-example-", - ); - ( - output, - bootstrap, - String::from_utf8(rendered.stdout).expect("kanban fixture is UTF-8"), - ) -} - -fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf { - fs::read_dir(build_dir) - .expect("read wasm build directory") - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().starts_with(prefix)) - .map(|entry| entry.path().join("out/hemx.client.js")) - .filter(|path| path.is_file()) - .max_by_key(|path| path.metadata().and_then(|meta| meta.modified()).ok()) - .expect("generated hemx client bootstrap") -} - -struct AppAssets { - module: Option, - service_worker: Option, - sync_runtime: PathBuf, -} - -fn kanban_app_assets(workspace: &Path) -> AppAssets { - AppAssets { - 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"), - } -} - -struct StaticServer { - address: String, - stop: Arc, - thread: Option>, -} - -impl StaticServer { - fn start( - package: PathBuf, - runtime: PathBuf, - bootstrap: PathBuf, - rendered: String, - asset_stem: &'static str, - app_assets: Option, - ) -> 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); - let thread = thread::spawn(move || { - while !thread_stop.load(Ordering::Relaxed) { - match listener.accept() { - Ok((stream, _)) => serve( - stream, - &package, - &runtime, - &bootstrap, - &rendered, - asset_stem, - app_assets.as_ref(), - ), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)) - } - Err(error) => panic!("fixture accept failed: {error}"), - } - } - }); - 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(); - } -} - -fn serve( - mut stream: TcpStream, - package: &Path, - runtime: &Path, - bootstrap: &Path, - rendered: &str, - asset_stem: &str, - app_assets: Option<&AppAssets>, -) { - let mut request = [0_u8; 2048]; - let length = stream.read(&mut request).unwrap_or(0); - let first = String::from_utf8_lossy(&request[..length]); - let path = first.split_whitespace().nth(1).unwrap_or("/"); - let (content_type, body) = match path { - "/" => ( - "text/html; charset=utf-8", - fixture_html(rendered, app_assets).into_bytes(), - ), - "/hemx.js" => ( - "text/javascript; charset=utf-8", - fs::read(runtime).expect("read runtime"), - ), - path if path == format!("/{asset_stem}.js") => ( - "text/javascript; charset=utf-8", - fs::read(package.join(format!("{asset_stem}.js"))).expect("read bindings"), - ), - path if path == format!("/{asset_stem}_bg.wasm") => ( - "application/wasm", - fs::read(package.join(format!("{asset_stem}_bg.wasm"))).expect("read wasm"), - ), - "/hemx.client.js" => ( - "text/javascript; charset=utf-8", - fs::read(bootstrap).expect("read generated client bootstrap"), - ), - "/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) - .expect("read sync runtime"), - ), - "/sync/patches" if app_assets.is_some() => { - let body = first.split("\r\n\r\n").nth(1).unwrap_or(""); - let patch: serde_json::Value = serde_json::from_str(body).expect("valid flat patch"); - let idempotency_key = patch["idempotencyKey"] - .as_str() - .expect("flat patch idempotency key"); - let operation_id = patch["operationId"] - .as_str() - .expect("flat patch operation id"); - assert_eq!( - operation_id, idempotency_key, - "interaction operation and idempotency identities diverged" - ); - ( - "application/json; charset=utf-8", - format!( - r#"{{"idempotencyKey":"{idempotency_key}","operationId":"{operation_id}"}}"# - ) - .into_bytes(), - ) - } - "/sync/context" if app_assets.is_some() => ( - "application/json; charset=utf-8", - br#"{"accountPartition":"demo:demo"}"#.to_vec(), - ), - _ => ("text/plain", b"not found".to_vec()), - }; - let status = if path == "/" - || path == "/hemx.js" - || path == "/hemx.client.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") - && app_assets.is_some()) - || path.starts_with(&format!("/{asset_stem}")) - { - "200 OK" - } else { - "404 Not Found" - }; - if write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).is_err() { - return; - } - let _ = stream.write_all(&body); -} - -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}" - ) -} - -async fn hold_command_transaction(driver: &WebDriver) -> WebDriverResult<()> { - driver - .execute( - r#" - window.__releaseCommandTransaction = false; - window.__commandTransactionHeld = false; - const open = indexedDB.open('hemx-kanban-v1'); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - window.__commandTransactionHeld = true; - const keepAlive = () => { - if (window.__releaseCommandTransaction) return; - const request = commands.count(); - request.onsuccess = keepAlive; - }; - keepAlive(); - }; - return true; - "#, - Vec::new(), - ) - .await?; - wait_until(driver, "return window.__commandTransactionHeld === true").await -} - -async fn release_command_transaction(driver: &WebDriver) -> WebDriverResult<()> { - driver - .execute( - "window.__releaseCommandTransaction = true; return true;", - Vec::new(), - ) - .await?; - Ok(()) -} - -async fn inject_quota_failure(driver: &WebDriver) -> WebDriverResult<()> { - let injected = driver - .execute( - r#" - if (!window.__kanbanOriginalAdd) window.__kanbanOriginalAdd = IDBObjectStore.prototype.add; - IDBObjectStore.prototype.add = function(value) { - if (this.name === 'commands' && value && value.kind === 'reorder_card') { - IDBObjectStore.prototype.add = window.__kanbanOriginalAdd; - throw new DOMException('Injected storage quota exhaustion', 'QuotaExceededError'); - } - return window.__kanbanOriginalAdd.call(this, value); - }; - return IDBObjectStore.prototype.add !== window.__kanbanOriginalAdd; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!(injected, true, "failed to inject quota error"); - Ok(()) -} - -async fn store_replay_commands(driver: &WebDriver, first: u64, last: u64) -> WebDriverResult<()> { - let stored = driver - .execute_async( - r#" - const first = arguments[0]; - const last = arguments[1]; - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onerror = () => done({ error: open.error && open.error.name }); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - const commands = tx.objectStore('commands'); - for (let causal = first; causal <= last; causal += 1) { - commands.add({ - id: `replay:${causal}`, - schemaVersion: 2, - accountPartition: 'demo:demo', - actor: 'replay', - session: 'replay', - causal, - queuedAt: Date.now(), - kind: 'reorder_card', - cardId: '1', - targetColumn: 'done', - eventKind: 'click', - key: null, - }); - } - tx.oncomplete = () => done({ count: last - first + 1 }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - vec![first.into(), last.into()], - ) - .await? - .json() - .clone(); - assert_eq!( - stored["count"], - last - first + 1, - "failed to store replay commands: {stored}" - ); - Ok(()) -} - -async fn store_malformed_command(driver: &WebDriver) -> WebDriverResult<()> { - let stored = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onerror = () => done({ error: open.error && open.error.name }); - open.onsuccess = () => { - const tx = open.result.transaction('commands', 'readwrite'); - tx.objectStore('commands').add({ - id: 'corrupt:1', - schemaVersion: 2, - accountPartition: 'demo:demo', - actor: 'corrupt', - session: 'corrupt', - causal: 1, - queuedAt: Date.now(), - kind: 'reorder_card', - cardId: '', - targetColumn: 'done', - eventKind: 'click', - key: null, - }); - tx.oncomplete = () => done({ stored: true }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert_eq!( - stored["stored"], true, - "failed to store malformed command: {stored}" - ); - Ok(()) -} - -async fn occupy_next_command_id(driver: &WebDriver) -> WebDriverResult<()> { - let occupied = driver - .execute_async( - r#" - const done = arguments[arguments.length - 1]; - const open = indexedDB.open('hemx-kanban-v1'); - open.onerror = () => done({ error: open.error && open.error.name }); - open.onsuccess = () => { - const tx = open.result.transaction(['commands', 'meta'], 'readwrite'); - const commands = tx.objectStore('commands'); - const meta = tx.objectStore('meta'); - let actor; - let causal; - let pending = 2; - const addCollision = () => { - pending -= 1; - if (pending !== 0) return; - const next = causal + 1; - commands.add({ - id: `${actor}:${next}`, - schemaVersion: 2, - accountPartition: 'demo:demo', - actor, - session: 'fault-injection', - causal: next, - queuedAt: Date.now(), - kind: 'reorder_card', - cardId: 'fault-injection', - targetColumn: 'done', - eventKind: 'click', - key: null, - }); - }; - const actorRequest = meta.get('actor:demo:demo'); - actorRequest.onsuccess = () => { actor = actorRequest.result; addCollision(); }; - const causalRequest = meta.get('causal:demo:demo'); - causalRequest.onsuccess = () => { causal = causalRequest.result; addCollision(); }; - tx.oncomplete = () => done({ id: `${actor}:${causal + 1}` }); - tx.onabort = () => done({ error: tx.error && tx.error.name }); - }; - "#, - Vec::new(), - ) - .await? - .json() - .clone(); - assert!( - occupied["error"].is_null(), - "failed to occupy command id: {occupied}" - ); - assert!( - occupied["id"].as_str().is_some(), - "missing occupied id: {occupied}" - ); - Ok(()) -} - -async fn export_commands(driver: &WebDriver) -> WebDriverResult { - 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 { - if driver - .execute(script, Vec::new()) - .await? - .json() - .as_bool() - .unwrap_or(false) - { - return Ok(()); - } - if Instant::now() >= deadline { - let state = driver - .execute( - "return { html: document.body.innerHTML, ready: document.readyState, errors: window.__clientErrors || [] }", - Vec::new(), - ) - .await? - .json() - .clone(); - panic!("timed out waiting for browser fixture; browser state: {state}"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn resource_count(driver: &WebDriver) -> WebDriverResult { - Ok(driver - .execute( - "return performance.getEntriesByType('resource').length", - Vec::new(), - ) - .await? - .json() - .as_u64() - .unwrap_or_default()) -} - -fn available_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("reserve webdriver port") - .local_addr() - .expect("webdriver address") - .port() -} - -struct ProcessGuard(std::process::Child); - -impl ProcessGuard { - fn start(mut command: Command, address: &str) -> Self { - let child = command.spawn().expect("start geckodriver"); - let deadline = Instant::now() + STARTUP_TIMEOUT; - while std::net::TcpStream::connect(address).is_err() { - assert!(Instant::now() < deadline, "timed out starting geckodriver"); - thread::sleep(Duration::from_millis(50)); - } - Self(child) - } -} - -impl Drop for ProcessGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} diff --git a/hemx-xtask/Cargo.toml b/hemx-xtask/Cargo.toml deleted file mode 100644 index fceab27..0000000 --- a/hemx-xtask/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "hemx-xtask" -version.workspace = true -edition.workspace = true -publish = false - -[[bin]] -name = "hemx-ci" -path = "src/main.rs" - -[dependencies] -hemx-js = { path = "../hemx-js" } diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs deleted file mode 100644 index 07affcc..0000000 --- a/hemx-xtask/src/main.rs +++ /dev/null @@ -1,2004 +0,0 @@ -use std::env; -use std::fs; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, ExitCode, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; - -fn main() -> ExitCode { - let mut args = env::args().skip(1); - match args.next().as_deref() { - Some("test") | None => run_test_plan(), - Some("html-examples-smoke") => run_html_examples_smoke(), - Some("bench") => run_bench_plan(), - Some("mutation") => { - let package = args.next(); - let shard = args.next(); - run_mutation_plan(package.as_deref(), shard.as_deref()) - } - Some("workout") => { - let subcommand = args.next(); - let operand = args.next(); - run_workout(subcommand.as_deref(), operand.as_deref()) - } - Some("app") => { - let subcommand = args.next(); - let operands = args.collect::>(); - run_app(subcommand.as_deref(), &operands) - } - Some("workout-mobile") => run_workout_mobile(args.next().as_deref()), - Some("help") | Some("--help") | Some("-h") => { - print_help(); - ExitCode::SUCCESS - } - Some(command) => { - eprintln!("unknown hemx-ci command `{command}`\n"); - print_help(); - ExitCode::from(2) - } - } -} - -fn print_help() { - println!( - "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_MUTEST_BIN=PATH mutest executable (default: mutest)\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" - ); -} - -struct HtmlExamplesServer { - child: Child, -} - -impl Drop for HtmlExamplesServer { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -fn run_html_examples_smoke() -> ExitCode { - // req: examples/001 req: htmx_equivalents/005 req: test/006 - let port = match pick_unused_port() { - Ok(port) => port, - Err(code) => return code, - }; - let addr = format!("127.0.0.1:{port}"); - let url = format!("http://{addr}"); - if let Err(code) = ensure_cdp_browser() { - return code; - } - // Clear any timers from a prior smoke page before a fresh server binds the port. - if let Err(code) = cdp_tab_goto("about:blank") { - return code; - } - let _server = match start_html_examples_server(&addr) { - Ok(server) => server, - Err(code) => return code, - }; - - let checks = [ - ("progress", PROGRESS_SMOKE), - ("click-to-edit save", CLICK_TO_EDIT_SMOKE), - ("edit-row save", EDIT_ROW_SMOKE), - ("inline validation revalidation", INLINE_VALIDATION_SMOKE), - ("active search", ACTIVE_SEARCH_SMOKE), - ("delete row", DELETE_ROW_SMOKE), - ("lazy load", LAZY_LOAD_SMOKE), - ("click-to-load load more", CLICK_TO_LOAD_SMOKE), - ("infinite/reveal rows", INFINITE_SCROLL_SMOKE), // req: test/006 test; req: runtime/007 - ("value select", VALUE_SELECT_SMOKE), - ("reset user input", RESET_INPUT_SMOKE), - ]; - - for (name, script) in checks { - if let Err(code) = cdp_tab_goto(&url) { - return code; - } - if let Err(code) = cdp_wait_for_html_examples() { - return code; - } - if let Err(code) = cdp_assert(name, script) { - return code; - } - println!("html_examples smoke ok: {name}"); - } - - // Re-load the runtime with IntersectionObserver disabled to exercise the - // deterministic revealed fallback path in a real browser. req: convention/014 req: test/006 - if let Err(code) = cdp_tab_goto(&url) { - return code; - } - if let Err(code) = cdp_wait_for_html_examples() { - return code; - } - if let Err(code) = cdp_assert( - "revealed fallback without IntersectionObserver", - REVEALED_FALLBACK_SMOKE, - ) { - return code; - } - println!("html_examples smoke ok: revealed fallback without IntersectionObserver"); - - ExitCode::SUCCESS -} - -fn pick_unused_port() -> Result { - TcpListener::bind("127.0.0.1:0") - .and_then(|listener| listener.local_addr()) - .map(|addr| addr.port()) - .map_err(|err| { - eprintln!("failed to choose local html_examples smoke port: {err}"); - ExitCode::FAILURE - }) -} - -fn start_html_examples_server(addr: &str) -> Result { - let mut child = Command::new("cargo") - .current_dir(workspace_root()) - .args(["run", "-p", "hemx-html-examples"]) - .env( - "HEMX_HTML_EXAMPLES_PORT", - addr.rsplit(':').next().unwrap_or("3029"), - ) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|err| { - eprintln!("failed to start hemx-html-examples: {err}"); - ExitCode::FAILURE - })?; - - for _ in 0..80 { - if TcpStream::connect(addr).is_ok() { - return Ok(HtmlExamplesServer { child }); - } - if let Ok(Some(status)) = child.try_wait() { - eprintln!("hemx-html-examples exited before listening on {addr}: {status}"); - return Err(ExitCode::FAILURE); - } - thread::sleep(Duration::from_millis(250)); - } - - let _ = child.kill(); - let _ = child.wait(); - eprintln!("hemx-html-examples did not listen on {addr} within 20s"); - Err(ExitCode::FAILURE) -} - -fn ensure_cdp_browser() -> Result<(), ExitCode> { - if Command::new("cdp-browser") - .arg("status") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) - { - return Ok(()); - } - let status = Command::new("cdp-browser") - .args(["launch", "--port", "9222"]) - .status() - .map_err(|err| { - eprintln!("failed to launch cdp-browser; install/fix browser tooling first: {err}"); - ExitCode::FAILURE - })?; - if status.success() { - Ok(()) - } else { - eprintln!("cdp-browser launch failed with {status}"); - Err(ExitCode::from(status.code().unwrap_or(1) as u8)) - } -} - -fn cdp_tab_goto(url: &str) -> Result<(), ExitCode> { - let status = Command::new("cdp-browser") - .args(["tab-goto", url]) - .stdout(Stdio::null()) - .status() - .map_err(|err| { - eprintln!("failed to navigate browser to {url}: {err}"); - ExitCode::FAILURE - })?; - if status.success() { - Ok(()) - } else { - eprintln!("cdp-browser tab-goto failed with {status}"); - Err(ExitCode::from(status.code().unwrap_or(1) as u8)) - } -} - -fn cdp_wait_for_html_examples() -> Result<(), ExitCode> { - for _ in 0..40 { - let output = Command::new("cdp-browser") - .args([ - "js", - "document.readyState === 'complete' && document.querySelectorAll('form').length >= 11", - ]) - .output() - .map_err(|err| { - eprintln!("failed to poll browser page readiness: {err}"); - ExitCode::FAILURE - })?; - if output.status.success() - && String::from_utf8_lossy(&output.stdout) - .trim() - .ends_with("true") - { - return Ok(()); - } - thread::sleep(Duration::from_millis(250)); - } - eprintln!("html_examples page did not become ready within 10s"); - Err(ExitCode::FAILURE) -} - -fn cdp_assert(name: &str, script: &str) -> Result<(), ExitCode> { - // Each interaction must be handled by hemx without full-page navigation or - // reload; failures here catch native form fallback sneaking into the smoke. req: test/014 - let guarded_script = format!( - r#"(async()=>{{ - const __hemxSmokeUrl = location.href; - const __hemxSmokeMarker = String(Date.now()) + Math.random(); - const __hemxSmokeNavCount = performance.getEntriesByType("navigation").length; - window.__hemxSmokeNoReload = __hemxSmokeMarker; - const __hemxSmokeResult = await ({script}); - if ("{name}" !== "active search" && location.href !== __hemxSmokeUrl) throw new Error("page navigated during smoke interaction"); - if (window.__hemxSmokeNoReload !== __hemxSmokeMarker) throw new Error("page reloaded during smoke interaction"); - if (performance.getEntriesByType("navigation").length !== __hemxSmokeNavCount) throw new Error("navigation entry changed during smoke interaction"); - return __hemxSmokeResult; - }})()"# - ); - let output = Command::new("cdp-browser") - .args(["js", &guarded_script]) - .output() - .map_err(|err| { - eprintln!("failed to run browser smoke `{name}`: {err}"); - ExitCode::FAILURE - })?; - if output.status.success() { - Ok(()) - } else { - eprintln!("browser smoke `{name}` failed"); - eprintln!("{}", String::from_utf8_lossy(&output.stdout)); - eprintln!("{}", String::from_utf8_lossy(&output.stderr)); - Err(ExitCode::from(output.status.code().unwrap_or(1) as u8)) - } -} - -const CLICK_TO_EDIT_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[0]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.name&&f.elements.name.value==="Ada Lovelace"); save.elements.name.value="Ada Byron"; save.elements.email.value="ada.byron@example.com"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!(text().includes("Ada Byron")&&!text().includes("Ada Lovelace ada@example.com"))) throw new Error("click-to-edit did not save"); return true;})()"#; -const EDIT_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[1]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.title); save.elements.title.value="Write dynamic HTML"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!text().includes("Write dynamic HTML")) throw new Error("edit-row did not save"); return true;})()"#; -const INLINE_VALIDATION_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.getAttribute("data-hemx-on")==="input"); const input=f.elements.email; input.value="wrong"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"g"})); await new Promise(r=>setTimeout(r,700)); const afterBad=document.body.textContent; input.value="qwdqdqwd@"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"@"})); await new Promise(r=>setTimeout(r,700)); if(document.body.textContent.includes("qwdqdqwd@ is valid")) throw new Error("partial email was accepted"); if(input.value!=="qwdqdqwd@") throw new Error("inline validation reset partial email"); input.value="xyz@example.com"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"m"})); await new Promise(r=>setTimeout(r,700)); const afterGood=document.body.textContent; if(!(afterBad.includes("Email needs a name and dotted domain")&&!afterGood.includes("Email needs a name and dotted domain")&&afterGood.includes("xyz@example.com is valid")&&input.value==="xyz@example.com")) throw new Error("inline validation did not recover"); return true;})()"#; -const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const input=document.querySelector('form input[name="q"]'); if(!input||!input.form) throw new Error("search form not found"); input.value="beta"; input.dispatchEvent(new Event("input",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(location.search.includes("q=beta")&&results.length===1&&results[0]==="Beta")) throw new Error("active search did not filter URL-state rows"); return true;})()"#; -const DELETE_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const del=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.trim()==="Delete"&&Array.from(f.elements).some(e=>e.name==="id"&&e.value==="1")); del.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(text().includes("Review content")) throw new Error("delete row did not remove row"); return true;})()"#; -const LAZY_LOAD_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); const panel=()=>f.nextElementSibling; const before=panel().textContent.trim(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=panel().textContent.trim(); if(!(after.includes("Lazy content loaded by server update #")&&after!==before)) throw new Error("lazy load did not visibly update"); return true;})()"#; -const CLICK_TO_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load more")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 3")&&text().includes("Loaded row 4"))) throw new Error("click-to-load did not append rows"); return true;})()"#; -const INFINITE_SCROLL_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Reveal more rows")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 4")&&text().includes("Loaded row 6"))) throw new Error("infinite scroll did not append rows"); return true;})()"#; -const REVEALED_FALLBACK_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const runtime=document.querySelector('script[src*="hemx"][src$=".js"]'); if(!runtime) throw new Error("runtime script not found"); Object.defineProperty(window,"IntersectionObserver",{value:undefined,configurable:true}); const reloaded=document.createElement("script"); reloaded.src=runtime.src; document.head.appendChild(reloaded); for(let i=0;i<20;i++){if(text().includes("Lazy content loaded by server update #")&&text().includes("Loaded row 4")&&text().includes("Loaded row 6")) return true; await new Promise(r=>setTimeout(r,150));} throw new Error("revealed fallback did not dispatch lazy/infinite forms without a click");})()"#; -const PROGRESS_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Tick progress")); if(f.hasAttribute("data-hemx-interval")) throw new Error("progress form should wait for an explicit click"); const text=()=>document.querySelector("progress").parentElement.textContent.replace(/\s+/g," ").trim(); const before=text(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=text(); if(!(before.includes("0% complete")&&after.includes("25% complete"))) throw new Error("progress click did not visibly tick from 0% to 25%"); return true;})()"#; -const VALUE_SELECT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.category); f.elements.category.value="numbers"; f.elements.category.dispatchEvent(new Event("change",{bubbles:true,cancelable:true})); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const options=Array.from(document.querySelectorAll("select[name=value] option")).map(option=>option.textContent.trim()); if(!(options.includes("One")&&options.includes("Two")&&!options.includes("Alpha"))) throw new Error("value select did not replace options"); return true;})()"#; -const RESET_INPUT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.message); f.elements.message.value="hello reset"; f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(document.body.textContent.includes("Sent: hello reset")&&f.elements.message.value==="")) throw new Error("reset user input did not clear field"); return true;})()"#; - -fn run_app(command: Option<&str>, operands: &[String]) -> ExitCode { - // req: ceremony/005 req: ceremony/006 req: canonical_authoring/003 - match command.unwrap_or("help") { - "new" | "create" => run_app_new(operands), - "help" | "--help" | "-h" => { - print_help(); - ExitCode::SUCCESS - } - other => { - eprintln!("unknown app command `{other}`\n"); - print_help(); - ExitCode::from(2) - } - } -} - -fn run_app_new(operands: &[String]) -> ExitCode { - // req: ceremony/005 req: ceremony/006 req: canonical_authoring/003 - let (mobile, destination) = match operands { - [destination] => (false, destination.as_str()), - [flag, destination] if flag == "--mobile" => (true, destination.as_str()), - [destination, flag] if flag == "--mobile" => (true, destination.as_str()), - _ => { - eprintln!("usage: cargo run -p hemx-xtask -- app new [--mobile] PATH"); - return ExitCode::from(2); - } - }; - let destination = PathBuf::from(destination); - if destination.exists() { - eprintln!( - "{} already exists; choose an empty path", - destination.display() - ); - return ExitCode::from(2); - } - let result = if mobile { - create_mobile_app_scaffold(&destination) - } else { - create_app_scaffold(&destination) - }; - match result { - Ok(()) => { - println!( - "{}\tpath={}", - if mobile { "app-new-mobile" } else { "app-new" }, - destination.display() - ); - println!("next\tcd {}", destination.display()); - if mobile { - println!("next\t./hemx-app test"); - println!("next\t./hemx-app build"); - println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release"); - println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify"); - } else { - println!("next\tcargo test"); - println!("next\tcargo run"); - println!("next\tcargo build --release"); - } - ExitCode::SUCCESS - } - Err(err) => { - eprintln!("failed to create app at {}: {err}", destination.display()); - ExitCode::FAILURE - } - } -} - -fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode { - // req: examples/001 req: examples/006 - match command.unwrap_or("dev") { - "new" | "create" => run_workout_new(operand), - "dev" | "run" => Step::new( - "workout-dev-server", - ["run", "--bin", "hemx-workout-example"], - ) - .run(&Budget::detect()) - .map(|()| ExitCode::SUCCESS) - .unwrap_or_else(|code| code), - "test" => Step::new("workout-test", ["test", "-p", "hemx-workout-example"]) - .run(&Budget::detect()) - .map(|()| ExitCode::SUCCESS) - .unwrap_or_else(|code| code), - "build" | "release-build" => run_workout_release_build("workout-release-build"), - "mobile-release" => run_workout_mobile_release(), - "mobile-verify" | "verify" => run_workout_mobile_verify(), - "doctor" => { - let config = WorkoutMobileConfig::from_env(); - let blockers = mobile_external_blockers(&config); - print_mobile_doctor(&config, &blockers); - ExitCode::SUCCESS - } - "help" | "--help" | "-h" => { - print_help(); - ExitCode::SUCCESS - } - other => { - eprintln!("unknown workout command `{other}`\n"); - print_help(); - ExitCode::from(2) - } - } -} - -fn run_workout_new(destination: Option<&str>) -> ExitCode { - // req: examples/001 req: examples/006 - let Some(destination) = destination else { - eprintln!("usage: cargo run -p hemx-xtask -- workout new PATH"); - return ExitCode::from(2); - }; - let destination = PathBuf::from(destination); - if destination.exists() { - eprintln!( - "{} already exists; choose an empty path", - destination.display() - ); - return ExitCode::from(2); - } - match create_workout_app(&destination) { - Ok(()) => { - println!("workout-new\tpath={}", destination.display()); - println!( - "next\tcargo run --manifest-path {}/Cargo.toml --bin workout-app", - destination.display() - ); - ExitCode::SUCCESS - } - Err(err) => { - eprintln!( - "failed to create Workout app at {}: {err}", - destination.display() - ); - ExitCode::FAILURE - } - } -} - -fn create_app_scaffold(destination: &Path) -> std::io::Result<()> { - let root = repo_root()?; - let hemplate = hemplate_checkout(&root)?; - copy_dir(&root.join("examples/v0"), destination)?; - let cargo_toml = destination.join("Cargo.toml"); - let manifest = fs::read_to_string(&cargo_toml)?; - fs::write( - &cargo_toml, - manifest - .replace("name = \"hemx-v0-examples\"", "name = \"hemx-app\"") - .replace("version.workspace = true", "version = \"0.1.0\"") - .replace("edition.workspace = true", "edition = \"2021\"") - .replace( - "path = \"../../../hemplate/hemplate\"", - &format!("path = \"{}\"", hemplate.display()), - ) - .replace( - "path = \"../../hemx\"", - &format!("path = \"{}\"", root.join("hemx").display()), - ) - .replace( - "path = \"../../hemx-axum\"", - &format!("path = \"{}\"", root.join("hemx-axum").display()), - ) - .replace( - "path = \"../../hemx-test\"", - &format!("path = \"{}\"", root.join("hemx-test").display()), - ) - .replace( - "path = \"../../hemx-build\"", - &format!("path = \"{}\"", root.join("hemx-build").display()), - ), - )?; - replace_in_tree(destination, "hemx_v0_examples", "hemx_app")?; - replace_in_tree(destination, "hemx-v0-examples", "hemx-app")?; - replace_in_tree(destination, "hemx v0 browser examples", "hemx app")?; - fs::write( - destination.join("README.md"), - "# hemx app\n\nThis app was created by `cargo run -p hemx-xtask -- app new PATH`. It is the generic checked-hypermedia starter: one page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing. req: ceremony/005\n\n## Run\n\n```sh\ncargo run\n```\n\nOpen .\n\n## Test and build\n\n```sh\ncargo test\ncargo build --release\n```\n\n## Edit\n\n- `templates/todos.heml` owns the reusable partials and generated target names.\n- `src/main.rs` owns handlers and app state.\n- `src/lib.rs` re-exports generated `ui` helpers.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003\n", - )?; - fs::write( - destination.join("CREATED.md"), - "# Created hemx app\n\nUse one app-owned command surface from this directory:\n\n```sh\ncargo test\ncargo run\ncargo build --release\n```\n\nThis scaffold is the generic checked-hypermedia starting point: one page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003 req: ceremony/005\n", - )?; - Ok(()) -} - -fn create_mobile_app_scaffold(destination: &Path) -> std::io::Result<()> { - // req: ceremony/006 req: examples/006 - create_workout_app(destination)?; - fs::rename( - destination.join("hemx-workout"), - destination.join("hemx-app"), - )?; - for (from, to) in [ - ("hemx_workout_app", "hemx_app"), - ("hemx-workout-app", "hemx-app"), - ("workout-app", "hemx-app"), - ("./hemx-workout", "./hemx-app"), - ("hemx-workout", "hemx-app"), - ("HEMX_WORKOUT_", "HEMX_APP_"), - ("target/hemx-mobile/workout", "target/hemx-mobile/app"), - ("com.hemx.workout", "com.hemx.app"), - ("hemx Workout Copilot", "hemx App"), - ( - "# Workout mobile external blockers", - "# hemx app mobile external blockers", - ), - ("workout-mobile", "app-mobile"), - ] { - replace_in_tree(destination, from, to)?; - } - fs::write( - destination.join("README.md"), - "# hemx mobile app\n\nThis app was created by `cargo run -p hemx-xtask -- app new --mobile PATH`. It is a phone-first hemx starter with a real page/form/keyed partial/notice flow, typed host haptics/share calls, app-owned command/event/projection recovery truth, and inspectable mobile release-kit commands. The starter uses the Workout product flow as the first concrete app, but the command surface is owned by this app. req: ceremony/006\n\n## Run\n\n```sh\n./hemx-app dev\n```\n\nOpen the printed local URL. Set `HEMX_APP_ADDR=127.0.0.1:3030` if the default port is busy.\n\n## Test and build\n\n```sh\n./hemx-app test\n./hemx-app build\n./hemx-app doctor\n```\n\n## Mobile release kit\n\n```sh\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify\n```\n\nThe release kit writes inspectable Android/iOS metadata under `target/hemx-mobile/app`, records runtime/cache/offline/host capability policy, and reports external blockers for SDKs, signing, and store accounts without storing secrets or predicting store approval.\n\n## Boundaries\n\nUse this path for Rust-owned hypermedia apps that need installability, recovery, and a few explicit host capabilities. Do not treat it as a native UI framework, client store, plugin marketplace, or store-submission bot.\n", - )?; - fs::write( - destination.join("CREATED.md"), - "# Created hemx mobile app\n\nUse one app-owned command surface from this directory:\n\n```sh\n./hemx-app dev\n./hemx-app test\n./hemx-app build\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release\nHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify\n./hemx-app doctor\n```\n\nThis app owns command/event/projection state and keeps Android/iOS SDKs, store submission targets, and signing outside the repo. req: ceremony/006 req: examples/006\n", - )?; - fs::write( - destination.join("MOBILE_STARTER.md"), - "# Mobile hemx starter\n\nThis starter is the phone-first path exposed through `app new --mobile`: it has a real page/form/keyed partial/notice flow, typed host haptics/share calls returning through Rust handlers, app-owned command/event/projection recovery truth, and app-owned `hemx-app mobile-release` / `hemx-app mobile-verify` release-kit commands.\n\nThe starter uses the Workout product flow as a concrete first app, but its command surface, crate name, binary name, release output, and environment variables are owned by the created app. It deliberately does not add a hemx mobile framework, client store, signing secret owner, or store submission bot. req: ceremony/006 req: examples/006\n", - )?; - Ok(()) -} - -fn create_workout_app(destination: &Path) -> std::io::Result<()> { - let root = repo_root()?; - let hemplate = hemplate_checkout(&root)?; - copy_dir(&root.join("examples/workout"), destination)?; - let cargo_toml = destination.join("Cargo.toml"); - let manifest = fs::read_to_string(&cargo_toml)?; - fs::write( - &cargo_toml, - manifest - .replace( - "name = \"hemx-workout-example\"", - "name = \"hemx-workout-app\"", - ) - .replace( - "[[bin]]\nname = \"hemx-workout-app\"", - "[[bin]]\nname = \"workout-app\"", - ) - .replace("version.workspace = true", "version = \"0.1.0\"") - .replace("edition.workspace = true", "edition = \"2021\"") - .replace( - "path = \"../../../hemplate/hemplate\"", - &format!("path = \"{}\"", hemplate.display()), - ) - .replace( - "path = \"../../hemx\"", - &format!("path = \"{}\"", root.join("hemx").display()), - ) - .replace( - "path = \"../../hemx-axum\"", - &format!("path = \"{}\"", root.join("hemx-axum").display()), - ) - .replace( - "path = \"../../hemx-host\"", - &format!("path = \"{}\"", root.join("hemx-host").display()), - ) - .replace( - "path = \"../../hemx-test\"", - &format!("path = \"{}\"", root.join("hemx-test").display()), - ) - .replace( - "path = \"../../hemx-build\"", - &format!("path = \"{}\"", root.join("hemx-build").display()), - ), - )?; - replace_in_tree(destination, "hemx_workout_example", "hemx_workout_app")?; - replace_in_tree(destination, "hemx-workout-example", "workout-app")?; - write_workout_app_command(destination)?; - fs::write( - destination.join("CREATED.md"), - "# Created Workout app\n\nUse one command surface from this directory:\n\n```sh\n./hemx-workout dev\n./hemx-workout test\n./hemx-workout build\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-release\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-verify\n./hemx-workout doctor\n```\n\nThis app owns command/event/projection state and keeps Android/iOS SDKs, store submission targets, and signing outside the repo. req: examples/006\n", - )?; - Ok(()) -} - -fn repo_root() -> std::io::Result { - let cwd = env::current_dir()?; - if cwd.join("examples/workout").exists() { - return Ok(cwd); - } - Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf()) -} - -fn hemplate_checkout(root: &Path) -> std::io::Result { - let hemplate = root.join("../hemplate/hemplate"); - if hemplate.join("Cargo.toml").exists() { - Ok(hemplate) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "hemplate checkout not found at {}; clone hemplate next to hemx or adjust the generated Cargo.toml after creation", - hemplate.display() - ), - )) - } -} - -fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> { - let contents = fs::read_to_string(path)?; - fs::write(path, contents.replace(from, to)) -} - -fn replace_in_tree(path: &Path, from: &str, to: &str) -> std::io::Result<()> { - if path.is_dir() { - for entry in fs::read_dir(path)? { - replace_in_tree(&entry?.path(), from, to)?; - } - } else { - replace_in_file(path, from, to)?; - } - Ok(()) -} - -fn write_workout_app_command(destination: &Path) -> std::io::Result<()> { - let script_path = destination.join("hemx-workout"); - fs::write(&script_path, workout_app_command_script())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script_path, permissions)?; - } - Ok(()) -} - -fn workout_app_command_script() -> String { - format!( - r#"#!/usr/bin/env sh -set -eu -APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -CMD=${{1:-dev}} -OUT=${{HEMX_WORKOUT_MOBILE_OUT:-"$APP_DIR/target/hemx-mobile/workout"}} -ORIGIN=${{HEMX_WORKOUT_ORIGIN:-https://workout.example.invalid}} -APP_ID=${{HEMX_WORKOUT_APP_ID:-com.hemx.workout}} -APP_NAME=${{HEMX_WORKOUT_APP_NAME:-hemx Workout Copilot}} -VERSION=${{HEMX_WORKOUT_VERSION:-0.1.0}} -RUNTIME_PATH='{runtime_path}' -RUNTIME_SHA='{runtime_sha}' - -write_blockers() {{ - mkdir -p "$OUT" - {{ - echo '# Workout mobile external blockers' - echo - echo 'The release kit is generated, but these external inputs are still required for signed store artifacts:' - echo - [ -n "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ] || echo '- Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts' - command -v java >/dev/null 2>&1 || echo '- Java runtime not found: Android packaging requires a JDK' - [ -n "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ] || echo '- Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing' - [ -n "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ] || echo '- Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app' - command -v xcodebuild >/dev/null 2>&1 || echo '- Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS' - [ -n "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ] || echo '- iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export' - [ -n "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ] || echo '- App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app' - }} > "$OUT/BLOCKERS.md" -}} - -write_blockers_json() {{ - first=1 - if [ -z "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts'; first=0; fi - if ! command -v java >/dev/null 2>&1; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Java runtime not found: Android packaging requires a JDK'; first=0; fi - if [ -z "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing'; first=0; fi - if [ -z "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app'; first=0; fi - if ! command -v xcodebuild >/dev/null 2>&1; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS'; first=0; fi - if [ -z "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export'; first=0; fi - if [ -z "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ]; then [ "$first" = 1 ] || printf ', '; printf '"%s"' 'App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app'; fi -}} - -check_blocker() {{ - grep -F "$1" "$OUT/BLOCKERS.md" >/dev/null || {{ echo "BLOCKERS.md missing external blocker: $1" >&2; exit 1; }} - grep -F "$1" "$OUT/release-manifest.json" >/dev/null || {{ echo "release-manifest.json missing external blocker: $1" >&2; exit 1; }} -}} - -check_external_blockers() {{ - [ -n "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ] || check_blocker 'Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts' - command -v java >/dev/null 2>&1 || check_blocker 'Java runtime not found: Android packaging requires a JDK' - [ -n "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ] || check_blocker 'Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing' - [ -n "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ] || check_blocker 'Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app' - command -v xcodebuild >/dev/null 2>&1 || check_blocker 'Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS' - [ -n "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ] || check_blocker 'iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export' - [ -n "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ] || check_blocker 'App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app' -}} - -case "$CMD" in - dev|run) - exec cargo run --manifest-path "$APP_DIR/Cargo.toml" --bin workout-app - ;; - test) - exec cargo test --manifest-path "$APP_DIR/Cargo.toml" - ;; - build) - exec cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app - ;; - mobile-release) - cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app - mkdir -p "$OUT/android" "$OUT/ios" - write_blockers - printf 'path\talgorithm\tdigest\n%s\tsha256\t%s\n' "$RUNTIME_PATH" "$RUNTIME_SHA" > "$OUT/asset-integrity.tsv" - cat > "$OUT/release-manifest.json" < "$OUT/android/twa-release.json" < "$OUT/ios/webview-release.json" <&2; exit 1; }} - case "$ORIGIN" in https://*) ;; *) echo 'HEMX_WORKOUT_ORIGIN must be HTTPS' >&2; exit 1;; esac - for f in release-manifest.json asset-integrity.tsv android/twa-release.json ios/webview-release.json BLOCKERS.md; do test -f "$OUT/$f" || {{ echo "missing $OUT/$f" >&2; exit 1; }}; done - grep -F "$RUNTIME_PATH" "$OUT/release-manifest.json" >/dev/null - grep -F "$RUNTIME_SHA" "$OUT/asset-integrity.tsv" >/dev/null - grep -F 'app-owned command/event/projection records' "$OUT/release-manifest.json" >/dev/null - grep -F 'host_result_kinds' "$OUT/android/twa-release.json" >/dev/null - grep -F 'host_result_kinds' "$OUT/ios/webview-release.json" >/dev/null - check_external_blockers - echo "workout-mobile-verified\tout=$OUT" - ;; - doctor) - write_blockers - cat "$OUT/BLOCKERS.md" - ;; - *) - echo 'usage: ./hemx-workout dev|test|build|mobile-release|mobile-verify|doctor' >&2 - exit 2 - ;; -esac -"#, - runtime_path = hemx_js::RUNTIME_JS_PATH, - runtime_sha = hemx_js::RUNTIME_JS_HASH, - ) -} - -fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> { - fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - let source_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - if source_path.is_dir() { - copy_dir(&source_path, &destination_path)?; - } else { - fs::copy(&source_path, &destination_path)?; - } - } - Ok(()) -} - -fn run_workout_mobile(command: Option<&str>) -> ExitCode { - match command.unwrap_or("release") { - "release" => run_workout_mobile_release(), - "verify" => run_workout_mobile_verify(), - "doctor" => { - let config = WorkoutMobileConfig::from_env(); - let blockers = mobile_external_blockers(&config); - print_mobile_doctor(&config, &blockers); - ExitCode::SUCCESS - } - "help" | "--help" | "-h" => { - print_help(); - ExitCode::SUCCESS - } - other => { - eprintln!("unknown workout-mobile command `{other}`\n"); - print_help(); - ExitCode::from(2) - } - } -} - -fn run_workout_release_build(label: &'static str) -> ExitCode { - let budget = Budget::detect(); - budget.report(); - Step::new( - label, - ["build", "--release", "--bin", "hemx-workout-example"], - ) - .run(&budget) - .map(|()| ExitCode::SUCCESS) - .unwrap_or_else(|code| code) -} - -fn run_workout_mobile_release() -> ExitCode { - // req: examples/001 req: examples/006 req: host/002 req: local/001 - let code = run_workout_release_build("workout-mobile-server-release"); - if code != ExitCode::SUCCESS { - return code; - } - - let config = WorkoutMobileConfig::from_env(); - let blockers = mobile_external_blockers(&config); - match write_workout_mobile_release(&config, &blockers) { - Ok(()) => { - println!( - "workout-mobile\tout={}\tblockers={}", - config.out_dir.display(), - blockers.len() - ); - for blocker in &blockers { - println!("workout-mobile-blocker\t{}", blocker); - } - ExitCode::SUCCESS - } - Err(err) => { - eprintln!("failed to write Workout mobile release kit: {err}"); - ExitCode::FAILURE - } - } -} - -fn run_workout_mobile_verify() -> ExitCode { - // req: examples/001 req: examples/006 req: host/002 req: local/001 - let budget = Budget::detect(); - budget.report(); - if let Err(code) = Step::new( - "workout-mobile-product-gate", - ["test", "-p", "hemx-workout-example"], - ) - .run(&budget) - { - return code; - } - - let config = WorkoutMobileConfig::from_env(); - let failures = verify_workout_mobile_release(&config, true); - let blockers = mobile_external_blockers(&config); - if failures.is_empty() { - println!( - "workout-mobile-verified\tout={}\tblockers={}", - config.out_dir.display(), - blockers.len() - ); - for blocker in &blockers { - println!("workout-mobile-blocker\t{}", blocker); - } - ExitCode::SUCCESS - } else { - eprintln!("Workout mobile release kit is not verifiable:"); - for failure in failures { - eprintln!("- {failure}"); - } - ExitCode::FAILURE - } -} - -#[derive(Clone, Debug)] -struct WorkoutMobileConfig { - app_id: String, - app_name: String, - version: String, - origin: String, - android_package: String, - ios_bundle_id: String, - out_dir: PathBuf, -} - -impl WorkoutMobileConfig { - fn from_env() -> Self { - let app_id = env::var("HEMX_WORKOUT_APP_ID").unwrap_or_else(|_| "com.hemx.workout".into()); - Self { - android_package: env::var("HEMX_WORKOUT_ANDROID_PACKAGE") - .unwrap_or_else(|_| app_id.clone()), - ios_bundle_id: env::var("HEMX_WORKOUT_IOS_BUNDLE_ID") - .unwrap_or_else(|_| app_id.clone()), - app_id, - app_name: env::var("HEMX_WORKOUT_APP_NAME") - .unwrap_or_else(|_| "hemx Workout Copilot".into()), - version: env::var("HEMX_WORKOUT_VERSION") - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()), - origin: env::var("HEMX_WORKOUT_ORIGIN") - .unwrap_or_else(|_| "https://workout.example.invalid".into()), - out_dir: env::var_os("HEMX_WORKOUT_MOBILE_OUT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("target/hemx-mobile/workout")), - } - } -} - -fn mobile_external_blockers(config: &WorkoutMobileConfig) -> Vec { - let mut blockers = Vec::new(); - if !config.origin.starts_with("https://") { - blockers.push("HEMX_WORKOUT_ORIGIN must be the production HTTPS origin used by Android and iOS shells".into()); - } - if env::var_os("ANDROID_HOME").is_none() && env::var_os("ANDROID_SDK_ROOT").is_none() { - blockers.push("Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts".into()); - } - if !has_command("java") { - blockers.push("Java runtime not found: Android packaging requires a JDK".into()); - } - if env::var_os("HEMX_WORKOUT_ANDROID_KEYSTORE").is_none() { - blockers.push("Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing".into()); - } - if env::var_os("HEMX_WORKOUT_GOOGLE_PLAY_TRACK").is_none() { - blockers.push("Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this repo".into()); - } - if !has_command("xcodebuild") { - blockers.push( - "Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS" - .into(), - ); - } - if env::var_os("HEMX_WORKOUT_IOS_TEAM_ID").is_none() { - blockers.push("iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export".into()); - } - if env::var_os("HEMX_WORKOUT_APP_STORE_CONNECT_TEAM").is_none() { - blockers.push("App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this repo".into()); - } - blockers -} - -fn print_mobile_doctor(config: &WorkoutMobileConfig, blockers: &[String]) { - println!("Workout mobile release doctor"); - println!( - "app_id={} version={} origin={}", - config.app_id, config.version, config.origin - ); - if blockers.is_empty() { - println!("ready: Android SDK/signing and iOS Xcode/signing inputs are visible"); - } else { - println!("blocked external steps:"); - for blocker in blockers { - println!("- {blocker}"); - } - } -} - -fn write_workout_mobile_release( - config: &WorkoutMobileConfig, - blockers: &[String], -) -> std::io::Result<()> { - let android_dir = config.out_dir.join("android"); - let ios_dir = config.out_dir.join("ios"); - fs::create_dir_all(&android_dir)?; - fs::create_dir_all(&ios_dir)?; - fs::write( - config.out_dir.join("release-manifest.json"), - workout_mobile_manifest(config, blockers), - )?; - fs::write( - config.out_dir.join("asset-integrity.tsv"), - workout_asset_integrity(), - )?; - fs::write( - config.out_dir.join("BLOCKERS.md"), - workout_mobile_blockers_md(blockers), - )?; - fs::write( - android_dir.join("twa-release.json"), - android_twa_release_json(config), - )?; - fs::write( - android_dir.join("README.md"), - android_release_readme(config), - )?; - fs::write( - ios_dir.join("webview-release.json"), - ios_webview_release_json(config), - )?; - fs::write(ios_dir.join("README.md"), ios_release_readme(config))?; - Ok(()) -} - -fn verify_workout_mobile_release( - config: &WorkoutMobileConfig, - require_server_binary: bool, -) -> Vec { - let mut failures = mobile_policy_failures(config); - if require_server_binary && !Path::new("target/release/hemx-workout-example").exists() { - failures.push( - "target/release/hemx-workout-example is missing; run workout-mobile release first" - .into(), - ); - } - - let manifest_path = config.out_dir.join("release-manifest.json"); - let android_path = config.out_dir.join("android/twa-release.json"); - let ios_path = config.out_dir.join("ios/webview-release.json"); - let blockers_path = config.out_dir.join("BLOCKERS.md"); - let integrity_path = config.out_dir.join("asset-integrity.tsv"); - for path in [ - &manifest_path, - &android_path, - &ios_path, - &blockers_path, - &integrity_path, - ] { - if !path.exists() { - failures.push(format!("{} is missing", path.display())); - } - } - - check_file_contains( - &manifest_path, - &[ - &config.app_id, - &config.version, - &config.origin, - hemx_js::RUNTIME_JS_PATH, - hemx_js::RUNTIME_JS_HASH, - "asset-integrity.tsv", - "app-owned command/event/projection records", - "secrets and signing credentials stay outside the repo", - "rollback", - "android/twa-release.json", - "ios/webview-release.json", - "denied", - "timeout", - "unavailable", - "error", - ], - &mut failures, - ); - check_file_contains( - &android_path, - &[ - &config.android_package, - &config.app_name, - origin_host(&config.origin), - "external Android keystore", - "share", - "haptics", - "denied", - "timeout", - "unavailable", - "error", - ], - &mut failures, - ); - check_file_contains( - &ios_path, - &[ - &config.ios_bundle_id, - &config.app_name, - "share", - "haptics", - "external Apple team", - "denied", - "timeout", - "unavailable", - "error", - ], - &mut failures, - ); - - check_file_contains( - &integrity_path, - &[hemx_js::RUNTIME_JS_PATH, hemx_js::RUNTIME_JS_HASH, "sha256"], - &mut failures, - ); - - for blocker in mobile_external_blockers(config) { - check_file_contains(&manifest_path, &[&blocker], &mut failures); - check_file_contains(&blockers_path, &[&blocker], &mut failures); - } - - failures -} - -fn mobile_policy_failures(config: &WorkoutMobileConfig) -> Vec { - let mut failures = Vec::new(); - if !config.origin.starts_with("https://") { - failures.push( - "HEMX_WORKOUT_ORIGIN must be a production HTTPS origin before mobile verification can pass" - .into(), - ); - } - failures -} - -fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec) { - let Ok(contents) = fs::read_to_string(path) else { - return; - }; - for needle in needles { - if !contents.contains(needle) { - failures.push(format!("{} does not contain `{}`", path.display(), needle)); - } - } -} - -fn workout_asset_integrity() -> String { - format!( - "path\talgorithm\tdigest\n{}\tsha256\t{}\n", - hemx_js::RUNTIME_JS_PATH, - hemx_js::RUNTIME_JS_HASH - ) -} - -fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String { - format!( - "{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"{}\",\n \"runtime_asset_sha256\": \"{}\",\n \"asset_integrity\": \"asset-integrity.tsv\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n", - json_escape(&config.app_id), - json_escape(&config.app_name), - json_escape(&config.version), - json_escape(&config.origin), - json_escape(hemx_js::RUNTIME_JS_PATH), - json_escape(hemx_js::RUNTIME_JS_HASH), - json_string_list(blockers), - ) -} - -fn android_twa_release_json(config: &WorkoutMobileConfig) -> String { - format!( - "{{\n \"package\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Android keystore; never commit credentials\"\n}}\n", - json_escape(&config.android_package), - json_escape(&config.app_name), - json_escape(config.origin.trim_end_matches('/')), - json_escape(origin_host(&config.origin)), - json_escape(&config.version), - ) -} - -fn ios_webview_release_json(config: &WorkoutMobileConfig) -> String { - format!( - "{{\n \"bundle_id\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Apple team/provisioning profile; never commit credentials\"\n}}\n", - json_escape(&config.ios_bundle_id), - json_escape(&config.app_name), - json_escape(config.origin.trim_end_matches('/')), - json_escape(&config.version), - ) -} - -fn workout_mobile_blockers_md(blockers: &[String]) -> String { - if blockers.is_empty() { - "# Workout mobile external blockers\n\nNo external blocker was detected locally. Store submission still remains a human/vendor step.\n".into() - } else { - let mut out = String::from("# Workout mobile external blockers\n\nThe hemx release kit is generated, but these external inputs are still required for signed store artifacts:\n\n"); - for blocker in blockers { - out.push_str("- "); - out.push_str(blocker); - out.push('\n'); - } - out - } -} - -fn android_release_readme(config: &WorkoutMobileConfig) -> String { - format!( - "# Workout Android release\n\nUse `twa-release.json` as the Android shell authority for `{}`. Build the hemx server with the same release and serve `{}/` over HTTPS. Android SDK, Java, signing credentials, Play Console account state, and submission track are external inputs; this repository does not own them.\n", - config.android_package, config.origin - ) -} - -fn ios_release_readme(config: &WorkoutMobileConfig) -> String { - format!( - "# Workout iOS release\n\nUse `webview-release.json` as the iOS shell authority for `{}`. Archive with Xcode against `{}/` and route share/haptics through the typed host adapter. Apple team IDs, provisioning profiles, and App Store submission are external inputs; this repository does not own them.\n", - config.ios_bundle_id, config.origin - ) -} - -fn json_string_list(values: &[String]) -> String { - values - .iter() - .map(|value| format!("\"{}\"", json_escape(value))) - .collect::>() - .join(", ") -} - -fn json_escape(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") -} - -fn origin_host(origin: &str) -> &str { - origin - .strip_prefix("https://") - .or_else(|| origin.strip_prefix("http://")) - .unwrap_or(origin) - .split('/') - .next() - .unwrap_or(origin) -} - -fn run_test_plan() -> ExitCode { - // req: test/004 - let budget = Budget::detect(); - budget.report(); - - let mut steps = vec![ - Step::new( - "workspace-no-techdemo", - ["test", "--workspace", "--exclude", "hemx-techdemo"], - ), - Step::new( - "techdemo-unit-http", - ["test", "-p", "hemx-techdemo", "--test", "e2e"], - ), - Step::new("redgate", ["refs"]).tool("redgate"), - ]; - - if !budget.skip_browser { - steps.insert( - 2, - Step::new( - "techdemo-browser", - ["test", "-p", "hemx-techdemo", "--test", "browser_e2e"], - ) - .test_threads(1), - ); - } - - for step in steps { - if let Err(code) = step.run(&budget) { - return code; - } - } - - ExitCode::SUCCESS -} - -fn run_bench_plan() -> ExitCode { - // req: test/004 - let budget = Budget::detect(); - budget.report(); - eprintln!( - "hemx-ci: benchmarking small safe test slices from 1 to {} job(s)", - budget.jobs - ); - - let steps = [ - Step::new("bench-xtask", ["test", "-p", "hemx-xtask"]), - Step::new("bench-runtime", ["test", "-p", "hemx-js"]), - ]; - - for jobs in bench_values(budget.jobs) { - let bench_budget = budget.with_jobs(jobs); - for step in &steps { - let started = Instant::now(); - if let Err(code) = step.run(&bench_budget) { - return code; - } - println!( - "bench\t{}\tjobs={}\ttest_threads={}\telapsed_ms={}", - step.name, - bench_budget.jobs, - bench_budget.test_threads, - started.elapsed().as_millis() - ); - } - } - - ExitCode::SUCCESS -} - -const MUTATION_PACKAGES: &[&str] = &[ - "hemx", - "hemx-axum", - "hemx-build", - "hemx-core", - "hemx-derive", - "hemx-host", - "hemx-js", - "hemx-sync", - "hemx-sync-macros", - "hemx-test", - "hemx-wasm", -]; - -fn mutation_packages(package: Option<&str>) -> Result, String> { - match package { - None | Some("all") => Ok(MUTATION_PACKAGES.to_vec()), - Some(package) => MUTATION_PACKAGES - .iter() - .copied() - .find(|candidate| *candidate == package) - .map(|package| vec![package]) - .ok_or_else(|| { - format!( - "unknown mutation package `{package}`; expected `all` or one of: {}", - MUTATION_PACKAGES.join(", ") - ) - }), - } -} - -fn mutation_shard(shard: Option<&str>) -> Result, String> { - let Some(shard) = shard else { - return Ok(None); - }; - let Some((index, total)) = shard.split_once('/') else { - return Err(format!( - "invalid mutation shard `{shard}`; expected SHARD/TOTAL such as `1/4`" - )); - }; - let index = index - .parse::() - .map_err(|_| format!("invalid mutation shard `{shard}`; SHARD must be an integer"))?; - let total = total - .parse::() - .map_err(|_| format!("invalid mutation shard `{shard}`; TOTAL must be an integer"))?; - if total < 2 || index == 0 || index > total { - return Err(format!( - "invalid mutation shard `{shard}`; require TOTAL >= 2 and 1 <= SHARD <= TOTAL" - )); - } - // mutest numbers shards from zero; xtask presents the conventional 1..=TOTAL surface. - Ok(Some(format!("{}/{total}", index - 1))) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct MutationConcurrency { - jobs: usize, - test_threads: usize, - jobserver: bool, -} - -fn mutation_concurrency(package: &str, budget: Budget) -> MutationConcurrency { - if package == "hemx-wasm" { - // Browser tests spawn nested Cargo builds plus Firefox. A parent jobserver can - // retain every token while those builds wait, and parallel browser sessions - // make timing assertions meaningless. Keep this package sequential and let - // its nested Cargo commands own the detected machine budget. - MutationConcurrency { - jobs: 1, - test_threads: 1, - jobserver: false, - } - } else { - MutationConcurrency { - jobs: budget.jobs, - test_threads: budget.test_threads, - jobserver: true, - } - } -} - -fn run_mutation_plan(package: Option<&str>, shard: Option<&str>) -> ExitCode { - let packages = match mutation_packages(package) { - Ok(packages) => packages, - Err(error) => { - eprintln!("{error}"); - return ExitCode::from(2); - } - }; - let requested_shard = shard.map(str::to_owned); - let native_shard = match mutation_shard(shard) { - Ok(shard) => shard, - Err(error) => { - eprintln!("{error}"); - return ExitCode::from(2); - } - }; - let budget = Budget::detect().with_jobs(4); - budget.report(); - let mutest = env::var_os("HEMX_MUTEST_BIN").unwrap_or_else(|| "mutest".into()); - - let output_root = workspace_root().join("target/mutest"); - if let Err(error) = fs::create_dir_all(&output_root) { - eprintln!( - "failed to create mutation output directory {}: {error}", - output_root.display() - ); - return ExitCode::FAILURE; - } - - for package in packages { - eprintln!("\n==> mutation: {package}"); - let output = requested_shard.as_ref().map_or_else( - || output_root.join(package), - |shard| { - output_root - .join(package) - .join(format!("shard-{}", shard.replace('/', "-of-"))) - }, - ); - if let Some(parent) = output.parent() { - if let Err(error) = fs::create_dir_all(parent) { - eprintln!( - "failed to create mutation output parent {}: {error}", - parent.display() - ); - return ExitCode::FAILURE; - } - } - let concurrency = mutation_concurrency(package, budget); - let jobs = concurrency.jobs.to_string(); - let mut command = Command::new(&mutest); - command - .current_dir(workspace_root()) - // Mutest runs several cargo-test processes concurrently. Bound each nested - // harness by the same resource budget so browser-backed packages cannot - // multiply into Cargo's unconstrained default thread count. - // req: test/004 req: test/020 - .env("RUST_TEST_THREADS", concurrency.test_threads.to_string()) - .args(["-p", package, "-j", &jobs]); - if concurrency.jobserver { - command.args(["--jobserver-tasks", &jobs]); - } else { - command.args(["--jobserver", "false"]); - } - command - .args([ - "--colors", - "never", - "--annotations", - "none", - "--no-times", - "--minimum-test-timeout", - "120", - "--exhaustive", - "-o", - ]) - .arg(output); - if let Some(shard) = &native_shard { - command.args(["--shard", shard]); - } - let status = command.status(); - match status { - Ok(status) if status.success() => {} - Ok(status) => { - eprintln!("mutation: {package} failed with {status}"); - return ExitCode::from(status.code().unwrap_or(1) as u8); - } - Err(error) => { - eprintln!("failed to run mutest for {package}: {error}"); - return ExitCode::FAILURE; - } - } - } - - ExitCode::SUCCESS -} - -#[derive(Clone, Copy, Debug)] -struct Budget { - cpus: usize, - mem_gib: Option, - jobs: usize, - test_threads: usize, - skip_browser: bool, -} - -impl Budget { - fn detect() -> Self { - let cpus = std::thread::available_parallelism() - .map_or(1, usize::from) - .max(1); - let mem_gib = available_mem_gib(); - let skip_browser = env::var_os("HEMX_CI_SKIP_BROWSER").is_some() - || env::var_os("CI_NO_BROWSER").is_some() - || !has_command("geckodriver"); - Self::from_resources( - cpus, - mem_gib, - env_usize("HEMX_CI_JOBS"), - env_usize("HEMX_CI_TEST_THREADS"), - skip_browser, - ) - } - - fn from_resources( - cpus: usize, - mem_gib: Option, - jobs_override: Option, - test_threads_override: Option, - skip_browser: bool, - ) -> Self { - let cpus = cpus.max(1); - let mem_jobs = mem_gib.map_or(cpus, |gib| (gib / 2).max(1)); - let auto_jobs = cpus.min(mem_jobs).clamp(1, 6); - let jobs = jobs_override.unwrap_or(auto_jobs).clamp(1, auto_jobs); - let max_test_threads = jobs.min(4); - let test_threads = test_threads_override - .unwrap_or(max_test_threads) - .clamp(1, max_test_threads); - Self { - cpus, - mem_gib, - jobs, - test_threads, - skip_browser, - } - } - - fn report(&self) { - eprintln!( - "hemx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}", - self.cpus, - self.mem_gib - .map(|mem| mem.to_string()) - .unwrap_or_else(|| "unknown".into()), - self.jobs, - self.test_threads, - if self.skip_browser { "skip" } else { "run" } - ); - } - - fn with_jobs(self, jobs: usize) -> Self { - let jobs = jobs.clamp(1, self.jobs); - Self { - jobs, - test_threads: self.test_threads.min(jobs.min(4)).max(1), - ..self - } - } -} - -fn bench_values(limit: usize) -> Vec { - let limit = limit.max(1); - let mut values = Vec::new(); - let mut value = 1; - while value < limit { - values.push(value); - value *= 2; - } - values.push(limit); - values.dedup(); - values -} - -#[derive(Clone, Debug)] -struct Step { - name: &'static str, - tool: &'static str, - args: Vec<&'static str>, - test_threads: Option, -} - -impl Step { - fn new(name: &'static str, args: [&'static str; N]) -> Self { - Self { - name, - tool: "cargo", - args: args.into(), - test_threads: None, - } - } - - fn tool(mut self, tool: &'static str) -> Self { - self.tool = tool; - self - } - - fn test_threads(mut self, threads: usize) -> Self { - self.test_threads = Some(threads); - self - } - - fn run(&self, budget: &Budget) -> Result<(), ExitCode> { - eprintln!("\n==> {}", self.name); - let mut command = Command::new(self.tool); - command.current_dir(workspace_root()).args(&self.args); - if self.tool == "cargo" { - command.env("CARGO_BUILD_JOBS", budget.jobs.to_string()); - command.env( - "RUST_TEST_THREADS", - self.test_threads.unwrap_or(budget.test_threads).to_string(), - ); - } - let status = command.status().map_err(|err| { - eprintln!("failed to run {}: {err}", self.name); - ExitCode::FAILURE - })?; - if status.success() { - Ok(()) - } else { - eprintln!("{} failed with {status}", self.name); - Err(ExitCode::from(status.code().unwrap_or(1) as u8)) - } - } -} - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("hemx-xtask must be a workspace member") - .to_owned() -} - -fn env_usize(key: &str) -> Option { - env::var(key).ok()?.parse().ok() -} - -fn available_mem_gib() -> Option { - const GIB: u64 = 1024 * 1024 * 1024; - let bytes = available_mem_bytes()?; - Some(((bytes / GIB) as usize).max(1)) -} - -fn available_mem_bytes() -> Option { - let mut candidates = Vec::new(); - if let Some(bytes) = proc_mem_available_bytes() { - candidates.push(bytes); - } - if let Some(bytes) = cgroup_mem_available_bytes() { - candidates.push(bytes); - } - candidates.into_iter().min() -} - -fn proc_mem_available_bytes() -> Option { - let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb = meminfo.lines().find_map(|line| { - let rest = line.strip_prefix("MemAvailable:")?; - rest.split_whitespace().next()?.parse::().ok() - })?; - kb.checked_mul(1024) -} - -fn cgroup_mem_available_bytes() -> Option { - cgroup_mem_available_v2().or_else(cgroup_mem_available_v1) -} - -fn cgroup_mem_available_v2() -> Option { - let limit = read_cgroup_limit("/sys/fs/cgroup/memory.max")?; - let current = read_u64_file("/sys/fs/cgroup/memory.current").unwrap_or(0); - Some(limit.saturating_sub(current).max(1)) -} - -fn cgroup_mem_available_v1() -> Option { - let limit = read_cgroup_limit("/sys/fs/cgroup/memory/memory.limit_in_bytes")?; - let current = read_u64_file("/sys/fs/cgroup/memory/memory.usage_in_bytes").unwrap_or(0); - Some(limit.saturating_sub(current).max(1)) -} - -fn read_cgroup_limit(path: &str) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let trimmed = raw.trim(); - if trimmed == "max" { - return None; - } - let value = trimmed.parse::().ok()?; - if value >= (1 << 60) { - None - } else { - Some(value) - } -} - -fn read_u64_file(path: &str) -> Option { - std::fs::read_to_string(path).ok()?.trim().parse().ok() -} - -fn has_command(name: &str) -> bool { - let Some(paths) = env::var_os("PATH") else { - return false; - }; - env::split_paths(&paths).any(|dir| is_executable(dir.join(name))) -} - -fn is_executable(path: impl AsRef) -> bool { - path.as_ref().is_file() -} - -#[cfg(test)] -mod tests { - use super::{ - android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold, - create_workout_app, mobile_external_blockers, mutation_concurrency, mutation_packages, - mutation_shard, origin_host, verify_workout_mobile_release, workout_mobile_manifest, - workspace_root, write_workout_mobile_release, Budget, MutationConcurrency, - WorkoutMobileConfig, MUTATION_PACKAGES, - }; - use std::fs; - use std::path::PathBuf; - - #[test] - fn mutation_plan_selects_only_elected_packages() { - // req: test/020 test req: test/021 test req: test/022 test - assert_eq!(mutation_packages(None).unwrap(), MUTATION_PACKAGES); - assert_eq!(mutation_packages(Some("all")).unwrap(), MUTATION_PACKAGES); - assert_eq!(mutation_packages(Some("hemx-js")).unwrap(), ["hemx-js"]); - let error = mutation_packages(Some("example-app")).unwrap_err(); - assert!(error.contains("unknown mutation package `example-app`")); - assert!(error.contains("hemx-core")); - - assert_eq!(mutation_shard(None).unwrap(), None); - assert_eq!(mutation_shard(Some("1/4")).unwrap().as_deref(), Some("0/4")); - assert_eq!(mutation_shard(Some("4/4")).unwrap().as_deref(), Some("3/4")); - for invalid in ["1", "a/4", "1/a", "0/4", "5/4", "1/1"] { - assert!( - mutation_shard(Some(invalid)).unwrap_err().contains(invalid), - "missing invalid shard in diagnostic" - ); - } - // test req: test/022 req: test/023 - } - - #[test] - fn browser_mutation_runs_one_harness_without_a_parent_jobserver() { - let budget = Budget::from_resources(22, Some(27), None, None, true).with_jobs(4); - assert_eq!( - mutation_concurrency("hemx-wasm", budget), - MutationConcurrency { - jobs: 1, - test_threads: 1, - jobserver: false, - } - ); - assert_eq!( - mutation_concurrency("hemx-core", budget), - MutationConcurrency { - jobs: 4, - test_threads: 4, - jobserver: true, - } - ); - // test req: test/004 req: test/020 - } - - #[test] - fn verification_steps_resolve_the_workspace_independent_of_caller_directory() { - assert!(workspace_root().join("Cargo.toml").is_file()); // req: test/004 - } - - #[test] - fn budget_is_capped_by_available_memory() { - // req: test/004 - let budget = Budget::from_resources(22, Some(1), None, None, true); - - assert_eq!(budget.jobs, 1); - assert_eq!(budget.test_threads, 1); - } - - #[test] - fn budget_caps_large_machines_by_default() { - let budget = Budget::from_resources(64, Some(128), None, None, true); - - assert_eq!(budget.jobs, 6); - assert_eq!(budget.test_threads, 4); - } - - #[test] - fn budget_clamps_explicit_overrides_to_resource_budget() { - let budget = Budget::from_resources(2, Some(2), Some(8), Some(7), true); - - assert_eq!(budget.jobs, 1); - assert_eq!(budget.test_threads, 1); - } - - #[test] - fn bench_values_grow_gradually_and_include_limit() { - assert_eq!(super::bench_values(1), vec![1]); - assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]); - } - - #[test] - fn app_new_creates_generated_helper_scaffold() { - // req: ceremony/005 req: canonical_authoring/003 - let destination = PathBuf::from("target/test-hemx-app-new"); - let _ = fs::remove_dir_all(&destination); - - create_app_scaffold(&destination).expect("create app scaffold"); - let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest"); - let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs"); - let template = - fs::read_to_string(destination.join("templates/todos.heml")).expect("template"); - let readme = fs::read_to_string(destination.join("README.md")).expect("readme"); - let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs"); - - assert!(manifest.contains("name = \"hemx-app\"")); - assert!(manifest.contains("edition = \"2021\"")); - assert!(manifest.contains("hemx-build")); - assert!(main_rs.contains("hemx_app::ui")); - assert!(!main_rs.contains("hemx_v0_examples")); - assert!(template.contains("data-hemx-form=\"new_todo\"")); - assert!(template.contains("data-hemx-slot=\"notice\"")); - assert!(template.contains("data-hemx-slot=\"todo_row\"")); - assert!(template.contains("h-key=\"row.id\"")); - assert!(readme.contains("# hemx app")); - assert!(readme.contains("cargo run")); - assert!(readme.contains("cargo build --release")); - assert!(readme.contains("templates/todos.heml")); - assert!(!readme.contains("cargo run -p hemx-app")); - assert!(created.contains("keyed row partial")); - assert!(created.contains("generated append/replace/remove/dynamic-batch effects")); - assert!(destination.join("src/lib.rs").exists()); - let _ = fs::remove_dir_all(&destination); - } - - #[test] - fn app_new_mobile_creates_phone_first_release_starter() { - // req: ceremony/006 req: examples/006 - let destination = PathBuf::from("target/test-hemx-app-new-mobile"); - let _ = fs::remove_dir_all(&destination); - - create_mobile_app_scaffold(&destination).expect("create mobile app scaffold"); - let readme = fs::read_to_string(destination.join("README.md")).expect("readme"); - let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs"); - let mobile_readme = - fs::read_to_string(destination.join("MOBILE_STARTER.md")).expect("mobile docs"); - let command = fs::read_to_string(destination.join("hemx-app")).expect("command"); - let lib_rs = fs::read_to_string(destination.join("src/lib.rs")).expect("lib rs"); - let template = - fs::read_to_string(destination.join("templates/workout.heml")).expect("template"); - - assert!(readme.contains("# hemx mobile app")); - assert!(readme.contains("./hemx-app dev")); - assert!( - readme.contains("HEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release") - ); - assert!(readme.contains("Do not treat it as a native UI framework")); - assert!(!readme.contains("cargo run -p hemx-xtask -- workout")); - assert!(!readme.contains("hemx-workout")); - assert!(created.contains("# Created hemx mobile app")); - assert!(created.contains("./hemx-app doctor")); - assert!(!created.contains("Created Workout app")); - assert!(mobile_readme.contains("phone-first path")); - assert!(mobile_readme.contains("typed host haptics/share calls")); - assert!(mobile_readme.contains("command/event/projection recovery truth")); - assert!(mobile_readme.contains("app-owned `hemx-app mobile-release`")); - assert!(mobile_readme.contains("does not add a hemx mobile framework")); - assert!(command.contains("mobile-release")); - assert!(command.contains("mobile-verify")); - assert!(command.contains("HEMX_APP_ORIGIN")); - assert!(command.contains("target/hemx-mobile/app")); - assert!(!command.contains("HEMX_WORKOUT_")); - assert!(command.contains("app-owned command/event/projection records")); - assert!(command.contains("external_blockers")); - assert!(lib_rs.contains("HostCall::Share")); - assert!(lib_rs.contains("WorkoutCommand")); - assert!(lib_rs.contains("WorkoutEvent")); - assert!(template.contains(" WorkoutMobileConfig { - workout_mobile_config_at(origin, "target/test-workout-mobile") - } - - fn workout_mobile_config_at(origin: &str, out_dir: &str) -> WorkoutMobileConfig { - WorkoutMobileConfig { - app_id: "com.hemx.workout".into(), - app_name: "hemx Workout Copilot".into(), - version: "1.2.3".into(), - origin: origin.into(), - android_package: "com.hemx.workout".into(), - ios_bundle_id: "com.hemx.workout".into(), - out_dir: PathBuf::from(out_dir), - } - } -} diff --git a/work/northstars/NS-0001-testing-strategy.md b/work/northstars/NS-0001-testing-strategy.md deleted file mode 100644 index 49929f0..0000000 --- a/work/northstars/NS-0001-testing-strategy.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -id: NS-0001 -type: northstar -status: ready -title: Testing strategy confidence ladder -refs: - - REQUIREMENTS.md - - AGENTS.md - - README.md - - hemx-xtask/src/main.rs - - hemx-test/src/lib.rs - - examples/html_examples/README.md ---- - -## Objective - -Make hemx verification boring and reliable by keeping a documented confidence ladder from fast crate tests, through focused browser/runtime smoke, to the full `cargo run -p hemx-xtask -- test` authority. The outcome is not more test ceremony; it is clear proof selection for agents and contributors. - -## Authority - -- `AGENTS.md` and `README.md` define stable verification commands, focused browser smoke, full xtask authority, and the 10 minute local full-check budget. -- `REQUIREMENTS.md` `test/001` through `test/017` constrain generated-resource assertions, examples, browser smoke, no `/tmp` scripts, no-reload interactions, tiers, shard expectations, and diagnostic quality. -- `hemx-xtask/src/main.rs` owns repo-capped verification commands and `html-examples-smoke`. -- `hemx-test/src/lib.rs` owns reusable test inspection helpers. - -## Close when - -- Fast, focused browser, and full verification tiers have ready slices with exact commands and ownership boundaries. -- Browser/runtime confidence covers no-reload dynamic interactions, revealed fallback, interval/timing behavior, and generated target assertions. -- Regression boundaries prefer generated-resource assertions and `hemx_test` helpers over raw ids or raw effect matching. -- Requirement/test governance stays tied to redgate health/lint without turning lint cleanup into product work. - -## Verification - -- `workledger check` -- `workledger list` -- Later implementation slices choose the narrowest relevant command, then use `cargo run -p hemx-xtask -- test` when changing shared verification authority. - -## Non-goals - -- Do not add a parallel test runner outside `hemx-xtask`. -- Do not replace meaningful behavior tests with mocks-only assertions. -- Do not make redgate warnings the product outcome unless the slice explicitly owns requirement maintainability. - -## Evidence - -- `cargo run -p hemx-xtask -- html-examples-smoke` is the focused browser proof for html_examples dynamic behavior. -- `cargo run -p hemx-xtask -- test` is the full local authority wrapper. -- `redgate health --strict` and `redgate lint` are governance proof commands. diff --git a/work/northstars/NS-0002-lsp-editor-experience.md b/work/northstars/NS-0002-lsp-editor-experience.md deleted file mode 100644 index cb1ef8b..0000000 --- a/work/northstars/NS-0002-lsp-editor-experience.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: NS-0002 -type: northstar -status: ready -title: LSP and editor experience completion -refs: - - REQUIREMENTS.md - - docs/editor-support.md - - docs/diagnostics.md - - docs/hemplate-syntax.md - - hemx-lsp/src/main.rs - - hemx-build/src/lib.rs ---- - -## Objective - -Complete the `.heml` editor experience around existing compiler/build authority: diagnostics parity, completion, hover, cross-file facts, and correct LSP protocol behavior without inventing a second template language or editor framework. - -## Authority - -- `docs/editor-support.md` says `.heml` authoring should feel like HTML first and layer hemx diagnostics/completions/hover on top. -- `docs/hemplate-syntax.md` is the stable syntax surface; LSP must not invent Vue/Handlebars-like syntax. -- `docs/diagnostics.md` keeps editor overlays subordinate to compiler/build diagnostics. -- `REQUIREMENTS.md` `diagnostics/004` through `diagnostics/008` and `check/001`/`check/003` constrain editor overlays, spans, completion, hover, and build-visible validation. -- `hemx-lsp/src/main.rs` currently owns the CLI/LSP implementation surface. - -## Close when - -- Diagnostics parity slices prove LSP-published diagnostics match `hemx-build` CLI diagnostics for open/save/file paths. -- Completion slices cover hemplate directives, `data-hemx-*` attributes, and context-sensitive generated facts without requiring external editor state. -- Hover slices explain real hemplate syntax and hemx attributes from docs authority. -- Cross-file fact slices describe what must fail at `cargo check` and what can remain editor-only. -- LSP protocol slices cover initialize, sync, completion, hover, diagnostics clearing, and error responses with tests. - -## Verification - -- `workledger check` -- `workledger list` -- Later implementation slices should prefer focused `cargo test -p hemx-lsp`/integration tests, then `cargo check --workspace` or `cargo run -p hemx-xtask -- test` when build integration changes. - -## Non-goals - -- Do not build a custom editor framework, formatter, Rust type system, selector model, or second `.heml` parser. -- Do not make VS Code/Cursor/Neovim provider behavior the source of truth; provider glue stays downstream of LSP/build facts. -- Do not add external publishing/signing flows without explicit authority. - -## Evidence - -- `hemx-lsp/src/main.rs` already exposes diagnostics, completion, and hover entry points. -- `docs/editor-support.md` and `docs/diagnostics.md` define the editor/build boundary. diff --git a/work/northstars/NS-0003-hemplate-highlighting.md b/work/northstars/NS-0003-hemplate-highlighting.md deleted file mode 100644 index 777b923..0000000 --- a/work/northstars/NS-0003-hemplate-highlighting.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: NS-0003 -type: northstar -status: ready -title: Hemplate highlighting and editor grammar boundary -refs: - - docs/editor-support.md - - docs/hemplate-syntax.md - - REQUIREMENTS.md - - README.md ---- - -## Objective - -Make `.heml` highlighting predictable in editors by defining the repo-owned highlighting boundary: HTML/tree-sitter remains the base, hemplate overlays cover only the documented syntax facts, and diagnostics/completion stay owned by `hemx-build`/`hemx-lsp`. - -## Authority - -- `docs/editor-support.md` says `.heml` should keep normal HTML highlighting and layer hemx-specific editor support on top. -- `docs/hemplate-syntax.md` defines escaped/trusted text, dynamic attributes, control flow, generated hemx targets, and boundaries. -- `REQUIREMENTS.md` `diagnostics/004` through `diagnostics/008` require optional overlays to share authority with `hemx-build` and avoid a second template language or custom editor framework. - -## Close when - -- Highlighting ownership is explicit: repo-owned fixtures/queries can cover documented hemplate tokens, while external editor distribution remains a separate slice or blocker. -- Tree-sitter work is bounded to syntax highlighting/injection facts and never owns diagnostics, completion, formatting, Rust type checking, or template validation. -- Ready slices distinguish repo-testable grammar/query behavior from editor packaging/publishing work that may require external authority. - -## Verification - -- `workledger check` -- `workledger list` -- Later implementation slices should use repo-owned fixture/query tests where available; provider-specific visual/manual checks must name the editor and blocker. - -## Non-goals - -- Do not create a second `.heml` parser for hemx-build. -- Do not make highlighting responsible for compiler diagnostics, completion, hover, formatting, or generated Rust facts. -- Do not publish editor extensions or tree-sitter packages without explicit release authority. - -## Evidence - -- Current docs already frame `.heml` as HTML first with a small hemplate overlay. -- No current repo authority grants external package publishing; that is represented as a separate blocked/future slice rather than assumed. diff --git a/work/northstars/NS-0004-html-examples-navigation.md b/work/northstars/NS-0004-html-examples-navigation.md deleted file mode 100644 index 9837a26..0000000 --- a/work/northstars/NS-0004-html-examples-navigation.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -id: NS-0004 -type: northstar -status: ready -title: html_examples index and per-example pages -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md - - examples/html_examples/src/main.rs - - examples/html_examples/templates/app_shell.heml - - examples/html_examples/templates/gallery.heml - - hemx-xtask/src/main.rs ---- - -## Objective - -Make `examples/html_examples` behave like a copy-pasteable pattern gallery instead of one long demo page: `/` is an index with navigation, each implemented htmx-style pattern has a focused page using the exact htmx slug as the visible name, and `infinite-scroll` is proved by real scrolling rather than a click-only load-more control. - -## Authority - -- `AGENTS.md` says `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples, with exact htmx URL slugs visible and behavior translated to boring `.heml`, generated resources, and server-owned Rust state. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008 -- `examples/html_examples/README.md` defines the pattern matrix, current implemented/deferred/refused boundaries, and the stable run/open command. -- `examples/html_examples/src/main.rs` and `templates/gallery.heml` currently render all implemented examples on `/`, including `infinite-scroll` as a revealed form with a visible `Reveal more rows` button. -- `hemx-xtask/src/main.rs` owns the focused browser smoke command that must prove no-reload dynamic interactions and the html_examples runtime path. req: test/006 - -## Close when - -- `/` is an index/landing page, not the long live example gallery. -- Implemented examples have focused routes/pages whose visible names preserve the htmx example slugs from the README matrix. -- `infinite-scroll` lives on its own focused page and is primarily exercised by scrolling to a sentinel, not by clicking a load-more button. -- README, smoke, and any requirement/doc changes keep the run command, pattern matrix, and verification authority honest. -- Any real product decision needed before implementation is captured as a blocked slice with the exact missing decision. - -## Verification - -- `workledger check` -- `workledger list` -- `cargo test -p hemx-html-examples` -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `redgate health --strict && redgate lint` - -## Non-goals - -- Do not create a router framework, docs generator, client component runtime, selector-targeting model, or second browser runner. -- Do not promote deferred/integration-owned/refused README rows unless a later slice proves the full vertical behavior. -- Do not hide click-to-load behavior inside `infinite-scroll`; keep click-triggered loading and scroll-triggered loading as distinct examples. - -## Evidence - -- Work mapping created at HEAD `379ed02`; implementation slices should update this section only when they complete or block. diff --git a/work/slices/SLICE-0001-fast-focused-full-verification.md b/work/slices/SLICE-0001-fast-focused-full-verification.md deleted file mode 100644 index 6c25dd9..0000000 --- a/work/slices/SLICE-0001-fast-focused-full-verification.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -id: SLICE-0001 -type: slice -status: done -parent: NS-0001 -title: Lock fast focused and full verification tiers -refs: - - REQUIREMENTS.md - - README.md - - AGENTS.md - - hemx-xtask/src/main.rs ---- - -## Objective - -Make the verification ladder executable and documented so contributors know when to run fast crate tests, `html-examples-smoke`, and the full xtask authority wrapper. - -## Authority - -`README.md`, `AGENTS.md`, and `REQUIREMENTS.md` `test/004`, `test/006`, `test/015`, and `test/016` already define the tier boundary and full local timeout expectation. - -## Close when - -- README/agent guidance names fast crate tests, focused browser smoke, and full xtask authority separately. -- `cargo run -p hemx-xtask -- test` remains the full wrapper rather than being bypassed by an undocumented shard. -- Any shard split is deterministic and documented under the xtask wrapper. - -## Verification - -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `cargo run -p hemx-xtask -- test` -- `redgate health --strict` - -## Non-goals - -- Do not add a new test harness outside xtask. -- Do not split the full wrapper unless the measured runtime exceeds the budget. - -## Evidence - -Done at HEAD `2e55406`: `cargo run -p hemx-xtask -- html-examples-smoke` passed, `cargo run -p hemx-xtask -- test` passed in about 100 seconds within the documented 10 minute budget, and `redgate health --strict`/`redgate lint` passed. The full wrapper remains `cargo run -p hemx-xtask -- test`; no new test runner or undocumented shard was added. diff --git a/work/slices/SLICE-0002-browser-runtime-confidence.md b/work/slices/SLICE-0002-browser-runtime-confidence.md deleted file mode 100644 index 86bcfdd..0000000 --- a/work/slices/SLICE-0002-browser-runtime-confidence.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: SLICE-0002 -type: slice -status: done -parent: NS-0001 -title: Keep html_examples browser smoke as runtime confidence proof -refs: - - REQUIREMENTS.md - - hemx-xtask/src/main.rs - - examples/html_examples/README.md - - examples/html_examples/src/main.rs ---- - -## Objective - -Keep `html-examples-smoke` as the repo-owned browser proof for no-reload dynamic interactions, explicit progress clicks, revealed fallback, and the htmx-equivalent gallery behaviors. - -## Authority - -`REQUIREMENTS.md` `test/006`, `test/013`, `test/014`, `convention/005`, `convention/014`, and `examples/012` constrain the smoke: no `/tmp` scripts, no navigation/reload fallback, and deterministic browser coverage. - -## Close when - -- The smoke covers click-to-edit, edit-row, inline validation, active search, delete row, lazy load, click-to-load, infinite/reveal, explicit progress click, value select, reset input, and revealed fallback without IntersectionObserver. -- The no-navigation/no-reload guard wraps dynamic interactions. -- Failures identify the named smoke path. - -## Verification - -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` - -## Non-goals - -- Do not add Playwright, Selenium, or a second browser runner without a separate decision. -- Do not move smoke scripts to `/tmp` or untracked files. - -## Evidence - -Done at HEAD `67d167f`, then corrected for manual progress behavior: `cargo run -p hemx-xtask -- html-examples-smoke` covers explicit progress click, click-to-edit, edit-row, inline validation, active search, delete row, lazy load, click-to-load, infinite/reveal, value select, reset input, and revealed fallback without IntersectionObserver. `hemx-xtask/src/main.rs` owns the CDP-driven smoke command, inline smoke scripts, and no-navigation/no-reload guard; no `/tmp` scripts or second browser runner were added. diff --git a/work/slices/SLICE-0003-keyed-regression-boundaries.md b/work/slices/SLICE-0003-keyed-regression-boundaries.md deleted file mode 100644 index c403681..0000000 --- a/work/slices/SLICE-0003-keyed-regression-boundaries.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: SLICE-0003 -type: slice -status: done -parent: NS-0001 -title: Protect keyed collection regressions with generated-resource assertions -refs: - - REQUIREMENTS.md - - examples/html_examples/src/main.rs - - hemx-test/src/lib.rs ---- - -## Objective - -Make keyed list regressions easy to catch without raw selector or raw effect coupling: tests should assert generated resource behavior for remove, replace, and append paths. - -## Authority - -`REQUIREMENTS.md` `list/006`, `test/008`, `test/009`, `test/010`, and `test/017` constrain keyed collection behavior and test diagnostics. - -## Close when - -- Filtered keyed collections are tested for removing filtered-out keys, replacing retained keys, and appending newly visible keys. -- Assertions use generated resource helpers or `hemx_test` inspection adapters, not raw runtime ids as app-facing API. -- Failure messages name generated resources where practical. - -## Verification - -- `cargo test -p hemx-html-examples` -- `cargo test -p hemx-test --test inspector` -- `cargo run -p hemx-xtask -- html-examples-smoke` - -## Non-goals - -- Do not add app authoring APIs for selectors or raw runtime ids. -- Do not test private runtime payload layout unless the slice changes wire semantics. - -## Evidence - -Done at HEAD `2122b3c`: `cargo test -p hemx-html-examples` covers active-search keyed remove/replace/append behavior through generated `gallery::search_result` assertions, `cargo test -p hemx-test --test inspector` covers rendered target/handle helper diagnostics with generated names, and `cargo run -p hemx-xtask -- html-examples-smoke` covers the browser active-search path. No app-facing selector/raw-id API was added. diff --git a/work/slices/SLICE-0004-requirement-test-governance.md b/work/slices/SLICE-0004-requirement-test-governance.md deleted file mode 100644 index ac20f68..0000000 --- a/work/slices/SLICE-0004-requirement-test-governance.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: SLICE-0004 -type: slice -status: done -parent: NS-0001 -title: Keep requirement and test governance aligned -refs: - - REQUIREMENTS.md - - AGENTS.md - - redgate ---- - -## Objective - -Keep behavior-changing test work tied to checkable requirements without turning redgate cleanup into unrelated product scope. - -## Authority - -`AGENTS.md` requires reading and updating `REQUIREMENTS.md` when behavior changes, citing relevant `req:` IDs, and running redgate commands when requirements change. - -## Close when - -- Behavior-changing test slices cite the requirements they constrain. -- Requirement-only maintenance is separated from behavior implementation. -- `redgate health --strict` and `redgate lint` are green for the changed scope. - -## Verification - -- `redgate health --strict` -- `redgate lint` -- Relevant behavior test from the implementation slice. - -## Non-goals - -- Do not add citation-only padding. -- Do not make redgate adoption or formatting the product payoff. - -## Evidence - -Done at HEAD `53e4709`: behavior/test slices cite their constraining requirements (`test/*`, `diagnostics/*`, `list/006`, `examples/*`), requirement-only maintenance stayed separated in workledger evidence commits, and `redgate health --strict` plus `redgate lint` pass with only the accepted `requirements-large` advisory from health and no lint warnings. diff --git a/work/slices/SLICE-0005-lsp-diagnostics-parity.md b/work/slices/SLICE-0005-lsp-diagnostics-parity.md deleted file mode 100644 index a89c2ed..0000000 --- a/work/slices/SLICE-0005-lsp-diagnostics-parity.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -id: SLICE-0005 -type: slice -status: done -parent: NS-0002 -title: Prove LSP diagnostics parity with hemx-build -refs: - - REQUIREMENTS.md - - docs/diagnostics.md - - docs/editor-support.md - - hemx-lsp/src/main.rs - - hemx-build/src/lib.rs ---- - -## Objective - -Make LSP diagnostics a faithful editor transport for `hemx-build` diagnostics across open, change, save, close, and CLI file checks. - -## Authority - -`docs/diagnostics.md` and `REQUIREMENTS.md` `diagnostics/004`, `diagnostics/005`, and `check/001` require editor overlays to share compiler authority and preserve useful spans. - -## Close when - -- A focused LSP test opens an invalid `.heml` document and observes the same diagnostic code/severity/range as the `hemx-lsp diagnostics FILE.heml` path. -- `didChange` updates diagnostics from in-memory text. -- `didSave` with and without text uses the documented source of truth. -- `didClose` clears diagnostics. - -## Verification - -- `cargo test -p hemx-lsp` -- `cargo run -p hemx-lsp -- diagnostics .heml` -- `cargo check --workspace` if public LSP/build types change. - -## Non-goals - -- Do not implement a second `.heml` parser in the LSP. -- Do not add provider-specific VS Code/Neovim behavior in this slice. - -## Evidence - -Done at commits `6cf800c` and verified again at `38794cf` with `cargo test -p hemx-lsp`: protocol tests cover build-equivalent diagnostics publishing on `didOpen`/`didSave`, clearing on `didChange`/`didClose`, and file-backed `didSave` without in-memory text. diff --git a/work/slices/SLICE-0006-lsp-completion-surface.md b/work/slices/SLICE-0006-lsp-completion-surface.md deleted file mode 100644 index 9fc54a7..0000000 --- a/work/slices/SLICE-0006-lsp-completion-surface.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: SLICE-0006 -type: slice -status: done -parent: NS-0002 -title: Complete context-aware hemplate completions -refs: - - REQUIREMENTS.md - - docs/editor-support.md - - docs/hemplate-syntax.md - - hemx-lsp/src/main.rs ---- - -## Objective - -Make completion useful for the real hemplate surface: directives, dynamic attributes, and `data-hemx-*` facts should complete in context without inventing syntax. - -## Authority - -`docs/hemplate-syntax.md` defines the public syntax; `docs/editor-support.md` and `REQUIREMENTS.md` `diagnostics/006` require completions to support that surface while remaining subordinate to build validation. - -## Close when - -- Completion items cover `h-if`, `h-for`, `h-key`, `h-match`, `h-case`, `{+ +}`, `{+= =+}`, `+attr`, and known `data-hemx-*` authoring facts. -- Tests prove context filters: directive completions in tag attributes, snippet/text completions in text positions, and no irrelevant completions inside quoted Rust expressions. -- Completion docs/details point to the existing syntax docs rather than new invented rules. - -## Verification - -- `cargo test -p hemx-lsp` -- Manual check if needed: request `textDocument/completion` against a fixture through the LSP test harness. - -## Non-goals - -- Do not add Rust type inference or project-wide symbol completion in this slice. -- Do not make completion acceptance a build authority. - -## Evidence - -Done at commit `baf4b15` and verified again at `38794cf` with `cargo test -p hemx-lsp`: completion tests cover documented hemplate directives, dynamic attributes, expression snippets, `data-hemx-root`, `data-hemx-slot`, `data-hemx-form`, and `data-hemx-handle` through helper and protocol paths. diff --git a/work/slices/SLICE-0007-lsp-hover-surface.md b/work/slices/SLICE-0007-lsp-hover-surface.md deleted file mode 100644 index be762e0..0000000 --- a/work/slices/SLICE-0007-lsp-hover-surface.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: SLICE-0007 -type: slice -status: done -parent: NS-0002 -title: Complete hover help for documented hemplate facts -refs: - - REQUIREMENTS.md - - docs/editor-support.md - - docs/hemplate-syntax.md - - hemx-lsp/src/main.rs ---- - -## Objective - -Make hover explain the real `.heml` facts that contributors see in templates without turning hover into a tutorial or alternative spec. - -## Authority - -`docs/hemplate-syntax.md` defines the facts; `REQUIREMENTS.md` `diagnostics/007` and `diagnostics/008` constrain hover and editor behavior. - -## Close when - -- Hover covers escaped/trusted text, dynamic attributes, control-flow directives, generated targets, forms, handles, roots, and keyed slots. -- Hover content is short and points back to the documented syntax authority. -- Hover tests assert range-sensitive behavior and no hover for unrelated HTML text where normal editor tooling owns the answer. - -## Verification - -- `cargo test -p hemx-lsp` - -## Non-goals - -- Do not duplicate `docs/hemplate-syntax.md` wholesale in source strings. -- Do not include provider-specific Markdown rendering assumptions. - -## Evidence - -Done at commit `258524f` and verified again at `38794cf` with `cargo test -p hemx-lsp`: hover is token-position aware and tests cover documented hemplate facts while ordinary HTML remains left to normal editor tooling. diff --git a/work/slices/SLICE-0008-cross-file-build-facts.md b/work/slices/SLICE-0008-cross-file-build-facts.md deleted file mode 100644 index fb6bf5c..0000000 --- a/work/slices/SLICE-0008-cross-file-build-facts.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -id: SLICE-0008 -type: slice -status: done -parent: NS-0002 -title: Surface build-owned template facts without replacing build validation -refs: - - REQUIREMENTS.md - - docs/editor-support.md - - docs/diagnostics.md - - hemx-build/src/lib.rs - - hemx-lsp/src/main.rs ---- - -## Objective - -Define and prove the handoff between editor-visible template facts and `hemx-build` validation without turning the LSP into a second source of template truth. - -## Authority - -`REQUIREMENTS.md` `check/001`, `check/003`, `diagnostics/004`, and `diagnostics/005` require cross-file references visible to build validation to fail at `cargo check` with useful spans, while editor overlays remain optional glue. - -## Close when - -- LSP diagnostics use the same `hemx_build::diagnostics_for_heml_*` path as the CLI/file checks and publish/clear those results through protocol tests. -- LSP completion and hover consume `hemx_build::template_context_facts_for_heml_source` for repo facts instead of maintaining a separate template fact model. -- Cross-file/global completeness remains a build/mount-test concern; editor overlays may surface facts but do not replace build validation. - -## Verification - -- `cargo test -p hemx-lsp` -- `workledger check` - -## Non-goals - -- Do not make the LSP own Rust type checking. -- Do not create a persistent project index unless a later slice proves it is required. - -## Evidence - -Done across commits `6cf800c`, `baf4b15`, `258524f`, and `e77ddd4`, then recorded at `f605c9e`: LSP diagnostics use build-owned diagnostic functions; repo-fact completion/hover tests exercise `template_context_facts_for_heml_source` with the workout template; protocol tests prove publish/clear, completion, hover, and basic request behavior. Verified with `cargo test -p hemx-lsp` and `workledger check`. diff --git a/work/slices/SLICE-0009-lsp-protocol-contract.md b/work/slices/SLICE-0009-lsp-protocol-contract.md deleted file mode 100644 index 9e6209d..0000000 --- a/work/slices/SLICE-0009-lsp-protocol-contract.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: SLICE-0009 -type: slice -status: done -parent: NS-0002 -title: Harden LSP protocol behavior -refs: - - hemx-lsp/src/main.rs - - docs/editor-support.md - - REQUIREMENTS.md ---- - -## Objective - -Make `hemx-lsp` predictable as an LSP server: initialize capabilities, document sync, completion, hover, diagnostics, shutdown, and unknown request errors should have focused protocol tests. - -## Authority - -`hemx-lsp/src/main.rs` implements the protocol loop; `docs/editor-support.md` names the editor-facing command surface. - -## Close when - -- Tests cover `initialize`, `didOpen`, `didChange`, `didSave`, `didClose`, `completion`, `hover`, `shutdown`, and unknown requests. -- The tests assert JSON-RPC framing and response IDs, not just helper functions. -- The server keeps normal HTML/tree-sitter tooling assumptions and does not add editor-provider policy. - -## Verification - -- `cargo test -p hemx-lsp` -- `cargo run -p hemx-lsp -- help` - -## Non-goals - -- Do not add async runtime or background watchers unless protocol tests prove sync IO is insufficient. -- Do not publish editor extensions in this slice. - -## Evidence - -Done at commit `e77ddd4` and verified again at `38794cf` with `cargo test -p hemx-lsp`: protocol tests cover initialize capabilities, shutdown, unknown request errors, diagnostics lifecycle, completion, and hover framing. diff --git a/work/slices/SLICE-0010-highlight-boundary.md b/work/slices/SLICE-0010-highlight-boundary.md deleted file mode 100644 index 71ed675..0000000 --- a/work/slices/SLICE-0010-highlight-boundary.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: SLICE-0010 -type: slice -status: done -parent: NS-0003 -title: Define repo-owned hemplate highlighting boundary -refs: - - docs/editor-support.md - - docs/hemplate-syntax.md - - REQUIREMENTS.md ---- - -## Objective - -Turn "tree-sitter highlighting for hemplate" into a bounded repo-owned contract: HTML stays the base grammar, hemplate overlay tokens are documented, and validation remains with `hemx-build`/`hemx-lsp`. - -## Authority - -`docs/editor-support.md` says normal HTML/tree-sitter tooling remains in use; `docs/hemplate-syntax.md` names the small overlay; `REQUIREMENTS.md` forbids a second template language or custom editor framework. - -## Close when - -- A highlighting boundary doc or work artifact lists the token classes the repo owns: escaped/trusted text delimiters, dynamic attribute prefix, `h-*` directives, and `data-hemx-*` facts. -- It explicitly excludes diagnostics, completion, hover, formatting, Rust expression parsing, and build validation from highlighting authority. -- It names what can be tested in-repo without editor package publishing. - -## Verification - -- `workledger check` for the slice contract now. -- Later implementation: a repo-owned fixture/query test command, or a manual editor smoke if no automated query runner exists yet. - -## Non-goals - -- Do not introduce a new parser for `hemx-build`. -- Do not publish or install editor packages. - -## Evidence - -Done at commit `5d38d0c` follow-up and verified with `workledger check`: `docs/editor-support.md` now lists repo-owned overlay token classes, explicitly excludes diagnostics/completion/hover/formatting/Rust parsing/build validation from highlighting authority, and names fixture/query tests as the repo-owned proof boundary before provider packaging. diff --git a/work/slices/SLICE-0011-highlight-fixtures.md b/work/slices/SLICE-0011-highlight-fixtures.md deleted file mode 100644 index c5a4737..0000000 --- a/work/slices/SLICE-0011-highlight-fixtures.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: SLICE-0011 -type: slice -status: done -parent: NS-0003 -title: Add hemplate highlighting fixtures and query tests -refs: - - docs/hemplate-syntax.md - - docs/editor-support.md - - REQUIREMENTS.md ---- - -## Objective - -Create repo-owned highlighting fixtures that prove documented hemplate syntax receives stable captures while ordinary HTML remains handled by existing HTML tooling. - -## Authority - -`docs/hemplate-syntax.md` is the syntax authority; `docs/editor-support.md` says highlighting is HTML first with hemx overlays. - -## Close when - -- Fixtures cover text insertion, trusted HTML insertion, dynamic attributes, `h-if`, `h-for`, `h-key`, `h-match`, `h-case`, `data-hemx-root`, `data-hemx-slot`, `data-hemx-form`, and `data-hemx-handle`. -- Tests or golden outputs verify capture names for those tokens. -- The test path is repo-owned and does not require publishing a VS Code/Cursor/Neovim package. - -## Verification - -- `cargo test -p hemx-lsp` -- `workledger check` - -## Non-goals - -- Do not parse Rust expressions inside `{+ +}` beyond highlighting delimiters/expression region. -- Do not make highlights responsible for diagnostics or completion. - -## Evidence - -Done at commit `34327d6` follow-up and verified with `cargo test -p hemx-lsp`: `docs/fixtures/hemplate-highlighting/hemplate.heml` covers the documented overlay token classes and `captures.tsv` records the golden capture names without requiring editor package publishing or a second parser. diff --git a/work/slices/SLICE-0012-editor-packaging-blocker.md b/work/slices/SLICE-0012-editor-packaging-blocker.md deleted file mode 100644 index 17f48f2..0000000 --- a/work/slices/SLICE-0012-editor-packaging-blocker.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: SLICE-0012 -type: slice -status: blocked -parent: NS-0003 -title: Package editor highlighting integrations -refs: - - docs/editor-support.md - - README.md ---- - -## Objective - -Ship `.heml` highlighting into specific editors only after repo-owned syntax/query behavior exists and release authority is explicit. - -## Authority - -`docs/editor-support.md` names VS Code, Cursor, and Neovim setup, but current repo authority does not grant external marketplace/package publishing from this work-graph mapping goal. - -## Close when - -- The target editor/provider is named. -- The package owner, signing/publishing credentials, and release process are explicit. -- The provider package consumes repo-owned query/fixture facts rather than becoming a separate syntax authority. - -## Verification - -- Provider-specific install/manual smoke, named by the future implementation slice. -- Repo-owned query fixture tests from `SLICE-0011` remain green. - -## Non-goals - -- Do not publish marketplace packages or mutate external editor registries under the current authority. -- Do not fork syntax behavior per editor. - -## Evidence - -This is intentionally blocked by missing external release authority, not by an implementation guess. diff --git a/work/slices/SLICE-0013-html-examples-index-landing.md b/work/slices/SLICE-0013-html-examples-index-landing.md deleted file mode 100644 index 5587555..0000000 --- a/work/slices/SLICE-0013-html-examples-index-landing.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -id: SLICE-0013 -type: slice -status: ready -parent: NS-0004 -title: Make html_examples root an index landing page -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md - - examples/html_examples/src/main.rs - - examples/html_examples/templates/app_shell.heml - - examples/html_examples/templates/gallery.heml - - hemx-xtask/src/main.rs ---- - -## Objective - -Change `http://127.0.0.1:3029/` from the long live gallery into a clear index page that links to implemented htmx-style examples by slug, so the starting page is navigation and orientation rather than every interaction mounted at once. - -## Authority - -- `AGENTS.md` requires exact htmx URL slugs to stay visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/007 -- `examples/html_examples/README.md` is the pattern matrix and run-command authority for implemented/deferred/refused example status. -- `examples/html_examples/src/main.rs` currently serves `/` as the mounted gallery page; `templates/app_shell.heml` and `templates/gallery.heml` own the visible root shape. - -## Close when - -- `/` shows a heading, short explanation, and navigation links for implemented examples from the README pattern matrix. -- Link text keeps exact htmx example slugs visible, including `click-to-edit`, `click-to-load`, `infinite-scroll`, `progress-bar`, and the other currently implemented rows. -- `/` does not eagerly mount live example forms/tables for the focused example pages. -- Native link behavior remains boring: links are real anchors to repo-owned same-origin pages; external htmx reference URLs, if shown, remain ordinary links. - -## Allowed files - -- `examples/html_examples/src/main.rs` -- `examples/html_examples/templates/app_shell.heml` -- `examples/html_examples/templates/gallery.heml` or a replacement index `.heml` template under `examples/html_examples/templates/` -- `examples/html_examples/README.md` only for run/navigation documentation if needed -- `hemx-xtask/src/main.rs` only to keep smoke coverage aligned with the index -- `REQUIREMENTS.md` only if the change creates or changes a durable product obligation - -## Verification - -- `cargo test -p hemx-html-examples` -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `redgate health --strict && redgate lint` if `REQUIREMENTS.md` changes -- Manual check if needed: run `cargo run -p hemx-html-examples`, open `http://127.0.0.1:3029/`, and confirm the root page is an index with real links rather than the long live gallery. - -## Non-goals - -- Do not implement or restructure every example page in this slice except the minimum route/link needed to prove index navigation. -- Do not add styling framework, docs generator, client-side router, or JavaScript navigation layer. -- Do not promote deferred/integration-owned/refused README rows into live links. - -## Evidence - -Ready; no implementation evidence yet. diff --git a/work/slices/SLICE-0014-html-examples-per-example-pages.md b/work/slices/SLICE-0014-html-examples-per-example-pages.md deleted file mode 100644 index cd3f7f0..0000000 --- a/work/slices/SLICE-0014-html-examples-per-example-pages.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -id: SLICE-0014 -type: slice -status: ready -parent: NS-0004 -title: Split implemented html_examples into focused per-example pages -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md - - examples/html_examples/src/main.rs - - examples/html_examples/templates/app_shell.heml - - examples/html_examples/templates/gallery.heml - - hemx-xtask/src/main.rs ---- - -## Objective - -Give each currently implemented html_examples pattern a focused page/route so contributors can open, copy, and test one pattern at a time while preserving generated-resource, server-owned-state behavior. - -## Authority - -- `AGENTS.md` says `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples and should keep exact htmx slugs visible without HTMX syntax, selector targeting, or user-authored browser JavaScript. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/007 -- `REQUIREMENTS.md` page swap and navigation rows preserve real anchors/history semantics and progressive enhancement boundaries. req: page_swap/007 req: page_swap/008 -- Existing generated templates and handlers in `examples/html_examples/src/main.rs` own the implemented dynamic behavior. - -## Close when - -- Implemented README rows have stable same-origin pages with exact slug names visible in the URL or page heading: `click-to-edit`, `click-to-load`, `delete-row`, `edit-row`, `lazy-load`, `inline-validation`, `active-search`, `progress-bar`, `value-select`, `reset-user-input`, `update-other-content` where represented by existing behavior, and `infinite-scroll` owned by `SLICE-0015`. -- Each page renders only the relevant example content plus minimal navigation back to the index/neighboring examples. -- Existing handlers continue to use generated forms/resources and server-owned Rust state; route splitting must not introduce selector-targeted swaps, user-authored browser JavaScript, or duplicated app state truth. -- Existing implemented interactions continue to work without full-page reload during the interaction itself. - -## Allowed files - -- `examples/html_examples/src/main.rs` -- `examples/html_examples/templates/app_shell.heml` -- Existing or new `.heml` templates under `examples/html_examples/templates/` -- Existing partial templates under `examples/html_examples/templates/partials/` only if moving markup requires local template boundaries -- `examples/html_examples/README.md` for updated route/page documentation -- `hemx-xtask/src/main.rs` for focused smoke route coverage -- `REQUIREMENTS.md` only if route/page behavior changes durable obligations - -## Verification - -- `cargo test -p hemx-html-examples` -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `redgate health --strict && redgate lint` if `REQUIREMENTS.md` changes -- Manual spot check if needed: from `/`, open several example links directly and verify their heading/slug and dynamic behavior. - -## Non-goals - -- Do not implement deferred examples such as `bulk-update`, `modal-custom`, or `tabs-hateoas`. -- Do not replace axum routes with a framework-owned router abstraction. -- Do not combine click-triggered `click-to-load` semantics with scroll-triggered `infinite-scroll`; keep their page behavior distinct. - -## Evidence - -Ready; no implementation evidence yet. diff --git a/work/slices/SLICE-0015-infinite-scroll-standalone-page.md b/work/slices/SLICE-0015-infinite-scroll-standalone-page.md deleted file mode 100644 index eba24a2..0000000 --- a/work/slices/SLICE-0015-infinite-scroll-standalone-page.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -id: SLICE-0015 -type: slice -status: ready -parent: NS-0004 -title: Make infinite-scroll a real scroll-triggered standalone page -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md - - examples/html_examples/src/main.rs - - examples/html_examples/templates/gallery.heml - - hemx-xtask/src/main.rs ---- - -## Objective - -Move `infinite-scroll` out of the crowded root gallery context and prove it as a standalone page where scrolling to a sentinel triggers loading more rows, instead of relying on a click-oriented `Reveal more rows` interaction. - -## Authority - -- `examples/html_examples/README.md` labels `infinite-scroll` implemented and currently describes a revealed sentinel form that posts to the server-owned loading model. -- `templates/gallery.heml` currently renders `infinite-scroll` as one section in the root gallery with `data-hemx-revealed="true"` and a visible `Reveal more rows` button. -- `AGENTS.md` and `REQUIREMENTS.md` keep examples HTML-shaped, server-owned, progressive, and covered by focused browser smoke. req: htmx_equivalents/001 req: examples/012 req: test/006 - -## Close when - -- `/infinite-scroll` or the chosen same-origin slug route opens a focused page headed `infinite-scroll`. -- The page starts with enough vertical space/rows or sentinel placement that the user/browser can scroll to trigger the next batch. -- Loading more rows is primarily driven by the existing revealed/intersection behavior when the sentinel enters view; the smoke proves rows increase after scrolling, not after clicking a visible load-more button. -- A fallback submit control may exist only as progressive enhancement/recovery, but it must not be the primary proof path or make the example look like `click-to-load`. -- The server remains the owner of row count/state and returns generated keyed `infinite_row` effects; no selector-targeted rerendering or user-authored browser JavaScript is introduced. - -## Allowed files - -- `examples/html_examples/src/main.rs` -- `examples/html_examples/templates/app_shell.heml` -- `examples/html_examples/templates/gallery.heml` or a new focused infinite-scroll `.heml` template under `examples/html_examples/templates/` -- `examples/html_examples/templates/partials/loaded_row.heml` only if a shared row partial remains the local generated-resource boundary -- `examples/html_examples/README.md` only to keep the matrix proof anchor honest -- `hemx-xtask/src/main.rs` for the scroll-based smoke proof -- `REQUIREMENTS.md` only if the behavior changes durable obligations - -## Verification - -- `cargo test -p hemx-html-examples` -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `redgate health --strict && redgate lint` if `REQUIREMENTS.md` changes -- Manual check if needed: open the infinite-scroll page directly, scroll until the sentinel enters view, and confirm additional rows appear without clicking `Reveal more rows`. - -## Non-goals - -- Do not implement virtual scrolling, pagination framework, client cache, or stored DOM patches. -- Do not make `click-to-load` scroll-triggered; it should remain the separate click example. -- Do not add bespoke browser JavaScript for this example when the runtime's revealed behavior can carry the proof. - -## Evidence - -Ready; no implementation evidence yet. diff --git a/work/slices/SLICE-0016-html-examples-smoke-and-readme.md b/work/slices/SLICE-0016-html-examples-smoke-and-readme.md deleted file mode 100644 index 2d047df..0000000 --- a/work/slices/SLICE-0016-html-examples-smoke-and-readme.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: SLICE-0016 -type: slice -status: ready -parent: NS-0004 -title: Update html_examples smoke and README for index/page navigation -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md - - examples/html_examples/src/main.rs - - hemx-xtask/src/main.rs ---- - -## Objective - -Keep the public documentation and focused browser smoke aligned with the new html_examples structure so contributors can trust `/` as an index, focused pages as copyable examples, and `infinite-scroll` as a scroll-triggered interaction. - -## Authority - -- `AGENTS.md` identifies `cargo run -p hemx-xtask -- html-examples-smoke` as the focused repo-owned browser verification for the HTML pattern gallery and no-reload dynamic interactions. req: test/006 req: test/013 req: test/014 -- `examples/html_examples/README.md` is the public run command and pattern matrix. -- `hemx-xtask/src/main.rs` owns smoke scripts, diagnostics, and no-navigation/no-reload guards. - -## Close when - -- `examples/html_examples/README.md` still explains the run command and pattern matrix, but its proof anchors and navigation notes match the new index/per-page structure. -- `cargo run -p hemx-xtask -- html-examples-smoke` starts from the index, follows real example links/routes, and proves representative no-reload dynamic behavior on focused pages. -- The smoke includes a scroll-based infinite-scroll proof and keeps the existing no-navigation/no-reload guard for dynamic interactions. -- Smoke failures identify the named example route/path so a broken page split is easy to diagnose. - -## Allowed files - -- `examples/html_examples/README.md` -- `hemx-xtask/src/main.rs` -- `examples/html_examples/src/main.rs` only for testability hooks or route names already owned by implementation slices -- `REQUIREMENTS.md` only if smoke/documentation duties become durable requirements -- `AGENTS.md` only if the stable command guidance must change; otherwise do not touch it - -## Verification - -- `cargo test -p hemx-xtask` -- `cargo run -p hemx-xtask -- html-examples-smoke` -- `cargo test -p hemx-html-examples` if route names/templates changed in the same slice -- `redgate health --strict && redgate lint` if `REQUIREMENTS.md` or `AGENTS.md` changes - -## Non-goals - -- Do not add a second smoke runner, Playwright/Selenium dependency, `/tmp` smoke scripts, or untracked proof files. -- Do not use smoke updates to implement new examples. -- Do not weaken existing dynamic checks for click-to-edit, edit-row, validation, search, lazy-load, click-to-load, progress, value-select, reset, or revealed fallback. - -## Evidence - -Ready; no implementation evidence yet. diff --git a/work/slices/SLICE-0017-html-examples-requirement-governance.md b/work/slices/SLICE-0017-html-examples-requirement-governance.md deleted file mode 100644 index 2f16a97..0000000 --- a/work/slices/SLICE-0017-html-examples-requirement-governance.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -id: SLICE-0017 -type: slice -status: ready -parent: NS-0004 -title: Keep html_examples requirements and docs aligned with page restructuring -refs: - - AGENTS.md - - REQUIREMENTS.md - - examples/html_examples/README.md ---- - -## Objective - -After the index, per-page routes, and true scroll-triggered infinite-scroll behavior are implemented, make the durable requirement/doc authority match the shipped behavior or explicitly record that requirements were unaffected. - -## Authority - -- `AGENTS.md` requires behavior changes to update `REQUIREMENTS.md` in the same change when durable product obligations, acceptance, safety/recovery behavior, or verification duties change. -- `REQUIREMENTS.md` already contains constraints for htmx-equivalent examples, enhanced page navigation, and browser smoke. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: page_swap/007 req: page_swap/008 req: test/006 -- Workledger evidence should reflect implemented or blocked state once the restructuring slices complete. - -## Close when - -- If restructuring changes durable obligations for the pattern gallery, `REQUIREMENTS.md` is updated in the same change with stable requirement IDs and redgate-valid rows. -- If no durable obligation changes, the implementation handoff says `REQUIREMENT IMPACT: none` and explains why the existing `htmx_equivalents/*`, `examples/*`, `page_swap/*`, and `test/*` requirements already cover the behavior. -- README and workledger evidence point to the actual page/route/smoke proof rather than the old single-page gallery shape. -- No requirement row is added only to satisfy tooling; requirements remain checkable obligations, not implementation notes. - -## Allowed files - -- `REQUIREMENTS.md` -- `AGENTS.md` only if stable agent guidance truly changes -- `examples/html_examples/README.md` -- `work/northstars/NS-0004-html-examples-navigation.md` -- `work/slices/SLICE-0013-html-examples-index-landing.md` -- `work/slices/SLICE-0014-html-examples-per-example-pages.md` -- `work/slices/SLICE-0015-infinite-scroll-standalone-page.md` -- `work/slices/SLICE-0016-html-examples-smoke-and-readme.md` - -## Verification - -- `redgate list` -- `redgate refs` -- `redgate health --strict` -- `redgate lint` if `REQUIREMENTS.md` changes or the slice claims maintainability cleanup -- `workledger check && workledger list` - -## Non-goals - -- Do not perform broad redgate lint cleanup outside html_examples/page navigation requirements. -- Do not rewrite existing requirements just because wording can be polished. -- Do not add new product commitments for deferred/integration-owned/refused examples. - -## Evidence - -Ready; no implementation evidence yet.