Docs

Browser end-to-end tests

@nifrajs/e2e serves your app on an ephemeral port, drives it through a browser, and gives you typed paths, locators and retrying assertions. On top it adds goal-driven agent steps and a replay cache, so a test can say what it wants in words and still run model-free after the first pass. It is a library: use bun:test, Vitest or anything that runs async functions.

A first test

TS
import { test } from "bun:test"
import { server } from "@nifrajs/core/server"
import { e2e, expect } from "@nifrajs/e2e"

const html = (body: string) => new Response(body, { headers: { "content-type": "text/html" } })

const app = server().get("/users/:id", (c) =>
  html(`<input aria-label="Nickname" value="ada"><h1>User ${c.params.id}`),
)

test("a user page renders", async () => {
  await using t = await e2e(app)
  await t.open("/users/:id", { params: { id: 42 } }) // a path or param the app lacks fails typecheck
  await expect(t.screen.getByRole("heading")).toHaveText("User 42")
  await t.screen.getByLabel("Nickname").fill("grace")
  await expect(t).toHaveURL("/users/42")
})

open() takes only paths the app declares and requires their :params. await using closes the browser and the server at scope exit. Names and text match as a case-insensitive substring; pass { exact: true } for a full match. A locator that matches several elements is an error for tap, fill, press and the one-element assertions, never a guess at the first. Assertions retry until assertionTimeout (5000 ms by default).

On a page that loads a module script, actions wait for data-nifra-hydrated on <html>, so a tap never lands before its handler exists. Pass hydration: "off" for a page that is not a hydrated Nifra page.

Signed-in sessions

TS
import { server } from "@nifrajs/core/server"
import { testSession } from "@nifrajs/testing"
import { e2e, expect } from "@nifrajs/e2e"

const app = server().get("/me", (c) =>
  new Response(`<p>session ${c.cookies.sid ?? "none"}`, { headers: { "content-type": "text/html" } }),
)

// Log in through the API, in process, then hand the cookie jar to the browser.
const { cookies } = testSession<typeof app>(app)
cookies.set("sid", "abc123")

await using t = await e2e(app, { cookies })
await t.open("/me")
await expect(t.screen.getByText("session abc123")).toBeVisible()

Agent steps

t.agent.act takes one goal; assert and waitFor take a statement; extract returns data your schema accepts. The model is a port you supply: { decide(request) }. The request carries a pruned outline of the page (at most snapshotChars, 12000 by default) and what was already tried; the answer is a small JSON decision.

TS
import { server } from "@nifrajs/core/server"
import { e2e, expect, Secret, type E2EModel } from "@nifrajs/e2e"

const html = (body: string) => new Response(body, { headers: { "content-type": "text/html" } })

// A sign-in form that lands on a dashboard, so a model has controls to find and a place to end.
const app = server()
  .get("/login", () =>
    html(`<input aria-label="Email"><input aria-label="Password" type="password"><button onclick="location.href='/dashboard'">Sign in`),
  )
  .get("/dashboard", () => html("<h1>Welcome, Ada"))

// The seam to a model. Nothing here names a vendor: you call your provider inside decide().
// doc-check: skip - the provider call is yours; a real adapter would await a model API here.
declare const askMyModel: (request: unknown) => Promise<unknown>
const model: E2EModel = { decide: (request) => askMyModel(request) }

await using t = await e2e(app, { model, agentContext: "Acme calls a plan a seat" })
await t.open("/login")

await t.agent.act("sign in as {email} with {password}", {
  params: { email: "ada@example.test", password: new Secret("password", process.env.PASSWORD ?? "") },
})
await t.agent.assert("the dashboard greets Ada")
await expect(t.screen.getByRole("heading")).toHaveText("Welcome, Ada") // locators and agent steps mix in one test
TS
// What decide() receives and returns, by request kind.
//
//   act      { goal, snapshot, history, secrets, handoff, ... }
//            -> { kind: "tap" | "fill" | "press", target, value?, key? }
//             | { kind: "goto", path } | { kind: "done", summary? } | { kind: "fail", reason }
//   assert   { statement, snapshot }   -> { pass: true | false | "inconclusive", reason }
//   waitFor  { statement, snapshot }   -> { pass, reason }
//   extract  { prompt, snapshot }      -> { value }   (validated with your Standard Schema)
//
// target is one of { role, name? } | { label } | { text } | { testId }, plus exact? and nth?.
// doc-check: skip - protocol sketch, not code.
  • Budgets. act stops at maxModelCalls (12), maxActions (25) or timeout (120000 ms), each its own error. A tap that finds nothing, or several, goes back to the model, which can retry with nth. The time limit is checked between decisions: it does not interrupt a decide() call that never resolves, so enforce your own deadline inside decide (the request carries a signal).
  • Secrets. A Secret is typed into the page by name. The model sees {password}, never the value, and every outline a session sends has any secret it was given replaced by its placeholder.
  • Same origin. goto takes an app path only; anything else is refused and reported back to the model.
  • No model. Agent steps reject with MODEL_MISSING; locator tests need none.
TS
import { server } from "@nifrajs/core/server"
import { t as s } from "@nifrajs/schema"
import { e2e } from "@nifrajs/e2e"

const app = server().get("/plan", () => new Response("<p>Pro, 5 seats", {
  headers: { "content-type": "text/html" },
}))

const model = {
  decide: () => ({ value: { plan: "Pro", seats: 5 } }), // a real model reads the outline in the request
}

await using t = await e2e(app, { model })
await t.open("/plan")
const plan = await t.agent.extract("the current plan and seat count", {
  schema: s.object({ plan: s.string(), seats: s.number() }),
})
plan.seats // number

The replay cache

The first run of an act asks the model. The next runs replay what it did, with no model call. A recording is written only after a later locator assertion, agent.assert or agent.waitFor passes, and a failing one deletes it, so a test should end with a check.

TS
import { server } from "@nifrajs/core/server"
import { e2e, expect, formatReport, unique, type E2EModel } from "@nifrajs/e2e"

const app = server().get("/signup", () => new Response("<h1>Sign up", {
  headers: { "content-type": "text/html" },
}))
const model: E2EModel = { decide: () => ({ kind: "done" }) }

await using t = await e2e(app, {
  model,
  cache: { testId: "a visitor signs up", mode: "read-write" },
})
await t.open("/signup")

// unique() keeps a run-varying value from missing the cache; it is typed again on replay.
await t.agent.act("sign up as {email}", { params: { email: unique(`ada-${Date.now()}@example.test`) } })

// The recording is written only when a LATER check passes.
await expect(t.screen.getByRole("heading")).toBeVisible()
console.log(formatReport(t.agent.report())) // Cache: 1 replayed - 0 handed off - 0 missed (0 model calls)
  • Replay needs the same start route, finds each control by the role, name, label, text or test id it had and requires the same number of matches, then checks the end route and the controls and headings that appeared or went away. It never taps a near miss.
  • Hand-off. When a replay stops (target-not-found, ambiguous, end-mismatch, action-uncertain, route-mismatch) the actions already done stay done, and the model finishes the goal from the current page. request.handoff carries the reason. A passing check re-records the repaired steps; if the goal then fails, the recording is deleted.
  • The key is cache.testId (set it: two tests that say the same thing otherwise share a recording), the goal text, the params, agentContext and cache.appIdentity. A unique() value and a Secret do not change it, and a secret is stored by name only.
  • Where. .nifra/e2e-cache/<hash>.json under the working directory, small and meant to be committed. cache.store swaps in your own; cache: false turns the cache off.

Modes

ModeReplaysWrites and deletesStale or missing recording
read-writeyesyeshand-off to the model
read-only (default when CI is set)yesnohand-off to the model
strictyesnoREPLAY_STALE / REPLAY_MISSING, no model call
offnonothe model runs every time
TS
import { server } from "@nifrajs/core/server"
import { e2e, expect } from "@nifrajs/e2e"

const html = (body: string) => new Response(body, { headers: { "content-type": "text/html" } })

const app = server()
  .get("/", () => html('<a href="/dashboard">Dashboard'))
  .get("/dashboard", () => html("<h1>Dashboard"))

// CI replays committed recordings and never writes or deletes them. With no model, a recording that
// no longer matches fails with MODEL_MISSING; mode: "strict" fails with REPLAY_STALE (or
// REPLAY_MISSING when nothing was recorded) instead of ever calling a model.
await using t = await e2e(app, { cache: { testId: "home to dashboard", mode: "strict" } })
await t.open("/")
await t.agent.act("open the dashboard") // reads the committed recording; strict mode checks it here
await expect(t.screen.getByRole("heading")).toHaveText("Dashboard")

t.agent.report() returns { replayed, handedOff, missed, modelCalls } and formatReport prints it. Token counts are not reported: the model port returns decisions, not usage. A cache store that refuses a write is counted, never thrown.

Engines

e2e() uses Playwright by default. Install it yourself (playwright is an optional peer dependency) and a browser with bunx playwright install chromium. The engine is a six-method interface, so anything else plugs in with e2e(app, { engine }); @nifrajs/e2e/fake is an in-memory engine with the same locator rules, for testing code built on this package.

TS
import { server } from "@nifrajs/core/server"
import { e2e } from "@nifrajs/e2e"
import { fakeEngine } from "@nifrajs/e2e/fake"

const app = server().get("/", () => new Response("ok"))

// An in-memory engine with the same locator rules, for testing code built on this package.
const { factory } = fakeEngine({ pages: { "/": [{ role: "heading", name: "Home", text: "Home" }] } })
await using t = await e2e(app, { engine: factory, cache: false })
await t.open("/")
await t.screen.getByRole("heading").count()

An agent step is only as safe as the app state behind it. Point it at a test database and an isolated fixture, never at production, and keep credentials in Secret params.