testScreen() is a runtime harness for Intent screens. It creates a ScreenRuntime, starts it, and gives you a ScreenHandle to interact with the screen semantically — no DOM, no selectors, no render waits.
It is exported from @intent-framework/testing.
import { testScreen } from "@intent-framework/testing"import { test, expect } from "vitest"
import { testScreen } from "@intent-framework/testing"
import { screen } from "@intent-framework/core"
const InviteMember = screen("InviteMember", $ => {
const email = $.state.text("email")
const emailAsk = $.ask("Email", email)
.required()
.validate(value => value.includes("@") ? true : "Enter a valid email")
const invite = $.act("Invite member")
.primary()
.when(emailAsk.valid, "Enter a valid email first")
$.surface("main").contains(emailAsk, invite)
})
test("invite is blocked until email is valid", async () => {
await testScreen(InviteMember, async app => {
await app.act("Invite member").toBeBlockedBy("Enter a valid email first")
await app.answer("Email", "ada@example.com")
await app.act("Invite member").toBeEnabled()
})
})testScreen()creates aScreenRuntimefrom the screen definition.- The runtime starts — resources with
autoLoad: trueare loaded,ResourceRefnodes connect. - The callback receives a
ScreenHandlefor semantic interaction. - When the callback completes (or throws), the runtime is disposed.
The ScreenHandle returned inside the callback has the following methods:
Returns an action assertion object for the given action label (case-insensitive match). Throws if no action with that label exists.
screen.act("Invite member")toBeEnabled() — Asserts the action is not blocked by any condition.
screen.act("Invite member").toBeEnabled()Throws if the action is blocked.
toBeBlocked() — Asserts the action is blocked (one or more conditions are not met).
screen.act("Invite member").toBeBlocked()Throws if the action is enabled.
toBeBlockedBy(...reasons) — Asserts the action is blocked and that the given reason strings are present in its blocked reasons.
screen.act("Invite member").toBeBlockedBy("Enter a valid email first")
screen.act("Log in").toBeBlockedBy("Enter your email.", "Enter your password.")Throws if the action is enabled or if any specified reason is not in the actual blocked reasons list.
run() — Executes the action through the runtime.
await screen.act("Invite member").run()The action handler runs with the runtime's execution context (services). If the action is blocked by a condition, the handler is not called and no invalidation happens.
Sets the state value for a given ask label (case-insensitive match). Throws if no ask with that label exists.
await screen.answer("Email", "ada@example.com")
await screen.answer("Password", "secret")This directly sets the underlying state value, which updates derived conditions (ask.valid, etc.) reactively.
Returns the status message from the last executed action, or null if no action has produced feedback.
const msg = screen.feedback()
// e.g. "Invitation sent!" or "Failed."The value comes from act.statusMessage — set by .feedback({ success: "...", failure: "..." }) on the action definition.
Returns the raw ScreenDefinition object for the screen, giving access to all semantic nodes directly.
const def = screen.state()Manually starts the runtime. The runtime is already started when testScreen enters the callback, but start() can be called again for re-initialization. Guards against double-start.
await screen.start()Returns a resource handle for the given resource name (case-insensitive match). Throws if no resource with that name exists.
const team = screen.resource("team")status() — Returns the current resource status: "idle", "pending", "ready", or "failed".
expect(team.status()).toBe("ready")load() — Manually runs the resource loader. Returns a promise that resolves when loading completes.
await screen.resource("team").load()reload() — Runs the resource loader again. If called without arguments, reuses the context from the previous load.
await screen.resource("team").reload()invalidate() — Marks the resource as stale. Does not trigger a reload.
screen.resource("team").invalidate()
expect(screen.resource("team").stale()).toBe(true)stale() — Returns true if the resource has been invalidated since its last successful load.
expect(screen.resource("team").stale()).toBe(false)Use toBeEnabled() and toBeBlocked() to check binary action state:
await testScreen(LoginScreen, async screen => {
// Initially blocked — email and password are empty
expect(() => screen.act("Log in").toBeBlocked()).not.toThrow()
await screen.answer("Email", "ada@example.com")
await screen.answer("Password", "secret")
// Now enabled
expect(() => screen.act("Log in").toBeEnabled()).not.toThrow()
})Use toBeBlockedBy() to assert specific blocked reasons:
await testScreen(LoginScreen, async screen => {
// Both reasons present
screen.act("Log in").toBeBlockedBy("Enter your email.", "Enter your password.")
// After answering email, only the password reason remains
await screen.answer("Email", "ada@example.com")
screen.act("Log in").toBeBlockedBy("Enter your password.")
// Answering password enables the action
await screen.answer("Password", "secret")
screen.act("Log in").toBeEnabled()
})This is useful for testing that the right blocking conditions are reported to the user.
Use act(label).run() to execute an action through the runtime:
await testScreen(InviteMember, async screen => {
await screen.answer("Email", "ada@example.com")
await screen.act("Invite member").run()
})If the action is blocked, run() returns early without calling the handler:
await testScreen(BlockedScreen, async screen => {
const navigate = vi.fn()
await screen.act("Go").run()
expect(navigate).not.toHaveBeenCalled()
}, { services: { navigate } })Action handlers receive the execution context with runtime services:
type AppServices = {
navigate: (name: string) => void
}
const TestScreen = screen<AppServices>("Test", $ => {
$.act("Go")
.when(true)
.does(({ navigate }) => {
navigate("somewhere")
})
})
await testScreen<AppServices>(TestScreen, async screen => {
await screen.act("Go").run()
}, { services: { navigate: vi.fn() } })Actions with feedback config produce a status message after execution:
const SaveScreen = screen("Save", $ => {
$.act("Save")
.when(true)
.does(async () => { /* save */ })
.feedback({ success: "Saved!", failure: "Save failed." })
})
await testScreen(SaveScreen, async screen => {
await screen.act("Save").run()
expect(screen.feedback()).toBe("Saved!")
})Pass services to the runtime via the services option:
await testScreen(
MyScreen,
async screen => {
await screen.act("Navigate").run()
expect(navigate).toHaveBeenCalledWith("login")
},
{ services: { navigate } }
)Services are available to action handlers and resource loaders as the execution context.
For full type safety, provide a generic type parameter:
type AppServices = {
analytics: { track: (event: string) => void }
navigate: (name: string) => void
}
await testScreen<AppServices>(
MyScreen,
async screen => {
await screen.act("Track and go").run()
},
{ services: { analytics, navigate } }
)Assert resource lifecycle states:
await testScreen(TeamScreen, async screen => {
expect(screen.resource("team").status()).toBe("ready")
})Resources with autoLoad: false start in the "idle" state:
await testScreen(TeamScreen, async screen => {
expect(screen.resource("searchResults").status()).toBe("idle")
await screen.resource("searchResults").load()
expect(screen.resource("searchResults").status()).toBe("ready")
})Manually trigger a resource load:
await testScreen(TeamScreen, async screen => {
const team = screen.resource("team")
expect(team.status()).toBe("idle")
await team.load()
expect(team.status()).toBe("ready")
})Reload a resource after it has been loaded:
let callCount = 0
const TeamScreen = screen("ReloadTest", $ => {
$.resource("team", {
load: async () => {
callCount++
return `data${callCount}`
},
autoLoad: false,
})
})
await testScreen(TeamScreen, async screen => {
await screen.resource("team").load()
expect(callCount).toBe(1)
await screen.resource("team").reload()
expect(callCount).toBe(2)
})Invalidate a resource and check staleness:
await testScreen(TeamScreen, async screen => {
expect(screen.resource("team").stale()).toBe(false)
screen.resource("team").invalidate()
expect(screen.resource("team").stale()).toBe(true)
await screen.resource("team").reload()
expect(screen.resource("team").stale()).toBe(false)
})After auto-load, resource conditions update and actions become enabled:
await testScreen(TeamScreen, async screen => {
// Resource autoloaded on start, act condition is met
expect(() => screen.act("Save").toBeEnabled()).not.toThrow()
})Resource loaders receive services as context:
type TestServices = { route: string }
await testScreen<TestServices>(
TeamScreen,
async screen => {
await screen.resource("team").load()
// Loader received { route: "/teams/team_1" }
},
{ services: { route: "/teams/team_1" } }
)expect(() => screen.act("Nonexistent")).toThrow('Act "Nonexistent" not found.')await expect(screen.answer("Fake", "value")).rejects.toThrow('Ask "Fake" not found.')expect(() => screen.resource("nonexistent")).toThrow('Resource "nonexistent" not found.')Testing provides a runtime harness for Intent screens. It depends on @intent-framework/core. Use it with any test runner (Vitest recommended). It pairs with inspectScreen() for diagnostics assertions.
| Purpose | testScreen |
inspectScreen |
|---|---|---|
| Runtime execution | Yes | No |
| Action execution | Yes | No |
| State mutation | Yes (via answer) | No |
| Diagnostics | No | Yes |
| Semantic graph snapshot | No | Yes |
| Blocked reason assertions | Yes | Read-only |
Use testScreen() when you need to test dynamic behavior — answering asks, executing actions, loading resources, asserting blocked reasons. Use inspectScreen() for static graph structure and diagnostics.
- Quickstart — step-by-step guide with testing
- Inspect Screen and Diagnostics Guide — graph inspection and diagnostics
- Resources Guide — resource semantics
- Root README — project overview
- Canonical runnable example — matches the Quickstart one-to-one