Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+46 -61
View File
@@ -1,74 +1,59 @@
# hemx — AGENTS.md # AGENTS.md
## Purpose ## Authority and workflow
This file tells coding agents how to work in this repository. It is a durable operating contract, not a generated inventory. Repository-root `INTENT.tsv` and `SPEC.tsv` are the sole product authorities. `INTENT.tsv` owns `PROBLEM`, `FOR`, and `OUTCOME`; `SPEC.tsv` owns falsifiable `RULE` records and their `INTENTS` edges. Code, tests, runtime observations, public documentation, `handoff.md`, and history are evidence, not parallel canon. Durable behavior changes update the canonical TSV first. `PLAN.md` is the disposable current execution surface.
Keep it stable. Prefer pointers to canonical sources over copied structure, file lists, metrics, architecture maps, command inventories, or status snapshots. Use `# intent: <id> <role>` and `# spec: <id> <role>` with the Redgate roles `impl`, `check`, `doc`, and `exception`. Put `check` only on the local executable assertion or focused command that would fail for that rule. Other roles provide traceability but do not satisfy `redgate check`; exceptions expose debt and never make strict checks pass. Feed findings back into authority, implementation, or proof rather than weakening checks.
## Agent workflow Run `redgate list`, `redgate refs`, `redgate lint`, and `redgate check` before completion. Broad or risky completion claims also require an independent fresh-context conformance review after deterministic checks pass.
- Start from product intent and requirements; inspect code only after the target behavior is clear. Final handoffs include `INTENT IMPACT` and `SPECIFICATION IMPACT`: changed IDs, proof, and any unresolved gap.
- For v1 work, read `docs/v1-product-evidence.md`, then `REQUIREMENTS.md`; use `PLAN.md` only as the mutable implementation cursor.
- Read `REQUIREMENTS.md` before changing behavior.
- If behavior changes, update `REQUIREMENTS.md` in the same change.
- If implementation work does not change durable product obligations, acceptance, safety/recovery behavior, or verification duties, say `REQUIREMENT IMPACT: none` in the handoff and why.
- Cite relevant requirements in code, tests, or docs as `req: <component>/001`.
- Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text.
- Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful.
- Run `redgate list`, `redgate refs`, and `redgate health` when requirements change; include `redgate lint` when the change is meant to reduce requirement maintainability warnings.
## Git workflow ## Workspace ownership
- Commit complete, coherent slices only; do not commit broken work or temporary debug output. The public core consists of seven packages:
- Use Conventional Commits: `type(scope): summary`.
- Keep commit subjects readable; requirement IDs do not have to be in the subject.
- Every behavior-changing or requirement-changing commit should cite relevant requirement IDs in the commit body or trailers using `req: <component>/001`.
- Use commit history for evolution: `git log --grep 'req: parser/012'` should find the commits that changed that behavior.
- Use the current tree for state: `REQUIREMENTS.md`, citations, tests, and `redgate health` describe what is true now.
- Before committing requirement or behavior changes, run relevant tests and the strongest `redgate health` mode supported by the installed tool. Do not invent an unsupported `--strict` flag; report a tool/format mismatch explicitly.
## Requirements-first TDD ```text
hemx-core
hemx-derive
hemx-js
hemx-build
hemx-axum
hemx
hemx-test
```
- Write requirements as desired behavior, not as a snapshot of current behavior. `hemx-core` owns effects, typed resources, wire encoding, and fingerprints. `hemx-build` inspects Hemplate templates and emits generated resource metadata. `hemx-derive` owns macros. `hemx-js` owns the generic browser runtime. `hemx-axum` owns Axum transport. `hemx` is the facade. `hemx-test` owns public testing utilities.
- Keep each requirement row to one checkable obligation with an explicit redgate ring field; shorten near-limit rows or split oversized examples, escape hatches, or negative cases into separate stable IDs while preserving the original ID's main intent.
- First split intent into broad error classes: what can go wrong, and what outcome must hold.
- Test the largest risky classes before narrow examples.
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
- When changing generated lowering, prove dynamic rendered attributes at the consumer boundary; literal lowering fixtures alone are insufficient.
- Do not over-codify existing behavior while direction is still moving.
- Add narrow concrete tests only after requirements converge into a stable direction.
## Redgate CLI Server-side Wasm uses normal `hemx` and the portable Hemplate runtime. Host parser and Tree-sitter dependencies must not enter its target normal dependency graph.
- `redgate list` — show requirements as TSV. The public GitHub mirror is release output. Keep `INTENT.tsv`, `SPEC.tsv`, `PLAN.md`, `.redgate/`, requirement checks, and private workflow text out of it while preserving the tested product tree. Experimental browser-local host, Wasm, and synchronization work belongs in the separate private Labs repository and must not be copied into this core.
- `redgate refs` — show `req:` citations found in the repo.
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
- `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup.
- `redgate refs` — with the installed CLI, parse the elected requirement format and audit repository citations; `redgate health` additionally enforces a newer prescriptive-row style not yet elected by this requirements corpus.
- `redgate agents` — print this starter template; review, shrink, and edit before committing.
## Local guidance ## Required proof
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run. Run the narrowest relevant test while editing. Before completion run:
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. ```console
- 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 redgate list
- 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 redgate refs
- 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 redgate lint
- 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 redgate check
- 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 cargo fmt --all -- --check
- 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 cargo clippy --workspace --all-targets --all-features -- -D warnings
- 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 cargo test --workspace --all-targets --all-features
- 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 cargo deny check licenses sources
- 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` and `data-hemx-ws` root-scoped/same-origin by default. A Cloudflare Durable Object proof keeps stable named-object authority and persisted Rust domain state, uses WebSocket hibernation for eviction recovery, and broadcasts rendered effects without storing DOM patches or effects as domain truth. 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: push/009 req: push/010 req: push/011 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 cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
- `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 cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 ```
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 The target tree must contain neither `tree-sitter` nor `hemplate-parser`. Publishable package changes also require archive-based `cargo package --list` and `cargo package` checks for all seven packages.
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling and viewport-aware revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 ## Product constraints
- 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 - The server owns effects and behavior; generated typed resources connect templates to handlers and effects.
- 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 - Raw HTML remains explicit and typed.
- Axum 0.8 apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings; keep hemx-axum as route/runtime/handler adapter around generated partial swaps, not a routing owner. req: axum/002 req: axum_integration/001 req: axum_integration/002 req: axum_integration/003 req: axum_integration/005 req: axum_integration/006 - Canonical wire bytes and ABI compatibility remain deterministic.
- Framework behavior stays in `hemx-axum`; browser runtime behavior stays in `hemx-js`.
- Public docs change with APIs, package boundaries, or compatibility.
- Preserve licenses; do not commit archives, `target/`, coverage, fuzz, mutation, local editor, or private governance output to the public mirror.
Generated
+58 -1506
View File
File diff suppressed because it is too large Load Diff
+5 -8
View File
@@ -1,17 +1,14 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["hemplate-runtime", "hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout", "examples/cloudflare_do"] members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "tests/server-wasm"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.3.0"
edition = "2021" edition = "2021"
rust-version = "1.88"
license = "MIT"
repository = "https://github.com/tmk241/hemx"
[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" }
+7
View File
@@ -0,0 +1,7 @@
ID PROBLEM FOR OUTCOME
server/001 Browser frameworks duplicate authoritative application state and policy Rust teams building ordinary server-owned web applications Keep state, authorization, validation, and recovery on the server
resource/001 Copied selectors and protocol identifiers drift away from rendered templates Template and handler authors Use generated typed resources and actions for every interaction boundary
protocol/001 Ad hoc browser commands create ambiguous ordering and compatibility Framework adapters and browser runtimes Apply one deterministic ordered effect protocol within an owned root
web/001 Framework abstractions often replace native web semantics unnecessarily People using Hemx applications Retain semantic HTML, accessibility, URLs, forms, and browser fallback
portable/001 Host-only parsing dependencies prevent portable server-side rendering Rust applications targeting Wasm and constrained servers Render through normal Hemx APIs without host parser dependencies
scope/001 Universal extension systems turn a small interaction layer into a client framework Maintainers and application authors Compose ordinary behavior from a small kernel, direct adapters, and explicit islands
1 ID PROBLEM FOR OUTCOME
2 server/001 Browser frameworks duplicate authoritative application state and policy Rust teams building ordinary server-owned web applications Keep state, authorization, validation, and recovery on the server
3 resource/001 Copied selectors and protocol identifiers drift away from rendered templates Template and handler authors Use generated typed resources and actions for every interaction boundary
4 protocol/001 Ad hoc browser commands create ambiguous ordering and compatibility Framework adapters and browser runtimes Apply one deterministic ordered effect protocol within an owned root
5 web/001 Framework abstractions often replace native web semantics unnecessarily People using Hemx applications Retain semantic HTML, accessibility, URLs, forms, and browser fallback
6 portable/001 Host-only parsing dependencies prevent portable server-side rendering Rust applications targeting Wasm and constrained servers Render through normal Hemx APIs without host parser dependencies
7 scope/001 Universal extension systems turn a small interaction layer into a client framework Maintainers and application authors Compose ordinary behavior from a small kernel, direct adapters, and explicit islands
+47 -25
View File
@@ -1,31 +1,53 @@
# PLAN — Cloudflare Durable Objects proof # Current plan
Parent outcome: prove that hemx keeps its semantic `.heml` authoring, generated typed resources, compile-time target checking, canonical `EffectBatch` wire format, and tiny runtime while a Cloudflare Durable Object owns one durable collaborative room and hibernating WebSocket fan-out. ## Slice HMX-001 — Closed typed effect path
Non-goals: a generic Cloudflare framework, Cloudflare-owned auth policy, RPC/alarm/queue abstractions, global object discovery, offline reconciliation, deployment automation, or treating stored HTML/effects as business truth. Outcome: A handler can build the complete closed effect algebra with generated resource types, encode it canonically, and have one owned browser root apply it in order with defined failure behavior.
Delta: kernel/001–016, resource/001–004, resource/006, runtime/001, runtime/005, axum/001, axum/005.
Path: generated resource API -> typed effect batch -> canonical Axum response -> root-scoped browser decoder and executor -> DOM, focus, scroll, history, or dispatch result.
Build: Reconcile `hemx-core` effect/resource types and wire format, `hemx-build`/`hemx-derive` generated capabilities, `hemx-axum` response boundary, and `hemx-js` execution semantics as one compatibility break.
Risk: Partial migration can make server and browser disagree about opcodes, targets, ordering, or failure, causing wrong-root mutation or partial application.
Checks: Focused core round-trip/rejection tests; generated capability compile-pass/fail tests; Axum byte/status tests; browser scenarios for all eight effects, ordering, created-resource reuse, move identity, and stop-on-failure; `tests/redgate_test.sh`; `redgate check` for the slice IDs.
Non-goals: Request-policy attributes, optional transport adapters, islands, or public release publication.
Residual risk: Native interaction policy and adapter lifecycle remain in later slices.
State: Done
Blocked by: none
## Slice CF-1 — Canonical WebSocket push ## Slice HMX-002 — Native interaction and recovery
Outcome: a hemx root can receive binary `EffectBatch` updates over a same-origin WebSocket with the same ABI/fingerprint and root-scoped failure behavior as HTTP/SSE. Outcome: Generated semantic HTML submits forms and navigation through the closed effect path while preserving accessibility and ordinary browser recovery.
Delta: push/001, push/003, push/005, push/009; runtime/001; failure/001. Delta: html/001–007, runtime/003–004, axum/003–004.
Path: `data-hemx-ws` on generated root -> runtime WebSocket -> binary frame -> existing `applyBatch` -> generated target or root error outlet. Path: generated marker and native anchor/form -> request policy and Axum boundary -> rendered validation or navigation response -> accessible browser result or native fallback.
Build: validate the root declaration in hemx-build; add runtime bind/cleanup/error behavior and focused consumer-boundary tests. Build: Align generated marker vocabulary, native-event defaults, bounded request policies, form encoding, validation markup, partial navigation metadata, and full-navigation recovery.
Risk: accepting text, cross-origin, malformed, or stale-build messages could bypass the canonical compatibility boundary or mutate the wrong root. Risk: Enhancement can suppress native input, accessibility, or fallback behavior and leave users unable to submit, navigate, or recover.
Proof: `cargo test -p hemx-build -p hemx-js` plus `cargo check --target wasm32-unknown-unknown -p hemx`. Checks: Generated-markup assertions for every marker and policy value; browser scenarios with enhancement enabled and unavailable; form encoding and body-limit integration tests; validation accessibility assertions; partial/full navigation recovery scenarios; `redgate check` for the slice IDs.
Non-goals: client command protocol, reconnect/replay policy beyond the browser WebSocket primitive, multiplexing, or a general transport trait. Non-goals: SSE, WebSocket, timers, reveal behavior, or island-owned local state.
Residual risk: a real Cloudflare hibernation journey remains for CF-2. Residual risk: Optional adapter and island lifecycle remains open.
State: Ready. State: Ready
Blocked by: none. Blocked by: none
## Slice CF-2 — Durable room exemplar ## Slice HMX-003 — Direct adapters and explicit islands
Outcome: two browser clients in one named room see a counter mutation rendered from durable Rust state without reload, and reopening the room after object restart/eviction restores the persisted count. Outcome: Optional transports and local islands compose with the generic runtime without adding effect schemas, a plugin registry, or mirrored client application state.
Delta: push/001, push/002, push/003, push/009, push/010, push/011; canonical_authoring/001; state/001; abi/004. Delta: runtime/006–008, boundary/001–004, axum/002.
Path: Worker room URL -> stable Durable Object name -> `.heml` page -> WebSocket upgrade -> typed increment command -> persisted counter -> generated counter partial -> canonical `EffectBatch` -> hibernating sockets -> both roots update. Path: root-owned adapter or explicit island marker -> direct bind/scan/cleanup lifecycle -> unchanged effect batch or island-owned subtree -> deterministic ownership result.
Build: add one `examples/cloudflare_do` worker-rs exemplar with build-time hemx generation, one semantic template, one Durable Object class, Wrangler migration/binding, and focused pure tests for command/state/render output. Build: Keep generic runtime behavior in `hemx-js`, framework transport in `hemx-axum`, direct adapter lifecycle beside each adapter, and explicit morph boundaries around islands.
Risk: target-toolchain incompatibility, using an in-memory socket registry, persisting UI output instead of state, or emitting noncanonical WebSocket bytes would invalidate the proof. Risk: Duplicate binding, leaked cleanup, or ambiguous ownership can apply effects twice, cross roots, or overwrite island state.
Proof: native tests for command/render behavior; `cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example`; then `wrangler dev` browser smoke when Wrangler is available. Checks: Browser lifecycle scenarios for bind-once, inserted fragments, removal cleanup, wrong-root rejection, and island preservation; static/public-API checks excluding plugin registries, extra core effects, and client stores; unchanged-byte transport tests; `redgate check` for the slice IDs.
Non-goals: production auth/CSRF/tenancy, alarms, queues, RPC wrappers, multi-object transactions, deployment, billing, or a public `hemx-cloudflare` crate. Non-goals: New transports, a general extension API, or application-specific island frameworks.
Residual risk: hosted Cloudflare deployment, jurisdiction policy, and production credentials remain external. Residual risk: Adapter-specific network behavior remains owned by each optional adapter.
State: Ready for local build; hosted runtime proof is Blocked. State: Ready
Blocked by: Wrangler runtime availability and Cloudflare account credentials for hosted verification. Blocked by: none
## Slice HMX-004 — Deterministic generation and portable server build
Outcome: Template inspection and macros produce stable, actionable generated contracts, and normal server-side Hemx APIs compile for wasm32 without host parser dependencies.
Delta: resource/005, build/001–003, derive/001–004, wasm/001.
Path: Hemplate template input -> build inspection and macro expansion -> generated contract artifact -> unchanged rebuild or actionable compile failure -> portable server target.
Build: Tighten semantic fingerprinting and no-op writes, source-path diagnostics, macro preservation and compile failures, and the target dependency boundary.
Risk: Nondeterministic artifacts cause rebuild churn and ABI drift; leaked host parsers make the promised server target unusable.
Checks: Existing deterministic/no-op build and compile-fail tests; source I/O diagnostic test; wasm32 check plus normal dependency-tree exclusion; `tests/redgate_test.sh`; `redgate check` for the slice IDs.
Non-goals: Changing Hemplate syntax, parser internals, or package publication.
Residual risk: None after focused checks and the repository’s required workspace gates pass.
State: Ready
Blocked by: none
+88 -204
View File
@@ -1,210 +1,94 @@
# hemx # Hemx
hemx is checked hypermedia for Rust: write hemplate templates, write typed Rust Hemx is checked hypermedia for Rust. Applications render Hemplate views, handle
handlers, and return generated UI commands. The browser receives checked UI events in typed Rust functions, and return generated UI effects. The browser runs
commands; ordinary server-first apps do not need a frontend framework, a small effect interpreter instead of a virtual DOM, hydration framework, or
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001 client-side expression language. The same core compiles for native servers and
server-side Wasm isolates; platform storage, sockets, and lifecycle remain
application concerns.
Status: the evidence-backed v1 behavior slices are implemented and browser-proven: ## How it works
server-first/page-enhanced behavior, client-local WASM, durable offline/sync, the
multiplayer Kanban milestone, and the production reference. Local workspace,
browser, performance, documentation, and canonical-example gates pass. The
warning-denied vulnerability and source audits are clean; strict license closure
awaits a repository license allowlist. See `docs/v1-product-evidence.md` for the
product boundary, `REQUIREMENTS.md` for authority, `PLAN.md` for execution state,
and `docs/v1-readiness.md` for evidence. No publishing is implied.
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped 1. `.heml` templates declare page roots, slots, forms, handles, and keyed targets.
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated 2. `hemx-build` generates typed Rust helpers from that surface.
slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`. 3. `#[hemx::handler]` functions accept ordinary Rust inputs and return typed effects.
Editor setup for VS Code, Cursor, and Neovim lives in `docs/editor-support.md`; 4. `hemx-axum` serves pages, assets, handler routes, and effect responses.
VS Code/Cursor share the repo extension in `editors/vscode-hemx`, while all 5. The browser runtime validates the build fingerprint and applies effects within the current root.
editors keep normal HTML/tree-sitter highlighting and layer `hemx-build`
diagnostics on top.
Local checkout note: until the hemplate crates are published, this repository ```rust,ignore
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app #[hemx::handler]
scaffolder fails with that exact path if the prerequisite is missing, instead of async fn add_todo(form: NewTodo) -> impl IntoEffect {
creating an app that fails later with a vague Cargo path-dependency error. ui::todos().append(TodoRow::from(form))
}
## The normal path
For beginner and production-shaped app code, stay on this path. req: public_api/001 req: public_api/005
1. **Templates declare the surface.** `.heml` files declare roots, slots,
forms, handles, keys, page targets, optional pending states, and explicit
leaf islands with `data-hemx-*` attributes. The stable syntax surface lives in
`docs/hemplate-syntax.md`.
2. **Build generates typed helpers.** `hemx_build::app().run()` consumes the
hemplate Surface and emits generated Rust helpers for slots, forms, handles,
page targets, classes, and events. req: ceremony/003 req: build/001
3. **Handlers are plain Rust.** App code uses `#[hemx::handler]` functions with
ordinary domain types, framework extractors, generated `Form<T>` inputs,
explicit `data-*` params, and `impl IntoEffect` or fallible returns. req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
4. **Handlers return generated commands.** Common handlers return helpers such
as `todos.append(todo)`, `todo_row.replace(row)`, `new_todo.error("title",
"Required")`, `new_todo.clear()`, `page.replace(view)`, or tuples of those
commands. req: dx/006 req: dx/007
5. **The runtime only applies effects.** The JavaScript runtime is a small,
root-scoped effect interpreter: no VDOM, no hydration framework, no client
expression engine, no selector retargeting, and no app state store. req: runtime/003 req: invariant/002
## Mental model: render → slot/key → effect → runtime
- **Render:** hemplate renders Rust view structs into checked HTML. App code
normally reaches rendering through generated `ui::page(...)` helpers at a
server page boundary, with explicit `SafeHtml` only for already-rendered
fragments, not raw HTML construction. req: html_safety/002 req: html_safety/004 req: view/001
- **Slot/key:** a generated slot names the target, and a generated keyed slot
also carries the stable row key. A list target inside `h-for` must have a
stable `h-key`, so row updates are addressable without CSS selectors. req: list/001
- **Effect:** handlers return typed commands that become a checked effect
response. Tuple composition is the normal fixed batch syntax; arrays and
`Vec<T: IntoEffect>` cover fixed or dynamic repeated partial updates.
- **Reuse:** the hemx answer to framework components is reusable hemplate
partials plus generated helpers, app-owned state, `IntoEffect` composition,
and explicit leaf islands when browser-owned behavior is necessary. See
`docs/recipes/reusable-partials.md`.
- **Runtime:** the browser checks the build fingerprint, resolves targets within
the current `data-hemx-root`, and applies compatible batches. Mismatched
server/runtime builds fail closed instead of silently mutating the wrong DOM.
req: abi/002 req: check/004 req: failure/005
## Forms and errors
Forms remain HTML forms. hemx checks the generated form contract against a
user-authored Rust form type, so domain newtypes such as `Email`, `TodoId`, and
`Title` parse through ordinary Rust traits rather than generated DTOs. req: form/001
Use validation effects for expected user mistakes, and `Result<impl IntoEffect,
E>` for fallible domain, database, or infrastructure work. Integration crates map
`E` to generated UI effects, redirects, events, or HTTP responses; the canonical
app code still uses the same handler shape for plain and fallible handlers. See
`docs/diagnostics.md` for common compile/build/runtime mistakes and fixes.
req: failure/004 req: derive_handler/004
## Pages, push, CSS, and islands
- Page navigation is a specialized generated page/slot effect around real
anchors and ordinary HTTP routes. hemx does not own routing. req: modes/002 req: axum_integration/002
- Server push streams send checked effect responses over framework-managed
transports such as SSE; auth and connection policy stay in the server
integration. req: wire/004 req: push/002
- Appearance is plain CSS. Generated class tokens can make dynamic classes
checked, but hemx does not introduce a styling runtime. req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006
- Custom JavaScript belongs at explicit opaque leaf boundaries: charts, maps,
editors, Web Components, or similar widgets. Islands communicate through
generated handles/events and do not create a second UI model. req: canonical_authoring/007 req: canonical_authoring/017 req: interop/003
## Production boundary
hemx is not a SaaS platform. Production concerns stay in normal Rust/web crates
and integrate at explicit boundaries. req: laws/002 req: auth/001
- **Persistence:** use SQLx or another storage adapter in your application
state/handlers. hemx should see ordinary domain values and generated UI
commands, not own the database layer. See `docs/recipes/sqlx-persistence.md`.
- **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept
typed auth/session context and return ordinary HTTP failures or generated UI
failures. See `docs/recipes/auth-session-csrf.md`. req: auth/002
- **CSRF:** keep CSRF policy in middleware/extractors with hidden form fields,
cookies, and normal SameSite/browser semantics. hemx preserves submitted form
fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005
- **Observability, feature flags, killswitches, deploy:** use explicit platform
integrations around handlers, routes, runtime assets, and mobile shells. Core
hemx must not vendor providers or add framework-specific magic. See
`docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`,
and `docs/recipes/mobile-release.md`. req: examples/011
- **Mobile starter:** create the phone-first path with `cargo run -p
hemx-xtask -- app new --mobile PATH`. The starter carries a real app flow,
typed host capabilities, command/event/projection recovery truth, and
inspectable mobile release-kit commands without adding a native UI framework.
Use it for Rust-owned hypermedia apps; use explicit native shells/islands for
heavy native UI, games, camera-heavy flows, deep OS integration, or complex
offline sync. req: ceremony/006 req: host/002
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
but core hemx must not gain a mandatory client state graph or local app
runtime. Local truth is commands/events/projections, not stored DOM patches or
stored `EffectBatch` payloads. See `docs/recipes/pwa-offline.md` and
`docs/recipes/local-command-log.md`. req: canonical_authoring/008 req: canonical_authoring/018 req: canonical_authoring/019 req: local/001 req: local/002
- **Host capabilities:** browser, PWA, WebView, and native-shell capabilities use
`hemx-host` manifests/calls/events. Adapters return host facts to app code;
UI still changes through normal hemx effects. See
`docs/recipes/host-capabilities.md`. req: host/001 req: host/002 req: host/005
## Escape hatches
Advanced APIs are named and isolated. Raw effects, low-level ids, manual
registries, raw HTML/render/target construction, runtime hooks, SSE internals,
and island internals are for integration crates, tests, migrations, or explicit
leaf boundaries. They should not appear in beginner examples or ordinary handler
docs. req: public_api/002 req: public_api/005
## Versioning and deploy compatibility
The generated API, symbols, effect wire schema, and JavaScript runtime carry
schema/ABI versions. Deploy a matching server, generated output, and runtime
asset together. Build fingerprints are derived from the generated surface and ABI
parts; the runtime refuses incompatible effect responses and integrations should
fall back to a full page reload when possible. See `docs/recipes/deploy-versioning.md`. req: abi/001 req: abi/002 req: failure/005
Before a v1 release, the semver policy and upgrade notes should explicitly state
which surfaces are stable: beginner generated helpers and handler shapes; the
wire/runtime ABI; and advanced escape hatches that may remain integration-level.
See `docs/versioning.md`.
## Examples
- `examples/v0`: canonical beginner path covering counter, typed todo CRUD,
form wizard, auth action, page swaps, SSE notifications, and keyed list
updates. Create the generic starter with `cargo run -p hemx-xtask -- app new
PATH`; it includes a page, form, keyed row partial, notice slot, handlers,
tests, and generated append/replace/remove/dynamic-batch updates. Start here.
req: ceremony/005
- `examples/html_examples`: copy-paste HTML pattern gallery for htmx-style
CRUD/form/search/load UX patterns. It proves the hemx idiom for click-to-edit,
edit row, delete row, inline validation, click-to-load, and active search with
`.heml`, generated resources, server-owned Rust state, and tiny runtime
behavior. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/001
- `examples/saas`: compile-tested v1 tutorial app covering auth/session,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without provider-heavy platform scope. Read the
walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in
`docs/recipes/sqlx-persistence.md` shows the provider boundary without moving
SQL into core.
- `examples/workout`: phone-first local-first product exemplar. Create a starter
with `cargo run -p hemx-xtask -- workout new PATH`; run the exemplar with
`cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`.
It keeps workout truth as commands/events/projections and routes export
through the host capability boundary; `examples/workout/README.md` documents
the canonical dev, test, production build, mobile release, verification, and
failure-mode paths. req: examples/001 req: examples/006 req: local/001 req: host/005
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may
expose manual registry or render escape hatches while exploring product limits.
- `examples/techdemo`: advanced integration demo with a leaf island and broader
product interactions.
## Local checks
```sh
# Fast compile/regression pass for the crate you touched.
cargo test -p <crate>
# Focused real-browser smoke for the HTML pattern gallery.
cargo run -p hemx-xtask -- html-examples-smoke
# Full local authority check; keep this green before shipping broad slices.
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate health --strict
``` ```
Use `cargo run -p hemx-xtask -- test` for the full local verification path so ```html
jobs stay capped for local CPU and memory. The focused browser tier is <form data-hemx-form="new_todo">
`cargo run -p hemx-xtask -- html-examples-smoke`; it owns dynamic html_examples <input name="title" required>
browser behavior and should complete in about 30 seconds locally. The full tier <button type="submit">Add</button>
should complete within a 10 minute local timeout; if it grows beyond that, split </form>
it into deterministic repo-owned shards that together cover the same behavior, <ul data-hemx-slot="todos"></ul>
with `cargo run -p hemx-xtask -- test` remaining the full authority wrapper. ```
req: test/004 req: test/006 req: test/015 req: test/016
## Design boundary
Hemx owns checked UI effects and their browser runtime. It does not own routing,
databases, authentication, CSS, or application state. Those remain ordinary
Rust and web concerns. Browser-specific behavior belongs in explicit leaf islands
rather than a second application model.
The normal application path is:
- `hemx` for handler and effect APIs;
- `hemx-build` for generated resources;
- `hemx-axum` for Axum integration;
- `hemx-js` for the browser effect runtime.
Server-side Wasm uses the same core crates and canonical effect batches; it does
not require a separate Hemx Wasm runtime package.
## Workspace
| Package | Purpose |
| --- | --- |
| `hemx` | Application-facing facade and handler macro export |
| `hemx-core` | Effect types, protocol values, validation, and runtime primitives |
| `hemx-derive` | Procedural macros |
| `hemx-build` | Build-time template analysis and generated resources |
| `hemx-axum` | Axum routes, responses, assets, and server push |
| `hemx-js` | Browser runtime source |
| `hemx-test` | Test support for applications |
## Agent skill
The optional [`idiomatic-hemx`](https://github.com/tmk241/hemx-skills) skill
helps coding agents apply Hemx's generated-resource and server-owned effect
model:
```console
npx skills@latest add tmk241/hemx-skills --skill idiomatic-hemx
```
## Development
```console
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo deny check licenses sources
```
## Related projects and acknowledgements
[htmx](https://htmx.org/) helped popularize the HTML-over-the-wire,
hypermedia-driven approach that inspired Hemx. Hemx is an independent
implementation and does not bundle htmx.
Hemx builds on [Hemplate](https://github.com/tmk241/hemplate),
[Tokio](https://github.com/tokio-rs/tokio),
[Axum](https://github.com/tokio-rs/axum), and the Rust procedural-macro
ecosystem. Thank you to their maintainers and contributors.
## License
MIT
-1146
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
ID RULE INTENTS
kernel/001 The wire effect algebra must contain only Patch, Insert, Remove, Move, Focus, Scroll, Visit, and Dispatch. protocol/001 scope/001
kernel/002 Patch must offer Morph and Replace; Insert and Move must use first, last, before, or after positions. protocol/001 resource/001
kernel/003 Every DOM effect target must be a generated resource reference, never a CSS selector. resource/001 protocol/001
kernel/004 A batch must validate its envelope before applying effects in order and stopping at the first failure. protocol/001
kernel/005 A later effect must be able to address a resource created by an earlier effect in the same batch. protocol/001 resource/001
kernel/006 Move must preserve the identity and browser-owned state of the moved node. protocol/001 web/001
kernel/007 Rendered HTML must enter an effect only through the explicit SafeHtml type. server/001 web/001
kernel/008 Identical semantic effect batches must produce identical canonical wire bytes. protocol/001
kernel/009 Decoding must reject invalid magic, versions, tags, lengths, UTF-8, fingerprints, and trailing bytes. protocol/001
kernel/010 Compatibility must never guess an opcode, resource, selector, fingerprint, or alternate wire meaning. protocol/001
kernel/011 Canonical effect batches must round-trip between typed values and wire bytes without semantic loss. protocol/001
kernel/012 A compatibility fixture must accept only its declared wire ABI version. protocol/001
kernel/013 Focus must change focus without encoding form validation or other application policy. protocol/001 web/001
kernel/014 Scroll must reveal its resource without changing focus. protocol/001 web/001
kernel/015 Dispatch must carry a generated typed non-visual payload without mutating the DOM. protocol/001 resource/001
kernel/016 Visit must update URL history through partial navigation with ordinary navigation fallback. protocol/001 web/001
resource/001 Generated resource types must expose only operations valid for their capability. resource/001
resource/002 Generated APIs must carry resource, action, fingerprint, opcode, and marker values for application code. resource/001
resource/003 Repeated resources must use generated stable keys for typed Remove and Move operations. resource/001 protocol/001
resource/004 Generated form helpers must address fields through typed resources, not copied selectors or IDs. resource/001 server/001
resource/005 Generated artifacts must change only when their semantic template inputs change. resource/001 protocol/001
resource/006 Missing or invalid generated resources must fail compilation with an actionable source diagnostic. resource/001
html/001 Generated markup must use data-hemx-root, build, resource, key, action, and island markers. resource/001 protocol/001
html/002 Native event defaults must need no attribute; data-hemx-on must only override the native event. web/001 scope/001
html/003 Navigation must use anchors or GET forms and retain ordinary browser fallback without enhancement. web/001 server/001
html/004 On validation failure, rendered forms must identify invalid fields and associate accessible messages. web/001 server/001
html/005 Hemx must preserve native keyboard, IME, autofill, file selection, and constraint-validation behavior. web/001 scope/001
html/006 Request policy must cover only concurrency, debounce, throttle, confirmation, navigation, and history. web/001 scope/001
html/007 Concurrency policy must be one of latest, queue, drop, or parallel. web/001 scope/001
runtime/001 The browser runtime must apply an effect only within the root owning its generated resource. protocol/001 resource/001
runtime/002 Loading the runtime must expose diagnostics without performing startup side effects. protocol/001
runtime/003 Ordinary forms must submit URL-encoded data unless native semantics select another encoding. web/001 server/001
runtime/004 GET forms must produce URL-state navigation while retaining ordinary navigation fallback. web/001 server/001
runtime/005 HTTP, decode, or effect failure must stop the batch and emit one root-scoped diagnostic. protocol/001
runtime/006 SSE and WebSocket adapters must carry unchanged canonical effect-batch bytes. protocol/001 scope/001
runtime/007 Each optional adapter must bind once, scan inserted fragments, clean removals, and enforce root ownership. scope/001 protocol/001
runtime/008 An explicit island must own its subtree; Hemx must not morph through island-owned nodes. scope/001 web/001
axum/001 Axum effect responses must carry canonical batch bytes and the generated build fingerprint. protocol/001 resource/001
axum/002 Axum must serve the generic browser runtime without owning application behavior. server/001 scope/001
axum/003 Partial navigation must preserve status and title metadata and support full-navigation recovery. web/001 server/001
axum/004 Interaction requests must enforce media type and configured body limits before handler dispatch. server/001
axum/005 Typed dispatch responses must preserve status, canonical bytes, and actionable diagnostics. protocol/001 server/001
build/001 A template read failure must report the source path and I/O cause. resource/001
build/002 Identical inspected template inputs must produce identical generated contract fingerprints. resource/001 protocol/001
build/003 A no-op build must not rewrite an unchanged generated contract artifact. resource/001
derive/001 The surface macro must preserve user-authored inline module items while adding generated resources. resource/001
derive/002 The handler macro must reject unknown handles and invalid handler signatures at compilation. resource/001 server/001
derive/003 The component macro must reject a component missing its required handler implementation. resource/001 server/001
derive/004 A reference to an absent generated resource must fail compilation. resource/001
wasm/001 Normal server-side Hemx APIs must compile for wasm32 without host parser dependencies. portable/001
boundary/001 Framework transport behavior must stay in hemx-axum and generic browser behavior in hemx-js. scope/001
boundary/002 Optional transport, timer, and reveal behavior must be direct lifecycle adapters, not a plugin registry. scope/001 protocol/001
boundary/003 The core must have no effects for classes, styles, attributes, scripts, validation, dialogs, or transport. scope/001 web/001
boundary/004 Hemx must not maintain a client application store that mirrors authoritative server state. server/001 scope/001
test/001 Public test utilities must inspect synchronous and asynchronous handlers through one effect model. server/001 protocol/001
test/002 A failed HTML update assertion must report both expected and actual effects. protocol/001
test/003 Public test utilities must inspect complete documents with owned HTML structure. web/001
test/004 With Axum support enabled, public test utilities must inspect effect responses through a real router. protocol/001 server/001
test/005 The public process helper must wait for delayed TCP readiness and capture child output on failure. server/001
1 ID RULE INTENTS
2 kernel/001 The wire effect algebra must contain only Patch, Insert, Remove, Move, Focus, Scroll, Visit, and Dispatch. protocol/001 scope/001
3 kernel/002 Patch must offer Morph and Replace; Insert and Move must use first, last, before, or after positions. protocol/001 resource/001
4 kernel/003 Every DOM effect target must be a generated resource reference, never a CSS selector. resource/001 protocol/001
5 kernel/004 A batch must validate its envelope before applying effects in order and stopping at the first failure. protocol/001
6 kernel/005 A later effect must be able to address a resource created by an earlier effect in the same batch. protocol/001 resource/001
7 kernel/006 Move must preserve the identity and browser-owned state of the moved node. protocol/001 web/001
8 kernel/007 Rendered HTML must enter an effect only through the explicit SafeHtml type. server/001 web/001
9 kernel/008 Identical semantic effect batches must produce identical canonical wire bytes. protocol/001
10 kernel/009 Decoding must reject invalid magic, versions, tags, lengths, UTF-8, fingerprints, and trailing bytes. protocol/001
11 kernel/010 Compatibility must never guess an opcode, resource, selector, fingerprint, or alternate wire meaning. protocol/001
12 kernel/011 Canonical effect batches must round-trip between typed values and wire bytes without semantic loss. protocol/001
13 kernel/012 A compatibility fixture must accept only its declared wire ABI version. protocol/001
14 kernel/013 Focus must change focus without encoding form validation or other application policy. protocol/001 web/001
15 kernel/014 Scroll must reveal its resource without changing focus. protocol/001 web/001
16 kernel/015 Dispatch must carry a generated typed non-visual payload without mutating the DOM. protocol/001 resource/001
17 kernel/016 Visit must update URL history through partial navigation with ordinary navigation fallback. protocol/001 web/001
18 resource/001 Generated resource types must expose only operations valid for their capability. resource/001
19 resource/002 Generated APIs must carry resource, action, fingerprint, opcode, and marker values for application code. resource/001
20 resource/003 Repeated resources must use generated stable keys for typed Remove and Move operations. resource/001 protocol/001
21 resource/004 Generated form helpers must address fields through typed resources, not copied selectors or IDs. resource/001 server/001
22 resource/005 Generated artifacts must change only when their semantic template inputs change. resource/001 protocol/001
23 resource/006 Missing or invalid generated resources must fail compilation with an actionable source diagnostic. resource/001
24 html/001 Generated markup must use data-hemx-root, build, resource, key, action, and island markers. resource/001 protocol/001
25 html/002 Native event defaults must need no attribute; data-hemx-on must only override the native event. web/001 scope/001
26 html/003 Navigation must use anchors or GET forms and retain ordinary browser fallback without enhancement. web/001 server/001
27 html/004 On validation failure, rendered forms must identify invalid fields and associate accessible messages. web/001 server/001
28 html/005 Hemx must preserve native keyboard, IME, autofill, file selection, and constraint-validation behavior. web/001 scope/001
29 html/006 Request policy must cover only concurrency, debounce, throttle, confirmation, navigation, and history. web/001 scope/001
30 html/007 Concurrency policy must be one of latest, queue, drop, or parallel. web/001 scope/001
31 runtime/001 The browser runtime must apply an effect only within the root owning its generated resource. protocol/001 resource/001
32 runtime/002 Loading the runtime must expose diagnostics without performing startup side effects. protocol/001
33 runtime/003 Ordinary forms must submit URL-encoded data unless native semantics select another encoding. web/001 server/001
34 runtime/004 GET forms must produce URL-state navigation while retaining ordinary navigation fallback. web/001 server/001
35 runtime/005 HTTP, decode, or effect failure must stop the batch and emit one root-scoped diagnostic. protocol/001
36 runtime/006 SSE and WebSocket adapters must carry unchanged canonical effect-batch bytes. protocol/001 scope/001
37 runtime/007 Each optional adapter must bind once, scan inserted fragments, clean removals, and enforce root ownership. scope/001 protocol/001
38 runtime/008 An explicit island must own its subtree; Hemx must not morph through island-owned nodes. scope/001 web/001
39 axum/001 Axum effect responses must carry canonical batch bytes and the generated build fingerprint. protocol/001 resource/001
40 axum/002 Axum must serve the generic browser runtime without owning application behavior. server/001 scope/001
41 axum/003 Partial navigation must preserve status and title metadata and support full-navigation recovery. web/001 server/001
42 axum/004 Interaction requests must enforce media type and configured body limits before handler dispatch. server/001
43 axum/005 Typed dispatch responses must preserve status, canonical bytes, and actionable diagnostics. protocol/001 server/001
44 build/001 A template read failure must report the source path and I/O cause. resource/001
45 build/002 Identical inspected template inputs must produce identical generated contract fingerprints. resource/001 protocol/001
46 build/003 A no-op build must not rewrite an unchanged generated contract artifact. resource/001
47 derive/001 The surface macro must preserve user-authored inline module items while adding generated resources. resource/001
48 derive/002 The handler macro must reject unknown handles and invalid handler signatures at compilation. resource/001 server/001
49 derive/003 The component macro must reject a component missing its required handler implementation. resource/001 server/001
50 derive/004 A reference to an absent generated resource must fail compilation. resource/001
51 wasm/001 Normal server-side Hemx APIs must compile for wasm32 without host parser dependencies. portable/001
52 boundary/001 Framework transport behavior must stay in hemx-axum and generic browser behavior in hemx-js. scope/001
53 boundary/002 Optional transport, timer, and reveal behavior must be direct lifecycle adapters, not a plugin registry. scope/001 protocol/001
54 boundary/003 The core must have no effects for classes, styles, attributes, scripts, validation, dialogs, or transport. scope/001 web/001
55 boundary/004 Hemx must not maintain a client application store that mirrors authoritative server state. server/001 scope/001
56 test/001 Public test utilities must inspect synchronous and asynchronous handlers through one effect model. server/001 protocol/001
57 test/002 A failed HTML update assertion must report both expected and actual effects. protocol/001
58 test/003 Public test utilities must inspect complete documents with owned HTML structure. web/001
59 test/004 With Axum support enabled, public test utilities must inspect effect responses through a real router. protocol/001 server/001
60 test/005 The public process helper must wait for delayed TCP readiness and capture child output on failure. server/001
+595
View File
@@ -0,0 +1,595 @@
# Hemx effect and attribute kernel handoff
## Status and authority
This document is design evidence, not product authority and not an implementation plan. Repository-root `INTENT.tsv` and `SPEC.tsv` are the sole authorities. Before changing public behavior, update those canonical TSV records, derive a current `PLAN.md` slice, and add direct Redgate proof.
Do not publish this file, `INTENT.tsv`, `SPEC.tsv`, `PLAN.md`, or requirement-proof scripts to the public mirror.
The current public Hemx 0.3 behavior remains supported until a separately authorized breaking release changes it.
## Product objective
Hemx should cover ordinary server-owned web applications with a very small set of mechanisms:
```text
semantic Hemplate
-> generated typed resources and actions
-> ordinary Rust handler and application state
-> ordered typed effects
-> one canonical wire format
-> root-scoped browser interpreter
```
The target is broad capability, not a feature for every product noun. Forms, validation, toasts, dialogs, tables, navigation, search, pagination, server push, sortable lists, uploads, and similar behavior must compose from the same kernel. High-frequency browser-owned behavior remains an explicit island.
## Design laws
1. The server owns application state, authorization, validation, and recovery.
2. HTML owns document semantics, forms, accessibility, and ordinary navigation.
3. Hemplate owns rendering and escapes interpolation by default.
4. Hemx owns generated identities, ordered effects, canonical bytes, and root-scoped browser application.
5. Generated concrete resource types expose valid operations; invalid combinations do not compile.
6. Application code never copies resource IDs, selectors, opcodes, fingerprints, or generated attributes.
7. Effects address generated resources, never arbitrary CSS selectors.
8. An effect batch is an ordered fail-stop script, not a claimed atomic DOM transaction.
9. Decode and ABI validation happen before application; target validation occurs immediately before each ordered effect so later effects may address content created earlier in the batch.
10. HTML, CSS, URLs, native controls, and browser APIs are reused before adding Hemx protocol.
11. Optional behavior is a small direct adapter with explicit lifecycle, not a universal plugin framework.
12. Public APIs describe current capability, never removed experiments or private repository structure.
## Comparative evidence
The design was compared against local source checkouts using `peek` plus targeted source tracing:
| Reference | Inspected commit | Materialized lesson |
| --- | --- | --- |
| htmx | `ad56dff71e55d9c717447437b4c942a64575d4b2` | server-controlled swap modes, out-of-band updates, response navigation/events, and conditional state-preserving `moveBefore()` |
| Turbo | `f3faa2daf3f9b96c986e7ac3ec4022d7c1eb0dbf` | small ordered stream actions: append, prepend, before, after, replace, update, remove, refresh |
| Phoenix LiveView | `a7dc7b65226ef42ba264c2724a6a66fdf5c113af` | ordered command algebra, focus/transition/navigation commands, but a component-diff model Hemx should not copy |
| Livewire | `4616d99586c4dbcb48602db871d2c0ca1150f463` | response-envelope effects for morph, dispatch, redirect, download, script, and streaming; several are intentionally too permissive for Hemx core |
| Unpoly | `deb75d6ebab1733f67928fb4a7dfe6bb52202e56` | layered fragment changes and overlay policy; useful product patterns but not irreducible protocol primitives |
| Datastar | `cbe24718784dee446ddea3ef5625b7a696f8c044` | very small SSE event vocabulary and explicit patch modes; signal state is not required for Hemx's server-owned model |
The references prove useful mechanisms, not Hemx product authority.
## Local experiments
### Direct Move versus full keyed reconciliation
Artifact: `/tmp/silly-orbit-dragon-ISwOgl/RESULTS.md`
A visible Chromium experiment used 1,000 existing keyed rows. For 100 last-to-first operations per sample:
- direct `Element.moveBefore()` had a median below the 0.1 ms timer resolution and p95 around 0.1 ms;
- complete keyed-order reconciliation had a median around 13.1 ms and p95 around 15.3 ms.
A focused-input probe showed:
- `insertBefore()` retained node identity and value but lost focus and selection;
- `moveBefore()` retained identity, value, focus, and selection.
Decision: retain direct `Move`. Do not lower a known move to full Morph. Prefer `Element.moveBefore()` where supported. Any fallback to `insertBefore()` has a known state-preservation limitation that requires explicit browser coverage.
### Typed capability API
Artifact: `/tmp/hemx-kernel-proof/RESULTS.md`
The dependency-free Rust prototype materialized toast insertion, invalid-form render plus focus, dialog render plus focus, typed autocomplete dispatch, navigation, and direct item movement. Unit tests pass. Separate fixture crates fail to compile for:
- append on a focus-only resource;
- focus on a non-focusable slot;
- wrong payload type for a generated event.
Decision: generated concrete capability types are ergonomic and valuable. Do not expose a public trait lattice. The prototype also showed that combining update and insertion into one generic `RenderMode` obscures target semantics, so retain separate erased `Patch` and `Insert` operations.
### Trigger adapters
Artifact: `/tmp/hemx-trigger-proof/RESULTS.md`
A generic bridge required 58 nonblank lines and the instrumented timer/reveal/confirm adapters another 32. The current direct timer/reveal regions are about 78 lines before surrounding request logic. A new universal bridge therefore does not earn its complexity.
Decision:
- keep native confirmation in the request seam;
- do not create a universal plugin registry or callback bus;
- make polling and revealed behavior optional direct adapters if retained;
- give adapters only explicit `scan(fragment)` and `dispose(fragment)` lifecycle calls from Patch, Insert, Remove, and initial root binding;
- do not add a global `MutationObserver` solely as an extension mechanism.
### Atom evidence
No public example or real application journey depends on `Atom`, `AtomState`, `SetAtom`, or `data-hemx-st`. Their evidence is limited to implementation, isolated tests, generated surface, and testkit documentation. `SetAtom` stores bytes in a browser map and emits an event, creating a second client-state channel without owning rendering.
Decision candidate: remove Atom state from core. Use server-owned state plus Patch, typed Dispatch, or an explicit island.
## Proposed erased effect kernel
The future canonical wire algebra should contain exactly these semantic operations:
```rust
enum Effect {
Patch {
target: ResourceRef,
mode: PatchMode, // Morph | Replace
html: SafeHtml,
},
Insert {
parent: ResourceRef,
position: InsertPosition, // First | Last | Before(item) | After(item)
html: SafeHtml,
},
Remove {
target: ResourceRef,
},
Move {
target: ResourceRef,
position: MovePosition, // First | Last | Before(item) | After(item)
},
Focus {
target: ResourceRef,
prevent_scroll: bool,
},
Scroll {
target: ResourceRef,
block: ScrollBlock,
behavior: ScrollBehavior,
},
Visit {
url: Url,
history: HistoryMode, // Push | Replace
},
Dispatch {
event: EventRef,
payload: Bytes,
},
}
```
### Why each operation remains
| Operation | Irreducible job |
| --- | --- |
| `Patch` | update an existing resource while optionally preserving local DOM identity and browser state |
| `Insert` | create new sibling/list content at a declared position |
| `Remove` | delete one existing resource |
| `Move` | reposition an existing stateful node without re-rendering it |
| `Focus` | control keyboard and accessibility focus |
| `Scroll` | reveal content without changing focus |
| `Visit` | update URL/history through a server-owned partial navigation with ordinary navigation fallback |
| `Dispatch` | carry typed non-visual intent or notification without inventing DOM mutation |
### Normalization from the current algebra
- `Put` becomes `Patch`.
- Current `Insert` and `Prepend` become one `Insert` plus position.
- `Remove { key: Option<_> }` becomes removal of an already resolved typed `Item` resource.
- `Move { key, before }` becomes movement between typed item resources.
- `Navigate` becomes `Visit`; title and scroll are not bundled into it.
- `Emit { name, payload }` becomes generated typed `Dispatch`.
- Internal form commands must not be magic public event names.
Do not add wire effects for classes, styles, arbitrary attributes, validation errors, pending indicators, toasts, dialogs, downloads, clipboard, script evaluation, timers, overlays, or transport selection. Those are HTML, CSS, compositions, direct adapters, or islands.
## Effect batch semantics
```text
decode all bytes
-> validate magic, ABI version, fingerprint, lengths, UTF-8, tags, trailing bytes
-> for each effect in order:
resolve against current root state
validate resource capability and ownership
apply effect
stop on first failure
```
Later effects may address resources produced by earlier effects. Do not preflight every target only against pre-batch DOM state. Do not claim rollback or atomicity the browser cannot provide.
On failure:
- stop applying remaining effects;
- emit one structured root-scoped runtime diagnostic;
- retain enough context for development diagnostics;
- never guess a selector, opcode, resource, or compatibility fallback;
- recover through the next authoritative render or full navigation when compatibility requires it.
## Generated Rust API
Application code should use generated resource capabilities, not construct raw effects.
```rust
ui::profile.render(&view)
ui::toasts.append(&toast)
ui::todo(id).move_before(ui::todo(other))
ui::email.focus()
ui::errors.scroll_into_view()
ui::dialog.remove()
ui::search_changed.emit(&SearchChanged { query })
hemx::visit("/account")
```
Recommended generated concrete types:
```text
Slot<View> -> render, replace, remove
List<View> -> prepend, append
Item<View> -> render, replace, remove, move_before, move_after, move_first, move_last
Field -> focus, scroll_into_view
Form<Input> -> typed extraction and action vocabulary
Event<Payload> -> emit only its generated payload type
```
The generated module may lower these methods to erased wire effects internally. Application-facing APIs should not expose raw resource IDs, numeric opcodes, arbitrary targets, or interchangeable primitive parameters.
## Minimal generated HTML protocol
Generated markers should converge on one uniform vocabulary:
```html
data-hemx-root
data-hemx-build
data-hemx-resource
data-hemx-key
data-hemx-action
data-hemx-island
```
| Marker | Job |
| --- | --- |
| `root` | ownership and effect scope |
| `build` | generated contract fingerprint |
| `resource` | opaque generated resource identity |
| `key` | stable identity for repeated content and keyed movement/morphing |
| `action` | opaque generated handler identity |
| `island` | explicit client-owned preservation boundary |
Slots, lists, items, handles, forms, and fields remain distinct Rust capabilities while sharing one browser resource marker. Authors do not copy generated marker values.
Remove or collapse protocol markers that expose internal resource kinds separately. Never add `data-hemx-target` or selector-based targeting.
## Minimal authorable request policy
Only behavior that genuinely varies per interaction should remain authorable:
```html
data-hemx-on
data-hemx-policy
data-hemx-debounce
data-hemx-throttle
data-hemx-confirm
data-hemx-nav
data-hemx-history
```
Rules:
- native event defaults require no attribute;
- `on` exists only to override the native event;
- `policy` owns concurrency only: `latest`, `queue`, `drop`, or `parallel`;
- `debounce` and `throttle` own timing only;
- `confirm` uses the boring native confirmation gate and the cancellable request seam;
- navigation uses real anchors and GET forms, with partial navigation as progressive enhancement;
- history changes are explicit and retain ordinary browser fallback.
Remove `delay` from core unless a real journey proves it cannot be represented by debounce, CSS, a timer adapter, or an island.
Runtime-global request timeout is initialization configuration, not per-element markup.
## Optional direct adapters
Possible optional adapters:
```html
data-hemx-every
data-hemx-revealed
data-hemx-sse
data-hemx-ws
```
They are not new effect schemas. SSE and WebSocket transport the same canonical `EffectBatch` bytes. Timer and reveal adapters dispatch generated actions through the same request seam.
Each adapter owns exactly:
```text
scan(fragment)
dispose(fragment)
```
Required adapter behavior:
- no duplicate binding;
- cleanup on removal;
- binding for content created by Patch or Insert;
- root ownership enforcement;
- deterministic error reporting;
- focused browser proof.
If an adapter needs global lifecycle orchestration, hidden application state, a second payload schema, or domain policy, it is no longer a leaf and must remain application-owned.
## Input and validation contract
### Native browser layer
Use semantic controls and browser behavior directly:
```html
<input
type="email"
name="email"
required
minlength="3"
autocomplete="email"
>
```
The browser continues to own keyboard input, IME, autofill, password managers, file selection, native constraint validation, and accessibility semantics.
### Authoritative server layer
```text
HTTP/browser input
-> media-type, size, origin, authentication and authorization checks
-> typed parsing
-> domain validation
-> state transition or typed validation view model
-> generated resource effects
```
An invalid form is ordinary rendering plus focus/scroll:
```rust
ui::signup.render(&submitted_form)
.then(ui::email.focus())
.then(ui::error_summary.scroll_into_view())
```
Rendered markup owns field state:
```html
<input
name="email"
value="submitted value"
aria-invalid="true"
aria-describedby="email-error"
>
<p id="email-error">Enter a valid email address.</p>
```
Validation rules:
- retain submitted valid values;
- do not replace file inputs or focused controls unnecessarily;
- focus the first invalid field for small forms;
- focus or scroll an error summary for large forms;
- use `aria-invalid`, `aria-describedby`, labels, and live regions;
- render expected validation errors in context, not as toasts;
- never expose infrastructure error text as validation feedback;
- do not add `SetFieldError`, `SetValidity`, or `FormError` wire effects.
## Pending and transport errors
The core request seam should expose standard browser state:
```html
aria-busy="true"
disabled
```
CSS composes directly:
```css
button[aria-busy="true"] .spinner { display: inline-block }
form[aria-busy="true"] { cursor: progress }
```
Only controls that are safe to disable should be disabled; do not disable fields whose successful values must remain part of form submission.
Transport failure emits one structured root-scoped runtime event. Domain validation remains rendered application content.
Remove framework-specific presentation configuration where standard state is enough:
```text
data-hemx-indicator
data-hemx-pending-class
data-hemx-disable-while-pending
data-hemx-error
data-hemx-error-for
```
## Common product compositions
| Product behavior | Composition |
| --- | --- |
| Toast | typed toast list + `Insert::Last`; later `Remove` or optional timeout adapter |
| Dialog | native `<dialog>` view + `Patch` + `Focus`; close through action and `Remove`/Patch |
| Field validation | typed form Patch + `Focus` + optional `Scroll` |
| Error summary | semantic summary + `Focus`/`Scroll` |
| Autocomplete | debounce action + result-list Patch |
| Pagination | real links/GET forms + progressively enhanced `Visit` |
| Infinite list | optional revealed action + `Insert::Last` |
| Sortable list | generated reorder action + direct `Move` |
| Inline edit | item Patch + field Focus |
| Chat/server push | SSE or WebSocket carrying canonical batches |
| Upload | native multipart form; optional progress adapter or island |
| Tabs | links/buttons plus CSS, URL state, or a small Patch |
| CRUD/table UI | semantic forms, typed views, Patch/Insert/Remove |
| Charts, maps, canvas, rich editor | explicit island receiving typed boundary data/events |
A product noun does not earn a protocol opcode. Toasts and dialogs remain first-class ergonomic generated helpers built from the kernel.
## Island boundary
Use an island only when browser-owned high-frequency state materially improves the interaction: editor internals, canvas, maps, drag motion, media timelines, chart interaction, or similar behavior.
An island owns its local DOM subtree and lifecycle. Hemx may Patch around it, remove it explicitly, or Dispatch typed boundary events. Hemx must not silently morph through island-owned nodes.
Do not use islands for ordinary form validation, CRUD, navigation, toasts, dialogs, pagination, or server push.
## Explicit cuts
Remove from future core unless a confirmed current requirement proves otherwise:
- `Atom`, `AtomState`, `SetAtom`, and `data-hemx-st`;
- separate Insert and Prepend wire tags;
- optional key fields on generic Remove;
- magic public event names for internal form reset/error commands;
- selector targets and server-selected DOM queries;
- class, style, and arbitrary attribute wire operations;
- script evaluation or execute-script effects;
- a client application store mirroring server truth;
- a universal adapter/plugin registry;
- global mutation observation solely for extension discovery;
- transport-specific effect schemas;
- product-specific core effects for toast, dialog, download, clipboard, overlay, timer, or validation.
Keep:
- native HTML, forms, URLs, history, focus, scroll, and accessibility;
- escaped Hemplate rendering and explicit `SafeHtml` trust boundaries;
- typed generated resources and actions;
- deterministic ordered canonical bytes and fingerprints;
- root-scoped application;
- direct state-preserving Move;
- explicit islands;
- transport adapters carrying unchanged canonical bytes.
## Compatibility and release posture
Do not preserve redundant public primitives merely for internal convenience. Do not silently break Hemx 0.3 either. This is a deliberate future breaking-contract candidate.
A migration release should provide semantic source-level replacements where useful, but must not retain two permanent effect or attribute languages. Compatibility helpers may lower old source calls into the new kernel for one documented transition window only if they do not preserve old wire semantics or duplicate runtime branches.
## Required authority reconciliation
Before implementation, revise and confirm at least these current requirement areas:
- `core/001`–`core/010`: closed algebra, resource targeting, safe HTML, ordered wire behavior, compatibility, navigation, generated helpers, Atom removal;
- `build/001`–`build/003`: one generated resource/action vocabulary and capability-specific APIs;
- `derive/001`–`derive/004`: generated surface and compile-fail capability proof;
- `axum/001`–`axum/005`: canonical bytes, fingerprint, partial navigation and diagnostics;
- `runtime/001`–`runtime/007`: new interpreter, request policy, adapters, focus/history/push behavior;
- `test/001`–`test/005`: inspector support for the normalized effects and generated types;
- `wasm/001`: portable server-Wasm boundary remains unchanged.
Do not treat this list as an automatic requirement change. `create-specification` must split and confirm individually falsifiable obligations.
## Recommended delivery order
Keep the work in independently useful breaking-release slices rather than one rewrite.
### Slice 1 — Remove the second client-state channel
- remove Atom resource generation, state bootstrap, `SetAtom`, runtime map, events, tests, docs, and inspector support;
- preserve typed Dispatch and islands as explicit alternatives;
- prove no real public example depends on Atom.
### Slice 2 — Correct state-preserving movement
- retain `Move`;
- apply it with `Element.moveBefore()` where available;
- define and test fallback behavior;
- add real-browser focus, selection, form-value, media/animation, listener, and custom-element lifecycle proof;
- retain keyed Morph for complete desired-tree updates, not known single moves.
### Slice 3 — Normalize the erased wire algebra
- introduce Patch, Insert+position, Remove, Move+position, Focus, Scroll, Visit, Dispatch;
- remove duplicate tags and magic internal events;
- update deterministic encode/decode, compatibility fixtures, malformed-input tests, inspectors, and TypeScript declarations;
- make batch application sequential against current post-effect state.
### Slice 4 — Generate capability-specific APIs and uniform markers
- generate Slot/List/Item/Field/Form/Event concrete types and methods;
- collapse browser resource-kind markers to `data-hemx-resource` plus key;
- preserve root, build, action, and island boundaries;
- add compile-fail proof for invalid capability combinations;
- reject copied IDs and selector targets.
### Slice 5 — Reduce request attributes and standardize UX state
- retain native event defaults, concurrency policy, debounce, throttle, confirm, navigation/history;
- replace custom pending/error presentation attributes with standard `aria-busy`, safe disabled state, semantic server-rendered validation, and one structured transport-error event;
- prove form values, focus, error summaries, password/file controls, and accessibility behavior in a real browser.
### Slice 6 — Extract optional leaf adapters
- polling, revealed, SSE, and WebSocket remain direct optional modules;
- all adapters use scan/dispose and canonical action/effect boundaries;
- no plugin registry or second schema;
- prove dynamic insertion, removal cleanup, no duplicate binding, reconnection, root ownership, and cancellation.
### Slice 7 — Public migration and registry proof
- update public docs to current behavior only;
- provide bounded source migration notes without describing removed private experiments;
- run full package, registry-only native/Axum/Wasm, browser, and hygiene gates;
- publish only after explicit effect-boundary authorization.
## Proof matrix
| Risk | Required proof |
| --- | --- |
| invalid capability combination | compile-fail test against generated API |
| malformed/incompatible wire | canonical fixture, truncation/tag/UTF-8/magic/version/trailing-byte rejection |
| dependent ordered effects | Patch/Insert then Focus/Dispatch/Move to newly created resource in one batch |
| identity-preserving Morph | real browser: node identity, input value, focus, selection, open/details state, island preservation |
| direct Move | real browser using `moveBefore()`, including focus/selection and custom element callbacks |
| fallback Move | explicit browser matrix and documented state limitation or equivalent preservation algorithm |
| validation UX | real Axum route and browser: retained values, field association, summary, focus, announcement |
| request concurrency | latest/queue/drop/parallel tests with cancellation and pending cleanup |
| partial navigation | URL, history back/forward, title, scroll restoration, full-navigation fallback |
| adapter lifecycle | initial and inserted fragments, dispose on removal, no duplicate timers/listeners/observers |
| SSE/WebSocket | same canonical bytes, root ownership, reconnection, incompatibility rejection |
| server-Wasm | actual generated Hemplate/Hemx consumer compiles without parser or Tree-sitter target dependencies |
| public package integrity | fmt, clippy, workspace tests, cargo-deny, archive checks, registry-only consumers, hygiene scans |
## Full mandatory repository checks
```console
redgate list
redgate refs
redgate lint
redgate check
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo deny check licenses sources
cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro
```
The target tree must contain neither `tree-sitter` nor `hemplate-parser`. Publishable packages also require archive-based `cargo package --list` and `cargo package` checks.
## Open decisions requiring explicit confirmation
1. Whether Atom removal is accepted as a breaking product decision.
2. Browser support floor for mandatory `Element.moveBefore()` versus fallback behavior.
3. Final public naming: `Patch`/`Insert`/`Visit`/`Dispatch` and source helper names.
4. Whether `Scroll` remains standalone or a confirmed workload demonstrates that Focus and URL fragments cover all supported cases.
5. Whether polling and revealed adapters ship in `hemx-js` as optional modules or remain application snippets.
6. Exact migration window from Hemx 0.3 source APIs and wire ABI.
These decisions must be resolved in `INTENT.tsv` and `SPEC.tsv`, not by implementation convenience.
## Final recommendation
Adopt the architecture direction, but do not implement it as one rewrite. The strongest immediately supported decisions are:
1. keep direct `Move` and correct it toward state-preserving `moveBefore()`;
2. remove Atom as the unproved second client-state channel after authority confirmation;
3. normalize duplicate insertion operations;
4. generate capability-specific concrete APIs;
5. reduce browser markers to one resource namespace;
6. keep validation in semantic HTML plus typed server rendering;
7. reject a universal plugin framework;
8. use small direct adapters and explicit islands for the remainder.
The result is not a smaller product. It is a smaller machine capable of building a large product surface.
+10 -3
View File
@@ -2,6 +2,13 @@
name = "hemx-axum" name = "hemx-axum"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Axum integration for Hemx applications"
keywords = ["axum", "hypermedia", "html", "web"]
categories = ["web-programming"]
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
@@ -9,10 +16,10 @@ path = "src/lib.rs"
[dependencies] [dependencies]
axum = { version = "0.8", default-features = false, features = ["multipart", "tokio"] } axum = { version = "0.8", default-features = false, features = ["multipart", "tokio"] }
futures-util = { version = "0.3", default-features = false } futures-util = { version = "0.3", default-features = false }
hemx-core = { path = "../hemx-core" } hemx-core = { version = "0.3.0", path = "../hemx-core" }
hemx-js = { path = "../hemx-js" } hemx-js = { version = "0.3.0", path = "../hemx-js" }
[dev-dependencies] [dev-dependencies]
scraper = "0.25" scraper = "0.25"
tokio = { version = "1", features = ["macros", "rt"] } tokio = { version = "1", features = ["macros", "rt", "sync"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-axum
Axum integration for Hemx pages, generated routes, assets, effects, and server push.
## License
MIT
+13 -23
View File
@@ -26,12 +26,10 @@ pub const fn runtime_js() -> RuntimeJs {
RuntimeJs RuntimeJs
} }
// req: axum_integration/005
pub const fn runtime_js_hash() -> &'static str { pub const fn runtime_js_hash() -> &'static str {
hemx_js::RUNTIME_JS_HASH hemx_js::RUNTIME_JS_HASH
} }
// req: axum_integration/005
pub const fn runtime_js_path() -> &'static str { pub const fn runtime_js_path() -> &'static str {
hemx_js::RUNTIME_JS_PATH hemx_js::RUNTIME_JS_PATH
} }
@@ -354,7 +352,6 @@ impl InteractionForm {
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new()) Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
} }
// req: multipart/001, req: multipart/002
pub async fn parse_multipart( pub async fn parse_multipart(
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> { ) -> Result<Self, InteractionFormRejection> {
@@ -471,7 +468,6 @@ pub fn state_interactions<S>(fingerprint: BuildFingerprint, state: S) -> StateHa
where where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
// req: axum_integration/003 req: ceremony/001
interactions(fingerprint).with_state(state) interactions(fingerprint).with_state(state)
} }
@@ -1254,8 +1250,8 @@ where
{ {
type Rejection = InteractionFormRejection; type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> { async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(req, state) InteractionForm::from_request(request, state)
.await .await
.map(|form| Self { form }) .map(|form| Self { form })
} }
@@ -1267,16 +1263,16 @@ where
{ {
type Rejection = InteractionFormRejection; type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> { async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
match interaction_media_type(req.headers())? { match interaction_media_type(request.headers())? {
InteractionMediaType::Multipart => { InteractionMediaType::Multipart => {
let multipart = Multipart::from_request(req, state) let multipart = Multipart::from_request(request, state)
.await .await
.map_err(extractor_rejection)?; .map_err(extractor_rejection)?;
Self::parse_multipart(multipart).await Self::parse_multipart(multipart).await
} }
InteractionMediaType::UrlEncoded => { InteractionMediaType::UrlEncoded => {
let bytes = Bytes::from_request(req, state) let bytes = Bytes::from_request(request, state)
.await .await
.map_err(extractor_rejection)?; .map_err(extractor_rejection)?;
Self::parse_urlencoded(&bytes) Self::parse_urlencoded(&bytes)
@@ -1459,7 +1455,6 @@ pub fn runtime_js_route_path() -> &'static str {
runtime_js_path() runtime_js_path()
} }
// req: push/001, req: push/003, req: push/004
pub fn sse<S, E>(batches: S) -> Sse<impl Stream<Item = Result<Event, E>> + Send> pub fn sse<S, E>(batches: S) -> Sse<impl Stream<Item = Result<Event, E>> + Send>
where where
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static, S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
@@ -1481,14 +1476,14 @@ pub fn encode_sse_batch(batch: &EffectBatch) -> String {
fn base64_url_no_pad(input: &[u8]) -> String { fn base64_url_no_pad(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
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 (chunks, remainder) = input.as_chunks::<3>();
for chunk in &mut chunks { for [a, b, c] in chunks {
out.push(ALPHABET[(chunk[0] >> 2) as usize] as char); out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char); out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char); out.push(ALPHABET[(((b & 0x0f) << 2) + (c >> 6)) as usize] as char);
out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char); out.push(ALPHABET[(c & 0x3f) as usize] as char);
} }
match chunks.remainder() { match remainder {
[a] => { [a] => {
out.push(ALPHABET[(a >> 2) as usize] as char); out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[((a & 0x03) << 4) as usize] as char); out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
@@ -1577,7 +1572,6 @@ mod tests {
#[test] #[test]
fn root_fingerprint_is_added_to_initial_root() { fn root_fingerprint_is_added_to_initial_root() {
// req: test/005
let html = html_with_root_fingerprint( let html = html_with_root_fingerprint(
"<html><body><main data-hemx-root>Docs</main></body></html>".into(), "<html><body><main data-hemx-root>Docs</main></body></html>".into(),
BuildFingerprint(99), BuildFingerprint(99),
@@ -1594,7 +1588,6 @@ mod tests {
#[test] #[test]
fn existing_root_fingerprint_is_preserved() { fn existing_root_fingerprint_is_preserved() {
// req: test/005
let html = html_with_root_fingerprint( let html = html_with_root_fingerprint(
"<main data-hemx-root data-hemx-fp=\"1\">Docs</main>".into(), "<main data-hemx-root data-hemx-fp=\"1\">Docs</main>".into(),
BuildFingerprint(99), BuildFingerprint(99),
@@ -1618,7 +1611,6 @@ mod tests {
html_with_root_fingerprint(html.clone(), BuildFingerprint(99)), html_with_root_fingerprint(html.clone(), BuildFingerprint(99)),
html html
); );
// test req: abi/005
} }
#[test] #[test]
@@ -1639,7 +1631,6 @@ mod tests {
] { ] {
assert_eq!(base64_url_no_pad(input), expected); assert_eq!(base64_url_no_pad(input), expected);
} }
// req: push/008 test
} }
#[test] #[test]
@@ -1677,7 +1668,6 @@ mod tests {
InteractionForm::parse_urlencoded(b"__h=1&bad=%"), InteractionForm::parse_urlencoded(b"__h=1&bad=%"),
Err(InteractionFormRejection::InvalidBody) Err(InteractionFormRejection::InvalidBody)
); );
// test req: form/002 req: failure/003
} }
#[tokio::test] #[tokio::test]
+5 -38
View File
@@ -19,7 +19,7 @@ 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(()); static BOUNDARY_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_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);
@@ -40,10 +40,7 @@ async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
#[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 let _guard = BOUNDARY_TEST_LOCK.lock().await;
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))
@@ -104,9 +101,7 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
#[tokio::test] #[tokio::test]
async fn interaction_boundary_extracts_multipart_fields_and_files() { async fn interaction_boundary_extracts_multipart_fields_and_files() {
let _guard = BOUNDARY_TEST_LOCK let _guard = BOUNDARY_TEST_LOCK.lock().await;
.lock()
.unwrap_or_else(|error| error.into_inner());
let boundary = "hemx-boundary"; let boundary = "hemx-boundary";
let body = concat!( let body = concat!(
"--hemx-boundary\r\n", "--hemx-boundary\r\n",
@@ -136,14 +131,11 @@ async fn interaction_boundary_extracts_multipart_fields_and_files() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
// test req: multipart/001 req: multipart/002 req: multipart/003
} }
#[tokio::test] #[tokio::test]
async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() { async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() {
let _guard = BOUNDARY_TEST_LOCK let _guard = BOUNDARY_TEST_LOCK.lock().await;
.lock()
.unwrap_or_else(|error| error.into_inner());
let app = Router::new().route("/mutate", post(bounded_mutation)); let app = Router::new().route("/mutate", post(bounded_mutation));
let missing_boundary = app let missing_boundary = app
.clone() .clone()
@@ -208,7 +200,6 @@ async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart(
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// test req: multipart/001 req: multipart/003
} }
fn selector(value: &str) -> Selector { fn selector(value: &str) -> Selector {
@@ -266,12 +257,10 @@ fn page_mode_detects_only_explicit_partial_header_values() {
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap()); headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial); 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
assert!(!PageRequest { assert!(!PageRequest {
mode: PageMode::Full mode: PageMode::Full
} }
@@ -321,7 +310,6 @@ fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
#[test] #[test]
fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() { fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
// req: axum_integration/001 req: html_safety/001 req: html_safety/002 req: test/005
let full = PageRequest { let full = PageRequest {
mode: PageMode::Full, mode: PageMode::Full,
} }
@@ -389,7 +377,6 @@ fn page_response_constructors_preserve_mode_and_optional_fingerprint() {
partial.fingerprint(fingerprint).fingerprint, partial.fingerprint(fingerprint).fingerprint,
Some(fingerprint) Some(fingerprint)
); );
// test req: page_swap/001 req: abi/005
} }
#[tokio::test] #[tokio::test]
@@ -410,7 +397,6 @@ async fn full_page_response_preserves_html_and_fingerprint_without_partial_heade
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(), to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
b"<html>Full</html>" b"<html>Full</html>"
); );
// test req: page_swap/001 req: abi/005
} }
#[test] #[test]
@@ -447,7 +433,6 @@ fn interaction_form_parses_handle_and_fields() {
#[test] #[test]
fn interaction_form_parses_typed_values() { fn interaction_form_parses_typed_values() {
// req: form/004 req: dx/003
let form = let form =
InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse"); InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
@@ -470,7 +455,6 @@ fn interaction_form_requires_numeric_handle() {
#[test] #[test]
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() { fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
// req: auth/004
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap(); let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
assert_eq!(form.handle_id, 42); assert_eq!(form.handle_id, 42);
@@ -480,7 +464,6 @@ fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
#[test] #[test]
fn interaction_request_dispatches_with_concise_handlers_helper() { fn interaction_request_dispatches_with_concise_handlers_helper() {
// req: axum_integration/003 req: ceremony/001 req: dx/003
let request = InteractionRequest::from(InteractionForm::for_handle( let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(7), Handle::<()>::new(7),
Vec::new(), Vec::new(),
@@ -498,7 +481,6 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
#[test] #[test]
fn state_interactions_starts_stateful_wiring_without_nested_closures() { fn state_interactions_starts_stateful_wiring_without_nested_closures() {
// req: axum_integration/003 req: ceremony/001 req: dx/003
fn ping(prefix: String) -> impl IntoEffect { fn ping(prefix: String) -> impl IntoEffect {
Slot::<String>::new(3).text(format!("{prefix}: ping")) Slot::<String>::new(3).text(format!("{prefix}: ping"))
} }
@@ -568,7 +550,6 @@ fn interaction_form_accessors_preserve_repeated_values_and_decode_diagnostics()
.fields() .fields()
.iter() .iter()
.any(|pair| pair == &("count".into(), "7".into()))); .any(|pair| pair == &("count".into(), "7".into())));
// test req: form/002 req: form/004 req: multipart/001
} }
#[tokio::test] #[tokio::test]
@@ -607,12 +588,10 @@ async fn interaction_form_rejections_return_stable_status_and_diagnostic() {
message.as_bytes() 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
let request = InteractionRequest::from(InteractionForm::for_handle( let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8), Handle::<()>::new(8),
vec![("project_id".to_owned(), "42".to_owned())], vec![("project_id".to_owned(), "42".to_owned())],
@@ -632,7 +611,6 @@ fn interaction_request_dispatches_typed_form_inputs() {
#[test] #[test]
fn interaction_request_rejects_invalid_typed_form_inputs() { fn interaction_request_rejects_invalid_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
let request = InteractionRequest::from(InteractionForm::for_handle( let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8), Handle::<()>::new(8),
vec![("project_id".to_owned(), "nope".to_owned())], vec![("project_id".to_owned(), "nope".to_owned())],
@@ -654,7 +632,6 @@ fn interaction_request_rejects_invalid_typed_form_inputs() {
#[test] #[test]
fn interaction_request_dispatches_typed_state_handlers() { fn interaction_request_dispatches_typed_state_handlers() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect { fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier) Slot::<String>::new(3).text(input.project_id.0 * multiplier)
} }
@@ -702,7 +679,6 @@ impl IntoHandlerFailure for UiFailure {
#[tokio::test] #[tokio::test]
async fn interaction_request_dispatches_async_typed_state_extractors() { async fn interaction_request_dispatches_async_typed_state_extractors() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
async fn open_project( async fn open_project(
State(multiplier): State<u32>, State(multiplier): State<u32>,
Form(input): Form<OpenProject>, Form(input): Form<OpenProject>,
@@ -729,7 +705,6 @@ async fn interaction_request_dispatches_async_typed_state_extractors() {
#[tokio::test] #[tokio::test]
async fn interaction_request_reports_async_result_handler_errors() { async fn interaction_request_reports_async_result_handler_errors() {
// req: axum_integration/003 req: form/004 req: failure/003
async fn open_project( async fn open_project(
State(_multiplier): State<u32>, State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>, Form(_input): Form<OpenProject>,
@@ -762,7 +737,6 @@ async fn interaction_request_reports_async_result_handler_errors() {
#[tokio::test] #[tokio::test]
async fn interaction_request_maps_async_result_errors_to_effects() { async fn interaction_request_maps_async_result_errors_to_effects() {
// req: axum_integration/003 req: failure/004
async fn open_project( async fn open_project(
State(_multiplier): State<u32>, State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>, Form(_input): Form<OpenProject>,
@@ -792,7 +766,6 @@ async fn interaction_request_maps_async_result_errors_to_effects() {
#[test] #[test]
fn interaction_request_maps_sync_result_errors_to_effects() { fn interaction_request_maps_sync_result_errors_to_effects() {
// req: axum_integration/003 req: failure/004
fn open_project( fn open_project(
State(_multiplier): State<u32>, State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>, Form(_input): Form<OpenProject>,
@@ -821,7 +794,6 @@ fn interaction_request_maps_sync_result_errors_to_effects() {
#[test] #[test]
fn interactions_dispatch_by_checked_handle() { fn interactions_dispatch_by_checked_handle() {
// req: ceremony/004 req: public_api/001
let title = Slot::<String>::new(7); let title = Slot::<String>::new(7);
let create = Handle::<()>::new(42); let create = Handle::<()>::new(42);
let request = InteractionRequest::from(InteractionForm::for_handle( let request = InteractionRequest::from(InteractionForm::for_handle(
@@ -862,7 +834,6 @@ async fn registry_contains_registered_sync_and_async_handles_and_async_falls_bac
sync_response.batch.ops, sync_response.batch.ops,
vec![Slot::<String>::new(1).text("sync")] vec![Slot::<String>::new(1).text("sync")]
); );
// test req: axum_integration/003 req: public_api/001
} }
#[tokio::test] #[tokio::test]
@@ -883,9 +854,8 @@ async fn state_result_registration_paths_preserve_state_form_and_failures() {
typed, typed,
String::from("typed"), String::from("typed"),
|state: State<String>, Form(form): Form<OpenProject>| { |state: State<String>, Form(form): Form<OpenProject>| {
Err::<Effect, _>(HandlerBoom).map_err(|error| { Err::<Effect, _>(HandlerBoom).inspect_err(|_| {
let _ = (state, form); let _ = (state, form);
error
}) })
}, },
) )
@@ -1011,7 +981,6 @@ async fn state_result_registration_paths_preserve_state_form_and_failures() {
response.batch.ops, response.batch.ops,
vec![Slot::<String>::new(1).text("typed-async:9")] vec![Slot::<String>::new(1).text("typed-async:9")]
); );
// test req: axum_integration/003 req: derive_handler/005
} }
#[test] #[test]
@@ -1074,7 +1043,6 @@ async fn effect_and_dispatch_responses_preserve_status_wire_and_diagnostics() {
let response = DispatchRejection::HandlerError(HandlerFailure::Effects(batch)).into_response(); let response = DispatchRejection::HandlerError(HandlerFailure::Effects(batch)).into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE); assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
// test req: axum_integration/003 req: failure/005 req: wire/007
} }
#[tokio::test] #[tokio::test]
@@ -1106,7 +1074,6 @@ async fn runtime_js_response_serves_embedded_runtime() {
); );
} }
// req: axum_integration/005
#[test] #[test]
fn runtime_js_path_is_content_hashed() { fn runtime_js_path_is_content_hashed() {
let path = runtime_js_path(); let path = runtime_js_path();
+9 -3
View File
@@ -2,13 +2,19 @@
name = "hemx-build" name = "hemx-build"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Build-time resource generation for Hemx"
keywords = ["hypermedia", "codegen", "html", "web"]
categories = ["development-tools::build-utils", "web-programming"]
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] } hemplate-core = { version = "0.2.0", features = ["surface"] }
hemx-core = { path = "../hemx-core" } hemx-core = { version = "0.3.0", path = "../hemx-core" }
quote = "1" quote = "1"
serde_json = "1"
syn = { version = "2", features = ["full"] } syn = { version = "2", features = ["full"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-build
Build-time Hemplate analysis and typed resource generation for Hemx applications.
## License
MIT
+10 -315
View File
@@ -212,7 +212,6 @@ impl AppBuilder {
resources.add_surface(&self.template_dir, path, &surface)?; resources.add_surface(&self.template_dir, path, &surface)?;
} }
} else { } else {
// req: surface/008
for (path, surface) in &self.surfaces { for (path, surface) in &self.surfaces {
resources.add_surface(&self.template_dir, path, surface)?; resources.add_surface(&self.template_dir, path, surface)?;
} }
@@ -232,10 +231,6 @@ impl AppBuilder {
resources.generated_rs(self.global_exports).as_bytes(), resources.generated_rs(self.global_exports).as_bytes(),
)?; )?;
write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?; write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?;
write_if_changed(
&out_dir.join("hemx.client.js"),
resources.client_bootstrap()?.as_bytes(),
)?;
Ok(()) Ok(())
} }
} }
@@ -269,8 +264,6 @@ struct Resources {
atoms: BTreeMap<String, Resource>, atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>, classes: BTreeMap<String, ClassToken>,
events: BTreeMap<String, EventToken>, events: BTreeMap<String, EventToken>,
client_handlers: BTreeSet<String>,
client_modules: BTreeSet<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -343,21 +336,6 @@ impl Resources {
} }
} }
if let Some(handler) = static_attr(&node.attrs, "data-hemx-client") {
let Some(handler) = rust_ident(&handler) else {
return Err(invalid_hemx_value(
path,
"data-hemx-client",
&handler,
"expected a Rust handler identifier",
));
};
self.client_handlers.insert(handler);
}
if let Some(module) = static_attr(&node.attrs, "data-hemx-client-module") {
self.client_modules.insert(module);
}
if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") { if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(surface, node.scope) let keyed = is_inside_keyed_for(surface, node.scope)
@@ -547,7 +525,6 @@ impl Resources {
.join(", ") .join(", ")
)); ));
// req: build/001 req: component/003
let component_names: BTreeSet<String> = self.component_names().into_iter().collect(); let component_names: BTreeSet<String> = self.component_names().into_iter().collect();
let root_indent = usize::default(); let root_indent = usize::default();
@@ -644,7 +621,6 @@ impl Resources {
out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n"));
out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{inner}impl<T> SlotTarget<T, ()> {{\n")); out.push_str(&format!("{inner}impl<T> SlotTarget<T, ()> {{\n"));
out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
@@ -656,7 +632,6 @@ impl Resources {
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{ {
let child = &res.ident; let child = &res.ident;
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<T> SlotTarget<T, {child_prefix}{child}::Component> {{\n" "{inner}impl<T> SlotTarget<T, {child_prefix}{child}::Component> {{\n"
)); ));
@@ -685,7 +660,6 @@ impl Resources {
out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n")); out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n"));
out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n")); out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T, ()> {{\n" "{inner}impl<T> KeyedSlotTarget<::std::string::String, T, ()> {{\n"
)); ));
@@ -705,7 +679,6 @@ impl Resources {
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{ {
let child = &res.ident; let child = &res.ident;
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" "{inner}impl<T> KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n"
)); ));
@@ -948,7 +921,6 @@ impl Resources {
"{pad} ::hemx::__private::html_trusted(lower(html))\n" "{pad} ::hemx::__private::html_trusted(lower(html))\n"
)); ));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!( out.push_str(&format!(
"{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n" "{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n"
@@ -962,17 +934,14 @@ impl Resources {
)); ));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!( out.push_str(&format!(
"{pad}#[cfg(not(target_arch = \"wasm32\"))]\n{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n" "{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"
)); ));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n")); out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!("{pad}pub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n")); out.push_str(&format!("{pad}pub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n"));
out.push_str(&format!("{pad} slot.html(render(view))\n")); out.push_str(&format!("{pad} slot.html(render(view))\n"));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!("{pad}pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n"));
out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad}where\n"));
@@ -980,7 +949,6 @@ impl Resources {
out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad}{{\n"));
out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); out.push_str(&format!("{pad} slot.append_html(key, render(view))\n"));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!("{pad}pub fn prepend<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}pub fn prepend<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n"));
out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad}where\n"));
@@ -988,7 +956,6 @@ impl Resources {
out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad}{{\n"));
out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n"));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!("{pad}pub fn replace<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}pub fn replace<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n"));
out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad}where\n"));
@@ -1160,50 +1127,6 @@ fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
components components
} }
fn client_bootstrap(&self) -> io::Result<String> {
if self.client_handlers.is_empty() {
return Ok(String::new());
}
let module = match self.client_modules.len() {
1 => self.client_modules.iter().next().unwrap(),
0 => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers require one data-hemx-client-module on a hemx root",
));
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers must share one data-hemx-client-module per generated application",
));
}
};
let exports = self
.client_handlers
.iter()
.map(|handler| format!("__hemx_client_{handler}"))
.collect::<Vec<_>>();
let mut out = format!(
"import init, {{ {} }} from {};\nawait init();\n",
exports.join(", "),
serde_json::to_string(module).expect("serialize client module")
);
for handler in &self.client_handlers {
out.push_str(&format!(
"window.hemx.registerClientHandler({}, __hemx_client_{});\n",
serde_json::to_string(handler).expect("serialize client handler"),
handler
));
}
let fingerprint = hemx_core::BuildFingerprint::from_parts(&self.fingerprint_parts()).0;
out.push_str(&format!(
"document.querySelectorAll('[data-hemx-root]').forEach((root) => {{ root.setAttribute('data-hemx-build', '{}'); root.setAttribute('data-hemx-client-ready', ''); }});\n",
fingerprint
));
Ok(out)
}
fn syms(&self) -> String { fn syms(&self) -> String {
let mut out = String::from("hemx-syms-v1\n"); let mut out = String::from("hemx-syms-v1\n");
for res in self.slots.values() { for res in self.slots.values() {
@@ -1609,12 +1532,6 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-atom" | "data-hemx-atom"
| "data-hemx-key" | "data-hemx-key"
| "data-hemx-on" | "data-hemx-on"
| "data-hemx-client"
| "data-hemx-client-event"
| "data-hemx-client-fallback"
| "data-hemx-client-module"
| "data-hemx-client-policy"
| "data-hemx-client-state-version"
| "data-hemx-pending-class" | "data-hemx-pending-class"
| "data-hemx-indicator" | "data-hemx-indicator"
| "data-hemx-confirm" | "data-hemx-confirm"
@@ -1659,52 +1576,6 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected `push`, `replace`, or empty for the default push behavior", "expected `push`, `replace`, or empty for the default push behavior",
)); ));
} }
"data-hemx-client" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty client handler name",
));
}
"data-hemx-client-policy" if !matches!(value.trim(), "latest" | "drop") => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected `latest` or `drop`",
));
}
"data-hemx-client-module" if !valid_client_module(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a same-origin module specifier beginning with `/`, `./`, or `../`",
));
}
"data-hemx-client-event" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a runtime-supported event",
));
}
"data-hemx-client-state-version"
if value
.parse::<u32>()
.ok()
.filter(|version| *version > 0)
.is_none() =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a positive client state ABI version",
));
}
"data-hemx-on" if !valid_event_list(value) => { "data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value( return Err(invalid_hemx_value(
path, path,
@@ -1791,13 +1662,6 @@ fn reject_invalid_hemx_attr_placement(
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
)); ));
} }
if has_attr(attrs, "data-hemx-client-module") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") { if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement( return Err(invalid_hemx_placement(
path, path,
@@ -1833,14 +1697,6 @@ fn valid_policy(value: &str) -> bool {
matches!(value.trim(), "latest" | "queue" | "drop" | "parallel") matches!(value.trim(), "latest" | "queue" | "drop" | "parallel")
} }
fn valid_client_module(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& !value.starts_with("//")
&& !value.contains(':')
&& (value.starts_with('/') || value.starts_with("./") || value.starts_with("../"))
}
fn valid_event_list(value: &str) -> bool { fn valid_event_list(value: &str) -> bool {
let mut events = event_tokens(value).peekable(); let mut events = event_tokens(value).peekable();
events.peek().is_some() && events.all(valid_runtime_event) events.peek().is_some() && events.all(valid_runtime_event)
@@ -2215,7 +2071,7 @@ fn rust_ident(name: &str) -> Option<String> {
fn stable_id(kind: &str, symbol: &str) -> u32 { fn stable_id(kind: &str, symbol: &str) -> u32 {
let mut hash = 0x811c9dc5u32; let mut hash = 0x811c9dc5u32;
for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) { for byte in kind.bytes().chain(*b":").chain(symbol.bytes()) {
hash ^= byte as u32; hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193); hash = hash.wrapping_mul(0x01000193);
} }
@@ -2429,7 +2285,6 @@ mod tests {
assert_eq!(error.kind(), io::ErrorKind::NotFound); assert_eq!(error.kind(), io::ErrorKind::NotFound);
} }
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
// test req: diagnostics/004 req: diagnostics/006 req: surface/008
} }
#[test] #[test]
@@ -2470,7 +2325,6 @@ mod tests {
"accepted non-vector type {invalid:?}" "accepted non-vector type {invalid:?}"
); );
} }
// test req: diagnostics/006
} }
#[test] #[test]
@@ -2493,7 +2347,6 @@ mod tests {
.unwrap() .unwrap()
.is_empty()); .is_empty());
let _ = std::fs::remove_dir_all(base); let _ = std::fs::remove_dir_all(base);
// test req: diagnostics/006
} }
#[test] #[test]
@@ -2628,9 +2481,7 @@ mod tests {
) )
.unwrap_err() .unwrap_err()
.to_string(), .to_string(),
format!( "duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`",
"duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`"
),
"{kind} collision must propagate" "{kind} collision must propagate"
); );
} }
@@ -2661,11 +2512,10 @@ mod tests {
io::ErrorKind::InvalidData io::ErrorKind::InvalidData
); );
} }
// test req: codegen/005 req: diagnostics/004
} }
#[test] #[test]
fn generated_contract_fingerprint_and_client_bootstrap_are_deterministic() { fn generated_contract_fingerprint_is_deterministic() {
let resource = |symbol: &str, component: &str, id| Resource { let resource = |symbol: &str, component: &str, id| Resource {
symbol: symbol.into(), symbol: symbol.into(),
ident: symbol.into(), ident: symbol.into(),
@@ -2805,12 +2655,12 @@ mod tests {
let canonical_syms = resources.syms(); let canonical_syms = resources.syms();
assert_eq!( assert_eq!(
stable_id("generated-rs", &canonical_generated), stable_id("generated-rs", &canonical_generated),
4_047_122_428, 262_445_078,
"canonical generated Rust changed" "canonical generated Rust changed"
); );
assert_eq!( assert_eq!(
stable_id("generated-rs-global", &canonical_globals), stable_id("generated-rs-global", &canonical_globals),
1_196_973_467, 3_211_924_671,
"canonical global-export Rust changed" "canonical global-export Rust changed"
); );
assert_eq!( assert_eq!(
@@ -2818,34 +2668,10 @@ mod tests {
984_222_700, 984_222_700,
"canonical symbol manifest changed" "canonical symbol manifest changed"
); );
assert_eq!(Resources::default().client_bootstrap().unwrap(), "");
resources
.client_handlers
.extend(["save".into(), "toggle".into()]);
assert_eq!(
resources.client_bootstrap().unwrap_err().to_string(),
"client-local handlers require one data-hemx-client-module on a hemx root"
);
resources.client_modules.insert("/app.wasm".into());
let bootstrap = resources.client_bootstrap().unwrap();
assert!(bootstrap.starts_with(
"import init, { __hemx_client_save, __hemx_client_toggle } from \"/app.wasm\";\nawait init();\n"
));
assert!(
bootstrap.contains("window.hemx.registerClientHandler(\"save\", __hemx_client_save);")
);
assert!(bootstrap.contains("data-hemx-client-ready"));
resources.client_modules.insert("/other.wasm".into());
assert_eq!(
resources.client_bootstrap().unwrap_err().to_string(),
"client-local handlers must share one data-hemx-client-module per generated application"
);
// test req: build/007 req: abi/003 req: client_local/011
} }
#[test] #[test]
fn app_builder_propagates_output_and_client_contract_errors_without_panicking() { fn app_builder_propagates_output_and_contract_errors_without_panicking() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"hemx-build-error-{}-{}", "hemx-build-error-{}-{}",
std::process::id(), std::process::id(),
@@ -2865,49 +2691,10 @@ mod tests {
assert!(result.unwrap().is_err()); assert!(result.unwrap().is_err());
std::fs::remove_dir_all(out.join("hemx.syms")).unwrap(); std::fs::remove_dir_all(out.join("hemx.syms")).unwrap();
std::fs::create_dir_all(out.join("hemx.client.js")).unwrap();
let result = std::panic::catch_unwind(|| app().out_dir(&out).run());
assert!(result.is_ok(), "client output errors must not panic");
assert!(result.unwrap().is_err());
let templates = root.join("templates"); let templates = root.join("templates");
let invalid_out = root.join("invalid-out"); let invalid_out = root.join("invalid-out");
std::fs::create_dir_all(&templates).unwrap(); std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("client.heml"),
"<button data-hemx-client=\"save\">Save</button>",
)
.unwrap();
let result =
std::panic::catch_unwind(|| app().template_dir(&templates).out_dir(&invalid_out).run());
assert!(result.is_ok(), "invalid client contracts must not panic");
assert_eq!(
result.unwrap().unwrap_err().to_string(),
"client-local handlers require one data-hemx-client-module on a hemx root"
);
let invalid_handler = templates.join("invalid_handler.heml");
std::fs::write(
&invalid_handler,
r#"<main data-hemx-root="app" data-hemx-client-module="/app.js"><button data-hemx-client="save-item">Save</button></main>"#,
)
.unwrap();
std::fs::remove_file(templates.join("client.heml")).unwrap();
let error = app()
.template_dir(&templates)
.out_dir(&invalid_out)
.run()
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert_eq!(
error.to_string(),
format!(
"{}: invalid data-hemx-client value `save-item`; expected a Rust handler identifier",
invalid_handler.display()
)
);
std::fs::remove_file(invalid_handler).unwrap();
std::fs::write( std::fs::write(
templates.join("invalid_param.heml"), templates.join("invalid_param.heml"),
r#"<button data-hemx-handle="save" data-123="value">Save</button>"#, r#"<button data-hemx-handle="save" data-123="value">Save</button>"#,
@@ -2941,7 +2728,6 @@ mod tests {
"invalid hemx slot name `123`; expected a Rust identifier" "invalid hemx slot name `123`; expected a Rust identifier"
); );
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
// test req: build/004 req: client_local/011 req: diagnostics/004
} }
#[test] #[test]
@@ -3087,12 +2873,10 @@ mod tests {
); );
let _ = std::fs::remove_dir_all(base); let _ = std::fs::remove_dir_all(base);
// test req: build/003 req: build/004 req: build/009 req: diagnostics/004
} }
#[test] #[test]
fn no_op_build_preserves_generated_artifact_timestamps() { fn no_op_build_preserves_generated_artifact_timestamps() {
// req: build/009
let base = test_dir("hemx-build-no-op"); let base = test_dir("hemx-build-no-op");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3162,12 +2946,10 @@ mod tests {
assert!(refreshed_symbols.contains("cancel")); assert!(refreshed_symbols.contains("cancel"));
assert!(!refreshed_symbols.contains("save")); assert!(!refreshed_symbols.contains("save"));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
// test req: build/009
} }
#[test] #[test]
fn emits_generated_resources_from_heml() { fn emits_generated_resources_from_heml() {
// req: codegen/003
let base = test_dir("hemx-build-test"); let base = test_dir("hemx-build-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3327,7 +3109,6 @@ mod tests {
"sibling", "space", "tab" "sibling", "space", "tab"
] ]
); );
// test req: style/001
} }
#[test] #[test]
@@ -3432,12 +3213,10 @@ mod tests {
); );
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
// test req: style/001 req: form/001 req: diagnostics/004
} }
#[test] #[test]
fn duplicate_collection_slot_upgrades_to_keyed_target() { fn duplicate_collection_slot_upgrades_to_keyed_target() {
// req: codegen/003 req: list/002
let base = test_dir("hemx-build-keyed-collection-test"); let base = test_dir("hemx-build-keyed-collection-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3462,7 +3241,6 @@ mod tests {
#[test] #[test]
fn app_can_consume_precomputed_surface_facts() { fn app_can_consume_precomputed_surface_facts() {
// req: surface/008 req: build/001
let base = test_dir("hemx-build-precomputed-surface-test"); let base = test_dir("hemx-build-precomputed-surface-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3497,7 +3275,6 @@ mod tests {
#[test] #[test]
fn global_resource_exports_are_explicit_opt_in() { fn global_resource_exports_are_explicit_opt_in() {
// req: component/003
let base = test_dir("hemx-build-no-global-exports-test"); let base = test_dir("hemx-build-no-global-exports-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3556,7 +3333,6 @@ mod tests {
#[test] #[test]
fn emits_form_contract_metadata_from_surface_controls() { fn emits_form_contract_metadata_from_surface_controls() {
// req: codegen/004 req: form/001
let base = test_dir("hemx-build-form-contract-test"); let base = test_dir("hemx-build-form-contract-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3619,7 +3395,7 @@ mod tests {
.count(), .count(),
1, 1,
"conditional controls with one submitted name are one Rust form field" "conditional controls with one submitted name are one Rust form field"
); // req: form/004 test );
assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n")); assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n"));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
@@ -3627,7 +3403,6 @@ mod tests {
#[test] #[test]
fn emits_checked_css_class_tokens_from_templates_and_stylesheets() { fn emits_checked_css_class_tokens_from_templates_and_stylesheets() {
// req: style/001, req: codegen/001
let base = test_dir("hemx-build-classes-test"); let base = test_dir("hemx-build-classes-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -3748,7 +3523,6 @@ fn main() {{
#[test] #[test]
fn diagnostics_for_heml_file_reports_unkeyed_generated_target() { fn diagnostics_for_heml_file_reports_unkeyed_generated_target() {
// req: diagnostics/002 req: diagnostics/004
let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target"); let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target");
std::fs::create_dir_all(&dir).expect("create test dir"); std::fs::create_dir_all(&dir).expect("create test dir");
let template = dir.join("todo.heml"); let template = dir.join("todo.heml");
@@ -3769,7 +3543,6 @@ fn main() {{
#[test] #[test]
fn generated_targets_for_heml_source_uses_surface_facts() { fn generated_targets_for_heml_source_uses_surface_facts() {
// req: diagnostics/004 req: diagnostics/005
let targets = generated_targets_for_heml_source( let targets = generated_targets_for_heml_source(
"inline.heml", "inline.heml",
r#" r#"
@@ -3819,7 +3592,6 @@ fn main() {{
#[test] #[test]
fn template_context_facts_include_self_fields_and_h_for_local_fields() { fn template_context_facts_include_self_fields_and_h_for_local_fields() {
// req: diagnostics/006
let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields"); let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields");
std::fs::create_dir_all(dir.join("src")).expect("create src dir"); std::fs::create_dir_all(dir.join("src")).expect("create src dir");
std::fs::write( std::fs::write(
@@ -3932,7 +3704,6 @@ fn main() {{
#[test] #[test]
fn unkeyed_generated_target_diagnostic_is_structured() { fn unkeyed_generated_target_diagnostic_is_structured() {
// req: diagnostics/002
let diagnostic = unkeyed_generated_target_diagnostic( let diagnostic = unkeyed_generated_target_diagnostic(
Path::new("templates/todo.heml"), Path::new("templates/todo.heml"),
"slot", "slot",
@@ -3957,7 +3728,6 @@ fn main() {{
#[test] #[test]
fn rejects_hemx_resources_inside_unkeyed_for() { fn rejects_hemx_resources_inside_unkeyed_for() {
// req: scope/001 req: diagnostics/002
for (case, template, resource, helper) in [ for (case, template, resource, helper) in [
( (
"slot", "slot",
@@ -4107,7 +3877,6 @@ fn main() {{
#[test] #[test]
fn rejects_selector_style_targeting_attrs() { fn rejects_selector_style_targeting_attrs() {
// req: locality/001 req: locality/002 req: htmx_equivalents/003
for (case, template, attr) in [ for (case, template, attr) in [
( (
"hemx-target", "hemx-target",
@@ -4148,7 +3917,6 @@ fn main() {{
#[test] #[test]
fn rejects_unknown_hemx_authoring_attrs() { fn rejects_unknown_hemx_authoring_attrs() {
// req: convention/009 req: diagnostics/002
let base = test_dir("hemx-build-unknown-hemx-attr-test"); let base = test_dir("hemx-build-unknown-hemx-attr-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -4180,7 +3948,6 @@ fn main() {{
#[test] #[test]
fn rejects_invalid_static_hemx_convention_values() { fn rejects_invalid_static_hemx_convention_values() {
// req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: diagnostics/002
for (case, template, attr, guidance) in [ for (case, template, attr, guidance) in [
( (
"policy", "policy",
@@ -4194,42 +3961,6 @@ fn main() {{
"data-hemx-policy", "data-hemx-policy",
"latest", "latest",
), ),
(
"empty-client",
r#"<button data-hemx-client="">Save</button>"#,
"data-hemx-client",
"non-empty",
),
(
"client-module-scheme",
r#"<main data-hemx-root="app" data-hemx-client-module="https://example.com/app.js"></main>"#,
"data-hemx-client-module",
"same-origin",
),
(
"client-event",
r#"<button data-hemx-client="save" data-hemx-client-event="blur">Save</button>"#,
"data-hemx-client-event",
"runtime-supported",
),
(
"client-version-zero",
r#"<button data-hemx-client="save" data-hemx-client-state-version="0">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-version-invalid",
r#"<button data-hemx-client="save" data-hemx-client-state-version="one">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-policy-invalid",
r#"<button data-hemx-client="save" data-hemx-client-policy="queue">Save</button>"#,
"data-hemx-client-policy",
"latest",
),
( (
"events-mixed", "events-mixed",
r#"<button data-hemx-handle="save" data-hemx-on="click blur">Save</button>"#, r#"<button data-hemx-handle="save" data-hemx-on="click blur">Save</button>"#,
@@ -4367,15 +4098,6 @@ fn main() {{
"data-hemx-history" => { "data-hemx-history" => {
"expected `push`, `replace`, or empty for the default push behavior" "expected `push`, `replace`, or empty for the default push behavior"
} }
"data-hemx-client" => "expected a non-empty client handler name",
"data-hemx-client-policy" => "expected `latest` or `drop`",
"data-hemx-client-module" => {
"expected a same-origin module specifier beginning with `/`, `./`, or `../`"
}
"data-hemx-client-event" => "expected a runtime-supported event",
"data-hemx-client-state-version" => {
"expected a positive client state ABI version"
}
"data-hemx-revealed-ahead" => "expected a non-negative number of viewports", "data-hemx-revealed-ahead" => "expected a non-negative number of viewports",
"data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", "data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
"data-hemx-confirm" => "expected a non-empty confirmation message", "data-hemx-confirm" => "expected a non-empty confirmation message",
@@ -4410,19 +4132,6 @@ fn main() {{
assert!(!valid_policy(invalid), "accepted policy {invalid:?}"); assert!(!valid_policy(invalid), "accepted policy {invalid:?}");
} }
for valid in ["/app.js", " ./app.js ", "../app.js"] {
assert!(valid_client_module(valid), "rejected module {valid:?}");
}
for invalid in [
"",
" ",
"//cdn/app.js",
"https://example.com/app.js",
"app.js",
] {
assert!(!valid_client_module(invalid), "accepted module {invalid:?}");
}
for valid in [ for valid in [
"click", "click",
"submit input change keydown dragstart dragover drop", "submit input change keydown dragstart dragover drop",
@@ -4460,9 +4169,8 @@ fn main() {{
} }
let valid_source = r#" let valid_source = r#"
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events" data-hemx-ws="/room/socket"> <main data-hemx-root="app" data-hemx-sse="/events" data-hemx-ws="/room/socket">
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button> <button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form> <form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
</main> </main>
"#; "#;
@@ -4474,12 +4182,10 @@ fn main() {{
std::fs::write(templates.join("valid.heml"), valid_source).unwrap(); std::fs::write(templates.join("valid.heml"), valid_source).unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap();
let _ = std::fs::remove_dir_all(base); let _ = std::fs::remove_dir_all(base);
// test req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006
} }
#[test] #[test]
fn placement_contracts_fail_closed_with_exact_diagnostics() { fn placement_contracts_fail_closed_with_exact_diagnostics() {
// req: page_swap/001 req: page_swap/007 req: push/006 req: diagnostics/002
for (case, template, attr, expectation) in [ for (case, template, attr, expectation) in [
( (
"nav-button", "nav-button",
@@ -4511,12 +4217,6 @@ fn main() {{
"data-hemx-boost", "data-hemx-boost",
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
), ),
(
"client-module-child",
r#"<section data-hemx-root="app"><div data-hemx-client-module="/app.js"></div></section>"#,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
),
( (
"sse-child", "sse-child",
r#"<section data-hemx-root="feed"><div data-hemx-sse="/events"></div></section>"#, r#"<section data-hemx-root="feed"><div data-hemx-sse="/events"></div></section>"#,
@@ -4561,10 +4261,6 @@ fn main() {{
"boost-container", "boost-container",
r#"<nav data-hemx-boost=""><a href="/docs">Docs</a></nav>"#, r#"<nav data-hemx-boost=""><a href="/docs">Docs</a></nav>"#,
), ),
(
"client-module-root",
r#"<main data-hemx-root="app" data-hemx-client-module="/app.js"></main>"#,
),
( (
"sse-root", "sse-root",
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#, r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
@@ -4587,7 +4283,6 @@ fn main() {{
#[test] #[test]
fn generated_lowering_injects_progressive_form_handle_and_key() { fn generated_lowering_injects_progressive_form_handle_and_key() {
// req: form/002, req: wire/001
let base = test_dir("hemx-build-lowering-test"); let base = test_dir("hemx-build-lowering-test");
let templates = base.join("templates"); let templates = base.join("templates");
let out = base.join("out"); let out = base.join("out");
@@ -4645,7 +4340,7 @@ fn main() {{
let html = todo::lower_html(r#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" h-key="7"></li>"#); let html = todo::lower_html(r#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" h-key="7"></li>"#);
assert!(html.contains(r#"data-hid="#)); assert!(html.contains(r#"data-hid="#));
assert!(html.contains(r#"name="__h""#)); assert!(html.contains(r#"name="__h""#));
assert!(html.contains(r#"data-key="7""#)); // req: list/002 test assert!(html.contains(r#"data-key="7""#));
assert!(!html.contains("h-key")); assert!(!html.contains("h-key"));
}} }}
"###, "###,
+7
View File
@@ -2,6 +2,13 @@
name = "hemx-core" name = "hemx-core"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Core effect and protocol types for Hemx"
keywords = ["hypermedia", "protocol", "web"]
categories = ["web-programming"]
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
# hemx-core
Core effect types, protocol values, validation, and runtime contracts for Hemx.
Most applications should depend on the `hemx` facade instead.
## License
MIT
+5 -4
View File
@@ -1,8 +1,8 @@
//! Repeatable wire-encoding baseline: `cargo bench -p hemx-core --bench wire`. //! Repeatable wire-encoding baseline: `cargo bench -p hemx-core --bench wire`.
use hemx_core::{ use hemx_core::{
BuildFingerprint, Effect, EffectBatch, Payload, ResourceId, ResourceKind, ResourceRef, BuildFingerprint, Effect, EffectBatch, PatchMode, ResourceId, ResourceKind, ResourceRef,
EFFECT_BATCH_ABI_VERSION, SafeHtml, EFFECT_BATCH_ABI_VERSION,
}; };
use std::hint::black_box; use std::hint::black_box;
use std::time::Instant; use std::time::Instant;
@@ -13,9 +13,10 @@ const ROUNDS: usize = 7;
fn representative_batch() -> EffectBatch { fn representative_batch() -> EffectBatch {
let target = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)); let target = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42));
let ops = (0..50) let ops = (0..50)
.map(|index| Effect::Put { .map(|index| Effect::Patch {
target: target.clone(), target: target.clone(),
payload: Payload::Text(format!("item-{index}-{}", "x".repeat(64))), mode: PatchMode::Morph,
html: SafeHtml::trusted(format!("item-{index}-{}", "x".repeat(64))),
}) })
.collect(); .collect();
EffectBatch { EffectBatch {
+275 -315
View File
@@ -69,7 +69,6 @@ impl ResourceId {
} }
/// A generated UI target that can be inspected without exposing raw slots. /// A generated UI target that can be inspected without exposing raw slots.
/// req: dx/006 req: test/001
pub trait GeneratedTarget { pub trait GeneratedTarget {
#[doc(hidden)] #[doc(hidden)]
fn __hemx_resource_id(self) -> ResourceId; fn __hemx_resource_id(self) -> ResourceId;
@@ -104,33 +103,43 @@ impl ResourceRef {
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum NavigateMode { pub enum PatchMode {
Push, Morph,
Replace, Replace,
Redirect,
} }
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum InsertPosition {
First,
Last,
Before(ResourceRef),
After(ResourceRef),
}
pub type MovePosition = InsertPosition;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScrollBlock {
Start,
Center,
End,
Nearest,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScrollBehavior { pub enum ScrollBehavior {
Preserve, Auto,
Top, Instant,
Element(ResourceRef), Smooth,
} }
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub type Url = String;
pub enum Payload { pub type EventRef = String;
Text(String),
Html(String),
}
impl Payload { #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub fn text(value: impl ToString) -> Self { pub enum HistoryMode {
Self::Text(value.to_string()) Push,
} Replace,
pub fn html(value: SafeHtml) -> Self {
Self::Html(value.into_string())
}
} }
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
@@ -179,7 +188,7 @@ impl core::fmt::Display for SafeHtml {
/// A generated, checked CSS class token. /// A generated, checked CSS class token.
/// ///
/// Plain CSS/SCSS owns appearance; hemx only gives Rust a typed reference to /// Plain CSS/SCSS owns appearance; hemx only gives Rust a typed reference to
/// class names discovered from build inputs. req: style/001 /// class names discovered from build inputs.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct CssClass { pub struct CssClass {
name: &'static str, name: &'static str,
@@ -216,7 +225,6 @@ impl core::fmt::Display for CssClass {
} }
/// A generated, checked parameter token. /// A generated, checked parameter token.
/// req: codegen/003
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ParamName { pub struct ParamName {
name: &'static str, name: &'static str,
@@ -245,7 +253,6 @@ impl core::fmt::Display for ParamName {
} }
/// A generated, checked component token. /// A generated, checked component token.
/// req: component/003
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ComponentRef { pub struct ComponentRef {
name: &'static str, name: &'static str,
@@ -274,7 +281,6 @@ impl core::fmt::Display for ComponentRef {
} }
/// A generated, checked event token. /// A generated, checked event token.
/// req: codegen/006
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct EventName { pub struct EventName {
name: &'static str, name: &'static str,
@@ -313,7 +319,6 @@ impl core::fmt::Display for EventName {
} }
/// A small displayable list of generated CSS class tokens for hemplate `+class`. /// A small displayable list of generated CSS class tokens for hemplate `+class`.
/// req: style/003
#[derive(Clone, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct CssClasses { pub struct CssClasses {
names: String, names: String,
@@ -413,41 +418,39 @@ pub enum FormControlKind {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Effect { pub enum Effect {
Put { Patch {
target: ResourceRef, target: ResourceRef,
payload: Payload, mode: PatchMode,
html: SafeHtml,
}, },
Insert { Insert {
target: ResourceRef, target: ResourceRef,
key: String, position: InsertPosition,
payload: Payload, html: SafeHtml,
},
Prepend {
target: ResourceRef,
key: String,
payload: Payload,
}, },
Remove { Remove {
target: ResourceRef, target: ResourceRef,
key: Option<String>,
}, },
Move { Move {
target: ResourceRef, target: ResourceRef,
key: String, position: MovePosition,
before: Option<String>,
}, },
Focus { Focus {
target: ResourceRef, target: ResourceRef,
prevent_scroll: bool,
}, },
Navigate { Scroll {
url: String, target: ResourceRef,
mode: NavigateMode, block: ScrollBlock,
scroll: ScrollBehavior, behavior: ScrollBehavior,
title: Option<String>,
}, },
Emit { Visit {
name: String, url: Url,
payload: String, history: HistoryMode,
},
Dispatch {
event: EventRef,
payload: Vec<u8>,
}, },
} }
@@ -460,12 +463,10 @@ pub struct EffectBatch {
impl EffectBatch { impl EffectBatch {
/// The versioned hemx codec is the sole public `EffectBatch` wire API. /// The versioned hemx codec is the sole public `EffectBatch` wire API.
///
/// ```compile_fail /// ```compile_fail
/// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1)); /// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1));
/// let _ = batch.to_postcard(); /// 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)
@@ -493,6 +494,7 @@ impl EffectBatch {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WireError { pub enum WireError {
BadMagic, BadMagic,
UnsupportedVersion,
Truncated, Truncated,
InvalidUtf8, InvalidUtf8,
UnknownTag, UnknownTag,
@@ -508,35 +510,38 @@ fn batch_wire_len(batch: &EffectBatch) -> usize {
fn effect_wire_len(effect: &Effect) -> usize { fn effect_wire_len(effect: &Effect) -> usize {
1 + match effect { 1 + match effect {
Effect::Put { target, payload } => ref_wire_len(target) + payload_wire_len(payload), Effect::Patch { target, html, .. } => {
ref_wire_len(target) + 1 + str_wire_len(html.as_str())
}
Effect::Insert { Effect::Insert {
target, target,
key, position,
payload, html,
} => ref_wire_len(target) + position_wire_len(position) + str_wire_len(html.as_str()),
Effect::Remove { target } => ref_wire_len(target),
Effect::Move { target, position } => ref_wire_len(target) + position_wire_len(position),
Effect::Focus { target, .. } => ref_wire_len(target) + 1,
Effect::Scroll { target, .. } => ref_wire_len(target) + 2,
Effect::Visit { url, .. } => str_wire_len(url) + 1,
Effect::Dispatch { event, payload } => {
str_wire_len(event.as_str()) + str_wire_len_bytes(payload)
} }
| Effect::Prepend {
target,
key,
payload,
} => ref_wire_len(target) + str_wire_len(key) + payload_wire_len(payload),
Effect::Remove { target, key } => {
ref_wire_len(target) + option_str_wire_len(key.as_deref())
}
Effect::Move {
target,
key,
before,
} => ref_wire_len(target) + str_wire_len(key) + option_str_wire_len(before.as_deref()),
Effect::Focus { target } => ref_wire_len(target),
Effect::Navigate {
url, scroll, title, ..
} => {
str_wire_len(url) + 1 + scroll_wire_len(scroll) + option_str_wire_len(title.as_deref())
}
Effect::Emit { name, payload } => str_wire_len(name) + str_wire_len(payload),
} }
} }
fn position_wire_len(position: &InsertPosition) -> usize {
1 + match position {
InsertPosition::First | InsertPosition::Last => 0,
InsertPosition::Before(reference) | InsertPosition::After(reference) => {
ref_wire_len(reference)
}
}
}
fn str_wire_len_bytes(value: &[u8]) -> usize {
4 + value.len()
}
fn ref_wire_len(reference: &ResourceRef) -> usize { fn ref_wire_len(reference: &ResourceRef) -> usize {
1 + 4 1 + 4
+ 1 + 1
@@ -546,23 +551,6 @@ fn ref_wire_len(reference: &ResourceRef) -> usize {
} }
} }
fn payload_wire_len(payload: &Payload) -> usize {
1 + match payload {
Payload::Text(value) | Payload::Html(value) => str_wire_len(value),
}
}
fn scroll_wire_len(scroll: &ScrollBehavior) -> usize {
1 + match scroll {
ScrollBehavior::Preserve | ScrollBehavior::Top => 0,
ScrollBehavior::Element(target) => ref_wire_len(target),
}
}
fn option_str_wire_len(value: Option<&str>) -> usize {
1 + value.map_or(0, str_wire_len)
}
const fn str_wire_len(value: &str) -> usize { const fn str_wire_len(value: &str) -> usize {
4 + value.len() 4 + value.len()
} }
@@ -579,73 +567,100 @@ fn write_batch(batch: &EffectBatch, out: &mut Vec<u8>) {
fn write_effect(effect: &Effect, out: &mut Vec<u8>) { fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
match effect { match effect {
Effect::Put { target, payload } => { Effect::Patch { target, mode, html } => {
write_u8(0, out); write_u8(0, out);
write_ref(target, out); write_ref(target, out);
write_payload(payload, out);
}
Effect::Insert {
target,
key,
payload,
} => {
write_u8(1, out);
write_ref(target, out);
write_str(key, out);
write_payload(payload, out);
}
Effect::Prepend {
target,
key,
payload,
} => {
write_u8(2, out);
write_ref(target, out);
write_str(key, out);
write_payload(payload, out);
}
Effect::Remove { target, key } => {
write_u8(3, out);
write_ref(target, out);
write_option_str(key.as_deref(), out);
}
Effect::Move {
target,
key,
before,
} => {
write_u8(4, out);
write_ref(target, out);
write_str(key, out);
write_option_str(before.as_deref(), out);
}
Effect::Focus { target } => {
write_u8(5, out);
write_ref(target, out);
}
Effect::Navigate {
url,
mode,
scroll,
title,
} => {
write_u8(6, out);
write_str(url, out);
write_u8( write_u8(
match mode { match mode {
NavigateMode::Push => 0, PatchMode::Morph => 0,
NavigateMode::Replace => 1, PatchMode::Replace => 1,
NavigateMode::Redirect => 2,
}, },
out, out,
); );
write_scroll(scroll, out); write_str(html.as_str(), out);
write_option_str(title.as_deref(), out);
} }
Effect::Emit { name, payload } => { Effect::Insert {
target,
position,
html,
} => {
write_u8(1, out);
write_ref(target, out);
write_position(position, out);
write_str(html.as_str(), out);
}
Effect::Remove { target } => {
write_u8(2, out);
write_ref(target, out);
}
Effect::Move { target, position } => {
write_u8(3, out);
write_ref(target, out);
write_position(position, out);
}
Effect::Focus {
target,
prevent_scroll,
} => {
write_u8(4, out);
write_ref(target, out);
write_u8(u8::from(*prevent_scroll), out);
}
Effect::Scroll {
target,
block,
behavior,
} => {
write_u8(5, out);
write_ref(target, out);
write_u8(
match block {
ScrollBlock::Start => 0,
ScrollBlock::Center => 1,
ScrollBlock::End => 2,
ScrollBlock::Nearest => 3,
},
out,
);
write_u8(
match behavior {
ScrollBehavior::Auto => 0,
ScrollBehavior::Instant => 1,
ScrollBehavior::Smooth => 2,
},
out,
);
}
Effect::Visit { url, history } => {
write_u8(6, out);
write_str(url, out);
write_u8(
match history {
HistoryMode::Push => 0,
HistoryMode::Replace => 1,
},
out,
);
}
Effect::Dispatch { event, payload } => {
write_u8(7, out); write_u8(7, out);
write_str(name, out); write_str(event.as_str(), out);
write_str(payload, out); write_bytes(payload, out);
}
}
}
fn write_position(position: &InsertPosition, out: &mut Vec<u8>) {
match position {
InsertPosition::First => write_u8(0, out),
InsertPosition::Last => write_u8(1, out),
InsertPosition::Before(reference) => {
write_u8(2, out);
write_ref(reference, out);
}
InsertPosition::After(reference) => {
write_u8(3, out);
write_ref(reference, out);
} }
} }
} }
@@ -674,38 +689,9 @@ fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
} }
} }
fn write_payload(payload: &Payload, out: &mut Vec<u8>) { fn write_bytes(value: &[u8], out: &mut Vec<u8>) {
match payload { write_u32(value.len() as u32, out);
Payload::Text(value) => { out.extend_from_slice(value);
write_u8(0, out);
write_str(value, out);
}
Payload::Html(value) => {
write_u8(1, out);
write_str(value, out);
}
}
}
fn write_scroll(scroll: &ScrollBehavior, out: &mut Vec<u8>) {
match scroll {
ScrollBehavior::Preserve => write_u8(0, out),
ScrollBehavior::Top => write_u8(1, out),
ScrollBehavior::Element(target) => {
write_u8(2, out);
write_ref(target, out);
}
}
}
fn write_option_str(value: Option<&str>, out: &mut Vec<u8>) {
match value {
None => write_u8(0, out),
Some(value) => {
write_u8(1, out);
write_str(value, out);
}
}
} }
fn write_str(value: &str, out: &mut Vec<u8>) { fn write_str(value: &str, out: &mut Vec<u8>) {
@@ -768,6 +754,11 @@ impl<'a> WireReader<'a> {
.map_err(|_| WireError::InvalidUtf8) .map_err(|_| WireError::InvalidUtf8)
} }
fn read_bytes(&mut self) -> Result<Vec<u8>, WireError> {
let len = self.read_u32()? as usize;
Ok(self.read_exact(len)?.to_vec())
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> { fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
let remaining = &self.bytes[self.offset..]; let remaining = &self.bytes[self.offset..];
let bytes = remaining.get(..len).ok_or(WireError::Truncated)?; let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
@@ -782,6 +773,9 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
return Err(WireError::BadMagic); return Err(WireError::BadMagic);
} }
let abi_version = reader.read_u32()?; let abi_version = reader.read_u32()?;
if abi_version != EFFECT_BATCH_ABI_VERSION {
return Err(WireError::UnsupportedVersion);
}
let fingerprint = BuildFingerprint(reader.read_u64()?); let fingerprint = BuildFingerprint(reader.read_u64()?);
let ops_len = reader.read_u32()?; let ops_len = reader.read_u32()?;
let mut ops = Vec::new(); let mut ops = Vec::new();
@@ -798,47 +792,73 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> { fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
match reader.read_u8()? { match reader.read_u8()? {
0 => Ok(Effect::Put { 0 => Ok(Effect::Patch {
target: read_ref(reader)?, target: read_ref(reader)?,
payload: read_payload(reader)?, mode: match reader.read_u8()? {
0 => PatchMode::Morph,
1 => PatchMode::Replace,
_ => return Err(WireError::UnknownTag),
},
html: SafeHtml::trusted(reader.read_str()?),
}), }),
1 => Ok(Effect::Insert { 1 => Ok(Effect::Insert {
target: read_ref(reader)?, target: read_ref(reader)?,
key: reader.read_str()?, position: read_position(reader)?,
payload: read_payload(reader)?, html: SafeHtml::trusted(reader.read_str()?),
}), }),
2 => Ok(Effect::Prepend { 2 => Ok(Effect::Remove {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
3 => Ok(Effect::Remove {
target: read_ref(reader)?,
key: read_option_str(reader)?,
}),
4 => Ok(Effect::Move {
target: read_ref(reader)?,
key: reader.read_str()?,
before: read_option_str(reader)?,
}),
5 => Ok(Effect::Focus {
target: read_ref(reader)?, target: read_ref(reader)?,
}), }),
6 => Ok(Effect::Navigate { 3 => Ok(Effect::Move {
url: reader.read_str()?, target: read_ref(reader)?,
mode: match reader.read_u8()? { position: read_position(reader)?,
0 => NavigateMode::Push, }),
1 => NavigateMode::Replace, 4 => Ok(Effect::Focus {
2 => NavigateMode::Redirect, target: read_ref(reader)?,
prevent_scroll: match reader.read_u8()? {
0 => false,
1 => true,
_ => return Err(WireError::UnknownTag), _ => return Err(WireError::UnknownTag),
}, },
scroll: read_scroll(reader)?,
title: read_option_str(reader)?,
}), }),
7 => Ok(Effect::Emit { 5 => Ok(Effect::Scroll {
name: reader.read_str()?, target: read_ref(reader)?,
payload: reader.read_str()?, block: match reader.read_u8()? {
0 => ScrollBlock::Start,
1 => ScrollBlock::Center,
2 => ScrollBlock::End,
3 => ScrollBlock::Nearest,
_ => return Err(WireError::UnknownTag),
},
behavior: match reader.read_u8()? {
0 => ScrollBehavior::Auto,
1 => ScrollBehavior::Instant,
2 => ScrollBehavior::Smooth,
_ => return Err(WireError::UnknownTag),
},
}), }),
6 => Ok(Effect::Visit {
url: reader.read_str()?,
history: match reader.read_u8()? {
0 => HistoryMode::Push,
1 => HistoryMode::Replace,
_ => return Err(WireError::UnknownTag),
},
}),
7 => Ok(Effect::Dispatch {
event: reader.read_str()?,
payload: reader.read_bytes()?,
}),
_ => Err(WireError::UnknownTag),
}
}
fn read_position(reader: &mut WireReader<'_>) -> Result<InsertPosition, WireError> {
match reader.read_u8()? {
0 => Ok(InsertPosition::First),
1 => Ok(InsertPosition::Last),
2 => Ok(InsertPosition::Before(read_ref(reader)?)),
3 => Ok(InsertPosition::After(read_ref(reader)?)),
_ => Err(WireError::UnknownTag), _ => Err(WireError::UnknownTag),
} }
} }
@@ -861,31 +881,6 @@ fn read_ref(reader: &mut WireReader<'_>) -> Result<ResourceRef, WireError> {
Ok(ResourceRef { resource, scope }) Ok(ResourceRef { resource, scope })
} }
fn read_payload(reader: &mut WireReader<'_>) -> Result<Payload, WireError> {
match reader.read_u8()? {
0 => Ok(Payload::Text(reader.read_str()?)),
1 => Ok(Payload::Html(reader.read_str()?)),
_ => Err(WireError::UnknownTag),
}
}
fn read_scroll(reader: &mut WireReader<'_>) -> Result<ScrollBehavior, WireError> {
match reader.read_u8()? {
0 => Ok(ScrollBehavior::Preserve),
1 => Ok(ScrollBehavior::Top),
2 => Ok(ScrollBehavior::Element(read_ref(reader)?)),
_ => Err(WireError::UnknownTag),
}
}
fn read_option_str(reader: &mut WireReader<'_>) -> Result<Option<String>, WireError> {
match reader.read_u8()? {
0 => Ok(None),
1 => Ok(Some(reader.read_str()?)),
_ => Err(WireError::UnknownTag),
}
}
pub trait IntoEffect { pub trait IntoEffect {
fn append_to(self, ops: &mut Vec<Effect>); fn append_to(self, ops: &mut Vec<Effect>);
@@ -995,16 +990,14 @@ impl<T> Slot<T> {
} }
pub fn text(self, value: impl ToString) -> Effect { pub fn text(self, value: impl ToString) -> Effect {
Effect::Put { self.html(SafeHtml::trusted(value.to_string()))
target: ResourceRef::unscoped(self.id),
payload: Payload::text(value),
}
} }
pub fn html(self, value: impl Into<SafeHtml>) -> Effect { pub fn html(self, value: impl Into<SafeHtml>) -> Effect {
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(self.id), target: ResourceRef::unscoped(self.id),
payload: Payload::html(value.into()), mode: PatchMode::Morph,
html: value.into(),
} }
} }
} }
@@ -1049,9 +1042,9 @@ where
T: ToString, T: ToString,
{ {
Effect::Insert { Effect::Insert {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: InsertPosition::Last,
payload: Payload::text(value), html: SafeHtml::trusted(value.to_string()),
} }
} }
@@ -1059,10 +1052,10 @@ where
where where
T: ToString, T: ToString,
{ {
Effect::Prepend { Effect::Insert {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: InsertPosition::First,
payload: Payload::text(value), html: SafeHtml::trusted(value.to_string()),
} }
} }
@@ -1071,62 +1064,64 @@ where
T: ToString, T: ToString,
{ {
let key = key.to_string(); let key = key.to_string();
Effect::Put { Effect::Patch {
target: ResourceRef { target: ResourceRef {
resource: self.id, resource: self.id,
scope: Some(ScopeKey::KeyValue(key)), scope: Some(ScopeKey::KeyValue(key)),
}, },
payload: Payload::text(value), mode: PatchMode::Morph,
html: SafeHtml::trusted(value.to_string()),
} }
} }
pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect { pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Insert { Effect::Insert {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: InsertPosition::Last,
payload: Payload::html(value.into()), html: value.into(),
} }
} }
pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect { pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Prepend { Effect::Insert {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: InsertPosition::First,
payload: Payload::html(value.into()), html: value.into(),
} }
} }
pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect { pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
let key = key.to_string(); let key = key.to_string();
Effect::Put { Effect::Patch {
target: ResourceRef { target: ResourceRef {
resource: self.id, resource: self.id,
scope: Some(ScopeKey::KeyValue(key)), scope: Some(ScopeKey::KeyValue(key)),
}, },
payload: Payload::html(value.into()), mode: PatchMode::Morph,
html: value.into(),
} }
} }
pub fn remove(self, key: K) -> Effect { pub fn remove(self, key: K) -> Effect {
Effect::Remove { Effect::Remove {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: Some(key.to_string()),
} }
} }
pub fn move_before(self, key: K, before: K) -> Effect { pub fn move_before(self, key: K, before: K) -> Effect {
Effect::Move { Effect::Move {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: MovePosition::Before(ResourceRef::scoped(
before: Some(before.to_string()), self.id,
ScopeKey::KeyValue(before.to_string()),
)),
} }
} }
pub fn move_to_end(self, key: K) -> Effect { pub fn move_to_end(self, key: K) -> Effect {
Effect::Move { Effect::Move {
target: ResourceRef::unscoped(self.id), target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
key: key.to_string(), position: MovePosition::Last,
before: None,
} }
} }
} }
@@ -1164,9 +1159,10 @@ impl<T> Atom<T> {
} }
pub fn set(self, value: impl ToString) -> Effect { pub fn set(self, value: impl ToString) -> Effect {
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(self.id), target: ResourceRef::unscoped(self.id),
payload: Payload::text(value), mode: PatchMode::Morph,
html: SafeHtml::trusted(value.to_string()),
} }
} }
} }
@@ -1297,48 +1293,18 @@ impl<T> Form<T> {
ResourceRef::scoped(self.id, ScopeKey::Field(name.into())) ResourceRef::scoped(self.id, ScopeKey::Field(name.into()))
} }
pub fn reset(self) -> Effect {
Effect::Emit {
name: String::from("hemx:form-reset"),
payload: self.id.id.to_string(),
}
}
pub fn clear(self) -> Effect {
self.reset()
}
pub fn clear_field(self, field: impl Into<String>) -> Effect { pub fn clear_field(self, field: impl Into<String>) -> Effect {
Effect::Put { Effect::Patch {
target: self.field(field), target: self.field(field),
payload: Payload::text(""), mode: PatchMode::Morph,
} html: SafeHtml::trusted(""),
}
pub fn error(self, field: impl Into<String>, message: impl ToString) -> Effect {
let field = field.into();
let message = message.to_string();
let mut payload = self.id.id.to_string();
payload.push('\u{1f}');
payload.push_str(&field);
payload.push('\u{1f}');
payload.push_str(&message);
Effect::Emit {
name: String::from("hemx:form-error"),
payload,
} }
} }
pub fn focus(self, field: impl Into<String>) -> Effect { pub fn focus(self, field: impl Into<String>) -> Effect {
Effect::Focus { Effect::Focus {
target: self.field(field), target: self.field(field),
} prevent_scroll: false,
}
pub fn disable_while_pending(self) -> Effect {
Effect::Emit {
name: String::from("hemx:form-disable-while-pending"),
payload: self.id.id.to_string(),
} }
} }
} }
@@ -1348,35 +1314,29 @@ pub fn navigate(url: impl Into<String>) -> Effect {
} }
pub fn push(url: impl Into<String>) -> Effect { pub fn push(url: impl Into<String>) -> Effect {
Effect::Navigate { Effect::Visit {
url: url.into(), url: url.into(),
mode: NavigateMode::Push, history: HistoryMode::Push,
scroll: ScrollBehavior::Top,
title: None,
} }
} }
pub fn replace(url: impl Into<String>) -> Effect { pub fn replace(url: impl Into<String>) -> Effect {
Effect::Navigate { Effect::Visit {
url: url.into(), url: url.into(),
mode: NavigateMode::Replace, history: HistoryMode::Replace,
scroll: ScrollBehavior::Top,
title: None,
} }
} }
pub fn redirect(url: impl Into<String>) -> Effect { pub fn redirect(url: impl Into<String>) -> Effect {
Effect::Navigate { Effect::Visit {
url: url.into(), url: url.into(),
mode: NavigateMode::Redirect, history: HistoryMode::Replace,
scroll: ScrollBehavior::Top,
title: None,
} }
} }
pub fn event(name: impl Into<String>, payload: impl Into<String>) -> Effect { pub fn event(name: impl Into<String>, payload: impl Into<String>) -> Effect {
Effect::Emit { Effect::Dispatch {
name: name.into(), event: name.into(),
payload: payload.into(), payload: payload.into().into_bytes(),
} }
} }
+172 -490
View File
@@ -1,543 +1,225 @@
use hemx_core::{ use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, navigate, redirect, replace, BuildFingerprint, Effect, EffectBatch, EventName, Form,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue, HistoryMode, InsertPosition, KeyedSlot, MovePosition, ParamName, PatchMode, ResourceId,
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceKind, ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, ScrollBlock, Slot, WireError,
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError, EFFECT_BATCH_ABI_VERSION,
}; };
#[test] fn reference(id: u32, scope: Option<ScopeKey>) -> ResourceRef {
fn effect_batch_wire_round_trips() { ResourceRef {
let count = Slot::<u32>::new(1); resource: ResourceId::new(ResourceKind::Slot, id),
let todos = KeyedSlot::<u64, String>::new(2); scope,
let user = Atom::<String>::new(3); }
}
let batch = ( fn batch(ops: Vec<Effect>) -> EffectBatch {
count.text(2), EffectBatch {
todos.append_text(7, String::from("Buy milk")), abi_version: EFFECT_BATCH_ABI_VERSION,
todos.replace_text(7, String::from("Buy oat milk")), fingerprint: BuildFingerprint(7),
user.set("Ada"), ops,
navigate("/todos"), }
event("toast", "Saved"), }
)
.into_batch(BuildFingerprint(42));
let bytes = batch.to_wire(); fn every_effect() -> Vec<Effect> {
assert_eq!(&bytes[..4], b"HEMX"); let target = reference(1, Some(ScopeKey::KeyValue(String::from("row"))));
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007 let sibling = reference(2, None);
let decoded = EffectBatch::from_wire(&bytes).unwrap(); vec![
Effect::Patch {
assert_eq!(decoded, batch); target: target.clone(),
assert!(decoded.is_compatible()); mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>safe</p>"),
},
Effect::Patch {
target: sibling.clone(),
mode: PatchMode::Replace,
html: SafeHtml::trusted("<p>replace</p>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::First,
html: SafeHtml::trusted("<li>first</li>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::Last,
html: SafeHtml::trusted("<li>last</li>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::Before(sibling.clone()),
html: SafeHtml::trusted("<li>before</li>"),
},
Effect::Insert {
target: sibling.clone(),
position: InsertPosition::After(target.clone()),
html: SafeHtml::trusted("<li>after</li>"),
},
Effect::Remove {
target: target.clone(),
},
Effect::Move {
target: target.clone(),
position: MovePosition::Last,
},
Effect::Focus {
target: target.clone(),
prevent_scroll: true,
},
Effect::Scroll {
target: target.clone(),
block: ScrollBlock::Center,
behavior: ScrollBehavior::Smooth,
},
Effect::Visit {
url: String::from("/docs"),
history: HistoryMode::Replace,
},
Effect::Dispatch {
event: String::from("app:updated"),
payload: vec![0, 1, 255],
},
]
} }
#[test] #[test]
fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() { fn effect_batch_wire_round_trips() {
// test req: abi/001 req: abi/003 req: v1_release/007 let original = batch(every_effect());
const V1_EMPTY_BATCH: &[u8] = &[ let bytes = original.to_wire();
72, 69, 77, 88, // HEMX assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), original);
1, 0, // ABI v1 assert_eq!(bytes.len(), original.encoded_len());
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] #[test]
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() { fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201)); let original = batch(every_effect());
let atom = ResourceRef::scoped( assert_eq!(original.ops.len(), 12);
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
ScopeKey::KeyValue(String::from("row")),
);
let handle = ResourceRef::scoped(
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
ScopeKey::Field(String::from("email")),
);
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
ops: vec![
Effect::Put {
target: slot.clone(),
payload: Payload::Text(String::from("text")),
},
Effect::Put {
target: handle.clone(),
payload: Payload::Html(String::from("<p>safe</p>")),
},
Effect::Insert {
target: atom.clone(),
key: String::from("insert"),
payload: Payload::Text(String::from("one")),
},
Effect::Prepend {
target: form.clone(),
key: String::from("prepend"),
payload: Payload::Html(String::from("two")),
},
Effect::Remove {
target: slot.clone(),
key: None,
},
Effect::Remove {
target: atom,
key: Some(String::from("remove")),
},
Effect::Move {
target: handle.clone(),
key: String::from("move"),
before: Some(String::from("before")),
},
Effect::Focus { target: form },
Effect::Navigate {
url: String::from("/push"),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
},
Effect::Navigate {
url: String::from("/replace"),
mode: NavigateMode::Replace,
scroll: ScrollBehavior::Top,
title: Some(String::from("Replace")),
},
Effect::Navigate {
url: String::from("/redirect"),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Element(handle),
title: Some(String::from("Redirect")),
},
Effect::Emit {
name: String::from("notice"),
payload: String::from("saved"),
},
],
};
let bytes = batch.to_wire(); let bytes = original.to_wire();
assert_eq!(&bytes[..4], b"HEMX"); for length in 0..bytes.len() {
assert_eq!(batch.encoded_len(), bytes.len());
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
for end in 0..bytes.len() {
assert_eq!( assert_eq!(
EffectBatch::from_wire(&bytes[..end]), EffectBatch::from_wire(&bytes[..length]),
Err(WireError::Truncated), Err(WireError::Truncated)
"prefix ending at byte {end} must fail closed"
); );
} }
// req: wire/007 test req: wire/008 test req: wire/009 test
} }
#[test] #[test]
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() { fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4; let original = batch(vec![Effect::Visit {
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN; url: String::from("/same"),
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1; history: HistoryMode::Push,
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4; }]);
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1; let bytes = original.to_wire();
assert_eq!(EffectBatch::from_wire(&bytes).unwrap().to_wire(), bytes);
let put = EffectBatch { let mut bad_magic = bytes.clone();
abi_version: 1, bad_magic[0] ^= 0xff;
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)); assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4; let mut bad_tag = bytes.clone();
let mut invalid_utf8 = put.clone(); bad_tag[20] = 0xff;
invalid_utf8[PUT_TEXT_START] = 0xff; assert_eq!(EffectBatch::from_wire(&bad_tag), Err(WireError::UnknownTag));
let mut bad_utf8 = bytes.clone();
let slash = bad_utf8.iter().position(|byte| *byte == b'/').unwrap();
bad_utf8[slash] = 0xff;
assert_eq!( assert_eq!(
EffectBatch::from_wire(&invalid_utf8), EffectBatch::from_wire(&bad_utf8),
Err(WireError::InvalidUtf8) Err(WireError::InvalidUtf8)
); );
let mut trailing = put; let mut trailing = bytes;
trailing.push(0); trailing.push(0);
assert_eq!( assert_eq!(
EffectBatch::from_wire(&trailing), EffectBatch::from_wire(&trailing),
Err(WireError::TrailingBytes) Err(WireError::TrailingBytes)
); );
// req: wire/008 test req: wire/009 test
} }
#[test] #[test]
fn optional_effects_compose_into_batches() { fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
// req: component/005 req: public_api/003 let compatible = batch(Vec::new());
let count = Slot::<u32>::new(1); assert!(compatible.is_compatible());
let batch = (Some(count.text(2)), Option::<Effect>::None).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 1); let mut incompatible = compatible.to_wire();
assert_eq!(batch.ops[0], count.text(2)); incompatible[4..8].copy_from_slice(&(EFFECT_BATCH_ABI_VERSION + 1).to_le_bytes());
} assert_eq!(
EffectBatch::from_wire(&incompatible),
#[test] Err(WireError::UnsupportedVersion)
fn effect_collections_compose_into_batches() { );
// req: canonical_authoring/003
let rows = KeyedSlot::<u64, String>::new(2);
let summary = Slot::<u32>::new(3);
let notice = Slot::<String>::new(4);
let dynamic_rows = [7, 8]
.into_iter()
.map(|id| rows.replace_text(id, format!("todo {id}")))
.collect::<Vec<_>>();
let fixed_notices = [notice.text("Saved"), notice.text("Synced")];
let batch =
(dynamic_rows, Some(summary.text(2)), fixed_notices).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 5);
assert_eq!(batch.ops[0], rows.replace_text(7, String::from("todo 7")));
assert_eq!(batch.ops[1], rows.replace_text(8, String::from("todo 8")));
assert_eq!(batch.ops[2], summary.text(2));
assert_eq!(batch.ops[3], notice.text("Saved"));
assert_eq!(batch.ops[4], notice.text("Synced"));
}
#[test]
fn keyed_slot_replace_uses_scoped_resource_ref() {
let todos = KeyedSlot::<u64, String>::new(9);
// req: codegen/002
let effect = todos.replace_text(12, String::from("done"));
let Effect::Put { target, payload } = effect else {
panic!("expected Put");
};
assert_eq!(target.resource.kind, ResourceKind::Slot);
assert_eq!(target.resource.id, 9);
assert_eq!(target.scope, Some(ScopeKey::KeyValue(String::from("12"))));
assert_eq!(payload, Payload::Text(String::from("done")));
}
#[test]
fn tuple_composition_supports_arity_twelve() {
let slot = Slot::<u8>::new(1);
let batch = (
slot.text(1),
slot.text(2),
slot.text(3),
slot.text(4),
slot.text(5),
slot.text(6),
slot.text(7),
slot.text(8),
slot.text(9),
slot.text(10),
slot.text(11),
slot.text(12),
)
.into_batch(BuildFingerprint(1));
assert_eq!(batch.ops.len(), 12);
}
#[test]
fn generated_form_helpers_target_form_fields() {
let signup = Form::<()>::new(4);
let Effect::Emit { name, payload } = signup.error("email", "Use your work email") else {
panic!("expected Emit");
};
assert_eq!(name, "hemx:form-error");
assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email");
let Effect::Focus { target } = signup.focus("email") else {
panic!("expected Focus");
};
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
} }
#[test] #[test]
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() { fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
let rows = KeyedSlot::<u64, String>::new(9); let rows: KeyedSlot<String, String> = KeyedSlot::new(3);
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9)); let append = rows.append_html("a".into(), SafeHtml::trusted("<li>a</li>"));
assert_eq!( assert!(matches!(
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")), append,
Effect::Put { Effect::Insert {
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())), target: ResourceRef {
payload: Payload::Html("<li>done</li>".into()), scope: Some(ScopeKey::KeyValue(key)),
} ..
); },
assert_eq!( position: InsertPosition::Last,
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 } if key == "a"
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] assert!(matches!(
fn css_class_accumulation_preserves_existing_classes() { navigate("/push"),
const A: CssClass = CssClass::new("a"); Effect::Visit {
const B: CssClass = CssClass::new("b"); history: HistoryMode::Push,
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 assert!(matches!(
replace("/replace"),
Effect::Visit {
history: HistoryMode::Replace,
..
}
));
assert!(matches!(
redirect("/redirect"),
Effect::Visit {
history: HistoryMode::Replace,
..
}
));
} }
#[test] #[test]
fn slot_html_requires_explicit_safe_html() { fn slot_html_requires_explicit_safe_html() {
let content = Slot::<String>::new(10); let slot: Slot<String> = Slot::new(4);
let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("<strong>ok</strong>")) else { let effect = slot.html(SafeHtml::trusted("<strong>done</strong>"));
panic!("expected Put"); assert!(matches!(
}; effect,
Effect::Patch {
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>"))); mode: PatchMode::Morph,
html,
..
} if html.as_str() == "<strong>done</strong>"
));
} }
#[test] #[test]
fn safe_html_joins_only_explicit_safe_fragments() { fn generated_form_helpers_target_form_fields() {
// req: html_safety/001 req: html_safety/002 let form: Form<()> = Form::new(5);
let html = SafeHtml::join([ let effect = form.focus("email");
SafeHtml::trusted("<main>"), assert!(matches!(
SafeHtml::trusted("<strong>ok</strong>"), effect,
SafeHtml::trusted("</main>"), Effect::Focus {
]); target: ResourceRef {
resource: ResourceId { kind: ResourceKind::Form, id: 5 },
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>"); scope: Some(ScopeKey::Field(field)),
assert_eq!(html.as_ref(), "<main><strong>ok</strong></main>"); },
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>"); prevent_scroll: false,
} if field == "email"
));
} }
#[test] #[test]
fn param_names_format_generated_param_names() { fn public_tokens_preserve_values() {
// req: codegen/003 assert_eq!(EventName::new("event").as_str(), "event");
let param = ParamName::new("todo_id"); assert_eq!(ParamName::new("param").as_str(), "param");
assert_eq!(param.as_str(), "todo_id");
assert_eq!(param.as_ref(), "todo_id");
assert_eq!(param.to_string(), "todo_id");
}
#[test]
fn component_refs_format_generated_component_names() {
// req: component/003
let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.as_ref(), "todo_list");
assert_eq!(component.to_string(), "todo_list");
}
#[test]
fn generated_resources_format_for_hemplate_dynamic_attrs() {
// req: public_api/001 req: public_api/002
assert_eq!(Slot::<String>::new(10).to_string(), "10");
assert_eq!(KeyedSlot::<u64, String>::new(11).to_string(), "11");
assert_eq!(Atom::<String>::new(12).to_string(), "12");
assert_eq!(Handle::<()>::new(42).to_string(), "42");
assert_eq!(Form::<()>::new(13).to_string(), "13");
}
#[test]
fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
// req: style/003
const CARD: CssClass = CssClass::new("work-card");
const SELECTED: CssClass = CssClass::new("is-selected");
let classes = CssClasses::from([CARD, SELECTED]);
assert_eq!(classes.as_str(), "work-card is-selected");
assert_eq!(classes.to_string(), "work-card is-selected");
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
assert_eq!(
CARD.with_if(true, SELECTED).as_str(),
"work-card is-selected"
);
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
}
#[test]
fn atom_state_bootstrap_is_postcard_round_trippable() {
let state = AtomState {
atoms: vec![AtomSnapshot {
id: 7,
bytes: vec![1, 2, 3],
}],
};
let bytes = state.to_postcard().unwrap();
assert_eq!(AtomState::from_postcard(&bytes).unwrap(), state);
}
#[test]
fn navigation_helpers_choose_explicit_modes() {
let Effect::Navigate { mode, .. } = navigate("/docs") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Push);
let Effect::Navigate { mode, .. } = replace("/docs") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Replace);
let Effect::Navigate { mode, .. } = redirect("/login") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Redirect);
}
#[test]
fn build_fingerprint_is_deterministic_from_abi_parts() {
let a = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
let b = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
let c = BuildFingerprint::from_parts(&[1, 2, 3, 5]);
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(
BuildFingerprint::from_parts(&[]),
BuildFingerprint(0xcbf29ce484222325)
);
assert_eq!(a, BuildFingerprint(13725386680924731485));
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
// test req: abi/001 req: abi/002 req: abi/003
}
#[test]
fn public_token_and_form_error_adapters_preserve_values() {
const NOTICE: EventName = EventName::new("notice");
assert_eq!(NOTICE.as_str(), "notice");
assert_eq!(NOTICE.as_ref(), "notice");
assert_eq!(NOTICE.to_string(), "notice");
assert_eq!(String::from(NOTICE), "notice");
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
const ACTIVE: CssClass = CssClass::new("active");
assert_eq!(ACTIVE.as_str(), "active");
assert_eq!(ACTIVE.as_ref(), "active");
assert_eq!(ACTIVE.to_string(), "active");
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
assert_eq!(classes.as_ref(), "active selected");
let error = FormError::new("invalid email");
assert_eq!(error.message(), "invalid email");
assert_eq!(error.to_string(), "invalid email");
assert_eq!(u32::parse_form_value("42"), Ok(42));
assert_eq!(
u32::parse_form_value("nope"),
Err("invalid form value".into())
);
assert_eq!(
Form::<()>::new(7).reset(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: "7".into(),
}
);
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
} }
+7
View File
@@ -2,6 +2,13 @@
name = "hemx-derive" name = "hemx-derive"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Procedural macros for Hemx"
keywords = ["hypermedia", "derive", "proc-macro"]
categories = ["development-tools::procedural-macro-helpers", "web-programming"]
[lib] [lib]
proc-macro = true proc-macro = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-derive
Procedural macros used by Hemx applications and the `hemx` facade.
## License
MIT
+25 -214
View File
@@ -12,10 +12,14 @@ 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 placement = match handler_placement(&placement) { if !placement.is_empty() {
Ok(placement) => placement, return syn::Error::new(
Err(error) => return error.into_compile_error().into(), proc_macro2::Span::call_site(),
}; "unsupported hemx handler placement; expected #[hemx::handler]",
)
.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();
@@ -72,70 +76,7 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
.into(); .into();
} }
expand_handler_function(function, placement).into() quote!(#function).into()
}
fn expand_handler_function(
function: ItemFn,
placement: HandlerPlacement,
) -> proc_macro2::TokenStream {
if placement == HandlerPlacement::Server {
return quote!(#function);
}
let has_inputs = match client_handler_has_inputs(&function) {
Ok(has_inputs) => has_inputs,
Err(error) => {
let message = error.to_string();
return quote!(
#function
compile_error!(#message);
);
}
};
let function_name = &function.sig.ident;
let export_name = format_ident!("__hemx_client_{function_name}");
let export_module = format_ident!("__hemx_client_export_{function_name}");
let invoke_handler = if has_inputs {
quote!(super::#function_name(event, state))
} else {
quote!(super::#function_name())
};
quote!(
#function
#[cfg(target_arch = "wasm32")]
mod #export_module {
use ::hemx::wasm as wasm_bindgen;
#[::hemx::wasm::wasm_bindgen(js_name = #export_name)]
#[allow(clippy::too_many_arguments)]
pub fn invoke(
event_version: u32,
event_kind: ::std::string::String,
event_value: ::std::option::Option<::std::string::String>,
event_checked: ::std::option::Option<bool>,
event_key: ::std::option::Option<::std::string::String>,
state_version: u32,
encoded_state: ::std::string::String,
) -> ::std::result::Result<::std::vec::Vec<u8>, ::hemx::wasm::JsValue> {
let (event, state) = ::hemx::wasm::decode_client_inputs(
event_version,
event_kind,
event_value,
event_checked,
event_key,
state_version,
encoded_state,
)
.map_err(|error| ::hemx::wasm::JsValue::from_str(&error))?;
Ok(::hemx::wasm::encode_handler_effect(
#invoke_handler,
crate::ui::BUILD_FINGERPRINT,
))
}
}
)
} }
#[proc_macro_attribute] #[proc_macro_attribute]
@@ -266,9 +207,7 @@ fn surface_include() -> String {
} else { } else {
format!( format!(
" compile_error!({:?}); ", " compile_error!({:?}); ",
format!( "#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation"
"#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation"
)
) )
} }
} }
@@ -337,43 +276,6 @@ fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
generics 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),
@@ -1013,92 +915,19 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs, add_app_registry_helper, add_component_register_helper, component_contract_errors,
component_contract_errors, component_handler_names, component_registration_call, component_handler_names, component_registration_call, form_contract_errors,
expand_handler_function, form_contract_errors, form_decode_fields, form_fields, form_decode_fields, form_fields, form_impl_generics, form_model_type, form_resource_id,
form_impl_generics, form_model_type, form_resource_id, generic_inner_type, handle_params, generic_inner_type, handle_params, handle_requires_form, handler_arg_names,
handle_requires_form, handler_arg_names, handler_form_model_type, handler_placement, handler_form_model_type, handler_syms_path, has_form_param, has_non_unit_return,
handler_syms_path, has_form_param, has_non_unit_return, is_type_named, is_type_named, join_contract_errors, missing_component_handlers,
join_contract_errors, missing_component_handlers, missing_form_generated_files_message, missing_form_generated_files_message, missing_handle_params,
missing_handle_params, missing_handler_generated_files_message, parser_type, missing_handler_generated_files_message, parser_type, returns_result, rust_ident,
returns_result, rust_ident, symbol_component, syms_components, syms_contains_handle, symbol_component, syms_components, syms_contains_handle, syms_handles, ComponentHandler,
syms_handles, ComponentHandler, HandlerPlacement,
}; };
use quote::{quote, ToTokens}; 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] #[test]
fn generated_file_and_form_helpers_preserve_exact_contracts() { fn generated_file_and_form_helpers_preserve_exact_contracts() {
assert_eq!( assert_eq!(
@@ -1140,7 +969,6 @@ mod tests {
.to_string(); .to_string();
assert!(where_clause.contains("String : :: hemx :: FormValue")); assert!(where_clause.contains("String : :: hemx :: FormValue"));
assert!(where_clause.contains("T : :: hemx :: FormValue")); assert!(where_clause.contains("T : :: hemx :: FormValue"));
// test req: derive_handler/001 req: form/004
} }
#[test] #[test]
@@ -1179,7 +1007,6 @@ mod tests {
assert!(is_type_named(&qualified, "String")); assert!(is_type_named(&qualified, "String"));
assert!(!is_type_named(&string, "Vec")); assert!(!is_type_named(&string, "Vec"));
assert!(!is_type_named(&reference, "String")); assert!(!is_type_named(&reference, "String"));
// test req: form/004 req: diagnostics/003
} }
#[test] #[test]
@@ -1209,18 +1036,12 @@ mod tests {
unimplemented!() unimplemented!()
} }
); );
assert_eq!( assert!(quote!(#function)
quote!(#function) .to_string()
.to_string() .contains("last : Form < crate :: Last >"));
.contains("last : Form < crate :: Last >"), assert!(quote!(#function)
true .to_string()
); .contains("first : hemx :: Form < crate :: First >"));
assert_eq!(
quote!(#function)
.to_string()
.contains("first : hemx :: Form < crate :: First >"),
true
);
assert_eq!( assert_eq!(
handler_form_model_type(&function) handler_form_model_type(&function)
.map(|ty| quote!(#ty).to_string()) .map(|ty| quote!(#ty).to_string())
@@ -1239,7 +1060,6 @@ mod tests {
); );
assert!(!returns_result(&no_result.sig.output)); assert!(!returns_result(&no_result.sig.output));
assert!(!returns_result(&no_return.sig.output)); assert!(!returns_result(&no_return.sig.output));
// test req: derive_handler/001 req: derive_handler/005
} }
#[test] #[test]
@@ -1336,7 +1156,6 @@ mod tests {
}); });
assert!(generic_inner_type(&empty_path, "Vec").is_none()); assert!(generic_inner_type(&empty_path, "Vec").is_none());
std::fs::remove_file(path).unwrap(); std::fs::remove_file(path).unwrap();
// test req: form/004 req: diagnostics/003
} }
#[test] #[test]
@@ -1378,7 +1197,6 @@ mod tests {
#[test] #[test]
fn handler_shape_accepts_form_or_effect_return() { fn handler_shape_accepts_form_or_effect_return() {
// req: derive_handler/001
let with_form = parse_quote!( let with_form = parse_quote!(
fn save(form: hemx::Form<String>) {} fn save(form: hemx::Form<String>) {}
); );
@@ -1399,7 +1217,6 @@ mod tests {
#[test] #[test]
fn form_parser_type_uses_option_and_vec_inner_types() { fn form_parser_type_uses_option_and_vec_inner_types() {
// req: form/004
let required: Type = parse_quote!(Email); let required: Type = parse_quote!(Email);
let optional: Type = parse_quote!(Option<Email>); let optional: Type = parse_quote!(Option<Email>);
let multiple: Type = parse_quote!(Vec<Email>); let multiple: Type = parse_quote!(Vec<Email>);
@@ -1424,7 +1241,6 @@ mod tests {
#[test] #[test]
fn form_param_matches_form_type_not_name_suffix() { fn form_param_matches_form_type_not_name_suffix() {
// req: form/004 req: form/006
let qualified_form: Type = parse_quote!(hemx::Form<CreateTodo>); let qualified_form: Type = parse_quote!(hemx::Form<CreateTodo>);
let imported_form: Type = parse_quote!(Form<CreateTodo>); let imported_form: Type = parse_quote!(Form<CreateTodo>);
let name_suffix_impostor: Type = parse_quote!(CreateTodoForm); let name_suffix_impostor: Type = parse_quote!(CreateTodoForm);
@@ -1445,7 +1261,6 @@ mod tests {
for invalid in ["", "2fast", "with space", "naïve"] { for invalid in ["", "2fast", "with space", "naïve"] {
assert_eq!(rust_ident(invalid), None, "{invalid:?} must fail closed"); assert_eq!(rust_ident(invalid), None, "{invalid:?} must fail closed");
} }
// test req: form/004 req: diagnostics/003
} }
#[test] #[test]
@@ -1528,7 +1343,6 @@ mod tests {
"args={typed_arg_count} async={is_async} result={returns_result}" "args={typed_arg_count} async={is_async} result={returns_result}"
); );
} }
// test req: component/003 req: derive_handler/003
} }
#[test] #[test]
@@ -1571,7 +1385,6 @@ mod tests {
#[test] #[test]
fn app_macro_generates_single_registry_entry_point() { fn app_macro_generates_single_registry_entry_point() {
// req: derive_app/001 req: component/003
let function = parse_quote! { let function = parse_quote! {
fn registry(state: std::sync::Arc<App>) -> hemx_axum::HandlerRegistry { fn registry(state: std::sync::Arc<App>) -> hemx_axum::HandlerRegistry {
hemx_axum::interactions(ui::BUILD_FINGERPRINT) hemx_axum::interactions(ui::BUILD_FINGERPRINT)
@@ -1667,12 +1480,10 @@ mod tests {
.len(), .len(),
2 2
); );
// test req: component/003 req: derive_handler/003
} }
#[test] #[test]
fn component_macro_generates_registration_helpers() { fn component_macro_generates_registration_helpers() {
// req: component/003 req: derive_handler/003
let module = parse_quote! { let module = parse_quote! {
mod handlers { mod handlers {
const COMPONENT_KIND: &str = "todos"; const COMPONENT_KIND: &str = "todos";
+39 -20
View File
@@ -3,7 +3,6 @@ use std::process::Command;
#[test] #[test]
fn component_macro_reports_missing_handler_implementation() { fn component_macro_reports_missing_handler_implementation() {
// req: component/005 req: check/002 req: test/003
let fixture = Fixture::new("hemx-derive-component-missing-handler-fail"); let fixture = Fixture::new("hemx-derive-component-missing-handler-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -58,7 +57,6 @@ mod todos {
#[test] #[test]
fn component_macro_can_validate_one_generated_component() { fn component_macro_can_validate_one_generated_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("hemx-derive-component-scoped-pass"); let fixture = Fixture::new("hemx-derive-component-scoped-pass");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -112,7 +110,6 @@ mod todos {
#[test] #[test]
fn component_macro_rejects_handlers_outside_scoped_component() { fn component_macro_rejects_handlers_outside_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("hemx-derive-component-extra-handler-fail"); let fixture = Fixture::new("hemx-derive-component-extra-handler-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -172,7 +169,6 @@ mod handlers {
#[test] #[test]
fn component_macro_rejects_unknown_scoped_component() { fn component_macro_rejects_unknown_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("hemx-derive-component-unknown-scope-fail"); let fixture = Fixture::new("hemx-derive-component-unknown-scope-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -231,7 +227,6 @@ mod handlers {
#[test] #[test]
fn component_macro_rejects_ambiguous_generated_handles() { fn component_macro_rejects_ambiguous_generated_handles() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("hemx-derive-component-ambiguous-handle-fail"); let fixture = Fixture::new("hemx-derive-component-ambiguous-handle-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -286,7 +281,6 @@ mod handlers {
#[test] #[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() { fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("hemx-derive-handler-fail"); let fixture = Fixture::new("hemx-derive-handler-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -343,9 +337,47 @@ fn known() {}
); );
} }
#[test]
fn handler_macro_rejects_client_placement() {
let fixture = Fixture::new("hemx-derive-handler-client-placement-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "hemx-derive-handler-client-placement-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
hemx = {{ path = {:?} }}
"#,
repo_path("hemx")
),
);
fixture.write(
"src/lib.rs",
r#"#[hemx::handler(client)]
fn save() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unsupported hemx handler placement; expected #[hemx::handler]"),
"missing client-placement diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn handler_macro_reports_missing_syms() { fn handler_macro_reports_missing_syms() {
// req: build/003 req: test/003
let fixture = Fixture::new("hemx-derive-handler-syms-fail"); let fixture = Fixture::new("hemx-derive-handler-syms-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -400,7 +432,6 @@ fn create() -> impl hemx::IntoEffect {
#[test] #[test]
fn handler_requires_generated_param_arguments() { fn handler_requires_generated_param_arguments() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("hemx-derive-param-handler-fail"); let fixture = Fixture::new("hemx-derive-param-handler-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -452,7 +483,6 @@ fn show(todo_id: String) -> impl hemx::IntoEffect {
#[test] #[test]
fn form_struct_rejects_missing_generated_field() { fn form_struct_rejects_missing_generated_field() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("hemx-derive-form-struct-missing-field-fail"); let fixture = Fixture::new("hemx-derive-form-struct-missing-field-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -502,7 +532,6 @@ struct CreateTodo {}
#[test] #[test]
fn form_struct_rejects_wrong_optionality_and_multiplicity() { fn form_struct_rejects_wrong_optionality_and_multiplicity() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("hemx-derive-form-struct-shape-fail"); let fixture = Fixture::new("hemx-derive-form-struct-shape-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -559,7 +588,6 @@ struct Profile {
#[test] #[test]
fn form_struct_requires_field_parser() { fn form_struct_requires_field_parser() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("hemx-derive-form-struct-parser-fail"); let fixture = Fixture::new("hemx-derive-form-struct-parser-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -613,7 +641,6 @@ struct Profile {
#[test] #[test]
fn form_struct_accepts_raw_identifier_for_reserved_control_name() { fn form_struct_accepts_raw_identifier_for_reserved_control_name() {
// req: form/004 test
let fixture = Fixture::new("hemx-derive-form-raw-identifier-pass"); let fixture = Fixture::new("hemx-derive-form-raw-identifier-pass");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -664,7 +691,6 @@ struct Filter {
#[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
let fixture = Fixture::new("hemx-derive-form-handler-checked-model-pass"); let fixture = Fixture::new("hemx-derive-form-handler-checked-model-pass");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -724,7 +750,6 @@ fn smoke() {
#[test] #[test]
fn form_handle_requires_form_parameter() { fn form_handle_requires_form_parameter() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("hemx-derive-form-handler-fail"); let fixture = Fixture::new("hemx-derive-form-handler-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -778,7 +803,6 @@ fn create() -> impl hemx::IntoEffect {
#[test] #[test]
fn form_handle_rejects_state_only_handler() { fn form_handle_rejects_state_only_handler() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("hemx-derive-form-handler-state-only-fail"); let fixture = Fixture::new("hemx-derive-form-handler-state-only-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -835,7 +859,6 @@ fn create(_state: State<App>) -> impl hemx::IntoEffect {
#[test] #[test]
fn form_handle_still_requires_generated_param_arguments() { fn form_handle_still_requires_generated_param_arguments() {
// req: form/006 req: derive_handler/003 req: test/003
let fixture = Fixture::new("hemx-derive-form-handler-param-fail"); let fixture = Fixture::new("hemx-derive-form-handler-param-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -889,7 +912,6 @@ fn create(_form: hemx::Form<CreateTodo>) -> impl hemx::IntoEffect {
#[test] #[test]
fn surface_macro_reports_missing_generated_include() { fn surface_macro_reports_missing_generated_include() {
// req: build/004 req: test/003
let fixture = Fixture::new("hemx-derive-surface-include-fail"); let fixture = Fixture::new("hemx-derive-surface-include-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -942,7 +964,6 @@ pub mod ui {}
#[test] #[test]
fn surface_macro_requires_inline_module() { fn surface_macro_requires_inline_module() {
// req: codegen/001 req: test/003
let fixture = Fixture::new("hemx-derive-surface-inline-fail"); let fixture = Fixture::new("hemx-derive-surface-inline-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -980,7 +1001,6 @@ pub mod ui;
#[test] #[test]
fn generated_resource_references_fail_when_name_is_absent() { fn generated_resource_references_fail_when_name_is_absent() {
// req: style/002 req: codegen/001 req: test/003
let fixture = Fixture::new("hemx-derive-generated-resource-fail"); let fixture = Fixture::new("hemx-derive-generated-resource-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
@@ -1033,7 +1053,6 @@ pub const MISSING: hemx::CssClass = ui::classes::missing;
#[test] #[test]
fn effect_batch_has_no_parallel_postcard_wire_api() { fn effect_batch_has_no_parallel_postcard_wire_api() {
// req: wire/010 test
let fixture = Fixture::new("hemx-effect-batch-postcard-api-fail"); let fixture = Fixture::new("hemx-effect-batch-postcard-api-fail");
fixture.write( fixture.write(
"Cargo.toml", "Cargo.toml",
+7
View File
@@ -2,6 +2,13 @@
name = "hemx-js" name = "hemx-js"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Browser runtime assets for Hemx"
keywords = ["hypermedia", "javascript", "web"]
categories = ["web-programming"]
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-js
Browser runtime assets embedded by Hemx integrations.
## License
MIT
+18 -27
View File
@@ -1,4 +1,3 @@
// req: ts/001
export type ResourceKind = "slot" | "atom" | "handle" | "form"; export type ResourceKind = "slot" | "atom" | "handle" | "form";
export interface ResourceId { export interface ResourceId {
@@ -15,24 +14,27 @@ export interface ResourceRef {
scope: ScopeKey | null; scope: ScopeKey | null;
} }
export type Payload = export type InsertPosition =
| { kind: "text"; value: string } | "first"
| { kind: "html"; value: string }; | "last"
| { kind: "before"; target: ResourceRef }
| { kind: "after"; target: ResourceRef };
export type ScrollBehavior = export type MovePosition = InsertPosition;
| "preserve" export type PatchMode = "morph" | "replace";
| "top" export type ScrollBlock = "start" | "center" | "end" | "nearest";
| { kind: "element"; target: ResourceRef }; export type ScrollBehavior = "auto" | "instant" | "smooth";
export type HistoryMode = "push" | "replace";
export type Effect = export type Effect =
| { kind: "put"; target: ResourceRef; payload: Payload } | { kind: "patch"; target: ResourceRef; mode: PatchMode; html: string }
| { kind: "insert"; target: ResourceRef; key: string; payload: Payload } | { kind: "insert"; target: ResourceRef; position: InsertPosition; html: string }
| { kind: "prepend"; target: ResourceRef; key: string; payload: Payload } | { kind: "remove"; target: ResourceRef }
| { kind: "remove"; target: ResourceRef; key: string | null } | { kind: "move"; target: ResourceRef; position: MovePosition }
| { kind: "move"; target: ResourceRef; key: string; before: string | null } | { kind: "focus"; target: ResourceRef; preventScroll: boolean }
| { kind: "focus"; target: ResourceRef } | { kind: "scroll"; target: ResourceRef; block: ScrollBlock; behavior: ScrollBehavior }
| { kind: "navigate"; url: string; mode: "push" | "replace" | "redirect"; scroll: ScrollBehavior; title: string | null } | { kind: "visit"; url: string; history: HistoryMode }
| { kind: "emit"; name: string; payload: string }; | { kind: "dispatch"; event: string; payload: Uint8Array };
export interface EffectBatch { export interface EffectBatch {
abiVersion: number; abiVersion: number;
@@ -45,16 +47,6 @@ export interface AtomSnapshot {
bytes: Uint8Array; bytes: Uint8Array;
} }
export type ClientHandler = (
eventVersion: number,
eventKind: string,
eventValue: string | undefined,
eventChecked: boolean | undefined,
eventKey: string | undefined,
stateVersion: number,
encodedState: string,
) => Uint8Array | Promise<Uint8Array>;
export interface HemxRuntime { export interface HemxRuntime {
readonly runtimeAbiVersion: number; readonly runtimeAbiVersion: number;
roots(): Element[]; roots(): Element[];
@@ -64,7 +56,6 @@ export interface HemxRuntime {
decodeBatch(buffer: ArrayBuffer): EffectBatch; decodeBatch(buffer: ArrayBuffer): EffectBatch;
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined; atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
decodeAtomState(encoded: string): AtomSnapshot[]; decodeAtomState(encoded: string): AtomSnapshot[];
registerClientHandler(name: string, handler: ClientHandler): ClientHandler | undefined;
} }
declare global { declare global {
+59 -185
View File
@@ -19,9 +19,6 @@
const webSockets = new WeakMap(); const webSockets = new WeakMap();
const atomStores = new WeakMap(); const atomStores = new WeakMap();
const dragKeys = new WeakMap(); const dragKeys = new WeakMap();
let currentOperationId = null;
const clientHandlers = new Map();
const clientRuns = new WeakMap();
const activeRequests = new Set(); const activeRequests = new Set();
const REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 10_000;
@@ -249,57 +246,6 @@
}); });
} }
async function runClient(el, event) {
const name = el.getAttribute("data-hemx-client");
const root = rootOf(el);
const policy = el.getAttribute("data-hemx-client-policy") || "latest";
const run = { root, generation: (clientRuns.get(root)?.generation || 0) + 1 };
if (policy === "drop" && clientRuns.has(root)) return;
clientRuns.set(root, run);
const active = () => clientRuns.get(root) === run && root.isConnected;
const handler = clientHandlers.get(name);
showError(el, null);
showPending(el, true);
try {
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
const stateVersion = Number(root.getAttribute("data-hemx-client-state-version") || "1");
const operationId = crypto.randomUUID();
const wire = await handler(
1,
event.type,
dragKeys.get(root) || el.getAttribute("data-card-id") || ("value" in el ? String(el.value) : undefined),
"checked" in el ? Boolean(el.checked) : undefined,
event.key || undefined,
stateVersion,
root.getAttribute(STATE) || "",
);
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
if (!active()) return;
currentOperationId = operationId;
try {
applyBatch(wire, root);
} finally {
currentOperationId = null;
}
if (name === "reorder_card") {
const key = dragKeys.get(root) || el.getAttribute("data-card-id");
const moved = key ? firstElement(root, (node) => node.getAttribute("data-key") === key) : null;
const focus = moved && (firstElement(moved, (node) => node.tagName === "BUTTON") || moved);
if (focus && typeof focus.focus === "function") focus.focus();
if (matchMedia("(prefers-reduced-motion: reduce)").matches) root.setAttribute("data-hemx-reduced-motion", "");
else root.removeAttribute("data-hemx-reduced-motion");
}
} catch (error) {
if (!active()) return;
const fallback = el.hasAttribute("data-hemx-client-fallback");
showError(el, error);
emit(root, "hemx:client-error", { handler: name, message: String(error), fallback });
if (fallback) await send(el, event.type, el);
} finally {
if (clientRuns.get(root) === run) clientRuns.delete(root);
if (el.isConnected) showPending(el, false);
}
}
async function send(el, eventName, source = el) { async function send(el, eventName, source = el) {
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return; if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
@@ -464,87 +410,63 @@
return; return;
} }
const scope = root || document; const scope = root || document;
const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean); for (const op of batch.ops) {
if (missingTarget) { if (!applyOp(scope, op)) return;
missing(scope, missingTarget);
return;
} }
for (const op of batch.ops) applyOp(scope, op);
bindPolling(scope); bindPolling(scope);
bindRevealed(scope); bindRevealed(scope);
} }
function canApplyOp(scope, op) { function positionTarget(scope, position) {
if (op.kind === "put" && isAtom(op.target)) return null; if (position.kind === "before" || position.kind === "after") return targetFor(scope, position.target);
if (op.kind === "put" || op.kind === "focus") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "insert" || op.kind === "prepend") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "remove") return (op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target)) ? null : op.target;
if (op.kind === "move") return targetFor(scope, op.target) && keyedTarget(scope, op.target.resource.id, op.key) ? null : op.target;
if (op.kind === "navigate" && op.scroll && op.scroll.kind === "element") return targetFor(scope, op.scroll.target) ? null : op.scroll.target;
return null; return null;
} }
function applyOp(scope, op) { function applyOp(scope, op) {
if (op.kind === "put") { if (op.kind === "patch") {
if (isAtom(op.target)) { const target = targetFor(scope, op.target);
atomStore(scope).set(String(op.target.resource.id), op.payload.value); if (!target) return missing(scope, op.target);
forEachElement(scope, (element) => { if (op.mode === "replace") {
if (element.getAttribute("data-aid") === String(op.target.resource.id)) { const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
putPayload(element, op.payload); target.replaceWith(...nodes);
} } else target.innerHTML = op.html;
}); } else if (op.kind === "insert") {
return true; const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.position === "first") target.prepend(...fragmentNodes({ kind: "html", value: op.html }, null, null));
else if (op.position === "last") target.append(...fragmentNodes({ kind: "html", value: op.html }, null, null));
else {
const anchor = positionTarget(scope, op.position);
if (!anchor) return missing(scope, op.position.target);
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
for (const node of nodes) anchor.parentNode.insertBefore(node, op.position.kind === "before" ? anchor : anchor.nextSibling);
} }
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
else putPayload(target, op.payload);
} else if (op.kind === "insert" || op.kind === "prepend") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
} else if (op.kind === "remove") { } else if (op.kind === "remove") {
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target); const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target); if (!target) return missing(scope, op.target);
target.remove(); target.remove();
} else if (op.kind === "move") { } else if (op.kind === "move") {
const target = targetFor(scope, op.target); const target = targetFor(scope, op.target);
const item = keyedTarget(scope, op.target.resource.id, op.key); if (!target) return missing(scope, op.target);
if (!target || !item) return missing(scope, op.target); if (op.position === "first") target.parentNode.prepend(target);
const before = op.before && keyedTarget(scope, op.target.resource.id, op.before); else if (op.position === "last") target.parentNode.append(target);
target.insertBefore(item, before || null); else {
const anchor = positionTarget(scope, op.position);
if (!anchor) return missing(scope, op.position.target);
anchor.parentNode.insertBefore(target, op.position.kind === "before" ? anchor : anchor.nextSibling);
}
} else if (op.kind === "focus") { } else if (op.kind === "focus") {
const target = targetFor(scope, op.target); const target = targetFor(scope, op.target);
if (target && target.focus) target.focus(); if (target && target.focus) target.focus({ preventScroll: op.preventScroll });
else return missing(scope, op.target); else return missing(scope, op.target);
} else if (op.kind === "navigate") { } else if (op.kind === "scroll") {
if (op.mode === "redirect") location.href = op.url; const target = targetFor(scope, op.target);
else { if (!target) return missing(scope, op.target);
history[op.mode === "replace" ? "replaceState" : "pushState"]({ hemx: true }, "", op.url); target.scrollIntoView({ block: op.block, behavior: op.behavior });
if (op.scroll === "top") scrollTo(0, 0); } else if (op.kind === "visit") {
else if (op.scroll && op.scroll.kind === "element") { navigateUrl(op.url, scope, op.history).catch(() => { location.href = op.url; });
const target = targetFor(scope, op.scroll.target); } else if (op.kind === "dispatch") {
if (target) target.scrollIntoView(); emit(scope, op.event, op.payload);
}
if (op.title) document.title = op.title;
}
} else if (op.kind === "emit") {
let payload = op.payload;
if (currentOperationId && op.name === "hemx:sync-patch") {
const event = JSON.parse(payload);
const patch = event && Object.getPrototypeOf(event) === Object.prototype && "patch" in event
? event.patch
: event;
if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") {
throw new Error("hemx sync patch is missing its interaction identity");
}
patch.idempotencyKey = currentOperationId;
patch.operationId = currentOperationId;
payload = JSON.stringify(event);
}
handleRuntimeEvent(scope, op.name, payload);
emit(scope, op.name, payload);
} }
return true; return true;
} }
@@ -561,6 +483,7 @@
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node); for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
while (stack.length) { while (stack.length) {
const node = stack.shift(); const node = stack.shift();
if (node.hasAttribute && node.hasAttribute(ROOT)) continue;
if (predicate(node)) return node; if (predicate(node)) return node;
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child); for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
} }
@@ -667,28 +590,6 @@
return nodes; return nodes;
} }
function handleRuntimeEvent(scope, name, payload) {
if (name === "hemx:form-reset") {
const form = generatedFormTarget(scope, payload);
if (form && form.reset) {
form.reset();
forEachElement(form, (el) => {
if (el.hasAttribute("data-hemx-error-for")) {
if (isInputControl(el)) el.setCustomValidity("");
else el.textContent = "";
}
});
}
} else if (name === "hemx:form-error") {
const [id, field, message] = String(payload).split("\u001f");
const target = formErrorTarget(scope, id, field);
if (target) putFormError(target, message || "");
} else if (name === "hemx:form-disable-while-pending") {
const form = generatedFormTarget(scope, payload);
if (form) form.setAttribute("data-hemx-disable-while-pending", "");
}
}
function missing(root, target) { function missing(root, target) {
emit(root, "hemx:missing-target", target); emit(root, "hemx:missing-target", target);
return false; return false;
@@ -772,10 +673,6 @@
function bindRoot(root) { function bindRoot(root) {
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => { ["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
root.addEventListener(name, (event) => { root.addEventListener(name, (event) => {
if (name === "keydown") {
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client"));
if (direct && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
}
if (name === "dragstart") { if (name === "dragstart") {
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key")); const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key"));
if (item) { if (item) {
@@ -800,18 +697,9 @@
} }
if (name === "click") { if (name === "click") {
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID)); const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
const active = direct || closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client")); if (direct && handlesEvent(direct, "click")) {
if (active && handlesEvent(active, "click")) {
event.preventDefault(); event.preventDefault();
if (active.hasAttribute("data-hemx-client")) { schedule(direct, name);
runClient(active, event).catch((error) => emit(root, "hemx:client-error", {
handler: active.getAttribute("data-hemx-client"),
message: String(error),
fallback: false,
}));
} else {
schedule(active, name);
}
return; return;
} }
} }
@@ -829,21 +717,13 @@
} }
} }
let el = closestInRoot(event.target, root, (node) => let el = closestInRoot(event.target, root, (node) =>
node.hasAttribute(HID) || node.hasAttribute("data-hemx-client") || node.hasAttribute(HID) ||
(node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node))) (node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node)))
); );
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target; if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target;
if (!el || !handlesEvent(el, name)) return; if (!el || !handlesEvent(el, name)) return;
event.preventDefault(); event.preventDefault();
if (el.hasAttribute("data-hemx-client")) { schedule(el, name, event.submitter || el);
runClient(el, event).catch((error) => emit(root, "hemx:client-error", {
handler: el.getAttribute("data-hemx-client"),
message: String(error),
fallback: false,
}));
} else {
schedule(el, name, event.submitter || el);
}
}); });
}); });
bindPolling(root); bindPolling(root);
@@ -1077,6 +957,7 @@
const str = () => new TextDecoder("utf-8", { fatal: true }).decode( const str = () => new TextDecoder("utf-8", { fatal: true }).decode(
need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")), need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")),
); );
const bytesField = () => need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx bytes"));
const enumValue = (values, field) => { const enumValue = (values, field) => {
const discriminant = u8(); const discriminant = u8();
if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`); if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`);
@@ -1097,24 +978,24 @@
throw new Error(`unknown hemx scope ${kind}`); throw new Error(`unknown hemx scope ${kind}`);
}; };
const ref = () => ({ resource: resource(), scope: scope() }); const ref = () => ({ resource: resource(), scope: scope() });
const payload = () => ({ kind: enumValue(["text", "html"], "hemx payload"), value: str() }); const position = () => {
const scroll = () => {
const kind = u8(); const kind = u8();
if (kind === 0) return "preserve"; if (kind === 0) return "first";
if (kind === 1) return "top"; if (kind === 1) return "last";
if (kind === 2) return { kind: "element", target: ref() }; if (kind === 2) return { kind: "before", target: ref() };
throw new Error(`unknown hemx scroll behavior ${kind}`); if (kind === 3) return { kind: "after", target: ref() };
throw new Error(`unknown hemx position ${kind}`);
}; };
const effect = () => { const effect = () => {
const kind = u8(); const kind = u8();
if (kind === 0) return { kind: "put", target: ref(), payload: payload() }; if (kind === 0) return { kind: "patch", target: ref(), mode: enumValue(["morph", "replace"], "hemx patch mode"), html: str() };
if (kind === 1) return { kind: "insert", target: ref(), key: str(), payload: payload() }; if (kind === 1) return { kind: "insert", target: ref(), position: position(), html: str() };
if (kind === 2) return { kind: "prepend", target: ref(), key: str(), payload: payload() }; if (kind === 2) return { kind: "remove", target: ref() };
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) }; if (kind === 3) return { kind: "move", target: ref(), position: position() };
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) }; if (kind === 4) return { kind: "focus", target: ref(), preventScroll: u8() !== 0 };
if (kind === 5) return { kind: "focus", target: ref() }; if (kind === 5) return { kind: "scroll", target: ref(), block: enumValue(["start", "center", "end", "nearest"], "hemx scroll block"), behavior: enumValue(["auto", "instant", "smooth"], "hemx scroll behavior") };
if (kind === 6) return { kind: "navigate", url: str(), mode: enumValue(["push", "replace", "redirect"], "hemx navigation mode"), scroll: scroll(), title: option(str) }; if (kind === 6) return { kind: "visit", url: str(), history: enumValue(["push", "replace"], "hemx history mode") };
if (kind === 7) return { kind: "emit", name: str(), payload: str() }; if (kind === 7) return { kind: "dispatch", event: str(), payload: bytesField() };
throw new Error(`unknown hemx effect ${kind}`); throw new Error(`unknown hemx effect ${kind}`);
}; };
const vec = (read) => { const vec = (read) => {
@@ -1164,7 +1045,6 @@
} }
function cleanupRemovedRoot(root) { function cleanupRemovedRoot(root) {
clientRuns.delete(root);
const source = sseSources.get(root); const source = sseSources.get(root);
if (source) source.close(); if (source) source.close();
sseSources.delete(root); sseSources.delete(root);
@@ -1266,12 +1146,6 @@
decodeBatch, decodeBatch,
atomValue, atomValue,
decodeAtomState, decodeAtomState,
registerClientHandler(name, handler) {
if (!name || typeof handler !== "function") throw new Error("client handler registration requires a name and function");
const previous = clientHandlers.get(name);
clientHandlers.set(name, handler);
return previous;
},
}); });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
+42 -54
View File
@@ -1,11 +1,10 @@
#[test] #[test]
fn runtime_abi_version_is_explicit_and_stable() { fn runtime_abi_version_is_explicit_and_stable() {
assert_eq!(hemx_js::RUNTIME_ABI_VERSION, 1); // test req: abi/001 assert_eq!(hemx_js::RUNTIME_ABI_VERSION, 1);
} }
#[test] #[test]
fn runtime_exposes_debug_api_before_startup_side_effects() { fn runtime_exposes_debug_api_before_startup_side_effects() {
// req: runtime/002
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
let api = source let api = source
@@ -23,7 +22,6 @@ fn runtime_exposes_debug_api_before_startup_side_effects() {
#[test] #[test]
fn runtime_reports_http_failures_without_applying_effects() { fn runtime_reports_http_failures_without_applying_effects() {
// req: runtime/005
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function httpError(response)")); assert!(source.contains("function httpError(response)"));
@@ -50,7 +48,6 @@ fn runtime_posts_urlencoded_forms_by_default() {
#[test] #[test]
fn runtime_turns_page_get_forms_into_url_state_navigation() { fn runtime_turns_page_get_forms_into_url_state_navigation() {
// req: page_swap/009 req: page_swap/010
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function pageFormHistoryMode(source, form)")); assert!(source.contains("function pageFormHistoryMode(source, form)"));
@@ -68,7 +65,6 @@ fn runtime_turns_page_get_forms_into_url_state_navigation() {
#[test] #[test]
fn runtime_preserves_multipart_file_upload_fallback_shape() { fn runtime_preserves_multipart_file_upload_fallback_shape() {
// req: multipart/003
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains( assert!(source.contains(
@@ -81,7 +77,6 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
#[test] #[test]
fn runtime_uses_root_scoped_walks_not_dom_selector_apis() { fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
// req: pitch/002 req: runtime/001 req: target_policy/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function firstElement(scope, predicate)")); assert!(source.contains("function firstElement(scope, predicate)"));
@@ -97,7 +92,6 @@ fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
#[test] #[test]
fn runtime_targets_generated_resources_not_response_selectors() { fn runtime_targets_generated_resources_not_response_selectors() {
// req: target_policy/001 req: target_policy/002
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function targetFor(scope, ref)")); assert!(source.contains("function targetFor(scope, ref)"));
@@ -105,22 +99,20 @@ fn runtime_targets_generated_resources_not_response_selectors() {
assert!(source.contains("function firstElement(scope, predicate)")); assert!(source.contains("function firstElement(scope, predicate)"));
assert!(source.contains("function generatedResource(el, id)")); assert!(source.contains("function generatedResource(el, id)"));
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))")); assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
assert!( assert!(source.contains("if (node.hasAttribute && node.hasAttribute(ROOT)) continue"));
source.contains("const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id)") assert!(source.contains("if (!applyOp(scope, op)) return"));
); assert!(!source.contains("canApplyOp"));
assert!(source.contains("if (op.target.scope && op.target.scope.kind === \"key\" && op.payload.kind === \"html\") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id)")); assert!(source.contains("if (op.position === \"first\") target.prepend"));
assert!(source.contains("function replacePayload(target, payload, key, resourceId)")); assert!(source.contains("else if (op.position === \"last\") target.append"));
assert!(source.contains("target.replaceWith(...nodes)")); assert!(source.contains("target.replaceWith(...nodes)"));
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)")); assert!(source.contains("emit(scope, op.event, op.payload)"));
assert!(source.contains("const target = generatedTarget(scope, id)")); assert!(!source.contains("handleRuntimeEvent"));
assert!(source.contains("if (!target) return missing(scope, op.target)"));
assert!(!source.contains("data-hemx-target")); assert!(!source.contains("data-hemx-target"));
assert!(!source.contains("data-hemx-select")); assert!(!source.contains("data-hemx-select"));
} }
#[test] #[test]
fn runtime_interval_dispatch_avoids_duplicate_timers() { fn runtime_interval_dispatch_avoids_duplicate_timers() {
// req: convention/005
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const everyTimers = new WeakMap()")); assert!(source.contains("const everyTimers = new WeakMap()"));
@@ -138,7 +130,6 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
#[test] #[test]
fn runtime_supports_tiny_delay_and_revealed_scheduling() { fn runtime_supports_tiny_delay_and_revealed_scheduling() {
// req: convention/001 req: convention/003 req: convention/005
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("data-hemx-delay")); assert!(source.contains("data-hemx-delay"));
@@ -146,26 +137,24 @@ 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("data-hemx-revealed-ahead"));
assert!(source.contains("typeof IntersectionObserver === \"undefined\"")); assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
assert!(source.contains("rootMargin")); assert!(source.contains("rootMargin"));
assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))")); 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("window.addEventListener(\"pageshow\", restoreRevealed)"));
assert!(source.contains("revealed.delete(el)")); // req: convention/014 test assert!(source.contains("revealed.delete(el)"));
assert!(source.contains("record.addedNodes.forEach((node) =>")); assert!(source.contains("record.addedNodes.forEach((node) =>"));
assert!(source.contains("descendantRoots(node).forEach(bindRoot)")); assert!(source.contains("descendantRoots(node).forEach(bindRoot)"));
assert!(source.contains("const owner = rootOf(node.parentElement)")); assert!(source.contains("const owner = rootOf(node.parentElement)"));
assert!(source.contains("bindPolling(owner)")); assert!(source.contains("bindPolling(owner)"));
assert!(source.contains("bindRevealed(owner)")); assert!(source.contains("bindRevealed(owner)"));
assert!(source.contains( assert!(source.contains("if (!applyOp(scope, op)) return;"));
"for (const op of batch.ops) applyOp(scope, op);\n bindPolling(scope);\n bindRevealed(scope);" assert!(source.contains("bindPolling(scope);\n bindRevealed(scope);"));
)); // req: runtime/007 test
} }
#[test] #[test]
fn runtime_toggles_pending_conventions_around_requests() { fn runtime_toggles_pending_conventions_around_requests() {
// req: convention/007 req: convention/008
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function showPending(el, on)")); assert!(source.contains("function showPending(el, on)"));
@@ -202,7 +191,6 @@ fn runtime_toggles_pending_conventions_around_requests() {
#[test] #[test]
fn runtime_confirms_before_handler_dispatch() { fn runtime_confirms_before_handler_dispatch() {
// req: convention/004
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("el.getAttribute(\"data-hemx-confirm\") && !confirm(el.getAttribute(\"data-hemx-confirm\"))")); assert!(source.contains("el.getAttribute(\"data-hemx-confirm\") && !confirm(el.getAttribute(\"data-hemx-confirm\"))"));
@@ -211,7 +199,6 @@ fn runtime_confirms_before_handler_dispatch() {
#[test] #[test]
fn runtime_bounds_and_cancels_ordinary_requests() { fn runtime_bounds_and_cancels_ordinary_requests() {
// test req: operations/003
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const REQUEST_TIMEOUT_MS = 10_000")); assert!(source.contains("const REQUEST_TIMEOUT_MS = 10_000"));
@@ -225,7 +212,6 @@ fn runtime_bounds_and_cancels_ordinary_requests() {
#[test] #[test]
fn runtime_fetches_with_same_origin_credentials() { fn runtime_fetches_with_same_origin_credentials() {
// req: auth/005
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert_eq!(source.matches("credentials: \"same-origin\"").count(), 2); assert_eq!(source.matches("credentials: \"same-origin\"").count(), 2);
@@ -242,7 +228,6 @@ fn runtime_handles_get_forms_without_request_body() {
#[test] #[test]
fn runtime_clicking_submitter_schedules_form_submit() { fn runtime_clicking_submitter_schedules_form_submit() {
// req: convention/004 req: runtime/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function formOwner(el)")); assert!(source.contains("function formOwner(el)"));
@@ -266,7 +251,6 @@ fn runtime_clicking_submitter_schedules_form_submit() {
#[test] #[test]
fn runtime_supports_drag_drop_params_without_user_js() { fn runtime_supports_drag_drop_params_without_user_js() {
// req: htmx_equivalents/002, req: dx/008
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const dragKeys = new WeakMap()")); assert!(source.contains("const dragKeys = new WeakMap()"));
@@ -278,7 +262,6 @@ fn runtime_supports_drag_drop_params_without_user_js() {
#[test] #[test]
fn runtime_supports_queued_request_policy() { fn runtime_supports_queued_request_policy() {
// req: convention/006
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function normalizedPolicy(value)")); assert!(source.contains("function normalizedPolicy(value)"));
@@ -293,7 +276,6 @@ fn runtime_supports_queued_request_policy() {
#[test] #[test]
fn runtime_latest_request_policy_releases_superseded_pending_state() { fn runtime_latest_request_policy_releases_superseded_pending_state() {
// req: convention/006 req: convention/007
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (active && policy === \"latest\") {\n active.abort.abort();\n showPending(target, false);\n }")); assert!(source.contains("if (active && policy === \"latest\") {\n active.abort.abort();\n showPending(target, false);\n }"));
@@ -302,7 +284,6 @@ fn runtime_latest_request_policy_releases_superseded_pending_state() {
#[test] #[test]
fn runtime_parallel_request_policy_releases_each_pending_state() { fn runtime_parallel_request_policy_releases_each_pending_state() {
// req: convention/006 req: convention/007 req: convention/008
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains( assert!(source.contains(
@@ -322,7 +303,7 @@ fn runtime_exposes_page_swap_hooks() {
assert!(source.contains("history.pushState")); assert!(source.contains("history.pushState"));
assert!(source.contains("snapshotPage(root)")); assert!(source.contains("snapshotPage(root)"));
assert!(source.contains("restorePage(event.state, root)")); assert!(source.contains("restorePage(event.state, root)"));
assert!(source.contains("scrollTo(state.scrollX, state.scrollY)")); // req: page_swap/005 test assert!(source.contains("scrollTo(state.scrollX, state.scrollY)"));
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"));
@@ -332,7 +313,6 @@ fn runtime_exposes_page_swap_hooks() {
#[test] #[test]
fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() { fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
// req: page_swap/005 req: page_swap/006 req: runtime/004
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (mode === \"none\") location.reload();")); assert!(source.contains("if (mode === \"none\") location.reload();"));
@@ -341,7 +321,6 @@ fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
#[test] #[test]
fn runtime_preserves_native_navigation_escape_hatches() { fn runtime_preserves_native_navigation_escape_hatches() {
// req: page_swap/007 req: failure/006
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function sameOriginNav(event, anchor)")); assert!(source.contains("function sameOriginNav(event, anchor)"));
@@ -355,7 +334,6 @@ fn runtime_preserves_native_navigation_escape_hatches() {
#[test] #[test]
fn runtime_refuses_partial_updates_on_fingerprint_mismatch() { fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
// req: abi/003 req: abi/004 req: runtime/004
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function compatibleFingerprint(response, root)")); assert!(source.contains("function compatibleFingerprint(response, root)"));
@@ -368,7 +346,6 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
#[test] #[test]
fn runtime_malformed_bootstrap_state_reports_and_continues() { fn runtime_malformed_bootstrap_state_reports_and_continues() {
// req: state/004 req: state/006 req: runtime/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function bootstrapState(root)")); assert!(source.contains("function bootstrapState(root)"));
@@ -381,7 +358,6 @@ fn runtime_malformed_bootstrap_state_reports_and_continues() {
#[test] #[test]
fn runtime_applies_sse_effect_batches_inside_roots() { fn runtime_applies_sse_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/006 req: runtime/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const sseSources = new WeakMap()")); assert!(source.contains("const sseSources = new WeakMap()"));
@@ -400,7 +376,6 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
#[test] #[test]
fn runtime_applies_binary_websocket_effect_batches_inside_roots() { fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/009 req: runtime/001 req: failure/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const webSockets = new WeakMap()")); assert!(source.contains("const webSockets = new WeakMap()"));
@@ -417,7 +392,6 @@ fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
#[test] #[test]
fn runtime_page_swaps_lowered_slot_ids() { fn runtime_page_swaps_lowered_slot_ids() {
// req: wire/001
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function replaceLoweredSlots(scope, doc)")); assert!(source.contains("function replaceLoweredSlots(scope, doc)"));
@@ -441,21 +415,18 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
} }
#[test] #[test]
fn runtime_preflights_batches_before_applying_ops() { fn runtime_applies_batches_in_order_and_stops_on_failure() {
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains( assert!(source.contains("for (const op of batch.ops)"));
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)" assert!(source.contains("if (!applyOp(scope, op)) return;"));
)); assert!(!source.contains("function canApplyOp(scope, op)"));
assert!(source.contains("function canApplyOp(scope, op)"));
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
} }
#[test] #[test]
fn runtime_ships_typescript_definitions() { fn runtime_ships_typescript_definitions() {
let source = hemx_js::RUNTIME_D_TS; let source = hemx_js::RUNTIME_D_TS;
assert!(source.contains("req: ts/001"));
assert!(source.contains("export interface EffectBatch")); assert!(source.contains("export interface EffectBatch"));
assert!(source.contains("fingerprint: bigint")); assert!(source.contains("fingerprint: bigint"));
assert!(source.contains("export type Effect =")); assert!(source.contains("export type Effect ="));
@@ -464,14 +435,31 @@ fn runtime_ships_typescript_definitions() {
} }
#[test] #[test]
fn runtime_clears_error_for_elements_on_form_reset() { fn runtime_dispatches_payload_without_application_policy() {
// req: runtime/005 req: form_effects/003
let source = hemx_js::RUNTIME_JS; let source = hemx_js::RUNTIME_JS;
assert!(source.contains(r#"if (name === "hemx:form-reset")"#)); assert!(source.contains("emit(scope, op.event, op.payload)"));
assert!(source.contains("form.reset();")); assert!(!source.contains("hemx:form-reset"));
assert!(source.contains(r#"if (el.hasAttribute("data-hemx-error-for"))"#)); assert!(!source.contains("handleRuntimeEvent"));
assert!(source.contains("forEachElement(form, (el) => {")); }
assert!(source.contains("el.setCustomValidity(\"\")"));
assert!(source.contains("el.textContent = \"\"")); #[test]
fn runtime_executes_every_closed_effect_with_owned_targets() {
let source = hemx_js::RUNTIME_JS;
for kind in [
"patch", "insert", "remove", "move", "focus", "scroll", "visit", "dispatch",
] {
assert!(
source.contains(&format!("op.kind === \"{kind}\"")),
"missing {kind} executor"
);
}
assert!(source.contains("target.replaceWith(...nodes)"));
assert!(source.contains("target.parentNode.prepend(target)"));
assert!(source.contains("target.parentNode.append(target)"));
assert!(source.contains("target.focus({ preventScroll: op.preventScroll })"));
assert!(source.contains("target.scrollIntoView({ block: op.block, behavior: op.behavior })"));
assert!(source.contains("navigateUrl(op.url, scope, op.history).catch"));
assert!(source.contains("emit(scope, op.event, op.payload)"));
} }
+19 -3
View File
@@ -1,10 +1,26 @@
[package] [package]
name = "hemx-test" name = "hemx-test"
version.workspace = true version = "0.4.0"
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Test support for Hemx applications"
keywords = ["hypermedia", "testing", "web"]
categories = ["development-tools::testing", "web-programming"]
[features]
default = []
axum = ["dep:axum", "dep:tower"]
[dependencies] [dependencies]
hemx-core = { path = "../hemx-core" } axum = { version = "0.8.8", optional = true, default-features = false }
hemx-core = { version = "0.3.0", path = "../hemx-core" }
scraper = "0.25.0"
tower = { version = "0.5.2", optional = true, features = ["util"] }
[dev-dependencies] [dev-dependencies]
hemx-build = { path = "../hemx-build" } hemx-axum = { version = "0.3.0", path = "../hemx-axum" }
hemx-build = { version = "0.3.0", path = "../hemx-build" }
tokio = { version = "1.48.0", features = ["macros", "rt"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+214
View File
@@ -0,0 +1,214 @@
# hemx-test
Test support for Hemx applications and generated resources.
`hemx-test` keeps assertions at the same typed boundary as application code. Use
an `EffectInspector` with generated targets instead of copying resource IDs or
matching raw wire operations.
## Choose the owning boundary
| Behavior under test | Smallest authoritative proof |
| --- | --- |
| Domain invariants and state transitions | Ordinary Rust unit tests; no Hemx harness |
| Handler output and generated-target effects | `run`, `run_async`, `run_result`, or `run_async_result` plus `EffectInspector` |
| Static document or effect-fragment structure | `HtmlInspector`, reached directly or through `target_html_document` / `target_html_fragment` |
| Axum extraction, middleware, status, headers, and response body | The opt-in `hemx_test::axum` router harness |
| Real process startup, readiness, sockets, logs, and cleanup | `TestProcess::builder` |
| JavaScript, focus, history, layout, or runtime reconciliation | A focused real-browser test outside `hemx-test` |
## Effects and structural HTML
```rust
use hemx_core::{GeneratedTarget, SafeHtml, Slot};
# use hemx_core::{Effect, ResourceId};
# #[derive(Clone, Copy)]
# struct GeneratedTodos(Slot<()>);
# impl GeneratedTarget for GeneratedTodos {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
# impl GeneratedTodos {
# fn html(self, value: SafeHtml) -> Effect { self.0.html(value) }
# }
// Application build output supplies generated target types like this one.
let todos = GeneratedTodos(Slot::new(7));
let effect = todos.html(SafeHtml::trusted(
r#"<ul><li class="todo" data-state="open">Write tests</li></ul>"#,
));
let inspected = hemx_test::inspect(effect);
assert!(inspected.updates_html(todos));
let html = inspected.target_html_fragment(todos)?;
html.assert_count("li.todo", 1);
html.assert_text("li.todo", "Write tests");
html.assert_attribute("li.todo", "data-state", "open");
# Ok::<(), hemx_test::HtmlInspectionError>(())
```
Complete server-rendered pages can be inspected directly:
```rust
let page = hemx_test::inspect_html_document(
"<!doctype html><html><body><main id=app data-sid=7>Ready</main></body></html>",
);
page.assert_text("main#app", "Ready");
# use hemx_core::{GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedApp(Slot<()>);
# impl GeneratedTarget for GeneratedApp {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
page.assert_target(GeneratedApp(Slot::new(7)));
```
Structural inspection is backed internally by an HTML parser, but its types are
not part of the public API. `HtmlInspector`, `HtmlSelection`, and `HtmlElement`
own the observable source and selected data. Structural inspection does not run
JavaScript or prove browser-owned behavior such as focus, history, layout, or
runtime reconciliation.
A target inspector requires exactly one `Put`, `Insert`, or `Prepend` HTML
payload for that generated target. Missing, non-HTML, and ambiguous target
effects return `HtmlInspectionError` with the relevant effects in the
diagnostic.
## Handlers
`run` and `run_async` invoke typed synchronous and asynchronous handlers and
return the same `EffectInspector`:
```rust
use hemx_core::{Effect, GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedCount(Slot<u32>);
# impl GeneratedTarget for GeneratedCount {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
async fn load_count(value: u32) -> Effect {
Slot::<u32>::new(1).text(value)
}
# async fn example() {
let count = GeneratedCount(Slot::new(1));
let inspected = hemx_test::run_async(load_count, 42).await;
assert!(inspected.updates_text_containing(count, "42"));
# }
```
Fallible handlers use `run_result` or `run_async_result`. Their concrete error
is returned unchanged and is never converted into an empty or success-looking
effect batch:
```rust
use hemx_core::{Effect, Slot};
#[derive(Debug, Eq, PartialEq)]
struct Rejected;
async fn save(accepted: bool) -> Result<Effect, Rejected> {
accepted
.then(|| Slot::<()>::new(1).text("saved"))
.ok_or(Rejected)
}
# async fn example() {
let error = hemx_test::run_async_result(save, false)
.await
.unwrap_err();
assert_eq!(error, Rejected);
# }
```
`IntoEffect` conversion itself is infallible in the current public contract, so
handler errors and inspected success effects remain distinct.
## Axum routers
Enable the `axum` feature to send owned requests through a real `axum::Router`.
The response always preserves status, headers, and raw body bytes; parse it as a
Hemx effect batch or structural HTML only when that is the response contract.
```rust
# #[cfg(feature = "axum")]
# async fn example() -> Result<(), hemx_test::axum::RouterTestError> {
use axum::{response::Html, routing::get, Router};
use axum::http::StatusCode;
let app = Router::new().route(
"/",
get(|| async { Html("<main id=app>Ready</main>") }),
);
let response = hemx_test::axum::get("/").send(app).await?;
assert_eq!(response.status(), StatusCode::OK);
response.html_fragment()?.assert_text("main#app", "Ready");
# Ok(())
# }
```
`post(...).form(handle, fields)` builds the URL-encoded interaction body from a
typed Hemx handle. Authentication, CSRF, sessions, persistence, middleware, and
test providers remain application-owned. This in-process harness does not prove
real sockets, process startup, or browser behavior.
## Processes
Use `TestProcess` only when the process and socket lifecycle are part of the
behavior under test. Readiness is explicit and bounded:
```rust,no_run
use hemx_test::TestProcess;
use std::process::Command;
use std::time::Duration;
let process = TestProcess::builder(Command::new("target/debug/my-app"))
.label("application server")
.arg("serve")
.env("APP_ADDR", "127.0.0.1:4100")
.http("127.0.0.1:4100", "/health")
.timeout(Duration::from_secs(5))
.start()?;
assert!(process.id().is_some());
# Ok::<(), hemx_test::ProcessError>(())
```
TCP readiness proves only that something accepts the address; HTTP readiness
requires a 2xx or 3xx response from the selected path. Startup errors include
the process label, readiness attempts, exit status when available, and bounded
stdout/stderr. Readers keep draining after the capture limit so noisy children
do not deadlock. Explicit `shutdown` and `Drop` both kill, wait for, and reap a
running child and are safe to call more than once.
`TestProcess::start` remains available as the compact compatibility entry point
for TCP readiness. The harness intentionally does not reserve ports or claim
that a reserve-then-bind handoff is atomic.
## Migrating selector helpers
The original `0.1.0` crate exposed app-specific CSS builders and island/browser
probe helpers. They duplicated CSS syntax, made application structure look like
a framework contract, and could not prove browser behavior. They are removed
from the next release rather than preserved as a second testing vocabulary.
- Replace semantic selector builders such as `article_selector`,
`class_selector`, and `nav_link_selector` with the CSS selector that expresses
the application's own HTML contract in `HtmlInspector`.
- Replace `assert_rendered_target` and `assert_rendered_handle` with
`HtmlInspector::assert_target` and `HtmlInspector::assert_handle`; generated
resources remain the assertion vocabulary and raw runtime IDs stay private.
- Replace `target_selector`, `handle_selector`, and keyed selector builders with
structural HTML assertions. Browser tests that genuinely need a selector
should keep that selector in their browser-test adapter.
- Replace island probe scripts, readout selectors, event-name helpers, and SSE
marker strings with a focused real-browser journey; `hemx-test` does not
emulate JavaScript or runtime behavior.
The generated-resource helpers for effect inspection and typed form bodies
remain supported.
## License
MIT
+212
View File
@@ -0,0 +1,212 @@
use crate::{handle_form_body, inspect_html_document, inspect_html_fragment, try_inspect_wire};
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
use axum::Router;
use hemx_core::{Handle, WireError};
use std::error::Error;
use std::fmt;
use tower::ServiceExt;
// Keep this private until the hemx-core release carrying its public constant is
// the minimum supported dependency of hemx-test.
const HEMX_CONTENT_TYPE: &str = "application/hemx";
const HTML_CONTENT_TYPE: &str = "text/html";
const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
/// Build a request against a real Axum [`Router`].
pub fn request(method: Method, uri: impl Into<String>) -> RouterRequest {
RouterRequest {
method,
uri: uri.into(),
headers: HeaderMap::new(),
body: Vec::new(),
body_limit: DEFAULT_BODY_LIMIT,
}
}
/// Build a GET request against a real Axum [`Router`].
pub fn get(uri: impl Into<String>) -> RouterRequest {
request(Method::GET, uri)
}
/// Build a POST request against a real Axum [`Router`].
pub fn post(uri: impl Into<String>) -> RouterRequest {
request(Method::POST, uri)
}
/// An owned request builder for exercising a real Axum router in process.
#[derive(Clone, Debug)]
pub struct RouterRequest {
method: Method,
uri: String,
headers: HeaderMap,
body: Vec<u8>,
body_limit: usize,
}
impl RouterRequest {
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = body.into();
self
}
/// Set a URL-encoded Hemx interaction form body from a typed handle.
pub fn form<I>(mut self, handle: Handle<I>, fields: &[(&str, &str)]) -> Self {
self.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(FORM_CONTENT_TYPE),
);
self.body = handle_form_body(handle, fields).into_bytes();
self
}
/// Bound the buffered response body. The default is 2 MiB.
pub fn body_limit(mut self, bytes: usize) -> Self {
self.body_limit = bytes;
self
}
/// Send this request through a real Axum router and buffer its response.
pub async fn send(self, router: Router) -> Result<RouterResponse, RouterTestError> {
let mut request = Request::builder()
.method(self.method)
.uri(&self.uri)
.body(Body::from(self.body))
.map_err(|error| RouterTestError::Request(error.to_string()))?;
*request.headers_mut() = self.headers;
let response = router
.oneshot(request)
.await
.map_err(|error| RouterTestError::Router(error.to_string()))?;
let (parts, body) = response.into_parts();
let bytes =
to_bytes(body, self.body_limit)
.await
.map_err(|error| RouterTestError::Body {
limit: self.body_limit,
message: error.to_string(),
})?;
Ok(RouterResponse {
status: parts.status,
headers: parts.headers,
body: bytes.to_vec(),
})
}
}
/// An owned Axum response preserving status, headers, and raw body bytes.
#[derive(Clone, Debug)]
pub struct RouterResponse {
status: StatusCode,
headers: HeaderMap,
body: Vec<u8>,
}
impl RouterResponse {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn content_type(&self) -> Option<&str> {
self.headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
}
pub fn text(&self) -> Result<&str, RouterTestError> {
std::str::from_utf8(&self.body).map_err(|error| RouterTestError::Utf8(error.to_string()))
}
/// Decode an `application/hemx` response into an effect inspector.
pub fn effects(&self) -> Result<crate::EffectInspector, RouterTestError> {
self.expect_content_type(HEMX_CONTENT_TYPE)?;
try_inspect_wire(&self.body).map_err(RouterTestError::Wire)
}
/// Parse a `text/html` response as a complete document.
pub fn html_document(&self) -> Result<crate::HtmlInspector, RouterTestError> {
self.expect_content_type(HTML_CONTENT_TYPE)?;
let source = self.text()?.to_owned();
Ok(inspect_html_document(source))
}
/// Parse a `text/html` response as a fragment.
pub fn html_fragment(&self) -> Result<crate::HtmlInspector, RouterTestError> {
self.expect_content_type(HTML_CONTENT_TYPE)?;
let source = self.text()?.to_owned();
Ok(inspect_html_fragment(source))
}
fn expect_content_type(&self, expected: &'static str) -> Result<(), RouterTestError> {
let actual = self.content_type().map(str::to_owned);
let media_type = actual
.as_deref()
.and_then(|value| value.split(';').next())
.map(str::trim);
if media_type == Some(expected) {
Ok(())
} else {
Err(RouterTestError::ContentType { expected, actual })
}
}
}
/// A request, router, body, content-type, UTF-8, or Hemx wire failure.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RouterTestError {
Request(String),
Router(String),
Body {
limit: usize,
message: String,
},
ContentType {
expected: &'static str,
actual: Option<String>,
},
Utf8(String),
Wire(WireError),
}
impl fmt::Display for RouterTestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Request(message) => {
write!(formatter, "could not build Axum test request: {message}")
}
Self::Router(message) => write!(
formatter,
"Axum router failed to serve test request: {message}"
),
Self::Body { limit, message } => write!(
formatter,
"could not buffer Axum response body within {limit} bytes: {message}"
),
Self::ContentType { expected, actual } => write!(
formatter,
"expected response content type {expected:?}, found {}",
actual.as_deref().unwrap_or("no content type")
),
Self::Utf8(message) => write!(formatter, "response body is not valid UTF-8: {message}"),
Self::Wire(error) => write!(formatter, "invalid Hemx effect response: {error:?}"),
}
}
}
impl Error for RouterTestError {}
+340
View File
@@ -0,0 +1,340 @@
use hemx_core::{GeneratedTarget, Handle, ResourceKind};
use scraper::{Html, Selector};
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::sync::Arc;
/// An owned, parsed HTML document or fragment.
///
/// The underlying parser is intentionally private so application tests do not
/// become coupled to `scraper`'s public types.
pub struct HtmlInspector {
source: Arc<str>,
origin: Arc<str>,
parsed: Html,
}
impl fmt::Debug for HtmlInspector {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HtmlInspector")
.field("origin", &self.origin)
.field("source", &self.source)
.finish_non_exhaustive()
}
}
impl HtmlInspector {
pub(crate) fn document(source: String, origin: String) -> Self {
Self {
parsed: Html::parse_document(&source),
source: source.into(),
origin: origin.into(),
}
}
pub(crate) fn fragment(source: String, origin: String) -> Self {
Self {
parsed: Html::parse_fragment(&source),
source: source.into(),
origin: origin.into(),
}
}
/// Return the original HTML supplied to the inspector.
pub fn source(&self) -> &str {
&self.source
}
/// Describe where the inspected HTML came from.
pub fn origin(&self) -> &str {
&self.origin
}
/// Select elements with a CSS selector and copy their observable structure
/// into an owned result.
pub fn select(&self, selector: &str) -> Result<HtmlSelection, HtmlInspectionError> {
let parsed_selector = Selector::parse(selector).map_err(|error| {
HtmlInspectionError::new(format!(
"invalid CSS selector {selector:?} while inspecting {}: {error:?}",
self.origin
))
})?;
let elements = self
.parsed
.select(&parsed_selector)
.map(|element| HtmlElement {
name: element.value().name().to_owned(),
text: normalize_text(element.text()),
attributes: element
.value()
.attrs()
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect(),
html: element.html(),
})
.collect();
Ok(HtmlSelection {
selector: selector.to_owned(),
origin: Arc::clone(&self.origin),
source: Arc::clone(&self.source),
elements,
})
}
/// Select elements carrying the runtime marker for a generated target.
pub fn select_target(&self, target: impl GeneratedTarget) -> HtmlSelection {
let resource = target.__hemx_resource_id();
self.selection_or_panic(&attribute_selector(
resource_attribute(resource.kind),
&resource.id.to_string(),
))
}
/// Assert that rendered HTML contains a generated target marker.
#[track_caller]
pub fn assert_target(&self, target: impl GeneratedTarget) {
self.select_target(target).assert_exists();
}
/// Select elements carrying the runtime marker for a typed handle.
pub fn select_handle<I>(&self, handle: Handle<I>) -> HtmlSelection {
self.selection_or_panic(&attribute_selector("data-hid", &handle.to_string()))
}
/// Assert that rendered HTML contains a typed handle marker.
#[track_caller]
pub fn assert_handle<I>(&self, handle: Handle<I>) {
self.select_handle(handle).assert_exists();
}
/// Assert that at least one element matches a CSS selector.
#[track_caller]
pub fn assert_exists(&self, selector: &str) {
self.selection_or_panic(selector).assert_exists();
}
/// Assert that exactly `expected` elements match a CSS selector.
#[track_caller]
pub fn assert_count(&self, selector: &str, expected: usize) {
self.selection_or_panic(selector).assert_count(expected);
}
/// Assert that one element matches a selector and has the expected
/// whitespace-normalized text.
#[track_caller]
pub fn assert_text(&self, selector: &str, expected: &str) {
self.selection_or_panic(selector).assert_text(expected);
}
/// Assert that one element matches a selector and has an exact attribute
/// value.
#[track_caller]
pub fn assert_attribute(&self, selector: &str, name: &str, expected: &str) {
self.selection_or_panic(selector)
.assert_attribute(name, expected);
}
#[track_caller]
fn selection_or_panic(&self, selector: &str) -> HtmlSelection {
self.select(selector)
.unwrap_or_else(|error| panic!("{error}"))
}
}
/// An owned set of elements selected from an [`HtmlInspector`].
#[derive(Clone, Debug)]
pub struct HtmlSelection {
selector: String,
origin: Arc<str>,
source: Arc<str>,
elements: Vec<HtmlElement>,
}
impl HtmlSelection {
pub fn selector(&self) -> &str {
&self.selector
}
pub fn len(&self) -> usize {
self.elements.len()
}
pub fn is_empty(&self) -> bool {
self.elements.is_empty()
}
pub fn elements(&self) -> &[HtmlElement] {
&self.elements
}
/// Assert that this selection contains at least one element.
#[track_caller]
pub fn assert_exists(&self) {
assert!(
!self.is_empty(),
"expected at least one element matching {:?} in {}; found none. source: {}",
self.selector,
self.origin,
excerpt(&self.source)
);
}
/// Assert that this selection contains exactly `expected` elements.
#[track_caller]
pub fn assert_count(&self, expected: usize) {
assert_eq!(
self.len(),
expected,
"unexpected match count for selector {:?} in {}. matches: {}. source: {}",
self.selector,
self.origin,
matching_markup(&self.elements),
excerpt(&self.source)
);
}
/// Assert that this selection has one element with exact
/// whitespace-normalized text.
#[track_caller]
pub fn assert_text(&self, expected: &str) {
let element = self.only_element("text");
assert_eq!(
element.text,
expected,
"unexpected text for selector {:?} in {}. element: {}",
self.selector,
self.origin,
excerpt(&element.html)
);
}
/// Assert that this selection has one element with an exact attribute
/// value.
#[track_caller]
pub fn assert_attribute(&self, name: &str, expected: &str) {
let element = self.only_element("an attribute");
let actual = element.attribute(name);
assert_eq!(
actual,
Some(expected),
"unexpected attribute {name:?} for selector {:?} in {}. element: {}",
self.selector,
self.origin,
excerpt(&element.html)
);
}
#[track_caller]
fn only_element(&self, assertion: &str) -> &HtmlElement {
assert_eq!(
self.elements.len(),
1,
"expected exactly one element matching {:?} in {} before asserting {assertion}; found {}. matches: {}. source: {}",
self.selector,
self.origin,
self.elements.len(),
matching_markup(&self.elements),
excerpt(&self.source)
);
&self.elements[0]
}
}
/// Owned observable structure for one selected HTML element.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HtmlElement {
name: String,
text: String,
attributes: BTreeMap<String, String>,
html: String,
}
impl HtmlElement {
pub fn name(&self) -> &str {
&self.name
}
/// Return whitespace-normalized descendant text.
pub fn text(&self) -> &str {
&self.text
}
pub fn attribute(&self, name: &str) -> Option<&str> {
self.attributes.get(name).map(String::as_str)
}
pub fn attributes(&self) -> impl Iterator<Item = (&str, &str)> {
self.attributes
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
}
pub fn html(&self) -> &str {
&self.html
}
}
/// A structural HTML inspection error with an owned diagnostic.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HtmlInspectionError {
message: String,
}
impl HtmlInspectionError {
pub(crate) fn new(message: String) -> Self {
Self { message }
}
}
impl fmt::Display for HtmlInspectionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for HtmlInspectionError {}
fn resource_attribute(kind: ResourceKind) -> &'static str {
match kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
ResourceKind::Handle => "data-hid",
ResourceKind::Form => "data-fid",
}
}
fn attribute_selector(name: &str, value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"[{name}="{escaped}"]"#)
}
fn normalize_text<'a>(text: impl Iterator<Item = &'a str>) -> String {
text.flat_map(str::split_whitespace)
.collect::<Vec<_>>()
.join(" ")
}
fn matching_markup(elements: &[HtmlElement]) -> String {
if elements.is_empty() {
return String::from("none");
}
elements
.iter()
.take(4)
.map(|element| excerpt(&element.html))
.collect::<Vec<_>>()
.join(" | ")
}
fn excerpt(value: &str) -> String {
const LIMIT: usize = 500;
if value.chars().count() <= LIMIT {
return value.to_owned();
}
let mut excerpt = value.chars().take(LIMIT).collect::<String>();
excerpt.push('…');
excerpt
}
+188 -530
View File
@@ -1,73 +1,20 @@
#![doc = include_str!("../README.md")]
#[cfg(feature = "axum")]
pub mod axum;
mod html;
mod process;
pub use html::{HtmlElement, HtmlInspectionError, HtmlInspector, HtmlSelection};
pub use process::{ProcessError, ProcessFailure, TestProcess, TestProcessBuilder};
use hemx_core::{ use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot, Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot, ResourceId, ResourceRef, ScopeKey, Slot,
}; };
use std::io; use std::future::Future;
use std::net::TcpStream;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
/// A child process owned by an integration test and proven ready over TCP.
///
/// The process is killed and reaped on every return path, including panics. Startup failures name
/// the process and address and distinguish early exit from a readiness timeout.
/// req: test/019
pub struct TestProcess {
child: Child,
}
fn test_process_try_wait(child: &mut Child) -> io::Result<Option<ExitStatus>> {
child.try_wait()
}
fn test_process_poll_delay() {
std::thread::sleep(Duration::from_millis(25));
}
impl TestProcess {
pub fn start(
mut command: Command,
label: impl Into<String>,
addr: &str,
timeout: Duration,
) -> io::Result<Self> {
let label = label.into();
let child = command
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| {
io::Error::new(error.kind(), format!("failed to spawn {label}: {error}"))
})?;
let mut process = Self { child };
let deadline = Instant::now() + timeout;
loop {
if TcpStream::connect(addr).is_ok() {
return Ok(process);
}
if let Some(status) = test_process_try_wait(&mut process.child)? {
return Err(io::Error::other(format!(
"{label} exited with {status} before listening on {addr}"
)));
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out after {timeout:?} waiting for {label} to listen on {addr}"),
));
}
test_process_poll_delay();
}
}
}
impl Drop for TestProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Run a synchronous handler with typed input and inspect its effects.
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
where where
F: FnOnce(I) -> R, F: FnOnce(I) -> R,
@@ -76,6 +23,46 @@ where
inspect(handler(input)) inspect(handler(input))
} }
/// Run an asynchronous handler with typed input and inspect its effects.
pub async fn run_async<I, F, HandlerFuture, R>(handler: F, input: I) -> EffectInspector
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = R>,
R: IntoEffect,
{
inspect(handler(input).await)
}
/// Run a fallible synchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects. `IntoEffect`
/// conversion is infallible in the current public contract, so an error from
/// the handler is returned unchanged rather than becoming an empty or
/// success-looking batch.
pub fn run_result<I, F, R, E>(handler: F, input: I) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> Result<R, E>,
R: IntoEffect,
{
handler(input).map(inspect)
}
/// Run a fallible asynchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects; the handler's original
/// error type and value are preserved.
pub async fn run_async_result<I, F, HandlerFuture, R, E>(
handler: F,
input: I,
) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = Result<R, E>>,
R: IntoEffect,
{
handler(input).await.map(inspect)
}
fn inspection_fingerprint() -> BuildFingerprint { fn inspection_fingerprint() -> BuildFingerprint {
BuildFingerprint(0) BuildFingerprint(0)
} }
@@ -84,35 +71,43 @@ pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
inspect_batch(effect.into_batch(inspection_fingerprint())) inspect_batch(effect.into_batch(inspection_fingerprint()))
} }
/// Parse and structurally inspect a complete server-rendered HTML document.
pub fn inspect_html_document(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::document(html.into(), String::from("HTML document"))
}
/// Parse and structurally inspect a server-rendered HTML fragment.
pub fn inspect_html_fragment(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::fragment(html.into(), String::from("HTML fragment"))
}
/// Inspect an already-dispatched batch without matching raw effect variants in tests. /// Inspect an already-dispatched batch without matching raw effect variants in tests.
/// req: test/001 req: dx/006
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector { pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
EffectInspector { batch } EffectInspector { batch }
} }
/// 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
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector { pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
inspect_batch( try_inspect_wire(bytes)
EffectBatch::from_wire(bytes) .unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}"))
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}")), }
)
/// Try to decode and inspect an effect wire response.
pub fn try_inspect_wire(bytes: &[u8]) -> Result<EffectInspector, hemx_core::WireError> {
EffectBatch::from_wire(bytes).map(inspect_batch)
} }
/// 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.
/// req: test/001 req: dx/006
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId { pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
target.__hemx_resource_id() target.__hemx_resource_id()
} }
/// Return the unscoped resource reference behind a generated target for low-level test assertions. /// Return the unscoped resource reference behind a generated target for low-level test assertions.
/// req: test/001 req: dx/006
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef { pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
ResourceRef::unscoped(target_resource(target)) ResourceRef::unscoped(target_resource(target))
} }
/// Build an interaction request body from a generated handle and form fields. /// Build an interaction request body from a generated handle and form fields.
/// req: test/001 req: dx/006
pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String { pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
let mut body = form_pair("__h", &handle.to_string()); let mut body = form_pair("__h", &handle.to_string());
for (name, value) in fields { for (name, value) in fields {
@@ -123,360 +118,10 @@ pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]
} }
/// Build a request body for invalid-handle tests without exposing the wire field name. /// Build a request body for invalid-handle tests without exposing the wire field name.
/// req: test/001 req: dx/006
pub fn unknown_handle_form_body(id: u32) -> String { pub fn unknown_handle_form_body(id: u32) -> String {
form_pair("__h", &id.to_string()) form_pair("__h", &id.to_string())
} }
/// Build a browser-driver selector from a generated handle without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn handle_selector<I>(handle: hemx_core::Handle<I>) -> String {
attr_selector("data-hid", &handle.to_string())
}
/// Build a selector for a clickable button with a generated handle.
/// req: test/001 req: dx/006
pub fn handle_button_selector<I>(handle: hemx_core::Handle<I>) -> String {
format!("button{}", handle_selector(handle))
}
/// Build a selector for a heading in a semantic container without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn heading_selector(scope_selector: &str, level: u8) -> String {
assert!((1..=6).contains(&level), "heading level must be 1..=6");
if scope_selector.is_empty() {
format!("h{level}")
} else {
format!("{scope_selector} h{level}")
}
}
/// Build a selector for article content without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn article_selector() -> &'static str {
"article"
}
/// Build a selector for emphasized/card-title text without spelling document structure.
/// req: test/001 req: dx/006
pub fn strong_text_selector() -> &'static str {
"strong"
}
/// Build a selector for secondary/help text without spelling document structure.
/// req: test/001 req: dx/006
pub fn small_text_selector() -> &'static str {
"small"
}
/// Build a selector for an HTML tag that must be absent when user text is escaped.
/// req: test/001 req: dx/006
pub fn escaped_markup_selector(tag: &str) -> String {
assert!(
tag.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"tag selector must be a simple tag name"
);
tag.to_owned()
}
/// Build a selector for list items without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn list_item_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"li".to_owned()
} else {
format!("{scope_selector} li")
}
}
/// Build a selector for prose text in a semantic container without spelling document structure.
/// req: test/001 req: dx/006
pub fn prose_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"p".to_owned()
} else {
format!("{scope_selector} p")
}
}
/// Build a selector for a form in a semantic container without spelling form structure in examples.
/// req: test/001 req: dx/006
pub fn form_selector(scope_selector: &str) -> String {
format!("{scope_selector} form")
}
/// Build a selector for a form select's options from the authoring field name.
/// req: test/001 req: dx/006
pub fn select_options_selector(field: &str) -> String {
format!("select{} > option", attr_selector("name", field))
}
/// Build a selector for an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_selector(class: &str) -> String {
assert_simple_selector_part(class, "class");
format!(".{class}")
}
/// Build a selector for an element carrying an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn element_class_selector(element: &str, class: &str) -> String {
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!("{element}.{class}")
}
/// Build a selector for classed children inside an app-owned semantic container.
/// req: test/001 req: dx/006
pub fn class_child_selector(parent_class: &str, element: &str, class: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!(".{parent_class} > {element}.{class}")
}
/// Build a selector for an element inside an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_descendant_selector(parent_class: &str, element: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
format!(".{parent_class} {element}")
}
/// Build a selector for disabled action buttons without spelling CSS selector state in examples.
/// req: test/001 req: dx/006
pub fn disabled_button_selector() -> &'static str {
"button[disabled]"
}
/// Build a selector for progressive-enhancement navigation links.
/// req: test/001 req: dx/006
pub fn nav_link_selector(href: &str) -> String {
format!("a{}", attr_selector("href", href))
}
/// Build a selector for page-enhanced navigation links that do not use handler dispatch.
/// req: test/001 req: dx/006
pub fn page_nav_link_selector(href: &str) -> String {
format!(
"{}[data-hemx-nav]:not([data-hemx-handle])",
nav_link_selector(href)
)
}
/// Build a browser-driver selector from a generated target without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn target_selector(target: impl GeneratedTarget) -> String {
let resource = target.__hemx_resource_id();
let attr = runtime_resource_attr(resource.kind);
attr_selector(attr, &resource.id.to_string())
}
/// Assert rendered HTML contains a generated target and report the generated name on failure.
/// req: test/017 req: dx/006
pub fn assert_rendered_target(target: impl GeneratedTarget, generated_name: &str, html: &str) {
let resource = target.__hemx_resource_id();
let marker = runtime_attr_marker(
runtime_resource_attr(resource.kind),
&resource.id.to_string(),
);
assert!(
html.contains(&marker),
"rendered hemx target `{generated_name}` missing runtime marker {marker}"
);
}
/// Assert rendered HTML contains a generated handle and report the generated name on failure.
/// req: test/017 req: dx/006
pub fn assert_rendered_handle<I>(handle: hemx_core::Handle<I>, generated_name: &str, html: &str) {
let marker = runtime_attr_marker("data-hid", &handle.to_string());
assert!(
html.contains(&marker),
"rendered hemx handle `{generated_name}` missing runtime marker {marker}"
);
}
/// Build a selector for an hemx root from its authoring name.
/// req: test/001 req: dx/006
pub fn root_selector(name: &str) -> String {
attr_selector("data-hemx-root", name)
}
/// Build a selector for a specific root element from its authoring name.
/// req: test/001 req: dx/006
pub fn root_element_selector(element: &str, name: &str) -> String {
format!("{}{}", element, root_selector(name))
}
/// Build a selector for the document body without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_body_selector() -> &'static str {
"body"
}
/// Build a selector for the document title without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_title_selector() -> &'static str {
"title"
}
/// Build a selector for the hemx runtime script without exposing its asset path in tests.
/// req: test/001 req: dx/006
pub fn runtime_script_selector() -> &'static str {
"script[src^=\"/hemx.\"][src$=\".js\"]"
}
/// Build a selector for any hemx root without spelling the attribute in tests.
/// req: test/001 req: dx/006
pub fn any_root_selector() -> &'static str {
"[data-hemx-root]"
}
/// Build a selector for a keyed generated row without spelling runtime key metadata.
/// req: test/001 req: dx/006
pub fn keyed_selector(base_selector: &str, key: impl ToString) -> String {
format!(
"{}{}",
base_selector,
attr_selector("data-key", &key.to_string())
)
}
/// Build a selector for all generated keyed rows under a semantic base selector.
/// req: test/001 req: dx/006
pub fn keyed_items_selector(base_selector: &str) -> String {
format!("{base_selector}[data-key]")
}
/// Build a selector for an island from its authoring name.
/// req: test/001 req: dx/006
pub fn island_selector(name: &str) -> String {
attr_selector("data-hemx-island", name)
}
/// Return the island metadata attribute name without spelling it in product tests.
/// req: test/001 req: dx/006
pub fn island_attribute_name() -> &'static str {
"data-hemx-island"
}
/// Return the runtime island event name for an authoring island name.
/// req: test/001 req: dx/006
pub fn island_event_name(name: &str) -> String {
format!("hemx:island-{name}")
}
/// Return an SSE enhancement marker without spelling framework metadata in tests.
/// req: test/001 req: dx/006
pub fn sse_endpoint_marker(path: &str) -> String {
format!("data-hemx-sse=\"{path}\"")
}
/// Return the island snapshot marker without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_snapshot_marker() -> &'static str {
"data-island-snapshot="
}
/// Build a selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_readout_selector() -> &'static str {
"[data-island-readout]"
}
/// Build browser-driver JavaScript for injecting a synthetic island probe.
///
/// This lets product tests exercise the island bridge without spelling hemx island
/// metadata attributes in the test body. req: test/001 req: dx/006
pub fn island_probe_script(
element_id: &str,
island_name: &str,
snapshot: &str,
event_detail: &str,
) -> String {
format!(
r#"
const root = arguments[0];
const islandName = {island_name};
const island = document.createElement('article');
island.id = {element_id};
island.setAttribute('data-hemx-island', islandName);
island.setAttribute('data-island-snapshot', {snapshot});
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 16;
island.appendChild(canvas);
const readout = document.createElement('p');
readout.setAttribute('data-island-readout', '');
readout.textContent = 'waiting';
island.appendChild(readout);
root.appendChild(island);
setTimeout(() => {{
root.dispatchEvent(new CustomEvent('hemx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
}}, 25);
return true;
"#,
element_id = js_string(element_id),
island_name = js_string(island_name),
snapshot = js_string(snapshot),
event_detail = js_string(event_detail),
)
}
/// Build a scoped selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
format!("{scope_selector} {}", island_readout_selector())
}
fn assert_simple_selector_part(value: &str, label: &str) {
assert!(
!value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"{label} selector part must contain one or more ascii alphanumerics or '-'"
);
}
fn runtime_resource_attr(kind: ResourceKind) -> &'static str {
match kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
ResourceKind::Handle => "data-hid",
ResourceKind::Form => "data-fid",
}
}
fn runtime_attr_marker(name: &str, value: &str) -> String {
format!(r#"{name}="{value}""#)
}
fn attr_selector(name: &str, value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"[{name}="{escaped}"]"#)
}
fn js_string(value: &str) -> String {
let mut escaped = String::from("\"");
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
ch => escaped.push(ch),
}
}
escaped.push('"');
escaped
}
fn form_pair(name: &str, value: &str) -> String { fn form_pair(name: &str, value: &str) -> String {
format!("{}={}", form_encode(name), form_encode(value)) format!("{}={}", form_encode(name), form_encode(value))
} }
@@ -529,13 +174,11 @@ impl EffectInspector {
} }
/// Assert against the same generated target object application handlers use. /// Assert against the same generated target object application handlers use.
/// req: test/001 req: dx/006
pub fn has_target(&self, target: impl GeneratedTarget) -> bool { pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
self.has_resource(target.__hemx_resource_id()) self.has_resource(target.__hemx_resource_id())
} }
/// Check that a generated target receives a text update, without matching raw effects. /// Check that a generated target receives a text update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool { pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), "") self.has_text_update_containing(target.__hemx_resource_id(), "")
} }
@@ -544,13 +187,11 @@ impl EffectInspector {
/// ///
/// This associates the payload condition with the intended target, unlike a separate global /// This associates the payload condition with the intended target, unlike a separate global
/// [`Self::payload_contains`] check that can accidentally match another operation. /// [`Self::payload_contains`] check that can accidentally match another operation.
/// req: test/001 req: test/018 req: dx/006
pub fn updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool { pub fn updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), needle) self.has_text_update_containing(target.__hemx_resource_id(), needle)
} }
/// Assert that a generated target receives a text update containing a fragment. /// Assert that a generated target receives a text update containing a fragment.
/// req: test/001 req: test/018 req: dx/006
#[track_caller] #[track_caller]
pub fn assert_updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) { pub fn assert_updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
@@ -565,31 +206,25 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Put { Effect::Patch { target, html, .. }
target, if target.resource == resource && html.as_str().contains(needle)
payload: Payload::Text(text),
} if target.resource == resource && text.contains(needle)
) )
}) })
} }
/// Assert that a generated target receives an HTML update, without matching raw effects. /// Assert that a generated target receives an HTML update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool { pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Put { Effect::Patch { target, .. } | Effect::Insert { target, .. }
target, if target.resource == resource
payload: Payload::Html(_),
} if target.resource == resource
) )
}) })
} }
/// Check that a generated target receives an HTML update containing text. /// Check that a generated target receives an HTML update containing text.
/// req: test/001 req: dx/006
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool { pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_html_update_containing(target.__hemx_resource_id(), needle) self.has_html_update_containing(target.__hemx_resource_id(), needle)
} }
@@ -598,7 +233,6 @@ impl EffectInspector {
/// ///
/// Unlike wrapping [`Self::updates_html_containing`] in `assert!`, failures include the /// Unlike wrapping [`Self::updates_html_containing`] in `assert!`, failures include the
/// expected resource and payload fragment together with every actual effect operation. /// expected resource and payload fragment together with every actual effect operation.
/// req: test/001 req: test/018 req: dx/006
#[track_caller] #[track_caller]
pub fn assert_updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) { pub fn assert_updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
@@ -613,16 +247,84 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Put { Effect::Patch { target, html, .. } | Effect::Insert { target, html, .. }
target, if target.resource == resource && html.as_str().contains(needle)
payload: Payload::Html(html),
} if target.resource == resource && html.contains(needle)
) )
}) })
} }
/// Parse the single HTML effect for a generated target as a complete document.
///
/// Returns an error when no HTML operation targets the resource or when more
/// than one operation would make the selected payload ambiguous.
pub fn target_html_document(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::document(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
/// Parse the single HTML effect for a generated target as a fragment.
///
/// `Put`, `Insert`, and `Prepend` HTML payloads are supported. The parser is
/// kept internal; the returned inspector and all selected elements own their
/// observable data.
pub fn target_html_fragment(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::fragment(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
fn single_target_html(
&self,
resource: ResourceId,
) -> Result<(&str, &'static str), HtmlInspectionError> {
let target_effects = self
.batch
.ops
.iter()
.filter(|effect| op_targets_resource(effect, resource))
.collect::<Vec<_>>();
let html_effects = target_effects
.iter()
.filter_map(|effect| match effect {
Effect::Patch { html, .. } => Some((html.as_str(), "Patch")),
Effect::Insert { html, .. } => Some((html.as_str(), "Insert")),
_ => None,
})
.collect::<Vec<_>>();
match html_effects.as_slice() {
[only] => Ok(*only),
[] => {
let actual = if target_effects.is_empty() {
format!("all effects: {:#?}", self.batch.ops)
} else {
format!("effects for target: {target_effects:#?}")
};
Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found none; {actual}"
)))
}
many => Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found {} and cannot choose a document or fragment payload; effects for target: {target_effects:#?}",
many.len()
))),
}
}
/// Assert that a keyed generated target is replaced with HTML containing text. /// Assert that a keyed generated target is replaced with HTML containing text.
/// req: test/001 req: dx/006
pub fn replaces_keyed_html_containing( pub fn replaces_keyed_html_containing(
&self, &self,
target: impl GeneratedTarget, target: impl GeneratedTarget,
@@ -634,16 +336,13 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Put { Effect::Patch { target, html, .. }
target, if target.resource == resource && target.scope == scope && html.as_str().contains(needle)
payload: Payload::Html(html),
} if target.resource == resource && target.scope == scope && html.contains(needle)
) )
}) })
} }
/// Assert that a keyed generated target appends HTML containing text. /// Assert that a keyed generated target appends HTML containing text.
/// req: test/001 req: dx/006
pub fn inserts_html_containing( pub fn inserts_html_containing(
&self, &self,
target: impl GeneratedTarget, target: impl GeneratedTarget,
@@ -651,52 +350,42 @@ impl EffectInspector {
needle: &str, needle: &str,
) -> bool { ) -> bool {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
let key = key.to_string(); let key_marker = format!("data-hemx-key=\"{}\"", key.to_string());
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Insert { Effect::Insert { target, html, .. }
target, if target.resource == resource
key: actual_key, && html.as_str().contains(&key_marker)
payload: Payload::Html(html), && html.as_str().contains(needle)
} if target.resource == resource && actual_key == &key && html.contains(needle)
) )
}) })
} }
/// Assert that a keyed generated target removes a key. /// Assert that a keyed generated target removes a key.
/// req: test/001 req: dx/006
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool { pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
let key = key.to_string(); let key = key.to_string();
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Remove { Effect::Remove { target }
target, if target.resource == resource && target.scope == Some(ScopeKey::KeyValue(key.clone()))
key: Some(actual_key),
} if target.resource == resource && actual_key == &key
) )
}) })
} }
/// Assert that the batch requests a push navigation to a URL. /// Assert that the batch requests a push navigation to a URL.
/// req: test/001 req: dx/006
pub fn pushes_to(&self, url: &str) -> bool { pub fn pushes_to(&self, url: &str) -> bool {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Navigate { Effect::Visit { url: actual_url, .. } if actual_url == url
url: actual_url,
mode: NavigateMode::Push,
..
} if actual_url == url
) )
}) })
} }
/// Assert that any payload or URL contains text, without matching raw effects. /// Assert that any payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_contains(&self, needle: &str) -> bool { pub fn payload_contains(&self, needle: &str) -> bool {
self.batch self.batch
.ops .ops
@@ -705,7 +394,6 @@ impl EffectInspector {
} }
/// Assert that no payload or URL contains text, without matching raw effects. /// Assert that no payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_excludes(&self, needle: &str) -> bool { pub fn payload_excludes(&self, needle: &str) -> bool {
self.batch self.batch
.ops .ops
@@ -714,13 +402,11 @@ impl EffectInspector {
} }
/// Assert that generated keyed-row metadata for a key is absent from payloads. /// Assert that generated keyed-row metadata for a key is absent from payloads.
/// req: test/001 req: dx/006
pub fn payload_excludes_key(&self, key: impl ToString) -> bool { pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
self.payload_excludes(&format!("data-key=\"{}\"", key.to_string())) self.payload_excludes(&format!("data-key=\"{}\"", key.to_string()))
} }
/// Return HTML for a generated target containing text, without exposing raw payloads. /// Return HTML for a generated target containing text, without exposing raw payloads.
/// req: test/001 req: dx/006
pub fn target_html_containing( pub fn target_html_containing(
&self, &self,
target: impl GeneratedTarget, target: impl GeneratedTarget,
@@ -728,48 +414,38 @@ impl EffectInspector {
) -> Option<&str> { ) -> Option<&str> {
let resource = target.__hemx_resource_id(); let resource = target.__hemx_resource_id();
self.batch.ops.iter().find_map(|op| match op { self.batch.ops.iter().find_map(|op| match op {
Effect::Put { Effect::Patch { target, html, .. }
target, if target.resource == resource && html.as_str().contains(needle) =>
payload: Payload::Html(html), {
} if target.resource == resource && html.contains(needle) => Some(html.as_str()), Some(html.as_str())
Effect::Insert { }
target, Effect::Insert { target, html, .. }
payload: Payload::Html(html), if target.resource == resource && html.as_str().contains(needle) =>
.. {
} if target.resource == resource && html.contains(needle) => Some(html.as_str()), Some(html.as_str())
Effect::Prepend { }
target,
payload: Payload::Html(html),
..
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
_ => None, _ => None,
}) })
} }
/// Assert that a named generated event is emitted with the exact payload. /// Assert that a named generated event is emitted with the exact payload.
/// req: test/001 req: dx/006
pub fn emits(&self, name: &str, payload: &str) -> bool { pub fn emits(&self, name: &str, payload: &str) -> bool {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Emit { Effect::Dispatch { event: actual_name, payload: actual_payload }
name: actual_name, if actual_name == name && actual_payload.as_slice() == payload.as_bytes()
payload: actual_payload,
} if actual_name == name && actual_payload == payload
) )
}) })
} }
/// Assert that a named generated event payload contains text. /// Assert that a named generated event payload contains text.
/// req: test/001 req: dx/006
pub fn emits_containing(&self, name: &str, needle: &str) -> bool { pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
self.batch.ops.iter().any(|op| { self.batch.ops.iter().any(|op| {
matches!( matches!(
op, op,
Effect::Emit { Effect::Dispatch { event: actual_name, payload }
name: actual_name, if actual_name == name && String::from_utf8_lossy(payload).contains(needle)
payload,
} if actual_name == name && payload.contains(needle)
) )
}) })
} }
@@ -796,58 +472,40 @@ impl EffectInspector {
pub fn has_form<T>(&self, form: Form<T>) -> bool { pub fn has_form<T>(&self, form: Form<T>) -> bool {
self.has_resource(form.id()) self.has_resource(form.id())
} }
/// Assert that a generated form is reset/cleared without matching raw events in tests.
/// req: test/001 req: dx/006
pub fn resets_form<T>(&self, form: Form<T>) -> bool {
let form_id = form.id().id.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit { name, payload }
if name == "hemx:form-reset" && payload == &form_id
)
})
}
} }
fn effect_payload_contains(op: &Effect, needle: &str) -> bool { fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
match op { match op {
Effect::Put { payload, .. } Effect::Patch { html, .. } | Effect::Insert { html, .. } => html.as_str().contains(needle),
| Effect::Insert { payload, .. } Effect::Dispatch { payload, .. } => String::from_utf8_lossy(payload).contains(needle),
| Effect::Prepend { payload, .. } => payload_value(payload).contains(needle), Effect::Visit { url, .. } => url.contains(needle),
Effect::Emit { payload, .. } => payload.contains(needle), Effect::Remove { .. }
Effect::Navigate { url, .. } => url.contains(needle), | Effect::Move { .. }
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false, | Effect::Focus { .. }
} | Effect::Scroll { .. } => false,
}
fn payload_value(payload: &Payload) -> &str {
match payload {
Payload::Text(value) | Payload::Html(value) => value,
} }
} }
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool { fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
match op { match op {
Effect::Put { target, .. } Effect::Patch { target, .. }
| Effect::Insert { target, .. } | Effect::Insert { target, .. }
| Effect::Prepend { target, .. } | Effect::Remove { target }
| Effect::Remove { target, .. }
| Effect::Move { target, .. } | Effect::Move { target, .. }
| Effect::Focus { target } => target.resource == resource, | Effect::Focus { target, .. }
Effect::Navigate { .. } | Effect::Emit { .. } => false, | Effect::Scroll { target, .. } => target.resource == resource,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
} }
} }
fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool { fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool {
match op { match op {
Effect::Put { target, .. } Effect::Patch { target, .. }
| Effect::Insert { target, .. } | Effect::Insert { target, .. }
| Effect::Prepend { target, .. } | Effect::Remove { target }
| Effect::Remove { target, .. }
| Effect::Move { target, .. } | Effect::Move { target, .. }
| Effect::Focus { target } => target == wanted, | Effect::Focus { target, .. }
Effect::Navigate { .. } | Effect::Emit { .. } => false, | Effect::Scroll { target, .. } => target == wanted,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
} }
} }
+610
View File
@@ -0,0 +1,610 @@
use std::collections::VecDeque;
use std::error::Error;
use std::ffi::OsStr;
use std::fmt;
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(25);
const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024;
/// Build and start a child process with an explicit readiness contract.
pub struct TestProcessBuilder {
command: Command,
label: String,
readiness: Option<Readiness>,
timeout: Duration,
poll_interval: Duration,
output_limit: usize,
}
impl fmt::Debug for TestProcessBuilder {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestProcessBuilder")
.field("command", &self.command)
.field("label", &self.label)
.field("readiness", &self.readiness)
.field("timeout", &self.timeout)
.field("poll_interval", &self.poll_interval)
.field("output_limit", &self.output_limit)
.finish()
}
}
impl TestProcessBuilder {
/// Create a builder around a real process command.
pub fn new(command: Command) -> Self {
let label = command.get_program().to_string_lossy().into_owned();
Self {
command,
label,
readiness: None,
timeout: DEFAULT_TIMEOUT,
poll_interval: DEFAULT_POLL_INTERVAL,
output_limit: DEFAULT_OUTPUT_LIMIT,
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn arg(mut self, argument: impl AsRef<OsStr>) -> Self {
self.command.arg(argument);
self
}
pub fn args<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.command.args(arguments);
self
}
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.command.env(key, value);
self
}
/// Wait until a TCP connection can be established.
///
/// A listener already occupying the address also satisfies this probe. Use
/// [`Self::http`] when readiness must identify an application endpoint.
pub fn tcp(mut self, address: impl Into<String>) -> Self {
self.readiness = Some(Readiness::Tcp {
address: address.into(),
});
self
}
/// Wait until a plain HTTP endpoint returns a status from 200 through 399.
pub fn http(mut self, address: impl Into<String>, path: impl Into<String>) -> Self {
self.readiness = Some(Readiness::Http {
address: address.into(),
path: path.into(),
});
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
/// Bound retained output per stream. Readers continue draining after the
/// limit is reached so a noisy child cannot deadlock on a full pipe.
pub fn output_limit(mut self, bytes: usize) -> Self {
self.output_limit = bytes;
self
}
/// Spawn the process and wait for its readiness contract.
pub fn start(mut self) -> Result<TestProcess, ProcessError> {
if self.poll_interval.is_zero() {
return Err(ProcessError::configuration(
&self.label,
"poll interval must be greater than zero",
));
}
let readiness = self.readiness.ok_or_else(|| {
ProcessError::configuration(&self.label, "choose TCP or HTTP readiness before start")
})?;
let readiness = readiness.resolve(&self.label)?;
let readiness_description = readiness.description();
self.command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = self.command.spawn().map_err(|source| ProcessError::Spawn {
label: self.label.clone(),
source,
})?;
let stdout = child
.stdout
.take()
.map(|stream| CapturedOutput::spawn(stream, self.output_limit));
let stderr = child
.stderr
.take()
.map(|stream| CapturedOutput::spawn(stream, self.output_limit));
let mut process = TestProcess {
child: Some(child),
label: self.label.clone(),
stdout,
stderr,
exit_status: None,
};
let started = Instant::now();
let deadline = started + self.timeout;
let mut attempts = 0usize;
loop {
attempts += 1;
let remaining = deadline.saturating_duration_since(Instant::now());
let probe_timeout = remaining.min(self.poll_interval);
if readiness.probe(probe_timeout) {
return Ok(process);
}
if let Some(status) = process.try_wait().map_err(|source| ProcessError::Monitor {
label: self.label.clone(),
source,
})? {
process.finish_capture();
return Err(ProcessError::EarlyExit {
status,
failure: Box::new(ProcessFailure {
label: self.label,
readiness: readiness_description,
attempts,
stdout: process.stdout(),
stderr: process.stderr(),
}),
});
}
if Instant::now() >= deadline {
let cleanup_error = process.shutdown().err().map(|error| error.to_string());
return Err(ProcessError::TimedOut {
timeout: self.timeout,
failure: Box::new(ProcessFailure {
label: self.label,
readiness: readiness_description,
attempts,
stdout: process.stdout(),
stderr: process.stderr(),
}),
cleanup_error,
});
}
thread::sleep(self.poll_interval.min(remaining));
}
}
}
/// A real child process owned by an integration test.
///
/// Explicit shutdown and `Drop` are idempotent. Both kill a running child, wait
/// for it, drain captured output, and release the process handle.
pub struct TestProcess {
child: Option<Child>,
label: String,
stdout: Option<CapturedOutput>,
stderr: Option<CapturedOutput>,
exit_status: Option<ExitStatus>,
}
impl fmt::Debug for TestProcess {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestProcess")
.field("label", &self.label)
.field("id", &self.id())
.field("exit_status", &self.exit_status)
.finish_non_exhaustive()
}
}
impl TestProcess {
pub fn builder(command: Command) -> TestProcessBuilder {
TestProcessBuilder::new(command)
}
/// Compatibility entry point for TCP-ready processes.
pub fn start(
command: Command,
label: impl Into<String>,
address: &str,
timeout: Duration,
) -> io::Result<Self> {
Self::builder(command)
.label(label)
.tcp(address)
.timeout(timeout)
.start()
.map_err(ProcessError::into_io)
}
pub fn id(&self) -> Option<u32> {
self.child.as_ref().map(Child::id)
}
pub fn exit_status(&self) -> Option<ExitStatus> {
self.exit_status
}
/// Return the bounded, currently captured standard output.
pub fn stdout(&self) -> String {
self.stdout
.as_ref()
.map(CapturedOutput::snapshot)
.unwrap_or_default()
}
/// Return the bounded, currently captured standard error.
pub fn stderr(&self) -> String {
self.stderr
.as_ref()
.map(CapturedOutput::snapshot)
.unwrap_or_default()
}
pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
if let Some(status) = self.exit_status {
return Ok(Some(status));
}
let Some(child) = self.child.as_mut() else {
return Ok(self.exit_status);
};
let status = child.try_wait()?;
if let Some(status) = status {
self.exit_status = Some(status);
}
Ok(status)
}
/// Kill a running child, wait for it, and drain output. Calling this more
/// than once is harmless.
pub fn shutdown(&mut self) -> io::Result<()> {
if let Some(mut child) = self.child.take() {
let status = match child.try_wait()? {
Some(status) => status,
None => {
if let Err(error) = child.kill() {
if error.kind() != io::ErrorKind::InvalidInput {
return Err(error);
}
}
child.wait()?
}
};
self.exit_status = Some(status);
}
self.finish_capture();
Ok(())
}
fn finish_capture(&mut self) {
if let Some(capture) = self.stdout.as_mut() {
capture.finish();
}
if let Some(capture) = self.stderr.as_mut() {
capture.finish();
}
}
}
impl Drop for TestProcess {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
/// Captured context for an early exit or readiness timeout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProcessFailure {
pub label: String,
pub readiness: String,
pub attempts: usize,
pub stdout: String,
pub stderr: String,
}
/// Failure to configure, start, observe, or clean up a test process.
#[derive(Debug)]
pub enum ProcessError {
Configuration {
label: String,
message: String,
},
Spawn {
label: String,
source: io::Error,
},
Monitor {
label: String,
source: io::Error,
},
EarlyExit {
status: ExitStatus,
failure: Box<ProcessFailure>,
},
TimedOut {
timeout: Duration,
failure: Box<ProcessFailure>,
cleanup_error: Option<String>,
},
}
impl ProcessError {
fn configuration(label: &str, message: &str) -> Self {
Self::Configuration {
label: label.to_owned(),
message: message.to_owned(),
}
}
fn into_io(self) -> io::Error {
let kind = match &self {
Self::Spawn { source, .. } | Self::Monitor { source, .. } => source.kind(),
Self::TimedOut { .. } => io::ErrorKind::TimedOut,
Self::Configuration { .. } => io::ErrorKind::InvalidInput,
Self::EarlyExit { .. } => io::ErrorKind::Other,
};
io::Error::new(kind, self)
}
}
impl fmt::Display for ProcessError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Configuration { label, message } => {
write!(formatter, "invalid readiness for {label}: {message}")
}
Self::Spawn { label, source } => write!(formatter, "failed to spawn {label}: {source}"),
Self::Monitor { label, source } => {
write!(formatter, "failed to observe {label}: {source}")
}
Self::EarlyExit { status, failure } => write!(
formatter,
"{} exited with {status} before {} after {} readiness attempts{}{}",
failure.label,
failure.readiness,
failure.attempts,
output_section("stdout", &failure.stdout),
output_section("stderr", &failure.stderr)
),
Self::TimedOut {
timeout,
failure,
cleanup_error,
} => write!(
formatter,
"timed out after {timeout:?} waiting for {} to satisfy {} after {} readiness attempts{}{}{}",
failure.label,
failure.readiness,
failure.attempts,
output_section("stdout", &failure.stdout),
output_section("stderr", &failure.stderr),
cleanup_error
.as_deref()
.map(|error| format!("\ncleanup error:\n{error}"))
.unwrap_or_default()
),
}
}
}
impl Error for ProcessError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Spawn { source, .. } | Self::Monitor { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Debug)]
enum Readiness {
Tcp { address: String },
Http { address: String, path: String },
}
impl Readiness {
fn resolve(self, label: &str) -> Result<ResolvedReadiness, ProcessError> {
match self {
Self::Tcp { address } => Ok(ResolvedReadiness::Tcp {
socket: resolve_address(label, &address)?,
address,
}),
Self::Http { address, path } => {
if !path.starts_with('/') {
return Err(ProcessError::configuration(
label,
"HTTP readiness path must start with '/'",
));
}
Ok(ResolvedReadiness::Http {
socket: resolve_address(label, &address)?,
address,
path,
})
}
}
}
}
#[derive(Clone, Debug)]
enum ResolvedReadiness {
Tcp {
address: String,
socket: SocketAddr,
},
Http {
address: String,
socket: SocketAddr,
path: String,
},
}
impl ResolvedReadiness {
fn description(&self) -> String {
match self {
Self::Tcp { address, .. } => format!("TCP readiness on {address}"),
Self::Http { address, path, .. } => {
format!("HTTP readiness at http://{address}{path}")
}
}
}
fn probe(&self, timeout: Duration) -> bool {
if timeout.is_zero() {
return false;
}
match self {
Self::Tcp { socket, .. } => TcpStream::connect_timeout(socket, timeout).is_ok(),
Self::Http {
address,
socket,
path,
} => probe_http(*socket, address, path, timeout),
}
}
}
fn resolve_address(label: &str, address: &str) -> Result<SocketAddr, ProcessError> {
address
.to_socket_addrs()
.map_err(|error| {
ProcessError::configuration(label, &format!("could not resolve {address:?}: {error}"))
})?
.next()
.ok_or_else(|| ProcessError::configuration(label, &format!("{address:?} resolved empty")))
}
fn probe_http(socket: SocketAddr, host: &str, path: &str, timeout: Duration) -> bool {
let Ok(mut stream) = TcpStream::connect_timeout(&socket, timeout) else {
return false;
};
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = Vec::with_capacity(128);
while response.len() < 128 && !response.contains(&b'\n') {
let mut chunk = [0_u8; 32];
let Ok(read) = stream.read(&mut chunk) else {
return false;
};
if read == 0 {
break;
}
response.extend_from_slice(&chunk[..read]);
}
let Ok(status_line) = std::str::from_utf8(&response) else {
return false;
};
status_line
.split_whitespace()
.nth(1)
.and_then(|status| status.parse::<u16>().ok())
.is_some_and(|status| (200..400).contains(&status))
}
struct CapturedOutput {
buffer: Arc<Mutex<CapturedBuffer>>,
reader: Option<JoinHandle<()>>,
}
impl CapturedOutput {
fn spawn(mut stream: impl Read + Send + 'static, limit: usize) -> Self {
let buffer = Arc::new(Mutex::new(CapturedBuffer::new(limit)));
let reader_buffer = Arc::clone(&buffer);
let reader = thread::spawn(move || {
let mut chunk = [0_u8; 4096];
loop {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => reader_buffer
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(&chunk[..read]),
}
}
});
Self {
buffer,
reader: Some(reader),
}
}
fn snapshot(&self) -> String {
self.buffer
.lock()
.unwrap_or_else(|error| error.into_inner())
.render()
}
fn finish(&mut self) {
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
struct CapturedBuffer {
bytes: VecDeque<u8>,
limit: usize,
omitted: usize,
}
impl CapturedBuffer {
fn new(limit: usize) -> Self {
Self {
bytes: VecDeque::with_capacity(limit.min(4096)),
limit,
omitted: 0,
}
}
fn push(&mut self, bytes: &[u8]) {
self.bytes.extend(bytes);
while self.bytes.len() > self.limit {
self.bytes.pop_front();
self.omitted += 1;
}
}
fn render(&self) -> String {
let bytes = self.bytes.iter().copied().collect::<Vec<_>>();
let output = String::from_utf8_lossy(&bytes);
if self.omitted == 0 {
output.into_owned()
} else {
format!("<{} earlier bytes omitted>\n{output}", self.omitted)
}
}
}
fn output_section(name: &str, output: &str) -> String {
if output.is_empty() {
String::new()
} else {
format!("\n{name}:\n{output}")
}
}
+182
View File
@@ -0,0 +1,182 @@
#![cfg(feature = "axum")]
use axum::body::Body;
use axum::http::{header, HeaderMap, HeaderValue, Method, Response, StatusCode};
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post};
use axum::Router;
use hemx_axum::{EffectResponse, InteractionForm, HEMX_CONTENT_TYPE};
use hemx_core::{BuildFingerprint, Handle, Slot};
async fn effects() -> EffectResponse {
EffectResponse::new(
Slot::<()>::new(1).text("ready"),
BuildFingerprint::from_parts(&[7]),
)
}
async fn document() -> Html<&'static str> {
Html("<!doctype html><html><body><main id=app>Ready</main></body></html>")
}
async fn fragment() -> Html<&'static str> {
Html("<li data-state=open>Write tests</li>")
}
async fn redirect() -> Redirect {
Redirect::to("/next")
}
async fn rejected() -> impl IntoResponse {
(StatusCode::UNPROCESSABLE_ENTITY, "invalid title")
}
async fn request_header(headers: HeaderMap) -> String {
headers
.get("x-test")
.and_then(|value| value.to_str().ok())
.unwrap_or("missing")
.to_owned()
}
async fn malformed_wire() -> Response<Body> {
Response::builder()
.header(header::CONTENT_TYPE, HEMX_CONTENT_TYPE)
.body(Body::from(vec![0xff, 0x00, 0x01]))
.unwrap()
}
async fn form(form: InteractionForm) -> String {
format!("{}:{}", form.handle_id, form.value("title").unwrap_or(""))
}
fn router() -> Router {
Router::new()
.route("/effects", get(effects))
.route("/document", get(document))
.route("/fragment", get(fragment))
.route("/redirect", get(redirect))
.route("/rejected", get(rejected))
.route("/request-header", get(request_header))
.route("/malformed", get(malformed_wire))
.route("/form", post(form))
}
#[tokio::test]
async fn inspects_effect_response_from_real_router() {
let response = hemx_test::axum::get("/effects")
.send(router())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.content_type(), Some(HEMX_CONTENT_TYPE));
assert!(!response.body().is_empty());
assert!(response
.effects()
.unwrap()
.updates_text_containing(GeneratedSlot, "ready"));
}
#[tokio::test]
async fn preserves_status_headers_redirects_and_text_errors() {
let request_header = hemx_test::axum::request(Method::GET, "/request-header")
.header(
"x-test".parse().unwrap(),
HeaderValue::from_static("request"),
)
.send(router())
.await
.unwrap();
assert_eq!(request_header.text().unwrap(), "request");
let redirect = hemx_test::axum::get("/redirect")
.send(router())
.await
.unwrap();
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
assert_eq!(redirect.headers()[header::LOCATION], "/next");
assert!(redirect.body().is_empty());
let rejected = hemx_test::axum::get("/rejected")
.send(router())
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(rejected.text().unwrap(), "invalid title");
}
#[tokio::test]
async fn structurally_inspects_document_and_fragment_responses() {
let document = hemx_test::axum::get("/document")
.send(router())
.await
.unwrap()
.html_document()
.unwrap();
document.assert_text("main#app", "Ready");
let fragment = hemx_test::axum::get("/fragment")
.send(router())
.await
.unwrap()
.html_fragment()
.unwrap();
fragment.assert_attribute("li", "data-state", "open");
fragment.assert_text("li", "Write tests");
}
#[tokio::test]
async fn builds_urlencoded_interaction_forms_from_typed_handles() {
let response = hemx_test::axum::post("/form")
.form(Handle::<()>::new(17), &[("title", "one & two")])
.send(router())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().unwrap(), "17:one & two");
}
#[tokio::test]
async fn rejects_unexpected_content_types_and_malformed_wire() {
let html = hemx_test::axum::get("/document")
.send(router())
.await
.unwrap();
let wrong_type = html.effects().unwrap_err().to_string();
assert!(wrong_type.contains("application/hemx"), "{wrong_type}");
assert!(wrong_type.contains("text/html"), "{wrong_type}");
let malformed = hemx_test::axum::get("/malformed")
.send(router())
.await
.unwrap()
.effects()
.unwrap_err()
.to_string();
assert!(
malformed.contains("invalid Hemx effect response"),
"{malformed}"
);
}
#[tokio::test]
async fn enforces_explicit_response_body_limit() {
let error = hemx_test::axum::get("/document")
.body_limit(8)
.send(router())
.await
.unwrap_err()
.to_string();
assert!(error.contains("within 8 bytes"), "{error}");
}
#[derive(Clone, Copy)]
struct GeneratedSlot;
impl hemx_core::GeneratedTarget for GeneratedSlot {
fn __hemx_resource_id(self) -> hemx_core::ResourceId {
Slot::<()>::new(1).id()
}
}
-388
View File
@@ -1,388 +0,0 @@
use std::path::Path;
#[test]
fn canonical_examples_do_not_author_browser_javascript() {
// req: examples/005
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.components().any(|part| part.as_os_str() == "tests") {
return;
}
for (line_no, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
failures.push(format!(
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
path.display(),
line_no + 1
));
}
if contains_inline_event_handler(trimmed) {
failures.push(format!(
"{}:{}: inline on*= handler is not allowed",
path.display(),
line_no + 1
));
}
if trimmed.to_ascii_lowercase().contains("javascript:") {
failures.push(format!(
"{}:{}: javascript: URL is not allowed",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn canonical_examples_do_not_author_low_level_resource_plumbing() {
// req: dx/002 req: ceremony/002 req: ceremony/004 req: examples/003
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"ResourceId::new",
".id().id",
"include!(concat!",
"OUT_DIR",
"global_exports(",
"EffectWriter",
"RuntimeOpcode",
"data-hid",
"data-sid",
"data-fid",
"data-aid",
"data-handle-id",
"data-slot-id",
"data-form-id",
"data-atom-id",
"::lower(include_str!",
"lower_html(",
"render_html(",
"HandlerRegistry",
".into_registry()",
"InteractionForm",
"register_handle(",
"SafeHtml",
"hemx::advanced",
"advanced::slots",
"KeyedSlot",
"Slot<",
"Effect::",
"Payload::",
"NavigateMode",
"ScopeKey",
"opcode",
"wire format",
"manual generated",
"RenderSlotExt",
"RenderKeyedSlotExt",
".render(&",
".html(ui::render",
".html(hemx::render",
".html(super::ui::render",
"ui::put(",
"ui::append(",
"ui::replace(",
"application/hemx",
"EffectBatch",
"__h=",
"name=\"__h\"",
"name='__h'",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.components().any(|part| part.as_os_str() == "tests") {
return;
}
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return;
};
if !matches!(ext, "rs" | "heml" | "html") {
return;
}
let runtime_source = if ext == "rs" {
text.split("#[cfg(test)]").next().unwrap_or(text)
} else {
text
};
for (line_no, line) in runtime_source.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example runtime code must use generated resources, not `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn public_prelude_does_not_export_low_level_primitives() {
// req: public_api/005 req: dx/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
let prelude = facade
.split("pub mod prelude {")
.nth(1)
.and_then(|tail| tail.split("\n}").next())
.expect("hemx facade exposes prelude module");
let exported = prelude
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
for token in [
"SafeHtml",
"Effect",
"Slot",
"KeyedSlot",
"ResourceId",
"EffectBatch",
"BuildFingerprint",
"Atom",
"ComponentRef",
"EventName",
"GeneratedTarget",
"Handle",
"ParamName",
"page",
] {
assert!(
!exported.contains(&token),
"hemx::prelude must not export low-level `{token}`"
);
}
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
assert!(
prelude.contains("IntoEffect"),
"hemx::prelude should export IntoEffect"
);
}
#[test]
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
// req: dx/002 req: dx/003 req: examples/003
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"generated handles and slots",
"generated hemx handles",
"generated handle ids",
"numeric handle ids",
"runtime lowering",
"application/hemx responses",
"EffectBatch",
"manual registry",
"HandlerRegistry",
".into_registry()",
"InteractionForm",
"register_handle(",
"lower_html(",
"render_html(",
"SafeHtml",
"KeyedSlot",
"Slot<",
"Effect::",
"opcode",
"wire format",
"manual generated",
"Effect::batch",
"Effect::class",
"Effect::move",
"Effect::set",
"Effect::broadcast",
"Effect::ack",
"SyncEffect::",
"postcard DOM ops",
"data-hid",
"data-sid",
".render(&",
"addEventListener(",
"querySelector",
"querySelectorAll",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|| is_advanced_boundary_doc(text)
{
return;
}
for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
// req: dx/006 req: misc/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"data-hid",
"data-sid",
"data-aid",
"data-fid",
"[data-hid",
"[data-sid",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
return;
}
for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn browser_e2e_does_not_shortcut_product_interactions() {
// req: examples/005
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let browser_e2e = root.join("examples/techdemo/tests/browser_e2e.rs");
let source = std::fs::read_to_string(browser_e2e).unwrap();
assert!(
!source.contains("fetch(\"/\""),
"browser E2E must drive UI, not post directly"
);
assert!(
!source.contains("applyBatch(buffer"),
"browser E2E must not apply wire batches manually"
);
}
#[test]
fn html_examples_slot_targets_dispatch_to_component_put() {
// req: runtime/005 req: examples/001 req: htmx_equivalents/001
// Regression: slots named after a component (e.g. contact_card, editable_row)
// must lower injected partials using that component's handle table, not the
// containing page's handle table.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let template_dir = root.join("examples/html_examples/templates");
let out_dir = std::env::temp_dir().join(format!(
"hemx-test-html-examples-slot-targets-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&out_dir);
std::fs::create_dir_all(&out_dir).unwrap();
let result = std::panic::catch_unwind(|| {
hemx_build::app()
.template_dir(&template_dir)
.out_dir(&out_dir)
.run()
.expect("hemx-build should generate html_examples artifacts");
let generated =
std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default();
for component in ["contact_card", "editable_row"] {
let component_path = format!("super::super::{component}");
let impl_header = format!("impl<T> SlotTarget<T, {component_path}::Component>");
let put_call = format!("{component_path}::put(self.slot, view)");
assert!(
generated.contains(&impl_header),
"gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`"
);
assert!(
generated.contains(&put_call),
"gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`"
);
}
});
let _ = std::fs::remove_dir_all(&out_dir);
result.unwrap();
}
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
scan_examples(&path, visit);
continue;
}
if !is_example_source(&path) {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
visit(&path, &text);
}
}
fn is_advanced_boundary_doc(text: &str) -> bool {
text.contains("advanced/low-level north-star boundary sketch")
}
fn is_example_source(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("rs" | "heml" | "html" | "md")
)
}
fn allowed_example_script(path: &Path, line: &str) -> bool {
// req: examples/005
line.contains(r#"<script +src="self.runtime_src" defer></script>"#)
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|| (path.ends_with("examples/saas/templates/app_shell.heml")
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
}
fn contains_inline_event_handler(line: &str) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
let boundary = bytes[i].is_ascii_whitespace() || bytes[i] == b'<';
if boundary
&& bytes[i + 1] == b'o'
&& bytes[i + 2] == b'n'
&& bytes[i + 3].is_ascii_lowercase()
{
let mut j = i + 4;
while j < bytes.len() && bytes[j].is_ascii_lowercase() {
j += 1;
}
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'=' {
return true;
}
}
i += 1;
}
false
}
+82
View File
@@ -0,0 +1,82 @@
use hemx_core::{Effect, GeneratedTarget, ResourceId, ResourceKind, Slot};
use std::future::Future;
use std::task::{Context, Poll, Waker};
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let mut context = Context::from_waker(Waker::noop());
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::yield_now(),
}
}
}
#[derive(Clone, Copy)]
struct CountTarget;
impl GeneratedTarget for CountTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(ResourceKind::Slot, 1)
}
}
fn sync_handler(value: u32) -> Effect {
Slot::<u32>::new(1).text(value)
}
async fn async_handler(value: u32) -> Effect {
std::future::ready(()).await;
Slot::<u32>::new(1).text(value)
}
#[derive(Debug, Eq, PartialEq)]
struct Rejected {
value: u32,
}
fn fallible_handler(value: u32) -> Result<Effect, Rejected> {
if value == 0 {
Err(Rejected { value })
} else {
Ok(sync_handler(value))
}
}
async fn fallible_async_handler(value: u32) -> Result<Effect, Rejected> {
std::future::ready(()).await;
if value == 0 {
Err(Rejected { value })
} else {
Ok(sync_handler(value))
}
}
#[test]
fn runs_sync_and_async_handlers_into_the_same_effect_inspector() {
let sync = hemx_test::run(sync_handler, 41);
assert!(sync.updates_text_containing(CountTarget, "41"));
let asynchronous = block_on(hemx_test::run_async(async_handler, 42));
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
}
#[test]
fn inspects_successful_fallible_handlers() {
let sync = hemx_test::run_result(fallible_handler, 41).unwrap();
assert!(sync.updates_text_containing(CountTarget, "41"));
let asynchronous = block_on(hemx_test::run_async_result(fallible_async_handler, 42)).unwrap();
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
}
#[test]
fn preserves_concrete_sync_and_async_handler_errors() {
let sync = hemx_test::run_result(fallible_handler, 0).unwrap_err();
assert_eq!(sync, Rejected { value: 0 });
let asynchronous =
block_on(hemx_test::run_async_result(fallible_async_handler, 0)).unwrap_err();
assert_eq!(asynchronous, Rejected { value: 0 });
}
+211
View File
@@ -0,0 +1,211 @@
use hemx_core::{
Effect, GeneratedTarget, InsertPosition, PatchMode, ResourceId, ResourceKind, ResourceRef,
SafeHtml,
};
fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result {
Ok(_) => panic!("operation must panic"),
Err(panic) => panic,
};
if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = panic.downcast_ref::<&str>() {
(*message).to_owned()
} else {
panic!("panic payload was not text")
}
}
#[test]
fn inspects_complete_documents_with_owned_structure() {
let inspected = hemx_test::inspect_html_document(
r#"<!doctype html>
<html lang="en">
<head><title>Todos &amp; notes</title></head>
<body><main><h1> Todos today </h1><p data-state="ready">two &lt; three</p></main></body>
</html>"#,
);
inspected.assert_count("html", 1);
inspected.assert_text("title", "Todos & notes");
inspected.assert_text("main > h1", "Todos today");
inspected.assert_attribute("p[data-state]", "data-state", "ready");
let paragraph = inspected.select("main p").unwrap();
assert_eq!(paragraph.elements()[0].name(), "p");
assert_eq!(paragraph.elements()[0].text(), "two < three");
assert_eq!(
paragraph.elements()[0].attribute("data-state"),
Some("ready")
);
assert!(paragraph.elements()[0]
.attributes()
.any(|attribute| attribute == ("data-state", "ready")));
}
#[test]
fn inspects_generated_targets_and_handles_without_raw_runtime_ids() {
let target = TestTarget(ResourceKind::Slot, 42);
let handle = hemx_core::Handle::<()>::new(7);
let inspected = hemx_test::inspect_html_fragment(
r#"<section data-sid="42"><button data-hid="7">Save</button></section>"#,
);
inspected.assert_target(target);
inspected.assert_handle(handle);
assert_eq!(
inspected.select_target(target).elements()[0].name(),
"section"
);
assert_eq!(inspected.select_handle(handle).elements()[0].text(), "Save");
}
#[test]
fn inspects_fragments_and_keeps_selections_owned() {
let selection = {
let inspected = hemx_test::inspect_html_fragment(
r#"<ul><li class="todo">one</li><li class="todo">two</li></ul>"#,
);
inspected.assert_exists("ul > li.todo");
inspected.assert_count("ul > li.todo", 2);
inspected.select("li.todo").unwrap()
};
assert_eq!(selection.selector(), "li.todo");
assert_eq!(selection.len(), 2);
assert!(!selection.is_empty());
assert_eq!(selection.elements()[0].text(), "one");
assert_eq!(selection.elements()[1].text(), "two");
}
#[test]
fn inspects_generated_target_document_and_fragment_payloads() {
let document_target = TestTarget(ResourceKind::Slot, 1);
let document_effect = Effect::Patch {
target: ResourceRef::unscoped(document_target.__hemx_resource_id()),
mode: PatchMode::Morph,
html: SafeHtml::trusted(
"<!doctype html><html><body><main id=app>ready</main></body></html>",
),
};
let document = hemx_test::inspect(document_effect)
.target_html_document(document_target)
.unwrap();
document.assert_text("main#app", "ready");
assert!(document.origin().contains("Patch HTML effect"));
for (effect, operation) in [
(
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
position: InsertPosition::Last,
html: SafeHtml::trusted("<li data-key=later>later</li>"),
},
"Insert",
),
(
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
position: InsertPosition::First,
html: SafeHtml::trusted("<li data-key=first>first</li>"),
},
"Insert",
),
] {
let fragment = hemx_test::inspect(effect)
.target_html_fragment(TestTarget(ResourceKind::Slot, 2))
.unwrap();
fragment.assert_count("li[data-key]", 1);
assert!(fragment.origin().contains(operation));
}
}
#[test]
fn target_html_errors_explain_missing_non_html_and_ambiguous_effects() {
let missing = hemx_test::inspect(Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9)),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>other</p>"),
})
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(missing.contains("generated target"), "{missing}");
assert!(missing.contains("found none"), "{missing}");
assert!(missing.contains("all effects"), "{missing}");
assert!(missing.contains("other"), "{missing}");
let non_html = hemx_test::inspect(Effect::Focus {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
prevent_scroll: false,
})
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(non_html.contains("found none"), "{non_html}");
assert!(non_html.contains("Focus"), "{non_html}");
let ambiguous = hemx_test::inspect(vec![
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>one</p>"),
},
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
position: InsertPosition::Last,
html: SafeHtml::trusted("<p>two</p>"),
},
])
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(ambiguous.contains("found 2"), "{ambiguous}");
assert!(ambiguous.contains("cannot choose"), "{ambiguous}");
assert!(ambiguous.contains("one"), "{ambiguous}");
assert!(ambiguous.contains("two"), "{ambiguous}");
}
#[test]
fn selector_and_assertion_failures_are_actionable() {
let inspected = hemx_test::inspect_html_fragment(
r#"<section><p class="actual">first</p><p class="actual">second</p></section>"#,
);
let invalid = inspected.select("section[").unwrap_err().to_string();
assert!(invalid.contains("invalid CSS selector"), "{invalid}");
assert!(invalid.contains("section["), "{invalid}");
assert!(invalid.contains("HTML fragment"), "{invalid}");
let missing = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_exists("article.missing"),
)));
assert!(missing.contains("article.missing"), "{missing}");
assert!(missing.contains("found none"), "{missing}");
assert!(missing.contains("class=\"actual\""), "{missing}");
let duplicate = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_text("p.actual", "first"),
)));
assert!(duplicate.contains("exactly one"), "{duplicate}");
assert!(duplicate.contains("found 2"), "{duplicate}");
assert!(duplicate.contains("first"), "{duplicate}");
assert!(duplicate.contains("second"), "{duplicate}");
let wrong_attribute = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_attribute("p:first-child", "class", "expected"),
)));
assert!(wrong_attribute.contains("data") || wrong_attribute.contains("class"));
assert!(wrong_attribute.contains("expected"), "{wrong_attribute}");
assert!(wrong_attribute.contains("actual"), "{wrong_attribute}");
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
+57 -255
View File
@@ -1,8 +1,18 @@
use hemx_core::{ use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode, Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, HistoryMode,
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot, InsertPosition, KeyedSlot, PatchMode, ResourceId, ResourceKind, ResourceRef, SafeHtml,
ScopeKey, Slot,
}; };
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
fn panic_text<T>(result: std::thread::Result<T>) -> String { fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result { let panic = match result {
Ok(_) => panic!("operation must panic"), Ok(_) => panic!("operation must panic"),
@@ -26,24 +36,26 @@ fn inspects_tuple_effects() {
assert!(inspected.has_slot(count)); assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user)); assert!(inspected.has_atom(user));
assert!(inspected.contains(&Effect::Put { assert!(inspected.contains(&Effect::Patch {
target: ResourceRef::unscoped(count.id()), target: ResourceRef::unscoped(count.id()),
payload: Payload::text(42), mode: PatchMode::Morph,
html: SafeHtml::trusted("42"),
})); }));
} }
#[test] #[test]
fn text_update_condition_is_bound_to_the_expected_target() { fn text_update_condition_is_bound_to_the_expected_target() {
// req: test/018
let expected_target = TestTarget(ResourceKind::Slot, 42); let expected_target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(vec![ let inspected = hemx_test::inspect(vec![
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)), target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)),
payload: Payload::Text(String::from("wrong payload")), mode: PatchMode::Morph,
html: SafeHtml::trusted("wrong payload"),
}, },
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)), target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Text(String::from("expected fragment")), mode: PatchMode::Morph,
html: SafeHtml::trusted("expected fragment"),
}, },
]); ]);
@@ -68,11 +80,11 @@ fn text_update_condition_is_bound_to_the_expected_target() {
#[test] #[test]
fn html_update_assertion_reports_expectation_and_actual_effects() { fn html_update_assertion_reports_expectation_and_actual_effects() {
// req: test/018
let target = TestTarget(ResourceKind::Slot, 42); let target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(Effect::Put { let inspected = hemx_test::inspect(Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)), target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Html(String::from("<p>actual</p>")), mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>actual</p>"),
}); });
let panic = std::panic::catch_unwind(|| { let panic = std::panic::catch_unwind(|| {
@@ -101,65 +113,64 @@ fn finds_keyed_slot_targets() {
#[test] #[test]
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() { 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 expected = TestTarget(ResourceKind::Slot, 42);
let other = TestTarget(ResourceKind::Slot, 7); let other = TestTarget(ResourceKind::Slot, 7);
let resource = ResourceId::new(ResourceKind::Slot, 42); let resource = ResourceId::new(ResourceKind::Slot, 42);
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into())); let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
let form = Form::<()>::new(11); let form = Form::<()>::new(11);
let expected_emit = Effect::Emit { let expected_emit = Effect::Dispatch {
name: "saved".into(), event: "saved".into(),
payload: "card 42 saved".into(), payload: b"card 42 saved".to_vec(),
}; };
let inspected = hemx_test::inspect(vec![ let inspected = hemx_test::inspect(vec![
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(other.__hemx_resource_id()), target: ResourceRef::unscoped(other.__hemx_resource_id()),
payload: Payload::Text("decoy needle".into()), mode: PatchMode::Morph,
html: SafeHtml::trusted("decoy needle"),
}, },
Effect::Put { Effect::Patch {
target: ResourceRef::unscoped(resource), target: ResourceRef::unscoped(resource),
payload: Payload::Text("expected text".into()), mode: PatchMode::Morph,
html: SafeHtml::trusted("expected text"),
}, },
Effect::Put { Effect::Patch {
target: keyed_ref.clone(), target: keyed_ref.clone(),
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()), mode: PatchMode::Replace,
html: SafeHtml::trusted("<li data-key=\"row-1\">replacement</li>"),
}, },
Effect::Insert { Effect::Insert {
target: ResourceRef::unscoped(resource), target: ResourceRef::unscoped(resource),
key: "row-2".into(), position: InsertPosition::After(ResourceRef::scoped(
payload: Payload::Html("<li>inserted</li>".into()), resource,
ScopeKey::KeyValue("row-1".into()),
)),
html: SafeHtml::trusted("<li data-hemx-key=\"row-2\">inserted</li>"),
}, },
Effect::Prepend { Effect::Insert {
target: ResourceRef::unscoped(resource), target: ResourceRef::unscoped(resource),
key: "row-0".into(), position: InsertPosition::First,
payload: Payload::Html("<li>prepended</li>".into()), html: SafeHtml::trusted("<li data-hemx-key=\"row-0\">prepended</li>"),
}, },
Effect::Remove { Effect::Remove {
target: ResourceRef::unscoped(resource), target: ResourceRef::scoped(resource, ScopeKey::KeyValue("row-old".into())),
key: Some("row-old".into()),
}, },
Effect::Focus { Effect::Focus {
target: ResourceRef::unscoped(form.id()), target: ResourceRef::unscoped(form.id()),
prevent_scroll: false,
}, },
Effect::Navigate { Effect::Visit {
url: "/cards/42".into(), url: "/cards/42".into(),
mode: NavigateMode::Push, history: HistoryMode::Push,
scroll: hemx_core::ScrollBehavior::Preserve,
title: None,
}, },
expected_emit.clone(), expected_emit.clone(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: form.id().id.to_string(),
},
]); ]);
assert!(!inspected.is_empty()); assert!(!inspected.is_empty());
assert_eq!(inspected.op_count(), 10); assert_eq!(inspected.op_count(), 9);
assert!(inspected.contains(&expected_emit)); assert!(inspected.contains(&expected_emit));
assert!(!inspected.contains(&Effect::Emit { assert!(!inspected.contains(&Effect::Dispatch {
name: "saved".into(), event: "saved".into(),
payload: "wrong".into(), payload: b"wrong".to_vec(),
})); }));
assert!(inspected.has_resource(resource)); assert!(inspected.has_resource(resource));
assert!(inspected.has_resource(other.__hemx_resource_id())); assert!(inspected.has_resource(other.__hemx_resource_id()));
@@ -195,11 +206,11 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
); );
assert_eq!( assert_eq!(
inspected.target_html_containing(expected, "inserted"), inspected.target_html_containing(expected, "inserted"),
Some("<li>inserted</li>") Some("<li data-hemx-key=\"row-2\">inserted</li>")
); );
assert_eq!( assert_eq!(
inspected.target_html_containing(expected, "prepended"), inspected.target_html_containing(expected, "prepended"),
Some("<li>prepended</li>") Some("<li data-hemx-key=\"row-0\">prepended</li>")
); );
assert_eq!(inspected.target_html_containing(expected, "missing"), None); assert_eq!(inspected.target_html_containing(expected, "missing"), None);
assert!(inspected.emits("saved", "card 42 saved")); assert!(inspected.emits("saved", "card 42 saved"));
@@ -219,120 +230,26 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
assert!(!inspected.has_atom(Atom::<()>::new(42))); assert!(!inspected.has_atom(Atom::<()>::new(42)));
assert!(inspected.has_form(form)); assert!(inspected.has_form(form));
assert!(!inspected.has_form(Form::<()>::new(12))); 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()); let empty = hemx_test::inspect(Vec::<Effect>::new());
assert!(empty.is_empty()); assert!(empty.is_empty());
assert_eq!(empty.op_count(), 0); 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] #[test]
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() { fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
let batch = EffectBatch { let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(9), fingerprint: BuildFingerprint(9),
ops: vec![Effect::Emit { ops: vec![Effect::Dispatch {
name: "saved".into(), event: "saved".into(),
payload: "ok".into(), payload: b"ok".to_vec(),
}], }],
}; };
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok")); 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"))); let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
assert!(message.contains("invalid hemx effect wire response: Truncated")); assert!(message.contains("invalid hemx effect wire response: Truncated"));
// req: test/001 test req: wire/009 test
} }
#[test] #[test]
@@ -348,118 +265,3 @@ fn builds_generated_handle_form_bodies() {
); );
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99"); assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
} }
#[test]
fn builds_authoring_boundary_selectors() {
assert_eq!(
hemx_test::root_selector("techdemo"),
r#"[data-hemx-root="techdemo"]"#
);
assert_eq!(
hemx_test::island_selector("orbit"),
r#"[data-hemx-island="orbit"]"#
);
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
assert_eq!(
hemx_test::sse_endpoint_marker("/events"),
r#"data-hemx-sse="/events""#
);
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
assert_eq!(
hemx_test::root_element_selector("main", "docs"),
r#"main[data-hemx-root="docs"]"#
);
assert_eq!(hemx_test::document_body_selector(), "body");
assert_eq!(hemx_test::document_title_selector(), "title");
assert_eq!(
hemx_test::runtime_script_selector(),
r#"script[src^="/hemx."][src$=".js"]"#
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
r#"[data-sid="42"]"#
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 42),
"gallery::search_result",
r#"<li data-sid="42">row</li>"#,
);
assert_eq!(
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
r#"button[data-hid="7"]"#
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(7),
"gallery::search",
r#"<button data-hid="7">Search</button>"#,
);
assert_eq!(hemx_test::article_selector(), "article");
assert_eq!(hemx_test::strong_text_selector(), "strong");
assert_eq!(hemx_test::small_text_selector(), "small");
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
assert_eq!(hemx_test::prose_selector("article"), "article p");
assert_eq!(hemx_test::form_selector("header"), "header form");
assert_eq!(
hemx_test::select_options_selector("column"),
r#"select[name="column"] > option"#
);
assert_eq!(hemx_test::class_selector("lane"), ".lane");
assert_eq!(
hemx_test::element_class_selector("span", "presence"),
"span.presence"
);
assert_eq!(
hemx_test::class_child_selector("columns", "section", "column"),
".columns > section.column"
);
assert_eq!(
hemx_test::class_descendant_selector("impact", "i"),
".impact i"
);
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
assert_eq!(
hemx_test::nav_link_selector("/architecture"),
r#"a[href="/architecture"]"#
);
assert_eq!(
hemx_test::page_nav_link_selector("/docs"),
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
);
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
assert_eq!(
hemx_test::island_readout_selector(),
"[data-island-readout]"
);
assert_eq!(
hemx_test::scoped_island_readout_selector("#probe-island"),
"#probe-island [data-island-readout]"
);
assert_eq!(
hemx_test::keyed_selector(".work-card", 4),
r#".work-card[data-key="4"]"#
);
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script(
"probe-\"island",
"orbit\\bridge",
"line1\nline2\rline3\tend",
"detail \"quoted\"",
);
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
+228 -79
View File
@@ -1,116 +1,265 @@
use hemx_test::TestProcess; use hemx_test::{ProcessError, TestProcess};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[test] fn current_test_command(helper: &str) -> Command {
fn process_harness_reports_early_exit_with_context() {
// req: test/019
let mut command = Command::new(std::env::current_exe().expect("current test executable")); let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command.arg("--exact").arg(helper).arg("--nocapture");
command command
.arg("--exact")
.arg("helper_process_exits_successfully")
.arg("--nocapture");
let error = match TestProcess::start(
command,
"short-lived helper",
"127.0.0.1:9",
Duration::from_secs(2),
) {
Ok(_) => panic!("a process that exits before readiness must fail startup"),
Err(error) => error,
};
let message = error.to_string();
assert!(message.contains("short-lived helper"), "{message}");
assert!(message.contains("127.0.0.1:9"), "{message}");
assert!(message.contains("exited with"), "{message}");
} }
#[test] fn unused_loopback_addr() -> String {
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 reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = reservation.local_addr().unwrap().to_string(); let address = reservation.local_addr().unwrap().to_string();
drop(reservation); drop(reservation);
address
}
let mut command = Command::new(std::env::current_exe().unwrap()); fn wait_until_closed(address: &str) {
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); let deadline = Instant::now() + Duration::from_secs(2);
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline { while TcpStream::connect(address).is_ok() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
assert!( assert!(
TcpStream::connect(&addr).is_err(), TcpStream::connect(address).is_err(),
"drop must reap the helper" "child listener {address} must be closed"
); );
} }
#[test] #[test]
fn process_harness_reports_spawn_and_readiness_timeout_errors() { fn builder_waits_for_delayed_tcp_readiness_and_captures_output() {
// test req: test/019 let address = unused_loopback_addr();
let spawn_error = match TestProcess::start( let process = TestProcess::builder(current_test_command("helper_process_listens"))
Command::new("/definitely/not/a/hemx/executable"), .label("delayed TCP helper")
"missing helper", .env("HEMX_TEST_PROCESS_ADDR", &address)
"127.0.0.1:9", .env("HEMX_TEST_PROCESS_DELAY_MS", "75")
Duration::from_millis(10), .tcp(&address)
) { .timeout(Duration::from_secs(2))
Ok(_) => panic!("spawn failure must be returned, not panic"), .poll_interval(Duration::from_millis(10))
Err(error) => error, .start()
}; .expect("readiness must observe the helper listener");
assert!(spawn_error
.to_string()
.contains("failed to spawn missing helper"));
let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); assert!(process.id().is_some());
let unused_addr = reservation.local_addr().unwrap().to_string(); assert!(TcpStream::connect(&address).is_ok());
drop(reservation); assert!(process.stdout().contains("tcp helper ready"));
let mut command = Command::new(std::env::current_exe().unwrap()); drop(process);
command wait_until_closed(&address);
.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 builder_waits_for_successful_http_readiness() {
let address = unused_loopback_addr();
let process = TestProcess::builder(current_test_command("helper_process_serves_http"))
.label("HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_secs(2))
.poll_interval(Duration::from_millis(10))
.start()
.expect("the second health response is successful");
assert!(process.stdout().contains("http helper ready"));
drop(process);
wait_until_closed(&address);
}
#[test]
fn early_exit_reports_bounded_stdout_and_stderr() {
let error = TestProcess::builder(current_test_command("helper_process_is_noisy"))
.label("noisy helper")
.env("HEMX_TEST_PROCESS_NOISY", "1")
.tcp("127.0.0.1:9")
.output_limit(256)
.timeout(Duration::from_secs(2))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::EarlyExit { .. }));
assert!(message.contains("noisy helper"), "{message}");
assert!(message.contains("exited with"), "{message}");
assert!(message.contains("earlier bytes omitted"), "{message}");
assert!(message.contains("stdout marker"), "{message}");
assert!(message.contains("stderr marker"), "{message}");
assert!(
message.len() < 1_500,
"diagnostic was not bounded: {message}"
);
}
#[test]
fn timeout_reports_readiness_attempts_output_and_cleanup() {
let address = unused_loopback_addr();
let error = TestProcess::builder(current_test_command("helper_process_stays_unready"))
.label("unready HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::TimedOut { .. }));
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("unready HTTP helper"), "{message}");
assert!(message.contains("readiness attempts"), "{message}");
assert!(message.contains("unready helper started"), "{message}");
}
#[test]
fn configuration_spawn_and_occupied_http_fail_honestly() {
let missing_readiness = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("unconfigured helper")
.start()
.unwrap_err();
assert!(matches!(
missing_readiness,
ProcessError::Configuration { .. }
));
let invalid_http = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("invalid HTTP helper")
.http("127.0.0.1:9", "health")
.start()
.unwrap_err();
assert!(invalid_http
.to_string()
.contains("path must start with '/'"));
let spawn = TestProcess::builder(Command::new("/definitely/not/a/hemx/executable"))
.label("missing helper")
.tcp("127.0.0.1:9")
.start()
.unwrap_err();
assert!(matches!(spawn, ProcessError::Spawn { .. }));
assert!(spawn.to_string().contains("failed to spawn missing helper"));
let occupied = TcpListener::bind("127.0.0.1:0").unwrap();
let address = occupied.local_addr().unwrap().to_string();
let occupied_error = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("HTTP ownership helper")
.env("HEMX_TEST_PROCESS_SLEEP", "1")
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
assert!(matches!(occupied_error, ProcessError::TimedOut { .. }));
drop(occupied);
}
#[test]
fn explicit_shutdown_and_drop_after_panic_reap_the_child() {
let address = unused_loopback_addr();
let mut process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &address)
.tcp(&address)
.start()
.unwrap();
process.shutdown().unwrap();
process.shutdown().unwrap();
assert!(process.id().is_none());
assert!(process.exit_status().is_some());
wait_until_closed(&address);
let panic_address = unused_loopback_addr();
let result = std::panic::catch_unwind(|| {
let _process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &panic_address)
.tcp(&panic_address)
.start()
.unwrap();
panic!("exercise panic cleanup");
});
assert!(result.is_err());
wait_until_closed(&panic_address);
}
#[test]
fn compatibility_start_still_waits_for_tcp_and_reaps() {
let address = unused_loopback_addr();
let mut command = current_test_command("helper_process_listens");
command.env("HEMX_TEST_PROCESS_ADDR", &address);
let process = TestProcess::start(
command,
"compatibility helper",
&address,
Duration::from_secs(2),
)
.unwrap();
drop(process);
wait_until_closed(&address);
}
#[test] #[test]
fn helper_process_listens() { fn helper_process_listens() {
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else { let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return; return;
}; };
let _listener = TcpListener::bind(addr).expect("bind helper listener"); if let Ok(delay) = std::env::var("HEMX_TEST_PROCESS_DELAY_MS") {
std::thread::sleep(Duration::from_millis(delay.parse().unwrap()));
}
let _listener = TcpListener::bind(address).expect("bind helper listener");
println!("tcp helper ready");
std::thread::sleep(Duration::from_secs(10)); std::thread::sleep(Duration::from_secs(10));
} }
#[test]
fn helper_process_serves_http() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind HTTP helper listener");
println!("http helper ready");
for status in ["503 Service Unavailable", "204 No Content"] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let read = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /health HTTP/1.1"));
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_stays_unready() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind unready helper listener");
println!("unready helper started");
loop {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
}
#[test]
fn helper_process_is_noisy() {
if std::env::var_os("HEMX_TEST_PROCESS_NOISY").is_none() {
return;
}
println!("{}\nstdout marker", "o".repeat(4_096));
eprintln!("{}\nstderr marker", "e".repeat(4_096));
}
#[test] #[test]
fn helper_process_sleeps() { fn helper_process_sleeps() {
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() { if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
println!("sleeping helper started");
std::thread::sleep(Duration::from_secs(10)); std::thread::sleep(Duration::from_secs(10));
} }
} }
+10 -6
View File
@@ -2,6 +2,13 @@
name = "hemx" name = "hemx"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Checked hypermedia for Rust applications"
keywords = ["hypermedia", "html", "web", "rust"]
categories = ["web-programming"]
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
@@ -12,12 +19,9 @@ harness = false
[features] [features]
default = [] default = []
client = ["dep:hemx-wasm"]
[dependencies] [dependencies]
hemx-core = { path = "../hemx-core" } hemx-core = { version = "0.3.0", path = "../hemx-core" }
hemx-derive = { path = "../hemx-derive" } hemx-derive = { version = "0.3.0", path = "../hemx-derive" }
hemx-wasm = { path = "../hemx-wasm", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] hemplate = { version = "0.2.0", default-features = false }
hemplate = { path = "../../hemplate/hemplate" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
# hemx
Application-facing facade for checked hypermedia with Rust and Hemplate.
Use this crate for handler macros, generated resource APIs, and effect types.
Axum applications normally pair it with `hemx-build` and `hemx-axum`.
## License
MIT
+7 -31
View File
@@ -3,9 +3,7 @@
//! Most application code should depend on this crate, use the proc-macros from //! Most application code should depend on this crate, use the proc-macros from
//! here, and import generated resources through `#[hemx::surface]`. //! here, and import generated resources through `#[hemx::surface]`.
use hemx_core::SafeHtml; use hemx_core::{KeyedSlot, SafeHtml, Slot};
#[cfg(not(target_arch = "wasm32"))]
use hemx_core::{KeyedSlot, Slot};
pub use hemx_core::{ pub use hemx_core::{
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract, navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract,
@@ -16,27 +14,17 @@ pub use hemx_core::{
/// ///
/// These stay addressable for compatibility, but ordinary app code should use generated /// These stay addressable for compatibility, but ordinary app code should use generated
/// helpers instead of naming raw handles, params, events, atoms, or component refs. /// helpers instead of naming raw handles, params, events, atoms, or component refs.
/// req: dx/006 req: public_api/005
#[doc(hidden)] #[doc(hidden)]
pub use hemx_core::{Atom, ComponentRef, EventName, GeneratedTarget, Handle, ParamName}; pub use hemx_core::{Atom, ComponentRef, EventName, GeneratedTarget, Handle, ParamName};
pub use hemx_derive::{app, component, form, handler, surface}; pub use hemx_derive::{app, component, form, handler, surface};
/// Browser/WASM integration used by `#[hemx::handler(client)]`.
///
/// The feature is opt-in so server-first applications do not compile or ship
/// WASM dependencies. req: client_local/001 req: client_local/004
#[cfg(feature = "client")]
#[doc(hidden)]
pub use hemx_wasm as wasm;
/// Advanced/raw hemx primitives used by generated code, integrations, and tests. /// Advanced/raw hemx primitives used by generated code, integrations, and tests.
/// ///
/// Beginner-facing application code should prefer generated targets, generated /// Beginner-facing application code should prefer generated targets, generated
/// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005 /// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition.
pub mod advanced { pub mod advanced {
pub use hemx_core::*; pub use hemx_core::*;
#[cfg(not(target_arch = "wasm32"))]
pub fn render(view: &impl hemplate::Hemplate) -> crate::Html { pub fn render(view: &impl hemplate::Hemplate) -> crate::Html {
crate::render_template(view) crate::render_template(view)
} }
@@ -45,7 +33,7 @@ pub mod advanced {
/// Rendered, checked HTML produced by hemplate/hemx rendering helpers. /// Rendered, checked HTML produced by hemplate/hemx rendering helpers.
/// ///
/// Raw trusted HTML construction remains an advanced boundary; beginner-facing /// Raw trusted HTML construction remains an advanced boundary; beginner-facing
/// code should receive `Html` values from generated render helpers. req: public_api/005 /// code should receive `Html` values from generated render helpers.
#[derive(Clone, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct Html(SafeHtml); pub struct Html(SafeHtml);
@@ -90,14 +78,12 @@ pub mod __private {
} }
} }
#[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).unwrap(); view.render_into(&mut html).unwrap();
__private::html_trusted(html) __private::html_trusted(html)
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn page(view: &impl hemplate::Hemplate) -> Html { pub fn page(view: &impl hemplate::Hemplate) -> Html {
render_template(view) render_template(view)
} }
@@ -106,12 +92,10 @@ pub fn page(view: &impl hemplate::Hemplate) -> Html {
/// ///
/// Generated keyed target helpers use this to keep ordinary handler code at the /// Generated keyed target helpers use this to keep ordinary handler code at the
/// level of `ui::row.replace(row)` instead of `ui::row.replace(row.id, &row)`. /// level of `ui::row.replace(row)` instead of `ui::row.replace(row.id, &row)`.
/// req: canonical_authoring/003 req: codegen/002
pub trait KeyedPartial { pub trait KeyedPartial {
fn hemx_key(&self) -> String; fn hemx_key(&self) -> String;
} }
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)] #[doc(hidden)]
pub fn render_html(view: &impl hemplate::Hemplate) -> Html { pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
render_template(view) render_template(view)
@@ -120,8 +104,7 @@ pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
/// Compatibility shim for raw slot rendering. /// Compatibility shim for raw slot rendering.
/// ///
/// Prefer generated target objects such as `targets::list.put(&view)` so /// Prefer generated target objects such as `targets::list.put(&view)` so
/// generated resource lowering stays attached to the view boundary. req: dx/006 /// generated resource lowering stays attached to the view boundary.
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)] #[doc(hidden)]
pub trait RenderSlotExt { pub trait RenderSlotExt {
fn render(self, view: &impl hemplate::Hemplate) -> Effect; fn render(self, view: &impl hemplate::Hemplate) -> Effect;
@@ -134,7 +117,6 @@ pub trait RenderSlotExt {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl<T> RenderSlotExt for Slot<T> { impl<T> RenderSlotExt for Slot<T> {
fn render(self, view: &impl hemplate::Hemplate) -> Effect { fn render(self, view: &impl hemplate::Hemplate) -> Effect {
self.html(render_template(view)) self.html(render_template(view))
@@ -144,8 +126,7 @@ impl<T> RenderSlotExt for Slot<T> {
/// Compatibility shim for raw keyed slot rendering. /// Compatibility shim for raw keyed slot rendering.
/// ///
/// Prefer generated target objects such as `targets::row.append(&view)` so /// Prefer generated target objects such as `targets::row.append(&view)` so
/// generated resource lowering stays attached to the view boundary. req: dx/006 /// generated resource lowering stays attached to the view boundary.
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)] #[doc(hidden)]
pub trait RenderKeyedSlotExt<K> { pub trait RenderKeyedSlotExt<K> {
fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect; fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect;
@@ -174,7 +155,6 @@ pub trait RenderKeyedSlotExt<K> {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl<K, T> RenderKeyedSlotExt<K> for KeyedSlot<K, T> impl<K, T> RenderKeyedSlotExt<K> for KeyedSlot<K, T>
where where
K: ToString, K: ToString,
@@ -208,7 +188,7 @@ mod tests {
struct InlineView; struct InlineView;
impl hemplate::Hemplate for InlineView { impl hemplate::Hemplate for InlineView {
fn render_into(&self, out: &mut String) -> Result<(), hemplate::error::HemplateError> { fn render_into(&self, out: &mut String) -> Result<(), hemplate::RenderError> {
out.push_str("<strong>ok</strong>"); out.push_str("<strong>ok</strong>");
Ok(()) Ok(())
} }
@@ -217,7 +197,7 @@ mod tests {
struct HintTrackingView(Cell<bool>); struct HintTrackingView(Cell<bool>);
impl hemplate::Hemplate for HintTrackingView { impl hemplate::Hemplate for HintTrackingView {
fn render_into(&self, out: &mut String) -> Result<(), hemplate::error::HemplateError> { fn render_into(&self, out: &mut String) -> Result<(), hemplate::RenderError> {
out.push_str("<strong>hinted</strong>"); out.push_str("<strong>hinted</strong>");
Ok(()) Ok(())
} }
@@ -230,7 +210,6 @@ mod tests {
#[test] #[test]
fn trusted_render_path_uses_the_view_size_hint() { fn trusted_render_path_uses_the_view_size_hint() {
// req: view/003 req: html_safety/002
let view = HintTrackingView(Cell::new(false)); let view = HintTrackingView(Cell::new(false));
assert_eq!(crate::page(&view).as_str(), "<strong>hinted</strong>"); assert_eq!(crate::page(&view).as_str(), "<strong>hinted</strong>");
@@ -239,7 +218,6 @@ mod tests {
#[test] #[test]
fn page_is_the_short_safe_html_helper() { fn page_is_the_short_safe_html_helper() {
// req: dx/006 req: html_safety/002
assert_eq!(crate::page(&InlineView).as_str(), "<strong>ok</strong>"); assert_eq!(crate::page(&InlineView).as_str(), "<strong>ok</strong>");
assert_eq!( assert_eq!(
crate::render_html(&InlineView).as_str(), crate::render_html(&InlineView).as_str(),
@@ -253,7 +231,6 @@ mod tests {
#[test] #[test]
fn prelude_exports_html_for_page_composition() { fn prelude_exports_html_for_page_composition() {
// req: dx/006 req: public_api/005 req: html_safety/002
use crate::prelude::*; use crate::prelude::*;
let html = Html::join([crate::page(&InlineView)]); let html = Html::join([crate::page(&InlineView)]);
@@ -268,6 +245,5 @@ mod tests {
assert_eq!(html.as_ref(), "<p>hello</p>"); assert_eq!(html.as_ref(), "<p>hello</p>");
assert_eq!(html.to_string(), "<p>hello</p>"); assert_eq!(html.to_string(), "<p>hello</p>");
assert_eq!(html.into_string(), "<p>hello</p>"); assert_eq!(html.into_string(), "<p>hello</p>");
// test req: public_api/005
} }
} }
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
cargo test -p hemx-core --test effect_batch canonical_wire_covers_every_closed_variant_and_rejects_truncation -- --exact # spec: kernel/001 check # spec: kernel/002 check
cargo test -p hemx-core --test effect_batch generated_form_helpers_target_form_fields -- --exact # spec: resource/004 check
cargo test -p hemx-core --test effect_batch slot_html_requires_explicit_safe_html -- --exact # spec: kernel/007 check
cargo test -p hemx-core --test effect_batch effect_batch_wire_round_trips -- --exact # spec: kernel/008 check # spec: kernel/011 check
cargo test -p hemx-core --test effect_batch canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes -- --exact # spec: kernel/009 check # spec: kernel/010 check
cargo test -p hemx-core --test effect_batch compatibility_fixture_accepts_only_the_declared_v1_wire_version -- --exact # spec: kernel/012 check
cargo test -p hemx-core --test effect_batch generated_resource_helpers_preserve_target_keys_and_navigation_modes -- --exact # spec: resource/001 check # spec: resource/002 check # spec: resource/003 check
cargo test -p hemx-js --test runtime runtime_executes_every_closed_effect_with_owned_targets -- --exact # spec: kernel/006 check # spec: kernel/013 check # spec: kernel/014 check # spec: kernel/015 check # spec: kernel/016 check
cargo test -p hemx-js --test runtime runtime_applies_batches_in_order_and_stops_on_failure -- --exact # spec: kernel/004 check # spec: kernel/005 check # spec: runtime/005 check
cargo test -p hemx-build public_heml_inspection_entry_points_preserve_paths_and_io_diagnostics --lib # spec: build/001 check
cargo test -p hemx-build generated_contract_fingerprint_is_deterministic --lib # spec: build/002 check
cargo test -p hemx-build no_op_build_preserves_generated_artifact_timestamps --lib # spec: build/003 check
cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check
cargo test -p hemx-derive --test compile_fail handler_macro_reports_unknown_handle_and_bad_shape -- --exact # spec: derive/002 check
cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check
cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check
cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check
cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check
cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check
cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check
cargo test -p hemx-axum --test response effect_and_dispatch_responses_preserve_status_wire_and_diagnostics -- --exact # spec: axum/005 check
cargo test -p hemx-js --test runtime runtime_exposes_debug_api_before_startup_side_effects -- --exact # spec: runtime/002 check
cargo test -p hemx-js --test runtime runtime_posts_urlencoded_forms_by_default -- --exact # spec: runtime/003 check
cargo test -p hemx-js --test runtime runtime_turns_page_get_forms_into_url_state_navigation -- --exact # spec: runtime/004 check
cargo test -p hemx-js --test runtime runtime_targets_generated_resources_not_response_selectors -- --exact # spec: kernel/003 check # spec: runtime/001 check
cargo test -p hemx-js --test runtime runtime_reports_http_failures_without_applying_effects -- --exact # spec: runtime/005 check
cargo test -p hemx-js --test runtime runtime_applies_sse_effect_batches_inside_roots -- --exact # spec: runtime/006 check
cargo test -p hemx-js --test runtime runtime_applies_binary_websocket_effect_batches_inside_roots -- --exact # spec: runtime/006 check
cargo test -p hemx-test --test handlers runs_sync_and_async_handlers_into_the_same_effect_inspector -- --exact # spec: test/001 check
cargo test -p hemx-test --test inspector html_update_assertion_reports_expectation_and_actual_effects -- --exact # spec: test/002 check
cargo test -p hemx-test --test html inspects_complete_documents_with_owned_structure -- --exact # spec: test/003 check
cargo test -p hemx-test --all-features --test axum inspects_effect_response_from_real_router -- --exact # spec: test/004 check
cargo test -p hemx-test --test process builder_waits_for_delayed_tcp_readiness_and_captures_output -- --exact # spec: test/005 check
cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown && ! cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro | grep -Eq 'tree-sitter|hemplate-parser' # spec: wasm/001 check
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "hemx-server-wasm-test"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
hemplate = { version = "0.2.0", default-features = false, features = ["derive"] }
hemx = { path = "../../hemx" }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
+6
View File
@@ -0,0 +1,6 @@
fn main() {
hemx_build::app()
.template_dir("templates")
.run()
.expect("compile server-side Wasm surfaces");
}
+23
View File
@@ -0,0 +1,23 @@
use hemplate::Hemplate;
use hemx::Effect;
#[hemx::surface]
pub mod ui {}
#[derive(Hemplate)]
pub struct Room {
pub count: i32,
}
#[derive(Hemplate)]
pub struct CounterView {
pub count: i32,
}
pub fn render_page(count: i32) -> hemx::Html {
ui::room::page(&Room { count })
}
pub fn update_counter(count: i32) -> Effect {
ui::room::put(ui::room::advanced::slots::counter, &CounterView { count })
}
@@ -0,0 +1 @@
<strong>Count: {+ self.count +}</strong>
+5
View File
@@ -0,0 +1,5 @@
<main data-hemx-root="room">
<section data-hemx-slot="counter">
<strong>Count: {+ self.count +}</strong>
</section>
</main>