Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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 withAdmission.

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 ConnectedAction:

CaseMeaning
RenderedThe action completed without terminal navigation.
LiveNavigation(navigation)A same-session route replacement is available to follow explicitly.
Redirect(to)A full redirect was emitted and the in-process transport closed.
DisconnectedThe transport closed before the correlated reply arrived.

A LiveNavigation contains a ConnectedNavigation. Inspect its destination and replace values, then call follow 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) 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) to query any retained HTML string, including view.html, the result of awaitHtml, or a disconnected page's body. A RenderedHtml 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:

Source
scala
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)
  )

The counter starts at zero and has no #missing element. CSS queries use jsoup selector syntax:

  • selectOne(css) returns Either[HtmlQueryError, RenderedElement] and requires exactly one match. Zero matches produce NotFound(selector); more than one produces MultipleMatches(selector, count).

  • selectAll(css) returns Either[HtmlQueryError, Vector[RenderedElement]], including Right(Vector.empty) for no matches.

  • Both return HtmlQueryError's InvalidSelector(selector, message) for malformed selectors. Assert on the Either or handle its errors explicitly rather than hiding failures in defaults.

Each RenderedElement exposes read-only tagName, Jsoup-normalized text, and value. attribute(name) 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 RenderedPage returned by DisconnectedRender.run exposes the status and headers through response, the exact body through html, normalized document text through text, and all forms through forms. form 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 RenderedForm exposes its id, action, method, fields, and values. It also reports phx-change, phx-submit, and phx-trigger-action presence. RenderedField 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 LiveView event.

Use FormPath for generated nested names when the application uses a FormCodec. Values remain a Vector because repeated names, such as checkbox groups, are valid.

Submit ordinary forms

RenderedForm.submit 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: followSeeOther accepts only a local 303 See Other with a Location header.

Test reconnect admission

ConnectedClient retains the page's signed bootstrap credentials, so reconnect 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:

Fragment
scala
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 ConnectedJoinFailure:

CaseMeaning
UnauthorizedSigned join authorization or connected mount admission rejected the join.
StaleThe server requires a fresh disconnected render and bootstrap.
DisconnectedThe transport closed while the join was pending.
Redirect(to)Connected mount requested a redirect instead of installing the view.
Transport(error)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:

Fragment
scala
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.