Connected test reference
Before you start
This page is the reference for scalive.testing. Read
Testing LiveViews first; it shows the common tests
and when to use each boundary.
What the connected harness runs
ConnectedRender.join and ConnectedRender.open perform the disconnected HTTP
render, validate the page's signed bootstrap credentials, and start the
connected lifecycle through the production in-process Phoenix transport and
supervision. ConnectedRender.open also runs the application's routes, mount
aspects, layouts, request URL, and any root admission installed with
withAdmissiondef withAdmission[R1, Claims, Out, Result, Id](aspect: LiveSessionMountAspect[R1, Ctx, Claims, Out])(connectionId: Claims => Id)(using append: ContextAppend.Aux[Ctx, Out, Result], connections: zio.package.Tag[LiveConnections[Id]]): LiveSessionBuilder.Admitted[&[&[R, R1], LiveConnections[Id]], Result]Adds the session's single active-connection admission boundary..
The in-process transport executes the same join, admission, event,
hosted-upload, leave, and navigation handling as the WebSocket adapter. The
upload helper supports hosted uploads only, not external uploaders. The harness
does not call the WebSocket endpoint, serialize network frames, or validate the
upgrade request's Origin header.
ConnectedView.html is a semantic projection of the latest committed server
render, not a browser DOM. Connected tests do not execute the Phoenix JavaScript
client, patch a real document, run hooks, manage focus, schedule browser
reconnect attempts, or prove browser history behavior. Keep those claims at the
browser boundary.
Connected actions
click, clickButton, changeForm, and submitForm resolve bindings from the
latest committed HTML and wait for the correlated lifecycle output.
send(message) delivers a typed message out of band, because arbitrary Scala
values have no Phoenix wire representation; its resulting lifecycle output still
uses the production transport sink. awaitDiff waits for an uncorrelated async
or subscription update, and awaitAction waits for uncorrelated navigation or
transport disconnect. joinNested(instanceId) enters a nested lifecycle
registered by the parent.
Actions return a ConnectedActionenum ConnectedActionThe semantic result of one connected browser or server-message action.:
| Case | Meaning |
|---|---|
Renderedval Rendered: testing.ConnectedActionThe action completed without terminal navigation. | The action completed without terminal navigation. |
LiveNavigation(navigation)enum LiveNavigation(navigation: testing.ConnectedNavigation) extends testing.ConnectedActionThe action requested a same-session route replacement that may be followed explicitly. | A same-session route replacement is available to follow explicitly. |
Redirect(to)enum Redirect(to: zio.http.URL) extends testing.ConnectedActionThe action requested a full redirect and closed the test transport. | A full redirect was emitted and the in-process transport closed. |
Disconnectedval Disconnected: testing.ConnectedActionThe physical transport closed before the action received a reply. | The transport closed before the correlated reply arrived. |
A LiveNavigation contains a
ConnectedNavigationclass ConnectedNavigationA same-session route replacement emitted by a connected action..
Inspect its
destinationval destination: zio.http.URLThe URL to navigate to.
and replaceval replace: BooleanWhether the navigation replaces the current browser-history entry instead of adding one. values,
then call
followdef follow: zio.package.IO[testing.ConnectedJoinFailure, testing.ConnectedView[Nothing]]Replaces the root through the production redirect-join admission path. to execute
the production redirect-join admission path.
Optional connectParams passed to ConnectedRender.open are untrusted client
JSON, not authenticated session or request data. The harness owns _mounts,
overwrites any supplied value, starts it at 0, and advances it for each root
reconnect or followed navigation. Applications should not treat Phoenix-owned
keys as a stable contract.
Wait for an HTML condition
awaitHtml(description, timeout)(predicate)def awaitHtml(description: String, timeout: java.time.Duration)(predicate: String => Boolean): zio.package.Task[String]Returns the first observed HTML satisfying predicate, checking the current snapshot before
waiting for uncorrelated output. One overall deadline bounds the wait; a timeout reports the
description and last observed HTML. The deadline uses the current ZIO clock. Predicate
exceptions and view closure fail the task.
returns a Task[String] containing the exact HTML that satisfied the
predicate; inspect that returned snapshot rather than reading view.html again.
It checks current HTML first, then rechecks after uncorrelated async diff
notifications. One overall deadline covers the wait: intermediate diffs do not
restart it, and it is not limited by awaitDiff's five-second timeout. A
java.util.concurrent.TimeoutException includes the description, requested
duration, and last observed HTML. Predicate exceptions fail the task. Keep the
predicate quick and nonblocking. Retirement or disconnection fails the wait; it
never follows navigation to another view. When the predicate queries the HTML,
treat a missing element as "not ready" but let an invalid selector fail
immediately instead of hiding it until the timeout.
The deadline uses the current ZIO clock. In ZIOSpecDefault this is TestClock:
advance it explicitly for deterministic deadline tests, apply
TestAspect.withLiveClock to the suite, or wrap just the wait in
zio.test.Live.live(view.awaitHtml(description, timeout)(predicate)) for a
wall-clock timeout without changing the clock used by application work already
running.
Only one waiter may consume a view's diff queue: do not run awaitHtml and
awaitDiff concurrently, or multiple awaitHtml calls on the same view. Finish
correlated click, send, and form actions before waiting. Each check observes the
latest semantic server projection, so transient states can be missed.
Inspect retained HTML snapshots
Use RenderedHtml.parse(html)def parse(html: String): testing.RenderedHtmlParses an HTML document or ordinary body content while retaining the exact input string.
to query any retained HTML string, including view.html, the result of awaitHtml,
or a disconnected page's body. A
RenderedHtmlclass RenderedHtmlAn immutable semantic snapshot parsed from retained HTML. keeps the exact
input in html, not a reserialized document; text is normalized whole-document
text. Parse old strings directly rather than querying the current live view:
for
retained <- ZIO.scoped {
for
view <- ConnectedRender.join(CounterLiveView())
before <- view.html
_ <- view.clickButton("Increase")
after <- view.html
yield (before, after)
}
yield
// The connected view is already closed; both strings remain queryable.
val before = RenderedHtml.parse(retained._1)
val after = RenderedHtml.parse(retained._2)
assertTrue(
before.html == retained._1,
before.selectOne("output").map(_.text) == Right("0"),
after.selectOne("output").map(_.text) == Right("1"),
before.selectOne("#missing") == Left(HtmlQueryError.NotFound("#missing")),
after.selectAll("#missing").map(_.isEmpty) == Right(true)
)View source (documentation/fixtures/quick-start/test/src/quickstart/CounterSnapshotSpec.scala:12-31)
The counter starts at zero and has no #missing element. CSS queries use jsoup
selector syntax:
selectOne(css)def selectOne(selector: String): Either[testing.HtmlQueryError, testing.RenderedElement]Selects exactly one element, reporting missing or ambiguous matches explicitly. returnsEither[HtmlQueryError, RenderedElement]and requires exactly one match. Zero matches produceNotFound(selector); more than one producesMultipleMatches(selector, count).selectAll(css)def selectAll(selector: String): Either[testing.HtmlQueryError, Vector[testing.RenderedElement]]Selects all matching elements in Jsoup document order; no matches produce an empty vector. returnsEither[HtmlQueryError, Vector[RenderedElement]], includingRight(Vector.empty)for no matches.Both return
HtmlQueryErrorenum HtmlQueryErrorDescribes why a CSS query could not select rendered elements.'sInvalidSelector(selector, message)for malformed selectors. Assert on theEitheror handle its errors explicitly rather than hiding failures in defaults.
Each RenderedElementclass RenderedElementImmutable values captured from one selected rendered element. exposes
read-only tagName, Jsoup-normalized text, and value.
attribute(name)def attribute(name: String): Option[String]Returns a decoded, unresolved attribute value with a case-insensitive HTML name lookup.
Preserves absent versus present-empty and does not synthesize absolute URL attributes. is
case-insensitive and returns entity-decoded values: None means absent, while
Some("") means present but empty. Relative URL attributes stay relative; lookup
does not resolve them against a base URL. value follows the
RenderedField semantics: textarea text has outer whitespace trimmed but retains
internal whitespace; other elements return their own value attribute or ""
if absent. It does not derive a select's value from selected options or supply
a checkbox's browser-default "on" value.
Snapshots and query results are immutable and remain valid after later renders, navigation, or harness closure. Every query uses an isolated working document, so selector evaluation cannot change other queries or retained results. These are semantic server-HTML assertions only: they do not execute JavaScript, simulate successful-control selection, or prove browser DOM patching.
Query rendered pages and forms
The RenderedPageclass RenderedPageA response and semantic view of its Jsoup-parsed HTML. returned by
DisconnectedRender.run exposes the status and headers through
responseval response: zio.http.ResponseThe route response with its body replaced by a replayable body containing html., the exact body
through htmlval html: StringThe complete decoded response body before Jsoup parsing., normalized
document text through textdef text: StringReturns the parsed document's decoded, combined text with whitespace normalized by Jsoup.,
and all forms through
formsdef forms: Vector[testing.RenderedForm]Returns every parsed form element in DOM order..
formdef form(query: testing.FormQuery = ...): Either[testing.FormQueryError, testing.RenderedForm]Selects exactly one form matching query. succeeds only when
exactly one form matches its optional action and method filters. Handle
NotFound and MultipleMatches instead of silently selecting the first form.
The RenderedFormclass RenderedFormA semantic view of one form in a parsed disconnected-render snapshot. exposes its
iddef id: Option[String]Returns the parsed id attribute, preserving absent versus present-empty.,
actiondef action: Option[String]Returns the parsed, unresolved action attribute.,
methoddef method: zio.http.MethodReturns POST for a case-insensitive post attribute and GET otherwise.,
fieldsdef fields: Vector[testing.RenderedField]Returns named descendant controls in DOM order., and
valuesdef values(name: String): Vector[String]Returns the values of the named fields, in DOM order.. It also reports
phx-changedef hasChangeBinding: BooleanReports whether the form has a phx-change attribute.,
phx-submitdef hasSubmitBinding: BooleanReports whether the form has a phx-submit attribute., and
phx-trigger-actiondef triggersAction: BooleanReports whether the form has a phx-trigger-action attribute. presence.
RenderedFieldclass RenderedFieldA named button, input, select, or textarea in a parsed form snapshot. exposes tag
name, id, name, value, input type, and required state. These are HTML queries;
they do not automatically choose submitted controls or dispatch a
LiveViewtrait LiveView[Msg, Model]Defines a server-rendered page with typed messages and model state. event.
Use FormPathclass FormPathA structured form field path rendered with browser bracket notation. for generated nested names when the application uses a
FormCodectrait FormCodec[A]Explicit low-level decoding for custom controls and transport adapters.. Values remain a Vector because repeated names, such as checkbox
groups, are valid.
Submit ordinary forms
RenderedForm.submitdef submit[R](routes: zio.http.Routes[R, Nothing], data: FormData, submitter: Option[RawFormSubmitter] = ...): zio.ZIO[R, Throwable, testing.RenderedPage]Submits explicit ordered fields to this form's local ordinary HTTP action.
retains duplicate field names and appends the optional submitter. GET fields
replace the action query. POST fields become an
application/x-www-form-urlencoded body while the action query is retained.
Other POST encodings fail explicitly. Relative actions honor the document's
first base[href]; same-origin absolute actions are accepted, while
cross-origin actions fail because the serverless harness executes only the
supplied routes.
Cookies returned by one response are carried by name into the next request, and
zero-Max-Age cookies are removed. This intentionally supports Scalive's
root-scoped test flows rather than simulating browser domain, path, Secure, or
SameSite policy. Redirects remain explicit:
followSeeOtherdef followSeeOther[R](routes: zio.http.Routes[R, Nothing]): zio.ZIO[R, Throwable, testing.RenderedPage]Follows this response's local 303 See Other location with a GET request.
accepts only a local 303 See Other with a Location header.
Test reconnect admission
ConnectedClientclass ConnectedClient[-R]A routed page bootstrap that can create fresh physical transports for reconnect tests. retains
the page's signed bootstrap credentials, so
reconnectdef reconnect: zio.ZIO[&[R, zio.Scope], testing.ConnectedJoinFailure, testing.ConnectedView[Nothing]]Closes any previous transport and rejoins with the retained page credentials. does not
issue another disconnected GET. Each join and reconnect creates a distinct
in-process transport, and admission installed with withAdmission runs again
for each one, as it does for followed same-session navigation.
To prove that revocation takes effect, revoke durable authorization, retire the admitted transport, wait until the view observes disconnection, and assert that the next transport is rejected:
for
client <- ConnectedRender.open(admittedApplication, config, request)
view <- client.join
_ <- authorization.revoke(sessionId)
_ <- connections.disconnect(sessionId)
_ <- view.awaitDisconnected
result <- client.reconnect.either
yield assertTrue(result == Left(ConnectedJoinFailure.Unauthorized))admittedApplication, authorization.revoke, sessionId, and the
LiveConnections value named connections are application test setup; the
authentication guide
shows the admission they exercise. Run the effect inside ZIO.scoped and
provide the layers the application requires.
Join and follow failures use
ConnectedJoinFailureenum ConnectedJoinFailureA protocol-visible reason why a routed connected join did not install a LiveView.:
| Case | Meaning |
|---|---|
Unauthorizedval Unauthorized: testing.ConnectedJoinFailureThe signed join or connected mount authorization was rejected. | Signed join authorization or connected mount admission rejected the join. |
Staleval Stale: testing.ConnectedJoinFailureThe server requires a fresh disconnected render. | The server requires a fresh disconnected render and bootstrap. |
Disconnectedval Disconnected: testing.ConnectedJoinFailureThe physical transport closed while the join was pending. | The transport closed while the join was pending. |
Redirect(to)enum Redirect(to: zio.http.URL) extends testing.ConnectedJoinFailureConnected mount requested a redirect instead of installing a view. | Connected mount requested a redirect instead of installing the view. |
Transport(error)enum Transport(error: Throwable) extends testing.ConnectedJoinFailureThe in-process transport failed outside a protocol-visible join outcome. | The in-process transport failed outside a protocol-visible join result. |
This proves server-side admission is rerun for a fresh transport. It does not prove when or how often a browser retries after network loss.
Test WebSocket origin admission
Exercise the finished ZIO HTTP routes when the upgrade policy itself is the behavior under test. This boundary can prove that one configured origin produces an upgrade response and a missing origin receives HTTP 403 without starting a server or browser:
val socketUrl = URL.decode("/live/websocket").fold(throw _, identity)
val allowedUpgrade = Request
.get(socketUrl)
.addHeader(Header.Custom("origin", "http://localhost"))
test("admits only a configured websocket origin") {
for
admitted <- ZIO.scoped(routes.runZIO(allowedUpgrade))
rejected <- ZIO.scoped(routes.runZIO(Request.get(socketUrl)))
yield assertTrue(
admitted.status == Status.SwitchingProtocols,
rejected.status == Status.Forbidden
)
}The request origin must match the allowedWebSocketOrigins used to construct
routes. Test the other rejected header shapes that
origin admission lists in the same
way. Browser automation cannot normally manufacture them, so keep them as
transport or edge integration tests, along with proxy behavior.
Related tasks
Start with the common tests in Testing LiveViews.
Bind physical connections to application sessions with Authentication and sessions.
Configure allowed WebSocket origins with Configuration.