Compare commits

...

59 Commits

Author SHA1 Message Date
slhx agent da28db9775 feat(runtime): support viewport-ahead reveals 2026-08-02 22:06:18 +02:00
slhx agent c530dc5d8f fix(build): lower rendered loop keys
req: list/002
2026-07-27 20:02:32 +02:00
slhx agent 2dc7703936 chore(repo): save local work 2026-07-27 13:23:56 +02:00
slhx agent 276f40ee59 refactor(saas): make navigation and status idiomatic
Use a real enhanceable Settings URL, remove response-simulated navigation, and replace the one-shot public SSE endpoint with an initial canonical event plus ongoing server-owned updates. Keep a bounded one-event probe for production-reference tests.

req: examples/014

req: nav/001

req: nav/002

req: nav/004

req: push/003
2026-07-22 18:18:48 +02:00
slhx agent c4a6edfed1 test(runtime): pin inserted trigger binding
req: runtime/007
2026-07-19 23:12:35 +02:00
slhx agent 895572b735 docs(runtime): tighten dynamic binding proof
req: runtime/007
2026-07-19 23:00:28 +02:00
slhx agent 7c98de1e8c fix(runtime): bind inserted revealed triggers
req: runtime/007
2026-07-19 21:37:26 +02:00
slhx agent e7ca6d2350 test(wasm): close mutation package gate
Run browser-backed mutest sequentially without a parent jobserver, disable Firefox background work, batch replay projections while preserving ordered application, and elect a 250 ms budget for the durable 64-command browser fixture. All four hemx-wasm shards pass with 59 mutants and no survivors, closing the mutation matrix.

req: test/004

req: test/020

req: test/021

req: performance/005

req: performance/007

req: v1_release/006
2026-07-17 17:11:19 +02:00
slhx agent 0be05f4432 test(sync-macros): prove public presence expansion
Exercise the proc-macro entry point from a real consumer crate so item preservation and argument routing are mutation-verified; record the clean 33-mutant package gate.

req: sync/003

req: sync/005

req: test/020

req: test/021
2026-07-17 15:01:00 +02:00
slhx agent f182c1661d docs(plan): record clean sync mutation gate
Record the complete 164-mutant hemx-sync package proof and narrow the remaining mutation frontier to sync macros and wasm.

req: test/020

req: test/021
2026-07-17 14:58:48 +02:00
slhx agent 1ff6ae1de9 docs(plan): record clean host mutation gate
Record the complete 76-mutant hemx-host package proof and narrow the remaining mutation frontier to sync, sync macros, and wasm.

req: test/020

req: test/021
2026-07-17 14:56:46 +02:00
slhx agent 7dfbf104d3 docs(plan): correct mutation package frontier
Classify binary-only hemx-lsp outside the elected library/proc-macro mutation set, record the clean hemx package gate, and name the four actual remaining package gates.

req: test/020

req: test/021
2026-07-17 14:55:07 +02:00
slhx agent df9f8f16fb test(derive): close final mutation shard
Fail closed on malformed symbol lines, prove sorted deduplicated component discovery and component filtering, exclude ordinary functions from handler registration, and emit diagnostics through quoted tokens without string reparsing.

req: diagnostics/003

req: component/003

req: derive_handler/003

req: test/020

req: test/021

req: test/022

req: test/023
2026-07-17 14:52:09 +02:00
slhx agent 5d6486c22c test(derive): close seventh mutation shard
Prove all state, async, and Result registration combinations and the exact no-generated-component repair diagnostic.

req: component/003

req: derive_handler/003

req: test/020

req: test/021

req: test/022

req: test/023
2026-07-17 14:47:17 +02:00
slhx agent d1af300bd4 test(derive): close sixth mutation shard
Exercise malformed parameter manifests, handler patterns, app diagnostics, and registration guards; delete an unreachable empty-handler branch and narrowly classify quote-owned parse invariants.

req: derive_app/001

req: component/003

req: derive_handler/003

req: test/020

req: test/021

req: test/022

req: test/023
2026-07-17 14:40:56 +02:00
slhx agent 31c52698e3 test(derive): close fifth mutation shard
Fail closed when generated form field names cannot map to Rust identifiers and delete the redundant empty generated-parameter branch.

req: form/004

req: diagnostics/003

req: test/022

req: test/023
2026-07-17 14:32:48 +02:00
slhx agent 78a0d91223 test(derive): close fourth mutation shard
Prove generated form decoding for required, optional, and repeated controls plus bare, qualified, mismatched, and empty generic container boundaries.

req: form/004

req: diagnostics/003

req: test/022

req: test/023
2026-07-17 14:27:35 +02:00
slhx agent dd230b470e test(derive): close third mutation shard
Prove missing generated-symbol fallback and exact accumulated form diagnostics for unknown forms, unnamed structs, missing fields, singular/multiple mismatches, and required Option fields.

req: form/004

req: diagnostics/003

req: derive_handler/005

req: test/022

req: test/023
2026-07-17 14:20:29 +02:00
slhx agent a59e9b0bfe test(derive): close second mutation shard
Match public Form<T> paths with one unambiguous segment pattern and record clean hemx-derive shard 2/8 proof.

req: derive_handler/004

req: test/022

req: test/023
2026-07-17 14:11:55 +02:00
slhx agent 62b0787a8e test(derive): close first mutation shard
Create shard output parents, exercise exact handler placement/signature/codegen contracts, preserve generated-file and form-bound diagnostics, and fail closed on malformed form symbol manifests. Public shard 1/8 passes through both 16-way partitions.

req: derive_handler/001

req: client_local/001

req: form/004

req: diagnostics/003

req: test/022

req: test/023
2026-07-17 14:02:19 +02:00
slhx agent 8c79ea26bf test(build): close package mutation gate
Exercise every generated form-control variant through public code generation and record all eight deterministic hemx-build shards clean: 1,304 mutants, 1,070 caught and 234 unviable.

req: form/004

req: codegen/005

req: test/021

req: test/022

req: test/023
2026-07-17 12:26:43 +02:00
slhx agent 1ba2d22f15 docs(plan): record corrected shard seven proof
Record the clean one-based hemx-build mutation shard 7/8 and advance the deterministic package gate frontier to the final shard.

req: test/022

req: test/023
2026-07-17 12:08:45 +02:00
slhx agent becddd6004 docs(plan): record corrected shard six proof
Record the clean one-based hemx-build mutation shard 6/8 and advance the deterministic package gate frontier to shard 7/8.

req: test/022

req: test/023
2026-07-17 11:57:42 +02:00
slhx agent 91c50a35c6 docs(plan): record shard five partition proof
Record complete hemx-build shard 5/8 coverage through its two deterministic 16-way hash partitions after the aggregate run was interrupted.

req: test/022

req: test/023
2026-07-17 11:48:41 +02:00
slhx agent f3a2397631 docs(plan): record shard four partition proof
Record complete hemx-build shard 4/8 coverage through its two deterministic 16-way hash partitions after the aggregate run exceeded the command limit.

req: test/022

req: test/023
2026-07-17 10:08:43 +02:00
slhx agent fb4e317d15 docs(plan): record corrected shard three proof
Record the clean one-based hemx-build mutation shard 3/8 and advance the deterministic package gate frontier to shard 4/8.

req: test/022

req: test/023
2026-07-17 09:38:08 +02:00
slhx agent 03e081a11a docs(plan): record corrected shard two proof
Record the clean one-based hemx-build mutation shard 2/8 and advance the deterministic package gate frontier to shard 3/8.

req: test/022

req: test/023
2026-07-17 09:29:16 +02:00
slhx agent 5586f87f43 fix(xtask): map mutation shards correctly
Translate one-based public shard numbers to mutest's zero-based API, retain requested labels in output paths, and grant compiler-probe tests a 120-second per-mutant floor. Corrected hemx-build shard 1/8 now passes.

req: test/022

req: test/023
2026-07-17 09:07:50 +02:00
slhx agent fdae13b2b6 docs(plan): record hemx-build shard two proof
Record the passing deterministic 2/4 package mutation shard and retain shards 3/4 and 4/4 as the active gate frontier.

req: test/022

req: test/023
2026-07-17 08:28:04 +02:00
slhx agent fe03f17667 feat(xtask): shard package mutation gates
Accept validated native SHARD/TOTAL operands, isolate shard output directories, preserve the unsharded package gate, and prove hemx-build shard 1/4 through the public xtask entry point.

req: test/022

req: test/023
2026-07-17 08:14:03 +02:00
slhx agent 216465f779 test(build): close residual helper mutants
Prove compact Rust type and vector facts plus default template paths, alias collision suppression, data parameter prefixes, keyed descendants, and absent Cargo roots. Classify only hemplate's infallible surface parser seam.

req: diagnostics/006

req: diagnostics/004

req: codegen/005

req: test/021
2026-07-17 07:59:26 +02:00
slhx agent 14f16cba23 test(build): close Rust fact and surface mutants
Prove recursive and inline Rust struct discovery, ignored non-Rust/out-of-tree files, source/parse failures, atom and handle collision propagation, and missing form-fact defaults; classify only non-injectable directory-entry errors and syn's named-field invariant.

req: diagnostics/006

req: diagnostics/004

req: surface/008

req: test/021
2026-07-17 07:16:43 +02:00
slhx agent 05154a58ed test(build): close target and identifier mutants
Prove first-seen cross-kind generated-target ordering and deduplication, exact identifier/literal/canonical-symbol boundaries, invalid UTF-8 and component names, and resource insertion reuse/collisions; simplify CSS class normalization through the shared Rust identifier validator.

req: codegen/005

req: diagnostics/004

req: diagnostics/005

req: surface/008

req: test/021
2026-07-17 06:58:36 +02:00
slhx agent 31534a538b test(build): close authoring diagnostic mutants
Assert exact selector-target and unknown-attribute InvalidData diagnostics and prove invalid scope IDs fail closed in unkeyed-loop diagnostics.

req: locality/001

req: locality/002

req: htmx_equivalents/003

req: convention/009

req: diagnostics/002

req: test/021
2026-07-17 06:36:15 +02:00
slhx agent a6f287e477 test(build): close value and keyed-scope mutants
Assert every static attribute validator boundary, exact missing/empty/invalid diagnostics, and valid AppBuilder lowering. Prove nested keyed ancestry, sibling/invalid scope rejection, keyed resource lowering, and unkeyed nested resource failures.

req: convention/002

req: convention/003

req: convention/004

req: convention/005

req: convention/006

req: list/005

req: diagnostics/004

req: test/021
2026-07-17 06:31:38 +02:00
slhx agent 4e7d772311 test(build): close placement diagnostic mutants
Assert exact InvalidData diagnostics for navigation, boost, client-module, and SSE misplacement and prove each accepted placement through AppBuilder::run.

req: page_swap/001

req: page_swap/007

req: push/006

req: diagnostics/002

req: test/021
2026-07-17 06:07:04 +02:00
slhx agent a322e519d3 fix(build): fail closed on invalid loop locals
Recognize only one valid Rust identifier bound directly to a self vector field as a template local; reject malformed and non-vector bindings, preserve unknown element locals without fabricated fields, and deduplicate repeated locals.

req: diagnostics/006

req: surface/008

req: test/021
2026-07-17 05:57:29 +02:00
slhx agent 2e39fb17a1 fix(build): recognize exact Hemlate derives
Parse derive paths structurally so qualified Hemlate derives provide template context while similarly named derives fail closed. Prove hyphen/underscore template type mapping, empty and non-UTF-8 paths, Cargo-root lookup, Rust source failures, and derived field facts through the public inspection entry point.

req: diagnostics/006

req: surface/008

req: test/021
2026-07-17 05:45:03 +02:00
slhx agent 185cd18990 test(build): close stylesheet scanner mutants
Prove sorted, deduplicated CSS/SCSS class extraction across starts, compounds, delimiters, invalid candidates, and invalid stylesheet module names; simplify token scanning to one boundary loop and classify only deterministic non-terminating progress mutants.

req: style/001

req: diagnostics/004

req: test/021
2026-07-17 05:37:07 +02:00
slhx agent 4058d05f20 test(build): close app input collection mutants
Traverse inputs once, preserve deterministic HE/ML-before-stylesheet lowering, and prove nested discovery, explicit surfaces, OUT_DIR fallback, missing inputs, invalid UTF-8, permission failures, output failures, and stable collision ordering through AppBuilder::run.

req: build/003

req: build/004

req: build/009

req: diagnostics/004

req: test/021
2026-07-17 05:03:03 +02:00
slhx agent 2ba0be4d24 test(build): lock canonical generated contracts
Hash complete generated Rust, global exports, and symbol manifests across component, keyed, collision, duplicate-class/event, form, and parameter branches; simplify root component exports and remove impossible target-fallback bookkeeping.

req: codegen/005

req: codegen/006

req: build/009

req: test/021
2026-07-17 04:50:36 +02:00
slhx agent 72bce73e52 test(build): close resource extraction mutants
Prove fail-closed slot and handler-parameter diagnostics through AppBuilder, consolidate resource insertion around the stored entry, and lower validated runtime events without redundant fallible identifier conversion.

req: build/004

req: diagnostics/004

req: surface/008

req: test/021
2026-07-17 03:57:29 +02:00
slhx agent b28ffacacb test(build): prove class and form extraction
Assert exact generated symbol records for class, form, field, slot, atom, handle, and event resources; cover invalid and colliding class/form identifiers plus repeated compatible form metadata; and remove a redundant class-token conflict branch already owned by identifier validation.

req: style/001

req: form/001

req: diagnostics/004

req: build/009

req: test/021
2026-07-17 03:49:11 +02:00
slhx agent 9cdc91984a test(build): reject invalid client handler names
Drive AppBuilder through a rooted client module with a non-Rust handler name and assert the exact InvalidData diagnostic, closing the public invalid-value mutation seam.

req: build/004

req: client_local/011

req: diagnostics/004

req: test/021
2026-07-17 03:32:12 +02:00
slhx agent db5e4bf56c test(build): prove stale artifact refresh
Drive AppBuilder through a changed template and prove generated Rust and symbols replace stale handles. Tighten the internal write helper to return only the result its callers use and classify the filesystem-equivalent read-guard mutant.

req: build/009

req: test/021
2026-07-17 03:20:53 +02:00
slhx agent 8a21cad98e test(build): prove public inspection contracts
Exercise file and source diagnostics, generated targets, Hemlate-derived context facts, missing-file propagation, and invalid Rust context through hemx-build's public inspection API; classify only the currently infallible hemplate parser seam.

req: diagnostics/004

req: diagnostics/006

req: surface/008

req: test/021
2026-07-17 03:14:24 +02:00
slhx agent 1a9c090028 test(build): harden convention diagnostics
Exercise malformed static policy, client module/event/state, confirmation, SSE, delay, throttle, and mixed-event values so build-time diagnostics fail closed across the public declarative surface.

req: diagnostics/004

req: convention/006

req: client_local/011
2026-07-17 02:30:06 +02:00
slhx agent 89747c8c48 test(axum): close package mutation gate
Exercise every state, typed-form, async, and result registration family; replace fallible numeric headers with typed HeaderValue construction; and classify only validated equivalent multipart/header mutants.

req: axum_integration/003

req: derive_handler/005

req: multipart/003

req: test/020

req: test/021

req: test/022
2026-07-17 01:50:16 +02:00
slhx agent 8aeab5fca1 test(axum): close page edge-case mutants
Prove partial constructor defaults, missing multipart-boundary rejection, first-root fingerprint replacement, and unchanged non-root HTML through public and nearest-boundary paths.

req: page_swap/001

req: multipart/003

req: abi/005
2026-07-17 01:05:48 +02:00
slhx agent 52a32cfb1f test(axum): harden page and media extraction
Prove explicit partial header values, full-page body and fingerprint delivery, and case-insensitive urlencoded media types through public Axum extractors and responses.

req: page_swap/001

req: abi/005

req: security/003
2026-07-17 00:36:04 +02:00
slhx agent 0fe2376874 test(axum): prove response delivery boundary
Decode emitted effect bytes, assert dispatch rejection status and diagnostics, and verify the shared embedded runtime body through public IntoResponse paths.

req: axum_integration/003

req: axum_integration/005

req: failure/005

req: wire/007
2026-07-17 00:21:42 +02:00
slhx agent b0a8275b03 test(axum): prove registry dispatch boundary
Exercise registered sync and async handles through public dispatch_async, prove membership semantics, and serialize shared-boundary tests so mutation workers remain deterministic.

req: axum_integration/003

req: public_api/001
2026-07-17 00:17:24 +02:00
slhx agent e38489621b test(axum): harden form extraction boundary
Prove repeated and required form semantics, stable rejection diagnostics, media-type and body limits, multipart files, unnamed-part skipping, invalid UTF-8, and streaming failures through the public extractor.

req: form/002

req: form/004

req: failure/003

req: multipart/001

req: multipart/002

req: multipart/003
2026-07-17 00:12:45 +02:00
slhx agent dc7c82f7ee test(axum): prove page response semantics
Close the first hemx-axum mutation class by proving page-mode detection and response constructor fingerprint behavior through the public integration API.

req: page_swap/001

req: abi/005
2026-07-16 23:59:51 +02:00
slhx agent d74810db4b test(core): close package mutation gate
Classify only the two non-terminating const-loop mutations after deterministic fingerprint outputs are asserted, then prove the complete hemx-core package mutation-clean through xtask.

req: abi/003

req: test/020

req: test/021

req: test/022
2026-07-16 23:43:41 +02:00
slhx agent 2705099aed test(harness): close hemx-test mutation gate
Prove canonical wire inspection diagnostics and selector failures, classify only OS-process and invariant-only mutation seams, and make the package-native mutation command pass for the complete hemx-test crate.

req: test/001

req: test/019

req: test/020

req: test/021

req: test/022

req: wire/009
2026-07-16 23:10:12 +02:00
slhx agent 612a66725f feat(xtask): add package-native mutation gate
Expose a capped mutation command that selects only elected packages, rejects unknown input, propagates mutest failures, and keeps output under the workspace target directory.

req: test/020

req: test/021

req: test/022
2026-07-16 22:40:07 +02:00
slhx agent 5e555f4d93 test(build): close generated contract mutants
Exercise invalid client contracts through AppBuilder and simplify invariant-only generation paths so the focused build contract mutation set has no unexplained survivors.

req: build/004

req: build/007

req: client_local/011
2026-07-16 22:34:41 +02:00
slhx agent e9ced4e0c1 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
2026-07-16 22:27:28 +02:00
32 changed files with 4799 additions and 712 deletions
+58
View File
@@ -0,0 +1,58 @@
# Explicit infrastructure/invariant classifications for the package-native release gate.
# - test_process_try_wait: OS process-status failures cannot be injected portably.
# - test_process_poll_delay: poll cadence is operational; readiness and timeout are integration-proven.
# - Drop for TestProcess: mutating reaping leaks helper processes beyond the test lifecycle.
# - inspection_fingerprint: deliberately unobservable test-harness metadata.
# - BuildFingerprint::from_parts loop-progress mutations: syntactically valid but
# non-terminating const-loop mutants; deterministic hash outputs are asserted.
# - Infallible header parsing and multipart byte collection: adjacent public tests
# prove exact ETag/runtime headers and streamed multipart errors; unwrap mutants
# are behaviorally equivalent at these validated boundaries.
# - hemx-build source inspection delegates to hemplate's currently infallible
# Surface parser; file I/O and invalid Rust-context errors remain explicitly proven.
# The direct surface_for_heml_source unwrap mutant is equivalent for the same seam.
# - AppBuilder reuses that same parser seam. Directory-open and recursive errors are
# proven, while a per-entry readdir fault cannot be injected portably after a
# successful read_dir; its unwrap mutant is classified as infrastructure-only.
# - write_if_changed propagates non-NotFound read errors; for ordinary filesystem
# paths, attempting the same write returns the same OS error, so the guard mutant
# is externally equivalent while create/update/no-op behavior is mutation-proven.
# - stylesheet_class_tokens loop-progress mutants are deterministically
# non-terminating; sorted, deduplicated, boundary-aware outputs are asserted.
# - context path words are filtered non-empty before extracting their first char;
# `?` and `unwrap` are equivalent under that local iterator invariant.
# - Rust-fact named fields always carry identifiers by syn's type contract. Per-entry
# and recursive read_dir errors cannot be injected portably after the parent opens;
# parent-open, source-read, and parse failures remain explicitly proven.
# - Registry-helper syntax is emitted entirely from quote-owned static tokens. Its
# parse succeeds by construction; expect/unwrap and expect-message mutations are
# equivalent, while exact generated registration and public diagnostics are proven.
exclude_re = [
"test_process_try_wait",
"test_process_poll_delay",
"delete statement std::thread::sleep\\(Duration::from_millis\\(25\\)\\)",
"<impl Drop for TestProcess>::drop",
"inspection_fingerprint",
"replace \\+= with \\*= in BuildFingerprint::from_parts",
"replace 1 with 0 in BuildFingerprint::from_parts",
"replace field \\.bytes\\(\\) \\.await \\.map_err.* with field.bytes\\(\\).await.map_err.*unwrap\\(\\) in InteractionForm::parse_multipart",
"replace String::from_utf8.* with String::from_utf8.*unwrap\\(\\) in InteractionForm::parse_multipart",
"replace HeaderValue::from_str.*runtime_js_hash.* with HeaderValue::from_str.*unwrap\\(\\) in <impl IntoResponse for RuntimeJs>::into_response",
'replace "runtime hash is a valid ETag" with "" in <impl IntoResponse for RuntimeJs>::into_response',
"replace build_ast.* with build_ast.*unwrap\\(\\) in surface_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in diagnostics_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in generated_targets_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in template_context_facts_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in AppBuilder::run",
"replace entry\\? with entry.unwrap\\(\\) in collect_input_files_into",
"replace match guard error.kind\\(\\) == io::ErrorKind::NotFound with true in write_if_changed",
"replace \\+= with (?:-=|\\*=) in stylesheet_class_tokens",
"replace 1 with 0 in stylesheet_class_tokens",
"replace chars.next\\(\\)\\? with chars.next\\(\\).unwrap\\(\\) in context_type_for_heml_path",
"replace entry\\? with entry.unwrap\\(\\) in collect_rust_struct_facts",
"replace collect_rust_struct_facts.*\\? with collect_rust_struct_facts.*unwrap\\(\\) in collect_rust_struct_facts",
'replace syn::parse2.* with syn::parse2.*unwrap\(\) in add_app_registry_helper',
'replace "generated app registry helper parses" with "" in add_app_registry_helper',
'replace "generated component register helper parses" with "" in add_component_register_helper',
'replace "generated component state register helper parses" with "" in add_component_register_helper',
]
+7 -5
View File
@@ -35,6 +35,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- First split intent into broad error classes: what can go wrong, and what outcome must hold.
- Test the largest risky classes before narrow examples.
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
- When changing generated lowering, prove dynamic rendered attributes at the consumer boundary; literal lowering fixtures alone are insufficient.
- Do not over-codify existing behavior while direction is still moving.
- Add narrow concrete tests only after requirements converge into a stable direction.
@@ -52,20 +53,21 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; the full path should stay within a documented 10 minute local timeout or be split into deterministic shards under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; mutation shards use one-based `1..=TOTAL` numbering, allow the wrapper's 120-second floor for compiler probes, and rely on the wrapper's package-specific nested concurrency rather than direct `mutest` invocation. The full path should stay within a documented 10 minute local timeout or use every deterministic mutation shard under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016 req: test/020 req: test/022 req: test/023
- Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019
- Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008
- Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata, the versioned canonical hemx `EffectBatch` codec, postcard surface facts, and form-encoded public requests—not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
- JS runtime changes must preserve root-scoped lookup, delegated listeners, postcard EffectBatch application, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling and viewport-aware revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001
- Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012
- Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003
+17 -76
View File
File diff suppressed because one or more lines are too long
+55 -11
View File
@@ -155,7 +155,7 @@ client app state framework.
004 Ordinary page navigation must not require user-authored handlers. Explicit navigation handlers are available only when custom application logic is needed, and they still return generated target/page commands. [north_star]
005 Browser back/forward is supported. On `popstate`, hemx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry. [north_star]
005 Browser back/forward restores a saved generated page snapshot and scroll position when available, otherwise fetches the URL as a partial request without pushing a new entry. [north_star]
006 If a page lacks the expected `content` slot, hemx-axum falls back to normal browser navigation in production and emits a diagnostic in development. [north_star]
@@ -320,7 +320,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
004 Without a key, hemx-addressable nodes inside a loop are rejected at build time. Keyed identity is `ResourceRef { resource: ResourceId, scope: Some(ScopeKey::KeyValue(...)) }`. [north_star]
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; hemx implements keyed slot lookups. [north_star]
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; hemx implements keyed slot lookups and lowers rendered `h-key` identity to its private `data-key` runtime marker. [north_star]
003 Generated helpers for keyed slots are `append(view)`, `prepend(view)`, `replace(view)`, and `remove(key_or_view)` when the template and view type provide an unambiguous `h-key`. [north_star]
@@ -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]
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]
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
@@ -402,12 +408,14 @@ 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]
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]
005 Failed hemx HTTP requests fail closed: non-2xx responses are not applied as effects, pending state is restored, root-scoped `data-hemx-error` outlets show transport failure, and runtime emits `hemx:error` with status when available. [north_star]
007 The runtime must bind polling and revealed triggers inserted by effects.
---
## failure
@@ -472,7 +480,7 @@ what a valid business email is. [north_star]
## 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.
@@ -486,6 +494,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.
008 SSE must transport canonical `EffectBatch` bytes as one unpadded base64url value in the `hemx` event data field. [north_star]
---
## sync
@@ -536,6 +546,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]
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
@@ -630,7 +652,7 @@ what a valid business email is. [north_star]
005 `.heml` editor startup for VS Code, Cursor, and Neovim must preserve normal HTML or tree-sitter HTML highlighting while using repo-owned `hemx-build` diagnostics through `hemx-lsp` as the shared authority for hemplate-specific feedback. [north_star]
006 `hemx-lsp` completion and hover for `.heml` Rust-shaped expressions uses hemx-owned compiler/build facts for derive-known template context fields and simple `h-for` locals. Missing or stale facts fall back to syntax/document completions. [north_star]
006 `hemx-lsp` completion and hover for `.heml` Rust-shaped expressions uses hemx-owned compiler/build facts for derive-known template context fields and simple `h-for` locals. A simple local is one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields do not produce local facts. Missing or stale facts fall back to syntax/document completions. [north_star]
008 `hemx-lsp` must not proxy rust-analyzer or own a second Rust type system. [north_star]
@@ -658,7 +680,7 @@ what a valid business email is. [north_star]
003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages. [north_star]
004 Repository-wide verification uses a resource-aware runner that runs from the hemx workspace root regardless of the caller's directory and caps Cargo build jobs and Rust test threads from available CPU and memory. User-requested concurrency cannot exceed the detected safe cap. [north_star]
004 Repository-wide verification uses a resource-aware runner from the hemx workspace root and caps Cargo jobs, Rust test threads, and nested mutation/browser concurrency from available CPU and memory. User-requested concurrency cannot exceed the safe cap. [north_star]
012 Browser E2E runs as an isolated step and can be skipped explicitly when browser infrastructure is unavailable. [north_star]
@@ -680,6 +702,14 @@ 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]
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]
022 `cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]` must run capped exhaustive package-native mutation tests with enough per-mutant time for repo-owned compiler probes, and fail on unexplained survivors. [north_star]
023 A supplied mutation `SHARD/TOTAL` uses one-based `1..=TOTAL` numbering, maps to the deterministic native shard, and rejects invalid bounds; omission preserves the full-package gate. [north_star]
---
## check
@@ -742,6 +772,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]
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
@@ -812,6 +852,8 @@ what a valid business email is. [north_star]
013 Workout mobile commands must not add a broad `hemx-mobile` framework. [north_star]
014 The SaaS production reference uses real URL navigation and an ongoing server-owned SSE status stream; its bounded test probe emits the same canonical hemx event shape. [north_star]
---
## ms
@@ -826,7 +868,7 @@ what a valid business email is. [north_star]
## 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.
---
@@ -963,13 +1005,13 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
010 Supported cosmetic convention attributes include `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, and `data-hemx-disable-while-pending`.
011 Supported timing/trigger convention attributes include `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-policy`, and `data-hemx-on`.
011 Supported timing/trigger convention attributes include `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `data-hemx-policy`, and `data-hemx-on`.
012 Runtime-supported delegated events are `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`; unsupported static event names are build errors.
013 Static empty confirmation messages are build errors: they silently disable the guard in browsers. Custom confirm UI belongs to integration crates.
014 `data-hemx-revealed` dispatches once when the element enters view, with an immediate fallback when `IntersectionObserver` is unavailable.
014 `data-hemx-revealed` dispatches once when the element enters its configured non-negative viewport-ahead margin, tracks viewport resizing, re-arms after browser page restoration, and has an immediate fallback when `IntersectionObserver` is unavailable.
015 Duplicate timers/observers per root are avoided.
@@ -1053,6 +1095,8 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
006 Optional WASM, sync, PWA, transition, and editor assets are separately loadable; server-first applications do not pay their download, initialization, or dependency cost. [north_star]
007 Queue replay preserves command order and keeps the durable 64-command fixture within 250 ms on the supported baseline browser. [north_star]
---
## v1_release
+10 -4
View File
@@ -16,9 +16,14 @@ closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: p
- Production reference: authenticated mutation, origin/CSRF denial, atomic
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
metrics, CSP, and mixed-build fail-closed recovery proven.
- V1 closure matrix: local workspace, browser, performance, docs, and example
gates pass. Warning-denied vulnerability and source audits are clean; strict
license closure awaits a repository license allowlist.
- V1 closure matrix: not closed. The recorded local workspace, browser,
performance, docs, and example gates pass, warning-denied vulnerability and
source audits are clean, and the mutation-applicable library/proc-macro matrix
has no unexplained survivors. Strict license closure still awaits an owner-chosen
license for 20 currently unlicensed workspace packages and an allowlist decision
for Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT,
Unicode-3.0, and Unlicense dependencies; this blocks a production-ready claim.
req: test/020 req: test/021 req: v1_release/006
- Publishing and deployment: explicitly unauthorized.
## Baseline evidence
@@ -167,6 +172,7 @@ Run these on the final tree before GOAL_DONE:
```sh
cargo run -p hemx-xtask -- test
cargo run -p hemx-xtask -- mutation
cargo check --workspace
cargo test -p hemx-saas-example
cargo test -p hemx-v0-examples
@@ -176,7 +182,7 @@ cargo test -p hemx-derive --test compile_fail
cargo test -p hemx-test --test examples_contract
redgate list
redgate refs
redgate health --strict
redgate health
git diff --check
```
+1 -1
View File
@@ -44,7 +44,7 @@ Status legend:
| `edit-row` | implemented | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` |
| `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` |
| `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` |
| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` |
| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows; `data-hemx-revealed-ahead` opts into viewport-ahead loading without moving the observed element. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `infinite_row` |
| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` |
| `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` |
@@ -69,7 +69,7 @@
{+ row +}
</template>
</ul>
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true">
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true" data-hemx-revealed-ahead="1">
<input type="hidden" name="request" value="more">
<button type="submit">Reveal more rows</button>
</form>
+3 -2
View File
@@ -9,7 +9,7 @@ const MIGRATION_KEY = "commandSchemaMigration";
const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1";
const EXPORT_SCHEMA = 1;
const MAX_REPLAY_COMMANDS = 64;
const REPLAY_BUDGET_MS = 100;
const REPLAY_BUDGET_MS = 250; // req: performance/007
const SESSION = "hemx-kanban-session-v1";
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
@@ -382,7 +382,8 @@ async function start() {
if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
commands.forEach(validate);
const replayStarted = performance.now();
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
const batches = await Promise.all(commands.map((command) => project(root, wasmHandler, command)));
for (const batch of batches) window.hemx.applyBatch(batch, root);
const replayMs = performance.now() - replayStarted;
root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3));
root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS));
+4 -26
View File
@@ -2,7 +2,7 @@
pub mod ui {}
use hemplate::Hemplate;
use hemx::{push, Html, IntoEffect};
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure,
Registry, State,
@@ -565,15 +565,6 @@ mod dashboard_handlers {
dashboard::live_status.set(format!("{total} projects persisted locally")),
))
}
#[hemx::handler]
pub async fn open_settings(State(_ctx): State<AppContext>) -> impl IntoEffect {
(
dashboard::page_panel.put(&SettingsPage::production_boundaries()),
dashboard::nav.set("Settings"),
push("/settings"),
)
}
}
pub fn live_status(projects: usize) -> impl IntoEffect {
@@ -740,23 +731,10 @@ mod tests {
assert!(created.updates_text(dashboard::live_status));
}
#[tokio::test]
async fn page_swap_and_push_shape_use_generated_targets() {
// req: page_swap/002 req: push/003 req: examples/001
#[test]
fn live_status_uses_the_generated_dashboard_target() {
// req: push/003 req: examples/014
let ctx = AppContext::demo();
let settings = inspect_batch(
InteractionRequest::from(form(dashboard::open_settings, &[]))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(
settings.updates_html_containing(dashboard::page_panel, "explicit app integrations")
);
assert!(settings.updates_text(dashboard::nav));
assert!(settings.pushes_to("/settings"));
let heartbeat = inspect(live_status(ctx.projects().len()));
assert!(heartbeat.updates_text(dashboard::live_status));
assert!(heartbeat.payload_contains("heartbeat"));
+15 -9
View File
@@ -5,14 +5,14 @@ use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use futures_util::stream;
use futures_util::{stream, StreamExt};
use hemx::IntoEffect;
use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest};
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
use std::time::Instant;
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -81,16 +81,22 @@ async fn events(
Query(params): Query<BTreeMap<String, String>>,
State(ctx): State<AppContext>,
) -> impl IntoResponse {
let count = ctx.projects().len();
// The production reference exposes an ongoing server-owned stream; `once`
// keeps a bounded probe for package tests without changing the public path.
// req: examples/014
let event = |ctx: &AppContext| {
Ok::<_, Infallible>(live_status(ctx.projects().len()).into_batch(ui::BUILD_FINGERPRINT))
};
let initial = stream::once(std::future::ready(event(&ctx)));
if params.contains_key("once") {
return sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]));
return sse(initial.left_stream());
}
sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]))
let updates = stream::unfold(ctx, move |ctx| async move {
tokio::time::sleep(Duration::from_secs(15)).await;
Some((event(&ctx), ctx))
});
sse(initial.chain(updates).right_stream())
}
// req: auth/001 req: auth/002 req: auth/004
+1 -1
View File
@@ -7,7 +7,7 @@
<nav class="tabs" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Projects</a>
<button type="button" data-hemx-handle="open_settings">Settings</button>
<a href="/settings" data-hemx-nav>Settings</a>
</nav>
<section class="panel" data-hemx-slot="page_panel">
@@ -125,6 +125,25 @@ fn authenticated_project_mutation_is_atomic_and_survives_restart() {
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
assert!(
home.contains("href=\"/settings\" data-hemx-nav"),
"settings must remain a real, enhanceable link"
);
let settings = request(&address, "GET", "/settings", &[], "");
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
assert!(settings.contains("explicit app integrations"), "{settings}");
let events = request(&address, "GET", "/events?once=1", &[], "");
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
assert_eq!(
response_header(&events, "content-type"),
"text/event-stream"
);
assert!(events.contains("event: hemx"), "{events}");
assert!(events.contains("data:"), "{events}");
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
+107 -41
View File
@@ -1347,10 +1347,10 @@ impl IntoResponse for PageResponse {
.headers_mut()
.insert(HEMX_PARTIAL_HEADER, HeaderValue::from_static("true"));
}
if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) {
if let Some(fingerprint) = self.fingerprint {
response
.headers_mut()
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
.insert(HEMX_FINGERPRINT_HEADER, fingerprint_header(fingerprint));
}
if let Some(title) = self
.title
@@ -1380,8 +1380,8 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -
html
}
fn fingerprint_header(fingerprint: BuildFingerprint) -> Option<HeaderValue> {
HeaderValue::from_str(&fingerprint.0.to_string()).ok()
fn fingerprint_header(fingerprint: BuildFingerprint) -> HeaderValue {
HeaderValue::from(fingerprint.0)
}
impl IntoResponse for EffectResponse {
@@ -1392,11 +1392,10 @@ impl IntoResponse for EffectResponse {
header::CONTENT_TYPE,
HeaderValue::from_static(HEMX_CONTENT_TYPE),
);
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) {
response
.headers_mut()
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
}
response.headers_mut().insert(
HEMX_FINGERPRINT_HEADER,
fingerprint_header(self.batch.fingerprint),
);
response
}
}
@@ -1442,8 +1441,7 @@ impl IntoResponse for RuntimeJs {
);
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(hemx_js::RUNTIME_JS.len().to_string().as_str())
.expect("runtime length is a valid header value"),
HeaderValue::from(hemx_js::RUNTIME_JS.len() as u64),
);
response
}
@@ -1485,23 +1483,20 @@ fn base64_url_no_pad(input: &[u8]) -> String {
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
let mut chunks = input.chunks_exact(3);
for chunk in &mut chunks {
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
out.push(ALPHABET[(n & 0x3f) as usize] as char);
out.push(ALPHABET[(chunk[0] >> 2) as usize] as char);
out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char);
out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char);
out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char);
}
match chunks.remainder() {
[a] => {
let n = (*a as u32) << 16;
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
}
[a, b] => {
let n = ((*a as u32) << 16) | ((*b as u32) << 8);
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
out.push(ALPHABET[((b & 0x0f) << 2) as usize] as char);
}
[] => {}
_ => unreachable!(),
@@ -1528,24 +1523,22 @@ fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, Interact
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
match input[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < input.len() => {
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?;
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?;
out.push((high << 4) | low);
i += 3;
}
b'%' => return Err(InteractionFormRejection::InvalidBody),
byte => {
out.push(byte);
i += 1;
let mut bytes = input.iter().copied();
while let Some(byte) = bytes.next() {
match byte {
b'+' => out.push(b' '),
b'%' => {
let high = bytes
.next()
.and_then(hex)
.ok_or(InteractionFormRejection::InvalidBody)?;
let low = bytes
.next()
.and_then(hex)
.ok_or(InteractionFormRejection::InvalidBody)?;
out.push(high * 16 + low);
}
byte => out.push(byte),
}
}
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
@@ -1563,7 +1556,8 @@ fn hex(byte: u8) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::{
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs,
percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection,
HEMX_SSE_EVENT,
};
use axum::{
@@ -1613,6 +1607,77 @@ mod tests {
.expect("root element is rendered");
assert_eq!(root.value().attr("data-hemx-fp"), Some("1"));
assert_eq!(root.text().collect::<String>(), "Docs");
assert_eq!(html.matches("data-hemx-fp=").count(), 1);
assert!(!html.contains("data-hemx-fp=\"99\""));
}
#[test]
fn fingerprint_injection_leaves_html_without_a_hemx_root_unchanged() {
let html = "<main>Docs</main>".to_owned();
assert_eq!(
html_with_root_fingerprint(html.clone(), BuildFingerprint(99)),
html
);
// test req: abi/005
}
#[test]
fn base64url_transport_matches_rfc_4648_vectors_without_padding() {
for (input, expected) in [
(b"".as_slice(), ""),
(b"f".as_slice(), "Zg"),
(b"fo".as_slice(), "Zm8"),
(b"foo".as_slice(), "Zm9v"),
(b"foob".as_slice(), "Zm9vYg"),
(b"fooba".as_slice(), "Zm9vYmE"),
(b"foobar".as_slice(), "Zm9vYmFy"),
(&[0xfb, 0xff, 0xff], "-___"),
(&[0x00, 0x0f, 0x00], "AA8A"),
(&[0x00, 0xcf, 0x00], "AM8A"),
(&[0xff], "_w"),
(&[0xff, 0xff], "__8"),
] {
assert_eq!(base64_url_no_pad(input), expected);
}
// req: push/008 test
}
#[test]
fn urlencoded_decoder_handles_standard_escapes_and_rejects_malformed_input() {
assert_eq!(
parse_urlencoded_pairs(
b"empty=&space=+&slash=%2f&digit=%39&upper=%4A&lower=%4a&repeat=1&repeat=2"
),
Ok(vec![
("empty".into(), String::new()),
("space".into(), " ".into()),
("slash".into(), "/".into()),
("digit".into(), "9".into()),
("upper".into(), "J".into()),
("lower".into(), "J".into()),
("repeat".into(), "1".into()),
("repeat".into(), "2".into()),
])
);
assert_eq!(parse_urlencoded_pairs(b""), Ok(Vec::new()));
for malformed in [
b"bad=%".as_slice(),
b"bad=%0".as_slice(),
b"bad=%gg".as_slice(),
b"bad=%0g".as_slice(),
b"%gg=value".as_slice(),
] {
assert_eq!(
parse_urlencoded_pairs(malformed),
Err(InteractionFormRejection::InvalidBody)
);
}
assert_eq!(percent_decode(b"a+b%2Fc"), Ok("a b/c".into()));
assert_eq!(
InteractionForm::parse_urlencoded(b"__h=1&bad=%"),
Err(InteractionFormRejection::InvalidBody)
);
// test req: form/002 req: failure/003
}
#[tokio::test]
@@ -1623,6 +1688,7 @@ mod tests {
ops: Vec::new(),
};
let encoded = encode_sse_batch(&batch);
assert_eq!(encoded, "SEVNWAEAAAALAAAAAAAAAAAAAAA");
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
assert_eq!(
+521 -11
View File
@@ -1,7 +1,11 @@
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{header, HeaderMap, Request, StatusCode};
use axum::response::IntoResponse;
use axum::{body::Body, routing::post, Router};
use axum::{
body::{to_bytes, Body, Bytes},
routing::post,
Router,
};
use hemx_axum::{
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
@@ -9,26 +13,53 @@ use hemx_axum::{
InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE,
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER,
};
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
use hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot};
use scraper::{Html, Selector};
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0);
static BOUNDARY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
MUTATION_CALLS.fetch_add(1, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
let form = request.form();
assert_eq!(form.handle_id, 7);
assert_eq!(form.value("title"), Some("report"));
assert_eq!(form.files().len(), 1);
let upload = form.file("upload").expect("uploaded file");
assert_eq!(upload.file_name.as_deref(), Some("report.txt"));
assert_eq!(upload.content_type.as_deref(), Some("text/plain"));
assert_eq!(upload.bytes, b"hello");
StatusCode::NO_CONTENT
}
#[tokio::test]
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
// test req: security/003
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
MUTATION_CALLS.store(0, Ordering::SeqCst);
let app = Router::new()
.route("/mutate", post(bounded_mutation))
.layer(DefaultBodyLimit::max(32));
let missing_content_type = app
.clone()
.oneshot(Request::post("/mutate").body(Body::from("__h=1")).unwrap())
.await
.unwrap();
assert_eq!(
missing_content_type.status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0);
let unsupported = app
.clone()
.oneshot(
@@ -60,7 +91,7 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
Request::post("/mutate")
.header(
header::CONTENT_TYPE,
"application/x-www-form-urlencoded; charset=utf-8",
"Application/X-Www-Form-Urlencoded; charset=UTF-8",
)
.body(Body::from("__h=1"))
.unwrap(),
@@ -71,6 +102,115 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn interaction_boundary_extracts_multipart_fields_and_files() {
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let boundary = "hemx-boundary";
let body = concat!(
"--hemx-boundary\r\n",
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
"7\r\n",
"--hemx-boundary\r\n",
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
"report\r\n",
"--hemx-boundary\r\n",
"Content-Disposition: form-data; name=\"upload\"; filename=\"report.txt\"\r\n",
"Content-Type: text/plain\r\n\r\n",
"hello\r\n",
"--hemx-boundary--\r\n"
);
let response = Router::new()
.route("/upload", post(multipart_mutation))
.oneshot(
Request::post("/upload")
.header(
header::CONTENT_TYPE,
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// test req: multipart/001 req: multipart/002 req: multipart/003
}
#[tokio::test]
async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() {
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let app = Router::new().route("/mutate", post(bounded_mutation));
let missing_boundary = app
.clone()
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "multipart/form-data")
.body(Body::from("invalid"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_boundary.status(), StatusCode::BAD_REQUEST);
let accepted = concat!(
"--b\r\n",
"Content-Disposition: form-data; filename=\"ignored.txt\"\r\n\r\n",
"ignored\r\n",
"--b\r\n",
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
"1\r\n",
"--b--\r\n"
);
let response = app
.clone()
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
.body(Body::from(accepted))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let invalid_utf8 =
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n\xff\r\n--b--\r\n";
let response = app
.clone()
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
.body(Body::from(invalid_utf8.as_slice()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let stream = futures_util::stream::iter([
Ok::<_, std::io::Error>(Bytes::from_static(
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n",
)),
Err(std::io::Error::other("stream failed")),
]);
let response = app
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
.body(Body::from_stream(stream))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// test req: multipart/001 req: multipart/003
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
@@ -107,17 +247,39 @@ impl hemx_core::FromForm for OpenProject {
}
#[test]
fn page_mode_detects_partial_header() {
let mut headers = HeaderMap::new();
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap());
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
fn page_mode_detects_only_explicit_partial_header_values() {
for value in [
None,
Some("false"),
Some("0"),
Some("TRUE"),
Some("invalid"),
] {
let mut headers = HeaderMap::new();
if let Some(value) = value {
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
}
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
}
for value in ["true", "1"] {
let mut headers = HeaderMap::new();
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
}
// test req: page_swap/001
}
#[test]
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
// req: test/005
assert!(!PageRequest {
mode: PageMode::Full
}
.is_partial());
assert!(PageRequest {
mode: PageMode::Partial
}
.is_partial());
let full = PageRequest {
mode: PageMode::Full,
}
@@ -209,6 +371,48 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
);
}
#[test]
fn page_response_constructors_preserve_mode_and_optional_fingerprint() {
let full = PageResponse::full("<html>Full</html>");
assert_eq!(full.mode, PageMode::Full);
assert_eq!(full.html, "<html>Full</html>");
assert_eq!(full.fingerprint, None);
let partial = PageResponse::partial("<main>Partial</main>");
assert_eq!(partial.mode, PageMode::Partial);
assert_eq!(partial.html, "<main>Partial</main>");
assert_eq!(partial.title, None);
assert_eq!(partial.fingerprint, None);
let fingerprint = BuildFingerprint(42);
assert_eq!(
partial.fingerprint(fingerprint).fingerprint,
Some(fingerprint)
);
// test req: page_swap/001 req: abi/005
}
#[tokio::test]
async fn full_page_response_preserves_html_and_fingerprint_without_partial_headers() {
let response = PageResponse::full("<html>Full</html>")
.fingerprint(BuildFingerprint(42))
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "42");
assert!(!response.headers().contains_key(HEMX_PARTIAL_HEADER));
assert!(!response.headers().contains_key(HEMX_TITLE_HEADER));
assert_eq!(
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
b"<html>Full</html>"
);
// test req: page_swap/001 req: abi/005
}
#[test]
fn partial_page_response_sets_partial_and_title_headers() {
let response = PageResponse::partial("<main>Docs</main>")
@@ -333,6 +537,79 @@ fn state_interactions_starts_stateful_wiring_without_nested_closures() {
);
}
#[test]
fn interaction_form_accessors_preserve_repeated_values_and_decode_diagnostics() {
let form =
InteractionForm::parse_urlencoded(b"__h=42&count=7&tag=alpha&tag=beta&empty=").unwrap();
assert_eq!(form.handle_id, 42);
assert_eq!(form.value("tag"), Some("alpha"));
assert_eq!(form.values("tag").collect::<Vec<_>>(), ["alpha", "beta"]);
assert_eq!(form.parse::<u32>("count"), Some(7));
assert_eq!(form.parse::<u32>("tag"), None);
assert_eq!(form.parse::<u32>("missing"), None);
assert_eq!(form.required("empty"), Ok(""));
assert_eq!(
form.required("missing").unwrap_err().message(),
"missing form field `missing`"
);
assert_eq!(form.parse_required::<u32>("count"), Ok(7));
assert_eq!(
form.parse_required::<u32>("missing").unwrap_err().message(),
"missing form field `missing`"
);
assert_eq!(
form.parse_required::<u32>("tag").unwrap_err().message(),
"invalid form field `tag`"
);
assert!(form.files().is_empty());
assert!(form.file("upload").is_none());
assert!(form
.fields()
.iter()
.any(|pair| pair == &("count".into(), "7".into())));
// test req: form/002 req: form/004 req: multipart/001
}
#[tokio::test]
async fn interaction_form_rejections_return_stable_status_and_diagnostic() {
for (rejection, status, message) in [
(
InteractionFormRejection::UnsupportedMediaType,
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
),
(
InteractionFormRejection::BodyTooLarge,
StatusCode::PAYLOAD_TOO_LARGE,
"hemx interaction body exceeds the host limit",
),
(
InteractionFormRejection::InvalidBody,
StatusCode::BAD_REQUEST,
"invalid hemx form body",
),
(
InteractionFormRejection::MissingHandle,
StatusCode::BAD_REQUEST,
"missing __h hemx handle field",
),
(
InteractionFormRejection::InvalidHandle,
StatusCode::BAD_REQUEST,
"invalid __h hemx handle field",
),
] {
let response = rejection.into_response();
assert_eq!(response.status(), status);
assert_eq!(
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
message.as_bytes()
);
}
// test req: failure/003 req: multipart/003
}
#[test]
fn interaction_request_dispatches_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
@@ -562,6 +839,181 @@ fn interactions_dispatch_by_checked_handle() {
assert_eq!(response.batch.ops, vec![title.text("Hello")]);
}
#[tokio::test]
async fn registry_contains_registered_sync_and_async_handles_and_async_falls_back_to_sync() {
let sync = Handle::<()>::new(21);
let asynchronous = Handle::<()>::new(22);
let registry = interactions(BuildFingerprint(55))
.on(sync, |_| Slot::<String>::new(1).text("sync"))
.on_async(asynchronous, |_| async {
Slot::<String>::new(1).text("async")
});
assert!(registry.contains(sync.id().id));
assert!(registry.contains(asynchronous.id().id));
assert!(!registry.contains(99));
let sync_response = InteractionRequest::from(InteractionForm::for_handle(sync, []))
.dispatch_async(registry)
.await
.unwrap();
assert_eq!(sync_response.batch.fingerprint, BuildFingerprint(55));
assert_eq!(
sync_response.batch.ops,
vec![Slot::<String>::new(1).text("sync")]
);
// test req: axum_integration/003 req: public_api/001
}
#[tokio::test]
async fn state_result_registration_paths_preserve_state_form_and_failures() {
let sync = Handle::<()>::new(30);
let typed = Handle::<()>::new(31);
let asynchronous = Handle::<()>::new(32);
let typed_async = Handle::<()>::new(33);
let typed_plain = Handle::<()>::new(34);
let typed_state_plain = Handle::<()>::new(37);
let typed_async_plain = Handle::<()>::new(35);
let state_async_plain = Handle::<()>::new(36);
let registry = interactions(BuildFingerprint(88))
.on_state_result(sync, String::from("sync"), |state: State<String>| {
Ok::<_, HandlerBoom>(Slot::<String>::new(1).text(state.0))
})
.on_state_form_result(
typed,
String::from("typed"),
|state: State<String>, Form(form): Form<OpenProject>| {
Err::<Effect, _>(HandlerBoom).map_err(|error| {
let _ = (state, form);
error
})
},
)
.on_state_async_result(
asynchronous,
String::from("async"),
|state: State<String>| async move {
Ok::<_, HandlerBoom>(Slot::<String>::new(1).text(state.0))
},
)
.on_form(typed_plain, |Form(form): Form<OpenProject>| {
Slot::<String>::new(1).text(format!("typed:{}", form.project_id.0))
})
.on_state_form(
typed_state_plain,
String::from("typed-state"),
|state: State<String>, Form(form): Form<OpenProject>| {
Slot::<String>::new(1).text(format!("{}:{}", state.0, form.project_id.0))
},
)
.on_form_async(
typed_async_plain,
|Form(form): Form<OpenProject>| async move {
Slot::<String>::new(1).text(format!("typed-plain-async:{}", form.project_id.0))
},
)
.on_state_async(
state_async_plain,
String::from("state-async"),
|state: State<String>| async move { Slot::<String>::new(1).text(state.0) },
)
.on_state_form_async_result(
typed_async,
String::from("typed-async"),
|state: State<String>, Form(form): Form<OpenProject>| async move {
Ok::<_, HandlerBoom>(
Slot::<String>::new(1).text(format!("{}:{}", state.0, form.project_id.0)),
)
},
);
for handle in [
sync,
typed,
asynchronous,
typed_async,
typed_plain,
typed_state_plain,
typed_async_plain,
state_async_plain,
] {
assert!(registry.contains(handle.id().id));
}
assert!(registry
.dispatch(InteractionForm::for_handle(sync, []))
.is_ok());
let invalid_form = registry
.dispatch(InteractionForm::for_handle(
typed,
[(String::from("project_id"), String::from("invalid"))],
))
.unwrap_err();
assert_eq!(
invalid_form,
DispatchRejection::InvalidForm {
handle_id: typed.id().id,
message: "invalid form field `project_id`".into(),
}
);
let error = registry
.dispatch(InteractionForm::for_handle(
typed,
[(String::from("project_id"), String::from("7"))],
))
.unwrap_err();
assert_eq!(
error,
DispatchRejection::HandlerError(HandlerFailure::internal("database unavailable"))
);
assert!(registry
.dispatch_async(InteractionForm::for_handle(asynchronous, []))
.await
.is_ok());
let invalid_plain = registry
.dispatch(InteractionForm::for_handle(
typed_plain,
[(String::from("project_id"), String::from("invalid"))],
))
.unwrap_err();
assert!(matches!(
invalid_plain,
DispatchRejection::InvalidForm { .. }
));
let invalid_state_plain = registry
.dispatch(InteractionForm::for_handle(
typed_state_plain,
[(String::from("project_id"), String::from("invalid"))],
))
.unwrap_err();
assert!(matches!(
invalid_state_plain,
DispatchRejection::InvalidForm { .. }
));
assert!(registry
.dispatch_async(InteractionForm::for_handle(
typed_async_plain,
[(String::from("project_id"), String::from("8"))],
))
.await
.is_ok());
assert!(registry
.dispatch_async(InteractionForm::for_handle(state_async_plain, []))
.await
.is_ok());
let response = registry
.dispatch_async(InteractionForm::for_handle(
typed_async,
[(String::from("project_id"), String::from("9"))],
))
.await
.unwrap();
assert_eq!(
response.batch.ops,
vec![Slot::<String>::new(1).text("typed-async:9")]
);
// test req: axum_integration/003 req: derive_handler/005
}
#[test]
fn interactions_reject_unknown_handle_ids() {
let request = InteractionRequest::from(InteractionForm::new(9, []));
@@ -574,8 +1026,59 @@ fn interactions_reject_unknown_handle_ids() {
);
}
#[test]
fn runtime_js_response_serves_embedded_runtime() {
#[tokio::test]
async fn effect_and_dispatch_responses_preserve_status_wire_and_diagnostics() {
let batch = Slot::<String>::new(3)
.text("saved")
.into_batch(BuildFingerprint(77));
let response = EffectResponse {
batch: batch.clone(),
}
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "77");
let body = to_bytes(response.into_body(), 4096).await.unwrap();
assert_eq!(hemx_core::EffectBatch::from_wire(&body), Ok(batch.clone()));
for (rejection, status, message) in [
(
DispatchRejection::UnknownHandle(9),
StatusCode::NOT_FOUND,
"unknown hemx handle id 9",
),
(
DispatchRejection::InvalidForm {
handle_id: 9,
message: "missing title".into(),
},
StatusCode::BAD_REQUEST,
"invalid hemx form for handle id 9: missing title",
),
(
DispatchRejection::HandlerError(HandlerFailure::Response {
status: StatusCode::CONFLICT,
message: "stale".into(),
}),
StatusCode::CONFLICT,
"stale",
),
] {
let response = rejection.into_response();
assert_eq!(response.status(), status);
assert_eq!(
to_bytes(response.into_body(), 4096).await.unwrap().as_ref(),
message.as_bytes()
);
}
let response = DispatchRejection::HandlerError(HandlerFailure::Effects(batch)).into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
// test req: axum_integration/003 req: failure/005 req: wire/007
}
#[tokio::test]
async fn runtime_js_response_serves_embedded_runtime() {
let response = runtime_js().into_response();
assert_eq!(
@@ -594,6 +1097,13 @@ fn runtime_js_response_serves_embedded_runtime() {
response.headers()[header::CONTENT_LENGTH],
runtime_js_source().len().to_string()
);
assert_eq!(
to_bytes(response.into_body(), runtime_js_source().len() + 1)
.await
.unwrap()
.as_ref(),
runtime_js_source().as_bytes()
);
}
// req: axum_integration/005
+1687 -300
View File
File diff suppressed because it is too large Load Diff
+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 abi_version: u32,
pub fingerprint: BuildFingerprint,
@@ -459,6 +459,13 @@ pub struct EffectBatch {
}
impl EffectBatch {
/// The versioned hemx codec is the sole public `EffectBatch` wire API.
///
/// ```compile_fail
/// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1));
/// let _ = batch.to_postcard();
/// ```
/// req: wire/010 test
/// Return the exact number of bytes produced by [`Self::to_wire`].
pub fn encoded_len(&self) -> usize {
batch_wire_len(self)
@@ -478,14 +485,6 @@ impl EffectBatch {
read_batch(bytes)
}
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
postcard::to_allocvec(self)
}
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
postcard::from_bytes(bytes)
}
pub const fn is_compatible(&self) -> bool {
self.abi_version == EFFECT_BATCH_ABI_VERSION
}
@@ -770,12 +769,9 @@ impl<'a> WireReader<'a> {
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?;
if end > self.bytes.len() {
return Err(WireError::Truncated);
}
let bytes = &self.bytes[self.offset..end];
self.offset = end;
let remaining = &self.bytes[self.offset..];
let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
self.offset += len;
Ok(bytes)
}
}
+260 -21
View File
@@ -1,8 +1,8 @@
use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey,
ScrollBehavior, Slot,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
};
#[test]
@@ -53,47 +53,70 @@ fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
}
#[test]
fn encoded_len_covers_every_effect_shape() {
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1));
let scoped = ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 2),
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
let atom = ResourceRef::scoped(
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
ScopeKey::KeyValue(String::from("row")),
);
let handle = ResourceRef::scoped(
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
ScopeKey::Field(String::from("email")),
);
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(42),
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
ops: vec![
Effect::Put {
target: unscoped.clone(),
target: slot.clone(),
payload: Payload::Text(String::from("text")),
},
Effect::Put {
target: handle.clone(),
payload: Payload::Html(String::from("<p>safe</p>")),
},
Effect::Insert {
target: scoped.clone(),
target: atom.clone(),
key: String::from("insert"),
payload: Payload::Text(String::from("one")),
},
Effect::Prepend {
target: scoped.clone(),
target: form.clone(),
key: String::from("prepend"),
payload: Payload::Text(String::from("two")),
payload: Payload::Html(String::from("two")),
},
Effect::Remove {
target: scoped.clone(),
target: slot.clone(),
key: None,
},
Effect::Remove {
target: atom,
key: Some(String::from("remove")),
},
Effect::Move {
target: scoped.clone(),
target: handle.clone(),
key: String::from("move"),
before: Some(String::from("before")),
},
Effect::Focus {
target: scoped.clone(),
Effect::Focus { target: form },
Effect::Navigate {
url: String::from("/push"),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
},
Effect::Navigate {
url: String::from("/next"),
url: String::from("/replace"),
mode: NavigateMode::Replace,
scroll: ScrollBehavior::Element(unscoped),
title: Some(String::from("Next")),
scroll: ScrollBehavior::Top,
title: Some(String::from("Replace")),
},
Effect::Navigate {
url: String::from("/redirect"),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Element(handle),
title: Some(String::from("Redirect")),
},
Effect::Emit {
name: String::from("notice"),
@@ -103,8 +126,90 @@ fn encoded_len_covers_every_effect_shape() {
};
let bytes = batch.to_wire();
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch);
assert_eq!(&bytes[..4], b"HEMX");
assert_eq!(batch.encoded_len(), bytes.len());
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
for end in 0..bytes.len() {
assert_eq!(
EffectBatch::from_wire(&bytes[..end]),
Err(WireError::Truncated),
"prefix ending at byte {end} must fail closed"
);
}
// req: wire/007 test req: wire/008 test req: wire/009 test
}
#[test]
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4;
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN;
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1;
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4;
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1;
let put = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Put {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
payload: Payload::Text(String::from("value")),
}],
}
.to_wire();
for offset in [
PUT_EFFECT_TAG,
PUT_RESOURCE_KIND_TAG,
PUT_SCOPE_TAG,
PUT_PAYLOAD_TAG,
] {
let mut corrupt = put.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
const NAVIGATE_MODE_TAG: usize = BATCH_HEADER_LEN + 1 + 4;
const NAVIGATE_SCROLL_TAG: usize = NAVIGATE_MODE_TAG + 1;
const NAVIGATE_TITLE_OPTION_TAG: usize = NAVIGATE_SCROLL_TAG + 1;
let navigate = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Navigate {
url: String::new(),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
}],
}
.to_wire();
for offset in [
NAVIGATE_MODE_TAG,
NAVIGATE_SCROLL_TAG,
NAVIGATE_TITLE_OPTION_TAG,
] {
let mut corrupt = navigate.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
let mut bad_magic = put.clone();
bad_magic[0] = b'X';
assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4;
let mut invalid_utf8 = put.clone();
invalid_utf8[PUT_TEXT_START] = 0xff;
assert_eq!(
EffectBatch::from_wire(&invalid_utf8),
Err(WireError::InvalidUtf8)
);
let mut trailing = put;
trailing.push(0);
assert_eq!(
EffectBatch::from_wire(&trailing),
Err(WireError::TrailingBytes)
);
// req: wire/008 test req: wire/009 test
}
#[test]
@@ -196,6 +301,93 @@ fn generated_form_helpers_target_form_fields() {
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
}
#[test]
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
let rows = KeyedSlot::<u64, String>::new(9);
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9));
assert_eq!(
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")),
Effect::Put {
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())),
payload: Payload::Html("<li>done</li>".into()),
}
);
assert_eq!(
rows.remove(12),
Effect::Remove {
target: expected.clone(),
key: Some("12".into()),
}
);
assert_eq!(
rows.move_before(12, 13),
Effect::Move {
target: expected.clone(),
key: "12".into(),
before: Some("13".into()),
}
);
assert_eq!(
rows.move_to_end(12),
Effect::Move {
target: expected,
key: "12".into(),
before: None,
}
);
let form = Form::<()>::new(4);
assert_eq!(
form.clear_field("email"),
Effect::Put {
target: ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 4),
ScopeKey::Field("email".into()),
),
payload: Payload::Text(String::new()),
}
);
assert_eq!(
form.disable_while_pending(),
Effect::Emit {
name: "hemx:form-disable-while-pending".into(),
payload: "4".into(),
}
);
assert_eq!(form.clear(), form.reset());
for (effect, expected_mode) in [
(navigate("/push"), NavigateMode::Push),
(hemx_core::push("/push"), NavigateMode::Push),
(replace("/replace"), NavigateMode::Replace),
(redirect("/redirect"), NavigateMode::Redirect),
] {
let Effect::Navigate {
mode,
scroll,
title,
..
} = effect
else {
panic!("navigation helper must return Navigate");
};
assert_eq!(mode, expected_mode);
assert_eq!(scroll, ScrollBehavior::Top);
assert_eq!(title, None);
}
// test req: list/003 req: form_effects/001 req: nav/001
}
#[test]
fn css_class_accumulation_preserves_existing_classes() {
const A: CssClass = CssClass::new("a");
const B: CssClass = CssClass::new("b");
const C: CssClass = CssClass::new("c");
assert_eq!(CssClasses::new([]).with(A).as_str(), "a");
assert_eq!(CssClasses::from(A).with(B).with(C).as_str(), "a b c");
// test req: style/003
}
#[test]
fn slot_html_requires_explicit_safe_html() {
let content = Slot::<String>::new(10);
@@ -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_ref(), "<main><strong>ok</strong></main>");
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>");
}
#[test]
@@ -223,6 +417,7 @@ fn param_names_format_generated_param_names() {
// req: codegen/003
let param = ParamName::new("todo_id");
assert_eq!(param.as_str(), "todo_id");
assert_eq!(param.as_ref(), "todo_id");
assert_eq!(param.to_string(), "todo_id");
}
@@ -231,6 +426,7 @@ fn component_refs_format_generated_component_names() {
// req: component/003
let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.as_ref(), "todo_list");
assert_eq!(component.to_string(), "todo_list");
}
@@ -301,4 +497,47 @@ fn build_fingerprint_is_deterministic_from_abi_parts() {
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(
BuildFingerprint::from_parts(&[]),
BuildFingerprint(0xcbf29ce484222325)
);
assert_eq!(a, BuildFingerprint(13725386680924731485));
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
// test req: abi/001 req: abi/002 req: abi/003
}
#[test]
fn public_token_and_form_error_adapters_preserve_values() {
const NOTICE: EventName = EventName::new("notice");
assert_eq!(NOTICE.as_str(), "notice");
assert_eq!(NOTICE.as_ref(), "notice");
assert_eq!(NOTICE.to_string(), "notice");
assert_eq!(String::from(NOTICE), "notice");
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
const ACTIVE: CssClass = CssClass::new("active");
assert_eq!(ACTIVE.as_str(), "active");
assert_eq!(ACTIVE.as_ref(), "active");
assert_eq!(ACTIVE.to_string(), "active");
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
assert_eq!(classes.as_ref(), "active selected");
let error = FormError::new("invalid email");
assert_eq!(error.message(), "invalid email");
assert_eq!(error.to_string(), "invalid email");
assert_eq!(u32::parse_form_value("42"), Ok(42));
assert_eq!(
u32::parse_form_value("nope"),
Err("invalid form value".into())
);
assert_eq!(
Form::<()>::new(7).reset(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: "7".into(),
}
);
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
}
+612 -93
View File
@@ -12,37 +12,23 @@ use syn::{
#[proc_macro_attribute]
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let placement = attr.to_string();
let is_client = match placement.as_str() {
"" => false,
"client" => true,
_ => {
return syn::Error::new(
proc_macro2::Span::call_site(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
)
.into_compile_error()
.into();
}
let placement = match handler_placement(&placement) {
Ok(placement) => placement,
Err(error) => return error.into_compile_error().into(),
};
let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else {
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
return quote!(
#function
compile_error!(#message);
)
.into();
let syms_path = match handler_syms_path(syms_path()) {
Ok(path) => path,
Err(message) => {
return quote!(
#function
compile_error!(#message);
)
.into();
}
};
if !syms_path.exists() {
let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation";
return quote!(
#function
compile_error!(#message);
)
.into();
}
if !syms_contains_handle(&syms_path, &name) {
let message = format!(
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler"
@@ -86,33 +72,34 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
.into();
}
if !is_client {
return quote!(#function).into();
}
let input_count = function.sig.inputs.len();
if !matches!(input_count, 0 | 2)
|| function.sig.asyncness.is_some()
|| function.sig.unsafety.is_some()
|| function.sig.constness.is_some()
|| !function.sig.generics.params.is_empty()
{
let message = format!(
"client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
);
return quote!(
#function
compile_error!(#message);
)
.into();
expand_handler_function(function, placement).into()
}
fn expand_handler_function(
function: ItemFn,
placement: HandlerPlacement,
) -> proc_macro2::TokenStream {
if placement == HandlerPlacement::Server {
return quote!(#function);
}
let has_inputs = match client_handler_has_inputs(&function) {
Ok(has_inputs) => has_inputs,
Err(error) => {
let message = error.to_string();
return quote!(
#function
compile_error!(#message);
);
}
};
let function_name = &function.sig.ident;
let export_name = format_ident!("__hemx_client_{function_name}");
let export_module = format_ident!("__hemx_client_export_{function_name}");
let invoke_handler = if input_count == 0 {
quote!(super::#function_name())
} else {
let invoke_handler = if has_inputs {
quote!(super::#function_name(event, state))
} else {
quote!(super::#function_name())
};
quote!(
#function
@@ -149,7 +136,6 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
}
}
)
.into()
}
#[proc_macro_attribute]
@@ -162,7 +148,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let form_name = parse_macro_input!(attr as LitStr).value();
let form_struct = parse_macro_input!(item as ItemStruct);
let Some(syms_path) = syms_path() else {
let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
let message = missing_form_generated_files_message();
return quote!(
#form_struct
compile_error!(#message);
@@ -174,13 +160,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let ident = &form_struct.ident;
let resource_id =
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
let mut generics = form_struct.generics.clone();
for ty in form_parser_types(&form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::hemx::FormValue));
}
let generics = form_impl_generics(&form_struct);
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote!(
@@ -201,7 +181,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
)
.into()
} else {
let message = errors.join("; ");
let message = join_contract_errors(&errors);
quote!(
#form_struct
compile_error!(#message);
@@ -228,9 +208,6 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
let Some(syms_path) = syms_path() else {
return quote!(#module).into();
};
if !syms_path.exists() {
return quote!(#module).into();
}
let component_filter = component_name.as_deref();
let errors = component_contract_errors(&syms_path, component_filter, items);
if !errors.is_empty() {
@@ -312,22 +289,13 @@ fn form_model_type(ty: &Type) -> Option<Type> {
let Type::Path(path) = ty else {
return None;
};
let mut segments = path.path.segments.iter();
let first = segments.next()?;
let last = path
.path
.segments
.last()
.expect("path has at least one segment");
let path_is_form = if path.path.segments.len() == 1 {
first.ident == "Form"
} else {
first.ident == "hemx" && last.ident == "Form"
let segments = path.path.segments.iter().collect::<Vec<_>>();
let form = match segments.as_slice() {
[form] if form.ident == "Form" => form,
[hemx, .., form] if hemx.ident == "hemx" && form.ident == "Form" => form,
_ => return None,
};
if !path_is_form {
return None;
}
let PathArguments::AngleBracketed(args) = &last.arguments else {
let PathArguments::AngleBracketed(args) = &form.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
@@ -336,6 +304,76 @@ fn form_model_type(ty: &Type) -> Option<Type> {
})
}
fn missing_handler_generated_files_message() -> &'static str {
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
}
fn missing_form_generated_files_message() -> &'static str {
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
}
fn handler_syms_path(syms_path: Option<PathBuf>) -> Result<PathBuf, &'static str> {
match syms_path {
None => Err(missing_handler_generated_files_message()),
Some(path) if !path.exists() => Err(
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation",
),
Some(path) => Ok(path),
}
}
fn join_contract_errors(errors: &[String]) -> String {
errors.join("; ")
}
fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
let mut generics = form_struct.generics.clone();
for ty in form_parser_types(form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::hemx::FormValue));
}
generics
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HandlerPlacement {
Server,
Client,
}
fn handler_placement(placement: &str) -> syn::Result<HandlerPlacement> {
match placement {
"" => Ok(HandlerPlacement::Server),
"client" => Ok(HandlerPlacement::Client),
_ => Err(syn::Error::new(
proc_macro2::Span::call_site(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
)),
}
}
fn client_handler_has_inputs(function: &ItemFn) -> syn::Result<bool> {
let input_count = function.sig.inputs.len();
if matches!(input_count, 0 | 2)
&& function.sig.asyncness.is_none()
&& function.sig.unsafety.is_none()
&& function.sig.constness.is_none()
&& function.sig.generics.params.is_empty()
{
Ok(input_count == 2)
} else {
Err(syn::Error::new_spanned(
&function.sig,
format!(
"client-local hemx handler `{}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`",
function.sig.ident
),
))
}
}
fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
function.sig.inputs.iter().rev().find_map(|arg| match arg {
FnArg::Typed(arg) => form_model_type(&arg.ty),
@@ -632,12 +670,8 @@ fn rust_ident(name: &str) -> Option<String> {
}
fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec<String> {
let required = handle_params(path, ident);
if required.is_empty() {
return Vec::new();
}
let args = handler_arg_names(function);
required
handle_params(path, ident)
.into_iter()
.filter(|param| !args.contains(param))
.collect()
@@ -731,9 +765,6 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
let Some(state_ty) = component_state_type(items) else {
return module;
};
if handlers.is_empty() {
return module;
}
let calls = handlers
.iter()
.map(|handler| component_registration_call(handler, &component_ident));
@@ -976,27 +1007,344 @@ fn symbol_component(symbol: &str) -> Option<&str> {
}
fn compile_error(message: &str) -> TokenStream {
format!("compile_error!({message:?});")
.parse()
.expect("compile_error expansion is valid")
quote!(compile_error!(#message);).into()
}
#[cfg(test)]
mod tests {
use super::{
add_app_registry_helper, add_component_register_helper, component_handler_names,
form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return,
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle,
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
component_contract_errors, component_handler_names, component_registration_call,
expand_handler_function, form_contract_errors, form_decode_fields, form_fields,
form_impl_generics, form_model_type, form_resource_id, generic_inner_type, handle_params,
handle_requires_form, handler_arg_names, handler_form_model_type, handler_placement,
handler_syms_path, has_form_param, has_non_unit_return, is_type_named,
join_contract_errors, missing_component_handlers, missing_form_generated_files_message,
missing_handle_params, missing_handler_generated_files_message, parser_type,
returns_result, rust_ident, symbol_component, syms_components, syms_contains_handle,
syms_handles, ComponentHandler, HandlerPlacement,
};
use quote::quote;
use quote::{quote, ToTokens};
use syn::{parse_quote, ItemFn, Type};
#[test]
fn handler_attribute_parses_server_and_client_modes_exactly() {
assert_eq!(handler_placement("").unwrap(), HandlerPlacement::Server);
assert_eq!(
handler_placement("client").unwrap(),
HandlerPlacement::Client
);
for invalid in ["server", " client", "client ", "CLIENT"] {
assert_eq!(
handler_placement(invalid).unwrap_err().to_string(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]"
);
}
let no_inputs: ItemFn = parse_quote!(
fn save() {}
);
let two_inputs: ItemFn = parse_quote!(
fn save(event: Event, state: State) {}
);
assert!(!client_handler_has_inputs(&no_inputs).unwrap());
assert!(client_handler_has_inputs(&two_inputs).unwrap());
let server =
expand_handler_function(no_inputs.clone(), HandlerPlacement::Server).to_string();
assert_eq!(
server,
quote!(
fn save() {}
)
.to_string()
);
let no_input_client =
expand_handler_function(no_inputs.clone(), HandlerPlacement::Client).to_string();
assert!(no_input_client.contains("super :: save ()"));
assert!(!no_input_client.contains("super :: save (event , state)"));
let input_client =
expand_handler_function(two_inputs.clone(), HandlerPlacement::Client).to_string();
assert!(input_client.contains("super :: save (event , state)"));
assert!(!input_client.contains("super :: save ()"));
for invalid in [
parse_quote!(
fn save(event: Event) {}
),
parse_quote!(
fn save(a: A, b: B, c: C) {}
),
parse_quote!(
async fn save() {}
),
parse_quote!(
unsafe fn save() {}
),
parse_quote!(
const fn save() {}
),
parse_quote!(
fn save<T>() {}
),
] {
assert_eq!(
client_handler_has_inputs(&invalid).unwrap_err().to_string(),
"client-local hemx handler `save` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
);
let expanded = expand_handler_function(invalid, HandlerPlacement::Client).to_string();
assert!(expanded.contains("compile_error !"));
assert!(expanded.contains("client-local hemx handler"));
}
// test req: derive_handler/001 req: client_local/001
}
#[test]
fn generated_file_and_form_helpers_preserve_exact_contracts() {
assert_eq!(
missing_handler_generated_files_message(),
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
);
assert_eq!(
missing_form_generated_files_message(),
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
);
assert_eq!(
handler_syms_path(None).unwrap_err(),
missing_handler_generated_files_message()
);
let missing = std::env::temp_dir().join("hemx-derive-missing-symbols");
assert_eq!(
handler_syms_path(Some(missing)).unwrap_err(),
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"
);
let existing = std::env::current_exe().unwrap();
assert_eq!(handler_syms_path(Some(existing.clone())).unwrap(), existing);
assert_eq!(
join_contract_errors(&["first".into(), "second".into()]),
"first; second"
);
let form: syn::ItemStruct = parse_quote!(
struct Profile<T> {
name: String,
tags: Vec<T>,
}
);
let generics = form_impl_generics(&form);
let where_clause = generics
.where_clause
.as_ref()
.unwrap()
.to_token_stream()
.to_string();
assert!(where_clause.contains("String : :: hemx :: FormValue"));
assert!(where_clause.contains("T : :: hemx :: FormValue"));
// test req: derive_handler/001 req: form/004
}
#[test]
fn generated_form_symbol_lookup_is_exact_and_fail_closed() {
let path =
std::env::temp_dir().join(format!("hemx-derive-form-symbols-{}", std::process::id()));
std::fs::write(
&path,
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform\nform\tbroken\nform\tmissing-id\tmissing-id\nform\tother.heml::other\tother\tbad\nform_field\tprofile\tname\ttrue\tfalse\nform_field\nform_field\tprofile\nform_field\tprofile\tbad name\tfalse\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\tbad-name\tfalse\tfalse\nform_field\tother\tignored\tfalse\tfalse\n",
)
.unwrap();
assert_eq!(form_resource_id(&path, "profile"), Some(42));
assert_eq!(form_resource_id(&path, "missing"), None);
assert_eq!(form_resource_id(&path, "broken"), None);
assert_eq!(form_resource_id(&path, "missing-id"), None);
assert_eq!(form_resource_id(&path, "other"), None);
let fields = form_fields(&path, "profile");
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].ident, "name");
assert!(fields[0].required);
assert!(!fields[0].multiple);
assert_eq!(fields[1].ident, "tags");
assert!(!fields[1].required);
assert!(fields[1].multiple);
assert_eq!(fields[2].ident, "bad_name");
assert!(form_fields(&path, "missing").is_empty());
std::fs::remove_file(&path).unwrap();
assert_eq!(form_resource_id(&path, "profile"), None);
assert!(form_fields(&path, "profile").is_empty());
let string: Type = parse_quote!(String);
let qualified: Type = parse_quote!(std::string::String);
let reference: Type = parse_quote!(&String);
assert!(is_type_named(&string, "String"));
assert!(is_type_named(&qualified, "String"));
assert!(!is_type_named(&string, "Vec"));
assert!(!is_type_named(&reference, "String"));
// test req: form/004 req: diagnostics/003
}
#[test]
fn handler_type_helpers_recognize_only_the_public_form_and_result_shapes() {
let bare: Type = parse_quote!(Form<String>);
let qualified: Type = parse_quote!(hemx::Form<crate::Input>);
let wrong_module: Type = parse_quote!(other::Form<String>);
let missing_model: Type = parse_quote!(hemx::Form);
let unrelated: Type = parse_quote!(String);
assert_eq!(quote!(#bare).to_string(), "Form < String >");
assert_eq!(
quote!(#qualified).to_string(),
"hemx :: Form < crate :: Input >"
);
assert!(form_model_type(&bare).is_some());
assert!(form_model_type(&qualified).is_some());
assert!(form_model_type(&wrong_module).is_none());
assert!(form_model_type(&missing_model).is_none());
assert!(form_model_type(&unrelated).is_none());
let function: ItemFn = parse_quote!(
fn save(
first: hemx::Form<crate::First>,
value: String,
last: Form<crate::Last>,
) -> Result<(), Error> {
unimplemented!()
}
);
assert_eq!(
quote!(#function)
.to_string()
.contains("last : Form < crate :: Last >"),
true
);
assert_eq!(
quote!(#function)
.to_string()
.contains("first : hemx :: Form < crate :: First >"),
true
);
assert_eq!(
handler_form_model_type(&function)
.map(|ty| quote!(#ty).to_string())
.as_deref(),
Some("crate :: Last")
);
assert!(returns_result(&function.sig.output));
let no_result: ItemFn = parse_quote!(
fn save() -> String {
String::new()
}
);
let no_return: ItemFn = parse_quote!(
fn save() {}
);
assert!(!returns_result(&no_result.sig.output));
assert!(!returns_result(&no_return.sig.output));
// test req: derive_handler/001 req: derive_handler/005
}
#[test]
fn form_contract_diagnostics_accumulate_all_mismatches() {
let path = std::env::temp_dir().join(format!(
"hemx-derive-form-contract-errors-{}",
std::process::id()
));
let missing: syn::ItemStruct = parse_quote!(
struct Profile {
first: String,
}
);
assert_eq!(
form_contract_errors(&path, "profile", &missing),
vec!["#[hemx::form] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"]
);
std::fs::write(
&path,
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform_field\tprofile\tfirst\tfalse\tfalse\nform_field\tprofile\tsecond\ttrue\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\trequired_name\ttrue\tfalse\n",
)
.unwrap();
assert_eq!(
form_contract_errors(&path, "unknown", &missing),
vec!["unknown hemx form `unknown`; add data-hemx-form=\"unknown\" to a template or rename this form binding"]
);
let tuple: syn::ItemStruct = parse_quote!(
struct Profile(String);
);
assert_eq!(
form_contract_errors(&path, "profile", &tuple),
vec!["hemx form `profile` must be a struct with named fields"]
);
let mismatched: syn::ItemStruct = parse_quote!(
struct Profile {
first: Vec<String>,
tags: String,
required_name: Option<String>,
}
);
assert_eq!(
form_contract_errors(&path, "profile", &mismatched),
vec![
"hemx form `profile` field `first` accepts one value and must not be Vec<_>",
"hemx form `profile` is missing field `second` for form control `second`",
"hemx form `profile` field `tags` accepts multiple values and must be Vec<_>",
"hemx form `profile` field `required_name` is required in HTML and must not be Option<_>",
]
);
let decode_struct: syn::ItemStruct = parse_quote!(
struct Profile {
first: Option<String>,
tags: Vec<String>,
required_name: String,
}
);
let decoded = form_decode_fields(&path, "profile", &decode_struct)
.into_iter()
.map(|tokens| tokens.to_string())
.collect::<Vec<_>>();
assert_eq!(decoded.len(), 3);
assert!(decoded[0].contains("Some (__hemx_value) => Some"));
assert!(decoded[0].contains("None => None"));
assert!(decoded[1].contains("collect :: < Result < Vec < _ > , _ >> () ?"));
assert!(decoded[2].contains("missing form field"));
let option: Type = parse_quote!(Option<String>);
let qualified_vec: Type = parse_quote!(std::vec::Vec<u64>);
let plain: Type = parse_quote!(String);
assert_eq!(
generic_inner_type(&option, "Option")
.unwrap()
.to_token_stream()
.to_string(),
"String"
);
assert_eq!(
generic_inner_type(&qualified_vec, "Vec")
.unwrap()
.to_token_stream()
.to_string(),
"u64"
);
assert!(generic_inner_type(&option, "Vec").is_none());
assert!(generic_inner_type(&plain, "String").is_none());
let empty_path = Type::Path(syn::TypePath {
qself: None,
path: syn::Path {
leading_colon: None,
segments: Default::default(),
},
});
assert!(generic_inner_type(&empty_path, "Vec").is_none());
std::fs::remove_file(path).unwrap();
// test req: form/004 req: diagnostics/003
}
#[test]
fn syms_lookup_matches_handle_ident() {
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
std::fs::write(
&path,
"hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
"hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/z.heml::archive\tarchive\t3\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle\ttemplates/a.heml::delete\tdelete\t4\nhandle\ttemplates/a.heml::create\tcreate-again\t5\nhandle\thas-no-component\tignored\t6\nhandle\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
)
.unwrap();
@@ -1006,8 +1354,26 @@ mod tests {
assert!(!handle_requires_form(&path, "missing"));
assert_eq!(handle_params(&path, "create"), vec!["todo_id"]);
assert!(handle_params(&path, "missing").is_empty());
assert_eq!(
syms_handles(&path, Some("a")),
vec!["create", "delete", "create-again"]
);
assert_eq!(
syms_handles(&path, None),
vec!["archive", "create", "delete", "create-again", "ignored"]
);
assert_eq!(syms_components(&path), vec!["a", "z"]);
assert_eq!(symbol_component("templates/a.heml::create"), Some("a"));
assert_eq!(symbol_component("a.heml::create"), Some("a"));
assert_eq!(symbol_component("a.html::create"), None);
assert_eq!(symbol_component("a.heml"), None);
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(&path);
assert!(
syms_contains_handle(&path, "create"),
"missing generated symbols defer to the dedicated generated-file diagnostic"
);
assert!(!handle_requires_form(&path, "create"));
}
#[test]
@@ -1072,6 +1438,16 @@ mod tests {
assert!(form_model_type(&foreign_form).is_none());
}
#[test]
fn generated_form_field_names_map_only_to_valid_rust_identifiers() {
assert_eq!(rust_ident("first-name").as_deref(), Some("first_name"));
assert_eq!(rust_ident("_private2").as_deref(), Some("_private2"));
for invalid in ["", "2fast", "with space", "naïve"] {
assert_eq!(rust_ident(invalid), None, "{invalid:?} must fail closed");
}
// test req: form/004 req: diagnostics/003
}
#[test]
fn handler_params_match_generated_param_names() {
let path = std::env::temp_dir().join("hemx-derive-param-test.syms");
@@ -1093,10 +1469,68 @@ mod tests {
assert!(missing_handle_params(&path, "show", &complete).is_empty());
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
assert!(missing_handle_params(&path, "unknown", &missing).is_empty());
std::fs::write(&path, "handle_param\nhandle_param\tshow\tmode\n").unwrap();
assert_eq!(handle_params(&path, "show"), vec!["mode"]);
let patterns: ItemFn = parse_quote! {
fn patterns(self, named: String, (left, right): (u8, u8), _: bool) {}
};
assert_eq!(handler_arg_names(&patterns), vec!["named"]);
let _ = std::fs::remove_file(path);
}
#[test]
fn component_registration_selects_each_state_async_result_shape() {
let component_ident: syn::Ident = parse_quote!(todos);
let expected = [
(0, false, false, quote!(.on(super::todos::save, save))),
(0, false, true, quote!(.on_result(super::todos::save, save))),
(0, true, false, quote!(.on_async(super::todos::save, save))),
(
0,
true,
true,
quote!(.on_async_result(super::todos::save, save)),
),
(1, false, false, quote!(.on_state(super::todos::save, save))),
(
1,
false,
true,
quote!(.on_state_result(super::todos::save, save)),
),
(
1,
true,
false,
quote!(.on_state_async(super::todos::save, save)),
),
(
1,
true,
true,
quote!(.on_state_async_result(super::todos::save, save)),
),
];
for (typed_arg_count, is_async, returns_result, expected) in expected {
let handler = ComponentHandler {
ident: parse_quote!(save),
is_async,
returns_result,
typed_arg_count,
};
assert_eq!(
component_registration_call(&handler, &component_ident).to_string(),
expected.to_string(),
"args={typed_arg_count} async={is_async} result={returns_result}"
);
}
// test req: component/003 req: derive_handler/003
}
#[test]
fn component_handlers_match_generated_handles() {
let path = std::env::temp_dir().join("hemx-derive-component-test.syms");
@@ -1109,6 +1543,8 @@ mod tests {
mod component {
#[hemx::handler]
fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() }
fn helper() {}
}
};
let (_, items) = module.content.expect("inline module");
@@ -1123,7 +1559,14 @@ mod tests {
vec!["delete"]
);
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(&path);
assert_eq!(
component_contract_errors(&path, Some("missing"), &items),
vec![
"#[hemx::component(\"missing\")] does not match any generated handles; no generated components with handles are available; add a data-hemx-handle to the component template or check build.rs generation",
"#[hemx::component] handler(s) not declared by this component's generated handles: create; move them to the matching component module or add data-hemx-handle in .heml",
]
);
}
#[test]
@@ -1151,6 +1594,80 @@ mod tests {
);
assert!(generated.contains("hemx_axum :: State"), "{generated}");
assert!(generated.contains("state . clone"), "{generated}");
let no_components: ItemFn = parse_quote!(
fn registry(state: App) {}
);
assert_eq!(
add_app_registry_helper(no_components, vec![]).err().unwrap(),
"#[hemx::app] requires component registry module(s), for example #[hemx::app(todo_handlers, auth_handlers)]"
);
let unnamed_state: ItemFn = parse_quote!(
fn registry((state,): (App,)) {}
);
assert_eq!(
add_app_registry_helper(unnamed_state, vec![parse_quote!(handlers)])
.err()
.unwrap(),
"#[hemx::app] must be used on a registry function with a named app state argument"
);
}
#[test]
fn component_registration_generation_respects_existing_and_incomplete_modules() {
let external: syn::ItemMod = parse_quote!(
mod handlers;
);
let unchanged = add_component_register_helper(external.clone(), "todos");
assert_eq!(
quote!(#external).to_string(),
quote!(#unchanged).to_string()
);
for existing in ["register", "register_with_state"] {
let existing_ident = syn::Ident::new(existing, proc_macro2::Span::call_site());
let module: syn::ItemMod = parse_quote! {
mod handlers {
struct App;
#[hemx::handler]
fn create(app: App) {}
fn #existing_ident() {}
}
};
let generated = add_component_register_helper(module, "todos");
let items = generated.content.unwrap().1;
assert_eq!(items.len(), 3, "must preserve existing {existing}");
}
let no_state: syn::ItemMod = parse_quote! {
mod handlers {
#[hemx::handler]
fn create() {}
}
};
assert_eq!(
add_component_register_helper(no_state, "todos")
.content
.unwrap()
.1
.len(),
1
);
let no_handlers: syn::ItemMod = parse_quote! {
mod handlers {
struct App;
fn helper(app: App) {}
}
};
assert_eq!(
add_component_register_helper(no_handlers, "todos")
.content
.unwrap()
.1
.len(),
2
);
// test req: component/003 req: derive_handler/003
}
#[test]
@@ -1158,6 +1675,8 @@ mod tests {
// req: component/003 req: derive_handler/003
let module = parse_quote! {
mod handlers {
const COMPONENT_KIND: &str = "todos";
#[hemx::handler]
fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
hemx::EventName::new("sync-form").emit("")
+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 {
Command::new("cargo")
.arg("check")
+93 -1
View File
@@ -468,6 +468,28 @@ mod tests {
native_shell_host_profile("ios-android-webview-test")
}
#[test]
fn capability_names_and_standard_profile_identity_are_stable() {
let names = [
(Capability::Haptics, "haptics"),
(Capability::Microphone, "microphone"),
(Capability::Camera, "camera"),
(Capability::Share, "share"),
(Capability::SecureStorage, "secure_storage"),
(Capability::Notifications, "notifications"),
(Capability::Clipboard, "clipboard"),
(Capability::FilePicker, "file_picker"),
(Capability::Geolocation, "geolocation"),
(Capability::custom("vendor.camera"), "vendor.camera"),
];
for (capability, expected) in names {
assert_eq!(capability.as_str(), expected);
}
assert_eq!(web_host().name, "browser-pwa");
// req: host/001 req: host/004
}
#[test]
fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() {
// req: host/001 req: host/002 req: host/005
@@ -509,6 +531,37 @@ mod tests {
assert_eq!(manifest.check(&web_host()), Ok(()));
}
#[test]
fn manifest_and_host_support_require_the_exact_capability_shape() {
let wrong_shape_host = HostProfile::new(
"wrong-shape",
[CapabilityUse::new(Capability::Share, CapabilityShape::Fire)],
);
let wrong_capability_host = HostProfile::new(
"wrong-capability",
[CapabilityUse::new(
Capability::Haptics,
CapabilityShape::Request,
)],
);
let manifest = CapabilityManifest::new([CapabilityUse::new(
Capability::Share,
CapabilityShape::Request,
)]);
assert!(!wrong_shape_host.supports(&Capability::Share, CapabilityShape::Request));
assert!(!wrong_capability_host.supports(&Capability::Share, CapabilityShape::Request));
assert_eq!(
manifest.check(&wrong_shape_host),
Err(HostCheckError::UnsupportedCapability {
capability: Capability::Share,
shape: CapabilityShape::Request,
host: "wrong-shape".into(),
})
);
// req: host/001 req: host/004
}
#[test]
fn host_calls_must_be_declared_and_supported() {
// req: host/001 req: host/004
@@ -516,12 +569,38 @@ mod tests {
Capability::Share,
CapabilityShape::Request,
)]);
let payload = SharePayload::text("log");
assert_eq!(
payload,
SharePayload {
title: None,
text: Some("log".into()),
url: None,
}
);
let call = HostCall::Share {
id: HostCallId::new("share-1"),
payload: SharePayload::text("log"),
payload,
};
assert_eq!(manifest.validate_call(&web_host(), &call), Ok(()));
let unsupported_host = HostProfile::new("offline-shell", []);
let unsupported = manifest
.validate_call(&unsupported_host, &call)
.expect_err("declared calls still require host support");
assert_eq!(
unsupported,
HostCheckError::UnsupportedCapability {
capability: Capability::Share,
shape: CapabilityShape::Request,
host: "offline-shell".into(),
}
);
assert_eq!(
unsupported.to_string(),
"host `offline-shell` does not support capability `share` with shape Request"
);
let haptic = HostCall::Haptic {
id: HostCallId::new("tap"),
pattern: HapticPattern::Success,
@@ -584,6 +663,19 @@ mod tests {
}
}
#[test]
fn host_failure_builders_preserve_call_context() {
let failure = HostFailure::new(HostFailureKind::Timeout, "host timed out")
.with_id(HostCallId::new("share-1"))
.with_capability(Capability::Share);
assert_eq!(failure.id, Some(HostCallId::new("share-1")));
assert_eq!(failure.capability, Some(Capability::Share));
assert_eq!(failure.kind, HostFailureKind::Timeout);
assert_eq!(failure.message, "host timed out");
// req: host/002 req: host/005
}
#[test]
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
// req: host/001 req: host/002 req: host/005
+80 -7
View File
@@ -343,6 +343,7 @@
await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode);
return;
}
savePage(roots()[0]);
const response = await fetch(requestUrl(form, data, method), {
method,
body,
@@ -382,7 +383,32 @@
}
}
function snapshotPage(root) {
return root ? {
hemx: true,
pageHtml: root.innerHTML,
pageTitle: document.title,
scrollX: window.scrollX,
scrollY: window.scrollY,
} : null;
}
function savePage(root) {
const snapshot = snapshotPage(root);
if (snapshot) history.replaceState(snapshot, "", location.href);
}
function restorePage(state, root) {
if (!root || !state || typeof state.pageHtml !== "string") return false;
root.innerHTML = state.pageHtml;
if (state.pageTitle) document.title = state.pageTitle;
bindRoot(root);
requestAnimationFrame(() => scrollTo(state.scrollX, state.scrollY));
return true;
}
async function navigateUrl(href, root, mode = "replace") {
if (mode === "push") savePage(root);
const response = await fetch(href, {
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
credentials: "same-origin",
@@ -443,6 +469,8 @@
return;
}
for (const op of batch.ops) applyOp(scope, op);
bindPolling(scope);
bindRevealed(scope);
}
function canApplyOp(scope, op) {
@@ -853,8 +881,14 @@
everyTimers.delete(el);
}
function revealedRootMargin(el) {
const ahead = Number(el.getAttribute("data-hemx-revealed-ahead") || "0");
const viewports = Number.isFinite(ahead) && ahead >= 0 ? ahead : 0;
return `0px 0px ${viewports * window.innerHeight}px 0px`;
}
function bindRevealed(root) {
let observer = revealObservers.get(root);
let observers = revealObservers.get(root);
forEachElement(root, (el) => {
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
if (typeof IntersectionObserver === "undefined") {
@@ -862,6 +896,12 @@
schedule(el, "revealed");
return;
}
if (!observers) {
observers = new Map();
revealObservers.set(root, observers);
}
const rootMargin = revealedRootMargin(el);
let observer = observers.get(rootMargin);
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
@@ -870,8 +910,8 @@
observer.unobserve(entry.target);
schedule(entry.target, "revealed");
});
});
revealObservers.set(root, observer);
}, { rootMargin });
observers.set(rootMargin, observer);
}
observer.observe(el);
});
@@ -1095,12 +1135,30 @@
const source = sseSources.get(root);
if (source) source.close();
sseSources.delete(root);
const observer = revealObservers.get(root);
if (observer) observer.disconnect();
const observers = revealObservers.get(root);
if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root);
stopDescendantPolling(root);
}
function rebindRevealed(resetDispatched) {
roots().forEach((root) => {
const observers = revealObservers.get(root);
if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root);
if (resetDispatched) {
forEachElement(root, (el) => {
if (attrEquals(el, "data-hemx-on", "revealed") || el.hasAttribute("data-hemx-revealed")) revealed.delete(el);
});
}
bindRevealed(root);
});
}
function restoreRevealed(event) {
if (event.persisted) rebindRevealed(true);
}
function start() {
roots().forEach((root) => {
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
@@ -1127,6 +1185,16 @@
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
descendantRoots(node).forEach(cleanupRemovedRoot);
});
record.addedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (node.hasAttribute(ROOT)) bindRoot(node);
descendantRoots(node).forEach(bindRoot);
const owner = rootOf(node.parentElement);
if (owner) {
bindPolling(owner);
bindRevealed(owner);
}
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
try {
@@ -1137,9 +1205,14 @@
}
}
addEventListener("popstate", () => {
window.addEventListener("pageshow", restoreRevealed);
window.addEventListener("resize", () => rebindRevealed(false));
addEventListener("popstate", (event) => {
const root = roots()[0];
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
if (root && !restorePage(event.state, root)) {
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
}
});
window.hemx = Object.freeze({
+21
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]
fn runtime_exposes_debug_api_before_startup_side_effects() {
// req: runtime/002
@@ -141,8 +146,21 @@ fn runtime_supports_tiny_delay_and_revealed_scheduling() {
assert!(source.contains("const revealObservers = new WeakMap()"));
assert!(source.contains("const revealed = new WeakSet()"));
assert!(source.contains("data-hemx-revealed"));
assert!(source.contains("data-hemx-revealed-ahead")); // req: convention/011 test
assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
assert!(source.contains("rootMargin"));
assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))"));
assert!(source.contains("schedule(entry.target, \"revealed\")"));
assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)"));
assert!(source.contains("revealed.delete(el)")); // req: convention/014 test
assert!(source.contains("record.addedNodes.forEach((node) =>"));
assert!(source.contains("descendantRoots(node).forEach(bindRoot)"));
assert!(source.contains("const owner = rootOf(node.parentElement)"));
assert!(source.contains("bindPolling(owner)"));
assert!(source.contains("bindRevealed(owner)"));
assert!(source.contains(
"for (const op of batch.ops) applyOp(scope, op);\n bindPolling(scope);\n bindRevealed(scope);"
)); // req: runtime/007 test
}
#[test]
@@ -302,6 +320,9 @@ fn runtime_exposes_page_swap_hooks() {
assert!(source.contains("data-hemx-nav"));
assert!(source.contains("data-hemx-boost"));
assert!(source.contains("history.pushState"));
assert!(source.contains("snapshotPage(root)"));
assert!(source.contains("restorePage(event.state, root)"));
assert!(source.contains("scrollTo(state.scrollX, state.scrollY)")); // req: page_swap/005 test
assert!(source.contains("popstate"));
assert!(source.contains("x-hemx-title"));
assert!(source.contains("x-hemx-fingerprint"));
+110 -25
View File
@@ -1,55 +1,52 @@
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{parse_macro_input, Error, FnArg, ItemFn, Pat, ReturnType};
use syn::{Error, FnArg, ItemFn, Pat, ReturnType};
#[proc_macro_attribute]
pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
expand_presence(attributes.into(), item.into())
.unwrap_or_else(Error::into_compile_error)
.into()
}
fn expand_presence(attributes: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
if !attributes.is_empty() {
return Error::new(
return Err(Error::new(
proc_macro2::Span::call_site(),
"#[hemx_sync::presence] does not accept arguments",
)
.to_compile_error()
.into();
));
}
let mut function = parse_macro_input!(item as ItemFn);
if function.sig.asyncness.is_some() {
return Error::new_spanned(
function.sig.asyncness,
let mut function: ItemFn = syn::parse2(item)?;
if let Some(asyncness) = &function.sig.asyncness {
return Err(Error::new_spanned(
asyncness,
"presence projections must be synchronous",
)
.to_compile_error()
.into();
));
}
if matches!(function.sig.output, ReturnType::Default) {
return Error::new_spanned(
return Err(Error::new_spanned(
&function.sig,
"presence projections must return impl IntoEffect",
)
.to_compile_error()
.into();
));
}
let argument = match function.sig.inputs.first() {
Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument,
_ => {
return Error::new_spanned(
return Err(Error::new_spanned(
&function.sig.inputs,
"presence projections require exactly one typed presence argument",
)
.to_compile_error()
.into();
));
}
};
let argument_name = match argument.pat.as_ref() {
Pat::Ident(argument) => argument.ident.clone(),
pattern => {
return Error::new_spanned(
return Err(Error::new_spanned(
pattern,
"presence projection argument must be a simple identifier",
)
.to_compile_error()
.into();
));
}
};
@@ -61,5 +58,93 @@ pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
let __hemx_sync_effect = (|| #body)();
::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect)
}));
quote!(#function).into()
Ok(quote!(#function))
}
#[cfg(test)]
mod tests {
use super::expand_presence;
use quote::quote;
#[test]
fn presence_expansion_enforces_the_typed_projection_contract() {
assert!(expand_presence(quote!(), quote!(not a function)).is_err());
for (attributes, item, expected) in [
(
quote!(unexpected),
quote!(
fn project(scope: Scope) -> Effect {
effect()
}
),
"#[hemx_sync::presence] does not accept arguments",
),
(
quote!(),
quote!(
async fn project(scope: Scope) -> Effect {
effect()
}
),
"presence projections must be synchronous",
),
(
quote!(),
quote!(
fn project(scope: Scope) {}
),
"presence projections must return impl IntoEffect",
),
(
quote!(),
quote!(
fn project() -> Effect {
effect()
}
),
"presence projections require exactly one typed presence argument",
),
(
quote!(),
quote!(
fn project(a: Scope, b: Scope) -> Effect {
effect()
}
),
"presence projections require exactly one typed presence argument",
),
(
quote!(),
quote!(
fn project((scope,): (Scope,)) -> Effect {
effect()
}
),
"presence projection argument must be a simple identifier",
),
] {
assert_eq!(
expand_presence(attributes, item).unwrap_err().to_string(),
expected
);
}
let expanded = expand_presence(
quote!(),
quote!(
pub fn project(scope: Scope) -> Effect {
effect(scope)
}
),
)
.unwrap()
.to_string();
assert!(expanded.contains("pub fn project"));
assert!(expanded.contains("impl :: hemx_sync :: PresenceUpdate"));
assert!(expanded.contains("PresenceScope :: presence_channel (& scope)"));
assert!(expanded.contains("PresenceProjection :: new"));
assert!(expanded.contains("effect (scope)"));
// test req: sync/003 req: sync/005
}
}
+62
View File
@@ -0,0 +1,62 @@
extern crate self as hemx_sync;
use hemx_sync_macros::presence;
pub trait IntoEffect {}
impl IntoEffect for &'static str {}
pub trait PresenceScope {
fn presence_channel(&self) -> String;
}
pub trait PresenceUpdate {
fn channel(&self) -> &str;
fn effect(&self) -> &str;
}
pub struct PresenceProjection<Effect> {
channel: String,
effect: Effect,
}
impl<Effect> PresenceProjection<Effect> {
pub fn new(channel: String, effect: Effect) -> Self {
Self { channel, effect }
}
}
impl PresenceUpdate for PresenceProjection<&'static str> {
fn channel(&self) -> &str {
&self.channel
}
fn effect(&self) -> &str {
self.effect
}
}
struct Signal(&'static str);
impl PresenceScope for Signal {
fn presence_channel(&self) -> String {
self.0.to_owned()
}
}
#[presence]
fn project(signal: Signal) -> impl IntoEffect {
if signal.0 == "board" {
"joined"
} else {
"left"
}
}
#[test]
fn public_presence_attribute_preserves_the_item_and_expands_it() {
let update = project(Signal("board"));
assert_eq!(update.channel(), "board");
assert_eq!(update.effect(), "joined");
// test req: sync/003 req: sync/005
}
+244 -4
View File
@@ -411,7 +411,6 @@ impl SyncEffect {
}
pub fn send_patch(patch: FlatPatch) -> Self {
patch.validate().expect("FlatPatch must remain valid");
Self(vec![Effect::Emit {
name: PATCH_EVENT.to_owned(),
payload: patch.payload(),
@@ -426,7 +425,6 @@ impl SyncEffect {
projection: impl IntoEffect,
fingerprint: BuildFingerprint,
) -> Self {
patch.validate().expect("FlatPatch must remain valid");
let projection = projection.into_batch(fingerprint);
let projection_wire = projection
.to_wire()
@@ -469,9 +467,30 @@ mod tests {
.into_batch(hemx_core::BuildFingerprint(7));
assert_eq!(batch.ops.len(), 2);
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
assert!(
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
let Effect::Emit { name, payload } = &batch.ops[1] else {
panic!("durable sync must end with its patch event");
};
assert_eq!(name, PATCH_EVENT);
let payload: serde_json::Value = serde_json::from_str(payload).unwrap();
let expected_projection = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(7),
ops: vec![Effect::Emit {
name: "projected".into(),
payload: "card:1".into(),
}],
}
.to_wire();
assert_eq!(
payload["projection"],
serde_json::Value::Array(
expected_projection
.into_iter()
.map(serde_json::Value::from)
.collect()
)
);
assert_eq!(payload["patch"]["idempotencyKey"], INTERACTION_ID);
}
#[test]
@@ -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]
fn schema_is_flat_and_rejects_reserved_keys() {
let patch = FlatPatch::new(
+26 -11
View File
@@ -4,7 +4,7 @@ use hemx_core::{
};
use std::io;
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
/// A child process owned by an integration test and proven ready over TCP.
@@ -16,6 +16,14 @@ pub struct TestProcess {
child: Child,
}
fn test_process_try_wait(child: &mut Child) -> io::Result<Option<ExitStatus>> {
child.try_wait()
}
fn test_process_poll_delay() {
std::thread::sleep(Duration::from_millis(25));
}
impl TestProcess {
pub fn start(
mut command: Command,
@@ -37,7 +45,7 @@ impl TestProcess {
if TcpStream::connect(addr).is_ok() {
return Ok(process);
}
if let Some(status) = process.child.try_wait()? {
if let Some(status) = test_process_try_wait(&mut process.child)? {
return Err(io::Error::other(format!(
"{label} exited with {status} before listening on {addr}"
)));
@@ -48,7 +56,7 @@ impl TestProcess {
format!("timed out after {timeout:?} waiting for {label} to listen on {addr}"),
));
}
std::thread::sleep(Duration::from_millis(25));
test_process_poll_delay();
}
}
}
@@ -68,8 +76,12 @@ where
inspect(handler(input))
}
fn inspection_fingerprint() -> BuildFingerprint {
BuildFingerprint(0)
}
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
inspect_batch(effect.into_batch(BuildFingerprint(0)))
inspect_batch(effect.into_batch(inspection_fingerprint()))
}
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
@@ -81,7 +93,10 @@ pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
/// req: test/001 req: dx/006
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
inspect_batch(EffectBatch::from_wire(bytes).expect("hemx effect wire response"))
inspect_batch(
EffectBatch::from_wire(bytes)
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}")),
)
}
/// Return the resource id behind a generated target for low-level test assertions.
@@ -420,10 +435,11 @@ pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
fn assert_simple_selector_part(value: &str, label: &str) {
assert!(
value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"{label} selector part must contain only ascii alphanumerics or '-'"
!value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"{label} selector part must contain one or more ascii alphanumerics or '-'"
);
}
@@ -437,8 +453,7 @@ fn runtime_resource_attr(kind: ResourceKind) -> &'static str {
}
fn runtime_attr_marker(name: &str, value: &str) -> String {
let escaped = value.replace('"', "&quot;");
format!(r#"{name}="{escaped}""#)
format!(r#"{name}="{value}""#)
}
fn attr_selector(name: &str, value: &str) -> String {
+264 -7
View File
@@ -1,7 +1,22 @@
use hemx_core::{
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode,
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
};
fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result {
Ok(_) => panic!("operation must panic"),
Err(panic) => panic,
};
if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = panic.downcast_ref::<&str>() {
(*message).to_owned()
} else {
panic!("panic payload was not text")
}
}
#[test]
fn inspects_tuple_effects() {
let count = Slot::<u32>::new(1);
@@ -84,6 +99,242 @@ fn finds_keyed_slot_targets() {
assert_eq!(inspected.ops().len(), 1);
}
#[test]
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
// test req: test/008 req: test/009 req: test/018
let expected = TestTarget(ResourceKind::Slot, 42);
let other = TestTarget(ResourceKind::Slot, 7);
let resource = ResourceId::new(ResourceKind::Slot, 42);
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
let form = Form::<()>::new(11);
let expected_emit = Effect::Emit {
name: "saved".into(),
payload: "card 42 saved".into(),
};
let inspected = hemx_test::inspect(vec![
Effect::Put {
target: ResourceRef::unscoped(other.__hemx_resource_id()),
payload: Payload::Text("decoy needle".into()),
},
Effect::Put {
target: ResourceRef::unscoped(resource),
payload: Payload::Text("expected text".into()),
},
Effect::Put {
target: keyed_ref.clone(),
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
},
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-2".into(),
payload: Payload::Html("<li>inserted</li>".into()),
},
Effect::Prepend {
target: ResourceRef::unscoped(resource),
key: "row-0".into(),
payload: Payload::Html("<li>prepended</li>".into()),
},
Effect::Remove {
target: ResourceRef::unscoped(resource),
key: Some("row-old".into()),
},
Effect::Focus {
target: ResourceRef::unscoped(form.id()),
},
Effect::Navigate {
url: "/cards/42".into(),
mode: NavigateMode::Push,
scroll: hemx_core::ScrollBehavior::Preserve,
title: None,
},
expected_emit.clone(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: form.id().id.to_string(),
},
]);
assert!(!inspected.is_empty());
assert_eq!(inspected.op_count(), 10);
assert!(inspected.contains(&expected_emit));
assert!(!inspected.contains(&Effect::Emit {
name: "saved".into(),
payload: "wrong".into(),
}));
assert!(inspected.has_resource(resource));
assert!(inspected.has_resource(other.__hemx_resource_id()));
assert!(inspected.has_target(expected));
assert!(inspected.has_target(other));
assert!(!inspected.has_target(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.updates_text(expected));
assert!(inspected.updates_text_containing(expected, "expected"));
assert!(!inspected.updates_text_containing(expected, "decoy"));
assert!(inspected.updates_text(other));
assert!(!inspected.updates_text(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.updates_html(expected));
assert!(inspected.updates_html_containing(expected, "replacement"));
assert!(!inspected.updates_html_containing(expected, "missing"));
assert!(!inspected.updates_html(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.replaces_keyed_html_containing(expected, "row-1", "replacement"));
assert!(!inspected.replaces_keyed_html_containing(expected, "row-2", "replacement"));
assert!(inspected.inserts_html_containing(expected, "row-2", "inserted"));
assert!(!inspected.inserts_html_containing(expected, "row-0", "inserted"));
assert!(inspected.removes_key(expected, "row-old"));
assert!(!inspected.removes_key(expected, "row-2"));
assert!(inspected.pushes_to("/cards/42"));
assert!(!inspected.pushes_to("/cards/7"));
assert!(inspected.payload_contains("prepended"));
assert!(!inspected.payload_contains("absent"));
assert!(inspected.payload_excludes("absent"));
assert!(!inspected.payload_excludes("expected"));
assert!(inspected.payload_excludes_key("absent"));
assert!(!inspected.payload_excludes_key("row-1"));
assert_eq!(
inspected.target_html_containing(expected, "replacement"),
Some("<li data-key=\"row-1\">replacement</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "inserted"),
Some("<li>inserted</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "prepended"),
Some("<li>prepended</li>")
);
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
assert!(inspected.emits("saved", "card 42 saved"));
assert!(!inspected.emits("saved", "wrong"));
assert!(inspected.emits_containing("saved", "42"));
assert!(!inspected.emits_containing("other", "42"));
assert!(inspected.has_ref(&keyed_ref));
assert!(inspected.has_ref(&ResourceRef::unscoped(resource)));
assert!(!inspected.has_ref(&ResourceRef::scoped(
resource,
ScopeKey::Field("row-1".into()),
)));
assert!(inspected.has_slot(Slot::<()>::new(42)));
assert!(!inspected.has_slot(Slot::<()>::new(99)));
assert!(inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(42)));
assert!(!inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(99)));
assert!(!inspected.has_atom(Atom::<()>::new(42)));
assert!(inspected.has_form(form));
assert!(!inspected.has_form(Form::<()>::new(12)));
assert!(inspected.resets_form(form));
assert!(!inspected.resets_form(Form::<()>::new(12)));
let empty = hemx_test::inspect(Vec::<Effect>::new());
assert!(empty.is_empty());
assert_eq!(empty.op_count(), 0);
}
#[test]
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
// test req: test/017
assert_eq!(hemx_test::heading_selector("", 2), "h2");
assert_eq!(hemx_test::list_item_selector(""), "li");
assert_eq!(hemx_test::prose_selector(""), "p");
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
"[data-sid=\"1\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
"[data-aid=\"2\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
"[data-hid=\"3\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
"[data-fid=\"4\"]"
);
assert_eq!(
hemx_test::nav_link_selector("/path\\\"quoted"),
"a[href=\"/path\\\\\\\"quoted\"]"
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 1),
"slot",
"<p data-sid=\"1\"></p>",
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-hid=\"3\"></button>",
);
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
})
.is_err());
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-other=\"3\"></button>",
);
})
.is_err());
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
assert!(panic_text(std::panic::catch_unwind(|| {
hemx_test::class_selector(invalid)
}))
.contains("class selector part"));
}
for (call, label) in [
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
"element selector part",
),
] {
assert!(panic_text(call).contains(label));
}
}
#[test]
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(9),
ops: vec![Effect::Emit {
name: "saved".into(),
payload: "ok".into(),
}],
};
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok"));
let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
assert!(message.contains("invalid hemx effect wire response: Truncated"));
// req: test/001 test req: wire/009 test
}
#[test]
fn builds_generated_handle_form_bodies() {
let handle = hemx_core::Handle::<()>::new(7);
@@ -91,6 +342,10 @@ fn builds_generated_handle_form_bodies() {
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
assert_eq!(
hemx_test::handle_form_body(handle, &[("AZaz09-_.~", "AZaz09-_.~ /é")],),
"__h=7&AZaz09-_.~=AZaz09-_.~+%2F%C3%A9"
);
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
}
@@ -189,13 +444,15 @@ fn builds_authoring_boundary_selectors() {
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script(
"probe-island",
"orbit",
"1|1|1|probe waiting",
"7|2|8|probe live",
"probe-\"island",
"orbit\\bridge",
"line1\nline2\rline3\tend",
"detail \"quoted\"",
);
assert!(probe.contains("probe-island"));
assert!(probe.contains("probe live"));
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
}
#[derive(Clone, Copy)]
+86 -1
View File
@@ -1,6 +1,7 @@
use hemx_test::TestProcess;
use std::net::{TcpListener, TcpStream};
use std::process::Command;
use std::time::Duration;
use std::time::{Duration, Instant};
#[test]
fn process_harness_reports_early_exit_with_context() {
@@ -27,5 +28,89 @@ fn process_harness_reports_early_exit_with_context() {
assert!(message.contains("exited with"), "{message}");
}
#[test]
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
// test req: test/019
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = reservation.local_addr().unwrap().to_string();
drop(reservation);
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_listens")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_ADDR", &addr);
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
.expect("readiness must observe the helper listener");
assert!(TcpStream::connect(&addr).is_ok());
drop(process);
let deadline = Instant::now() + Duration::from_secs(2);
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
TcpStream::connect(&addr).is_err(),
"drop must reap the helper"
);
}
#[test]
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
// test req: test/019
let spawn_error = match TestProcess::start(
Command::new("/definitely/not/a/hemx/executable"),
"missing helper",
"127.0.0.1:9",
Duration::from_millis(10),
) {
Ok(_) => panic!("spawn failure must be returned, not panic"),
Err(error) => error,
};
assert!(spawn_error
.to_string()
.contains("failed to spawn missing helper"));
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let unused_addr = reservation.local_addr().unwrap().to_string();
drop(reservation);
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_sleeps")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_SLEEP", "1");
let timeout = match TestProcess::start(
command,
"non-listening helper",
&unused_addr,
Duration::from_millis(100),
) {
Ok(_) => panic!("non-listening process must time out"),
Err(error) => error,
};
let message = timeout.to_string();
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("non-listening helper"), "{message}");
}
#[test]
fn helper_process_exits_successfully() {}
#[test]
fn helper_process_listens() {
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let _listener = TcpListener::bind(addr).expect("bind helper listener");
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_sleeps() {
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
std::thread::sleep(Duration::from_secs(10));
}
}
+98 -2
View File
@@ -34,8 +34,8 @@ pub struct ClientState {
/// Validates primitive wasm-bindgen values before application code runs.
///
/// Primitive arguments keep JavaScript from owning a second binary codec. The
/// ordinary effect result remains postcard-encoded by `hemx-core`.
/// Primitive arguments keep JavaScript from owning a second event/state codec.
/// The ordinary effect result uses `hemx-core`'s canonical `EffectBatch` codec.
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn decode_client_inputs(
@@ -149,6 +149,102 @@ mod tests {
); // req: client_local/008
}
#[test]
fn client_input_boundary_accepts_limits_and_preserves_values() {
let kind = "k".repeat(256);
let value = "v".repeat(64 * 1024);
let key = "x".repeat(1024);
let state = "s".repeat(1024 * 1024);
let (event, decoded_state) = decode_client_inputs(
1,
kind.clone(),
Some(value.clone()),
Some(true),
Some(key.clone()),
1,
state.clone(),
)
.expect("documented client-local limits are inclusive");
assert_eq!(
event,
ClientEvent {
kind,
value: Some(value),
checked: Some(true),
key: Some(key),
}
);
assert_eq!(decoded_state, ClientState { encoded: state });
// req: client_local/005 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test
}
#[test]
fn client_input_boundary_rejects_invalid_versions_and_payload_sizes() {
let decode = |event_version, kind, value, key, state_version, state| {
decode_client_inputs(event_version, kind, value, None, key, state_version, state)
};
for (result, expected) in [
(
decode(0, "click".into(), None, None, 1, String::new()),
"unsupported client-local event ABI version 0; expected 1",
),
(
decode(1, String::new(), None, None, 1, String::new()),
"invalid client-local event payload: event kind must contain 1..=256 bytes",
),
(
decode(1, "k".repeat(257), None, None, 1, String::new()),
"invalid client-local event payload: event kind must contain 1..=256 bytes",
),
(
decode(
1,
"input".into(),
Some("v".repeat(64 * 1024 + 1)),
None,
1,
String::new(),
),
"invalid client-local event payload: value exceeds 65536 bytes",
),
(
decode(
1,
"keydown".into(),
None,
Some("k".repeat(1025)),
1,
String::new(),
),
"invalid client-local event payload: key exceeds 1024 bytes",
),
(
decode(1, "click".into(), None, None, 0, String::new()),
"unsupported client-local state ABI version 0; expected 1",
),
(
decode(
1,
"click".into(),
None,
None,
1,
"s".repeat(1024 * 1024 + 1),
),
"invalid client-local state payload: state exceeds 1048576 bytes",
),
] {
assert_eq!(
result.expect_err("invalid client input must fail closed"),
expected
);
}
// req: client_local/008 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test
}
#[test]
fn client_handler_uses_the_ordinary_effect_wire_format() {
let fingerprint = BuildFingerprint(17);
+40 -28
View File
@@ -7,11 +7,37 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use thirtyfour::common::capabilities::firefox::FirefoxPreferences;
use thirtyfour::common::capabilities::firefox::{FirefoxCapabilities, FirefoxPreferences};
use thirtyfour::prelude::*;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
fn headless_firefox_capabilities(javascript_enabled: bool) -> WebDriverResult<FirefoxCapabilities> {
let mut preferences = FirefoxPreferences::new();
for preference in [
"app.normandy.enabled",
"app.shield.optoutstudies.enabled",
"app.update.enabled",
"browser.newtabpage.activity-stream.feeds.telemetry",
"browser.newtabpage.activity-stream.telemetry",
"datareporting.healthreport.uploadEnabled",
"datareporting.policy.dataSubmissionEnabled",
"extensions.systemAddon.update.enabled",
"network.captive-portal-service.enabled",
"network.connectivity-service.enabled",
"toolkit.telemetry.enabled",
] {
preferences.set(preference, false)?;
}
preferences.set("javascript.enabled", javascript_enabled)?;
preferences.set("services.settings.server", "data:,")?;
let mut capabilities = DesiredCapabilities::firefox();
capabilities.set_headless()?;
capabilities.set_preferences(preferences)?;
Ok(capabilities)
}
#[tokio::test]
async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> {
// req: client_local/005 req: client_local/009 req: client_local/010
@@ -31,8 +57,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -216,8 +241,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
@@ -409,11 +433,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut no_script_preferences = FirefoxPreferences::new();
no_script_preferences.set("javascript.enabled", false)?;
let mut no_script_caps = DesiredCapabilities::firefox();
no_script_caps.set_headless()?;
no_script_caps.set_preferences(no_script_preferences)?;
let no_script_caps = headless_firefox_capabilities(false)?;
let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?;
no_script_driver.goto(&host_url).await?;
no_script_driver
@@ -431,8 +451,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
let _ = no_script_driver.quit().await;
moved_without_script?;
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&webdriver_url, caps).await?;
let result = async {
driver.goto(&host_url).await?;
@@ -678,8 +697,7 @@ async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverRe
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -861,8 +879,7 @@ async fn kanban_queued_status_precedes_durable_projection_within_budget() -> Web
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -960,8 +977,7 @@ async fn kanban_quota_failure_is_fail_closed_and_recoverable() -> WebDriverResul
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -1103,8 +1119,7 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -1190,9 +1205,9 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
#[tokio::test]
async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> {
// test req: sync/014 req: performance/005
// test req: sync/014 req: performance/005 req: performance/007
const REPLAY_LIMIT: u64 = 64;
const REPLAY_BUDGET_MS: f64 = 100.0;
const REPLAY_BUDGET_MS: f64 = 250.0;
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -1214,8 +1229,7 @@ async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> {
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -1323,8 +1337,7 @@ async fn kanban_corrupt_command_refuses_projection_and_recovers() -> WebDriverRe
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
@@ -1426,8 +1439,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let caps = headless_firefox_capabilities(true)?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
+234 -5
View File
@@ -12,6 +12,11 @@ fn main() -> ExitCode {
Some("test") | None => run_test_plan(),
Some("html-examples-smoke") => run_html_examples_smoke(),
Some("bench") => run_bench_plan(),
Some("mutation") => {
let package = args.next();
let shard = args.next();
run_mutation_plan(package.as_deref(), shard.as_deref())
}
Some("workout") => {
let subcommand = args.next();
let operand = args.next();
@@ -37,7 +42,7 @@ fn main() -> ExitCode {
fn print_help() {
println!(
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_MUTEST_BIN=PATH mutest executable (default: mutest)\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
);
}
@@ -81,7 +86,7 @@ fn run_html_examples_smoke() -> ExitCode {
("delete row", DELETE_ROW_SMOKE),
("lazy load", LAZY_LOAD_SMOKE),
("click-to-load load more", CLICK_TO_LOAD_SMOKE),
("infinite/reveal rows", INFINITE_SCROLL_SMOKE),
("infinite/reveal rows", INFINITE_SCROLL_SMOKE), // req: test/006 test; req: runtime/007
("value select", VALUE_SELECT_SMOKE),
("reset user input", RESET_INPUT_SMOKE),
];
@@ -1248,6 +1253,185 @@ fn run_bench_plan() -> ExitCode {
ExitCode::SUCCESS
}
const MUTATION_PACKAGES: &[&str] = &[
"hemx",
"hemx-axum",
"hemx-build",
"hemx-core",
"hemx-derive",
"hemx-host",
"hemx-js",
"hemx-sync",
"hemx-sync-macros",
"hemx-test",
"hemx-wasm",
];
fn mutation_packages(package: Option<&str>) -> Result<Vec<&'static str>, String> {
match package {
None | Some("all") => Ok(MUTATION_PACKAGES.to_vec()),
Some(package) => MUTATION_PACKAGES
.iter()
.copied()
.find(|candidate| *candidate == package)
.map(|package| vec![package])
.ok_or_else(|| {
format!(
"unknown mutation package `{package}`; expected `all` or one of: {}",
MUTATION_PACKAGES.join(", ")
)
}),
}
}
fn mutation_shard(shard: Option<&str>) -> Result<Option<String>, String> {
let Some(shard) = shard else {
return Ok(None);
};
let Some((index, total)) = shard.split_once('/') else {
return Err(format!(
"invalid mutation shard `{shard}`; expected SHARD/TOTAL such as `1/4`"
));
};
let index = index
.parse::<usize>()
.map_err(|_| format!("invalid mutation shard `{shard}`; SHARD must be an integer"))?;
let total = total
.parse::<usize>()
.map_err(|_| format!("invalid mutation shard `{shard}`; TOTAL must be an integer"))?;
if total < 2 || index == 0 || index > total {
return Err(format!(
"invalid mutation shard `{shard}`; require TOTAL >= 2 and 1 <= SHARD <= TOTAL"
));
}
// mutest numbers shards from zero; xtask presents the conventional 1..=TOTAL surface.
Ok(Some(format!("{}/{total}", index - 1)))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct MutationConcurrency {
jobs: usize,
test_threads: usize,
jobserver: bool,
}
fn mutation_concurrency(package: &str, budget: Budget) -> MutationConcurrency {
if package == "hemx-wasm" {
// Browser tests spawn nested Cargo builds plus Firefox. A parent jobserver can
// retain every token while those builds wait, and parallel browser sessions
// make timing assertions meaningless. Keep this package sequential and let
// its nested Cargo commands own the detected machine budget.
MutationConcurrency {
jobs: 1,
test_threads: 1,
jobserver: false,
}
} else {
MutationConcurrency {
jobs: budget.jobs,
test_threads: budget.test_threads,
jobserver: true,
}
}
}
fn run_mutation_plan(package: Option<&str>, shard: Option<&str>) -> ExitCode {
let packages = match mutation_packages(package) {
Ok(packages) => packages,
Err(error) => {
eprintln!("{error}");
return ExitCode::from(2);
}
};
let requested_shard = shard.map(str::to_owned);
let native_shard = match mutation_shard(shard) {
Ok(shard) => shard,
Err(error) => {
eprintln!("{error}");
return ExitCode::from(2);
}
};
let budget = Budget::detect().with_jobs(4);
budget.report();
let mutest = env::var_os("HEMX_MUTEST_BIN").unwrap_or_else(|| "mutest".into());
let output_root = workspace_root().join("target/mutest");
if let Err(error) = fs::create_dir_all(&output_root) {
eprintln!(
"failed to create mutation output directory {}: {error}",
output_root.display()
);
return ExitCode::FAILURE;
}
for package in packages {
eprintln!("\n==> mutation: {package}");
let output = requested_shard.as_ref().map_or_else(
|| output_root.join(package),
|shard| {
output_root
.join(package)
.join(format!("shard-{}", shard.replace('/', "-of-")))
},
);
if let Some(parent) = output.parent() {
if let Err(error) = fs::create_dir_all(parent) {
eprintln!(
"failed to create mutation output parent {}: {error}",
parent.display()
);
return ExitCode::FAILURE;
}
}
let concurrency = mutation_concurrency(package, budget);
let jobs = concurrency.jobs.to_string();
let mut command = Command::new(&mutest);
command
.current_dir(workspace_root())
// Mutest runs several cargo-test processes concurrently. Bound each nested
// harness by the same resource budget so browser-backed packages cannot
// multiply into Cargo's unconstrained default thread count.
// req: test/004 req: test/020
.env("RUST_TEST_THREADS", concurrency.test_threads.to_string())
.args(["-p", package, "-j", &jobs]);
if concurrency.jobserver {
command.args(["--jobserver-tasks", &jobs]);
} else {
command.args(["--jobserver", "false"]);
}
command
.args([
"--colors",
"never",
"--annotations",
"none",
"--no-times",
"--minimum-test-timeout",
"120",
"--exhaustive",
"-o",
])
.arg(output);
if let Some(shard) = &native_shard {
command.args(["--shard", shard]);
}
let status = command.status();
match status {
Ok(status) if status.success() => {}
Ok(status) => {
eprintln!("mutation: {package} failed with {status}");
return ExitCode::from(status.code().unwrap_or(1) as u8);
}
Err(error) => {
eprintln!("failed to run mutest for {package}: {error}");
return ExitCode::FAILURE;
}
}
}
ExitCode::SUCCESS
}
#[derive(Clone, Copy, Debug)]
struct Budget {
cpus: usize,
@@ -1473,13 +1657,58 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
mod tests {
use super::{
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release,
workout_mobile_manifest, workspace_root, write_workout_mobile_release, Budget,
WorkoutMobileConfig,
create_workout_app, mobile_external_blockers, mutation_concurrency, mutation_packages,
mutation_shard, origin_host, verify_workout_mobile_release, workout_mobile_manifest,
workspace_root, write_workout_mobile_release, Budget, MutationConcurrency,
WorkoutMobileConfig, MUTATION_PACKAGES,
};
use std::fs;
use std::path::PathBuf;
#[test]
fn mutation_plan_selects_only_elected_packages() {
// req: test/020 test req: test/021 test req: test/022 test
assert_eq!(mutation_packages(None).unwrap(), MUTATION_PACKAGES);
assert_eq!(mutation_packages(Some("all")).unwrap(), MUTATION_PACKAGES);
assert_eq!(mutation_packages(Some("hemx-js")).unwrap(), ["hemx-js"]);
let error = mutation_packages(Some("example-app")).unwrap_err();
assert!(error.contains("unknown mutation package `example-app`"));
assert!(error.contains("hemx-core"));
assert_eq!(mutation_shard(None).unwrap(), None);
assert_eq!(mutation_shard(Some("1/4")).unwrap().as_deref(), Some("0/4"));
assert_eq!(mutation_shard(Some("4/4")).unwrap().as_deref(), Some("3/4"));
for invalid in ["1", "a/4", "1/a", "0/4", "5/4", "1/1"] {
assert!(
mutation_shard(Some(invalid)).unwrap_err().contains(invalid),
"missing invalid shard in diagnostic"
);
}
// test req: test/022 req: test/023
}
#[test]
fn browser_mutation_runs_one_harness_without_a_parent_jobserver() {
let budget = Budget::from_resources(22, Some(27), None, None, true).with_jobs(4);
assert_eq!(
mutation_concurrency("hemx-wasm", budget),
MutationConcurrency {
jobs: 1,
test_threads: 1,
jobserver: false,
}
);
assert_eq!(
mutation_concurrency("hemx-core", budget),
MutationConcurrency {
jobs: 4,
test_threads: 4,
jobserver: true,
}
);
// test req: test/004 req: test/020
}
#[test]
fn verification_steps_resolve_the_workspace_independent_of_caller_directory() {
assert!(workspace_root().join("Cargo.toml").is_file()); // req: test/004
+13 -4
View File
@@ -5,10 +5,10 @@
use hemx_core::SafeHtml;
#[cfg(not(target_arch = "wasm32"))]
use hemx_core::{Effect, KeyedSlot, Slot};
use hemx_core::{KeyedSlot, Slot};
pub use hemx_core::{
navigate, push, redirect, replace, CssClass, CssClasses, Form, FormContract, FormControlKind,
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract, FormControlKind,
FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
};
@@ -93,8 +93,7 @@ pub mod __private {
#[cfg(not(target_arch = "wasm32"))]
fn render_template(view: &impl hemplate::Hemplate) -> Html {
let mut html = String::with_capacity(view.size_hint());
view.render_into(&mut html)
.expect("hemplate view renders into hemx effect payload");
view.render_into(&mut html).unwrap();
__private::html_trusted(html)
}
@@ -261,4 +260,14 @@ mod tests {
assert_eq!(html.as_str(), "<strong>ok</strong>");
}
#[test]
fn html_string_views_preserve_the_rendered_fragment() {
let html = crate::__private::html_trusted("<p>hello</p>");
assert_eq!(html.as_str(), "<p>hello</p>");
assert_eq!(html.as_ref(), "<p>hello</p>");
assert_eq!(html.to_string(), "<p>hello</p>");
assert_eq!(html.into_string(), "<p>hello</p>");
// test req: public_api/005
}
}