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:
slhx agent
2026-07-16 22:27:28 +02:00
parent e7211df4c5
commit e9ced4e0c1
19 changed files with 1529 additions and 198 deletions
+3 -3
View File
@@ -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
+31 -70
View File
File diff suppressed because one or more lines are too long
+39 -5
View File
@@ -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.
--- ---
+7 -4
View File
@@ -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
View File
@@ -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!(
+128
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+39
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+4 -4
View File
@@ -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('"', "&quot;"); format!(r#"{name}="{value}""#)
format!(r#"{name}="{escaped}""#)
} }
fn attr_selector(name: &str, value: &str) -> String { fn attr_selector(name: &str, value: &str) -> String {
+209 -7
View File
@@ -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)]
+86 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
} }