feat(v1): harden typed runtime boundaries
Elect one canonical EffectBatch codec, remove the parallel postcard API, and strengthen fail-closed host, form, sync, WASM, macro, generated-contract, and test-harness proofs with mutation-driven coverage. req: wire/008 req: wire/009 req: wire/010 req: push/008 req: client_local/015 req: client_local/016 req: client_local/017 req: client_local/018 req: client_local/019 req: sync/024 req: sync/025 req: sync/026 req: sync/027 req: sync/028 req: sync/029 req: test/020 req: test/021
This commit is contained in:
@@ -56,16 +56,16 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
|||||||
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
||||||
- 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
|
- 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
|
- `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
|
- 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 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
|
- 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
|
- 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
|
- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `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
|
||||||
- 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
|
- 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
|
- 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
|
- 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
|
||||||
|
|||||||
+39
-5
@@ -372,12 +372,18 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
003 HTTP interaction responses may be `text/html` fragments containing `<template data-hemx>...`. Push streams use `application/hemx` or transport-specific event frames carrying serialized `EffectBatch`. [north_star]
|
003 HTTP interaction responses may be `text/html` fragments containing `<template data-hemx>...`. Push streams use `application/hemx` or transport-specific event frames carrying serialized `EffectBatch`. [north_star]
|
||||||
|
|
||||||
004 Server push is orthogonal: integration crates stream postcard `EffectBatch` over SSE or WebSocket connections. hemx core owns the effect bytes; transport and connection management are integration concerns. [north_star]
|
004 Server push is orthogonal: integration crates stream canonical `EffectBatch` bytes over SSE or WebSocket connections. hemx core owns the effect codec; transport and connection management are integration concerns. [north_star]
|
||||||
|
|
||||||
005 No JSON anywhere in hemx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; effects and symbols use postcard. `application/json` is acceptable only at integration boundaries. [north_star]
|
005 No JSON anywhere in hemx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; `EffectBatch` uses the versioned hemx codec; generated symbols and surface facts may use postcard. `application/json` is acceptable only at integration boundaries. [north_star]
|
||||||
|
|
||||||
007 `EffectBatch::encoded_len` reports the exact canonical wire size, and `to_wire` uses it to pre-size encoding to one output allocation while preserving canonical bytes. [north_star]
|
007 `EffectBatch::encoded_len` reports the exact canonical wire size, and `to_wire` uses it to pre-size encoding to one output allocation while preserving canonical bytes. [north_star]
|
||||||
|
|
||||||
|
008 The canonical `EffectBatch` codec must use `HEMX` magic, fixed-width little-endian numbers, length-prefixed UTF-8, and one-byte closed-variant tags. [north_star]
|
||||||
|
|
||||||
|
009 `EffectBatch::from_wire` must reject bad magic, truncation at every byte boundary, invalid UTF-8, unknown tags, and trailing bytes without panicking. [north_star]
|
||||||
|
|
||||||
|
010 `EffectBatch` must expose only `encoded_len`, `to_wire`, and `from_wire` as its wire contract; postcard conversion is not a parallel effect-batch format. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## abi
|
## abi
|
||||||
@@ -402,7 +408,7 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter: no selectors, VDOM, scheduler, or expressions. [north_star]
|
003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter: no selectors, VDOM, scheduler, or expressions. [north_star]
|
||||||
|
|
||||||
006 The core JS runtime reads postcard `EffectBatch` bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files. [north_star]
|
006 The core JS runtime reads canonical versioned hemx `EffectBatch` bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files. [north_star]
|
||||||
|
|
||||||
004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `hemx-wasm`, `hemx-sync`, and `hemx-transition`. [north_star]
|
004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `hemx-wasm`, `hemx-sync`, and `hemx-transition`. [north_star]
|
||||||
|
|
||||||
@@ -472,7 +478,7 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
## push
|
## push
|
||||||
|
|
||||||
001 Server push streams canonical postcard EffectBatch over SSE or WebSocket. hemx core owns the EffectBatch schema, not the transport.
|
001 Server push streams canonical versioned hemx `EffectBatch` bytes over SSE or WebSocket. hemx core owns the effect codec, not the transport.
|
||||||
|
|
||||||
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. hemx does not define auth semantics for streams.
|
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. hemx does not define auth semantics for streams.
|
||||||
|
|
||||||
@@ -486,6 +492,8 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
007 Cross-origin push streams belong to explicit integration code rather than the standard runtime convention.
|
007 Cross-origin push streams belong to explicit integration code rather than the standard runtime convention.
|
||||||
|
|
||||||
|
008 SSE must transport canonical `EffectBatch` bytes as one unpadded base64url value in the `hemx` event data field. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## sync
|
## sync
|
||||||
@@ -536,6 +544,18 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
023 A durable browser test proves offline mutation, reload, reconnect replay, duplicate delivery, rejection, conflict, schema mismatch, and final convergence through public hemx APIs. [north_star]
|
023 A durable browser test proves offline mutation, reload, reconnect replay, duplicate delivery, rejection, conflict, schema mismatch, and final convergence through public hemx APIs. [north_star]
|
||||||
|
|
||||||
|
024 Sync channel names must contain 1..=128 ASCII alphanumeric, colon, underscore, hyphen, or period bytes. [north_star]
|
||||||
|
|
||||||
|
025 Flat patch identifiers must contain 1..=128 bytes from the sync-channel character set. [north_star]
|
||||||
|
|
||||||
|
026 Flat patch keys must contain 1..=64 bytes, begin with an ASCII letter, continue with ASCII alphanumeric, underscore, or hyphen bytes, and exclude reserved keys. [north_star]
|
||||||
|
|
||||||
|
027 Flat patch string values must not exceed 4,096 bytes, and integer values must remain within JavaScript's safe integer range. [north_star]
|
||||||
|
|
||||||
|
028 Flat patch serialization must produce valid JSON with escaped string values. [north_star]
|
||||||
|
|
||||||
|
029 Flat patch deserialization must reject unknown fields, unsupported schemas, and invalid identifiers, keys, or values. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## local
|
## local
|
||||||
@@ -680,6 +700,10 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
019 Repo-owned process-backed tests use one RAII harness that waits for TCP readiness, reports early exit or timeout with the process label and address, and always reaps the child. [north_star]
|
019 Repo-owned process-backed tests use one RAII harness that waits for TCP readiness, reports early exit or timeout with the process label and address, and always reaps the child. [north_star]
|
||||||
|
|
||||||
|
020 Full local release verification must mutation-test each mutation-applicable Rust library through its package-native test targets. [north_star]
|
||||||
|
|
||||||
|
021 Unexplained missed mutants must block release; equivalent, invariant-only, and infrastructure-inapplicable mutants must be explicitly classified. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## check
|
## check
|
||||||
@@ -742,6 +766,16 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
014 Client-local behavior has a browser-level proof using generated resources, a real WASM artifact, zero app-authored JavaScript, and network instrumentation that distinguishes local from server execution. [north_star]
|
014 Client-local behavior has a browser-level proof using generated resources, a real WASM artifact, zero app-authored JavaScript, and network instrumentation that distinguishes local from server execution. [north_star]
|
||||||
|
|
||||||
|
015 The client-local boundary must reject an empty event kind or an event kind over 256 UTF-8 bytes before application code runs. [north_star]
|
||||||
|
|
||||||
|
016 The client-local boundary must reject an optional value over 65,536 UTF-8 bytes before application code runs. [north_star]
|
||||||
|
|
||||||
|
017 The client-local boundary must reject an optional key over 1,024 UTF-8 bytes before application code runs. [north_star]
|
||||||
|
|
||||||
|
018 The client-local boundary must reject encoded state over 1,048,576 UTF-8 bytes before application code runs. [north_star]
|
||||||
|
|
||||||
|
019 The client-local boundary must accept only event ABI version 1 and state ABI version 1 before application code runs. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## async_data
|
## async_data
|
||||||
@@ -826,7 +860,7 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
## ts
|
## ts
|
||||||
|
|
||||||
001 TypeScript definitions for `hemx-js` runtime are shipped as a single `.d.ts` file. Types mirror the postcard `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
|
001 TypeScript definitions for `hemx-js` runtime are shipped as a single `.d.ts` file. Types mirror the canonical `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: p
|
|||||||
- Production reference: authenticated mutation, origin/CSRF denial, atomic
|
- Production reference: authenticated mutation, origin/CSRF denial, atomic
|
||||||
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
|
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
|
||||||
metrics, CSP, and mixed-build fail-closed recovery proven.
|
metrics, CSP, and mixed-build fail-closed recovery proven.
|
||||||
- V1 closure matrix: local workspace, browser, performance, docs, and example
|
- V1 closure matrix: not closed. The recorded local workspace, browser,
|
||||||
gates pass. Warning-denied vulnerability and source audits are clean; strict
|
performance, docs, and example gates pass, and warning-denied vulnerability
|
||||||
license closure awaits a repository license allowlist.
|
and source audits are clean. Strict license closure still awaits a repository
|
||||||
|
license allowlist, and the mutation-testing release gate is only partially
|
||||||
|
classified. These open gates block a feature-complete or production-ready
|
||||||
|
claim. req: test/020 req: test/021 req: v1_release/006
|
||||||
- Publishing and deployment: explicitly unauthorized.
|
- Publishing and deployment: explicitly unauthorized.
|
||||||
|
|
||||||
## Baseline evidence
|
## Baseline evidence
|
||||||
@@ -176,7 +179,7 @@ cargo test -p hemx-derive --test compile_fail
|
|||||||
cargo test -p hemx-test --test examples_contract
|
cargo test -p hemx-test --test examples_contract
|
||||||
redgate list
|
redgate list
|
||||||
redgate refs
|
redgate refs
|
||||||
redgate health --strict
|
redgate health
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+86
-30
@@ -1485,23 +1485,20 @@ fn base64_url_no_pad(input: &[u8]) -> String {
|
|||||||
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
|
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
|
||||||
let mut chunks = input.chunks_exact(3);
|
let mut chunks = input.chunks_exact(3);
|
||||||
for chunk in &mut chunks {
|
for chunk in &mut chunks {
|
||||||
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
|
out.push(ALPHABET[(chunk[0] >> 2) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char);
|
||||||
out.push(ALPHABET[(n & 0x3f) as usize] as char);
|
|
||||||
}
|
}
|
||||||
match chunks.remainder() {
|
match chunks.remainder() {
|
||||||
[a] => {
|
[a] => {
|
||||||
let n = (*a as u32) << 16;
|
out.push(ALPHABET[(a >> 2) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
|
||||||
}
|
}
|
||||||
[a, b] => {
|
[a, b] => {
|
||||||
let n = ((*a as u32) << 16) | ((*b as u32) << 8);
|
out.push(ALPHABET[(a >> 2) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
out.push(ALPHABET[((b & 0x0f) << 2) as usize] as char);
|
||||||
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
|
|
||||||
}
|
}
|
||||||
[] => {}
|
[] => {}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
@@ -1528,24 +1525,22 @@ fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, Interact
|
|||||||
|
|
||||||
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
||||||
let mut out = Vec::with_capacity(input.len());
|
let mut out = Vec::with_capacity(input.len());
|
||||||
let mut i = 0;
|
let mut bytes = input.iter().copied();
|
||||||
while i < input.len() {
|
while let Some(byte) = bytes.next() {
|
||||||
match input[i] {
|
match byte {
|
||||||
b'+' => {
|
b'+' => out.push(b' '),
|
||||||
out.push(b' ');
|
b'%' => {
|
||||||
i += 1;
|
let high = bytes
|
||||||
}
|
.next()
|
||||||
b'%' if i + 2 < input.len() => {
|
.and_then(hex)
|
||||||
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?;
|
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?;
|
let low = bytes
|
||||||
out.push((high << 4) | low);
|
.next()
|
||||||
i += 3;
|
.and_then(hex)
|
||||||
}
|
.ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
b'%' => return Err(InteractionFormRejection::InvalidBody),
|
out.push(high * 16 + low);
|
||||||
byte => {
|
|
||||||
out.push(byte);
|
|
||||||
i += 1;
|
|
||||||
}
|
}
|
||||||
|
byte => out.push(byte),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
||||||
@@ -1563,7 +1558,8 @@ fn hex(byte: u8) -> Option<u8> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
HEMX_SSE_EVENT,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -1615,6 +1611,65 @@ mod tests {
|
|||||||
assert_eq!(root.text().collect::<String>(), "Docs");
|
assert_eq!(root.text().collect::<String>(), "Docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[tokio::test]
|
||||||
async fn sse_response_streams_base64_url_effect_batches() {
|
async fn sse_response_streams_base64_url_effect_batches() {
|
||||||
let batch = EffectBatch {
|
let batch = EffectBatch {
|
||||||
@@ -1623,6 +1678,7 @@ mod tests {
|
|||||||
ops: Vec::new(),
|
ops: Vec::new(),
|
||||||
};
|
};
|
||||||
let encoded = encode_sse_batch(&batch);
|
let encoded = encode_sse_batch(&batch);
|
||||||
|
assert_eq!(encoded, "SEVNWAEAAAALAAAAAAAAAAAAAAA");
|
||||||
|
|
||||||
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
|
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2325,6 +2325,134 @@ fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_contract_fingerprint_and_client_bootstrap_are_deterministic() {
|
||||||
|
let resource = |symbol: &str, component: &str, id| Resource {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
ident: symbol.into(),
|
||||||
|
component: component.into(),
|
||||||
|
keyed: false,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
let mut resources = Resources::default();
|
||||||
|
resources
|
||||||
|
.slots
|
||||||
|
.insert("slot".into(), resource("slot", "page", 11));
|
||||||
|
resources
|
||||||
|
.handles
|
||||||
|
.insert("save".into(), resource("save", "page", 12));
|
||||||
|
resources
|
||||||
|
.atoms
|
||||||
|
.insert("count".into(), resource("count", "page", 13));
|
||||||
|
resources.forms.insert(
|
||||||
|
"profile".into(),
|
||||||
|
FormResource {
|
||||||
|
resource: resource("profile", "page", 14),
|
||||||
|
controls: vec![
|
||||||
|
GeneratedControl {
|
||||||
|
name: "email".into(),
|
||||||
|
kind: ControlKind::Text,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
GeneratedControl {
|
||||||
|
name: "nickname".into(),
|
||||||
|
kind: ControlKind::Text,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let expected_parts = vec![
|
||||||
|
SURFACE_SCHEMA_VERSION,
|
||||||
|
EFFECT_BATCH_ABI_VERSION,
|
||||||
|
RUNTIME_ABI_VERSION,
|
||||||
|
0,
|
||||||
|
11,
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
3,
|
||||||
|
13,
|
||||||
|
2,
|
||||||
|
14,
|
||||||
|
2,
|
||||||
|
stable_id("form-field", "email"),
|
||||||
|
1,
|
||||||
|
stable_id("form-field", "nickname"),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
assert_eq!(resources.fingerprint_parts(), expected_parts);
|
||||||
|
|
||||||
|
let generated = resources.generated_rs(false);
|
||||||
|
assert!(generated.starts_with("// @generated by hemx-build. Do not edit.\n"));
|
||||||
|
assert!(generated.contains("pub const BUILD_FINGERPRINT"));
|
||||||
|
assert!(generated.contains(
|
||||||
|
"\n#[allow(non_upper_case_globals)]\npub mod page {\n #[derive(Clone, Copy)]\n pub struct Component;\n"
|
||||||
|
));
|
||||||
|
assert!(generated.contains("pub const slot: SlotTarget"));
|
||||||
|
assert!(generated.contains("pub const save: ::hemx::Handle"));
|
||||||
|
assert!(generated.contains("pub const count: ::hemx::Atom"));
|
||||||
|
assert!(generated.contains("pub const profile: ::hemx::Form"));
|
||||||
|
assert!(generated.contains("\n pub mod targets {\n"));
|
||||||
|
|
||||||
|
let generated_with_globals = resources.generated_rs(true);
|
||||||
|
assert!(generated_with_globals.contains("pub mod advanced {"));
|
||||||
|
assert!(generated_with_globals.contains("pub mod slots {"));
|
||||||
|
|
||||||
|
assert_eq!(Resources::default().client_bootstrap().unwrap(), "");
|
||||||
|
resources
|
||||||
|
.client_handlers
|
||||||
|
.extend(["save".into(), "toggle".into()]);
|
||||||
|
assert_eq!(
|
||||||
|
resources.client_bootstrap().unwrap_err().to_string(),
|
||||||
|
"client-local handlers require one data-hemx-client-module on a hemx root"
|
||||||
|
);
|
||||||
|
resources.client_modules.insert("/app.wasm".into());
|
||||||
|
let bootstrap = resources.client_bootstrap().unwrap();
|
||||||
|
assert!(bootstrap.starts_with(
|
||||||
|
"import init, { __hemx_client_save, __hemx_client_toggle } from \"/app.wasm\";\nawait init();\n"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
bootstrap.contains("window.hemx.registerClientHandler(\"save\", __hemx_client_save);")
|
||||||
|
);
|
||||||
|
assert!(bootstrap.contains("data-hemx-client-ready"));
|
||||||
|
resources.client_modules.insert("/other.wasm".into());
|
||||||
|
assert_eq!(
|
||||||
|
resources.client_bootstrap().unwrap_err().to_string(),
|
||||||
|
"client-local handlers must share one data-hemx-client-module per generated application"
|
||||||
|
);
|
||||||
|
// test req: build/007 req: abi/003 req: client_local/011
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_builder_returns_output_errors_instead_of_panicking() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"hemx-build-error-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().name().unwrap_or("test")
|
||||||
|
));
|
||||||
|
let out = root.join("out");
|
||||||
|
std::fs::create_dir_all(out.join("hemx.generated.rs")).unwrap();
|
||||||
|
|
||||||
|
let result = std::panic::catch_unwind(|| app().out_dir(&out).run());
|
||||||
|
assert!(result.is_ok(), "ordinary output errors must not panic");
|
||||||
|
assert!(result.unwrap().is_err());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(out.join("hemx.generated.rs")).unwrap();
|
||||||
|
std::fs::create_dir_all(out.join("hemx.syms")).unwrap();
|
||||||
|
let result = std::panic::catch_unwind(|| app().out_dir(&out).run());
|
||||||
|
assert!(result.is_ok(), "symbol output errors must not panic");
|
||||||
|
assert!(result.unwrap().is_err());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(out.join("hemx.syms")).unwrap();
|
||||||
|
std::fs::create_dir_all(out.join("hemx.client.js")).unwrap();
|
||||||
|
let result = std::panic::catch_unwind(|| app().out_dir(&out).run());
|
||||||
|
assert!(result.is_ok(), "client output errors must not panic");
|
||||||
|
assert!(result.unwrap().is_err());
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
// test req: build/004
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_op_build_preserves_generated_artifact_timestamps() {
|
fn no_op_build_preserves_generated_artifact_timestamps() {
|
||||||
// req: build/009
|
// req: build/009
|
||||||
|
|||||||
+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 struct EffectBatch {
|
||||||
pub abi_version: u32,
|
pub abi_version: u32,
|
||||||
pub fingerprint: BuildFingerprint,
|
pub fingerprint: BuildFingerprint,
|
||||||
@@ -459,6 +459,13 @@ pub struct EffectBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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`].
|
/// Return the exact number of bytes produced by [`Self::to_wire`].
|
||||||
pub fn encoded_len(&self) -> usize {
|
pub fn encoded_len(&self) -> usize {
|
||||||
batch_wire_len(self)
|
batch_wire_len(self)
|
||||||
@@ -478,14 +485,6 @@ impl EffectBatch {
|
|||||||
read_batch(bytes)
|
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 {
|
pub const fn is_compatible(&self) -> bool {
|
||||||
self.abi_version == EFFECT_BATCH_ABI_VERSION
|
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> {
|
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
|
||||||
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?;
|
let remaining = &self.bytes[self.offset..];
|
||||||
if end > self.bytes.len() {
|
let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
|
||||||
return Err(WireError::Truncated);
|
self.offset += len;
|
||||||
}
|
|
||||||
let bytes = &self.bytes[self.offset..end];
|
|
||||||
self.offset = end;
|
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-21
@@ -1,8 +1,8 @@
|
|||||||
use hemx_core::{
|
use hemx_core::{
|
||||||
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
|
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
|
||||||
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
|
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
|
||||||
NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey,
|
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
|
||||||
ScrollBehavior, Slot,
|
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -53,47 +53,70 @@ fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encoded_len_covers_every_effect_shape() {
|
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
|
||||||
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1));
|
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
|
||||||
let scoped = ResourceRef::scoped(
|
let atom = ResourceRef::scoped(
|
||||||
ResourceId::new(ResourceKind::Form, 2),
|
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")),
|
ScopeKey::Field(String::from("email")),
|
||||||
);
|
);
|
||||||
|
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
|
||||||
let batch = EffectBatch {
|
let batch = EffectBatch {
|
||||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||||
fingerprint: BuildFingerprint(42),
|
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
|
||||||
ops: vec![
|
ops: vec![
|
||||||
Effect::Put {
|
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>")),
|
payload: Payload::Html(String::from("<p>safe</p>")),
|
||||||
},
|
},
|
||||||
Effect::Insert {
|
Effect::Insert {
|
||||||
target: scoped.clone(),
|
target: atom.clone(),
|
||||||
key: String::from("insert"),
|
key: String::from("insert"),
|
||||||
payload: Payload::Text(String::from("one")),
|
payload: Payload::Text(String::from("one")),
|
||||||
},
|
},
|
||||||
Effect::Prepend {
|
Effect::Prepend {
|
||||||
target: scoped.clone(),
|
target: form.clone(),
|
||||||
key: String::from("prepend"),
|
key: String::from("prepend"),
|
||||||
payload: Payload::Text(String::from("two")),
|
payload: Payload::Html(String::from("two")),
|
||||||
},
|
},
|
||||||
Effect::Remove {
|
Effect::Remove {
|
||||||
target: scoped.clone(),
|
target: slot.clone(),
|
||||||
|
key: None,
|
||||||
|
},
|
||||||
|
Effect::Remove {
|
||||||
|
target: atom,
|
||||||
key: Some(String::from("remove")),
|
key: Some(String::from("remove")),
|
||||||
},
|
},
|
||||||
Effect::Move {
|
Effect::Move {
|
||||||
target: scoped.clone(),
|
target: handle.clone(),
|
||||||
key: String::from("move"),
|
key: String::from("move"),
|
||||||
before: Some(String::from("before")),
|
before: Some(String::from("before")),
|
||||||
},
|
},
|
||||||
Effect::Focus {
|
Effect::Focus { target: form },
|
||||||
target: scoped.clone(),
|
Effect::Navigate {
|
||||||
|
url: String::from("/push"),
|
||||||
|
mode: NavigateMode::Push,
|
||||||
|
scroll: ScrollBehavior::Preserve,
|
||||||
|
title: None,
|
||||||
},
|
},
|
||||||
Effect::Navigate {
|
Effect::Navigate {
|
||||||
url: String::from("/next"),
|
url: String::from("/replace"),
|
||||||
mode: NavigateMode::Replace,
|
mode: NavigateMode::Replace,
|
||||||
scroll: ScrollBehavior::Element(unscoped),
|
scroll: ScrollBehavior::Top,
|
||||||
title: Some(String::from("Next")),
|
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 {
|
Effect::Emit {
|
||||||
name: String::from("notice"),
|
name: String::from("notice"),
|
||||||
@@ -103,8 +126,90 @@ fn encoded_len_covers_every_effect_shape() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let bytes = batch.to_wire();
|
let bytes = batch.to_wire();
|
||||||
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007
|
assert_eq!(&bytes[..4], b"HEMX");
|
||||||
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch);
|
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]
|
#[test]
|
||||||
@@ -196,6 +301,93 @@ fn generated_form_helpers_target_form_fields() {
|
|||||||
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
|
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]
|
#[test]
|
||||||
fn slot_html_requires_explicit_safe_html() {
|
fn slot_html_requires_explicit_safe_html() {
|
||||||
let content = Slot::<String>::new(10);
|
let content = Slot::<String>::new(10);
|
||||||
@@ -216,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_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]
|
#[test]
|
||||||
@@ -223,6 +417,7 @@ fn param_names_format_generated_param_names() {
|
|||||||
// req: codegen/003
|
// req: codegen/003
|
||||||
let param = ParamName::new("todo_id");
|
let param = ParamName::new("todo_id");
|
||||||
assert_eq!(param.as_str(), "todo_id");
|
assert_eq!(param.as_str(), "todo_id");
|
||||||
|
assert_eq!(param.as_ref(), "todo_id");
|
||||||
assert_eq!(param.to_string(), "todo_id");
|
assert_eq!(param.to_string(), "todo_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +426,7 @@ fn component_refs_format_generated_component_names() {
|
|||||||
// req: component/003
|
// req: component/003
|
||||||
let component = ComponentRef::new("todo_list");
|
let component = ComponentRef::new("todo_list");
|
||||||
assert_eq!(component.as_str(), "todo_list");
|
assert_eq!(component.as_str(), "todo_list");
|
||||||
|
assert_eq!(component.as_ref(), "todo_list");
|
||||||
assert_eq!(component.to_string(), "todo_list");
|
assert_eq!(component.to_string(), "todo_list");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,4 +497,47 @@ fn build_fingerprint_is_deterministic_from_abi_parts() {
|
|||||||
|
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
assert_ne!(a, c);
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-2
@@ -985,12 +985,73 @@ fn compile_error(message: &str) -> TokenStream {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
add_app_registry_helper, add_component_register_helper, component_handler_names,
|
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,
|
form_model_type, handle_params, handle_requires_form, handler_form_model_type,
|
||||||
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle,
|
has_form_param, has_non_unit_return, missing_component_handlers, missing_handle_params,
|
||||||
|
parser_type, returns_result, syms_contains_handle,
|
||||||
};
|
};
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{parse_quote, ItemFn, Type};
|
use syn::{parse_quote, ItemFn, Type};
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn syms_lookup_matches_handle_ident() {
|
fn syms_lookup_matches_handle_ident() {
|
||||||
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
|
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
|
||||||
|
|||||||
@@ -1031,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 {
|
fn check_fixture(fixture: &Fixture) -> std::process::Output {
|
||||||
Command::new("cargo")
|
Command::new("cargo")
|
||||||
.arg("check")
|
.arg("check")
|
||||||
|
|||||||
+93
-1
@@ -468,6 +468,28 @@ mod tests {
|
|||||||
native_shell_host_profile("ios-android-webview-test")
|
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]
|
#[test]
|
||||||
fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() {
|
fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() {
|
||||||
// req: host/001 req: host/002 req: host/005
|
// req: host/001 req: host/002 req: host/005
|
||||||
@@ -509,6 +531,37 @@ mod tests {
|
|||||||
assert_eq!(manifest.check(&web_host()), Ok(()));
|
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]
|
#[test]
|
||||||
fn host_calls_must_be_declared_and_supported() {
|
fn host_calls_must_be_declared_and_supported() {
|
||||||
// req: host/001 req: host/004
|
// req: host/001 req: host/004
|
||||||
@@ -516,12 +569,38 @@ mod tests {
|
|||||||
Capability::Share,
|
Capability::Share,
|
||||||
CapabilityShape::Request,
|
CapabilityShape::Request,
|
||||||
)]);
|
)]);
|
||||||
|
let payload = SharePayload::text("log");
|
||||||
|
assert_eq!(
|
||||||
|
payload,
|
||||||
|
SharePayload {
|
||||||
|
title: None,
|
||||||
|
text: Some("log".into()),
|
||||||
|
url: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
let call = HostCall::Share {
|
let call = HostCall::Share {
|
||||||
id: HostCallId::new("share-1"),
|
id: HostCallId::new("share-1"),
|
||||||
payload: SharePayload::text("log"),
|
payload,
|
||||||
};
|
};
|
||||||
assert_eq!(manifest.validate_call(&web_host(), &call), Ok(()));
|
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 {
|
let haptic = HostCall::Haptic {
|
||||||
id: HostCallId::new("tap"),
|
id: HostCallId::new("tap"),
|
||||||
pattern: HapticPattern::Success,
|
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]
|
#[test]
|
||||||
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
|
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
|
||||||
// req: host/001 req: host/002 req: host/005
|
// req: host/001 req: host/002 req: host/005
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn runtime_exposes_debug_api_before_startup_side_effects() {
|
fn runtime_exposes_debug_api_before_startup_side_effects() {
|
||||||
// req: runtime/002
|
// req: runtime/002
|
||||||
|
|||||||
+110
-25
@@ -1,55 +1,52 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
|
use proc_macro2::TokenStream as TokenStream2;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{parse_macro_input, Error, FnArg, ItemFn, Pat, ReturnType};
|
use syn::{Error, FnArg, ItemFn, Pat, ReturnType};
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
|
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() {
|
if !attributes.is_empty() {
|
||||||
return Error::new(
|
return Err(Error::new(
|
||||||
proc_macro2::Span::call_site(),
|
proc_macro2::Span::call_site(),
|
||||||
"#[hemx_sync::presence] does not accept arguments",
|
"#[hemx_sync::presence] does not accept arguments",
|
||||||
)
|
));
|
||||||
.to_compile_error()
|
|
||||||
.into();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut function = parse_macro_input!(item as ItemFn);
|
let mut function: ItemFn = syn::parse2(item)?;
|
||||||
if function.sig.asyncness.is_some() {
|
if let Some(asyncness) = &function.sig.asyncness {
|
||||||
return Error::new_spanned(
|
return Err(Error::new_spanned(
|
||||||
function.sig.asyncness,
|
asyncness,
|
||||||
"presence projections must be synchronous",
|
"presence projections must be synchronous",
|
||||||
)
|
));
|
||||||
.to_compile_error()
|
|
||||||
.into();
|
|
||||||
}
|
}
|
||||||
if matches!(function.sig.output, ReturnType::Default) {
|
if matches!(function.sig.output, ReturnType::Default) {
|
||||||
return Error::new_spanned(
|
return Err(Error::new_spanned(
|
||||||
&function.sig,
|
&function.sig,
|
||||||
"presence projections must return impl IntoEffect",
|
"presence projections must return impl IntoEffect",
|
||||||
)
|
));
|
||||||
.to_compile_error()
|
|
||||||
.into();
|
|
||||||
}
|
}
|
||||||
let argument = match function.sig.inputs.first() {
|
let argument = match function.sig.inputs.first() {
|
||||||
Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument,
|
Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument,
|
||||||
_ => {
|
_ => {
|
||||||
return Error::new_spanned(
|
return Err(Error::new_spanned(
|
||||||
&function.sig.inputs,
|
&function.sig.inputs,
|
||||||
"presence projections require exactly one typed presence argument",
|
"presence projections require exactly one typed presence argument",
|
||||||
)
|
));
|
||||||
.to_compile_error()
|
|
||||||
.into();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let argument_name = match argument.pat.as_ref() {
|
let argument_name = match argument.pat.as_ref() {
|
||||||
Pat::Ident(argument) => argument.ident.clone(),
|
Pat::Ident(argument) => argument.ident.clone(),
|
||||||
pattern => {
|
pattern => {
|
||||||
return Error::new_spanned(
|
return Err(Error::new_spanned(
|
||||||
pattern,
|
pattern,
|
||||||
"presence projection argument must be a simple identifier",
|
"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)();
|
let __hemx_sync_effect = (|| #body)();
|
||||||
::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect)
|
::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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-4
@@ -411,7 +411,6 @@ impl SyncEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_patch(patch: FlatPatch) -> Self {
|
pub fn send_patch(patch: FlatPatch) -> Self {
|
||||||
patch.validate().expect("FlatPatch must remain valid");
|
|
||||||
Self(vec![Effect::Emit {
|
Self(vec![Effect::Emit {
|
||||||
name: PATCH_EVENT.to_owned(),
|
name: PATCH_EVENT.to_owned(),
|
||||||
payload: patch.payload(),
|
payload: patch.payload(),
|
||||||
@@ -426,7 +425,6 @@ impl SyncEffect {
|
|||||||
projection: impl IntoEffect,
|
projection: impl IntoEffect,
|
||||||
fingerprint: BuildFingerprint,
|
fingerprint: BuildFingerprint,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
patch.validate().expect("FlatPatch must remain valid");
|
|
||||||
let projection = projection.into_batch(fingerprint);
|
let projection = projection.into_batch(fingerprint);
|
||||||
let projection_wire = projection
|
let projection_wire = projection
|
||||||
.to_wire()
|
.to_wire()
|
||||||
@@ -469,9 +467,30 @@ mod tests {
|
|||||||
.into_batch(hemx_core::BuildFingerprint(7));
|
.into_batch(hemx_core::BuildFingerprint(7));
|
||||||
assert_eq!(batch.ops.len(), 2);
|
assert_eq!(batch.ops.len(), 2);
|
||||||
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
|
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
|
||||||
assert!(
|
let Effect::Emit { name, payload } = &batch.ops[1] else {
|
||||||
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
|
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]
|
#[test]
|
||||||
@@ -549,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]
|
#[test]
|
||||||
fn schema_is_flat_and_rejects_reserved_keys() {
|
fn schema_is_flat_and_rejects_reserved_keys() {
|
||||||
let patch = FlatPatch::new(
|
let patch = FlatPatch::new(
|
||||||
|
|||||||
@@ -420,10 +420,11 @@ pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
|
|||||||
|
|
||||||
fn assert_simple_selector_part(value: &str, label: &str) {
|
fn assert_simple_selector_part(value: &str, label: &str) {
|
||||||
assert!(
|
assert!(
|
||||||
value
|
!value.is_empty()
|
||||||
|
&& value
|
||||||
.chars()
|
.chars()
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||||
"{label} selector part must contain only ascii alphanumerics or '-'"
|
"{label} selector part must contain one or more ascii alphanumerics or '-'"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,8 +438,7 @@ fn runtime_resource_attr(kind: ResourceKind) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_attr_marker(name: &str, value: &str) -> String {
|
fn runtime_attr_marker(name: &str, value: &str) -> String {
|
||||||
let escaped = value.replace('"', """);
|
format!(r#"{name}="{value}""#)
|
||||||
format!(r#"{name}="{escaped}""#)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attr_selector(name: &str, value: &str) -> String {
|
fn attr_selector(name: &str, value: &str) -> String {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use hemx_core::{
|
use hemx_core::{
|
||||||
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
|
Atom, Effect, Form, GeneratedTarget, KeyedSlot, NavigateMode, Payload, ResourceId,
|
||||||
|
ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -84,6 +85,201 @@ fn finds_keyed_slot_targets() {
|
|||||||
assert_eq!(inspected.ops().len(), 1);
|
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!(std::panic::catch_unwind(|| hemx_test::class_selector(invalid)).is_err());
|
||||||
|
}
|
||||||
|
for call in [
|
||||||
|
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
|
||||||
|
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
|
||||||
|
] {
|
||||||
|
assert!(call.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_generated_handle_form_bodies() {
|
fn builds_generated_handle_form_bodies() {
|
||||||
let handle = hemx_core::Handle::<()>::new(7);
|
let handle = hemx_core::Handle::<()>::new(7);
|
||||||
@@ -91,6 +287,10 @@ fn builds_generated_handle_form_bodies() {
|
|||||||
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
|
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!(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");
|
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,13 +389,15 @@ fn builds_authoring_boundary_selectors() {
|
|||||||
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
||||||
|
|
||||||
let probe = hemx_test::island_probe_script(
|
let probe = hemx_test::island_probe_script(
|
||||||
"probe-island",
|
"probe-\"island",
|
||||||
"orbit",
|
"orbit\\bridge",
|
||||||
"1|1|1|probe waiting",
|
"line1\nline2\rline3\tend",
|
||||||
"7|2|8|probe live",
|
"detail \"quoted\"",
|
||||||
);
|
);
|
||||||
assert!(probe.contains("probe-island"));
|
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
|
||||||
assert!(probe.contains("probe live"));
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use hemx_test::TestProcess;
|
use hemx_test::TestProcess;
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_harness_reports_early_exit_with_context() {
|
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}");
|
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]
|
#[test]
|
||||||
fn helper_process_exits_successfully() {}
|
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.
|
/// Validates primitive wasm-bindgen values before application code runs.
|
||||||
///
|
///
|
||||||
/// Primitive arguments keep JavaScript from owning a second binary codec. The
|
/// Primitive arguments keep JavaScript from owning a second event/state codec.
|
||||||
/// ordinary effect result remains postcard-encoded by `hemx-core`.
|
/// The ordinary effect result uses `hemx-core`'s canonical `EffectBatch` codec.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn decode_client_inputs(
|
pub fn decode_client_inputs(
|
||||||
@@ -149,6 +149,102 @@ mod tests {
|
|||||||
); // req: client_local/008
|
); // 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]
|
#[test]
|
||||||
fn client_handler_uses_the_ordinary_effect_wire_format() {
|
fn client_handler_uses_the_ordinary_effect_wire_format() {
|
||||||
let fingerprint = BuildFingerprint(17);
|
let fingerprint = BuildFingerprint(17);
|
||||||
|
|||||||
+11
-2
@@ -93,8 +93,7 @@ pub mod __private {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn render_template(view: &impl hemplate::Hemplate) -> Html {
|
fn render_template(view: &impl hemplate::Hemplate) -> Html {
|
||||||
let mut html = String::with_capacity(view.size_hint());
|
let mut html = String::with_capacity(view.size_hint());
|
||||||
view.render_into(&mut html)
|
view.render_into(&mut html).unwrap();
|
||||||
.expect("hemplate view renders into hemx effect payload");
|
|
||||||
__private::html_trusted(html)
|
__private::html_trusted(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,4 +260,14 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
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