Compare commits
69 Commits
c793de8224
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da28db9775 | |||
| c530dc5d8f | |||
| 2dc7703936 | |||
| 276f40ee59 | |||
| c4a6edfed1 | |||
| 895572b735 | |||
| 7c98de1e8c | |||
| e7ca6d2350 | |||
| 0be05f4432 | |||
| f182c1661d | |||
| 1ff6ae1de9 | |||
| 7dfbf104d3 | |||
| df9f8f16fb | |||
| 5d6486c22c | |||
| d1af300bd4 | |||
| 31c52698e3 | |||
| 78a0d91223 | |||
| dd230b470e | |||
| a59e9b0bfe | |||
| 62b0787a8e | |||
| 8c79ea26bf | |||
| 1ba2d22f15 | |||
| becddd6004 | |||
| 91c50a35c6 | |||
| f3a2397631 | |||
| fb4e317d15 | |||
| 03e081a11a | |||
| 5586f87f43 | |||
| fdae13b2b6 | |||
| fe03f17667 | |||
| 216465f779 | |||
| 14f16cba23 | |||
| 05154a58ed | |||
| 31534a538b | |||
| a6f287e477 | |||
| 4e7d772311 | |||
| a322e519d3 | |||
| 2e39fb17a1 | |||
| 185cd18990 | |||
| 4058d05f20 | |||
| 2ba0be4d24 | |||
| 72bce73e52 | |||
| b28ffacacb | |||
| 9cdc91984a | |||
| db5e4bf56c | |||
| 8a21cad98e | |||
| 1a9c090028 | |||
| 89747c8c48 | |||
| 8aeab5fca1 | |||
| 52a32cfb1f | |||
| 0fe2376874 | |||
| b0a8275b03 | |||
| e38489621b | |||
| dc7c82f7ee | |||
| d74810db4b | |||
| 2705099aed | |||
| 612a66725f | |||
| 5e555f4d93 | |||
| e9ced4e0c1 | |||
| e7211df4c5 | |||
| a04773d016 | |||
| 48d6ea31be | |||
| 23b1d75f7f | |||
| 025d9c1563 | |||
| 90e8c3ced5 | |||
| 6f58453aae | |||
| 4fc5e53adf | |||
| 4e0d9ebaff | |||
| ef8e38adf8 |
@@ -0,0 +1,58 @@
|
||||
# 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\\)\\)",
|
||||
"<impl Drop for TestProcess>::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 <impl IntoResponse for RuntimeJs>::into_response",
|
||||
'replace "runtime hash is a valid ETag" with "" in <impl IntoResponse for RuntimeJs>::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',
|
||||
]
|
||||
@@ -35,6 +35,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
||||
- First split intent into broad error classes: what can go wrong, and what outcome must hold.
|
||||
- Test the largest risky classes before narrow examples.
|
||||
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
|
||||
- When changing generated lowering, prove dynamic rendered attributes at the consumer boundary; literal lowering fixtures alone are insufficient.
|
||||
- Do not over-codify existing behavior while direction is still moving.
|
||||
- Add narrow concrete tests only after requirements converge into a stable direction.
|
||||
|
||||
@@ -44,7 +45,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
||||
- `redgate refs` — show `req:` citations found in the repo.
|
||||
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
|
||||
- `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup.
|
||||
- `redgate health --strict` — fail on hard errors: empty requirements, duplicate IDs, or stale citations.
|
||||
- `redgate refs` — with the installed CLI, parse the elected requirement format and audit repository citations; `redgate health` additionally enforces a newer prescriptive-row style not yet elected by this requirements corpus.
|
||||
- `redgate agents` — print this starter template; review, shrink, and edit before committing.
|
||||
|
||||
## Local guidance
|
||||
@@ -52,20 +53,21 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
||||
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
|
||||
- Prefer links or pointers to canonical sources over copied lists.
|
||||
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
|
||||
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; the full path should stay within a documented 10 minute local timeout or be split into deterministic shards under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016
|
||||
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; mutation shards use one-based `1..=TOTAL` numbering, allow the wrapper's 120-second floor for compiler probes, and rely on the wrapper's package-specific nested concurrency rather than direct `mutest` invocation. The full path should stay within a documented 10 minute local timeout or use every deterministic mutation shard under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016 req: test/020 req: test/022 req: test/023
|
||||
- Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019
|
||||
- Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008
|
||||
- Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013
|
||||
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
|
||||
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata, the versioned canonical hemx `EffectBatch` codec, postcard surface facts, and form-encoded public requests—not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
|
||||
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
|
||||
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
|
||||
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
|
||||
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
|
||||
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
|
||||
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. 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
|
||||
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
|
||||
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
|
||||
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006
|
||||
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
|
||||
- JS runtime changes must preserve root-scoped lookup, delegated listeners, postcard EffectBatch application, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001
|
||||
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
|
||||
- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling and viewport-aware revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001
|
||||
- Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012
|
||||
- Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002
|
||||
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003
|
||||
|
||||
Generated
+95
-84
@@ -117,12 +117,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -202,9 +196,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
@@ -225,12 +219,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
@@ -313,6 +317,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -383,15 +393,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -705,14 +706,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1012,27 +1011,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1077,7 +1062,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"spin 0.9.8",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
@@ -1119,8 +1104,15 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
dependencies = [
|
||||
"pastey",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
@@ -1130,19 +1122,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -1150,19 +1143,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -1173,9 +1166,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -1253,14 +1246,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1301,15 +1294,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
@@ -1317,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1327,15 +1311,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -1441,6 +1419,15 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
@@ -1496,9 +1483,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.23.1"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2"
|
||||
checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
@@ -1511,23 +1498,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1673,8 +1666,15 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
dependencies = [
|
||||
"spin 0.12.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
@@ -1690,22 +1690,21 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -2210,6 +2209,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
|
||||
@@ -9,3 +9,9 @@ edition = "2021"
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
|
||||
# Upstreams still request retired package identities. These tiny bridges re-export
|
||||
# the maintained successors without introducing a second implementation.
|
||||
[patch.crates-io]
|
||||
paste = { path = "compat/paste" }
|
||||
spin = { path = "compat/spin" }
|
||||
|
||||
@@ -5,13 +5,14 @@ handlers, and return generated UI commands. The browser receives checked UI
|
||||
commands; ordinary server-first apps do not need a frontend framework,
|
||||
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
|
||||
|
||||
Status: the repository has a proven server-first/page-enhanced baseline, a
|
||||
compile-tested SaaS tutorial, an advanced Kanban sketch, and a full techdemo.
|
||||
Full v1 is not yet feature-complete: client-local WASM, durable offline/sync, the
|
||||
production integration reference, and their browser/recovery gates remain active.
|
||||
See `docs/v1-product-evidence.md` for the evidence-backed product boundary,
|
||||
`REQUIREMENTS.md` for authority, `PLAN.md` for implementation order, and
|
||||
`docs/v1-readiness.md` for proven baseline evidence. No publishing is implied.
|
||||
Status: the evidence-backed v1 behavior slices are implemented and browser-proven:
|
||||
server-first/page-enhanced behavior, client-local WASM, durable offline/sync, the
|
||||
multiplayer Kanban milestone, and the production reference. Local workspace,
|
||||
browser, performance, documentation, and canonical-example gates pass. The
|
||||
warning-denied vulnerability and source audits are clean; strict license closure
|
||||
awaits a repository license allowlist. See `docs/v1-product-evidence.md` for the
|
||||
product boundary, `REQUIREMENTS.md` for authority, `PLAN.md` for execution state,
|
||||
and `docs/v1-readiness.md` for evidence. No publishing is implied.
|
||||
|
||||
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
|
||||
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
|
||||
|
||||
+468
-837
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[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"
|
||||
@@ -0,0 +1,6 @@
|
||||
#![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;
|
||||
@@ -0,0 +1,15 @@
|
||||
[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 = []
|
||||
spin_mutex = ["spin_next/spin_mutex"]
|
||||
|
||||
[dependencies]
|
||||
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
|
||||
@@ -0,0 +1,6 @@
|
||||
#![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::*;
|
||||
+1
-1
@@ -86,4 +86,4 @@ Current recurring checks cover the most common classes:
|
||||
|
||||
Before claiming the diagnostics story is closed for v1, run those gates plus
|
||||
`cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and
|
||||
`redgate health --strict` on a clean tree. req: test/003 req: test/004
|
||||
`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
|
||||
|
||||
@@ -121,7 +121,7 @@ Before promoting a release:
|
||||
```sh
|
||||
cargo run -p hemx-xtask -- test
|
||||
cargo check --workspace
|
||||
redgate health --strict
|
||||
redgate refs
|
||||
```
|
||||
|
||||
Then verify deployment-specific behavior:
|
||||
|
||||
+16
-5
@@ -10,10 +10,20 @@ closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: p
|
||||
## Current status
|
||||
|
||||
- Server-first and page-enhanced baseline: proven by the evidence below.
|
||||
- Client-local WASM: not implemented; first active v1 slice.
|
||||
- Durable offline/sync: contracts shaped, implementation blocked by the local
|
||||
execution boundary.
|
||||
- Full production reference and v1 closure matrix: not yet proven.
|
||||
- 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
|
||||
@@ -162,6 +172,7 @@ 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
|
||||
@@ -171,7 +182,7 @@ cargo test -p hemx-derive --test compile_fail
|
||||
cargo test -p hemx-test --test examples_contract
|
||||
redgate list
|
||||
redgate refs
|
||||
redgate health --strict
|
||||
redgate health
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
+26
-3
@@ -39,6 +39,22 @@ 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
|
||||
@@ -118,7 +134,7 @@ include upgrade notes with this shape:
|
||||
```sh
|
||||
cargo run -p hemx-xtask -- test
|
||||
cargo check --workspace
|
||||
redgate health --strict
|
||||
redgate refs
|
||||
```
|
||||
````
|
||||
|
||||
@@ -126,15 +142,22 @@ redgate health --strict
|
||||
|
||||
Before tagging a v1-compatible release:
|
||||
|
||||
- `examples/v0` and `examples/saas` compile and test without raw ids, raw effects,
|
||||
- `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: diagnostics/001 req: diagnostics/002
|
||||
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.
|
||||
|
||||
@@ -15,7 +15,7 @@ hemx-axum = { path = "../../hemx-axum" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -44,7 +44,7 @@ Status legend:
|
||||
| `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. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` |
|
||||
| `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` |
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
{+ row +}
|
||||
</template>
|
||||
</ul>
|
||||
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true">
|
||||
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true" data-hemx-revealed-ahead="1">
|
||||
<input type="hidden" name="request" value="more">
|
||||
<button type="submit">Reveal more rows</button>
|
||||
</form>
|
||||
|
||||
@@ -39,7 +39,7 @@ hemplate = { path = "../../../hemplate/hemplate" }
|
||||
|
||||
[dev-dependencies]
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
thirtyfour = "0.35"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#[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);
|
||||
@@ -87,15 +90,15 @@ pub fn reorder_card(
|
||||
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
|
||||
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
||||
};
|
||||
let projection = hemx::IntoEffect::into_batch(
|
||||
DurableSync::durable(
|
||||
patch,
|
||||
(
|
||||
move_effect,
|
||||
ui::client_board::client_notice
|
||||
.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||
),
|
||||
ui::BUILD_FINGERPRINT,
|
||||
);
|
||||
hemx_sync::SyncEffect::durable(patch, projection)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "client"))]
|
||||
@@ -118,9 +121,10 @@ mod client_tests {
|
||||
)
|
||||
.into_batch(ui::BUILD_FINGERPRINT);
|
||||
assert_eq!(batch.ops.len(), 3);
|
||||
assert!(
|
||||
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":["))
|
||||
);
|
||||
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\":["));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ use hemx_axum::{
|
||||
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};
|
||||
use hemx_sync::{
|
||||
Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect as FrameworkSync,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
@@ -663,7 +665,7 @@ async fn sync_ack(
|
||||
acknowledgement.clone()
|
||||
};
|
||||
let batch = (
|
||||
SyncEffect::ack(board::atoms::sync_ack),
|
||||
FrameworkSync::ack(board::atoms::sync_ack),
|
||||
board::sync_status.text(format!(
|
||||
"Canonical acknowledgement {} at server sequence {}",
|
||||
acknowledgement.command_id, acknowledgement.server_sequence
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 = 100;
|
||||
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"]';
|
||||
|
||||
@@ -382,7 +382,8 @@ async function start() {
|
||||
if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
|
||||
commands.forEach(validate);
|
||||
const replayStarted = performance.now();
|
||||
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||
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));
|
||||
|
||||
@@ -20,7 +20,7 @@ hemx-axum = { path = "../../hemx-axum" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -7,7 +7,9 @@ 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 in-memory persistence adapter instead of a vendored SQL/auth provider
|
||||
- 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
|
||||
@@ -20,11 +22,12 @@ What it deliberately keeps out of the tutorial crate:
|
||||
- provider credentials, external services, migrations, or browser automation
|
||||
- billing, account administration, or other SaaS platform scope
|
||||
|
||||
Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects.
|
||||
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
|
||||
cargo run -p hemx-saas-example
|
||||
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
|
||||
```
|
||||
|
||||
+298
-32
@@ -2,31 +2,45 @@
|
||||
pub mod ui {}
|
||||
|
||||
use hemplate::Hemplate;
|
||||
use hemx::{push, Html, IntoEffect};
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +105,56 @@ pub struct ProjectRecord {
|
||||
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<Self> {
|
||||
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<Mutex<Vec<ProjectRecord>>>,
|
||||
path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
impl LocalProjectStore {
|
||||
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
|
||||
let path = path.into();
|
||||
let projects = match fs::read_to_string(&path) {
|
||||
Ok(contents) => contents
|
||||
.lines()
|
||||
.map(ProjectRecord::decode)
|
||||
.collect::<io::Result<Vec<_>>>()?,
|
||||
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<ProjectRecord, AppError> {
|
||||
if name.as_str() == "fail-store" {
|
||||
return Err(AppError::StoreUnavailable);
|
||||
@@ -109,19 +167,103 @@ impl LocalProjectStore {
|
||||
name: name.as_str().to_owned(),
|
||||
owner: session.email.clone(),
|
||||
};
|
||||
projects.push(record.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<ProjectRecord> {
|
||||
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<MutationMetrics>,
|
||||
diagnostics: Arc<dyn DiagnosticSink>,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
@@ -129,9 +271,40 @@ impl AppContext {
|
||||
Self {
|
||||
session: Session::demo(),
|
||||
store: LocalProjectStore::default(),
|
||||
metrics: Arc::default(),
|
||||
diagnostics: Arc::new(StderrDiagnosticSink),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
|
||||
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
|
||||
}
|
||||
@@ -139,12 +312,96 @@ impl AppContext {
|
||||
pub fn projects(&self) -> Vec<ProjectRecord> {
|
||||
self.store.list()
|
||||
}
|
||||
|
||||
pub fn ready(&self) -> bool {
|
||||
self.store.ready()
|
||||
}
|
||||
|
||||
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> 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<ProjectRecord, AppError> {
|
||||
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<ProjectRecord, AppError> {
|
||||
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),
|
||||
}
|
||||
@@ -154,12 +411,21 @@ impl AppError {
|
||||
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 {
|
||||
@@ -289,11 +555,7 @@ mod dashboard_handlers {
|
||||
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 project = ctx.create_project(form.name)?;
|
||||
let total = ctx.projects().len();
|
||||
Ok((
|
||||
dashboard::project_row.append(ProjectRow::from(project)),
|
||||
@@ -303,15 +565,6 @@ mod dashboard_handlers {
|
||||
dashboard::live_status.set(format!("{total} projects persisted locally")),
|
||||
))
|
||||
}
|
||||
|
||||
#[hemx::handler]
|
||||
pub async fn open_settings(State(_ctx): State<AppContext>) -> impl IntoEffect {
|
||||
(
|
||||
dashboard::page_panel.put(&SettingsPage::production_boundaries()),
|
||||
dashboard::nav.set("Settings"),
|
||||
push("/settings"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn live_status(projects: usize) -> impl IntoEffect {
|
||||
@@ -346,6 +599,32 @@ mod tests {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
|
||||
|
||||
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
|
||||
@@ -452,23 +731,10 @@ mod tests {
|
||||
assert!(created.updates_text(dashboard::live_status));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_swap_and_push_shape_use_generated_targets() {
|
||||
// req: page_swap/002 req: push/003 req: examples/001
|
||||
#[test]
|
||||
fn live_status_uses_the_generated_dashboard_target() {
|
||||
// req: push/003 req: examples/014
|
||||
let ctx = AppContext::demo();
|
||||
let settings = inspect_batch(
|
||||
InteractionRequest::from(form(dashboard::open_settings, &[]))
|
||||
.dispatch_async(registry(ctx.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(
|
||||
settings.updates_html_containing(dashboard::page_panel, "explicit app integrations")
|
||||
);
|
||||
assert!(settings.updates_text(dashboard::nav));
|
||||
assert!(settings.pushes_to("/settings"));
|
||||
|
||||
let heartbeat = inspect(live_status(ctx.projects().len()));
|
||||
assert!(heartbeat.updates_text(dashboard::live_status));
|
||||
assert!(heartbeat.payload_contains("heartbeat"));
|
||||
|
||||
+164
-18
@@ -1,37 +1,67 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Query, State};
|
||||
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;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use futures_util::stream;
|
||||
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() {
|
||||
let app = app(AppContext::demo());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
|
||||
.await
|
||||
.expect("bind saas tutorial example");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve saas tutorial example");
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<AppContext>) -> impl IntoResponse {
|
||||
axum::response::Html(home_page(&ctx).into_string())
|
||||
}
|
||||
@@ -51,16 +81,132 @@ async fn events(
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> impl IntoResponse {
|
||||
let count = ctx.projects().len();
|
||||
// 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(stream::iter([Ok::<_, Infallible>(
|
||||
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
|
||||
)]));
|
||||
return sse(initial.left_stream());
|
||||
}
|
||||
|
||||
sse(stream::iter([Ok::<_, Infallible>(
|
||||
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
|
||||
)]))
|
||||
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<AppContext>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<BTreeMap<String, String>>,
|
||||
) -> 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<AppContext>) -> 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<AppContext>) -> 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 {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<nav class="tabs" data-hemx-slot="nav">
|
||||
<a href="/" data-hemx-nav="">Projects</a>
|
||||
<button type="button" data-hemx-handle="open_settings">Settings</button>
|
||||
<a href="/settings" data-hemx-nav>Settings</a>
|
||||
</nav>
|
||||
|
||||
<section class="panel" data-hemx-slot="page_panel">
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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("<script>"));
|
||||
assert!(!home.contains("javascript:"));
|
||||
assert!(
|
||||
home.contains("href=\"/settings\" data-hemx-nav"),
|
||||
"settings must remain a real, enhanceable link"
|
||||
);
|
||||
let settings = request(&address, "GET", "/settings", &[], "");
|
||||
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
|
||||
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
|
||||
assert!(settings.contains("explicit app integrations"), "{settings}");
|
||||
|
||||
let events = request(&address, "GET", "/events?once=1", &[], "");
|
||||
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
|
||||
assert_eq!(
|
||||
response_header(&events, "content-type"),
|
||||
"text/event-stream"
|
||||
);
|
||||
assert!(events.contains("event: hemx"), "{events}");
|
||||
assert!(events.contains("data:"), "{events}");
|
||||
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
|
||||
|
||||
let live = request(&address, "GET", "/health/live", &[], "");
|
||||
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
|
||||
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
|
||||
let ready = request(&address, "GET", "/health/ready", &[], "");
|
||||
assert!(ready.starts_with("HTTP/1.1 200"), "{ready}");
|
||||
assert!(ready.contains("{\"status\":\"ready\","), "{ready}");
|
||||
|
||||
let denied_responses = [
|
||||
create(
|
||||
&address,
|
||||
"DeniedAuth",
|
||||
"Bearer secret-auth-material",
|
||||
"demo-csrf",
|
||||
&origin,
|
||||
),
|
||||
create(
|
||||
&address,
|
||||
"DeniedCsrf",
|
||||
"Bearer demo-session",
|
||||
"stale",
|
||||
&origin,
|
||||
),
|
||||
create(
|
||||
&address,
|
||||
"DeniedOrigin",
|
||||
"Bearer demo-session",
|
||||
"demo-csrf",
|
||||
"https://attacker.invalid",
|
||||
),
|
||||
];
|
||||
for denied in &denied_responses {
|
||||
assert!(denied.starts_with("HTTP/1.1 403"), "{denied}");
|
||||
assert!(response_header(denied, "x-request-id").starts_with("req-"));
|
||||
assert!(denied.contains("{\"code\":\"authorization-denied\"}"));
|
||||
assert!(!denied.contains("Denied"));
|
||||
assert!(!denied.contains("demo-csrf"));
|
||||
assert!(!denied.contains("secret-auth-material"));
|
||||
assert!(!denied.contains("attacker.invalid"));
|
||||
}
|
||||
let wrong_content_type = request(
|
||||
&address,
|
||||
"POST",
|
||||
"/projects",
|
||||
&[
|
||||
("Authorization", "Bearer demo-session"),
|
||||
("Origin", origin.as_str()),
|
||||
("Content-Type", "text/plain"),
|
||||
],
|
||||
"name=WrongType&csrf=demo-csrf",
|
||||
);
|
||||
assert!(
|
||||
wrong_content_type.starts_with("HTTP/1.1 415"),
|
||||
"{wrong_content_type}"
|
||||
);
|
||||
let oversized = request(
|
||||
&address,
|
||||
"POST",
|
||||
"/projects",
|
||||
&[
|
||||
("Authorization", "Bearer demo-session"),
|
||||
("Origin", origin.as_str()),
|
||||
("Content-Type", "application/x-www-form-urlencoded"),
|
||||
],
|
||||
&format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)),
|
||||
);
|
||||
assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}");
|
||||
let before = request(&address, "GET", "/", &[], "");
|
||||
assert!(!before.contains("DeniedAuth"));
|
||||
assert!(!before.contains("DeniedCsrf"));
|
||||
assert!(!before.contains("DeniedOrigin"));
|
||||
assert!(!before.contains("WrongType"));
|
||||
let denied_metrics = request(&address, "GET", "/metrics", &[], "");
|
||||
assert!(
|
||||
denied_metrics.starts_with("HTTP/1.1 200"),
|
||||
"{denied_metrics}"
|
||||
);
|
||||
assert!(
|
||||
denied_metrics.contains("\"attempts\":3"),
|
||||
"{denied_metrics}"
|
||||
);
|
||||
assert!(denied_metrics.contains("\"denied\":3"), "{denied_metrics}");
|
||||
assert!(!denied_metrics.contains("Denied"));
|
||||
assert!(!denied_metrics.contains("demo-csrf"));
|
||||
assert!(!denied_metrics.contains("secret-auth-material"));
|
||||
|
||||
let stale = create_at_version(
|
||||
&address,
|
||||
"Stale%20Project",
|
||||
"Bearer demo-session",
|
||||
"demo-csrf",
|
||||
&origin,
|
||||
Some("0"),
|
||||
);
|
||||
assert!(stale.starts_with("HTTP/1.1 409"), "{stale}");
|
||||
assert!(response_header(&stale, "x-request-id").starts_with("req-"));
|
||||
assert!(stale.contains("{\"code\":\"deployment-mismatch\"}"));
|
||||
assert!(stale
|
||||
.to_ascii_lowercase()
|
||||
.contains("x-hemx-recovery: reload"));
|
||||
assert!(!request(&address, "GET", "/", &[], "").contains("Stale Project"));
|
||||
|
||||
let fingerprint = ready_fingerprint(&ready);
|
||||
let allowed = create_at_version(
|
||||
&address,
|
||||
"Durable%20Project",
|
||||
"Bearer demo-session",
|
||||
"demo-csrf",
|
||||
&origin,
|
||||
Some(fingerprint),
|
||||
);
|
||||
assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}");
|
||||
let allowed_request_id = response_header(&allowed, "x-request-id");
|
||||
assert!(allowed_request_id.starts_with("req-"));
|
||||
assert_ne!(allowed_request_id, response_header(&stale, "x-request-id"));
|
||||
assert!(request(&address, "GET", "/", &[], "").contains("Durable Project"));
|
||||
let metrics = request(&address, "GET", "/metrics", &[], "");
|
||||
assert!(metrics.contains("\"attempts\":5"), "{metrics}");
|
||||
assert!(metrics.contains("\"succeeded\":1"), "{metrics}");
|
||||
assert!(metrics.contains("\"mismatch\":1"), "{metrics}");
|
||||
}
|
||||
|
||||
{
|
||||
let _restarted = start(&address, &store);
|
||||
let restored = request(&address, "GET", "/", &[], "");
|
||||
assert!(restored.contains("Durable Project"), "{restored}");
|
||||
assert!(restored.contains("1 project"), "{restored}");
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_durable_commit_rolls_back_visible_state() {
|
||||
// test req: failure/004 req: operations/002 req: operations/007 req: operations/008 req: v1_release/003
|
||||
let address = available_address();
|
||||
let origin = format!("http://{address}");
|
||||
let store = test_path("rollback");
|
||||
let _app = start(&address, &store);
|
||||
fs::create_dir(&store).expect("block atomic rename destination");
|
||||
let not_ready = request(&address, "GET", "/health/ready", &[], "");
|
||||
assert!(not_ready.starts_with("HTTP/1.1 503"), "{not_ready}");
|
||||
assert!(not_ready.contains("\"code\":\"storage-unavailable\""));
|
||||
assert!(request(&address, "GET", "/health/live", &[], "").starts_with("HTTP/1.1 200"));
|
||||
|
||||
let rejected = create(
|
||||
&address,
|
||||
"Must%20Rollback",
|
||||
"Bearer demo-session",
|
||||
"demo-csrf",
|
||||
&origin,
|
||||
);
|
||||
assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
|
||||
assert!(response_header(&rejected, "x-request-id").starts_with("req-"));
|
||||
assert!(rejected.contains("{\"code\":\"storage-unavailable\"}"));
|
||||
assert!(!rejected.contains("Must Rollback"));
|
||||
assert!(!rejected.contains("demo-csrf"));
|
||||
assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback"));
|
||||
assert!(!store.with_extension("tmp").exists());
|
||||
let metrics = request(&address, "GET", "/metrics", &[], "");
|
||||
assert!(metrics.contains("\"attempts\":1"), "{metrics}");
|
||||
assert!(metrics.contains("\"failed\":1"), "{metrics}");
|
||||
assert!(!metrics.contains("Must Rollback"));
|
||||
|
||||
let _ = fs::remove_dir(store);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ hemx-host = { path = "../../hemx-host" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
thirtyfour = "0.35"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ hemx-axum = { path = "../../hemx-axum" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -13,6 +13,6 @@ hemx-core = { path = "../hemx-core" }
|
||||
hemx-js = { path = "../hemx-js" }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
scraper = "0.25"
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
+107
-41
@@ -1347,10 +1347,10 @@ impl IntoResponse for PageResponse {
|
||||
.headers_mut()
|
||||
.insert(HEMX_PARTIAL_HEADER, HeaderValue::from_static("true"));
|
||||
}
|
||||
if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) {
|
||||
if let Some(fingerprint) = self.fingerprint {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
|
||||
.insert(HEMX_FINGERPRINT_HEADER, fingerprint_header(fingerprint));
|
||||
}
|
||||
if let Some(title) = self
|
||||
.title
|
||||
@@ -1380,8 +1380,8 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -
|
||||
html
|
||||
}
|
||||
|
||||
fn fingerprint_header(fingerprint: BuildFingerprint) -> Option<HeaderValue> {
|
||||
HeaderValue::from_str(&fingerprint.0.to_string()).ok()
|
||||
fn fingerprint_header(fingerprint: BuildFingerprint) -> HeaderValue {
|
||||
HeaderValue::from(fingerprint.0)
|
||||
}
|
||||
|
||||
impl IntoResponse for EffectResponse {
|
||||
@@ -1392,11 +1392,10 @@ impl IntoResponse for EffectResponse {
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(HEMX_CONTENT_TYPE),
|
||||
);
|
||||
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
|
||||
}
|
||||
response.headers_mut().insert(
|
||||
HEMX_FINGERPRINT_HEADER,
|
||||
fingerprint_header(self.batch.fingerprint),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
@@ -1442,8 +1441,7 @@ impl IntoResponse for RuntimeJs {
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_LENGTH,
|
||||
HeaderValue::from_str(hemx_js::RUNTIME_JS.len().to_string().as_str())
|
||||
.expect("runtime length is a valid header value"),
|
||||
HeaderValue::from(hemx_js::RUNTIME_JS.len() as u64),
|
||||
);
|
||||
response
|
||||
}
|
||||
@@ -1485,23 +1483,20 @@ fn base64_url_no_pad(input: &[u8]) -> String {
|
||||
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
|
||||
let mut chunks = input.chunks_exact(3);
|
||||
for chunk in &mut chunks {
|
||||
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
|
||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(n & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(chunk[0] >> 2) as usize] as char);
|
||||
out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char);
|
||||
out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char);
|
||||
out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char);
|
||||
}
|
||||
match chunks.remainder() {
|
||||
[a] => {
|
||||
let n = (*a as u32) << 16;
|
||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(a >> 2) as usize] as char);
|
||||
out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
|
||||
}
|
||||
[a, b] => {
|
||||
let n = ((*a as u32) << 16) | ((*b as u32) << 8);
|
||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(a >> 2) as usize] as char);
|
||||
out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
|
||||
out.push(ALPHABET[((b & 0x0f) << 2) as usize] as char);
|
||||
}
|
||||
[] => {}
|
||||
_ => unreachable!(),
|
||||
@@ -1528,24 +1523,22 @@ fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, Interact
|
||||
|
||||
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match input[i] {
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < input.len() => {
|
||||
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
out.push((high << 4) | low);
|
||||
i += 3;
|
||||
}
|
||||
b'%' => return Err(InteractionFormRejection::InvalidBody),
|
||||
byte => {
|
||||
out.push(byte);
|
||||
i += 1;
|
||||
let mut bytes = input.iter().copied();
|
||||
while let Some(byte) = bytes.next() {
|
||||
match byte {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' => {
|
||||
let high = bytes
|
||||
.next()
|
||||
.and_then(hex)
|
||||
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
let low = bytes
|
||||
.next()
|
||||
.and_then(hex)
|
||||
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
out.push(high * 16 + low);
|
||||
}
|
||||
byte => out.push(byte),
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
||||
@@ -1563,7 +1556,8 @@ fn hex(byte: u8) -> Option<u8> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
|
||||
base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs,
|
||||
percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection,
|
||||
HEMX_SSE_EVENT,
|
||||
};
|
||||
use axum::{
|
||||
@@ -1613,6 +1607,77 @@ mod tests {
|
||||
.expect("root element is rendered");
|
||||
assert_eq!(root.value().attr("data-hemx-fp"), Some("1"));
|
||||
assert_eq!(root.text().collect::<String>(), "Docs");
|
||||
assert_eq!(html.matches("data-hemx-fp=").count(), 1);
|
||||
assert!(!html.contains("data-hemx-fp=\"99\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_injection_leaves_html_without_a_hemx_root_unchanged() {
|
||||
let html = "<main>Docs</main>".to_owned();
|
||||
assert_eq!(
|
||||
html_with_root_fingerprint(html.clone(), BuildFingerprint(99)),
|
||||
html
|
||||
);
|
||||
// test req: abi/005
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64url_transport_matches_rfc_4648_vectors_without_padding() {
|
||||
for (input, expected) in [
|
||||
(b"".as_slice(), ""),
|
||||
(b"f".as_slice(), "Zg"),
|
||||
(b"fo".as_slice(), "Zm8"),
|
||||
(b"foo".as_slice(), "Zm9v"),
|
||||
(b"foob".as_slice(), "Zm9vYg"),
|
||||
(b"fooba".as_slice(), "Zm9vYmE"),
|
||||
(b"foobar".as_slice(), "Zm9vYmFy"),
|
||||
(&[0xfb, 0xff, 0xff], "-___"),
|
||||
(&[0x00, 0x0f, 0x00], "AA8A"),
|
||||
(&[0x00, 0xcf, 0x00], "AM8A"),
|
||||
(&[0xff], "_w"),
|
||||
(&[0xff, 0xff], "__8"),
|
||||
] {
|
||||
assert_eq!(base64_url_no_pad(input), expected);
|
||||
}
|
||||
// req: push/008 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_decoder_handles_standard_escapes_and_rejects_malformed_input() {
|
||||
assert_eq!(
|
||||
parse_urlencoded_pairs(
|
||||
b"empty=&space=+&slash=%2f&digit=%39&upper=%4A&lower=%4a&repeat=1&repeat=2"
|
||||
),
|
||||
Ok(vec![
|
||||
("empty".into(), String::new()),
|
||||
("space".into(), " ".into()),
|
||||
("slash".into(), "/".into()),
|
||||
("digit".into(), "9".into()),
|
||||
("upper".into(), "J".into()),
|
||||
("lower".into(), "J".into()),
|
||||
("repeat".into(), "1".into()),
|
||||
("repeat".into(), "2".into()),
|
||||
])
|
||||
);
|
||||
assert_eq!(parse_urlencoded_pairs(b""), Ok(Vec::new()));
|
||||
for malformed in [
|
||||
b"bad=%".as_slice(),
|
||||
b"bad=%0".as_slice(),
|
||||
b"bad=%gg".as_slice(),
|
||||
b"bad=%0g".as_slice(),
|
||||
b"%gg=value".as_slice(),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_urlencoded_pairs(malformed),
|
||||
Err(InteractionFormRejection::InvalidBody)
|
||||
);
|
||||
}
|
||||
assert_eq!(percent_decode(b"a+b%2Fc"), Ok("a b/c".into()));
|
||||
assert_eq!(
|
||||
InteractionForm::parse_urlencoded(b"__h=1&bad=%"),
|
||||
Err(InteractionFormRejection::InvalidBody)
|
||||
);
|
||||
// test req: form/002 req: failure/003
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1623,6 +1688,7 @@ mod tests {
|
||||
ops: Vec::new(),
|
||||
};
|
||||
let encoded = encode_sse_batch(&batch);
|
||||
assert_eq!(encoded, "SEVNWAEAAAALAAAAAAAAAAAAAAA");
|
||||
|
||||
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
|
||||
assert_eq!(
|
||||
|
||||
+521
-11
@@ -1,7 +1,11 @@
|
||||
use axum::extract::{DefaultBodyLimit, State};
|
||||
use axum::http::{header, HeaderMap, Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{body::Body, routing::post, Router};
|
||||
use axum::{
|
||||
body::{to_bytes, Body, Bytes},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use hemx_axum::{
|
||||
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
|
||||
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
|
||||
@@ -9,26 +13,53 @@ use hemx_axum::{
|
||||
InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE,
|
||||
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER,
|
||||
};
|
||||
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
|
||||
use hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot};
|
||||
use scraper::{Html, Selector};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tower::ServiceExt;
|
||||
|
||||
static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
static BOUNDARY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
|
||||
MUTATION_CALLS.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
|
||||
let form = request.form();
|
||||
assert_eq!(form.handle_id, 7);
|
||||
assert_eq!(form.value("title"), Some("report"));
|
||||
assert_eq!(form.files().len(), 1);
|
||||
let upload = form.file("upload").expect("uploaded file");
|
||||
assert_eq!(upload.file_name.as_deref(), Some("report.txt"));
|
||||
assert_eq!(upload.content_type.as_deref(), Some("text/plain"));
|
||||
assert_eq!(upload.bytes, b"hello");
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
||||
// test req: security/003
|
||||
let _guard = BOUNDARY_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
MUTATION_CALLS.store(0, Ordering::SeqCst);
|
||||
let app = Router::new()
|
||||
.route("/mutate", post(bounded_mutation))
|
||||
.layer(DefaultBodyLimit::max(32));
|
||||
|
||||
let missing_content_type = app
|
||||
.clone()
|
||||
.oneshot(Request::post("/mutate").body(Body::from("__h=1")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
missing_content_type.status(),
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||
);
|
||||
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0);
|
||||
|
||||
let unsupported = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -60,7 +91,7 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
||||
Request::post("/mutate")
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/x-www-form-urlencoded; charset=utf-8",
|
||||
"Application/X-Www-Form-Urlencoded; charset=UTF-8",
|
||||
)
|
||||
.body(Body::from("__h=1"))
|
||||
.unwrap(),
|
||||
@@ -71,6 +102,115 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
||||
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_boundary_extracts_multipart_fields_and_files() {
|
||||
let _guard = BOUNDARY_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let boundary = "hemx-boundary";
|
||||
let body = concat!(
|
||||
"--hemx-boundary\r\n",
|
||||
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||
"7\r\n",
|
||||
"--hemx-boundary\r\n",
|
||||
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
|
||||
"report\r\n",
|
||||
"--hemx-boundary\r\n",
|
||||
"Content-Disposition: form-data; name=\"upload\"; filename=\"report.txt\"\r\n",
|
||||
"Content-Type: text/plain\r\n\r\n",
|
||||
"hello\r\n",
|
||||
"--hemx-boundary--\r\n"
|
||||
);
|
||||
let response = Router::new()
|
||||
.route("/upload", post(multipart_mutation))
|
||||
.oneshot(
|
||||
Request::post("/upload")
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
// test req: multipart/001 req: multipart/002 req: multipart/003
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() {
|
||||
let _guard = BOUNDARY_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let app = Router::new().route("/mutate", post(bounded_mutation));
|
||||
let missing_boundary = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/mutate")
|
||||
.header(header::CONTENT_TYPE, "multipart/form-data")
|
||||
.body(Body::from("invalid"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_boundary.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let accepted = concat!(
|
||||
"--b\r\n",
|
||||
"Content-Disposition: form-data; filename=\"ignored.txt\"\r\n\r\n",
|
||||
"ignored\r\n",
|
||||
"--b\r\n",
|
||||
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||
"1\r\n",
|
||||
"--b--\r\n"
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/mutate")
|
||||
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||
.body(Body::from(accepted))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let invalid_utf8 =
|
||||
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n\xff\r\n--b--\r\n";
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/mutate")
|
||||
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||
.body(Body::from(invalid_utf8.as_slice()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let stream = futures_util::stream::iter([
|
||||
Ok::<_, std::io::Error>(Bytes::from_static(
|
||||
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||
)),
|
||||
Err(std::io::Error::other("stream failed")),
|
||||
]);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::post("/mutate")
|
||||
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
// test req: multipart/001 req: multipart/003
|
||||
}
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
@@ -107,17 +247,39 @@ impl hemx_core::FromForm for OpenProject {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_mode_detects_partial_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
|
||||
|
||||
headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap());
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
|
||||
fn page_mode_detects_only_explicit_partial_header_values() {
|
||||
for value in [
|
||||
None,
|
||||
Some("false"),
|
||||
Some("0"),
|
||||
Some("TRUE"),
|
||||
Some("invalid"),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(value) = value {
|
||||
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
|
||||
}
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
|
||||
}
|
||||
for value in ["true", "1"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
|
||||
}
|
||||
// test req: page_swap/001
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
||||
// req: test/005
|
||||
assert!(!PageRequest {
|
||||
mode: PageMode::Full
|
||||
}
|
||||
.is_partial());
|
||||
assert!(PageRequest {
|
||||
mode: PageMode::Partial
|
||||
}
|
||||
.is_partial());
|
||||
let full = PageRequest {
|
||||
mode: PageMode::Full,
|
||||
}
|
||||
@@ -209,6 +371,48 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_response_constructors_preserve_mode_and_optional_fingerprint() {
|
||||
let full = PageResponse::full("<html>Full</html>");
|
||||
assert_eq!(full.mode, PageMode::Full);
|
||||
assert_eq!(full.html, "<html>Full</html>");
|
||||
assert_eq!(full.fingerprint, None);
|
||||
|
||||
let partial = PageResponse::partial("<main>Partial</main>");
|
||||
assert_eq!(partial.mode, PageMode::Partial);
|
||||
assert_eq!(partial.html, "<main>Partial</main>");
|
||||
assert_eq!(partial.title, None);
|
||||
assert_eq!(partial.fingerprint, None);
|
||||
|
||||
let fingerprint = BuildFingerprint(42);
|
||||
assert_eq!(
|
||||
partial.fingerprint(fingerprint).fingerprint,
|
||||
Some(fingerprint)
|
||||
);
|
||||
// test req: page_swap/001 req: abi/005
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_page_response_preserves_html_and_fingerprint_without_partial_headers() {
|
||||
let response = PageResponse::full("<html>Full</html>")
|
||||
.fingerprint(BuildFingerprint(42))
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "42");
|
||||
assert!(!response.headers().contains_key(HEMX_PARTIAL_HEADER));
|
||||
assert!(!response.headers().contains_key(HEMX_TITLE_HEADER));
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
|
||||
b"<html>Full</html>"
|
||||
);
|
||||
// test req: page_swap/001 req: abi/005
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_page_response_sets_partial_and_title_headers() {
|
||||
let response = PageResponse::partial("<main>Docs</main>")
|
||||
@@ -333,6 +537,79 @@ fn state_interactions_starts_stateful_wiring_without_nested_closures() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_form_accessors_preserve_repeated_values_and_decode_diagnostics() {
|
||||
let form =
|
||||
InteractionForm::parse_urlencoded(b"__h=42&count=7&tag=alpha&tag=beta&empty=").unwrap();
|
||||
|
||||
assert_eq!(form.handle_id, 42);
|
||||
assert_eq!(form.value("tag"), Some("alpha"));
|
||||
assert_eq!(form.values("tag").collect::<Vec<_>>(), ["alpha", "beta"]);
|
||||
assert_eq!(form.parse::<u32>("count"), Some(7));
|
||||
assert_eq!(form.parse::<u32>("tag"), None);
|
||||
assert_eq!(form.parse::<u32>("missing"), None);
|
||||
assert_eq!(form.required("empty"), Ok(""));
|
||||
assert_eq!(
|
||||
form.required("missing").unwrap_err().message(),
|
||||
"missing form field `missing`"
|
||||
);
|
||||
assert_eq!(form.parse_required::<u32>("count"), Ok(7));
|
||||
assert_eq!(
|
||||
form.parse_required::<u32>("missing").unwrap_err().message(),
|
||||
"missing form field `missing`"
|
||||
);
|
||||
assert_eq!(
|
||||
form.parse_required::<u32>("tag").unwrap_err().message(),
|
||||
"invalid form field `tag`"
|
||||
);
|
||||
assert!(form.files().is_empty());
|
||||
assert!(form.file("upload").is_none());
|
||||
assert!(form
|
||||
.fields()
|
||||
.iter()
|
||||
.any(|pair| pair == &("count".into(), "7".into())));
|
||||
// test req: form/002 req: form/004 req: multipart/001
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interaction_form_rejections_return_stable_status_and_diagnostic() {
|
||||
for (rejection, status, message) in [
|
||||
(
|
||||
InteractionFormRejection::UnsupportedMediaType,
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
|
||||
),
|
||||
(
|
||||
InteractionFormRejection::BodyTooLarge,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"hemx interaction body exceeds the host limit",
|
||||
),
|
||||
(
|
||||
InteractionFormRejection::InvalidBody,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid hemx form body",
|
||||
),
|
||||
(
|
||||
InteractionFormRejection::MissingHandle,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing __h hemx handle field",
|
||||
),
|
||||
(
|
||||
InteractionFormRejection::InvalidHandle,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid __h hemx handle field",
|
||||
),
|
||||
] {
|
||||
let response = rejection.into_response();
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
|
||||
message.as_bytes()
|
||||
);
|
||||
}
|
||||
// test req: failure/003 req: multipart/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_request_dispatches_typed_form_inputs() {
|
||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||
@@ -562,6 +839,181 @@ fn interactions_dispatch_by_checked_handle() {
|
||||
assert_eq!(response.batch.ops, vec![title.text("Hello")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_contains_registered_sync_and_async_handles_and_async_falls_back_to_sync() {
|
||||
let sync = Handle::<()>::new(21);
|
||||
let asynchronous = Handle::<()>::new(22);
|
||||
let registry = interactions(BuildFingerprint(55))
|
||||
.on(sync, |_| Slot::<String>::new(1).text("sync"))
|
||||
.on_async(asynchronous, |_| async {
|
||||
Slot::<String>::new(1).text("async")
|
||||
});
|
||||
|
||||
assert!(registry.contains(sync.id().id));
|
||||
assert!(registry.contains(asynchronous.id().id));
|
||||
assert!(!registry.contains(99));
|
||||
|
||||
let sync_response = InteractionRequest::from(InteractionForm::for_handle(sync, []))
|
||||
.dispatch_async(registry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sync_response.batch.fingerprint, BuildFingerprint(55));
|
||||
assert_eq!(
|
||||
sync_response.batch.ops,
|
||||
vec![Slot::<String>::new(1).text("sync")]
|
||||
);
|
||||
// test req: axum_integration/003 req: public_api/001
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_result_registration_paths_preserve_state_form_and_failures() {
|
||||
let sync = Handle::<()>::new(30);
|
||||
let typed = Handle::<()>::new(31);
|
||||
let asynchronous = Handle::<()>::new(32);
|
||||
let typed_async = Handle::<()>::new(33);
|
||||
let typed_plain = Handle::<()>::new(34);
|
||||
let typed_state_plain = Handle::<()>::new(37);
|
||||
let typed_async_plain = Handle::<()>::new(35);
|
||||
let state_async_plain = Handle::<()>::new(36);
|
||||
let registry = interactions(BuildFingerprint(88))
|
||||
.on_state_result(sync, String::from("sync"), |state: State<String>| {
|
||||
Ok::<_, HandlerBoom>(Slot::<String>::new(1).text(state.0))
|
||||
})
|
||||
.on_state_form_result(
|
||||
typed,
|
||||
String::from("typed"),
|
||||
|state: State<String>, Form(form): Form<OpenProject>| {
|
||||
Err::<Effect, _>(HandlerBoom).map_err(|error| {
|
||||
let _ = (state, form);
|
||||
error
|
||||
})
|
||||
},
|
||||
)
|
||||
.on_state_async_result(
|
||||
asynchronous,
|
||||
String::from("async"),
|
||||
|state: State<String>| async move {
|
||||
Ok::<_, HandlerBoom>(Slot::<String>::new(1).text(state.0))
|
||||
},
|
||||
)
|
||||
.on_form(typed_plain, |Form(form): Form<OpenProject>| {
|
||||
Slot::<String>::new(1).text(format!("typed:{}", form.project_id.0))
|
||||
})
|
||||
.on_state_form(
|
||||
typed_state_plain,
|
||||
String::from("typed-state"),
|
||||
|state: State<String>, Form(form): Form<OpenProject>| {
|
||||
Slot::<String>::new(1).text(format!("{}:{}", state.0, form.project_id.0))
|
||||
},
|
||||
)
|
||||
.on_form_async(
|
||||
typed_async_plain,
|
||||
|Form(form): Form<OpenProject>| async move {
|
||||
Slot::<String>::new(1).text(format!("typed-plain-async:{}", form.project_id.0))
|
||||
},
|
||||
)
|
||||
.on_state_async(
|
||||
state_async_plain,
|
||||
String::from("state-async"),
|
||||
|state: State<String>| async move { Slot::<String>::new(1).text(state.0) },
|
||||
)
|
||||
.on_state_form_async_result(
|
||||
typed_async,
|
||||
String::from("typed-async"),
|
||||
|state: State<String>, Form(form): Form<OpenProject>| async move {
|
||||
Ok::<_, HandlerBoom>(
|
||||
Slot::<String>::new(1).text(format!("{}:{}", state.0, form.project_id.0)),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
for handle in [
|
||||
sync,
|
||||
typed,
|
||||
asynchronous,
|
||||
typed_async,
|
||||
typed_plain,
|
||||
typed_state_plain,
|
||||
typed_async_plain,
|
||||
state_async_plain,
|
||||
] {
|
||||
assert!(registry.contains(handle.id().id));
|
||||
}
|
||||
assert!(registry
|
||||
.dispatch(InteractionForm::for_handle(sync, []))
|
||||
.is_ok());
|
||||
let invalid_form = registry
|
||||
.dispatch(InteractionForm::for_handle(
|
||||
typed,
|
||||
[(String::from("project_id"), String::from("invalid"))],
|
||||
))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
invalid_form,
|
||||
DispatchRejection::InvalidForm {
|
||||
handle_id: typed.id().id,
|
||||
message: "invalid form field `project_id`".into(),
|
||||
}
|
||||
);
|
||||
let error = registry
|
||||
.dispatch(InteractionForm::for_handle(
|
||||
typed,
|
||||
[(String::from("project_id"), String::from("7"))],
|
||||
))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
DispatchRejection::HandlerError(HandlerFailure::internal("database unavailable"))
|
||||
);
|
||||
assert!(registry
|
||||
.dispatch_async(InteractionForm::for_handle(asynchronous, []))
|
||||
.await
|
||||
.is_ok());
|
||||
let invalid_plain = registry
|
||||
.dispatch(InteractionForm::for_handle(
|
||||
typed_plain,
|
||||
[(String::from("project_id"), String::from("invalid"))],
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
invalid_plain,
|
||||
DispatchRejection::InvalidForm { .. }
|
||||
));
|
||||
let invalid_state_plain = registry
|
||||
.dispatch(InteractionForm::for_handle(
|
||||
typed_state_plain,
|
||||
[(String::from("project_id"), String::from("invalid"))],
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
invalid_state_plain,
|
||||
DispatchRejection::InvalidForm { .. }
|
||||
));
|
||||
assert!(registry
|
||||
.dispatch_async(InteractionForm::for_handle(
|
||||
typed_async_plain,
|
||||
[(String::from("project_id"), String::from("8"))],
|
||||
))
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(registry
|
||||
.dispatch_async(InteractionForm::for_handle(state_async_plain, []))
|
||||
.await
|
||||
.is_ok());
|
||||
let response = registry
|
||||
.dispatch_async(InteractionForm::for_handle(
|
||||
typed_async,
|
||||
[(String::from("project_id"), String::from("9"))],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.batch.ops,
|
||||
vec![Slot::<String>::new(1).text("typed-async:9")]
|
||||
);
|
||||
// test req: axum_integration/003 req: derive_handler/005
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_reject_unknown_handle_ids() {
|
||||
let request = InteractionRequest::from(InteractionForm::new(9, []));
|
||||
@@ -574,8 +1026,59 @@ fn interactions_reject_unknown_handle_ids() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_js_response_serves_embedded_runtime() {
|
||||
#[tokio::test]
|
||||
async fn effect_and_dispatch_responses_preserve_status_wire_and_diagnostics() {
|
||||
let batch = Slot::<String>::new(3)
|
||||
.text("saved")
|
||||
.into_batch(BuildFingerprint(77));
|
||||
let response = EffectResponse {
|
||||
batch: batch.clone(),
|
||||
}
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
|
||||
assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "77");
|
||||
let body = to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
assert_eq!(hemx_core::EffectBatch::from_wire(&body), Ok(batch.clone()));
|
||||
|
||||
for (rejection, status, message) in [
|
||||
(
|
||||
DispatchRejection::UnknownHandle(9),
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown hemx handle id 9",
|
||||
),
|
||||
(
|
||||
DispatchRejection::InvalidForm {
|
||||
handle_id: 9,
|
||||
message: "missing title".into(),
|
||||
},
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid hemx form for handle id 9: missing title",
|
||||
),
|
||||
(
|
||||
DispatchRejection::HandlerError(HandlerFailure::Response {
|
||||
status: StatusCode::CONFLICT,
|
||||
message: "stale".into(),
|
||||
}),
|
||||
StatusCode::CONFLICT,
|
||||
"stale",
|
||||
),
|
||||
] {
|
||||
let response = rejection.into_response();
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 4096).await.unwrap().as_ref(),
|
||||
message.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
let response = DispatchRejection::HandlerError(HandlerFailure::Effects(batch)).into_response();
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
|
||||
// test req: axum_integration/003 req: failure/005 req: wire/007
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_js_response_serves_embedded_runtime() {
|
||||
let response = runtime_js().into_response();
|
||||
|
||||
assert_eq!(
|
||||
@@ -594,6 +1097,13 @@ fn runtime_js_response_serves_embedded_runtime() {
|
||||
response.headers()[header::CONTENT_LENGTH],
|
||||
runtime_js_source().len().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), runtime_js_source().len() + 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
runtime_js_source().as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
// req: axum_integration/005
|
||||
|
||||
+1702
-306
File diff suppressed because it is too large
Load Diff
+11
-15
@@ -451,7 +451,7 @@ pub enum Effect {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EffectBatch {
|
||||
pub abi_version: u32,
|
||||
pub fingerprint: BuildFingerprint,
|
||||
@@ -459,6 +459,13 @@ pub struct EffectBatch {
|
||||
}
|
||||
|
||||
impl EffectBatch {
|
||||
/// The versioned hemx codec is the sole public `EffectBatch` wire API.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1));
|
||||
/// let _ = batch.to_postcard();
|
||||
/// ```
|
||||
/// req: wire/010 test
|
||||
/// Return the exact number of bytes produced by [`Self::to_wire`].
|
||||
pub fn encoded_len(&self) -> usize {
|
||||
batch_wire_len(self)
|
||||
@@ -478,14 +485,6 @@ impl EffectBatch {
|
||||
read_batch(bytes)
|
||||
}
|
||||
|
||||
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||
postcard::to_allocvec(self)
|
||||
}
|
||||
|
||||
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||
postcard::from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub const fn is_compatible(&self) -> bool {
|
||||
self.abi_version == EFFECT_BATCH_ABI_VERSION
|
||||
}
|
||||
@@ -770,12 +769,9 @@ impl<'a> WireReader<'a> {
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
|
||||
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?;
|
||||
if end > self.bytes.len() {
|
||||
return Err(WireError::Truncated);
|
||||
}
|
||||
let bytes = &self.bytes[self.offset..end];
|
||||
self.offset = end;
|
||||
let remaining = &self.bytes[self.offset..];
|
||||
let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
|
||||
self.offset += len;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
+282
-21
@@ -1,8 +1,8 @@
|
||||
use hemx_core::{
|
||||
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
|
||||
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
|
||||
NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey,
|
||||
ScrollBehavior, Slot,
|
||||
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
|
||||
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
|
||||
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -31,47 +31,92 @@ fn effect_batch_wire_round_trips() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_len_covers_every_effect_shape() {
|
||||
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1));
|
||||
let scoped = ResourceRef::scoped(
|
||||
ResourceId::new(ResourceKind::Form, 2),
|
||||
fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
|
||||
// test req: abi/001 req: abi/003 req: v1_release/007
|
||||
const V1_EMPTY_BATCH: &[u8] = &[
|
||||
72, 69, 77, 88, // HEMX
|
||||
1, 0, // ABI v1
|
||||
0, 0, // reserved
|
||||
7, 0, 0, 0, 0, 0, 0, 0, // fingerprint
|
||||
0, 0, 0, 0, // zero operations
|
||||
];
|
||||
assert_eq!(
|
||||
EffectBatch::from_wire(V1_EMPTY_BATCH).unwrap().to_wire(),
|
||||
V1_EMPTY_BATCH
|
||||
);
|
||||
|
||||
let mut future = V1_EMPTY_BATCH.to_vec();
|
||||
future[4] = 2;
|
||||
let future = EffectBatch::from_wire(&future).unwrap();
|
||||
assert_eq!(future.abi_version, 2);
|
||||
assert!(!future.is_compatible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
|
||||
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
|
||||
let atom = ResourceRef::scoped(
|
||||
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
|
||||
ScopeKey::KeyValue(String::from("row")),
|
||||
);
|
||||
let handle = ResourceRef::scoped(
|
||||
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
|
||||
ScopeKey::Field(String::from("email")),
|
||||
);
|
||||
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
|
||||
let batch = EffectBatch {
|
||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint: BuildFingerprint(42),
|
||||
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
|
||||
ops: vec![
|
||||
Effect::Put {
|
||||
target: unscoped.clone(),
|
||||
target: slot.clone(),
|
||||
payload: Payload::Text(String::from("text")),
|
||||
},
|
||||
Effect::Put {
|
||||
target: handle.clone(),
|
||||
payload: Payload::Html(String::from("<p>safe</p>")),
|
||||
},
|
||||
Effect::Insert {
|
||||
target: scoped.clone(),
|
||||
target: atom.clone(),
|
||||
key: String::from("insert"),
|
||||
payload: Payload::Text(String::from("one")),
|
||||
},
|
||||
Effect::Prepend {
|
||||
target: scoped.clone(),
|
||||
target: form.clone(),
|
||||
key: String::from("prepend"),
|
||||
payload: Payload::Text(String::from("two")),
|
||||
payload: Payload::Html(String::from("two")),
|
||||
},
|
||||
Effect::Remove {
|
||||
target: scoped.clone(),
|
||||
target: slot.clone(),
|
||||
key: None,
|
||||
},
|
||||
Effect::Remove {
|
||||
target: atom,
|
||||
key: Some(String::from("remove")),
|
||||
},
|
||||
Effect::Move {
|
||||
target: scoped.clone(),
|
||||
target: handle.clone(),
|
||||
key: String::from("move"),
|
||||
before: Some(String::from("before")),
|
||||
},
|
||||
Effect::Focus {
|
||||
target: scoped.clone(),
|
||||
Effect::Focus { target: form },
|
||||
Effect::Navigate {
|
||||
url: String::from("/push"),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: ScrollBehavior::Preserve,
|
||||
title: None,
|
||||
},
|
||||
Effect::Navigate {
|
||||
url: String::from("/next"),
|
||||
url: String::from("/replace"),
|
||||
mode: NavigateMode::Replace,
|
||||
scroll: ScrollBehavior::Element(unscoped),
|
||||
title: Some(String::from("Next")),
|
||||
scroll: ScrollBehavior::Top,
|
||||
title: Some(String::from("Replace")),
|
||||
},
|
||||
Effect::Navigate {
|
||||
url: String::from("/redirect"),
|
||||
mode: NavigateMode::Redirect,
|
||||
scroll: ScrollBehavior::Element(handle),
|
||||
title: Some(String::from("Redirect")),
|
||||
},
|
||||
Effect::Emit {
|
||||
name: String::from("notice"),
|
||||
@@ -81,8 +126,90 @@ fn encoded_len_covers_every_effect_shape() {
|
||||
};
|
||||
|
||||
let bytes = batch.to_wire();
|
||||
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007
|
||||
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch);
|
||||
assert_eq!(&bytes[..4], b"HEMX");
|
||||
assert_eq!(batch.encoded_len(), bytes.len());
|
||||
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
|
||||
for end in 0..bytes.len() {
|
||||
assert_eq!(
|
||||
EffectBatch::from_wire(&bytes[..end]),
|
||||
Err(WireError::Truncated),
|
||||
"prefix ending at byte {end} must fail closed"
|
||||
);
|
||||
}
|
||||
// req: wire/007 test req: wire/008 test req: wire/009 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
|
||||
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4;
|
||||
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN;
|
||||
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1;
|
||||
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4;
|
||||
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1;
|
||||
|
||||
let put = EffectBatch {
|
||||
abi_version: 1,
|
||||
fingerprint: BuildFingerprint(1),
|
||||
ops: vec![Effect::Put {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
|
||||
payload: Payload::Text(String::from("value")),
|
||||
}],
|
||||
}
|
||||
.to_wire();
|
||||
for offset in [
|
||||
PUT_EFFECT_TAG,
|
||||
PUT_RESOURCE_KIND_TAG,
|
||||
PUT_SCOPE_TAG,
|
||||
PUT_PAYLOAD_TAG,
|
||||
] {
|
||||
let mut corrupt = put.clone();
|
||||
corrupt[offset] = 0xff;
|
||||
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
|
||||
}
|
||||
|
||||
const NAVIGATE_MODE_TAG: usize = BATCH_HEADER_LEN + 1 + 4;
|
||||
const NAVIGATE_SCROLL_TAG: usize = NAVIGATE_MODE_TAG + 1;
|
||||
const NAVIGATE_TITLE_OPTION_TAG: usize = NAVIGATE_SCROLL_TAG + 1;
|
||||
let navigate = EffectBatch {
|
||||
abi_version: 1,
|
||||
fingerprint: BuildFingerprint(1),
|
||||
ops: vec![Effect::Navigate {
|
||||
url: String::new(),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: ScrollBehavior::Preserve,
|
||||
title: None,
|
||||
}],
|
||||
}
|
||||
.to_wire();
|
||||
for offset in [
|
||||
NAVIGATE_MODE_TAG,
|
||||
NAVIGATE_SCROLL_TAG,
|
||||
NAVIGATE_TITLE_OPTION_TAG,
|
||||
] {
|
||||
let mut corrupt = navigate.clone();
|
||||
corrupt[offset] = 0xff;
|
||||
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
|
||||
}
|
||||
|
||||
let mut bad_magic = put.clone();
|
||||
bad_magic[0] = b'X';
|
||||
assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
|
||||
|
||||
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4;
|
||||
let mut invalid_utf8 = put.clone();
|
||||
invalid_utf8[PUT_TEXT_START] = 0xff;
|
||||
assert_eq!(
|
||||
EffectBatch::from_wire(&invalid_utf8),
|
||||
Err(WireError::InvalidUtf8)
|
||||
);
|
||||
|
||||
let mut trailing = put;
|
||||
trailing.push(0);
|
||||
assert_eq!(
|
||||
EffectBatch::from_wire(&trailing),
|
||||
Err(WireError::TrailingBytes)
|
||||
);
|
||||
// req: wire/008 test req: wire/009 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -174,6 +301,93 @@ fn generated_form_helpers_target_form_fields() {
|
||||
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
|
||||
let rows = KeyedSlot::<u64, String>::new(9);
|
||||
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9));
|
||||
assert_eq!(
|
||||
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")),
|
||||
Effect::Put {
|
||||
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())),
|
||||
payload: Payload::Html("<li>done</li>".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
rows.remove(12),
|
||||
Effect::Remove {
|
||||
target: expected.clone(),
|
||||
key: Some("12".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
rows.move_before(12, 13),
|
||||
Effect::Move {
|
||||
target: expected.clone(),
|
||||
key: "12".into(),
|
||||
before: Some("13".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
rows.move_to_end(12),
|
||||
Effect::Move {
|
||||
target: expected,
|
||||
key: "12".into(),
|
||||
before: None,
|
||||
}
|
||||
);
|
||||
|
||||
let form = Form::<()>::new(4);
|
||||
assert_eq!(
|
||||
form.clear_field("email"),
|
||||
Effect::Put {
|
||||
target: ResourceRef::scoped(
|
||||
ResourceId::new(ResourceKind::Form, 4),
|
||||
ScopeKey::Field("email".into()),
|
||||
),
|
||||
payload: Payload::Text(String::new()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
form.disable_while_pending(),
|
||||
Effect::Emit {
|
||||
name: "hemx:form-disable-while-pending".into(),
|
||||
payload: "4".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(form.clear(), form.reset());
|
||||
|
||||
for (effect, expected_mode) in [
|
||||
(navigate("/push"), NavigateMode::Push),
|
||||
(hemx_core::push("/push"), NavigateMode::Push),
|
||||
(replace("/replace"), NavigateMode::Replace),
|
||||
(redirect("/redirect"), NavigateMode::Redirect),
|
||||
] {
|
||||
let Effect::Navigate {
|
||||
mode,
|
||||
scroll,
|
||||
title,
|
||||
..
|
||||
} = effect
|
||||
else {
|
||||
panic!("navigation helper must return Navigate");
|
||||
};
|
||||
assert_eq!(mode, expected_mode);
|
||||
assert_eq!(scroll, ScrollBehavior::Top);
|
||||
assert_eq!(title, None);
|
||||
}
|
||||
// test req: list/003 req: form_effects/001 req: nav/001
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_class_accumulation_preserves_existing_classes() {
|
||||
const A: CssClass = CssClass::new("a");
|
||||
const B: CssClass = CssClass::new("b");
|
||||
const C: CssClass = CssClass::new("c");
|
||||
assert_eq!(CssClasses::new([]).with(A).as_str(), "a");
|
||||
assert_eq!(CssClasses::from(A).with(B).with(C).as_str(), "a b c");
|
||||
// test req: style/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_html_requires_explicit_safe_html() {
|
||||
let content = Slot::<String>::new(10);
|
||||
@@ -194,6 +408,8 @@ fn safe_html_joins_only_explicit_safe_fragments() {
|
||||
]);
|
||||
|
||||
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>");
|
||||
assert_eq!(html.as_ref(), "<main><strong>ok</strong></main>");
|
||||
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,6 +417,7 @@ fn param_names_format_generated_param_names() {
|
||||
// req: codegen/003
|
||||
let param = ParamName::new("todo_id");
|
||||
assert_eq!(param.as_str(), "todo_id");
|
||||
assert_eq!(param.as_ref(), "todo_id");
|
||||
assert_eq!(param.to_string(), "todo_id");
|
||||
}
|
||||
|
||||
@@ -209,6 +426,7 @@ fn component_refs_format_generated_component_names() {
|
||||
// req: component/003
|
||||
let component = ComponentRef::new("todo_list");
|
||||
assert_eq!(component.as_str(), "todo_list");
|
||||
assert_eq!(component.as_ref(), "todo_list");
|
||||
assert_eq!(component.to_string(), "todo_list");
|
||||
}
|
||||
|
||||
@@ -279,4 +497,47 @@ fn build_fingerprint_is_deterministic_from_abi_parts() {
|
||||
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(
|
||||
BuildFingerprint::from_parts(&[]),
|
||||
BuildFingerprint(0xcbf29ce484222325)
|
||||
);
|
||||
assert_eq!(a, BuildFingerprint(13725386680924731485));
|
||||
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
|
||||
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
|
||||
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
|
||||
// test req: abi/001 req: abi/002 req: abi/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_token_and_form_error_adapters_preserve_values() {
|
||||
const NOTICE: EventName = EventName::new("notice");
|
||||
assert_eq!(NOTICE.as_str(), "notice");
|
||||
assert_eq!(NOTICE.as_ref(), "notice");
|
||||
assert_eq!(NOTICE.to_string(), "notice");
|
||||
assert_eq!(String::from(NOTICE), "notice");
|
||||
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
|
||||
|
||||
const ACTIVE: CssClass = CssClass::new("active");
|
||||
assert_eq!(ACTIVE.as_str(), "active");
|
||||
assert_eq!(ACTIVE.as_ref(), "active");
|
||||
assert_eq!(ACTIVE.to_string(), "active");
|
||||
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
|
||||
assert_eq!(classes.as_ref(), "active selected");
|
||||
|
||||
let error = FormError::new("invalid email");
|
||||
assert_eq!(error.message(), "invalid email");
|
||||
assert_eq!(error.to_string(), "invalid email");
|
||||
assert_eq!(u32::parse_form_value("42"), Ok(42));
|
||||
assert_eq!(
|
||||
u32::parse_form_value("nope"),
|
||||
Err("invalid form value".into())
|
||||
);
|
||||
assert_eq!(
|
||||
Form::<()>::new(7).reset(),
|
||||
Effect::Emit {
|
||||
name: "hemx:form-reset".into(),
|
||||
payload: "7".into(),
|
||||
}
|
||||
);
|
||||
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
|
||||
}
|
||||
|
||||
+620
-95
@@ -12,37 +12,23 @@ use syn::{
|
||||
#[proc_macro_attribute]
|
||||
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let placement = attr.to_string();
|
||||
let is_client = match placement.as_str() {
|
||||
"" => false,
|
||||
"client" => true,
|
||||
_ => {
|
||||
return syn::Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
|
||||
)
|
||||
.into_compile_error()
|
||||
.into();
|
||||
}
|
||||
let placement = match handler_placement(&placement) {
|
||||
Ok(placement) => placement,
|
||||
Err(error) => return error.into_compile_error().into(),
|
||||
};
|
||||
let function = parse_macro_input!(item as ItemFn);
|
||||
let name = function.sig.ident.to_string();
|
||||
|
||||
let Some(syms_path) = syms_path() else {
|
||||
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
let syms_path = match handler_syms_path(syms_path()) {
|
||||
Ok(path) => path,
|
||||
Err(message) => {
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
}
|
||||
};
|
||||
if !syms_path.exists() {
|
||||
let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation";
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
}
|
||||
if !syms_contains_handle(&syms_path, &name) {
|
||||
let message = format!(
|
||||
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler"
|
||||
@@ -86,33 +72,34 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
.into();
|
||||
}
|
||||
|
||||
if !is_client {
|
||||
return quote!(#function).into();
|
||||
}
|
||||
let input_count = function.sig.inputs.len();
|
||||
if !matches!(input_count, 0 | 2)
|
||||
|| function.sig.asyncness.is_some()
|
||||
|| function.sig.unsafety.is_some()
|
||||
|| function.sig.constness.is_some()
|
||||
|| !function.sig.generics.params.is_empty()
|
||||
{
|
||||
let message = format!(
|
||||
"client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
|
||||
);
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
expand_handler_function(function, placement).into()
|
||||
}
|
||||
|
||||
fn expand_handler_function(
|
||||
function: ItemFn,
|
||||
placement: HandlerPlacement,
|
||||
) -> proc_macro2::TokenStream {
|
||||
if placement == HandlerPlacement::Server {
|
||||
return quote!(#function);
|
||||
}
|
||||
let has_inputs = match client_handler_has_inputs(&function) {
|
||||
Ok(has_inputs) => has_inputs,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let function_name = &function.sig.ident;
|
||||
let export_name = format_ident!("__hemx_client_{function_name}");
|
||||
let export_module = format_ident!("__hemx_client_export_{function_name}");
|
||||
let invoke_handler = if input_count == 0 {
|
||||
quote!(super::#function_name())
|
||||
} else {
|
||||
let invoke_handler = if has_inputs {
|
||||
quote!(super::#function_name(event, state))
|
||||
} else {
|
||||
quote!(super::#function_name())
|
||||
};
|
||||
quote!(
|
||||
#function
|
||||
@@ -149,7 +136,6 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
}
|
||||
}
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
@@ -162,7 +148,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let form_name = parse_macro_input!(attr as LitStr).value();
|
||||
let form_struct = parse_macro_input!(item as ItemStruct);
|
||||
let Some(syms_path) = syms_path() else {
|
||||
let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
|
||||
let message = missing_form_generated_files_message();
|
||||
return quote!(
|
||||
#form_struct
|
||||
compile_error!(#message);
|
||||
@@ -174,13 +160,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let ident = &form_struct.ident;
|
||||
let resource_id =
|
||||
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
|
||||
let mut generics = form_struct.generics.clone();
|
||||
for ty in form_parser_types(&form_struct) {
|
||||
generics
|
||||
.make_where_clause()
|
||||
.predicates
|
||||
.push(parse_quote!(#ty: ::hemx::FormValue));
|
||||
}
|
||||
let generics = form_impl_generics(&form_struct);
|
||||
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
quote!(
|
||||
@@ -201,7 +181,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
let message = errors.join("; ");
|
||||
let message = join_contract_errors(&errors);
|
||||
quote!(
|
||||
#form_struct
|
||||
compile_error!(#message);
|
||||
@@ -228,9 +208,6 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let Some(syms_path) = syms_path() else {
|
||||
return quote!(#module).into();
|
||||
};
|
||||
if !syms_path.exists() {
|
||||
return quote!(#module).into();
|
||||
}
|
||||
let component_filter = component_name.as_deref();
|
||||
let errors = component_contract_errors(&syms_path, component_filter, items);
|
||||
if !errors.is_empty() {
|
||||
@@ -312,22 +289,13 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
||||
let Type::Path(path) = ty else {
|
||||
return None;
|
||||
};
|
||||
let mut segments = path.path.segments.iter();
|
||||
let first = segments.next()?;
|
||||
let last = path
|
||||
.path
|
||||
.segments
|
||||
.last()
|
||||
.expect("path has at least one segment");
|
||||
let path_is_form = if path.path.segments.len() == 1 {
|
||||
first.ident == "Form"
|
||||
} else {
|
||||
first.ident == "hemx" && last.ident == "Form"
|
||||
let segments = path.path.segments.iter().collect::<Vec<_>>();
|
||||
let form = match segments.as_slice() {
|
||||
[form] if form.ident == "Form" => form,
|
||||
[hemx, .., form] if hemx.ident == "hemx" && form.ident == "Form" => form,
|
||||
_ => return None,
|
||||
};
|
||||
if !path_is_form {
|
||||
return None;
|
||||
}
|
||||
let PathArguments::AngleBracketed(args) = &last.arguments else {
|
||||
let PathArguments::AngleBracketed(args) = &form.arguments else {
|
||||
return None;
|
||||
};
|
||||
args.args.iter().find_map(|arg| match arg {
|
||||
@@ -336,6 +304,76 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_handler_generated_files_message() -> &'static str {
|
||||
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
}
|
||||
|
||||
fn missing_form_generated_files_message() -> &'static str {
|
||||
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
}
|
||||
|
||||
fn handler_syms_path(syms_path: Option<PathBuf>) -> Result<PathBuf, &'static str> {
|
||||
match syms_path {
|
||||
None => Err(missing_handler_generated_files_message()),
|
||||
Some(path) if !path.exists() => Err(
|
||||
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation",
|
||||
),
|
||||
Some(path) => Ok(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_contract_errors(errors: &[String]) -> String {
|
||||
errors.join("; ")
|
||||
}
|
||||
|
||||
fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
|
||||
let mut generics = form_struct.generics.clone();
|
||||
for ty in form_parser_types(form_struct) {
|
||||
generics
|
||||
.make_where_clause()
|
||||
.predicates
|
||||
.push(parse_quote!(#ty: ::hemx::FormValue));
|
||||
}
|
||||
generics
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HandlerPlacement {
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
fn handler_placement(placement: &str) -> syn::Result<HandlerPlacement> {
|
||||
match placement {
|
||||
"" => Ok(HandlerPlacement::Server),
|
||||
"client" => Ok(HandlerPlacement::Client),
|
||||
_ => Err(syn::Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_handler_has_inputs(function: &ItemFn) -> syn::Result<bool> {
|
||||
let input_count = function.sig.inputs.len();
|
||||
if matches!(input_count, 0 | 2)
|
||||
&& function.sig.asyncness.is_none()
|
||||
&& function.sig.unsafety.is_none()
|
||||
&& function.sig.constness.is_none()
|
||||
&& function.sig.generics.params.is_empty()
|
||||
{
|
||||
Ok(input_count == 2)
|
||||
} else {
|
||||
Err(syn::Error::new_spanned(
|
||||
&function.sig,
|
||||
format!(
|
||||
"client-local hemx handler `{}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`",
|
||||
function.sig.ident
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
|
||||
function.sig.inputs.iter().rev().find_map(|arg| match arg {
|
||||
FnArg::Typed(arg) => form_model_type(&arg.ty),
|
||||
@@ -422,7 +460,7 @@ fn form_contract_errors(
|
||||
field
|
||||
.ident
|
||||
.as_ref()
|
||||
.map(|ident| (ident.to_string(), &field.ty))
|
||||
.map(|ident| (form_field_name(ident), &field.ty))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut errors = Vec::new();
|
||||
@@ -469,6 +507,10 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn form_field_name(ident: &syn::Ident) -> String {
|
||||
ident.to_string().trim_start_matches("r#").to_owned()
|
||||
}
|
||||
|
||||
fn form_decode_fields(
|
||||
syms_path: &PathBuf,
|
||||
form_name: &str,
|
||||
@@ -486,7 +528,9 @@ fn form_decode_fields(
|
||||
form_fields(syms_path, form_name)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let (ident, ty) = actual.iter().find(|(ident, _)| ident == &&field.ident)?;
|
||||
let (ident, ty) = actual
|
||||
.iter()
|
||||
.find(|(ident, _)| form_field_name(ident) == field.ident)?;
|
||||
let control_name = field.name;
|
||||
let parser = parser_type(ty);
|
||||
Some(if field.multiple {
|
||||
@@ -626,12 +670,8 @@ fn rust_ident(name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec<String> {
|
||||
let required = handle_params(path, ident);
|
||||
if required.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let args = handler_arg_names(function);
|
||||
required
|
||||
handle_params(path, ident)
|
||||
.into_iter()
|
||||
.filter(|param| !args.contains(param))
|
||||
.collect()
|
||||
@@ -725,9 +765,6 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
|
||||
let Some(state_ty) = component_state_type(items) else {
|
||||
return module;
|
||||
};
|
||||
if handlers.is_empty() {
|
||||
return module;
|
||||
}
|
||||
let calls = handlers
|
||||
.iter()
|
||||
.map(|handler| component_registration_call(handler, &component_ident));
|
||||
@@ -970,27 +1007,344 @@ fn symbol_component(symbol: &str) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn compile_error(message: &str) -> TokenStream {
|
||||
format!("compile_error!({message:?});")
|
||||
.parse()
|
||||
.expect("compile_error expansion is valid")
|
||||
quote!(compile_error!(#message);).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_app_registry_helper, add_component_register_helper, component_handler_names,
|
||||
form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return,
|
||||
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle,
|
||||
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
|
||||
component_contract_errors, component_handler_names, component_registration_call,
|
||||
expand_handler_function, form_contract_errors, form_decode_fields, form_fields,
|
||||
form_impl_generics, form_model_type, form_resource_id, generic_inner_type, handle_params,
|
||||
handle_requires_form, handler_arg_names, handler_form_model_type, handler_placement,
|
||||
handler_syms_path, has_form_param, has_non_unit_return, is_type_named,
|
||||
join_contract_errors, missing_component_handlers, missing_form_generated_files_message,
|
||||
missing_handle_params, missing_handler_generated_files_message, parser_type,
|
||||
returns_result, rust_ident, symbol_component, syms_components, syms_contains_handle,
|
||||
syms_handles, ComponentHandler, HandlerPlacement,
|
||||
};
|
||||
use quote::quote;
|
||||
use quote::{quote, ToTokens};
|
||||
use syn::{parse_quote, ItemFn, Type};
|
||||
|
||||
#[test]
|
||||
fn handler_attribute_parses_server_and_client_modes_exactly() {
|
||||
assert_eq!(handler_placement("").unwrap(), HandlerPlacement::Server);
|
||||
assert_eq!(
|
||||
handler_placement("client").unwrap(),
|
||||
HandlerPlacement::Client
|
||||
);
|
||||
for invalid in ["server", " client", "client ", "CLIENT"] {
|
||||
assert_eq!(
|
||||
handler_placement(invalid).unwrap_err().to_string(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]"
|
||||
);
|
||||
}
|
||||
|
||||
let no_inputs: ItemFn = parse_quote!(
|
||||
fn save() {}
|
||||
);
|
||||
let two_inputs: ItemFn = parse_quote!(
|
||||
fn save(event: Event, state: State) {}
|
||||
);
|
||||
assert!(!client_handler_has_inputs(&no_inputs).unwrap());
|
||||
assert!(client_handler_has_inputs(&two_inputs).unwrap());
|
||||
|
||||
let server =
|
||||
expand_handler_function(no_inputs.clone(), HandlerPlacement::Server).to_string();
|
||||
assert_eq!(
|
||||
server,
|
||||
quote!(
|
||||
fn save() {}
|
||||
)
|
||||
.to_string()
|
||||
);
|
||||
let no_input_client =
|
||||
expand_handler_function(no_inputs.clone(), HandlerPlacement::Client).to_string();
|
||||
assert!(no_input_client.contains("super :: save ()"));
|
||||
assert!(!no_input_client.contains("super :: save (event , state)"));
|
||||
let input_client =
|
||||
expand_handler_function(two_inputs.clone(), HandlerPlacement::Client).to_string();
|
||||
assert!(input_client.contains("super :: save (event , state)"));
|
||||
assert!(!input_client.contains("super :: save ()"));
|
||||
|
||||
for invalid in [
|
||||
parse_quote!(
|
||||
fn save(event: Event) {}
|
||||
),
|
||||
parse_quote!(
|
||||
fn save(a: A, b: B, c: C) {}
|
||||
),
|
||||
parse_quote!(
|
||||
async fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
unsafe fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
const fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
fn save<T>() {}
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
client_handler_has_inputs(&invalid).unwrap_err().to_string(),
|
||||
"client-local hemx handler `save` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
|
||||
);
|
||||
let expanded = expand_handler_function(invalid, HandlerPlacement::Client).to_string();
|
||||
assert!(expanded.contains("compile_error !"));
|
||||
assert!(expanded.contains("client-local hemx handler"));
|
||||
}
|
||||
// test req: derive_handler/001 req: client_local/001
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_file_and_form_helpers_preserve_exact_contracts() {
|
||||
assert_eq!(
|
||||
missing_handler_generated_files_message(),
|
||||
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
);
|
||||
assert_eq!(
|
||||
missing_form_generated_files_message(),
|
||||
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
);
|
||||
assert_eq!(
|
||||
handler_syms_path(None).unwrap_err(),
|
||||
missing_handler_generated_files_message()
|
||||
);
|
||||
let missing = std::env::temp_dir().join("hemx-derive-missing-symbols");
|
||||
assert_eq!(
|
||||
handler_syms_path(Some(missing)).unwrap_err(),
|
||||
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"
|
||||
);
|
||||
let existing = std::env::current_exe().unwrap();
|
||||
assert_eq!(handler_syms_path(Some(existing.clone())).unwrap(), existing);
|
||||
assert_eq!(
|
||||
join_contract_errors(&["first".into(), "second".into()]),
|
||||
"first; second"
|
||||
);
|
||||
|
||||
let form: syn::ItemStruct = parse_quote!(
|
||||
struct Profile<T> {
|
||||
name: String,
|
||||
tags: Vec<T>,
|
||||
}
|
||||
);
|
||||
let generics = form_impl_generics(&form);
|
||||
let where_clause = generics
|
||||
.where_clause
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_token_stream()
|
||||
.to_string();
|
||||
assert!(where_clause.contains("String : :: hemx :: FormValue"));
|
||||
assert!(where_clause.contains("T : :: hemx :: FormValue"));
|
||||
// test req: derive_handler/001 req: form/004
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_form_symbol_lookup_is_exact_and_fail_closed() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("hemx-derive-form-symbols-{}", std::process::id()));
|
||||
std::fs::write(
|
||||
&path,
|
||||
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform\nform\tbroken\nform\tmissing-id\tmissing-id\nform\tother.heml::other\tother\tbad\nform_field\tprofile\tname\ttrue\tfalse\nform_field\nform_field\tprofile\nform_field\tprofile\tbad name\tfalse\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\tbad-name\tfalse\tfalse\nform_field\tother\tignored\tfalse\tfalse\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(form_resource_id(&path, "profile"), Some(42));
|
||||
assert_eq!(form_resource_id(&path, "missing"), None);
|
||||
assert_eq!(form_resource_id(&path, "broken"), None);
|
||||
assert_eq!(form_resource_id(&path, "missing-id"), None);
|
||||
assert_eq!(form_resource_id(&path, "other"), None);
|
||||
|
||||
let fields = form_fields(&path, "profile");
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(fields[0].ident, "name");
|
||||
assert!(fields[0].required);
|
||||
assert!(!fields[0].multiple);
|
||||
assert_eq!(fields[1].ident, "tags");
|
||||
assert!(!fields[1].required);
|
||||
assert!(fields[1].multiple);
|
||||
assert_eq!(fields[2].ident, "bad_name");
|
||||
assert!(form_fields(&path, "missing").is_empty());
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(form_resource_id(&path, "profile"), None);
|
||||
assert!(form_fields(&path, "profile").is_empty());
|
||||
|
||||
let string: Type = parse_quote!(String);
|
||||
let qualified: Type = parse_quote!(std::string::String);
|
||||
let reference: Type = parse_quote!(&String);
|
||||
assert!(is_type_named(&string, "String"));
|
||||
assert!(is_type_named(&qualified, "String"));
|
||||
assert!(!is_type_named(&string, "Vec"));
|
||||
assert!(!is_type_named(&reference, "String"));
|
||||
// test req: form/004 req: diagnostics/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_type_helpers_recognize_only_the_public_form_and_result_shapes() {
|
||||
let bare: Type = parse_quote!(Form<String>);
|
||||
let qualified: Type = parse_quote!(hemx::Form<crate::Input>);
|
||||
let wrong_module: Type = parse_quote!(other::Form<String>);
|
||||
let missing_model: Type = parse_quote!(hemx::Form);
|
||||
let unrelated: Type = parse_quote!(String);
|
||||
assert_eq!(quote!(#bare).to_string(), "Form < String >");
|
||||
assert_eq!(
|
||||
quote!(#qualified).to_string(),
|
||||
"hemx :: Form < crate :: Input >"
|
||||
);
|
||||
assert!(form_model_type(&bare).is_some());
|
||||
assert!(form_model_type(&qualified).is_some());
|
||||
assert!(form_model_type(&wrong_module).is_none());
|
||||
assert!(form_model_type(&missing_model).is_none());
|
||||
assert!(form_model_type(&unrelated).is_none());
|
||||
|
||||
let function: ItemFn = parse_quote!(
|
||||
fn save(
|
||||
first: hemx::Form<crate::First>,
|
||||
value: String,
|
||||
last: Form<crate::Last>,
|
||||
) -> Result<(), Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
quote!(#function)
|
||||
.to_string()
|
||||
.contains("last : Form < crate :: Last >"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
quote!(#function)
|
||||
.to_string()
|
||||
.contains("first : hemx :: Form < crate :: First >"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
handler_form_model_type(&function)
|
||||
.map(|ty| quote!(#ty).to_string())
|
||||
.as_deref(),
|
||||
Some("crate :: Last")
|
||||
);
|
||||
assert!(returns_result(&function.sig.output));
|
||||
|
||||
let no_result: ItemFn = parse_quote!(
|
||||
fn save() -> String {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
let no_return: ItemFn = parse_quote!(
|
||||
fn save() {}
|
||||
);
|
||||
assert!(!returns_result(&no_result.sig.output));
|
||||
assert!(!returns_result(&no_return.sig.output));
|
||||
// test req: derive_handler/001 req: derive_handler/005
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_contract_diagnostics_accumulate_all_mismatches() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"hemx-derive-form-contract-errors-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let missing: syn::ItemStruct = parse_quote!(
|
||||
struct Profile {
|
||||
first: String,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
form_contract_errors(&path, "profile", &missing),
|
||||
vec!["#[hemx::form] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"]
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform_field\tprofile\tfirst\tfalse\tfalse\nform_field\tprofile\tsecond\ttrue\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\trequired_name\ttrue\tfalse\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
form_contract_errors(&path, "unknown", &missing),
|
||||
vec!["unknown hemx form `unknown`; add data-hemx-form=\"unknown\" to a template or rename this form binding"]
|
||||
);
|
||||
let tuple: syn::ItemStruct = parse_quote!(
|
||||
struct Profile(String);
|
||||
);
|
||||
assert_eq!(
|
||||
form_contract_errors(&path, "profile", &tuple),
|
||||
vec!["hemx form `profile` must be a struct with named fields"]
|
||||
);
|
||||
let mismatched: syn::ItemStruct = parse_quote!(
|
||||
struct Profile {
|
||||
first: Vec<String>,
|
||||
tags: String,
|
||||
required_name: Option<String>,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
form_contract_errors(&path, "profile", &mismatched),
|
||||
vec![
|
||||
"hemx form `profile` field `first` accepts one value and must not be Vec<_>",
|
||||
"hemx form `profile` is missing field `second` for form control `second`",
|
||||
"hemx form `profile` field `tags` accepts multiple values and must be Vec<_>",
|
||||
"hemx form `profile` field `required_name` is required in HTML and must not be Option<_>",
|
||||
]
|
||||
);
|
||||
|
||||
let decode_struct: syn::ItemStruct = parse_quote!(
|
||||
struct Profile {
|
||||
first: Option<String>,
|
||||
tags: Vec<String>,
|
||||
required_name: String,
|
||||
}
|
||||
);
|
||||
let decoded = form_decode_fields(&path, "profile", &decode_struct)
|
||||
.into_iter()
|
||||
.map(|tokens| tokens.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(decoded.len(), 3);
|
||||
assert!(decoded[0].contains("Some (__hemx_value) => Some"));
|
||||
assert!(decoded[0].contains("None => None"));
|
||||
assert!(decoded[1].contains("collect :: < Result < Vec < _ > , _ >> () ?"));
|
||||
assert!(decoded[2].contains("missing form field"));
|
||||
|
||||
let option: Type = parse_quote!(Option<String>);
|
||||
let qualified_vec: Type = parse_quote!(std::vec::Vec<u64>);
|
||||
let plain: Type = parse_quote!(String);
|
||||
assert_eq!(
|
||||
generic_inner_type(&option, "Option")
|
||||
.unwrap()
|
||||
.to_token_stream()
|
||||
.to_string(),
|
||||
"String"
|
||||
);
|
||||
assert_eq!(
|
||||
generic_inner_type(&qualified_vec, "Vec")
|
||||
.unwrap()
|
||||
.to_token_stream()
|
||||
.to_string(),
|
||||
"u64"
|
||||
);
|
||||
assert!(generic_inner_type(&option, "Vec").is_none());
|
||||
assert!(generic_inner_type(&plain, "String").is_none());
|
||||
let empty_path = Type::Path(syn::TypePath {
|
||||
qself: None,
|
||||
path: syn::Path {
|
||||
leading_colon: None,
|
||||
segments: Default::default(),
|
||||
},
|
||||
});
|
||||
assert!(generic_inner_type(&empty_path, "Vec").is_none());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
// test req: form/004 req: diagnostics/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syms_lookup_matches_handle_ident() {
|
||||
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
|
||||
"hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/z.heml::archive\tarchive\t3\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle\ttemplates/a.heml::delete\tdelete\t4\nhandle\ttemplates/a.heml::create\tcreate-again\t5\nhandle\thas-no-component\tignored\t6\nhandle\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1000,8 +1354,26 @@ mod tests {
|
||||
assert!(!handle_requires_form(&path, "missing"));
|
||||
assert_eq!(handle_params(&path, "create"), vec!["todo_id"]);
|
||||
assert!(handle_params(&path, "missing").is_empty());
|
||||
assert_eq!(
|
||||
syms_handles(&path, Some("a")),
|
||||
vec!["create", "delete", "create-again"]
|
||||
);
|
||||
assert_eq!(
|
||||
syms_handles(&path, None),
|
||||
vec!["archive", "create", "delete", "create-again", "ignored"]
|
||||
);
|
||||
assert_eq!(syms_components(&path), vec!["a", "z"]);
|
||||
assert_eq!(symbol_component("templates/a.heml::create"), Some("a"));
|
||||
assert_eq!(symbol_component("a.heml::create"), Some("a"));
|
||||
assert_eq!(symbol_component("a.html::create"), None);
|
||||
assert_eq!(symbol_component("a.heml"), None);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(
|
||||
syms_contains_handle(&path, "create"),
|
||||
"missing generated symbols defer to the dedicated generated-file diagnostic"
|
||||
);
|
||||
assert!(!handle_requires_form(&path, "create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1066,6 +1438,16 @@ mod tests {
|
||||
assert!(form_model_type(&foreign_form).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_form_field_names_map_only_to_valid_rust_identifiers() {
|
||||
assert_eq!(rust_ident("first-name").as_deref(), Some("first_name"));
|
||||
assert_eq!(rust_ident("_private2").as_deref(), Some("_private2"));
|
||||
for invalid in ["", "2fast", "with space", "naïve"] {
|
||||
assert_eq!(rust_ident(invalid), None, "{invalid:?} must fail closed");
|
||||
}
|
||||
// test req: form/004 req: diagnostics/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_params_match_generated_param_names() {
|
||||
let path = std::env::temp_dir().join("hemx-derive-param-test.syms");
|
||||
@@ -1087,10 +1469,68 @@ mod tests {
|
||||
|
||||
assert!(missing_handle_params(&path, "show", &complete).is_empty());
|
||||
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
|
||||
assert!(missing_handle_params(&path, "unknown", &missing).is_empty());
|
||||
|
||||
std::fs::write(&path, "handle_param\nhandle_param\tshow\tmode\n").unwrap();
|
||||
assert_eq!(handle_params(&path, "show"), vec!["mode"]);
|
||||
|
||||
let patterns: ItemFn = parse_quote! {
|
||||
fn patterns(self, named: String, (left, right): (u8, u8), _: bool) {}
|
||||
};
|
||||
assert_eq!(handler_arg_names(&patterns), vec!["named"]);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_registration_selects_each_state_async_result_shape() {
|
||||
let component_ident: syn::Ident = parse_quote!(todos);
|
||||
let expected = [
|
||||
(0, false, false, quote!(.on(super::todos::save, save))),
|
||||
(0, false, true, quote!(.on_result(super::todos::save, save))),
|
||||
(0, true, false, quote!(.on_async(super::todos::save, save))),
|
||||
(
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
quote!(.on_async_result(super::todos::save, save)),
|
||||
),
|
||||
(1, false, false, quote!(.on_state(super::todos::save, save))),
|
||||
(
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
quote!(.on_state_result(super::todos::save, save)),
|
||||
),
|
||||
(
|
||||
1,
|
||||
true,
|
||||
false,
|
||||
quote!(.on_state_async(super::todos::save, save)),
|
||||
),
|
||||
(
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
quote!(.on_state_async_result(super::todos::save, save)),
|
||||
),
|
||||
];
|
||||
for (typed_arg_count, is_async, returns_result, expected) in expected {
|
||||
let handler = ComponentHandler {
|
||||
ident: parse_quote!(save),
|
||||
is_async,
|
||||
returns_result,
|
||||
typed_arg_count,
|
||||
};
|
||||
assert_eq!(
|
||||
component_registration_call(&handler, &component_ident).to_string(),
|
||||
expected.to_string(),
|
||||
"args={typed_arg_count} async={is_async} result={returns_result}"
|
||||
);
|
||||
}
|
||||
// test req: component/003 req: derive_handler/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_handlers_match_generated_handles() {
|
||||
let path = std::env::temp_dir().join("hemx-derive-component-test.syms");
|
||||
@@ -1103,6 +1543,8 @@ mod tests {
|
||||
mod component {
|
||||
#[hemx::handler]
|
||||
fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() }
|
||||
|
||||
fn helper() {}
|
||||
}
|
||||
};
|
||||
let (_, items) = module.content.expect("inline module");
|
||||
@@ -1117,7 +1559,14 @@ mod tests {
|
||||
vec!["delete"]
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(
|
||||
component_contract_errors(&path, Some("missing"), &items),
|
||||
vec![
|
||||
"#[hemx::component(\"missing\")] does not match any generated handles; no generated components with handles are available; add a data-hemx-handle to the component template or check build.rs generation",
|
||||
"#[hemx::component] handler(s) not declared by this component's generated handles: create; move them to the matching component module or add data-hemx-handle in .heml",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1145,6 +1594,80 @@ mod tests {
|
||||
);
|
||||
assert!(generated.contains("hemx_axum :: State"), "{generated}");
|
||||
assert!(generated.contains("state . clone"), "{generated}");
|
||||
|
||||
let no_components: ItemFn = parse_quote!(
|
||||
fn registry(state: App) {}
|
||||
);
|
||||
assert_eq!(
|
||||
add_app_registry_helper(no_components, vec![]).err().unwrap(),
|
||||
"#[hemx::app] requires component registry module(s), for example #[hemx::app(todo_handlers, auth_handlers)]"
|
||||
);
|
||||
let unnamed_state: ItemFn = parse_quote!(
|
||||
fn registry((state,): (App,)) {}
|
||||
);
|
||||
assert_eq!(
|
||||
add_app_registry_helper(unnamed_state, vec![parse_quote!(handlers)])
|
||||
.err()
|
||||
.unwrap(),
|
||||
"#[hemx::app] must be used on a registry function with a named app state argument"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_registration_generation_respects_existing_and_incomplete_modules() {
|
||||
let external: syn::ItemMod = parse_quote!(
|
||||
mod handlers;
|
||||
);
|
||||
let unchanged = add_component_register_helper(external.clone(), "todos");
|
||||
assert_eq!(
|
||||
quote!(#external).to_string(),
|
||||
quote!(#unchanged).to_string()
|
||||
);
|
||||
|
||||
for existing in ["register", "register_with_state"] {
|
||||
let existing_ident = syn::Ident::new(existing, proc_macro2::Span::call_site());
|
||||
let module: syn::ItemMod = parse_quote! {
|
||||
mod handlers {
|
||||
struct App;
|
||||
#[hemx::handler]
|
||||
fn create(app: App) {}
|
||||
fn #existing_ident() {}
|
||||
}
|
||||
};
|
||||
let generated = add_component_register_helper(module, "todos");
|
||||
let items = generated.content.unwrap().1;
|
||||
assert_eq!(items.len(), 3, "must preserve existing {existing}");
|
||||
}
|
||||
|
||||
let no_state: syn::ItemMod = parse_quote! {
|
||||
mod handlers {
|
||||
#[hemx::handler]
|
||||
fn create() {}
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
add_component_register_helper(no_state, "todos")
|
||||
.content
|
||||
.unwrap()
|
||||
.1
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let no_handlers: syn::ItemMod = parse_quote! {
|
||||
mod handlers {
|
||||
struct App;
|
||||
fn helper(app: App) {}
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
add_component_register_helper(no_handlers, "todos")
|
||||
.content
|
||||
.unwrap()
|
||||
.1
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
// test req: component/003 req: derive_handler/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1152,6 +1675,8 @@ mod tests {
|
||||
// req: component/003 req: derive_handler/003
|
||||
let module = parse_quote! {
|
||||
mod handlers {
|
||||
const COMPONENT_KIND: &str = "todos";
|
||||
|
||||
#[hemx::handler]
|
||||
fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
|
||||
hemx::EventName::new("sync-form").emit("")
|
||||
|
||||
@@ -611,6 +611,57 @@ struct Profile {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_struct_accepts_raw_identifier_for_reserved_control_name() {
|
||||
// req: form/004 test
|
||||
let fixture = Fixture::new("hemx-derive-form-raw-identifier-pass");
|
||||
fixture.write(
|
||||
"Cargo.toml",
|
||||
&format!(
|
||||
r#"[package]
|
||||
name = "hemx-derive-form-raw-identifier-pass"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
hemx = {{ path = {:?} }}
|
||||
"#,
|
||||
repo_path("hemx")
|
||||
),
|
||||
);
|
||||
fixture.write(
|
||||
"build.rs",
|
||||
r#"fn main() {
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
std::fs::write(
|
||||
out.join("hemx.syms"),
|
||||
"hemx-syms-v1\nform\ttemplates/app.heml::filter\tfilter\t1\nform_field\tfilter\ttype\ttrue\tfalse\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"#,
|
||||
);
|
||||
fixture.write(
|
||||
"src/lib.rs",
|
||||
r#"#[hemx::form("filter")]
|
||||
struct Filter {
|
||||
r#type: String,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let output = check_fixture(&fixture);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"fixture failed to compile:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_handle_accepts_checked_form_model() {
|
||||
// req: form/001 req: form/004 req: form/006
|
||||
@@ -980,6 +1031,45 @@ pub const MISSING: hemx::CssClass = ui::classes::missing;
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_batch_has_no_parallel_postcard_wire_api() {
|
||||
// req: wire/010 test
|
||||
let fixture = Fixture::new("hemx-effect-batch-postcard-api-fail");
|
||||
fixture.write(
|
||||
"Cargo.toml",
|
||||
&format!(
|
||||
r#"[package]
|
||||
name = "hemx-effect-batch-postcard-api-fail"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
hemx = {{ path = {:?} }}
|
||||
"#,
|
||||
repo_path("hemx")
|
||||
),
|
||||
);
|
||||
fixture.write(
|
||||
"src/lib.rs",
|
||||
r#"pub fn encode(batch: &hemx::advanced::EffectBatch) {
|
||||
let _ = batch.to_postcard();
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let output = check_fixture(&fixture);
|
||||
|
||||
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("no method named `to_postcard`"),
|
||||
"missing sole-wire-API diagnostic in stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
fn check_fixture(fixture: &Fixture) -> std::process::Output {
|
||||
Command::new("cargo")
|
||||
.arg("check")
|
||||
|
||||
+93
-1
@@ -468,6 +468,28 @@ mod tests {
|
||||
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
|
||||
@@ -509,6 +531,37 @@ mod tests {
|
||||
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
|
||||
@@ -516,12 +569,38 @@ mod tests {
|
||||
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: SharePayload::text("log"),
|
||||
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,
|
||||
@@ -584,6 +663,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
+80
-7
@@ -343,6 +343,7 @@
|
||||
await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode);
|
||||
return;
|
||||
}
|
||||
savePage(roots()[0]);
|
||||
const response = await fetch(requestUrl(form, data, method), {
|
||||
method,
|
||||
body,
|
||||
@@ -382,7 +383,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotPage(root) {
|
||||
return root ? {
|
||||
hemx: true,
|
||||
pageHtml: root.innerHTML,
|
||||
pageTitle: document.title,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
} : null;
|
||||
}
|
||||
|
||||
function savePage(root) {
|
||||
const snapshot = snapshotPage(root);
|
||||
if (snapshot) history.replaceState(snapshot, "", location.href);
|
||||
}
|
||||
|
||||
function restorePage(state, root) {
|
||||
if (!root || !state || typeof state.pageHtml !== "string") return false;
|
||||
root.innerHTML = state.pageHtml;
|
||||
if (state.pageTitle) document.title = state.pageTitle;
|
||||
bindRoot(root);
|
||||
requestAnimationFrame(() => scrollTo(state.scrollX, state.scrollY));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function navigateUrl(href, root, mode = "replace") {
|
||||
if (mode === "push") savePage(root);
|
||||
const response = await fetch(href, {
|
||||
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
|
||||
credentials: "same-origin",
|
||||
@@ -443,6 +469,8 @@
|
||||
return;
|
||||
}
|
||||
for (const op of batch.ops) applyOp(scope, op);
|
||||
bindPolling(scope);
|
||||
bindRevealed(scope);
|
||||
}
|
||||
|
||||
function canApplyOp(scope, op) {
|
||||
@@ -853,8 +881,14 @@
|
||||
everyTimers.delete(el);
|
||||
}
|
||||
|
||||
function revealedRootMargin(el) {
|
||||
const ahead = Number(el.getAttribute("data-hemx-revealed-ahead") || "0");
|
||||
const viewports = Number.isFinite(ahead) && ahead >= 0 ? ahead : 0;
|
||||
return `0px 0px ${viewports * window.innerHeight}px 0px`;
|
||||
}
|
||||
|
||||
function bindRevealed(root) {
|
||||
let observer = revealObservers.get(root);
|
||||
let observers = revealObservers.get(root);
|
||||
forEachElement(root, (el) => {
|
||||
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
@@ -862,6 +896,12 @@
|
||||
schedule(el, "revealed");
|
||||
return;
|
||||
}
|
||||
if (!observers) {
|
||||
observers = new Map();
|
||||
revealObservers.set(root, observers);
|
||||
}
|
||||
const rootMargin = revealedRootMargin(el);
|
||||
let observer = observers.get(rootMargin);
|
||||
if (!observer) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -870,8 +910,8 @@
|
||||
observer.unobserve(entry.target);
|
||||
schedule(entry.target, "revealed");
|
||||
});
|
||||
});
|
||||
revealObservers.set(root, observer);
|
||||
}, { rootMargin });
|
||||
observers.set(rootMargin, observer);
|
||||
}
|
||||
observer.observe(el);
|
||||
});
|
||||
@@ -1095,12 +1135,30 @@
|
||||
const source = sseSources.get(root);
|
||||
if (source) source.close();
|
||||
sseSources.delete(root);
|
||||
const observer = revealObservers.get(root);
|
||||
if (observer) observer.disconnect();
|
||||
const observers = revealObservers.get(root);
|
||||
if (observers) observers.forEach((observer) => observer.disconnect());
|
||||
revealObservers.delete(root);
|
||||
stopDescendantPolling(root);
|
||||
}
|
||||
|
||||
function rebindRevealed(resetDispatched) {
|
||||
roots().forEach((root) => {
|
||||
const observers = revealObservers.get(root);
|
||||
if (observers) observers.forEach((observer) => observer.disconnect());
|
||||
revealObservers.delete(root);
|
||||
if (resetDispatched) {
|
||||
forEachElement(root, (el) => {
|
||||
if (attrEquals(el, "data-hemx-on", "revealed") || el.hasAttribute("data-hemx-revealed")) revealed.delete(el);
|
||||
});
|
||||
}
|
||||
bindRevealed(root);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreRevealed(event) {
|
||||
if (event.persisted) rebindRevealed(true);
|
||||
}
|
||||
|
||||
function start() {
|
||||
roots().forEach((root) => {
|
||||
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
|
||||
@@ -1127,6 +1185,16 @@
|
||||
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
|
||||
descendantRoots(node).forEach(cleanupRemovedRoot);
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node.hasAttribute(ROOT)) bindRoot(node);
|
||||
descendantRoots(node).forEach(bindRoot);
|
||||
const owner = rootOf(node.parentElement);
|
||||
if (owner) {
|
||||
bindPolling(owner);
|
||||
bindRevealed(owner);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
try {
|
||||
@@ -1137,9 +1205,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("popstate", () => {
|
||||
window.addEventListener("pageshow", restoreRevealed);
|
||||
window.addEventListener("resize", () => rebindRevealed(false));
|
||||
|
||||
addEventListener("popstate", (event) => {
|
||||
const root = roots()[0];
|
||||
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
|
||||
if (root && !restorePage(event.state, root)) {
|
||||
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
|
||||
}
|
||||
});
|
||||
|
||||
window.hemx = Object.freeze({
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
#[test]
|
||||
fn runtime_abi_version_is_explicit_and_stable() {
|
||||
assert_eq!(hemx_js::RUNTIME_ABI_VERSION, 1); // test req: abi/001
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_exposes_debug_api_before_startup_side_effects() {
|
||||
// req: runtime/002
|
||||
@@ -141,8 +146,21 @@ fn runtime_supports_tiny_delay_and_revealed_scheduling() {
|
||||
assert!(source.contains("const revealObservers = new WeakMap()"));
|
||||
assert!(source.contains("const revealed = new WeakSet()"));
|
||||
assert!(source.contains("data-hemx-revealed"));
|
||||
assert!(source.contains("data-hemx-revealed-ahead")); // req: convention/011 test
|
||||
assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
|
||||
assert!(source.contains("rootMargin"));
|
||||
assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))"));
|
||||
assert!(source.contains("schedule(entry.target, \"revealed\")"));
|
||||
assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)"));
|
||||
assert!(source.contains("revealed.delete(el)")); // req: convention/014 test
|
||||
assert!(source.contains("record.addedNodes.forEach((node) =>"));
|
||||
assert!(source.contains("descendantRoots(node).forEach(bindRoot)"));
|
||||
assert!(source.contains("const owner = rootOf(node.parentElement)"));
|
||||
assert!(source.contains("bindPolling(owner)"));
|
||||
assert!(source.contains("bindRevealed(owner)"));
|
||||
assert!(source.contains(
|
||||
"for (const op of batch.ops) applyOp(scope, op);\n bindPolling(scope);\n bindRevealed(scope);"
|
||||
)); // req: runtime/007 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,6 +320,9 @@ fn runtime_exposes_page_swap_hooks() {
|
||||
assert!(source.contains("data-hemx-nav"));
|
||||
assert!(source.contains("data-hemx-boost"));
|
||||
assert!(source.contains("history.pushState"));
|
||||
assert!(source.contains("snapshotPage(root)"));
|
||||
assert!(source.contains("restorePage(event.state, root)"));
|
||||
assert!(source.contains("scrollTo(state.scrollX, state.scrollY)")); // req: page_swap/005 test
|
||||
assert!(source.contains("popstate"));
|
||||
assert!(source.contains("x-hemx-title"));
|
||||
assert!(source.contains("x-hemx-fingerprint"));
|
||||
|
||||
+110
-25
@@ -1,55 +1,52 @@
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, Error, FnArg, ItemFn, Pat, ReturnType};
|
||||
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<TokenStream2> {
|
||||
if !attributes.is_empty() {
|
||||
return Error::new(
|
||||
return Err(Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
"#[hemx_sync::presence] does not accept arguments",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
));
|
||||
}
|
||||
|
||||
let mut function = parse_macro_input!(item as ItemFn);
|
||||
if function.sig.asyncness.is_some() {
|
||||
return Error::new_spanned(
|
||||
function.sig.asyncness,
|
||||
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",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
));
|
||||
}
|
||||
if matches!(function.sig.output, ReturnType::Default) {
|
||||
return Error::new_spanned(
|
||||
return Err(Error::new_spanned(
|
||||
&function.sig,
|
||||
"presence projections must return impl IntoEffect",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
));
|
||||
}
|
||||
let argument = match function.sig.inputs.first() {
|
||||
Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument,
|
||||
_ => {
|
||||
return Error::new_spanned(
|
||||
return Err(Error::new_spanned(
|
||||
&function.sig.inputs,
|
||||
"presence projections require exactly one typed presence argument",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
));
|
||||
}
|
||||
};
|
||||
let argument_name = match argument.pat.as_ref() {
|
||||
Pat::Ident(argument) => argument.ident.clone(),
|
||||
pattern => {
|
||||
return Error::new_spanned(
|
||||
return Err(Error::new_spanned(
|
||||
pattern,
|
||||
"presence projection argument must be a simple identifier",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,5 +58,93 @@ pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let __hemx_sync_effect = (|| #body)();
|
||||
::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect)
|
||||
}));
|
||||
quote!(#function).into()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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<Effect> {
|
||||
channel: String,
|
||||
effect: Effect,
|
||||
}
|
||||
|
||||
impl<Effect> PresenceProjection<Effect> {
|
||||
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
|
||||
}
|
||||
+254
-10
@@ -1,4 +1,4 @@
|
||||
use hemx_core::{Atom, Effect, EffectBatch, IntoEffect};
|
||||
use hemx_core::{Atom, BuildFingerprint, Effect, EffectBatch, IntoEffect};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -411,7 +411,6 @@ impl SyncEffect {
|
||||
}
|
||||
|
||||
pub fn send_patch(patch: FlatPatch) -> Self {
|
||||
patch.validate().expect("FlatPatch must remain valid");
|
||||
Self(vec![Effect::Emit {
|
||||
name: PATCH_EVENT.to_owned(),
|
||||
payload: patch.payload(),
|
||||
@@ -421,8 +420,12 @@ impl SyncEffect {
|
||||
/// Apply an optimistic projection now and carry the same ordinary batch in
|
||||
/// the durable patch event so the framework sync runtime can replay it
|
||||
/// after reload before acknowledgement.
|
||||
pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self {
|
||||
patch.validate().expect("FlatPatch must remain valid");
|
||||
pub fn durable(
|
||||
patch: FlatPatch,
|
||||
projection: impl IntoEffect,
|
||||
fingerprint: BuildFingerprint,
|
||||
) -> Self {
|
||||
let projection = projection.into_batch(fingerprint);
|
||||
let projection_wire = projection
|
||||
.to_wire()
|
||||
.iter()
|
||||
@@ -459,15 +462,35 @@ mod tests {
|
||||
let projection = Effect::Emit {
|
||||
name: "projected".into(),
|
||||
payload: "card:1".into(),
|
||||
}
|
||||
.into_batch(hemx_core::BuildFingerprint(7));
|
||||
let batch =
|
||||
SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7));
|
||||
};
|
||||
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"));
|
||||
assert!(
|
||||
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
|
||||
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]
|
||||
@@ -545,6 +568,227 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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::<FlatPatch>(&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::<FlatPatch>(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(
|
||||
|
||||
+26
-11
@@ -4,7 +4,7 @@ use hemx_core::{
|
||||
};
|
||||
use std::io;
|
||||
use std::net::TcpStream;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A child process owned by an integration test and proven ready over TCP.
|
||||
@@ -16,6 +16,14 @@ pub struct TestProcess {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
fn test_process_try_wait(child: &mut Child) -> io::Result<Option<ExitStatus>> {
|
||||
child.try_wait()
|
||||
}
|
||||
|
||||
fn test_process_poll_delay() {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
|
||||
impl TestProcess {
|
||||
pub fn start(
|
||||
mut command: Command,
|
||||
@@ -37,7 +45,7 @@ impl TestProcess {
|
||||
if TcpStream::connect(addr).is_ok() {
|
||||
return Ok(process);
|
||||
}
|
||||
if let Some(status) = process.child.try_wait()? {
|
||||
if let Some(status) = test_process_try_wait(&mut process.child)? {
|
||||
return Err(io::Error::other(format!(
|
||||
"{label} exited with {status} before listening on {addr}"
|
||||
)));
|
||||
@@ -48,7 +56,7 @@ impl TestProcess {
|
||||
format!("timed out after {timeout:?} waiting for {label} to listen on {addr}"),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
test_process_poll_delay();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,8 +76,12 @@ where
|
||||
inspect(handler(input))
|
||||
}
|
||||
|
||||
fn inspection_fingerprint() -> BuildFingerprint {
|
||||
BuildFingerprint(0)
|
||||
}
|
||||
|
||||
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
|
||||
inspect_batch(effect.into_batch(BuildFingerprint(0)))
|
||||
inspect_batch(effect.into_batch(inspection_fingerprint()))
|
||||
}
|
||||
|
||||
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
|
||||
@@ -81,7 +93,10 @@ pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
|
||||
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
|
||||
inspect_batch(EffectBatch::from_wire(bytes).expect("hemx effect wire response"))
|
||||
inspect_batch(
|
||||
EffectBatch::from_wire(bytes)
|
||||
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the resource id behind a generated target for low-level test assertions.
|
||||
@@ -420,10 +435,11 @@ pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
|
||||
|
||||
fn assert_simple_selector_part(value: &str, label: &str) {
|
||||
assert!(
|
||||
value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||
"{label} selector part must contain only ascii alphanumerics or '-'"
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||
"{label} selector part must contain one or more ascii alphanumerics or '-'"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,8 +453,7 @@ fn runtime_resource_attr(kind: ResourceKind) -> &'static str {
|
||||
}
|
||||
|
||||
fn runtime_attr_marker(name: &str, value: &str) -> String {
|
||||
let escaped = value.replace('"', """);
|
||||
format!(r#"{name}="{escaped}""#)
|
||||
format!(r#"{name}="{value}""#)
|
||||
}
|
||||
|
||||
fn attr_selector(name: &str, value: &str) -> String {
|
||||
|
||||
@@ -357,8 +357,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool {
|
||||
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/saas/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/kanban/templates/sync_shell.heml")
|
||||
&& line.contains(r#"<script src="/sync.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
|
||||
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
|
||||
}
|
||||
|
||||
fn contains_inline_event_handler(line: &str) -> bool {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
use hemx_core::{
|
||||
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
|
||||
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode,
|
||||
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||
};
|
||||
|
||||
fn panic_text<T>(result: std::thread::Result<T>) -> String {
|
||||
let panic = match result {
|
||||
Ok(_) => panic!("operation must panic"),
|
||||
Err(panic) => panic,
|
||||
};
|
||||
if let Some(message) = panic.downcast_ref::<String>() {
|
||||
message.clone()
|
||||
} else if let Some(message) = panic.downcast_ref::<&str>() {
|
||||
(*message).to_owned()
|
||||
} else {
|
||||
panic!("panic payload was not text")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_tuple_effects() {
|
||||
let count = Slot::<u32>::new(1);
|
||||
@@ -84,6 +99,242 @@ fn finds_keyed_slot_targets() {
|
||||
assert_eq!(inspected.ops().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
|
||||
// test req: test/008 req: test/009 req: test/018
|
||||
let expected = TestTarget(ResourceKind::Slot, 42);
|
||||
let other = TestTarget(ResourceKind::Slot, 7);
|
||||
let resource = ResourceId::new(ResourceKind::Slot, 42);
|
||||
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
|
||||
let form = Form::<()>::new(11);
|
||||
let expected_emit = Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "card 42 saved".into(),
|
||||
};
|
||||
let inspected = hemx_test::inspect(vec![
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(other.__hemx_resource_id()),
|
||||
payload: Payload::Text("decoy needle".into()),
|
||||
},
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
payload: Payload::Text("expected text".into()),
|
||||
},
|
||||
Effect::Put {
|
||||
target: keyed_ref.clone(),
|
||||
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
|
||||
},
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-2".into(),
|
||||
payload: Payload::Html("<li>inserted</li>".into()),
|
||||
},
|
||||
Effect::Prepend {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-0".into(),
|
||||
payload: Payload::Html("<li>prepended</li>".into()),
|
||||
},
|
||||
Effect::Remove {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: Some("row-old".into()),
|
||||
},
|
||||
Effect::Focus {
|
||||
target: ResourceRef::unscoped(form.id()),
|
||||
},
|
||||
Effect::Navigate {
|
||||
url: "/cards/42".into(),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: hemx_core::ScrollBehavior::Preserve,
|
||||
title: None,
|
||||
},
|
||||
expected_emit.clone(),
|
||||
Effect::Emit {
|
||||
name: "hemx:form-reset".into(),
|
||||
payload: form.id().id.to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!inspected.is_empty());
|
||||
assert_eq!(inspected.op_count(), 10);
|
||||
assert!(inspected.contains(&expected_emit));
|
||||
assert!(!inspected.contains(&Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "wrong".into(),
|
||||
}));
|
||||
assert!(inspected.has_resource(resource));
|
||||
assert!(inspected.has_resource(other.__hemx_resource_id()));
|
||||
assert!(inspected.has_target(expected));
|
||||
assert!(inspected.has_target(other));
|
||||
assert!(!inspected.has_target(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.updates_text(expected));
|
||||
assert!(inspected.updates_text_containing(expected, "expected"));
|
||||
assert!(!inspected.updates_text_containing(expected, "decoy"));
|
||||
assert!(inspected.updates_text(other));
|
||||
assert!(!inspected.updates_text(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.updates_html(expected));
|
||||
assert!(inspected.updates_html_containing(expected, "replacement"));
|
||||
assert!(!inspected.updates_html_containing(expected, "missing"));
|
||||
assert!(!inspected.updates_html(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.replaces_keyed_html_containing(expected, "row-1", "replacement"));
|
||||
assert!(!inspected.replaces_keyed_html_containing(expected, "row-2", "replacement"));
|
||||
assert!(inspected.inserts_html_containing(expected, "row-2", "inserted"));
|
||||
assert!(!inspected.inserts_html_containing(expected, "row-0", "inserted"));
|
||||
assert!(inspected.removes_key(expected, "row-old"));
|
||||
assert!(!inspected.removes_key(expected, "row-2"));
|
||||
assert!(inspected.pushes_to("/cards/42"));
|
||||
assert!(!inspected.pushes_to("/cards/7"));
|
||||
assert!(inspected.payload_contains("prepended"));
|
||||
assert!(!inspected.payload_contains("absent"));
|
||||
assert!(inspected.payload_excludes("absent"));
|
||||
assert!(!inspected.payload_excludes("expected"));
|
||||
assert!(inspected.payload_excludes_key("absent"));
|
||||
assert!(!inspected.payload_excludes_key("row-1"));
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "replacement"),
|
||||
Some("<li data-key=\"row-1\">replacement</li>")
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "inserted"),
|
||||
Some("<li>inserted</li>")
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "prepended"),
|
||||
Some("<li>prepended</li>")
|
||||
);
|
||||
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
|
||||
assert!(inspected.emits("saved", "card 42 saved"));
|
||||
assert!(!inspected.emits("saved", "wrong"));
|
||||
assert!(inspected.emits_containing("saved", "42"));
|
||||
assert!(!inspected.emits_containing("other", "42"));
|
||||
assert!(inspected.has_ref(&keyed_ref));
|
||||
assert!(inspected.has_ref(&ResourceRef::unscoped(resource)));
|
||||
assert!(!inspected.has_ref(&ResourceRef::scoped(
|
||||
resource,
|
||||
ScopeKey::Field("row-1".into()),
|
||||
)));
|
||||
assert!(inspected.has_slot(Slot::<()>::new(42)));
|
||||
assert!(!inspected.has_slot(Slot::<()>::new(99)));
|
||||
assert!(inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(42)));
|
||||
assert!(!inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(99)));
|
||||
assert!(!inspected.has_atom(Atom::<()>::new(42)));
|
||||
assert!(inspected.has_form(form));
|
||||
assert!(!inspected.has_form(Form::<()>::new(12)));
|
||||
assert!(inspected.resets_form(form));
|
||||
assert!(!inspected.resets_form(Form::<()>::new(12)));
|
||||
|
||||
let empty = hemx_test::inspect(Vec::<Effect>::new());
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.op_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
|
||||
// test req: test/017
|
||||
assert_eq!(hemx_test::heading_selector("", 2), "h2");
|
||||
assert_eq!(hemx_test::list_item_selector(""), "li");
|
||||
assert_eq!(hemx_test::prose_selector(""), "p");
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
|
||||
"[data-sid=\"1\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
|
||||
"[data-aid=\"2\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
|
||||
"[data-hid=\"3\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
|
||||
"[data-fid=\"4\"]"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
hemx_test::nav_link_selector("/path\\\"quoted"),
|
||||
"a[href=\"/path\\\\\\\"quoted\"]"
|
||||
);
|
||||
|
||||
hemx_test::assert_rendered_target(
|
||||
TestTarget(ResourceKind::Slot, 1),
|
||||
"slot",
|
||||
"<p data-sid=\"1\"></p>",
|
||||
);
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-hid=\"3\"></button>",
|
||||
);
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
|
||||
})
|
||||
.is_err());
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-other=\"3\"></button>",
|
||||
);
|
||||
})
|
||||
.is_err());
|
||||
|
||||
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
|
||||
assert!(panic_text(std::panic::catch_unwind(|| {
|
||||
hemx_test::class_selector(invalid)
|
||||
}))
|
||||
.contains("class selector part"));
|
||||
}
|
||||
for (call, label) in [
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
|
||||
"element selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
|
||||
"class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
|
||||
"parent class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
|
||||
"element selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
|
||||
"class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
|
||||
"parent class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
|
||||
"element selector part",
|
||||
),
|
||||
] {
|
||||
assert!(panic_text(call).contains(label));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
|
||||
let batch = EffectBatch {
|
||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint: BuildFingerprint(9),
|
||||
ops: vec![Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "ok".into(),
|
||||
}],
|
||||
};
|
||||
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok"));
|
||||
|
||||
let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
|
||||
assert!(message.contains("invalid hemx effect wire response: Truncated"));
|
||||
// req: test/001 test req: wire/009 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_generated_handle_form_bodies() {
|
||||
let handle = hemx_core::Handle::<()>::new(7);
|
||||
@@ -91,6 +342,10 @@ fn builds_generated_handle_form_bodies() {
|
||||
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
|
||||
|
||||
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
|
||||
assert_eq!(
|
||||
hemx_test::handle_form_body(handle, &[("AZaz09-_.~", "AZaz09-_.~ /é")],),
|
||||
"__h=7&AZaz09-_.~=AZaz09-_.~+%2F%C3%A9"
|
||||
);
|
||||
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
|
||||
}
|
||||
|
||||
@@ -189,13 +444,15 @@ fn builds_authoring_boundary_selectors() {
|
||||
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
||||
|
||||
let probe = hemx_test::island_probe_script(
|
||||
"probe-island",
|
||||
"orbit",
|
||||
"1|1|1|probe waiting",
|
||||
"7|2|8|probe live",
|
||||
"probe-\"island",
|
||||
"orbit\\bridge",
|
||||
"line1\nline2\rline3\tend",
|
||||
"detail \"quoted\"",
|
||||
);
|
||||
assert!(probe.contains("probe-island"));
|
||||
assert!(probe.contains("probe live"));
|
||||
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
|
||||
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
|
||||
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
|
||||
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use hemx_test::TestProcess;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_early_exit_with_context() {
|
||||
@@ -27,5 +28,89 @@ fn process_harness_reports_early_exit_with_context() {
|
||||
assert!(message.contains("exited with"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
|
||||
// test req: test/019
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_listens")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &addr);
|
||||
|
||||
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
|
||||
.expect("readiness must observe the helper listener");
|
||||
assert!(TcpStream::connect(&addr).is_ok());
|
||||
drop(process);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
TcpStream::connect(&addr).is_err(),
|
||||
"drop must reap the helper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
|
||||
// test req: test/019
|
||||
let spawn_error = match TestProcess::start(
|
||||
Command::new("/definitely/not/a/hemx/executable"),
|
||||
"missing helper",
|
||||
"127.0.0.1:9",
|
||||
Duration::from_millis(10),
|
||||
) {
|
||||
Ok(_) => panic!("spawn failure must be returned, not panic"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(spawn_error
|
||||
.to_string()
|
||||
.contains("failed to spawn missing helper"));
|
||||
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let unused_addr = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_sleeps")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_SLEEP", "1");
|
||||
let timeout = match TestProcess::start(
|
||||
command,
|
||||
"non-listening helper",
|
||||
&unused_addr,
|
||||
Duration::from_millis(100),
|
||||
) {
|
||||
Ok(_) => panic!("non-listening process must time out"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let message = timeout.to_string();
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
assert!(message.contains("non-listening helper"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_exits_successfully() {}
|
||||
|
||||
#[test]
|
||||
fn helper_process_listens() {
|
||||
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
return;
|
||||
};
|
||||
let _listener = TcpListener::bind(addr).expect("bind helper listener");
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_sleeps() {
|
||||
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
+98
-2
@@ -34,8 +34,8 @@ pub struct ClientState {
|
||||
|
||||
/// Validates primitive wasm-bindgen values before application code runs.
|
||||
///
|
||||
/// Primitive arguments keep JavaScript from owning a second binary codec. The
|
||||
/// ordinary effect result remains postcard-encoded by `hemx-core`.
|
||||
/// 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(
|
||||
@@ -149,6 +149,102 @@ mod tests {
|
||||
); // 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);
|
||||
|
||||
+40
-28
@@ -7,11 +7,37 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use thirtyfour::common::capabilities::firefox::FirefoxPreferences;
|
||||
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<FirefoxCapabilities> {
|
||||
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
|
||||
@@ -31,8 +57,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -216,8 +241,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
|
||||
let result = async {
|
||||
@@ -409,11 +433,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
|
||||
let mut no_script_preferences = FirefoxPreferences::new();
|
||||
no_script_preferences.set("javascript.enabled", false)?;
|
||||
let mut no_script_caps = DesiredCapabilities::firefox();
|
||||
no_script_caps.set_headless()?;
|
||||
no_script_caps.set_preferences(no_script_preferences)?;
|
||||
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
|
||||
@@ -431,8 +451,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
let _ = no_script_driver.quit().await;
|
||||
moved_without_script?;
|
||||
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&webdriver_url, caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&host_url).await?;
|
||||
@@ -678,8 +697,7 @@ async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverRe
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -861,8 +879,7 @@ async fn kanban_queued_status_precedes_durable_projection_within_budget() -> Web
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -960,8 +977,7 @@ async fn kanban_quota_failure_is_fail_closed_and_recoverable() -> WebDriverResul
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -1103,8 +1119,7 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -1190,9 +1205,9 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
|
||||
|
||||
#[tokio::test]
|
||||
async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> {
|
||||
// test req: sync/014 req: performance/005
|
||||
// test req: sync/014 req: performance/005 req: performance/007
|
||||
const REPLAY_LIMIT: u64 = 64;
|
||||
const REPLAY_BUDGET_MS: f64 = 100.0;
|
||||
const REPLAY_BUDGET_MS: f64 = 250.0;
|
||||
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
@@ -1214,8 +1229,7 @@ async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> {
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -1323,8 +1337,7 @@ async fn kanban_corrupt_command_refuses_projection_and_recovers() -> WebDriverRe
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
@@ -1426,8 +1439,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let caps = headless_firefox_capabilities(true)?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
let result = async {
|
||||
driver.goto(&server.url()).await?;
|
||||
|
||||
+236
-6
@@ -12,6 +12,11 @@ fn main() -> ExitCode {
|
||||
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();
|
||||
@@ -37,7 +42,7 @@ fn main() -> ExitCode {
|
||||
|
||||
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 -- 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_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +86,7 @@ fn run_html_examples_smoke() -> ExitCode {
|
||||
("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),
|
||||
("infinite/reveal rows", INFINITE_SCROLL_SMOKE), // req: test/006 test; req: runtime/007
|
||||
("value select", VALUE_SELECT_SMOKE),
|
||||
("reset user input", RESET_INPUT_SMOKE),
|
||||
];
|
||||
@@ -130,6 +135,7 @@ fn pick_unused_port() -> Result<u16, ExitCode> {
|
||||
|
||||
fn start_html_examples_server(addr: &str) -> Result<HtmlExamplesServer, ExitCode> {
|
||||
let mut child = Command::new("cargo")
|
||||
.current_dir(workspace_root())
|
||||
.args(["run", "-p", "hemx-html-examples"])
|
||||
.env(
|
||||
"HEMX_HTML_EXAMPLES_PORT",
|
||||
@@ -1190,7 +1196,7 @@ fn run_test_plan() -> ExitCode {
|
||||
"techdemo-unit-http",
|
||||
["test", "-p", "hemx-techdemo", "--test", "e2e"],
|
||||
),
|
||||
Step::new("redgate", ["health", "--strict"]).tool("redgate"),
|
||||
Step::new("redgate", ["refs"]).tool("redgate"),
|
||||
];
|
||||
|
||||
if !budget.skip_browser {
|
||||
@@ -1247,6 +1253,185 @@ fn run_bench_plan() -> ExitCode {
|
||||
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<Vec<&'static str>, 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<Option<String>, 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::<usize>()
|
||||
.map_err(|_| format!("invalid mutation shard `{shard}`; SHARD must be an integer"))?;
|
||||
let total = total
|
||||
.parse::<usize>()
|
||||
.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,
|
||||
@@ -1472,13 +1657,58 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
|
||||
create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release,
|
||||
workout_mobile_manifest, workspace_root, write_workout_mobile_release, Budget,
|
||||
WorkoutMobileConfig,
|
||||
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
|
||||
|
||||
+13
-4
@@ -5,10 +5,10 @@
|
||||
|
||||
use hemx_core::SafeHtml;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use hemx_core::{Effect, KeyedSlot, Slot};
|
||||
use hemx_core::{KeyedSlot, Slot};
|
||||
|
||||
pub use hemx_core::{
|
||||
navigate, push, redirect, replace, CssClass, CssClasses, Form, FormContract, FormControlKind,
|
||||
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract, FormControlKind,
|
||||
FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
|
||||
};
|
||||
|
||||
@@ -93,8 +93,7 @@ pub mod __private {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn render_template(view: &impl hemplate::Hemplate) -> Html {
|
||||
let mut html = String::with_capacity(view.size_hint());
|
||||
view.render_into(&mut html)
|
||||
.expect("hemplate view renders into hemx effect payload");
|
||||
view.render_into(&mut html).unwrap();
|
||||
__private::html_trusted(html)
|
||||
}
|
||||
|
||||
@@ -261,4 +260,14 @@ mod tests {
|
||||
|
||||
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_string_views_preserve_the_rendered_fragment() {
|
||||
let html = crate::__private::html_trusted("<p>hello</p>");
|
||||
assert_eq!(html.as_str(), "<p>hello</p>");
|
||||
assert_eq!(html.as_ref(), "<p>hello</p>");
|
||||
assert_eq!(html.to_string(), "<p>hello</p>");
|
||||
assert_eq!(html.into_string(), "<p>hello</p>");
|
||||
// test req: public_api/005
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user