On this pageTesting Through the View
Scene
Scene tests features through the rendered view. Where Story sends Messages directly to update, Scene clicks buttons, types into inputs, presses keys, and asserts on the rendered HTML. The view function runs on every step, so if it crashes or renders the wrong thing, the test catches it.
Scene operates on the VNode tree directly. No DOM, no JSDOM, no browser. Tests are pure, deterministic, and fast.
Locators find elements the way users find them: by role, by label, by visible text. Each factory returns a Locator that resolves to a single match; interactions and assertions accept either a Locator or a raw CSS selector string.
import { Scene } from 'foldkit'
Scene.role('button', { name: 'Submit' })
Scene.label('Email')
Scene.text('Welcome back')
Scene.placeholder('Search...')
Scene.altText('Company logo')
Scene.title('Close dialog')
Scene.testId('cart-summary')
Scene.displayValue('[email protected]')
Scene.selector('.fallback-class')| Locator | Finds | Example |
|---|---|---|
Scene.role(role, options?) | Elements by ARIA role (explicit or implicit). Options narrow by accessible name and ARIA state. | Scene.role('button', { name: 'Save' }) |
Scene.label(text) | Form controls by their aria-label or associated <label> text. | Scene.label('Email') |
Scene.placeholder(text) | Inputs by their placeholder attribute. | Scene.placeholder('Search...') |
Scene.text(text) | Elements by visible text content. | Scene.text('Welcome back') |
Scene.altText(text) | Images and similar elements by their alt attribute. | Scene.altText('Profile photo') |
Scene.title(text) | Elements by their title attribute (tooltip text). | Scene.title('Delete') |
Scene.testId(id) | Elements by data-testid — the escape hatch for tests. | Scene.testId('cart-item-3') |
Scene.displayValue(value) | Form controls by their current value. | Scene.displayValue('US') |
Scene.selector(css) | Elements by CSS selector. Use when no accessible query fits. | Scene.selector('.chart-legend') |
Scene.role is the most common locator. It accepts a second argument of state options that narrow the match. All options are optional:
import { Scene } from 'foldkit'
// Match by role alone
Scene.role('button')
// Narrow by accessible name
Scene.role('button', { name: 'Save' })
// Narrow by heading level
Scene.role('heading', { level: 2 })
// Narrow by ARIA state
Scene.role('checkbox', { checked: true })
Scene.role('button', { pressed: true, disabled: false })| Option | Type | Matches |
|---|---|---|
name | string | Accessible name (aria-label, aria-labelledby, label[for], or text content) |
level | number | Heading level (for role: "heading") |
checked | boolean | 'mixed' | aria-checked or the checked attribute |
selected | boolean | aria-selected |
pressed | boolean | 'mixed' | aria-pressed |
expanded | boolean | aria-expanded |
disabled | boolean | aria-disabled or the disabled attribute |
Scene.within(parent, child) scopes a single locator to a parent element. Scene.inside(parent, ...steps) scopes a whole block of steps — every assertion or interaction inside the block resolves within the parent’s subtree. Use within for one-off scoped queries; use inside when several steps share the same scope. Nested inside calls compose.
import { Scene } from 'foldkit'
// Scope a single locator to a parent element.
Scene.within(Scene.role('region', { name: 'Sidebar' }), Scene.role('link'))
// Scope a block of steps — every assertion and interaction
// resolves within the parent's subtree.
Scene.inside(
Scene.role('dialog', { name: 'Confirm' }),
Scene.expect(Scene.role('heading')).toHaveText('Delete item?'),
Scene.click(Scene.role('button', { name: 'Cancel' })),
)For lists and repeated elements, the Scene.all.* factories (Scene.all.role, Scene.all.text, Scene.all.label, and so on — one per single-match factory) return a LocatorAll that resolves to every match. Pick one with Scene.first, Scene.last, or Scene.nth(index), or narrow with Scene.filter:
import { pipe } from 'effect'
import { Scene } from 'foldkit'
// Multi-match locators return every match.
Scene.all.role('row')
Scene.all.text('Delete')
Scene.all.label('Email')
// Pick one element from the set.
Scene.first(Scene.all.role('row'))
Scene.last(Scene.all.role('button', { name: 'Delete' }))
Scene.nth(Scene.all.role('row'), 2)
// Narrow with filter, then pick.
pipe(Scene.all.role('row'), Scene.filter({ hasText: 'Alice' }), Scene.first)
pipe(
Scene.all.role('row'),
Scene.filter({ has: Scene.role('button', { name: 'Delete' }) }),
Scene.first,
)| Filter option | Keeps matches where |
|---|---|
has | The element contains a descendant matching the given Locator |
hasNot | The element does not contain a descendant matching the Locator |
hasText | The element’s text content includes the given substring |
hasNotText | The element’s text content does not include the substring |
Interactions exercise the view by invoking event handlers on matched elements. Each one captures the dispatched Message, feeds it through update, and re-renders. They accept either a Locator or a CSS selector string.
import { Scene } from 'foldkit'
Scene.click(Scene.role('button', { name: 'Log out' }))
Scene.doubleClick(Scene.role('button', { name: 'Expand' }))
Scene.hover(Scene.role('menuitem', { name: 'File' }))
Scene.focus(Scene.label('Email'))
Scene.blur(Scene.label('Email'))
Scene.type(Scene.label('Email'), '[email protected]')
Scene.change(Scene.label('Country'), 'US')
Scene.submit(Scene.role('form'))
Scene.keydown(Scene.label('Search'), 'Enter')| Step | Invokes |
|---|---|
Scene.click(target) | OnClick |
Scene.doubleClick(target) | OnDoubleClick |
Scene.hover(target) | OnMouseEnter (falls back to OnMouseOver) |
Scene.focus(target) | OnFocus |
Scene.blur(target) | OnBlur |
Scene.type(target, text) | OnInput with the given text |
Scene.change(target, value) | OnChange with the given value — for <select> and similar |
Scene.keydown(target, key, modifiers?) | OnKeyDown or OnKeyDownPreventDefault with optional { shiftKey, ctrlKey, altKey, metaKey } |
Scene.submit(target) | OnSubmit |
Scene.tap(fn) runs a function for side effects (like ad-hoc assertions on raw VNodes or accumulated Commands) without breaking the step chain.
Scene.expect(locator) creates an inline assertion step against a single element. Every matcher has a .not variant that inverts the assertion.
import { Scene } from 'foldkit'
// Single-element assertions
Scene.expect(Scene.role('heading')).toExist()
Scene.expect(Scene.role('heading')).toHaveText('Welcome')
Scene.expect(Scene.role('heading')).toHaveText(/^Welcome/)
Scene.expect(Scene.role('heading')).toContainText('Welcome')
Scene.expect(Scene.role('dialog')).toBeAbsent()
Scene.expect(Scene.role('status')).toBeVisible()
Scene.expect(Scene.role('status')).toBeEmpty()
Scene.expect(Scene.role('region')).toHaveAccessibleName('User session')
Scene.expect(Scene.label('Email')).toHaveValue('[email protected]')
Scene.expect(Scene.role('button', { name: 'Submit' })).toBeDisabled()
Scene.expect(Scene.role('button')).not.toBeDisabled()
// Multi-match assertions — count-based
Scene.expectAll(Scene.all.role('row')).toHaveCount(3)
Scene.expectAll(Scene.all.role('alert')).toBeEmpty()| Matcher | Asserts that the element |
|---|---|
.toExist() | Is present in the tree |
.toBeAbsent() | Is not present in the tree |
.toBeVisible() | Is not hidden via the hidden attribute, aria-hidden, display: none, or visibility: hidden |
.toBeEmpty() | Has no text content or child elements |
.toHaveText(value) | Has text content equal to the given string or matching the given regex |
.toContainText(value) | Has text content including the given substring or matching the regex |
.toHaveAccessibleName(name) | Has the given accessible name (resolves aria-labelledby, aria-label, label[for], text content) |
.toHaveAccessibleDescription(description) | Has the given accessible description (resolves aria-describedby) |
.toBeDisabled() | Has aria-disabled or the disabled attribute |
.toBeEnabled() | Is not disabled |
.toBeChecked() | Has aria-checked="true" or the checked attribute |
.toHaveValue(value) | Has the given current form-control value |
.toHaveAttr(name, value) | Has the given attribute set to the given value |
.toHaveId(id) | Has the given id |
.toHaveClass(name) | Has the given CSS class |
.toHaveStyle(name, value) | Has the given inline style property |
For LocatorAll (from Scene.all.*), use Scene.expectAll(locatorAll) for count-based assertions:
| Matcher | Asserts that |
|---|---|
.toHaveCount(n) | The locator matches exactly n elements |
.toBeEmpty() | The locator matches zero elements |
Here’s a Scene test for a weather app. The user types a zip code, submits the form, sees a loading state, and then the forecast appears:
import { Scene } from 'foldkit'
import { expect, test } from 'vitest'
test('weather search: type a zip code, see the forecast', () => {
Scene.scene(
{ update, view },
Scene.with(model),
Scene.type(Scene.label('Zip code'), '90210'),
Scene.submit(Scene.role('form')),
Scene.expect(Scene.role('button', { name: 'Loading...' })).toExist(),
Scene.resolve(
FetchWeather,
SucceededFetchWeather({ weather: beverlyHillsWeather }),
),
Scene.inside(
Scene.role('article'),
Scene.expect(Scene.role('heading', { name: '90210' })).toExist(),
Scene.expect(Scene.text('72\u00B0F')).toExist(),
Scene.expect(Scene.text('Clear sky')).toExist(),
),
)
})Every interaction targets an element the way a user would: by label, by role, by placeholder. Every assertion reads like a sentence. Commands are resolved inline, just like in Story.
Story and Scene are complementary. Story tests the state machine: does this sequence of Messages produce the right Model? Scene tests the contract: does this feature work from the user’s perspective?
Use Story for update logic, edge cases, and Command wiring. Use Scene for user flows, view rendering, and accessibility. A well-tested app uses both.