Compare commits

...

69 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
slhx agent e7211df4c5 fix(forms): support reserved control identifiers
Normalize raw Rust identifiers when matching generated form contracts so reserved HTML control names remain usable.

req: form/004
2026-07-16 10:00:43 +02:00
slhx agent a04773d016 fix(deps): replace retired transitive crates 2026-07-14 09:44:19 +02:00
slhx agent 48d6ea31be fix(release): complete local readiness gates 2026-07-14 01:45:28 +02:00
slhx agent 23b1d75f7f fix(requirements): migrate to installed redgate format 2026-07-14 01:37:05 +02:00
slhx agent 025d9c1563 fix(release): restore canonical local gate
req: public_api/001

req: v1_release/007
2026-07-14 01:24:31 +02:00
slhx agent 90e8c3ced5 test(versioning): pin supported compatibility matrix
req: abi/001

req: abi/003

req: v1_release/007
2026-07-14 01:09:07 +02:00
slhx agent 6f58453aae feat(saas): enforce strict response policy
req: security/006

req: operations/006

req: operations/008
2026-07-14 00:59:06 +02:00
slhx agent 4fc5e53adf feat(saas): correlate mutation diagnostics
req: operations/001

req: operations/003

req: security/008
2026-07-14 00:53:04 +02:00
slhx agent 4e0d9ebaff feat(saas): expose redacted operational signals
req: operations/003

req: operations/005

req: operations/007

req: security/008

req: v1_release/003
2026-07-14 00:48:55 +02:00
slhx agent ef8e38adf8 feat(saas): prove durable authenticated mutation
req: auth/001

req: auth/002

req: auth/004

req: security/004

req: v1_release/003
2026-07-14 00:40:46 +02:00
52 changed files with 6265 additions and 1683 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',
]
+8 -6
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. - First split intent into broad error classes: what can go wrong, and what outcome must hold.
- Test the largest risky classes before narrow examples. - Test the largest risky classes before narrow examples.
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs. - 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. - Do not over-codify existing behavior while direction is still moving.
- Add narrow concrete tests only after requirements converge into a stable direction. - Add narrow concrete tests only after requirements converge into a stable direction.
@@ -44,7 +45,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- `redgate refs` — show `req:` citations found in the repo. - `redgate refs` — show `req:` citations found in the repo.
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations. - `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
- `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup. - `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup.
- `redgate health --strict` — fail on hard errors: empty requirements, duplicate IDs, or stale citations. - `redgate refs` — with the installed CLI, parse the elected requirement format and audit repository citations; `redgate health` additionally enforces a newer prescriptive-row style not yet elected by this requirements corpus.
- `redgate agents` — print this starter template; review, shrink, and edit before committing. - `redgate agents` — print this starter template; review, shrink, and edit before committing.
## Local guidance ## Local guidance
@@ -52,20 +53,21 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run. - 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. - 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. - Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; the full path should stay within a documented 10 minute local timeout or be split into deterministic shards under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016 - Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; mutation shards use one-based `1..=TOTAL` numbering, allow the wrapper's 120-second floor for compiler probes, and rely on the wrapper's package-specific nested concurrency rather than direct `mutest` invocation. The full path should stay within a documented 10 minute local timeout or use every deterministic mutation shard under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016 req: test/020 req: test/022 req: test/023
- Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019 - Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019
- Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008 - Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008
- Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013 - Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010 - hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata, the versioned canonical hemx `EffectBatch` codec, postcard surface facts, and form-encoded public requests—not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008 - Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
- 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 - Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 - Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008 - `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003 - Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. 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, postcard EffectBatch application, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 - JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, 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 - Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012
- Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002 - Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003 - Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003
Generated
+95 -84
View File
@@ -117,12 +117,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -202,9 +196,9 @@ dependencies = [
[[package]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [ dependencies = [
"cssparser-macros", "cssparser-macros",
"dtoa-short", "dtoa-short",
@@ -225,12 +219,22 @@ dependencies = [
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version",
"syn", "syn",
] ]
@@ -313,6 +317,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -383,15 +393,6 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -705,14 +706,12 @@ dependencies = [
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
dependencies = [ dependencies = [
"log", "log",
"mac",
"markup5ever", "markup5ever",
"match_token",
] ]
[[package]] [[package]]
@@ -1012,27 +1011,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
dependencies = [ dependencies = [
"log", "log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril", "tendril",
] "web_atoms",
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -1077,7 +1062,7 @@ dependencies = [
"httparse", "httparse",
"memchr", "memchr",
"mime", "mime",
"spin", "spin 0.9.8",
"version_check", "version_check",
] ]
@@ -1119,8 +1104,15 @@ dependencies = [
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
dependencies = [
"pastey",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
@@ -1130,19 +1122,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.11.3" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [ dependencies = [
"phf_macros", "phf_macros",
"phf_shared", "phf_shared",
"serde",
] ]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.11.3" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared",
@@ -1150,19 +1143,19 @@ dependencies = [
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.11.3" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [ dependencies = [
"fastrand",
"phf_shared", "phf_shared",
"rand 0.8.6",
] ]
[[package]] [[package]]
name = "phf_macros" name = "phf_macros"
version = "0.11.3" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared",
@@ -1173,9 +1166,9 @@ dependencies = [
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.11.3" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [ dependencies = [
"siphasher", "siphasher",
] ]
@@ -1253,14 +1246,14 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -1301,15 +1294,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
@@ -1317,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core 0.9.5", "rand_core",
] ]
[[package]] [[package]]
@@ -1327,15 +1311,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.9.5", "rand_core",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -1441,6 +1419,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@@ -1496,9 +1483,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "scraper" name = "scraper"
version = "0.23.1" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2" checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb"
dependencies = [ dependencies = [
"cssparser", "cssparser",
"ego-tree", "ego-tree",
@@ -1511,23 +1498,29 @@ dependencies = [
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.26.0" version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cssparser", "cssparser",
"derive_more", "derive_more",
"fxhash",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf", "phf",
"phf_codegen", "phf_codegen",
"precomputed-hash", "precomputed-hash",
"rustc-hash",
"servo_arc", "servo_arc",
"smallvec", "smallvec",
] ]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -1673,8 +1666,15 @@ dependencies = [
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
dependencies = [
"spin 0.12.2",
]
[[package]]
name = "spin"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b"
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
@@ -1690,22 +1690,21 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared", "phf_shared",
"precomputed-hash", "precomputed-hash",
"serde",
] ]
[[package]] [[package]]
name = "string_cache_codegen" name = "string_cache_codegen"
version = "0.5.4" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared",
@@ -2210,6 +2209,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web_atoms"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.7" version = "1.0.7"
+6
View File
@@ -9,3 +9,9 @@ edition = "2021"
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true
# Upstreams still request retired package identities. These tiny bridges re-export
# the maintained successors without introducing a second implementation.
[patch.crates-io]
paste = { path = "compat/paste" }
spin = { path = "compat/spin" }
+17 -74
View File
File diff suppressed because one or more lines are too long
+8 -7
View File
@@ -5,13 +5,14 @@ handlers, and return generated UI commands. The browser receives checked UI
commands; ordinary server-first apps do not need a frontend framework, commands; ordinary server-first apps do not need a frontend framework,
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001 handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
Status: the repository has a proven server-first/page-enhanced baseline, a Status: the evidence-backed v1 behavior slices are implemented and browser-proven:
compile-tested SaaS tutorial, an advanced Kanban sketch, and a full techdemo. server-first/page-enhanced behavior, client-local WASM, durable offline/sync, the
Full v1 is not yet feature-complete: client-local WASM, durable offline/sync, the multiplayer Kanban milestone, and the production reference. Local workspace,
production integration reference, and their browser/recovery gates remain active. browser, performance, documentation, and canonical-example gates pass. The
See `docs/v1-product-evidence.md` for the evidence-backed product boundary, warning-denied vulnerability and source audits are clean; strict license closure
`REQUIREMENTS.md` for authority, `PLAN.md` for implementation order, and awaits a repository license allowlist. See `docs/v1-product-evidence.md` for the
`docs/v1-readiness.md` for proven baseline evidence. No publishing is implied. product boundary, `REQUIREMENTS.md` for authority, `PLAN.md` for execution state,
and `docs/v1-readiness.md` for evidence. No publishing is implied.
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
+468 -837
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "paste"
version = "1.0.15"
edition = "2021"
rust-version = "1.56"
publish = false
license = "MIT OR Apache-2.0"
description = "Workspace compatibility alias from paste to its maintained successor pastey"
[dependencies]
pastey = "=0.2.3"
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still name the unmaintained
//! `paste` crate. New code should depend on `pastey` directly.
pub use pastey::paste;
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "spin"
version = "0.9.8"
edition = "2021"
rust-version = "1.71"
publish = false
license = "MIT"
description = "Workspace compatibility alias from yanked spin 0.9 to maintained spin 0.12"
[features]
default = []
spin_mutex = ["spin_next/spin_mutex"]
[dependencies]
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still require yanked `spin 0.9`.
//! New code should depend on the maintained `spin` release directly.
pub use spin_next::*;
+1 -1
View File
@@ -86,4 +86,4 @@ Current recurring checks cover the most common classes:
Before claiming the diagnostics story is closed for v1, run those gates plus Before claiming the diagnostics story is closed for v1, run those gates plus
`cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and
`redgate health --strict` on a clean tree. req: test/003 req: test/004 `redgate refs` on a clean tree. The installed CLI's `health` mode additionally requires every historical row to use its newer prescriptive wording, which is not the elected compatibility gate for this corpus. req: test/003 req: test/004
+1 -1
View File
@@ -121,7 +121,7 @@ Before promoting a release:
```sh ```sh
cargo run -p hemx-xtask -- test cargo run -p hemx-xtask -- test
cargo check --workspace cargo check --workspace
redgate health --strict redgate refs
``` ```
Then verify deployment-specific behavior: Then verify deployment-specific behavior:
+16 -5
View File
@@ -10,10 +10,20 @@ closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: p
## Current status ## Current status
- Server-first and page-enhanced baseline: proven by the evidence below. - Server-first and page-enhanced baseline: proven by the evidence below.
- Client-local WASM: not implemented; first active v1 slice. - Client-local WASM: real generated-resource browser/WASM execution proven.
- Durable offline/sync: contracts shaped, implementation blocked by the local - Durable offline/sync and multiplayer milestone: framework-owned replay,
execution boundary. acknowledgement, convergence, presence, recovery, and accessibility proven.
- Full production reference and v1 closure matrix: not yet proven. - Production reference: authenticated mutation, origin/CSRF denial, atomic
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
metrics, CSP, and mixed-build fail-closed recovery proven.
- V1 closure matrix: not closed. The recorded local workspace, browser,
performance, docs, and example gates pass, warning-denied vulnerability and
source audits are clean, and the mutation-applicable library/proc-macro matrix
has no unexplained survivors. Strict license closure still awaits an owner-chosen
license for 20 currently unlicensed workspace packages and an allowlist decision
for Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT,
Unicode-3.0, and Unlicense dependencies; this blocks a production-ready claim.
req: test/020 req: test/021 req: v1_release/006
- Publishing and deployment: explicitly unauthorized. - Publishing and deployment: explicitly unauthorized.
## Baseline evidence ## Baseline evidence
@@ -162,6 +172,7 @@ Run these on the final tree before GOAL_DONE:
```sh ```sh
cargo run -p hemx-xtask -- test cargo run -p hemx-xtask -- test
cargo run -p hemx-xtask -- mutation
cargo check --workspace cargo check --workspace
cargo test -p hemx-saas-example cargo test -p hemx-saas-example
cargo test -p hemx-v0-examples cargo test -p hemx-v0-examples
@@ -171,7 +182,7 @@ cargo test -p hemx-derive --test compile_fail
cargo test -p hemx-test --test examples_contract cargo test -p hemx-test --test examples_contract
redgate list redgate list
redgate refs redgate refs
redgate health --strict redgate health
git diff --check git diff --check
``` ```
+26 -3
View File
@@ -39,6 +39,22 @@ An incompatible wire/runtime change must bump the relevant ABI version and cause
old pages or old runtimes to refuse partial updates rather than silently applying old pages or old runtimes to refuse partial updates rather than silently applying
wrong effects. req: abi/002 req: abi/003 req: abi/004 req: failure/005 wrong effects. req: abi/002 req: abi/003 req: abi/004 req: failure/005
### Supported compatibility matrix
The v1 support claim is deliberately narrow:
| Boundary | Supported | Fails closed when |
|---|---|---|
| Rust toolchain | stable Rust, workspace edition 2021 | an unsupported compiler cannot build the workspace |
| Browser/WASM | Firefox browser suite plus the generated real-WASM path | WASM/bootstrap cannot load or bind |
| Effect wire | ABI `1` only | decoding preserves the version, `is_compatible()` is false, and runtimes refuse application |
| Generated resources | one matching build fingerprint | a stale fingerprint receives reload recovery instead of mutation |
| Durable sync | schema `1`; legacy flat schema-1 records upgrade in place | unknown schema or malformed projection is rejected |
| Runtime set | same-tree `hemx-js`, `hemx-wasm`, generated bindings, and framework sync runtime | mismatched assets have no compatibility guarantee |
| Canonical examples | `v0`, Kanban, client-local, and SaaS workspace packages | an example no longer builds or its focused proof fails |
No support claim is made for untested browser engines, future wire/schema versions, or arbitrary cross-release runtime mixing. req: abi/001 req: abi/003 req: public_api/003 req: v1_release/007
### Advanced escape hatches ### Advanced escape hatches
These are public but advanced. They may evolve faster, but every change still These are public but advanced. They may evolve faster, but every change still
@@ -118,7 +134,7 @@ include upgrade notes with this shape:
```sh ```sh
cargo run -p hemx-xtask -- test cargo run -p hemx-xtask -- test
cargo check --workspace cargo check --workspace
redgate health --strict redgate refs
``` ```
```` ````
@@ -126,15 +142,22 @@ redgate health --strict
Before tagging a v1-compatible release: Before tagging a v1-compatible release:
- `examples/v0` and `examples/saas` compile and test without raw ids, raw effects, - `examples/v0`, `examples/client_local`, `examples/kanban`, and `examples/saas`
compile and their package tests pass; v0 and SaaS remain the canonical public
surface examples without raw ids, raw effects,
selector targeting, manual registries, raw render/lower calls, or user-authored selector targeting, manual registries, raw render/lower calls, or user-authored
UI JavaScript in the normal path. req: examples/004 req: examples/005 UI JavaScript in the normal path. req: examples/004 req: examples/005
- `docs/diagnostics.md` describes any new common error class in user language. - `docs/diagnostics.md` describes any new common error class in user language.
req: diagnostics/001 req: diagnostics/002 req: diag/001 req: diag/002
- The canonical local release gate is `cargo run -p hemx-xtask -- test`; there
are no separate `public-api` or `ownership-check` xtask subcommands.
- `docs/recipes/deploy-versioning.md` remains accurate for runtime asset and - `docs/recipes/deploy-versioning.md` remains accurate for runtime asset and
fingerprint behavior. fingerprint behavior.
- Any incompatible generated ABI/runtime change bumps the relevant ABI/fingerprint - Any incompatible generated ABI/runtime change bumps the relevant ABI/fingerprint
inputs and has tests for fail-closed behavior. req: abi/005 inputs and has tests for fail-closed behavior. req: abi/005
- The checked-in ABI-v1 byte fixture in `hemx-core/tests/effect_batch.rs`, the
legacy flat durable-record browser migration, and canonical example package
tests all pass. req: abi/001 req: abi/003 req: v1_release/007
- Advanced APIs touched by the release are still named as escape hatches in docs. - Advanced APIs touched by the release are still named as escape hatches in docs.
- Upgrade notes state whether users must regenerate code, redeploy the - Upgrade notes state whether users must regenerate code, redeploy the
helper-provided runtime asset, or change app code. helper-provided runtime asset, or change app code.
+1 -1
View File
@@ -15,7 +15,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.25"
hemx-test = { path = "../../hemx-test" } hemx-test = { path = "../../hemx-test" }
[build-dependencies] [build-dependencies]
+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` | | `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` | | `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` | | `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` | | `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` | | `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` | | `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 +} {+ row +}
</template> </template>
</ul> </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"> <input type="hidden" name="request" value="more">
<button type="submit">Reveal more rows</button> <button type="submit">Reveal more rows</button>
</form> </form>
+1 -1
View File
@@ -39,7 +39,7 @@ hemplate = { path = "../../../hemplate/hemplate" }
[dev-dependencies] [dev-dependencies]
hemx-test = { path = "../../hemx-test" } hemx-test = { path = "../../hemx-test" }
scraper = "0.23" scraper = "0.25"
thirtyfour = "0.35" thirtyfour = "0.35"
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
+10 -6
View File
@@ -1,6 +1,9 @@
#[hemx::surface] #[hemx::surface]
pub mod ui {} pub mod ui {}
#[cfg(feature = "client")]
use hemx_sync::SyncEffect as DurableSync;
#[cfg(feature = "client")] #[cfg(feature = "client")]
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
struct CardId(String); struct CardId(String);
@@ -87,15 +90,15 @@ pub fn reorder_card(
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0), Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
None => ui::client_board::client_cards.move_to_end(card.clone()), None => ui::client_board::client_cards.move_to_end(card.clone()),
}; };
let projection = hemx::IntoEffect::into_batch( DurableSync::durable(
patch,
( (
move_effect, move_effect,
ui::client_board::client_notice ui::client_board::client_notice
.text(format!("Moved {card} with {}", projected.input_kind)), .text(format!("Moved {card} with {}", projected.input_kind)),
), ),
ui::BUILD_FINGERPRINT, ui::BUILD_FINGERPRINT,
); )
hemx_sync::SyncEffect::durable(patch, projection)
} }
#[cfg(all(test, feature = "client"))] #[cfg(all(test, feature = "client"))]
@@ -118,9 +121,10 @@ mod client_tests {
) )
.into_batch(ui::BUILD_FINGERPRINT); .into_batch(ui::BUILD_FINGERPRINT);
assert_eq!(batch.ops.len(), 3); assert_eq!(batch.ops.len(), 3);
assert!( let wire = String::from_utf8_lossy(&batch.to_wire()).into_owned();
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":[")) assert!(wire.contains(hemx_sync::PATCH_EVENT));
); assert!(wire.contains("$hemx-interaction"));
assert!(wire.contains("\"projection\":["));
} }
} }
+4 -2
View File
@@ -15,7 +15,9 @@ use hemx_axum::{
use hemx_kanban_example::ui::board::{self as board}; use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board; use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui}; use hemx_kanban_example::ui::{self, board as board_ui};
use hemx_sync::{Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect}; use hemx_sync::{
Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect as FrameworkSync,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::Infallible; use std::convert::Infallible;
@@ -663,7 +665,7 @@ async fn sync_ack(
acknowledgement.clone() acknowledgement.clone()
}; };
let batch = ( let batch = (
SyncEffect::ack(board::atoms::sync_ack), FrameworkSync::ack(board::atoms::sync_ack),
board::sync_status.text(format!( board::sync_status.text(format!(
"Canonical acknowledgement {} at server sequence {}", "Canonical acknowledgement {} at server sequence {}",
acknowledgement.command_id, acknowledgement.server_sequence acknowledgement.command_id, acknowledgement.server_sequence
+3 -2
View File
@@ -9,7 +9,7 @@ const MIGRATION_KEY = "commandSchemaMigration";
const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1"; const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1";
const EXPORT_SCHEMA = 1; const EXPORT_SCHEMA = 1;
const MAX_REPLAY_COMMANDS = 64; 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 SESSION = "hemx-kanban-session-v1";
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]'; 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); if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
commands.forEach(validate); commands.forEach(validate);
const replayStarted = performance.now(); 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; const replayMs = performance.now() - replayStarted;
root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3)); root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3));
root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS)); root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS));
+1 -1
View File
@@ -20,7 +20,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.25"
hemx-test = { path = "../../hemx-test" } hemx-test = { path = "../../hemx-test" }
[build-dependencies] [build-dependencies]
+6 -3
View File
@@ -7,7 +7,9 @@ What it proves:
- typed form/newtype inputs for project creation - typed form/newtype inputs for project creation
- auth/session context passed through normal Rust state - auth/session context passed through normal Rust state
- CSRF-safe mutation checked before persistence - CSRF-safe mutation checked before persistence
- local in-memory persistence adapter instead of a vendored SQL/auth provider - local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider
- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, CSRF token, and matching generated build fingerprint when supplied
- `/health/live`, dependency-aware `/health/ready`, and aggregate `/metrics` endpoints with secret-free structured diagnostics
- generated form, slot, keyed row, page-swap, and live-status commands - generated form, slot, keyed row, page-swap, and live-status commands
- page shell with plain CSS and one explicit metrics island script - page shell with plain CSS and one explicit metrics island script
- compile-time surface generation plus interaction tests - compile-time surface generation plus interaction tests
@@ -20,11 +22,12 @@ What it deliberately keeps out of the tutorial crate:
- provider credentials, external services, migrations, or browser automation - provider credentials, external services, migrations, or browser automation
- billing, account administration, or other SaaS platform scope - billing, account administration, or other SaaS platform scope
Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009
Run: Run:
```sh ```sh
cargo run -p hemx-saas-example HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example
cargo test -p hemx-saas-example --test production_reference
cargo test -p hemx-saas-example cargo test -p hemx-saas-example
``` ```
+298 -32
View File
@@ -2,31 +2,45 @@
pub mod ui {} pub mod ui {}
use hemplate::Hemplate; use hemplate::Hemplate;
use hemx::{push, Html, IntoEffect}; use hemx::{Html, IntoEffect};
use hemx_axum::{ use hemx_axum::{
interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure,
Registry, State, Registry, State,
}; };
use std::convert::Infallible; use std::convert::Infallible;
use std::fmt::Display; use std::fmt::Display;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration;
use ui::dashboard; use ui::dashboard;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SessionId(u64);
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session { pub struct Session {
session_id: SessionId,
user_id: UserId, user_id: UserId,
email: String, email: String,
csrf: CsrfToken, csrf: CsrfToken,
origin: String,
bearer: String,
} }
impl Session { impl Session {
pub fn demo() -> Self { pub fn demo() -> Self {
Self { Self {
session_id: SessionId(1),
user_id: UserId(42), user_id: UserId(42),
email: "founder@example.com".to_owned(), email: "founder@example.com".to_owned(),
csrf: CsrfToken("demo-csrf".to_owned()), csrf: CsrfToken("demo-csrf".to_owned()),
origin: "http://127.0.0.1:3000".to_owned(),
bearer: "Bearer demo-session".to_owned(),
} }
} }
} }
@@ -91,12 +105,56 @@ pub struct ProjectRecord {
owner: String, owner: String,
} }
impl ProjectRecord {
fn encode(&self) -> String {
format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name)
}
fn decode(line: &str) -> io::Result<Self> {
let mut fields = line.splitn(3, '\t');
let id = fields
.next()
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?;
let owner = fields
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?;
let name = fields
.next()
.filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t']))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?;
Ok(Self {
id: ProjectId(id),
name: name.to_owned(),
owner: owner.to_owned(),
})
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct LocalProjectStore { pub struct LocalProjectStore {
projects: Arc<Mutex<Vec<ProjectRecord>>>, projects: Arc<Mutex<Vec<ProjectRecord>>>,
path: Option<Arc<PathBuf>>,
} }
impl LocalProjectStore { impl LocalProjectStore {
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let projects = match fs::read_to_string(&path) {
Ok(contents) => contents
.lines()
.map(ProjectRecord::decode)
.collect::<io::Result<Vec<_>>>()?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error),
};
Ok(Self {
projects: Arc::new(Mutex::new(projects)),
path: Some(Arc::new(path)),
})
}
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> { pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
if name.as_str() == "fail-store" { if name.as_str() == "fail-store" {
return Err(AppError::StoreUnavailable); return Err(AppError::StoreUnavailable);
@@ -109,19 +167,103 @@ impl LocalProjectStore {
name: name.as_str().to_owned(), name: name.as_str().to_owned(),
owner: session.email.clone(), owner: session.email.clone(),
}; };
projects.push(record.clone()); let mut next = projects.clone();
next.push(record.clone());
if let Some(path) = self.path.as_deref() {
persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?;
}
*projects = next;
Ok(record) Ok(record)
} }
pub fn list(&self) -> Vec<ProjectRecord> { pub fn list(&self) -> Vec<ProjectRecord> {
self.projects.lock().unwrap().clone() self.projects.lock().unwrap().clone()
} }
fn ready(&self) -> bool {
let Some(path) = self.path.as_deref() else {
return true;
};
if path.exists() && !path.is_file() {
return false;
}
path.parent().unwrap_or_else(|| Path::new(".")).is_dir()
}
}
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
let temporary = path.with_extension("tmp");
let mut file = fs::File::create(&temporary)?;
for project in projects {
file.write_all(project.encode().as_bytes())?;
}
file.sync_all()?;
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(temporary);
return Err(error);
}
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestCorrelationId(String);
impl Display for RequestCorrelationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
pub struct MutationDiagnostic {
pub request_id: RequestCorrelationId,
pub session_id: SessionId,
pub user_id: UserId,
pub outcome: &'static str,
pub duration_micros: u64,
}
pub trait DiagnosticSink: Send + Sync {
fn record(&self, diagnostic: MutationDiagnostic);
}
struct StderrDiagnosticSink;
impl DiagnosticSink for StderrDiagnosticSink {
fn record(&self, diagnostic: MutationDiagnostic) {
eprintln!(
"event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}",
diagnostic.request_id,
diagnostic.session_id.0,
diagnostic.user_id.0,
diagnostic.outcome,
diagnostic.duration_micros
);
}
}
#[derive(Default)]
struct MutationMetrics {
attempts: AtomicU64,
succeeded: AtomicU64,
denied: AtomicU64,
invalid: AtomicU64,
mismatch: AtomicU64,
failed: AtomicU64,
duration_micros: AtomicU64,
next_request_id: AtomicU64,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct AppContext { pub struct AppContext {
session: Session, session: Session,
store: LocalProjectStore, store: LocalProjectStore,
metrics: Arc<MutationMetrics>,
diagnostics: Arc<dyn DiagnosticSink>,
} }
impl AppContext { impl AppContext {
@@ -129,9 +271,40 @@ impl AppContext {
Self { Self {
session: Session::demo(), session: Session::demo(),
store: LocalProjectStore::default(), store: LocalProjectStore::default(),
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
} }
} }
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
let mut session = Session::demo();
session.origin = origin.into();
Ok(Self {
session,
store: LocalProjectStore::durable(path)?,
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
})
}
pub fn authorize_mutation(
&self,
bearer: &str,
csrf: &CsrfToken,
origin: &str,
) -> Result<(), AppError> {
if self.session.email.is_empty() || bearer != self.session.bearer {
return Err(AppError::MissingSession);
}
if csrf != &self.session.csrf {
return Err(AppError::CsrfRejected);
}
if origin != self.session.origin {
return Err(AppError::OriginRejected);
}
Ok(())
}
pub fn csrf(&self) -> &CsrfToken { pub fn csrf(&self) -> &CsrfToken {
&self.session.csrf &self.session.csrf
} }
@@ -139,12 +312,96 @@ impl AppContext {
pub fn projects(&self) -> Vec<ProjectRecord> { pub fn projects(&self) -> Vec<ProjectRecord> {
self.store.list() self.store.list()
} }
pub fn ready(&self) -> bool {
self.store.ready()
}
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn next_request_id(&self) -> RequestCorrelationId {
let sequence = self
.metrics
.next_request_id
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
RequestCorrelationId(format!("req-{}-{sequence}", std::process::id()))
}
pub fn record_mutation(
&self,
request_id: RequestCorrelationId,
outcome: &'static str,
duration: Duration,
) {
self.metrics.attempts.fetch_add(1, Ordering::Relaxed);
match outcome {
"succeeded" => &self.metrics.succeeded,
"denied" => &self.metrics.denied,
"invalid" => &self.metrics.invalid,
"mismatch" => &self.metrics.mismatch,
_ => &self.metrics.failed,
}
.fetch_add(1, Ordering::Relaxed);
let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64;
self.metrics
.duration_micros
.fetch_add(duration_micros, Ordering::Relaxed);
self.diagnostics.record(MutationDiagnostic {
request_id,
session_id: self.session.session_id,
user_id: self.session.user_id,
outcome,
duration_micros,
});
}
pub fn metrics_json(&self) -> String {
format!(
"{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}",
self.metrics.attempts.load(Ordering::Relaxed),
self.metrics.succeeded.load(Ordering::Relaxed),
self.metrics.denied.load(Ordering::Relaxed),
self.metrics.invalid.load(Ordering::Relaxed),
self.metrics.mismatch.load(Ordering::Relaxed),
self.metrics.failed.load(Ordering::Relaxed),
self.metrics.duration_micros.load(Ordering::Relaxed),
)
}
pub fn create_project_authorized(
&self,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
) -> Result<ProjectRecord, AppError> {
let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings");
self.authorize_mutation(bearer, &csrf, origin)?;
self.create_project(
ProjectName::from_str(name).expect("project names are infallible strings"),
)
}
fn create_project(&self, name: ProjectName) -> Result<ProjectRecord, AppError> {
if name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) {
return Err(AppError::Validation("Project name is invalid"));
}
self.store.insert(name, &self.session)
}
} }
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {
MissingSession, MissingSession,
CsrfRejected, CsrfRejected,
OriginRejected,
StoreUnavailable, StoreUnavailable,
Validation(&'static str), Validation(&'static str),
} }
@@ -154,12 +411,21 @@ impl AppError {
match self { match self {
Self::MissingSession => "Sign in to continue", Self::MissingSession => "Sign in to continue",
Self::CsrfRejected => "Refresh the page before creating another project", Self::CsrfRejected => "Refresh the page before creating another project",
Self::OriginRejected => "Origin verification failed",
Self::StoreUnavailable => "Project storage is temporarily unavailable", Self::StoreUnavailable => "Project storage is temporarily unavailable",
Self::Validation(message) => message, Self::Validation(message) => message,
} }
} }
} }
impl Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for AppError {}
impl IntoHandlerFailure for AppError { impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self { match self {
@@ -289,11 +555,7 @@ mod dashboard_handlers {
if form.csrf != ctx.session.csrf { if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected); return Err(AppError::CsrfRejected);
} }
if form.name.as_str().is_empty() { let project = ctx.create_project(form.name)?;
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let total = ctx.projects().len(); let total = ctx.projects().len();
Ok(( Ok((
dashboard::project_row.append(ProjectRow::from(project)), dashboard::project_row.append(ProjectRow::from(project)),
@@ -303,15 +565,6 @@ mod dashboard_handlers {
dashboard::live_status.set(format!("{total} projects persisted locally")), 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 { pub fn live_status(projects: usize) -> impl IntoEffect {
@@ -346,6 +599,32 @@ mod tests {
Selector::parse(value).expect("test selector parses") Selector::parse(value).expect("test selector parses")
} }
#[derive(Default)]
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
impl DiagnosticSink for RecordingDiagnostics {
fn record(&self, diagnostic: MutationDiagnostic) {
self.0.lock().unwrap().push(diagnostic);
}
}
#[test]
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
// req: operations/003 req: operations/005
let diagnostics = Arc::new(RecordingDiagnostics::default());
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
let request_id = ctx.next_request_id();
ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7));
let recorded = diagnostics.0.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].request_id, request_id);
assert_eq!(recorded[0].session_id, SessionId(1));
assert_eq!(recorded[0].user_id, UserId(42));
assert_eq!(recorded[0].outcome, "denied");
assert_eq!(recorded[0].duration_micros, 7);
}
#[test] #[test]
fn home_page_documents_the_production_app_boundaries() { fn home_page_documents_the_production_app_boundaries() {
// req: examples/001 req: auth/001 req: auth/004 req: interop/003 // req: examples/001 req: auth/001 req: auth/004 req: interop/003
@@ -452,23 +731,10 @@ mod tests {
assert!(created.updates_text(dashboard::live_status)); assert!(created.updates_text(dashboard::live_status));
} }
#[tokio::test] #[test]
async fn page_swap_and_push_shape_use_generated_targets() { fn live_status_uses_the_generated_dashboard_target() {
// req: page_swap/002 req: push/003 req: examples/001 // req: push/003 req: examples/014
let ctx = AppContext::demo(); 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())); let heartbeat = inspect(live_status(ctx.projects().len()));
assert!(heartbeat.updates_text(dashboard::live_status)); assert!(heartbeat.updates_text(dashboard::live_status));
assert!(heartbeat.payload_contains("heartbeat")); assert!(heartbeat.payload_contains("heartbeat"));
+164 -18
View File
@@ -1,37 +1,67 @@
use axum::body::Body; use axum::body::Body;
use axum::extract::{Query, State}; use axum::extract::{DefaultBodyLimit, Form, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use futures_util::stream; use futures_util::{stream, StreamExt};
use hemx::IntoEffect; use hemx::IntoEffect;
use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest}; 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 hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = app(AppContext::demo()); let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003") let store = std::env::var_os("HEMX_SAAS_STORE")
.await .map(PathBuf::from)
.expect("bind saas tutorial example"); .unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv"));
axum::serve(listener, app) let app = app(AppContext::durable(store, format!("http://{address}"))?);
.await let listener = tokio::net::TcpListener::bind(&address).await?;
.expect("serve saas tutorial example"); axum::serve(listener, app).await?;
Ok(())
} }
fn app(ctx: AppContext) -> Router { fn app(ctx: AppContext) -> Router {
Router::new() Router::new()
.route("/", get(home).post(interact)) .route("/", get(home).post(interact))
.route("/settings", get(settings)) .route("/settings", get(settings))
.route("/projects", post(create_project))
.route("/health/live", get(health_live))
.route("/health/ready", get(health_ready))
.route("/metrics", get(metrics))
.route("/events", get(events)) .route("/events", get(events))
.route(runtime_js_path(), get(runtime)) .route(runtime_js_path(), get(runtime))
.route("/app.css", get(css)) .route("/app.css", get(css))
.route("/metrics.js", get(metrics_js)) .route("/metrics.js", get(metrics_js))
.layer(DefaultBodyLimit::max(8 * 1024))
.layer(middleware::from_fn(security_headers))
.with_state(ctx) .with_state(ctx)
} }
// req: security/006 req: security/009
async fn security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response
}
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse { async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string()) axum::response::Html(home_page(&ctx).into_string())
} }
@@ -51,16 +81,132 @@ async fn events(
Query(params): Query<BTreeMap<String, String>>, Query(params): Query<BTreeMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> impl IntoResponse { ) -> 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") { if params.contains_key("once") {
return sse(stream::iter([Ok::<_, Infallible>( return sse(initial.left_stream());
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]));
} }
sse(stream::iter([Ok::<_, Infallible>( let updates = stream::unfold(ctx, move |ctx| async move {
live_status(count).into_batch(ui::BUILD_FINGERPRINT), tokio::time::sleep(Duration::from_secs(15)).await;
)])) Some((event(&ctx), ctx))
});
sse(initial.chain(updates).right_stream())
}
// req: auth/001 req: auth/002 req: auth/004
// req: security/004 req: v1_release/003
async fn create_project(
State(ctx): State<AppContext>,
headers: HeaderMap,
Form(form): Form<BTreeMap<String, String>>,
) -> Response {
let started = Instant::now();
let request_id = ctx.next_request_id();
let bearer = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let name = form.get("name").map(String::as_str).unwrap_or_default();
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
if let Some(client_fingerprint) = headers
.get("x-hemx-fingerprint")
.and_then(|value| value.to_str().ok())
{
let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string();
if client_fingerprint != current_fingerprint {
ctx.record_mutation(request_id.clone(), "mismatch", started.elapsed());
return Response::builder()
.status(StatusCode::CONFLICT)
.header("content-type", "application/problem+json")
.header("x-hemx-recovery", "reload")
.header("x-hemx-fingerprint", current_fingerprint)
.header("x-request-id", request_id.to_string())
.body(Body::from("{\"code\":\"deployment-mismatch\"}"))
.expect("deployment mismatch response");
}
}
let (outcome, mut response) = match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (
"succeeded",
(StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
),
Err(
hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected,
) => (
"denied",
problem(StatusCode::FORBIDDEN, "authorization-denied"),
),
Err(hemx_saas_example::AppError::Validation(_)) => (
"invalid",
problem(StatusCode::BAD_REQUEST, "invalid-project"),
),
Err(_) => (
"failed",
problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"),
),
};
ctx.record_mutation(request_id.clone(), outcome, started.elapsed());
response.headers_mut().insert(
"x-request-id",
HeaderValue::from_str(&request_id.to_string()).expect("generated request ID is a header"),
);
response
}
fn problem(status: StatusCode, code: &'static str) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/problem+json")
.body(Body::from(format!("{{\"code\":\"{code}\"}}")))
.expect("problem response")
}
// req: operations/007
async fn health_live() -> Response {
json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned())
}
// req: operations/007
async fn health_ready(State(ctx): State<AppContext>) -> Response {
if ctx.ready() {
json_response(
StatusCode::OK,
format!(
"{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}",
ui::BUILD_FINGERPRINT.0
),
)
} else {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
"{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(),
)
}
}
// req: operations/005 req: operations/007
async fn metrics(State(ctx): State<AppContext>) -> Response {
json_response(StatusCode::OK, ctx.metrics_json())
}
fn json_response(status: StatusCode, body: String) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.expect("JSON response")
} }
async fn runtime() -> impl IntoResponse { async fn runtime() -> impl IntoResponse {
+1 -1
View File
@@ -7,7 +7,7 @@
<nav class="tabs" data-hemx-slot="nav"> <nav class="tabs" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Projects</a> <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> </nav>
<section class="panel" data-hemx-slot="page_panel"> <section class="panel" data-hemx-slot="page_panel">
+311
View File
@@ -0,0 +1,311 @@
use hemx_test::TestProcess;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
fn available_address() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
let address = listener.local_addr().expect("test address");
drop(listener);
address.to_string()
}
fn test_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_nanos();
std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id()))
}
fn start(address: &str, store: &Path) -> TestProcess {
let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example"));
command
.env("HEMX_SAAS_ADDR", address)
.env("HEMX_SAAS_STORE", store);
TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app")
}
fn request(
address: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> String {
let mut stream = TcpStream::connect(address).expect("connect to SaaS app");
write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
body.len()
)
.expect("write request line");
for (name, value) in headers {
write!(stream, "{name}: {value}\r\n").expect("write request header");
}
write!(stream, "\r\n{body}").expect("finish request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("read response");
response
}
fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String {
create_at_version(address, name, bearer, csrf, origin, None)
}
fn create_at_version(
address: &str,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
fingerprint: Option<&str>,
) -> String {
let mut headers = vec![
("Authorization", bearer),
("Origin", origin),
("Content-Type", "application/x-www-form-urlencoded"),
];
if let Some(fingerprint) = fingerprint {
headers.push(("x-hemx-fingerprint", fingerprint));
}
request(
address,
"POST",
"/projects",
&headers,
&format!("name={name}&csrf={csrf}"),
)
}
fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
response
.lines()
.find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
})
.unwrap_or_else(|| panic!("missing {name} response header"))
}
fn ready_fingerprint(response: &str) -> &str {
let marker = "\"fingerprint\":\"";
let start = response.find(marker).expect("readiness fingerprint") + marker.len();
let end = response[start..].find('"').expect("fingerprint end") + start;
&response[start..end]
}
#[test]
fn authenticated_project_mutation_is_atomic_and_survives_restart() {
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006
// test req: security/009 req: operations/001 req: operations/006 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("durable");
{
let _app = start(&address, &store);
let home = request(&address, "GET", "/", &[], "");
let csp = response_header(&home, "content-security-policy");
assert!(csp.contains("default-src 'self'"), "{csp}");
assert!(csp.contains("script-src 'self'"), "{csp}");
assert!(csp.contains("object-src 'none'"), "{csp}");
assert!(csp.contains("form-action 'self'"), "{csp}");
assert!(!csp.contains("unsafe-inline"), "{csp}");
assert!(!csp.contains("unsafe-eval"), "{csp}");
assert_eq!(response_header(&home, "x-content-type-options"), "nosniff");
assert_eq!(
response_header(&home, "referrer-policy"),
"strict-origin-when-cross-origin"
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
assert!(
home.contains("href=\"/settings\" data-hemx-nav"),
"settings must remain a real, enhanceable link"
);
let settings = request(&address, "GET", "/settings", &[], "");
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
assert!(settings.contains("explicit app integrations"), "{settings}");
let events = request(&address, "GET", "/events?once=1", &[], "");
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
assert_eq!(
response_header(&events, "content-type"),
"text/event-stream"
);
assert!(events.contains("event: hemx"), "{events}");
assert!(events.contains("data:"), "{events}");
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
let ready = request(&address, "GET", "/health/ready", &[], "");
assert!(ready.starts_with("HTTP/1.1 200"), "{ready}");
assert!(ready.contains("{\"status\":\"ready\","), "{ready}");
let denied_responses = [
create(
&address,
"DeniedAuth",
"Bearer secret-auth-material",
"demo-csrf",
&origin,
),
create(
&address,
"DeniedCsrf",
"Bearer demo-session",
"stale",
&origin,
),
create(
&address,
"DeniedOrigin",
"Bearer demo-session",
"demo-csrf",
"https://attacker.invalid",
),
];
for denied in &denied_responses {
assert!(denied.starts_with("HTTP/1.1 403"), "{denied}");
assert!(response_header(denied, "x-request-id").starts_with("req-"));
assert!(denied.contains("{\"code\":\"authorization-denied\"}"));
assert!(!denied.contains("Denied"));
assert!(!denied.contains("demo-csrf"));
assert!(!denied.contains("secret-auth-material"));
assert!(!denied.contains("attacker.invalid"));
}
let wrong_content_type = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "text/plain"),
],
"name=WrongType&csrf=demo-csrf",
);
assert!(
wrong_content_type.starts_with("HTTP/1.1 415"),
"{wrong_content_type}"
);
let oversized = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "application/x-www-form-urlencoded"),
],
&format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)),
);
assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}");
let before = request(&address, "GET", "/", &[], "");
assert!(!before.contains("DeniedAuth"));
assert!(!before.contains("DeniedCsrf"));
assert!(!before.contains("DeniedOrigin"));
assert!(!before.contains("WrongType"));
let denied_metrics = request(&address, "GET", "/metrics", &[], "");
assert!(
denied_metrics.starts_with("HTTP/1.1 200"),
"{denied_metrics}"
);
assert!(
denied_metrics.contains("\"attempts\":3"),
"{denied_metrics}"
);
assert!(denied_metrics.contains("\"denied\":3"), "{denied_metrics}");
assert!(!denied_metrics.contains("Denied"));
assert!(!denied_metrics.contains("demo-csrf"));
assert!(!denied_metrics.contains("secret-auth-material"));
let stale = create_at_version(
&address,
"Stale%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some("0"),
);
assert!(stale.starts_with("HTTP/1.1 409"), "{stale}");
assert!(response_header(&stale, "x-request-id").starts_with("req-"));
assert!(stale.contains("{\"code\":\"deployment-mismatch\"}"));
assert!(stale
.to_ascii_lowercase()
.contains("x-hemx-recovery: reload"));
assert!(!request(&address, "GET", "/", &[], "").contains("Stale Project"));
let fingerprint = ready_fingerprint(&ready);
let allowed = create_at_version(
&address,
"Durable%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some(fingerprint),
);
assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}");
let allowed_request_id = response_header(&allowed, "x-request-id");
assert!(allowed_request_id.starts_with("req-"));
assert_ne!(allowed_request_id, response_header(&stale, "x-request-id"));
assert!(request(&address, "GET", "/", &[], "").contains("Durable Project"));
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":5"), "{metrics}");
assert!(metrics.contains("\"succeeded\":1"), "{metrics}");
assert!(metrics.contains("\"mismatch\":1"), "{metrics}");
}
{
let _restarted = start(&address, &store);
let restored = request(&address, "GET", "/", &[], "");
assert!(restored.contains("Durable Project"), "{restored}");
assert!(restored.contains("1 project"), "{restored}");
}
let _ = fs::remove_file(store);
}
#[test]
fn failed_durable_commit_rolls_back_visible_state() {
// test req: failure/004 req: operations/002 req: operations/007 req: operations/008 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("rollback");
let _app = start(&address, &store);
fs::create_dir(&store).expect("block atomic rename destination");
let not_ready = request(&address, "GET", "/health/ready", &[], "");
assert!(not_ready.starts_with("HTTP/1.1 503"), "{not_ready}");
assert!(not_ready.contains("\"code\":\"storage-unavailable\""));
assert!(request(&address, "GET", "/health/live", &[], "").starts_with("HTTP/1.1 200"));
let rejected = create(
&address,
"Must%20Rollback",
"Bearer demo-session",
"demo-csrf",
&origin,
);
assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
assert!(response_header(&rejected, "x-request-id").starts_with("req-"));
assert!(rejected.contains("{\"code\":\"storage-unavailable\"}"));
assert!(!rejected.contains("Must Rollback"));
assert!(!rejected.contains("demo-csrf"));
assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback"));
assert!(!store.with_extension("tmp").exists());
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":1"), "{metrics}");
assert!(metrics.contains("\"failed\":1"), "{metrics}");
assert!(!metrics.contains("Must Rollback"));
let _ = fs::remove_dir(store);
}
+1 -1
View File
@@ -17,7 +17,7 @@ hemx-host = { path = "../../hemx-host" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.25"
hemx-test = { path = "../../hemx-test" } hemx-test = { path = "../../hemx-test" }
thirtyfour = "0.35" thirtyfour = "0.35"
+1 -1
View File
@@ -16,7 +16,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.25"
hemx-test = { path = "../../hemx-test" } hemx-test = { path = "../../hemx-test" }
[build-dependencies] [build-dependencies]
+1 -1
View File
@@ -13,6 +13,6 @@ hemx-core = { path = "../hemx-core" }
hemx-js = { path = "../hemx-js" } hemx-js = { path = "../hemx-js" }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.25"
tokio = { version = "1", features = ["macros", "rt"] } tokio = { version = "1", features = ["macros", "rt"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
+107 -41
View File
@@ -1347,10 +1347,10 @@ impl IntoResponse for PageResponse {
.headers_mut() .headers_mut()
.insert(HEMX_PARTIAL_HEADER, HeaderValue::from_static("true")); .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 response
.headers_mut() .headers_mut()
.insert(HEMX_FINGERPRINT_HEADER, fingerprint); .insert(HEMX_FINGERPRINT_HEADER, fingerprint_header(fingerprint));
} }
if let Some(title) = self if let Some(title) = self
.title .title
@@ -1380,8 +1380,8 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -
html html
} }
fn fingerprint_header(fingerprint: BuildFingerprint) -> Option<HeaderValue> { fn fingerprint_header(fingerprint: BuildFingerprint) -> HeaderValue {
HeaderValue::from_str(&fingerprint.0.to_string()).ok() HeaderValue::from(fingerprint.0)
} }
impl IntoResponse for EffectResponse { impl IntoResponse for EffectResponse {
@@ -1392,11 +1392,10 @@ impl IntoResponse for EffectResponse {
header::CONTENT_TYPE, header::CONTENT_TYPE,
HeaderValue::from_static(HEMX_CONTENT_TYPE), HeaderValue::from_static(HEMX_CONTENT_TYPE),
); );
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) { response.headers_mut().insert(
response HEMX_FINGERPRINT_HEADER,
.headers_mut() fingerprint_header(self.batch.fingerprint),
.insert(HEMX_FINGERPRINT_HEADER, fingerprint); );
}
response response
} }
} }
@@ -1442,8 +1441,7 @@ impl IntoResponse for RuntimeJs {
); );
response.headers_mut().insert( response.headers_mut().insert(
header::CONTENT_LENGTH, header::CONTENT_LENGTH,
HeaderValue::from_str(hemx_js::RUNTIME_JS.len().to_string().as_str()) HeaderValue::from(hemx_js::RUNTIME_JS.len() as u64),
.expect("runtime length is a valid header value"),
); );
response 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 out = String::with_capacity((input.len() * 4).div_ceil(3));
let mut chunks = input.chunks_exact(3); let mut chunks = input.chunks_exact(3);
for chunk in &mut chunks { for chunk in &mut chunks {
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32; out.push(ALPHABET[(chunk[0] >> 2) as usize] as char);
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char); out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char);
out.push(ALPHABET[(n & 0x3f) as usize] as char);
} }
match chunks.remainder() { match chunks.remainder() {
[a] => { [a] => {
let n = (*a as u32) << 16; out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
} }
[a, b] => { [a, b] => {
let n = ((*a as u32) << 16) | ((*b as u32) << 8); out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); out.push(ALPHABET[((b & 0x0f) << 2) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
} }
[] => {} [] => {}
_ => unreachable!(), _ => unreachable!(),
@@ -1528,24 +1523,22 @@ fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, Interact
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> { fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
let mut out = Vec::with_capacity(input.len()); let mut out = Vec::with_capacity(input.len());
let mut i = 0; let mut bytes = input.iter().copied();
while i < input.len() { while let Some(byte) = bytes.next() {
match input[i] { match byte {
b'+' => { b'+' => out.push(b' '),
out.push(b' '); b'%' => {
i += 1; let high = bytes
} .next()
b'%' if i + 2 < input.len() => { .and_then(hex)
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?; .ok_or(InteractionFormRejection::InvalidBody)?;
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?; let low = bytes
out.push((high << 4) | low); .next()
i += 3; .and_then(hex)
} .ok_or(InteractionFormRejection::InvalidBody)?;
b'%' => return Err(InteractionFormRejection::InvalidBody), out.push(high * 16 + low);
byte => {
out.push(byte);
i += 1;
} }
byte => out.push(byte),
} }
} }
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody) String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
@@ -1563,7 +1556,8 @@ fn hex(byte: u8) -> Option<u8> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs,
percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection,
HEMX_SSE_EVENT, HEMX_SSE_EVENT,
}; };
use axum::{ use axum::{
@@ -1613,6 +1607,77 @@ mod tests {
.expect("root element is rendered"); .expect("root element is rendered");
assert_eq!(root.value().attr("data-hemx-fp"), Some("1")); assert_eq!(root.value().attr("data-hemx-fp"), Some("1"));
assert_eq!(root.text().collect::<String>(), "Docs"); 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] #[tokio::test]
@@ -1623,6 +1688,7 @@ mod tests {
ops: Vec::new(), ops: Vec::new(),
}; };
let encoded = encode_sse_batch(&batch); let encoded = encode_sse_batch(&batch);
assert_eq!(encoded, "SEVNWAEAAAALAAAAAAAAAAAAAAA");
let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response(); let response = sse(stream::iter([Ok::<_, Infallible>(batch)])).into_response();
assert_eq!( assert_eq!(
+521 -11
View File
@@ -1,7 +1,11 @@
use axum::extract::{DefaultBodyLimit, State}; use axum::extract::{DefaultBodyLimit, State};
use axum::http::{header, HeaderMap, Request, StatusCode}; use axum::http::{header, HeaderMap, Request, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{body::Body, routing::post, Router}; use axum::{
body::{to_bytes, Body, Bytes},
routing::post,
Router,
};
use hemx_axum::{ use hemx_axum::{
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, 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, InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE,
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, 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 scraper::{Html, Selector};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt; use tower::ServiceExt;
static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0); static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0);
static BOUNDARY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
async fn bounded_mutation(_: InteractionRequest) -> StatusCode { async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
MUTATION_CALLS.fetch_add(1, Ordering::SeqCst); MUTATION_CALLS.fetch_add(1, Ordering::SeqCst);
StatusCode::NO_CONTENT 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] #[tokio::test]
async fn interaction_boundary_honors_media_type_and_host_body_limit() { async fn interaction_boundary_honors_media_type_and_host_body_limit() {
// test req: security/003 // test req: security/003
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
MUTATION_CALLS.store(0, Ordering::SeqCst); MUTATION_CALLS.store(0, Ordering::SeqCst);
let app = Router::new() let app = Router::new()
.route("/mutate", post(bounded_mutation)) .route("/mutate", post(bounded_mutation))
.layer(DefaultBodyLimit::max(32)); .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 let unsupported = app
.clone() .clone()
.oneshot( .oneshot(
@@ -60,7 +91,7 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
Request::post("/mutate") Request::post("/mutate")
.header( .header(
header::CONTENT_TYPE, header::CONTENT_TYPE,
"application/x-www-form-urlencoded; charset=utf-8", "Application/X-Www-Form-Urlencoded; charset=UTF-8",
) )
.body(Body::from("__h=1")) .body(Body::from("__h=1"))
.unwrap(), .unwrap(),
@@ -71,6 +102,115 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1); 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 { fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses") Selector::parse(value).expect("test selector parses")
} }
@@ -107,17 +247,39 @@ impl hemx_core::FromForm for OpenProject {
} }
#[test] #[test]
fn page_mode_detects_partial_header() { fn page_mode_detects_only_explicit_partial_header_values() {
let mut headers = HeaderMap::new(); for value in [
assert_eq!(PageMode::from_headers(&headers), PageMode::Full); None,
Some("false"),
headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap()); Some("0"),
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial); 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] #[test]
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() { fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
// req: test/005 // req: test/005
assert!(!PageRequest {
mode: PageMode::Full
}
.is_partial());
assert!(PageRequest {
mode: PageMode::Partial
}
.is_partial());
let full = PageRequest { let full = PageRequest {
mode: PageMode::Full, 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] #[test]
fn partial_page_response_sets_partial_and_title_headers() { fn partial_page_response_sets_partial_and_title_headers() {
let response = PageResponse::partial("<main>Docs</main>") 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] #[test]
fn interaction_request_dispatches_typed_form_inputs() { fn interaction_request_dispatches_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003 // 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")]); 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] #[test]
fn interactions_reject_unknown_handle_ids() { fn interactions_reject_unknown_handle_ids() {
let request = InteractionRequest::from(InteractionForm::new(9, [])); let request = InteractionRequest::from(InteractionForm::new(9, []));
@@ -574,8 +1026,59 @@ fn interactions_reject_unknown_handle_ids() {
); );
} }
#[test] #[tokio::test]
fn runtime_js_response_serves_embedded_runtime() { 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(); let response = runtime_js().into_response();
assert_eq!( assert_eq!(
@@ -594,6 +1097,13 @@ fn runtime_js_response_serves_embedded_runtime() {
response.headers()[header::CONTENT_LENGTH], response.headers()[header::CONTENT_LENGTH],
runtime_js_source().len().to_string() 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 // req: axum_integration/005
+1702 -306
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 struct EffectBatch {
pub abi_version: u32, pub abi_version: u32,
pub fingerprint: BuildFingerprint, pub fingerprint: BuildFingerprint,
@@ -459,6 +459,13 @@ pub struct EffectBatch {
} }
impl EffectBatch { impl EffectBatch {
/// The versioned hemx codec is the sole public `EffectBatch` wire API.
///
/// ```compile_fail
/// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1));
/// let _ = batch.to_postcard();
/// ```
/// req: wire/010 test
/// Return the exact number of bytes produced by [`Self::to_wire`]. /// Return the exact number of bytes produced by [`Self::to_wire`].
pub fn encoded_len(&self) -> usize { pub fn encoded_len(&self) -> usize {
batch_wire_len(self) batch_wire_len(self)
@@ -478,14 +485,6 @@ impl EffectBatch {
read_batch(bytes) read_batch(bytes)
} }
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
postcard::to_allocvec(self)
}
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
postcard::from_bytes(bytes)
}
pub const fn is_compatible(&self) -> bool { pub const fn is_compatible(&self) -> bool {
self.abi_version == EFFECT_BATCH_ABI_VERSION self.abi_version == EFFECT_BATCH_ABI_VERSION
} }
@@ -770,12 +769,9 @@ impl<'a> WireReader<'a> {
} }
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> { fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?; let remaining = &self.bytes[self.offset..];
if end > self.bytes.len() { let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
return Err(WireError::Truncated); self.offset += len;
}
let bytes = &self.bytes[self.offset..end];
self.offset = end;
Ok(bytes) Ok(bytes)
} }
} }
+282 -21
View File
@@ -1,8 +1,8 @@
use hemx_core::{ use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot, ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey, Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
ScrollBehavior, Slot, ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
}; };
#[test] #[test]
@@ -31,47 +31,92 @@ fn effect_batch_wire_round_trips() {
} }
#[test] #[test]
fn encoded_len_covers_every_effect_shape() { fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)); // test req: abi/001 req: abi/003 req: v1_release/007
let scoped = ResourceRef::scoped( const V1_EMPTY_BATCH: &[u8] = &[
ResourceId::new(ResourceKind::Form, 2), 72, 69, 77, 88, // HEMX
1, 0, // ABI v1
0, 0, // reserved
7, 0, 0, 0, 0, 0, 0, 0, // fingerprint
0, 0, 0, 0, // zero operations
];
assert_eq!(
EffectBatch::from_wire(V1_EMPTY_BATCH).unwrap().to_wire(),
V1_EMPTY_BATCH
);
let mut future = V1_EMPTY_BATCH.to_vec();
future[4] = 2;
let future = EffectBatch::from_wire(&future).unwrap();
assert_eq!(future.abi_version, 2);
assert!(!future.is_compatible());
}
#[test]
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
let atom = ResourceRef::scoped(
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
ScopeKey::KeyValue(String::from("row")),
);
let handle = ResourceRef::scoped(
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
ScopeKey::Field(String::from("email")), ScopeKey::Field(String::from("email")),
); );
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
let batch = EffectBatch { let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(42), fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
ops: vec![ ops: vec![
Effect::Put { Effect::Put {
target: unscoped.clone(), target: slot.clone(),
payload: Payload::Text(String::from("text")),
},
Effect::Put {
target: handle.clone(),
payload: Payload::Html(String::from("<p>safe</p>")), payload: Payload::Html(String::from("<p>safe</p>")),
}, },
Effect::Insert { Effect::Insert {
target: scoped.clone(), target: atom.clone(),
key: String::from("insert"), key: String::from("insert"),
payload: Payload::Text(String::from("one")), payload: Payload::Text(String::from("one")),
}, },
Effect::Prepend { Effect::Prepend {
target: scoped.clone(), target: form.clone(),
key: String::from("prepend"), key: String::from("prepend"),
payload: Payload::Text(String::from("two")), payload: Payload::Html(String::from("two")),
}, },
Effect::Remove { Effect::Remove {
target: scoped.clone(), target: slot.clone(),
key: None,
},
Effect::Remove {
target: atom,
key: Some(String::from("remove")), key: Some(String::from("remove")),
}, },
Effect::Move { Effect::Move {
target: scoped.clone(), target: handle.clone(),
key: String::from("move"), key: String::from("move"),
before: Some(String::from("before")), before: Some(String::from("before")),
}, },
Effect::Focus { Effect::Focus { target: form },
target: scoped.clone(), Effect::Navigate {
url: String::from("/push"),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
}, },
Effect::Navigate { Effect::Navigate {
url: String::from("/next"), url: String::from("/replace"),
mode: NavigateMode::Replace, mode: NavigateMode::Replace,
scroll: ScrollBehavior::Element(unscoped), scroll: ScrollBehavior::Top,
title: Some(String::from("Next")), title: Some(String::from("Replace")),
},
Effect::Navigate {
url: String::from("/redirect"),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Element(handle),
title: Some(String::from("Redirect")),
}, },
Effect::Emit { Effect::Emit {
name: String::from("notice"), name: String::from("notice"),
@@ -81,8 +126,90 @@ fn encoded_len_covers_every_effect_shape() {
}; };
let bytes = batch.to_wire(); let bytes = batch.to_wire();
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007 assert_eq!(&bytes[..4], b"HEMX");
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch); assert_eq!(batch.encoded_len(), bytes.len());
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
for end in 0..bytes.len() {
assert_eq!(
EffectBatch::from_wire(&bytes[..end]),
Err(WireError::Truncated),
"prefix ending at byte {end} must fail closed"
);
}
// req: wire/007 test req: wire/008 test req: wire/009 test
}
#[test]
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4;
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN;
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1;
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4;
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1;
let put = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Put {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
payload: Payload::Text(String::from("value")),
}],
}
.to_wire();
for offset in [
PUT_EFFECT_TAG,
PUT_RESOURCE_KIND_TAG,
PUT_SCOPE_TAG,
PUT_PAYLOAD_TAG,
] {
let mut corrupt = put.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
const NAVIGATE_MODE_TAG: usize = BATCH_HEADER_LEN + 1 + 4;
const NAVIGATE_SCROLL_TAG: usize = NAVIGATE_MODE_TAG + 1;
const NAVIGATE_TITLE_OPTION_TAG: usize = NAVIGATE_SCROLL_TAG + 1;
let navigate = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Navigate {
url: String::new(),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
}],
}
.to_wire();
for offset in [
NAVIGATE_MODE_TAG,
NAVIGATE_SCROLL_TAG,
NAVIGATE_TITLE_OPTION_TAG,
] {
let mut corrupt = navigate.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
let mut bad_magic = put.clone();
bad_magic[0] = b'X';
assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4;
let mut invalid_utf8 = put.clone();
invalid_utf8[PUT_TEXT_START] = 0xff;
assert_eq!(
EffectBatch::from_wire(&invalid_utf8),
Err(WireError::InvalidUtf8)
);
let mut trailing = put;
trailing.push(0);
assert_eq!(
EffectBatch::from_wire(&trailing),
Err(WireError::TrailingBytes)
);
// req: wire/008 test req: wire/009 test
} }
#[test] #[test]
@@ -174,6 +301,93 @@ fn generated_form_helpers_target_form_fields() {
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email")))); assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
} }
#[test]
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
let rows = KeyedSlot::<u64, String>::new(9);
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9));
assert_eq!(
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")),
Effect::Put {
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())),
payload: Payload::Html("<li>done</li>".into()),
}
);
assert_eq!(
rows.remove(12),
Effect::Remove {
target: expected.clone(),
key: Some("12".into()),
}
);
assert_eq!(
rows.move_before(12, 13),
Effect::Move {
target: expected.clone(),
key: "12".into(),
before: Some("13".into()),
}
);
assert_eq!(
rows.move_to_end(12),
Effect::Move {
target: expected,
key: "12".into(),
before: None,
}
);
let form = Form::<()>::new(4);
assert_eq!(
form.clear_field("email"),
Effect::Put {
target: ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 4),
ScopeKey::Field("email".into()),
),
payload: Payload::Text(String::new()),
}
);
assert_eq!(
form.disable_while_pending(),
Effect::Emit {
name: "hemx:form-disable-while-pending".into(),
payload: "4".into(),
}
);
assert_eq!(form.clear(), form.reset());
for (effect, expected_mode) in [
(navigate("/push"), NavigateMode::Push),
(hemx_core::push("/push"), NavigateMode::Push),
(replace("/replace"), NavigateMode::Replace),
(redirect("/redirect"), NavigateMode::Redirect),
] {
let Effect::Navigate {
mode,
scroll,
title,
..
} = effect
else {
panic!("navigation helper must return Navigate");
};
assert_eq!(mode, expected_mode);
assert_eq!(scroll, ScrollBehavior::Top);
assert_eq!(title, None);
}
// test req: list/003 req: form_effects/001 req: nav/001
}
#[test]
fn css_class_accumulation_preserves_existing_classes() {
const A: CssClass = CssClass::new("a");
const B: CssClass = CssClass::new("b");
const C: CssClass = CssClass::new("c");
assert_eq!(CssClasses::new([]).with(A).as_str(), "a");
assert_eq!(CssClasses::from(A).with(B).with(C).as_str(), "a b c");
// test req: style/003
}
#[test] #[test]
fn slot_html_requires_explicit_safe_html() { fn slot_html_requires_explicit_safe_html() {
let content = Slot::<String>::new(10); let content = Slot::<String>::new(10);
@@ -194,6 +408,8 @@ fn safe_html_joins_only_explicit_safe_fragments() {
]); ]);
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>"); assert_eq!(html.as_str(), "<main><strong>ok</strong></main>");
assert_eq!(html.as_ref(), "<main><strong>ok</strong></main>");
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>");
} }
#[test] #[test]
@@ -201,6 +417,7 @@ fn param_names_format_generated_param_names() {
// req: codegen/003 // req: codegen/003
let param = ParamName::new("todo_id"); let param = ParamName::new("todo_id");
assert_eq!(param.as_str(), "todo_id"); assert_eq!(param.as_str(), "todo_id");
assert_eq!(param.as_ref(), "todo_id");
assert_eq!(param.to_string(), "todo_id"); assert_eq!(param.to_string(), "todo_id");
} }
@@ -209,6 +426,7 @@ fn component_refs_format_generated_component_names() {
// req: component/003 // req: component/003
let component = ComponentRef::new("todo_list"); let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list"); assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.as_ref(), "todo_list");
assert_eq!(component.to_string(), "todo_list"); assert_eq!(component.to_string(), "todo_list");
} }
@@ -279,4 +497,47 @@ fn build_fingerprint_is_deterministic_from_abi_parts() {
assert_eq!(a, b); assert_eq!(a, b);
assert_ne!(a, c); assert_ne!(a, c);
assert_eq!(
BuildFingerprint::from_parts(&[]),
BuildFingerprint(0xcbf29ce484222325)
);
assert_eq!(a, BuildFingerprint(13725386680924731485));
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
// test req: abi/001 req: abi/002 req: abi/003
}
#[test]
fn public_token_and_form_error_adapters_preserve_values() {
const NOTICE: EventName = EventName::new("notice");
assert_eq!(NOTICE.as_str(), "notice");
assert_eq!(NOTICE.as_ref(), "notice");
assert_eq!(NOTICE.to_string(), "notice");
assert_eq!(String::from(NOTICE), "notice");
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
const ACTIVE: CssClass = CssClass::new("active");
assert_eq!(ACTIVE.as_str(), "active");
assert_eq!(ACTIVE.as_ref(), "active");
assert_eq!(ACTIVE.to_string(), "active");
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
assert_eq!(classes.as_ref(), "active selected");
let error = FormError::new("invalid email");
assert_eq!(error.message(), "invalid email");
assert_eq!(error.to_string(), "invalid email");
assert_eq!(u32::parse_form_value("42"), Ok(42));
assert_eq!(
u32::parse_form_value("nope"),
Err("invalid form value".into())
);
assert_eq!(
Form::<()>::new(7).reset(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: "7".into(),
}
);
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
} }
+620 -95
View File
@@ -12,37 +12,23 @@ use syn::{
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let placement = attr.to_string(); let placement = attr.to_string();
let is_client = match placement.as_str() { let placement = match handler_placement(&placement) {
"" => false, Ok(placement) => placement,
"client" => true, Err(error) => return error.into_compile_error().into(),
_ => {
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 function = parse_macro_input!(item as ItemFn); let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string(); let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else { let syms_path = match handler_syms_path(syms_path()) {
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"; Ok(path) => path,
return quote!( Err(message) => {
#function return quote!(
compile_error!(#message); #function
) compile_error!(#message);
.into(); )
.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) { if !syms_contains_handle(&syms_path, &name) {
let message = format!( let message = format!(
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler" "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(); .into();
} }
if !is_client { expand_handler_function(function, placement).into()
return quote!(#function).into(); }
}
let input_count = function.sig.inputs.len(); fn expand_handler_function(
if !matches!(input_count, 0 | 2) function: ItemFn,
|| function.sig.asyncness.is_some() placement: HandlerPlacement,
|| function.sig.unsafety.is_some() ) -> proc_macro2::TokenStream {
|| function.sig.constness.is_some() if placement == HandlerPlacement::Server {
|| !function.sig.generics.params.is_empty() return quote!(#function);
{
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();
} }
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 function_name = &function.sig.ident;
let export_name = format_ident!("__hemx_client_{function_name}"); let export_name = format_ident!("__hemx_client_{function_name}");
let export_module = format_ident!("__hemx_client_export_{function_name}"); let export_module = format_ident!("__hemx_client_export_{function_name}");
let invoke_handler = if input_count == 0 { let invoke_handler = if has_inputs {
quote!(super::#function_name())
} else {
quote!(super::#function_name(event, state)) quote!(super::#function_name(event, state))
} else {
quote!(super::#function_name())
}; };
quote!( quote!(
#function #function
@@ -149,7 +136,6 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
} }
} }
) )
.into()
} }
#[proc_macro_attribute] #[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_name = parse_macro_input!(attr as LitStr).value();
let form_struct = parse_macro_input!(item as ItemStruct); let form_struct = parse_macro_input!(item as ItemStruct);
let Some(syms_path) = syms_path() else { 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!( return quote!(
#form_struct #form_struct
compile_error!(#message); compile_error!(#message);
@@ -174,13 +160,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let ident = &form_struct.ident; let ident = &form_struct.ident;
let resource_id = let resource_id =
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms"); form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
let mut generics = form_struct.generics.clone(); let generics = form_impl_generics(&form_struct);
for ty in form_parser_types(&form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::hemx::FormValue));
}
let decode_fields = form_decode_fields(&syms_path, &form_name, &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(); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote!( quote!(
@@ -201,7 +181,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
) )
.into() .into()
} else { } else {
let message = errors.join("; "); let message = join_contract_errors(&errors);
quote!( quote!(
#form_struct #form_struct
compile_error!(#message); compile_error!(#message);
@@ -228,9 +208,6 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
let Some(syms_path) = syms_path() else { let Some(syms_path) = syms_path() else {
return quote!(#module).into(); return quote!(#module).into();
}; };
if !syms_path.exists() {
return quote!(#module).into();
}
let component_filter = component_name.as_deref(); let component_filter = component_name.as_deref();
let errors = component_contract_errors(&syms_path, component_filter, items); let errors = component_contract_errors(&syms_path, component_filter, items);
if !errors.is_empty() { if !errors.is_empty() {
@@ -312,22 +289,13 @@ fn form_model_type(ty: &Type) -> Option<Type> {
let Type::Path(path) = ty else { let Type::Path(path) = ty else {
return None; return None;
}; };
let mut segments = path.path.segments.iter(); let segments = path.path.segments.iter().collect::<Vec<_>>();
let first = segments.next()?; let form = match segments.as_slice() {
let last = path [form] if form.ident == "Form" => form,
.path [hemx, .., form] if hemx.ident == "hemx" && form.ident == "Form" => form,
.segments _ => return None,
.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"
}; };
if !path_is_form { let PathArguments::AngleBracketed(args) = &form.arguments else {
return None;
}
let PathArguments::AngleBracketed(args) = &last.arguments else {
return None; return None;
}; };
args.args.iter().find_map(|arg| match arg { 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> { fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
function.sig.inputs.iter().rev().find_map(|arg| match arg { function.sig.inputs.iter().rev().find_map(|arg| match arg {
FnArg::Typed(arg) => form_model_type(&arg.ty), FnArg::Typed(arg) => form_model_type(&arg.ty),
@@ -422,7 +460,7 @@ fn form_contract_errors(
field field
.ident .ident
.as_ref() .as_ref()
.map(|ident| (ident.to_string(), &field.ty)) .map(|ident| (form_field_name(ident), &field.ty))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut errors = Vec::new(); let mut errors = Vec::new();
@@ -469,6 +507,10 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
.collect() .collect()
} }
fn form_field_name(ident: &syn::Ident) -> String {
ident.to_string().trim_start_matches("r#").to_owned()
}
fn form_decode_fields( fn form_decode_fields(
syms_path: &PathBuf, syms_path: &PathBuf,
form_name: &str, form_name: &str,
@@ -486,7 +528,9 @@ fn form_decode_fields(
form_fields(syms_path, form_name) form_fields(syms_path, form_name)
.into_iter() .into_iter()
.filter_map(|field| { .filter_map(|field| {
let (ident, ty) = actual.iter().find(|(ident, _)| ident == &&field.ident)?; let (ident, ty) = actual
.iter()
.find(|(ident, _)| form_field_name(ident) == field.ident)?;
let control_name = field.name; let control_name = field.name;
let parser = parser_type(ty); let parser = parser_type(ty);
Some(if field.multiple { Some(if field.multiple {
@@ -626,12 +670,8 @@ fn rust_ident(name: &str) -> Option<String> {
} }
fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec<String> { 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); let args = handler_arg_names(function);
required handle_params(path, ident)
.into_iter() .into_iter()
.filter(|param| !args.contains(param)) .filter(|param| !args.contains(param))
.collect() .collect()
@@ -725,9 +765,6 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
let Some(state_ty) = component_state_type(items) else { let Some(state_ty) = component_state_type(items) else {
return module; return module;
}; };
if handlers.is_empty() {
return module;
}
let calls = handlers let calls = handlers
.iter() .iter()
.map(|handler| component_registration_call(handler, &component_ident)); .map(|handler| component_registration_call(handler, &component_ident));
@@ -970,27 +1007,344 @@ fn symbol_component(symbol: &str) -> Option<&str> {
} }
fn compile_error(message: &str) -> TokenStream { fn compile_error(message: &str) -> TokenStream {
format!("compile_error!({message:?});") quote!(compile_error!(#message);).into()
.parse()
.expect("compile_error expansion is valid")
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
add_app_registry_helper, add_component_register_helper, component_handler_names, add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return, component_contract_errors, component_handler_names, component_registration_call,
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle, 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}; 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] #[test]
fn syms_lookup_matches_handle_ident() { fn syms_lookup_matches_handle_ident() {
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms"); let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
std::fs::write( std::fs::write(
&path, &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(); .unwrap();
@@ -1000,8 +1354,26 @@ mod tests {
assert!(!handle_requires_form(&path, "missing")); assert!(!handle_requires_form(&path, "missing"));
assert_eq!(handle_params(&path, "create"), vec!["todo_id"]); assert_eq!(handle_params(&path, "create"), vec!["todo_id"]);
assert!(handle_params(&path, "missing").is_empty()); 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] #[test]
@@ -1066,6 +1438,16 @@ mod tests {
assert!(form_model_type(&foreign_form).is_none()); 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] #[test]
fn handler_params_match_generated_param_names() { fn handler_params_match_generated_param_names() {
let path = std::env::temp_dir().join("hemx-derive-param-test.syms"); let path = std::env::temp_dir().join("hemx-derive-param-test.syms");
@@ -1087,10 +1469,68 @@ mod tests {
assert!(missing_handle_params(&path, "show", &complete).is_empty()); assert!(missing_handle_params(&path, "show", &complete).is_empty());
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]); 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); 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] #[test]
fn component_handlers_match_generated_handles() { fn component_handlers_match_generated_handles() {
let path = std::env::temp_dir().join("hemx-derive-component-test.syms"); let path = std::env::temp_dir().join("hemx-derive-component-test.syms");
@@ -1103,6 +1543,8 @@ mod tests {
mod component { mod component {
#[hemx::handler] #[hemx::handler]
fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() } fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() }
fn helper() {}
} }
}; };
let (_, items) = module.content.expect("inline module"); let (_, items) = module.content.expect("inline module");
@@ -1117,7 +1559,14 @@ mod tests {
vec!["delete"] 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] #[test]
@@ -1145,6 +1594,80 @@ mod tests {
); );
assert!(generated.contains("hemx_axum :: State"), "{generated}"); assert!(generated.contains("hemx_axum :: State"), "{generated}");
assert!(generated.contains("state . clone"), "{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] #[test]
@@ -1152,6 +1675,8 @@ mod tests {
// req: component/003 req: derive_handler/003 // req: component/003 req: derive_handler/003
let module = parse_quote! { let module = parse_quote! {
mod handlers { mod handlers {
const COMPONENT_KIND: &str = "todos";
#[hemx::handler] #[hemx::handler]
fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect { fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
hemx::EventName::new("sync-form").emit("") hemx::EventName::new("sync-form").emit("")
+90
View File
@@ -611,6 +611,57 @@ struct Profile {
); );
} }
#[test]
fn form_struct_accepts_raw_identifier_for_reserved_control_name() {
// req: form/004 test
let fixture = Fixture::new("hemx-derive-form-raw-identifier-pass");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "hemx-derive-form-raw-identifier-pass"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
hemx = {{ path = {:?} }}
"#,
repo_path("hemx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("hemx.syms"),
"hemx-syms-v1\nform\ttemplates/app.heml::filter\tfilter\t1\nform_field\tfilter\ttype\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[hemx::form("filter")]
struct Filter {
r#type: String,
}
"#,
);
let output = check_fixture(&fixture);
assert!(
output.status.success(),
"fixture failed to compile:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test] #[test]
fn form_handle_accepts_checked_form_model() { fn form_handle_accepts_checked_form_model() {
// req: form/001 req: form/004 req: form/006 // req: form/001 req: form/004 req: form/006
@@ -980,6 +1031,45 @@ pub const MISSING: hemx::CssClass = ui::classes::missing;
); );
} }
#[test]
fn effect_batch_has_no_parallel_postcard_wire_api() {
// req: wire/010 test
let fixture = Fixture::new("hemx-effect-batch-postcard-api-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "hemx-effect-batch-postcard-api-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
hemx = {{ path = {:?} }}
"#,
repo_path("hemx")
),
);
fixture.write(
"src/lib.rs",
r#"pub fn encode(batch: &hemx::advanced::EffectBatch) {
let _ = batch.to_postcard();
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no method named `to_postcard`"),
"missing sole-wire-API diagnostic in stderr:\n{stderr}"
);
}
fn check_fixture(fixture: &Fixture) -> std::process::Output { fn check_fixture(fixture: &Fixture) -> std::process::Output {
Command::new("cargo") Command::new("cargo")
.arg("check") .arg("check")
+93 -1
View File
@@ -468,6 +468,28 @@ mod tests {
native_shell_host_profile("ios-android-webview-test") native_shell_host_profile("ios-android-webview-test")
} }
#[test]
fn capability_names_and_standard_profile_identity_are_stable() {
let names = [
(Capability::Haptics, "haptics"),
(Capability::Microphone, "microphone"),
(Capability::Camera, "camera"),
(Capability::Share, "share"),
(Capability::SecureStorage, "secure_storage"),
(Capability::Notifications, "notifications"),
(Capability::Clipboard, "clipboard"),
(Capability::FilePicker, "file_picker"),
(Capability::Geolocation, "geolocation"),
(Capability::custom("vendor.camera"), "vendor.camera"),
];
for (capability, expected) in names {
assert_eq!(capability.as_str(), expected);
}
assert_eq!(web_host().name, "browser-pwa");
// req: host/001 req: host/004
}
#[test] #[test]
fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() { fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() {
// req: host/001 req: host/002 req: host/005 // req: host/001 req: host/002 req: host/005
@@ -509,6 +531,37 @@ mod tests {
assert_eq!(manifest.check(&web_host()), Ok(())); assert_eq!(manifest.check(&web_host()), Ok(()));
} }
#[test]
fn manifest_and_host_support_require_the_exact_capability_shape() {
let wrong_shape_host = HostProfile::new(
"wrong-shape",
[CapabilityUse::new(Capability::Share, CapabilityShape::Fire)],
);
let wrong_capability_host = HostProfile::new(
"wrong-capability",
[CapabilityUse::new(
Capability::Haptics,
CapabilityShape::Request,
)],
);
let manifest = CapabilityManifest::new([CapabilityUse::new(
Capability::Share,
CapabilityShape::Request,
)]);
assert!(!wrong_shape_host.supports(&Capability::Share, CapabilityShape::Request));
assert!(!wrong_capability_host.supports(&Capability::Share, CapabilityShape::Request));
assert_eq!(
manifest.check(&wrong_shape_host),
Err(HostCheckError::UnsupportedCapability {
capability: Capability::Share,
shape: CapabilityShape::Request,
host: "wrong-shape".into(),
})
);
// req: host/001 req: host/004
}
#[test] #[test]
fn host_calls_must_be_declared_and_supported() { fn host_calls_must_be_declared_and_supported() {
// req: host/001 req: host/004 // req: host/001 req: host/004
@@ -516,12 +569,38 @@ mod tests {
Capability::Share, Capability::Share,
CapabilityShape::Request, CapabilityShape::Request,
)]); )]);
let payload = SharePayload::text("log");
assert_eq!(
payload,
SharePayload {
title: None,
text: Some("log".into()),
url: None,
}
);
let call = HostCall::Share { let call = HostCall::Share {
id: HostCallId::new("share-1"), id: HostCallId::new("share-1"),
payload: SharePayload::text("log"), payload,
}; };
assert_eq!(manifest.validate_call(&web_host(), &call), Ok(())); assert_eq!(manifest.validate_call(&web_host(), &call), Ok(()));
let unsupported_host = HostProfile::new("offline-shell", []);
let unsupported = manifest
.validate_call(&unsupported_host, &call)
.expect_err("declared calls still require host support");
assert_eq!(
unsupported,
HostCheckError::UnsupportedCapability {
capability: Capability::Share,
shape: CapabilityShape::Request,
host: "offline-shell".into(),
}
);
assert_eq!(
unsupported.to_string(),
"host `offline-shell` does not support capability `share` with shape Request"
);
let haptic = HostCall::Haptic { let haptic = HostCall::Haptic {
id: HostCallId::new("tap"), id: HostCallId::new("tap"),
pattern: HapticPattern::Success, pattern: HapticPattern::Success,
@@ -584,6 +663,19 @@ mod tests {
} }
} }
#[test]
fn host_failure_builders_preserve_call_context() {
let failure = HostFailure::new(HostFailureKind::Timeout, "host timed out")
.with_id(HostCallId::new("share-1"))
.with_capability(Capability::Share);
assert_eq!(failure.id, Some(HostCallId::new("share-1")));
assert_eq!(failure.capability, Some(Capability::Share));
assert_eq!(failure.kind, HostFailureKind::Timeout);
assert_eq!(failure.message, "host timed out");
// req: host/002 req: host/005
}
#[test] #[test]
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() { fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
// req: host/001 req: host/002 req: host/005 // req: host/001 req: host/002 req: host/005
+80 -7
View File
@@ -343,6 +343,7 @@
await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode); await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode);
return; return;
} }
savePage(roots()[0]);
const response = await fetch(requestUrl(form, data, method), { const response = await fetch(requestUrl(form, data, method), {
method, method,
body, 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") { async function navigateUrl(href, root, mode = "replace") {
if (mode === "push") savePage(root);
const response = await fetch(href, { const response = await fetch(href, {
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" }, headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
credentials: "same-origin", credentials: "same-origin",
@@ -443,6 +469,8 @@
return; return;
} }
for (const op of batch.ops) applyOp(scope, op); for (const op of batch.ops) applyOp(scope, op);
bindPolling(scope);
bindRevealed(scope);
} }
function canApplyOp(scope, op) { function canApplyOp(scope, op) {
@@ -853,8 +881,14 @@
everyTimers.delete(el); 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) { function bindRevealed(root) {
let observer = revealObservers.get(root); let observers = revealObservers.get(root);
forEachElement(root, (el) => { forEachElement(root, (el) => {
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return; if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
if (typeof IntersectionObserver === "undefined") { if (typeof IntersectionObserver === "undefined") {
@@ -862,6 +896,12 @@
schedule(el, "revealed"); schedule(el, "revealed");
return; return;
} }
if (!observers) {
observers = new Map();
revealObservers.set(root, observers);
}
const rootMargin = revealedRootMargin(el);
let observer = observers.get(rootMargin);
if (!observer) { if (!observer) {
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
@@ -870,8 +910,8 @@
observer.unobserve(entry.target); observer.unobserve(entry.target);
schedule(entry.target, "revealed"); schedule(entry.target, "revealed");
}); });
}); }, { rootMargin });
revealObservers.set(root, observer); observers.set(rootMargin, observer);
} }
observer.observe(el); observer.observe(el);
}); });
@@ -1095,12 +1135,30 @@
const source = sseSources.get(root); const source = sseSources.get(root);
if (source) source.close(); if (source) source.close();
sseSources.delete(root); sseSources.delete(root);
const observer = revealObservers.get(root); const observers = revealObservers.get(root);
if (observer) observer.disconnect(); if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root); revealObservers.delete(root);
stopDescendantPolling(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() { function start() {
roots().forEach((root) => { roots().forEach((root) => {
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS)); root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
@@ -1127,6 +1185,16 @@
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node); if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
descendantRoots(node).forEach(cleanupRemovedRoot); 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 }); }).observe(document.documentElement, { childList: true, subtree: true });
try { try {
@@ -1137,9 +1205,14 @@
} }
} }
addEventListener("popstate", () => { window.addEventListener("pageshow", restoreRevealed);
window.addEventListener("resize", () => rebindRevealed(false));
addEventListener("popstate", (event) => {
const root = roots()[0]; 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({ 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] #[test]
fn runtime_exposes_debug_api_before_startup_side_effects() { fn runtime_exposes_debug_api_before_startup_side_effects() {
// req: runtime/002 // req: runtime/002
@@ -141,8 +146,21 @@ fn runtime_supports_tiny_delay_and_revealed_scheduling() {
assert!(source.contains("const revealObservers = new WeakMap()")); assert!(source.contains("const revealObservers = new WeakMap()"));
assert!(source.contains("const revealed = new WeakSet()")); assert!(source.contains("const revealed = new WeakSet()"));
assert!(source.contains("data-hemx-revealed")); 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("typeof IntersectionObserver === \"undefined\""));
assert!(source.contains("rootMargin"));
assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))"));
assert!(source.contains("schedule(entry.target, \"revealed\")")); 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] #[test]
@@ -302,6 +320,9 @@ fn runtime_exposes_page_swap_hooks() {
assert!(source.contains("data-hemx-nav")); assert!(source.contains("data-hemx-nav"));
assert!(source.contains("data-hemx-boost")); assert!(source.contains("data-hemx-boost"));
assert!(source.contains("history.pushState")); 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("popstate"));
assert!(source.contains("x-hemx-title")); assert!(source.contains("x-hemx-title"));
assert!(source.contains("x-hemx-fingerprint")); assert!(source.contains("x-hemx-fingerprint"));
+110 -25
View File
@@ -1,55 +1,52 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote; use quote::quote;
use syn::{parse_macro_input, Error, FnArg, ItemFn, Pat, ReturnType}; use syn::{Error, FnArg, ItemFn, Pat, ReturnType};
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream { pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
expand_presence(attributes.into(), item.into())
.unwrap_or_else(Error::into_compile_error)
.into()
}
fn expand_presence(attributes: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
if !attributes.is_empty() { if !attributes.is_empty() {
return Error::new( return Err(Error::new(
proc_macro2::Span::call_site(), proc_macro2::Span::call_site(),
"#[hemx_sync::presence] does not accept arguments", "#[hemx_sync::presence] does not accept arguments",
) ));
.to_compile_error()
.into();
} }
let mut function = parse_macro_input!(item as ItemFn); let mut function: ItemFn = syn::parse2(item)?;
if function.sig.asyncness.is_some() { if let Some(asyncness) = &function.sig.asyncness {
return Error::new_spanned( return Err(Error::new_spanned(
function.sig.asyncness, asyncness,
"presence projections must be synchronous", "presence projections must be synchronous",
) ));
.to_compile_error()
.into();
} }
if matches!(function.sig.output, ReturnType::Default) { if matches!(function.sig.output, ReturnType::Default) {
return Error::new_spanned( return Err(Error::new_spanned(
&function.sig, &function.sig,
"presence projections must return impl IntoEffect", "presence projections must return impl IntoEffect",
) ));
.to_compile_error()
.into();
} }
let argument = match function.sig.inputs.first() { let argument = match function.sig.inputs.first() {
Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument, Some(FnArg::Typed(argument)) if function.sig.inputs.len() == 1 => argument,
_ => { _ => {
return Error::new_spanned( return Err(Error::new_spanned(
&function.sig.inputs, &function.sig.inputs,
"presence projections require exactly one typed presence argument", "presence projections require exactly one typed presence argument",
) ));
.to_compile_error()
.into();
} }
}; };
let argument_name = match argument.pat.as_ref() { let argument_name = match argument.pat.as_ref() {
Pat::Ident(argument) => argument.ident.clone(), Pat::Ident(argument) => argument.ident.clone(),
pattern => { pattern => {
return Error::new_spanned( return Err(Error::new_spanned(
pattern, pattern,
"presence projection argument must be a simple identifier", "presence projection argument must be a simple identifier",
) ));
.to_compile_error()
.into();
} }
}; };
@@ -61,5 +58,93 @@ pub fn presence(attributes: TokenStream, item: TokenStream) -> TokenStream {
let __hemx_sync_effect = (|| #body)(); let __hemx_sync_effect = (|| #body)();
::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect) ::hemx_sync::PresenceProjection::new(__hemx_sync_channel, __hemx_sync_effect)
})); }));
quote!(#function).into() Ok(quote!(#function))
}
#[cfg(test)]
mod tests {
use super::expand_presence;
use quote::quote;
#[test]
fn presence_expansion_enforces_the_typed_projection_contract() {
assert!(expand_presence(quote!(), quote!(not a function)).is_err());
for (attributes, item, expected) in [
(
quote!(unexpected),
quote!(
fn project(scope: Scope) -> Effect {
effect()
}
),
"#[hemx_sync::presence] does not accept arguments",
),
(
quote!(),
quote!(
async fn project(scope: Scope) -> Effect {
effect()
}
),
"presence projections must be synchronous",
),
(
quote!(),
quote!(
fn project(scope: Scope) {}
),
"presence projections must return impl IntoEffect",
),
(
quote!(),
quote!(
fn project() -> Effect {
effect()
}
),
"presence projections require exactly one typed presence argument",
),
(
quote!(),
quote!(
fn project(a: Scope, b: Scope) -> Effect {
effect()
}
),
"presence projections require exactly one typed presence argument",
),
(
quote!(),
quote!(
fn project((scope,): (Scope,)) -> Effect {
effect()
}
),
"presence projection argument must be a simple identifier",
),
] {
assert_eq!(
expand_presence(attributes, item).unwrap_err().to_string(),
expected
);
}
let expanded = expand_presence(
quote!(),
quote!(
pub fn project(scope: Scope) -> Effect {
effect(scope)
}
),
)
.unwrap()
.to_string();
assert!(expanded.contains("pub fn project"));
assert!(expanded.contains("impl :: hemx_sync :: PresenceUpdate"));
assert!(expanded.contains("PresenceScope :: presence_channel (& scope)"));
assert!(expanded.contains("PresenceProjection :: new"));
assert!(expanded.contains("effect (scope)"));
// test req: sync/003 req: sync/005
}
} }
+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
}
+254 -10
View File
@@ -1,4 +1,4 @@
use hemx_core::{Atom, Effect, EffectBatch, IntoEffect}; use hemx_core::{Atom, BuildFingerprint, Effect, EffectBatch, IntoEffect};
use serde::{de, Deserialize, Deserializer, Serialize}; use serde::{de, Deserialize, Deserializer, Serialize};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@@ -411,7 +411,6 @@ impl SyncEffect {
} }
pub fn send_patch(patch: FlatPatch) -> Self { pub fn send_patch(patch: FlatPatch) -> Self {
patch.validate().expect("FlatPatch must remain valid");
Self(vec![Effect::Emit { Self(vec![Effect::Emit {
name: PATCH_EVENT.to_owned(), name: PATCH_EVENT.to_owned(),
payload: patch.payload(), payload: patch.payload(),
@@ -421,8 +420,12 @@ impl SyncEffect {
/// Apply an optimistic projection now and carry the same ordinary batch in /// Apply an optimistic projection now and carry the same ordinary batch in
/// the durable patch event so the framework sync runtime can replay it /// the durable patch event so the framework sync runtime can replay it
/// after reload before acknowledgement. /// after reload before acknowledgement.
pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self { pub fn durable(
patch.validate().expect("FlatPatch must remain valid"); patch: FlatPatch,
projection: impl IntoEffect,
fingerprint: BuildFingerprint,
) -> Self {
let projection = projection.into_batch(fingerprint);
let projection_wire = projection let projection_wire = projection
.to_wire() .to_wire()
.iter() .iter()
@@ -459,15 +462,35 @@ mod tests {
let projection = Effect::Emit { let projection = Effect::Emit {
name: "projected".into(), name: "projected".into(),
payload: "card:1".into(), payload: "card:1".into(),
} };
.into_batch(hemx_core::BuildFingerprint(7)); let batch = SyncEffect::durable(patch, projection, hemx_core::BuildFingerprint(7))
let batch = .into_batch(hemx_core::BuildFingerprint(7));
SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7));
assert_eq!(batch.ops.len(), 2); assert_eq!(batch.ops.len(), 2);
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected")); assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
assert!( let Effect::Emit { name, payload } = &batch.ops[1] else {
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction")) panic!("durable sync must end with its patch event");
};
assert_eq!(name, PATCH_EVENT);
let payload: serde_json::Value = serde_json::from_str(payload).unwrap();
let expected_projection = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(7),
ops: vec![Effect::Emit {
name: "projected".into(),
payload: "card:1".into(),
}],
}
.to_wire();
assert_eq!(
payload["projection"],
serde_json::Value::Array(
expected_projection
.into_iter()
.map(serde_json::Value::from)
.collect()
)
); );
assert_eq!(payload["patch"]["idempotencyKey"], INTERACTION_ID);
} }
#[test] #[test]
@@ -545,6 +568,227 @@ mod tests {
); );
} }
#[test]
fn channel_boundary_and_errors_are_explicit() {
let valid = format!("a{}", "x".repeat(127));
assert_eq!(Channel::new(&valid).unwrap().as_str(), valid);
for (value, expected, message) in [
(
"".to_owned(),
ChannelError::Empty,
"sync channel must not be empty",
),
(
format!("a{}", "x".repeat(128)),
ChannelError::TooLong,
"sync channel is too long",
),
(
"board/alpha".to_owned(),
ChannelError::InvalidCharacter,
"sync channel contains an invalid character",
),
] {
let error = Channel::new(value).expect_err("invalid channel must fail closed");
assert_eq!(error, expected);
assert_eq!(error.to_string(), message);
}
// req: sync/024 test
}
#[test]
fn presence_leave_and_projection_preserve_observable_state() {
let channel = Channel::new("board").unwrap();
let mut tracker = PresenceTracker::default();
assert_eq!(
tracker.leave(&channel, &"missing"),
PresenceChange {
changed: false,
count: 0,
}
);
tracker.join(channel.clone(), "ada");
tracker.join(channel.clone(), "grace");
assert_eq!(tracker.count(&channel), 2);
assert_eq!(
tracker.leave(&channel, &"ada"),
PresenceChange {
changed: true,
count: 1,
}
);
assert_eq!(tracker.count(&channel), 1);
assert_eq!(tracker.leave(&channel, &"grace").count, 0);
assert_eq!(tracker.count(&channel), 0);
assert!(!tracker.members.contains_key(&channel));
let effect = Effect::Emit {
name: "presence".into(),
payload: "joined".into(),
};
let projection = PresenceProjection::new(channel, effect.clone());
assert_eq!(projection.into_batch(BuildFingerprint(1)).ops, vec![effect]);
// test req: sync/003 req: sync/005
}
#[test]
fn flat_patch_enforces_identifier_key_and_value_boundaries() {
let valid_identifier = format!("a{}", "x".repeat(127));
let valid_key = format!("a{}", "x".repeat(63));
let patch = FlatPatch::new(
&valid_identifier,
&valid_identifier,
&valid_key,
PatchValue::String("x".repeat(4096)),
)
.expect("documented patch limits are inclusive");
assert_eq!(patch.validate(), Ok(()));
assert_eq!(
FlatPatch::new("", "operation", "field", PatchValue::Boolean(true)),
Err(PatchError::Empty("idempotency_key"))
);
assert_eq!(
FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)),
Err(PatchError::Empty("operation_id"))
);
assert_eq!(
FlatPatch::new(
"actor",
"operation",
format!("a{}", "x".repeat(64)),
PatchValue::Boolean(true),
),
Err(PatchError::TooLong("key"))
);
let cases = [
FlatPatch::new(INTERACTION_ID, "", "field", PatchValue::Boolean(true)),
FlatPatch::new("", INTERACTION_ID, "field", PatchValue::Boolean(true)),
FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)),
FlatPatch::new(
format!("a{}", "x".repeat(128)),
"operation",
"field",
PatchValue::Boolean(true),
),
FlatPatch::new("actor", "bad operation", "field", PatchValue::Boolean(true)),
FlatPatch::new(
"actor",
format!("a{}", "x".repeat(128)),
"field",
PatchValue::Boolean(true),
),
FlatPatch::new("actor", "operation", "", PatchValue::Boolean(true)),
FlatPatch::new(
"actor",
"operation",
format!("a{}", "x".repeat(64)),
PatchValue::Boolean(true),
),
FlatPatch::new("actor", "operation", "1field", PatchValue::Boolean(true)),
FlatPatch::new("actor", "operation", "field.dot", PatchValue::Boolean(true)),
FlatPatch::new(
"actor",
"operation",
"field",
PatchValue::String("x".repeat(4097)),
),
FlatPatch::new(
"actor",
"operation",
"field",
PatchValue::Integer(9_007_199_254_740_992),
),
FlatPatch::new(
"actor",
"operation",
"field",
PatchValue::Integer(-9_007_199_254_740_992),
),
];
for result in cases {
assert!(result.is_err(), "invalid patch boundary must fail closed");
}
assert!(FlatPatch::new(
"actor",
"operation",
"field",
PatchValue::Integer(-9_007_199_254_740_991),
)
.is_ok());
assert_eq!(
FlatPatch::for_interaction("", PatchValue::Boolean(true)),
Err(PatchError::Empty("key"))
);
assert_eq!(
FlatPatch::for_interaction("field", PatchValue::String("x".repeat(4097)),),
Err(PatchError::ValueTooLong)
);
assert_eq!(PatchError::ReservedKey.to_string(), "patch key is reserved");
assert_eq!(
PatchError::ValueTooLong.to_string(),
"patch string value is too long"
);
// req: sync/017 test req: sync/025 test req: sync/026 test req: sync/027 test
}
#[test]
fn flat_patch_json_round_trips_escaped_values_and_rejects_invalid_input() {
let escaped = "quote:\" slash:\\ newline:\n return:\r tab:\t control:\u{1f}";
let patch = FlatPatch::new(
"actor:1",
"operation-1",
"field_name",
PatchValue::String(escaped.into()),
)
.unwrap();
let payload = patch.payload();
let decoded: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(decoded["value"], escaped);
assert_eq!(serde_json::from_str::<FlatPatch>(&payload).unwrap(), patch);
for (json, expected) in [
(
r#"{"schemaVersion":2,"idempotencyKey":"actor","operationId":"op","key":"field","value":true}"#,
"unsupported patch schema version 2",
),
(
r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"field","value":true,"extra":1}"#,
"unknown field `extra`",
),
(
r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"1field","value":true}"#,
"key contains an invalid character",
),
] {
assert!(
serde_json::from_str::<FlatPatch>(json)
.unwrap_err()
.to_string()
.contains(expected),
"invalid JSON must report {expected}"
);
}
// req: sync/014 test req: sync/028 test req: sync/029 test
}
#[test]
fn send_patch_emits_the_canonical_payload() {
let patch = FlatPatch::for_interaction("done", PatchValue::Boolean(true)).unwrap();
assert_eq!(patch.validate(), Ok(()));
let expected = patch.payload();
assert_eq!(
SyncEffect::send_patch(patch)
.into_batch(BuildFingerprint(4))
.ops,
vec![Effect::Emit {
name: PATCH_EVENT.into(),
payload: expected,
}]
);
// test req: sync/002 req: sync/009
}
#[test] #[test]
fn schema_is_flat_and_rejects_reserved_keys() { fn schema_is_flat_and_rejects_reserved_keys() {
let patch = FlatPatch::new( let patch = FlatPatch::new(
+26 -11
View File
@@ -4,7 +4,7 @@ use hemx_core::{
}; };
use std::io; use std::io;
use std::net::TcpStream; use std::net::TcpStream;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// A child process owned by an integration test and proven ready over TCP. /// A child process owned by an integration test and proven ready over TCP.
@@ -16,6 +16,14 @@ pub struct TestProcess {
child: Child, 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 { impl TestProcess {
pub fn start( pub fn start(
mut command: Command, mut command: Command,
@@ -37,7 +45,7 @@ impl TestProcess {
if TcpStream::connect(addr).is_ok() { if TcpStream::connect(addr).is_ok() {
return Ok(process); 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!( return Err(io::Error::other(format!(
"{label} exited with {status} before listening on {addr}" "{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}"), 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)) inspect(handler(input))
} }
fn inspection_fingerprint() -> BuildFingerprint {
BuildFingerprint(0)
}
pub fn inspect(effect: impl IntoEffect) -> EffectInspector { 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. /// 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. /// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
/// req: test/001 req: dx/006 /// req: test/001 req: dx/006
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector { 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. /// 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) { fn assert_simple_selector_part(value: &str, label: &str) {
assert!( assert!(
value !value.is_empty()
.chars() && value
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'), .chars()
"{label} selector part must contain only ascii alphanumerics or '-'" .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 { fn runtime_attr_marker(name: &str, value: &str) -> String {
let escaped = value.replace('"', "&quot;"); format!(r#"{name}="{value}""#)
format!(r#"{name}="{escaped}""#)
} }
fn attr_selector(name: &str, value: &str) -> String { fn attr_selector(name: &str, value: &str) -> String {
+2 -2
View File
@@ -357,8 +357,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool {
&& line.contains(r#"<script src="/island.js" defer></script>"#)) && line.contains(r#"<script src="/island.js" defer></script>"#))
|| (path.ends_with("examples/saas/templates/app_shell.heml") || (path.ends_with("examples/saas/templates/app_shell.heml")
&& line.contains(r#"<script src="/metrics.js" defer></script>"#)) && line.contains(r#"<script src="/metrics.js" defer></script>"#))
|| (path.ends_with("examples/kanban/templates/sync_shell.heml") || (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
&& line.contains(r#"<script src="/sync.js" defer></script>"#)) && line.contains(r#"<script type="module" src="/sync.js"></script>"#))
} }
fn contains_inline_event_handler(line: &str) -> bool { fn contains_inline_event_handler(line: &str) -> bool {
+264 -7
View File
@@ -1,7 +1,22 @@
use hemx_core::{ 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] #[test]
fn inspects_tuple_effects() { fn inspects_tuple_effects() {
let count = Slot::<u32>::new(1); let count = Slot::<u32>::new(1);
@@ -84,6 +99,242 @@ fn finds_keyed_slot_targets() {
assert_eq!(inspected.ops().len(), 1); assert_eq!(inspected.ops().len(), 1);
} }
#[test]
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
// test req: test/008 req: test/009 req: test/018
let expected = TestTarget(ResourceKind::Slot, 42);
let other = TestTarget(ResourceKind::Slot, 7);
let resource = ResourceId::new(ResourceKind::Slot, 42);
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
let form = Form::<()>::new(11);
let expected_emit = Effect::Emit {
name: "saved".into(),
payload: "card 42 saved".into(),
};
let inspected = hemx_test::inspect(vec![
Effect::Put {
target: ResourceRef::unscoped(other.__hemx_resource_id()),
payload: Payload::Text("decoy needle".into()),
},
Effect::Put {
target: ResourceRef::unscoped(resource),
payload: Payload::Text("expected text".into()),
},
Effect::Put {
target: keyed_ref.clone(),
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
},
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-2".into(),
payload: Payload::Html("<li>inserted</li>".into()),
},
Effect::Prepend {
target: ResourceRef::unscoped(resource),
key: "row-0".into(),
payload: Payload::Html("<li>prepended</li>".into()),
},
Effect::Remove {
target: ResourceRef::unscoped(resource),
key: Some("row-old".into()),
},
Effect::Focus {
target: ResourceRef::unscoped(form.id()),
},
Effect::Navigate {
url: "/cards/42".into(),
mode: NavigateMode::Push,
scroll: hemx_core::ScrollBehavior::Preserve,
title: None,
},
expected_emit.clone(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: form.id().id.to_string(),
},
]);
assert!(!inspected.is_empty());
assert_eq!(inspected.op_count(), 10);
assert!(inspected.contains(&expected_emit));
assert!(!inspected.contains(&Effect::Emit {
name: "saved".into(),
payload: "wrong".into(),
}));
assert!(inspected.has_resource(resource));
assert!(inspected.has_resource(other.__hemx_resource_id()));
assert!(inspected.has_target(expected));
assert!(inspected.has_target(other));
assert!(!inspected.has_target(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.updates_text(expected));
assert!(inspected.updates_text_containing(expected, "expected"));
assert!(!inspected.updates_text_containing(expected, "decoy"));
assert!(inspected.updates_text(other));
assert!(!inspected.updates_text(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.updates_html(expected));
assert!(inspected.updates_html_containing(expected, "replacement"));
assert!(!inspected.updates_html_containing(expected, "missing"));
assert!(!inspected.updates_html(TestTarget(ResourceKind::Slot, 99)));
assert!(inspected.replaces_keyed_html_containing(expected, "row-1", "replacement"));
assert!(!inspected.replaces_keyed_html_containing(expected, "row-2", "replacement"));
assert!(inspected.inserts_html_containing(expected, "row-2", "inserted"));
assert!(!inspected.inserts_html_containing(expected, "row-0", "inserted"));
assert!(inspected.removes_key(expected, "row-old"));
assert!(!inspected.removes_key(expected, "row-2"));
assert!(inspected.pushes_to("/cards/42"));
assert!(!inspected.pushes_to("/cards/7"));
assert!(inspected.payload_contains("prepended"));
assert!(!inspected.payload_contains("absent"));
assert!(inspected.payload_excludes("absent"));
assert!(!inspected.payload_excludes("expected"));
assert!(inspected.payload_excludes_key("absent"));
assert!(!inspected.payload_excludes_key("row-1"));
assert_eq!(
inspected.target_html_containing(expected, "replacement"),
Some("<li data-key=\"row-1\">replacement</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "inserted"),
Some("<li>inserted</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "prepended"),
Some("<li>prepended</li>")
);
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
assert!(inspected.emits("saved", "card 42 saved"));
assert!(!inspected.emits("saved", "wrong"));
assert!(inspected.emits_containing("saved", "42"));
assert!(!inspected.emits_containing("other", "42"));
assert!(inspected.has_ref(&keyed_ref));
assert!(inspected.has_ref(&ResourceRef::unscoped(resource)));
assert!(!inspected.has_ref(&ResourceRef::scoped(
resource,
ScopeKey::Field("row-1".into()),
)));
assert!(inspected.has_slot(Slot::<()>::new(42)));
assert!(!inspected.has_slot(Slot::<()>::new(99)));
assert!(inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(42)));
assert!(!inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(99)));
assert!(!inspected.has_atom(Atom::<()>::new(42)));
assert!(inspected.has_form(form));
assert!(!inspected.has_form(Form::<()>::new(12)));
assert!(inspected.resets_form(form));
assert!(!inspected.resets_form(Form::<()>::new(12)));
let empty = hemx_test::inspect(Vec::<Effect>::new());
assert!(empty.is_empty());
assert_eq!(empty.op_count(), 0);
}
#[test]
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
// test req: test/017
assert_eq!(hemx_test::heading_selector("", 2), "h2");
assert_eq!(hemx_test::list_item_selector(""), "li");
assert_eq!(hemx_test::prose_selector(""), "p");
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
"[data-sid=\"1\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
"[data-aid=\"2\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
"[data-hid=\"3\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
"[data-fid=\"4\"]"
);
assert_eq!(
hemx_test::nav_link_selector("/path\\\"quoted"),
"a[href=\"/path\\\\\\\"quoted\"]"
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 1),
"slot",
"<p data-sid=\"1\"></p>",
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-hid=\"3\"></button>",
);
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
})
.is_err());
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-other=\"3\"></button>",
);
})
.is_err());
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
assert!(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] #[test]
fn builds_generated_handle_form_bodies() { fn builds_generated_handle_form_bodies() {
let handle = hemx_core::Handle::<()>::new(7); let handle = hemx_core::Handle::<()>::new(7);
@@ -91,6 +342,10 @@ fn builds_generated_handle_form_bodies() {
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]); let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b"); assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
assert_eq!(
hemx_test::handle_form_body(handle, &[("AZaz09-_.~", "AZaz09-_.~ /é")],),
"__h=7&AZaz09-_.~=AZaz09-_.~+%2F%C3%A9"
);
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99"); assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
} }
@@ -189,13 +444,15 @@ fn builds_authoring_boundary_selectors() {
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]"); assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script( let probe = hemx_test::island_probe_script(
"probe-island", "probe-\"island",
"orbit", "orbit\\bridge",
"1|1|1|probe waiting", "line1\nline2\rline3\tend",
"7|2|8|probe live", "detail \"quoted\"",
); );
assert!(probe.contains("probe-island")); assert!(probe.contains(r#"island.id = "probe-\"island";"#));
assert!(probe.contains("probe live")); assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
+86 -1
View File
@@ -1,6 +1,7 @@
use hemx_test::TestProcess; use hemx_test::TestProcess;
use std::net::{TcpListener, TcpStream};
use std::process::Command; use std::process::Command;
use std::time::Duration; use std::time::{Duration, Instant};
#[test] #[test]
fn process_harness_reports_early_exit_with_context() { fn process_harness_reports_early_exit_with_context() {
@@ -27,5 +28,89 @@ fn process_harness_reports_early_exit_with_context() {
assert!(message.contains("exited with"), "{message}"); assert!(message.contains("exited with"), "{message}");
} }
#[test]
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
// test req: test/019
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = reservation.local_addr().unwrap().to_string();
drop(reservation);
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_listens")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_ADDR", &addr);
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
.expect("readiness must observe the helper listener");
assert!(TcpStream::connect(&addr).is_ok());
drop(process);
let deadline = Instant::now() + Duration::from_secs(2);
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
TcpStream::connect(&addr).is_err(),
"drop must reap the helper"
);
}
#[test]
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
// test req: test/019
let spawn_error = match TestProcess::start(
Command::new("/definitely/not/a/hemx/executable"),
"missing helper",
"127.0.0.1:9",
Duration::from_millis(10),
) {
Ok(_) => panic!("spawn failure must be returned, not panic"),
Err(error) => error,
};
assert!(spawn_error
.to_string()
.contains("failed to spawn missing helper"));
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let unused_addr = reservation.local_addr().unwrap().to_string();
drop(reservation);
let mut command = Command::new(std::env::current_exe().unwrap());
command
.arg("--exact")
.arg("helper_process_sleeps")
.arg("--nocapture")
.env("HEMX_TEST_PROCESS_SLEEP", "1");
let timeout = match TestProcess::start(
command,
"non-listening helper",
&unused_addr,
Duration::from_millis(100),
) {
Ok(_) => panic!("non-listening process must time out"),
Err(error) => error,
};
let message = timeout.to_string();
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("non-listening helper"), "{message}");
}
#[test] #[test]
fn helper_process_exits_successfully() {} fn helper_process_exits_successfully() {}
#[test]
fn helper_process_listens() {
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let _listener = TcpListener::bind(addr).expect("bind helper listener");
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_sleeps() {
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
std::thread::sleep(Duration::from_secs(10));
}
}
+98 -2
View File
@@ -34,8 +34,8 @@ pub struct ClientState {
/// Validates primitive wasm-bindgen values before application code runs. /// Validates primitive wasm-bindgen values before application code runs.
/// ///
/// Primitive arguments keep JavaScript from owning a second binary codec. The /// Primitive arguments keep JavaScript from owning a second event/state codec.
/// ordinary effect result remains postcard-encoded by `hemx-core`. /// The ordinary effect result uses `hemx-core`'s canonical `EffectBatch` codec.
#[doc(hidden)] #[doc(hidden)]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn decode_client_inputs( pub fn decode_client_inputs(
@@ -149,6 +149,102 @@ mod tests {
); // req: client_local/008 ); // req: client_local/008
} }
#[test]
fn client_input_boundary_accepts_limits_and_preserves_values() {
let kind = "k".repeat(256);
let value = "v".repeat(64 * 1024);
let key = "x".repeat(1024);
let state = "s".repeat(1024 * 1024);
let (event, decoded_state) = decode_client_inputs(
1,
kind.clone(),
Some(value.clone()),
Some(true),
Some(key.clone()),
1,
state.clone(),
)
.expect("documented client-local limits are inclusive");
assert_eq!(
event,
ClientEvent {
kind,
value: Some(value),
checked: Some(true),
key: Some(key),
}
);
assert_eq!(decoded_state, ClientState { encoded: state });
// req: client_local/005 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test
}
#[test]
fn client_input_boundary_rejects_invalid_versions_and_payload_sizes() {
let decode = |event_version, kind, value, key, state_version, state| {
decode_client_inputs(event_version, kind, value, None, key, state_version, state)
};
for (result, expected) in [
(
decode(0, "click".into(), None, None, 1, String::new()),
"unsupported client-local event ABI version 0; expected 1",
),
(
decode(1, String::new(), None, None, 1, String::new()),
"invalid client-local event payload: event kind must contain 1..=256 bytes",
),
(
decode(1, "k".repeat(257), None, None, 1, String::new()),
"invalid client-local event payload: event kind must contain 1..=256 bytes",
),
(
decode(
1,
"input".into(),
Some("v".repeat(64 * 1024 + 1)),
None,
1,
String::new(),
),
"invalid client-local event payload: value exceeds 65536 bytes",
),
(
decode(
1,
"keydown".into(),
None,
Some("k".repeat(1025)),
1,
String::new(),
),
"invalid client-local event payload: key exceeds 1024 bytes",
),
(
decode(1, "click".into(), None, None, 0, String::new()),
"unsupported client-local state ABI version 0; expected 1",
),
(
decode(
1,
"click".into(),
None,
None,
1,
"s".repeat(1024 * 1024 + 1),
),
"invalid client-local state payload: state exceeds 1048576 bytes",
),
] {
assert_eq!(
result.expect_err("invalid client input must fail closed"),
expected
);
}
// req: client_local/008 test req: client_local/015 test req: client_local/016 test req: client_local/017 test req: client_local/018 test req: client_local/019 test
}
#[test] #[test]
fn client_handler_uses_the_ordinary_effect_wire_format() { fn client_handler_uses_the_ordinary_effect_wire_format() {
let fingerprint = BuildFingerprint(17); let fingerprint = BuildFingerprint(17);
+40 -28
View File
@@ -7,11 +7,37 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use thirtyfour::common::capabilities::firefox::FirefoxPreferences; use thirtyfour::common::capabilities::firefox::{FirefoxCapabilities, FirefoxPreferences};
use thirtyfour::prelude::*; use thirtyfour::prelude::*;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); 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] #[tokio::test]
async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> {
// req: client_local/005 req: client_local/009 req: client_local/010 // req: client_local/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()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { 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()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut no_script_preferences = FirefoxPreferences::new(); let no_script_caps = headless_firefox_capabilities(false)?;
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_driver = WebDriver::new(&webdriver_url, no_script_caps).await?; let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?;
no_script_driver.goto(&host_url).await?; no_script_driver.goto(&host_url).await?;
no_script_driver no_script_driver
@@ -431,8 +451,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
let _ = no_script_driver.quit().await; let _ = no_script_driver.quit().await;
moved_without_script?; moved_without_script?;
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&webdriver_url, caps).await?; let driver = WebDriver::new(&webdriver_url, caps).await?;
let result = async { let result = async {
driver.goto(&host_url).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; driver.goto(&server.url()).await?;
@@ -1190,9 +1205,9 @@ async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriver
#[tokio::test] #[tokio::test]
async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> { 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_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")) let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
@@ -1214,8 +1229,7 @@ async fn kanban_replay_is_bounded_and_within_budget() -> WebDriverResult<()> {
let mut webdriver = Command::new("geckodriver"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; 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"); let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string()); webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut caps = DesiredCapabilities::firefox(); let caps = headless_firefox_capabilities(true)?;
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async { let result = async {
driver.goto(&server.url()).await?; driver.goto(&server.url()).await?;
+236 -6
View File
@@ -12,6 +12,11 @@ fn main() -> ExitCode {
Some("test") | None => run_test_plan(), Some("test") | None => run_test_plan(),
Some("html-examples-smoke") => run_html_examples_smoke(), Some("html-examples-smoke") => run_html_examples_smoke(),
Some("bench") => run_bench_plan(), 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") => { Some("workout") => {
let subcommand = args.next(); let subcommand = args.next();
let operand = args.next(); let operand = args.next();
@@ -37,7 +42,7 @@ fn main() -> ExitCode {
fn print_help() { fn print_help() {
println!( 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), ("delete row", DELETE_ROW_SMOKE),
("lazy load", LAZY_LOAD_SMOKE), ("lazy load", LAZY_LOAD_SMOKE),
("click-to-load load more", CLICK_TO_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), ("value select", VALUE_SELECT_SMOKE),
("reset user input", RESET_INPUT_SMOKE), ("reset user input", RESET_INPUT_SMOKE),
]; ];
@@ -130,6 +135,7 @@ fn pick_unused_port() -> Result<u16, ExitCode> {
fn start_html_examples_server(addr: &str) -> Result<HtmlExamplesServer, ExitCode> { fn start_html_examples_server(addr: &str) -> Result<HtmlExamplesServer, ExitCode> {
let mut child = Command::new("cargo") let mut child = Command::new("cargo")
.current_dir(workspace_root())
.args(["run", "-p", "hemx-html-examples"]) .args(["run", "-p", "hemx-html-examples"])
.env( .env(
"HEMX_HTML_EXAMPLES_PORT", "HEMX_HTML_EXAMPLES_PORT",
@@ -1190,7 +1196,7 @@ fn run_test_plan() -> ExitCode {
"techdemo-unit-http", "techdemo-unit-http",
["test", "-p", "hemx-techdemo", "--test", "e2e"], ["test", "-p", "hemx-techdemo", "--test", "e2e"],
), ),
Step::new("redgate", ["health", "--strict"]).tool("redgate"), Step::new("redgate", ["refs"]).tool("redgate"),
]; ];
if !budget.skip_browser { if !budget.skip_browser {
@@ -1247,6 +1253,185 @@ fn run_bench_plan() -> ExitCode {
ExitCode::SUCCESS 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)] #[derive(Clone, Copy, Debug)]
struct Budget { struct Budget {
cpus: usize, cpus: usize,
@@ -1472,13 +1657,58 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
mod tests { mod tests {
use super::{ use super::{
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold, android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release, create_workout_app, mobile_external_blockers, mutation_concurrency, mutation_packages,
workout_mobile_manifest, workspace_root, write_workout_mobile_release, Budget, mutation_shard, origin_host, verify_workout_mobile_release, workout_mobile_manifest,
WorkoutMobileConfig, workspace_root, write_workout_mobile_release, Budget, MutationConcurrency,
WorkoutMobileConfig, MUTATION_PACKAGES,
}; };
use std::fs; use std::fs;
use std::path::PathBuf; 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] #[test]
fn verification_steps_resolve_the_workspace_independent_of_caller_directory() { fn verification_steps_resolve_the_workspace_independent_of_caller_directory() {
assert!(workspace_root().join("Cargo.toml").is_file()); // req: test/004 assert!(workspace_root().join("Cargo.toml").is_file()); // req: test/004
+13 -4
View File
@@ -5,10 +5,10 @@
use hemx_core::SafeHtml; use hemx_core::SafeHtml;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use hemx_core::{Effect, KeyedSlot, Slot}; use hemx_core::{KeyedSlot, Slot};
pub use hemx_core::{ 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, FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
}; };
@@ -93,8 +93,7 @@ pub mod __private {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn render_template(view: &impl hemplate::Hemplate) -> Html { fn render_template(view: &impl hemplate::Hemplate) -> Html {
let mut html = String::with_capacity(view.size_hint()); let mut html = String::with_capacity(view.size_hint());
view.render_into(&mut html) view.render_into(&mut html).unwrap();
.expect("hemplate view renders into hemx effect payload");
__private::html_trusted(html) __private::html_trusted(html)
} }
@@ -261,4 +260,14 @@ mod tests {
assert_eq!(html.as_str(), "<strong>ok</strong>"); assert_eq!(html.as_str(), "<strong>ok</strong>");
} }
#[test]
fn html_string_views_preserve_the_rendered_fragment() {
let html = crate::__private::html_trusted("<p>hello</p>");
assert_eq!(html.as_str(), "<p>hello</p>");
assert_eq!(html.as_ref(), "<p>hello</p>");
assert_eq!(html.to_string(), "<p>hello</p>");
assert_eq!(html.into_string(), "<p>hello</p>");
// test req: public_api/005
}
} }