Current section
Files
Jump to
Current section
Files
live_interaction_contracts
CUSTOM_APP_CONTRACTS.md
CUSTOM_APP_CONTRACTS.md
# CUSTOM_APP_CONTRACTS — verify your own hooks and third-party widgets
The external-app runner is not limited to this package's reference components. Use
it to prove that an interaction in your already-started LiveView application keeps
the same browser-owned state across a real acknowledged patch.
This guide is for application authors. To add a reference component to this package,
use `BUILDING_COMPONENTS.md` instead.
## Start with ownership, not `phx-update="ignore"`
Choose the smallest reconciliation boundary that matches who creates and updates the
DOM:
| Client responsibility | LiveView boundary | Contract evidence |
|---|---|---|
| Builds the container's children, such as an SVG seat map or chart | `phx-update="ignore"` on that container | selected child `node_identity` remains `same` |
| Writes attributes on otherwise server-owned DOM | `JS.ignore_attributes/1` for only those attributes | exact `attribute` values survive |
| Opens native popover/dialog state while server still owns content | keep the subtree patchable; protect only reflected client-owned attributes when needed | `popover` or complete `dialog` vector plus identity/attributes |
| Only observes server/browser state | no ownership override | probe the state that must survive |
Whole-subtree ignore is not a generic protection helper. It transfers ownership of
all descendants away from LiveView. Use it only when the client really builds and
owns those descendants. See `KERNEL.md` and `CONTRACT_ISLAND.md` for the underlying
rules.
## Build one independent contract
A useful contract has four parts:
1. **Subject** — the patch-relevant interaction boundary.
2. **Activation** — a real click, hover, or deterministic scroll that reaches the
browser state you intend to preserve.
3. **Patch proof** — a click or restricted `Enter`/`Space` press plus a subject-local
attribute whose value changes only when the LiveView patch arrives.
4. **Expectations** — named, reviewable state observed before and after that patch.
Use stable application-owned selectors. Prefer IDs or explicit `data-*` attributes
over styling classes or DOM-position selectors. The acknowledgment must be inside the
subject, but probe targets may be elsewhere when the interaction genuinely spans
elements.
Never use a timer as patch proof. Add a deterministic revision, selected-state, mode,
or other server-rendered attribute whose change acknowledges the exact patch under
test.
## Four proven application patterns
### Client-built SVG or canvas island
Seat maps and canvas builders commonly mount into an empty server-rendered container
and create SVG descendants in a hook. Activate the hook, trigger a sibling patch, and
retain the exact client-created child object:
```json
{
"schema_version": 1,
"id": "seat-map-svg-survives-sibling-patch",
"route": "/e2e/seat-map",
"subject": {"selector": "#seat-map"},
"activate": [{"type": "click", "selector": "#choose-seat"}],
"patch": {
"trigger": {"type": "click", "selector": "#change-mode"},
"ack": {"type": "attribute_change", "selector": "#seat-map", "attribute": "data-mode"}
},
"expectations": [
{"probe": "node_identity", "selector": "#seat-map svg", "after": "same"}
]
}
```
Identity survival proves that the selected DOM object was not replaced. It does not
prove that the island's content is correct or that subtree ownership was assigned
correctly; retain product-specific assertions in the application test suite.
### ScrollSpy or navigation hook
For a hook-owned navigation island, combine identity with the exact hook-written
attribute. Put the patch trigger somewhere that remains genuinely clickable without
changing the scroll position being tested:
```json
{
"probe": "attribute",
"selector": "#section-nav [data-target=\"billing\"]",
"attribute": "aria-current",
"before": {"value": "true"},
"after": {"value": "true"}
}
```
The activation can be `{"type":"scroll","selector":"#billing"}`. The runner uses
real browser scrolling and waits for all declared before-state expectations to agree
in one polling round before taking identity baselines.
### Hover-owned tooltip during a keyboard-triggered patch
Hover the tooltip, then trigger a server patch with a real key press so the pointer
does not move:
```json
{
"activate": [{"type": "hover", "selector": "#rate-tip-trigger"}],
"patch": {
"trigger": {"type": "press", "selector": "#published-filter", "key": "Enter"},
"ack": {"type": "attribute_change", "selector": "main", "attribute": "data-filter-rev"}
},
"expectations": [
{"probe": "popover", "selector": "#rate-tip", "before": {"open": true}, "after": {"open": true}}
]
}
```
Press targets must receive exact focus before keyboard dispatch. The runner does not
fall back to a synthetic click.
### Server-owned subtree with client-written attributes
When LiveView must continue patching descendants, do not wrap the subtree in
`phx-update="ignore"`. Protect only the client-written attributes and assert their
exact values with the `attribute` probe. `null` means the attribute must be absent.
## Run one contract or a directory suite
Single-contract mode preserves the original v1 interface:
```sh
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contract test/interaction_contracts/seat_map.json \
--out tmp/seat-map-result.json
```
Suite mode is a deterministic batch convenience for **independent** contracts:
```sh
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contracts test/interaction_contracts \
--out-dir tmp/interaction-contract-results
```
It reads only first-level regular `*.json` files in lexical filename order. It does
not recurse, accept symlink entries or leaf directories, define dependencies, share
browser contexts, or compose contracts. Ancestor path aliases are canonicalized for
input/output safety. Every contract keeps its own schema-v1 result file. Suite exit
reduction is `error` (3), then `inconclusive` (2), then `fail` (1), otherwise all
pass (0).
Fresh browser contexts isolate cookies, storage, pages, and DOM state. They do not
reset the running application's database, processes, or external side effects.
Contracts must be independently executable, and the caller owns application fixture
setup/reset between contracts when a route mutates shared state.
The runner preflights every contract before launching a browser. Use
`--validate-only` to run that preflight explicitly. Give `--out-dir` a dedicated
generated directory: mapped stale results are invalidated, and an unexpected old
`*.result.json` is rejected rather than silently uploaded as current evidence.
## Keep product assertions in the product
Portable contracts should assert browser and patch mechanics with named probes.
Business workflow, data authorization, visual layout, text, and application-specific
semantics belong in the application's own ExUnit or Playwright tests. Do not turn a
portable contract into arbitrary JavaScript just to replace those tests.
The two layers are complementary:
- declarative contract: did the browser-owned interaction state survive the patch?
- application test: did the user-visible workflow produce the correct business result?