Testing LiveViews
Before You Start
Start with one observable behavior or incident to reproduce, the application routes that expose it, and a decision about whether it belongs to initial HTTP rendering, the connected socket, or the browser.
Choose The Test Boundary
Scalive applications have three useful test boundaries:
Disconnected tests execute the finalized ZIO HTTP routes and inspect the first HTML response. This boundary has public support in
scalive.testing.Connected tests use
ConnectedRenderobject ConnectedRenderJoins LiveViews through production route admission and connection supervision without starting a network server. to run an isolated root or routed application through signed bootstrap, Phoenix transport dispatch, and lifecycle supervision, then interact through a typedConnectedViewclass ConnectedView[-Msg]A semantic handle to one connected root or nested LiveView.. Routed joins also exercise application routes and transport-ownedwithAdmission.Browser tests run the Phoenix LiveView JavaScript client against a real server. Scalive does not currently publish a browser fixture or Playwright library.
Use the narrowest boundary that proves the behavior. Keep most rendering, routing, form, cookie, and initial lifecycle assertions disconnected. Connected tests can cover server-side navigation, transport invalidation, and reconnect admission. Use a real browser when the claim depends on DOM patching, JavaScript hooks, focus, browser history, reconnect timing, or network behavior.
Add Testing Support
Scalive publishes and supports exactly two Scala coordinates:
dev.scalive::scalive, containing all production API, render, runtime, protocol,
and transport classes, and dev.scalive::scalive-testing for optional test
support. Add the latter to the test module that already depends on your
application. Inside this repository, Mill modules use moduleDeps = Seq(...,
scalive.testing). External snapshot consumers use the same snapshot repository
and revision as the application artifact:
def repositories = Seq(
"https://central.sonatype.com/repository/maven-snapshots"
)
def mvnDeps = Seq(
mvn"dev.scalive::scalive-testing:0.0.1-341a561ee032-SNAPSHOT"
)Test Disconnected Rendering
The DisconnectedRender.runobject DisconnectedRenderRuns serverless tests against the first, disconnected HTTP render. method accepts
finalized Routes and a ZIO HTTP Request. It runs the route, consumes the
response body once, restores a replayable body, and parses the HTML with jsoup:
The example uses ZIOSpecDefault for the test runtime, suite to group tests,
and test for an effectful assertion. orDieWith turns an unexpected typed
failure into a test defect with a useful assertion error.
import zio.*
import zio.http.*
import zio.test.*
import scalive.*
import scalive.testing.*
object ProfilePageSpec extends ZIOSpecDefault:
private val config = ZioHttpConfig(
signingSecret = "fixed-test-signing-secret-000000000000",
sessionMaxAge = java.time.Duration.ofMinutes(30),
secureCookie = false,
allowedWebSocketOrigins = Set(WebSocketOrigin.http("localhost"))
).fold(error => throw IllegalArgumentException(error.toString), identity)
private val application = Live.router(Routes.profile -> ProfileLiveView())
private val routes = ZioHttp.routes(application, config)
def spec = suite("ProfilePageSpec")(
test("renders the profile form") {
for
page <- DisconnectedRender.run(routes, Request.get(URL.root))
profileForm <- ZIO
.fromEither(
page.form(
FormQuery(
action = Some("/profiles"),
method = Some(Method.POST)
)
)
)
.orDieWith(error => new AssertionError(error.toString))
yield assertTrue(
page.response.status == Status.Ok,
page.text.contains("Profile"),
profileForm.hasSubmitBinding,
profileForm.values(FormPath("profile", "name")) == Vector("Alice")
)
}
)
end ProfilePageSpecThe names Routes.profile and ProfileLiveView represent application code. Use
a fixed, valid test ZioHttpConfigclass ZioHttpConfigValidated security configuration for the ZIO HTTP transport.
when assertions depend on signed cookies or tokens; do not compare output
produced with independently constructed configurations. Even disconnected tests
must supply a non-empty origin allowlist because it is part of valid transport
configuration.
Query Forms Semantically
The RenderedPageclass RenderedPageA response and semantic view of its Jsoup-parsed HTML. 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]. 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 view 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
Use 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.
when a rendered GET or POST form should execute an ordinary local HTTP route.
Supply the complete ordered FormDataclass FormDataAn ordered browser form payload that preserves duplicate textual fields.,
including the rendered CSRF field for a checked POST:
for
page <- DisconnectedRender.run(liveRoutes, Request.get(loginUrl))
form <- ZIO.fromEither(page.form(FormQuery(method = Some(Method.POST))))
csrf <- ZIO.fromOption(form.values(CsrfProtection.ParamName).headOption)
redirect <- form.submit(
httpRoutes,
FormData(
Vector(
CsrfProtection.ParamName -> csrf,
LoginForm.Email.name -> "ada@example.test"
)
),
submitter = Some(RawFormSubmitter("sign-in", "yes"))
)
dashboard <- redirect.followSeeOther(liveRoutes)
yield assertTrue(dashboard.text.contains("Welcome"))Submission 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.
Cover Connected Behavior
Join An Isolated LiveView
ConnectedRender.joindef join[Msg, Model](liveView: LiveView[Msg, Model]): zio.package.RIO[zio.Scope, testing.ConnectedView[Msg]]
finalizes a single LiveView at /, performs disconnected rendering, validates
its bootstrap credentials, and starts the connected lifecycle through the
production in-process Phoenix transport and supervision:
test("increments after a connected click") {
ZIO.scoped {
for
view <- ConnectedRender.join(CounterLiveView())
_ <- view.clickButton("Increment")
count <- view.text("#count")
yield assertTrue(count == "1")
}
}Use this overload when routing and reconnect admission are not part of the
claim. The name CounterLiveView represents application code.
Open A Routed Application Client
For routed behavior, use
ConnectedRender.opendef open[R](application: LiveApplication[R], config: ZioHttpConfig, request: zio.http.Request, connectParams: Map[String, zio.json.ast.Json] = ...): zio.ZIO[R, Throwable, testing.ConnectedClient[R]]Executes the disconnected route and returns a stateful client that can join and reconnect with
the page's retained bootstrap credentials. Connect parameters are untrusted client JSON; the
harness replaces _mounts with its own progressing join counter.
with a LiveApplication, a fixed validated ZioHttpConfig, and the request
that should render the route. It performs the disconnected request and returns a
ConnectedClientclass ConnectedClient[-R]A routed page bootstrap that can create fresh physical transports for reconnect tests. whose
joindef join: zio.ZIO[&[R, zio.Scope], testing.ConnectedJoinFailure, testing.ConnectedView[Nothing]]Opens the initial physical transport and joins the routed root. and
reconnectdef reconnect: zio.ZIO[&[R, zio.Scope], testing.ConnectedJoinFailure, testing.ConnectedView[Nothing]]Closes any previous transport and rejoins with the retained page credentials. operations
each create a distinct in-process transport:
ZIO.scoped {
for
client <- ConnectedRender.open(
application,
config,
Request.get(URL.decode("/accounts/42").fold(throw _, identity))
)
view <- client.join
title <- view.text("h1")
yield assertTrue(title == "Account 42")
}Here application and its /accounts/42 route are application code. This form
exercises the actual route, mount aspects, layouts, signed bootstrap, request
URL, Phoenix event dispatcher, 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..
That transport-owned boundary is sometimes called physical admission. It
revalidates every root join before installing its connected lifecycle, including
reconnects and followed same-session navigation; reconnect also creates a new
transport identity.
Optional connectParams 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.
Exercise Actions And Navigation
Use view.send(message) when a typed server message is the behavior under test.
Typed messages are an out-of-band test operation because arbitrary Scala values
have no Phoenix wire representation; their resulting lifecycle output still uses
the production transport sink.
Use click, clickButton, changeForm, and submitForm to resolve bindings
from the latest committed HTML and wait for the correlated lifecycle output.
awaitDiff waits for an uncorrelated async or subscription update, and
awaitAction waits for uncorrelated navigation or transport disconnect. The
joinNested(instanceId) method enters a nested lifecycle registered by the parent.
Actions return a ConnectedActionenum ConnectedAction:
| 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.ConnectedAction | A same-session route replacement is available to follow explicitly. |
Redirect(to)enum Redirect(to: zio.http.URL) extends testing.ConnectedAction | 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. |
Wait For An HTML Condition
Use awaitHtmldef 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. for
async work that may produce several intermediate renders. It returns a
Task[String] containing the exact HTML that satisfied the predicate; inspect
that returned snapshot rather than reading view.html again:
import scalive.testing.{HtmlQueryError, RenderedHtml}
for
_ <- view.clickButton("Run report")
matched <- view.awaitHtml("report succeeds", java.time.Duration.ofSeconds(10)) { html =>
RenderedHtml.parse(html).selectOne("[data-report-status]") match
case Right(status) => status.text == "Succeeded"
case Left(HtmlQueryError.NotFound(_) | HtmlQueryError.MultipleMatches(_, _)) =>
false
case Left(error @ HtmlQueryError.InvalidSelector(_, _)) =>
throw IllegalArgumentException(error.toString)
}
yield assertTrue(
RenderedHtml.parse(matched).selectOne("[data-report-status]").map(_.text) == Right("Succeeded")
)The button and selector are application-defined. awaitHtml 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. In this predicate, missing or multiple
status elements mean not ready; an invalid selector fails immediately instead of
being hidden until timeout.
The deadline uses the current ZIO clock. In ZIOSpecDefault this is TestClock:
advance it explicitly for deterministic deadline tests, 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. This is
not browser simulation and does not execute JavaScript or prove DOM patching.
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:
import scalive.testing.{ConnectedRender, HtmlQueryError, RenderedHtml}
for
retained <- ZIO.scoped {
for
view <- ConnectedRender.join(CounterLiveView())
before <- view.html
_ <- view.clickButton("Increment")
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("#count").map(_.text) == Right("0"),
after.selectOne("#count").map(_.text) == Right("1"),
before.selectOne("#missing") == Left(HtmlQueryError.NotFound("#missing")),
after.selectAll("#missing").map(_.isEmpty) == Right(true)
)CounterLiveView represents application code with an initial count of zero and
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 HtmlQueryError'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 existing
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.
Test Typed Form Behavior
Use changeForm with a target and _unused_* markers to verify field-local
feedback. Use submitForm without unused markers to verify that submission
reveals all errors and produces a domain value only when valid. Query the
committed HTML after each action rather than asserting against an independently
decoded form:
for
view <- ConnectedRender.join(ProfileLiveView())
_ <- view.changeForm(
"#profile-form",
Vector(
Profile.Name.name -> "",
"profile[_unused_email]" -> "",
Profile.Email.name -> ""
),
target = Some(Profile.Name.name)
)
changed <- view.html
_ <- view.submitForm(
"#profile-form",
Vector(Profile.Name.name -> "", Profile.Email.name -> "invalid")
)
submitted <- view.html
yield assertTrue(
changed.contains("Name is required"),
!changed.contains("Enter a valid email"),
submitted.contains("Enter a valid email")
)For a definition-owned typed submitter, pass its raw successful-control pair explicitly. The connected harness appends that pair to the ordered payload just as the Phoenix browser serializer does; it does not synthesize optional protocol metadata:
view.submitForm(
"#profile-form",
validFields,
submitter = Some(Profile.Submitter.raw(Profile.Intent.Preview))
)This exercises the same FormData decoder as a real button click. Use a browser test when the claim
also depends on native implicit submission, form association, constraint validation, or JavaScript
loading behavior.
Repeated forms must submit every row's presence control along with its fields.
Exercise add, remove, and reorder controls through the connected view, then
assert stable row keys rather than display indexes. The
repeated contacts example demonstrates
the complete interaction. An isolated ConnectedView is enough for ordinary
change and submit behavior. ConnectedClient.reconnect proves server-side
transport admission and lifecycle behavior, but it does not run Phoenix
JavaScript or replay browser form values. Verify automatic form recovery in a
browser test, or dispatch an explicit recovery payload at
a lower protocol boundary when that protocol behavior is the claim.
A LiveNavigation contains a
ConnectedNavigationclass ConnectedNavigationA same-session route replacement emitted by a connected action..
Inspect its
destinationval destination: zio.http.URL
and replaceval replace: Boolean 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:
for
action <- view.click("[data-open-settings]")
settings <- action match
case ConnectedAction.LiveNavigation(navigation) =>
navigation.follow
case other =>
ZIO.fail(new AssertionError(s"Expected live navigation, got $other"))
heading <- settings.text("h1")
yield assertTrue(heading == "Settings")The selector and destination route in this example belong to the application.
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 reconnect tests do not issue another
disconnected GET. Revoke durable authorization, retire the admitted transport,
wait until the view observes disconnection, and assert that the next transport
is rejected:
val request = Request.get(
URL.decode("/accounts/42?session=test-session").fold(throw _, identity)
)
ZIO.scoped {
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))
}The /accounts/42 route, admittedApplication, authorization.revoke,
sessionId, and the LiveConnections value named connections are application
test setup. The application installs its admission with withAdmission; the
example does not define an application authorization API. Provide the layers
required by admittedApplication around this scoped effect as described in
Services and dependency injection.
Join and follow failures use
ConnectedJoinFailureenum ConnectedJoinFailure:
| 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.ConnectedJoinFailure | Connected mount requested a redirect instead of installing the view. |
Transport(error)enum Transport(error: Throwable) extends testing.ConnectedJoinFailure | 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.
Keep Connected Harness Boundaries Explicit
The in-process transport executes the same transport-owned join, admission,
event, hosted-upload, leave, and navigation state 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.
Test WebSocket Origin Admission
Exercise the finalized 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. Use transport or edge integration tests for duplicate physical headers
and proxy behavior; those shapes are not reliably produced by browser automation.
Test In A Browser
Run the same production-shaped server, browser assets, root layout, security configuration, and socket path that users will receive. A browser smoke suite should prove at least:
the disconnected document contains meaningful content before the socket joins;
the LiveSocket reaches the connected state;
the server admits the page's exact configured HTTP or HTTPS
Origin;one event updates the existing DOM;
live patch or navigation preserves the expected URL and title;
hooks and uploads work in a real browser when the application uses them;
controls expose semantic elements, visible labels, and accessible names;
keyboard-only interaction follows the intended focus order and visibly shows focus;
validation, status, and other live feedback is announced when appropriate, with only intentional focus moves such as focusing an error summary; and
a deliberately interrupted WebSocket exercises the application's reconnect expectations.
At the transport request boundary, separately verify that missing, null,
malformed, duplicate, combined, and mismatched Origin values receive HTTP 403
before upgrade. Browser automation cannot normally manufacture all of those
forbidden header shapes, so keep these as edge or transport integration tests.
The Scalive repository runs the upstream Phoenix LiveView v1.2.10 Playwright
suite against e2eApp with:
./scripts/e2e-run-upstream.shChanges to the runtime, protocol, transport, or synchronized fixtures can use
./scripts/e2e-run-upstream-strict.sh to require three complete consecutive
runs with retries disabled.
These scripts, their test/playwright.upstream.config.js, and the repository's site
Playwright suites are project regression infrastructure. They are evidence for
the compatibility matrix, not a distributed browser-testing API for Scalive
applications. Application teams should own selectors, fixtures, server startup,
and assertions for their product.
Know What Each Test Proves
A passing disconnected test does not prove a socket can join. A passing
ConnectedRender test proves the server-side transport session but neither
WebSocket Origin admission nor that the Phoenix client can patch the browser DOM.
A browser test proves its scenario but may not isolate the failing lifecycle
stage. Keep at least one assertion at each boundary your application depends on, and use
Troubleshooting to locate a failure
before expanding the test suite.
Related Tasks
Prepare the production-shaped browser assets with Client setup and static assets.
Locate the failing lifecycle stage with Troubleshooting.
Supply deterministic dependencies with Services and dependency injection.