kahraman
Accessibility-first, codecept-style test actor and locator DSL for Storybook portable stories and Vitest browser mode.
kahraman
Accessibility-first, codecept-style test actor and locator DSL for Storybook portable stories and Vitest browser mode.
kahraman gives you a small, declarative I.see(...) / I.click(...) actor
built on Testing Library queries, plus a fluent locator DSL that biases every
assertion toward roles, accessible names, and visible text. It runs inside a
Storybook story play function or a Vitest browser test — anywhere you have a
canvas and userEvent.
Built and maintained by apphane.
import { createActor, button, heading, role } from 'kahraman'
const I = createActor()
export const Default = meta.story()
Default.test('signs the user in', async (context) => {
I.init(context)
await I.see(heading('Sign in'))
await I.fill(role('textbox', 'Email'), 'ada@example.com')
await I.click(button('Continue'))
await I.waitExit(role('status'))
await I.see(heading('Welcome, Ada'))
})
Why accessibility-first
Every locator resolves through Testing Library's accessibility tree — by role,
accessible name, heading, link, button, or visible text — before you ever reach
for a lower-level selector. Tests written this way assert what a user (and a
screen reader) actually perceives, and they surface real accessibility gaps: if
you can't target an element by its role or name, neither can assistive tech.
kahraman deliberately keeps the public locator surface small so the path of
least resistance is also the accessible one.
Install
npm install --save-dev kahraman
# or: pnpm add -D kahraman
storybook (which provides storybook/test, and with it Testing Library and
userEvent) is a peer dependency — you already have it. kahraman ships
ESM only and is renderer-agnostic: it reads only canvasElement and userEvent
from the story context, so it works with the React, Vue, and Svelte renderers
alike.
Or vendor the source with jsrepo
Prefer to own the code? kahraman is also published as a
jsrepo registry, so you can copy the source straight into
your project and adapt it:
# the actor + locator DSL
npx jsrepo add github/apphane-dev/kahraman/actor
# the opt-in diagnostics preview annotation
npx jsrepo add github/apphane-dev/kahraman/preview
# include the unit tests / example page actor too
npx jsrepo add --with test --with example github/apphane-dev/kahraman/actor
The full source also lives in src/ inside the published npm tarball, so it is
available for reference even when you install the package normally.
Usage
Create and initialize an actor
import { createActor } from 'kahraman'
const I = createActor()
// Inside a story play / test, before any actor call:
I.init(context) // context: the Storybook StoryContext (any renderer)
createActor() returns a fresh actor with its own step trace. Call
I.init(context) first — it wires up the canvas and resets the trace.
To make clicks easier to follow when manually playing a story, configure an
explicit delay. The default is 0, so automated tests are not slowed down:
const I = createActor({
clickDelay: navigator.webdriver ? 0 : 500,
})
Keeping the environment check in the consumer makes the pacing policy visible and lets each Storybook choose how it distinguishes manual and automated runs.
Locators
Build locators with role, text, and the heading / button / link
shorthands, then refine them fluently:
import { role, text, heading, button, link } from 'kahraman'
role('textbox', 'Email') // by role + accessible name
heading('Dashboard') // role('heading', 'Dashboard')
text(/items? found/) // by visible text (string or RegExp)
link('Storage').options({ current: 'page' }) // extra Testing Library options
button('Save').within(role('dialog')) // scope to a container
role('listitem').all() // all matches (for counts)
role('status', 'Loading').wait() // findBy* (async)
role('alert').maybe() // queryBy* (nullable)
| Transition | Query mode | Returns |
|---|---|---|
| (default) | getBy* |
one element (throws) |
.wait() |
findBy* |
Promise<element> |
.all() |
getAllBy* |
element[] |
.maybe() |
queryBy* |
element | null |
.within(s) |
scope to s |
same variant, scoped |
.options() |
merge query options | same variant |
.within(scope) accepts a locator, an HTMLElement, or 'global' (the whole
document body).
Actor methods
// Assertions
await I.see(role('alert')) // present in the document
await I.dontSee(button('Delete')) // absent
await I.waitExit(role('status')) // wait for it to disappear (stabilization)
await I.seeInField(role('textbox', 'Email'), 'ada@example.com')
await I.seeChecked(role('checkbox', 'Remember me'))
await I.seeDisabled(button('Save'))
await I.seeAttribute(link('Docs'), 'target', '_blank')
await I.seeNumberOfElements(role('listitem').all(), 3)
// Interactions
await I.click(button('Save'))
await I.fill(role('textbox', 'Email'), 'ada@example.com')
await I.clear(role('textbox', 'Email'))
await I.selectOption(role('combobox', 'Country'), 'Portugal')
await I.press('{Enter}')
// Scoping
await I.within(role('main'), async () => {
await I.see(heading('Details'))
})
// Extraction
const title = await I.grabTextFrom(heading())
const rows = await I.grabTextFromAll(role('row').all())
// Resilience
await I.retryTo((n) => I.see(text(`Attempt ${n}`)), 3, 200)
if (await I.tryTo(() => I.see(role('dialog')))) {
/* optional */
}
// Soft assertions — collect failures, report all at once
await I.hopeThat(() => I.see(heading('A')))
await I.hopeThat(() => I.see(heading('B')))
I.hopeThat.noErrors() // throws an AggregateError if any failed
Extending with page actors
Keep reusable, page-level expectations out of your stories with I.extend(...):
import { button, heading, role, text, type BaseActor } from 'kahraman'
const withPageError =
(error: { title: string; description?: string }) => (I: Pick<BaseActor, 'see'>) => ({
seeError: async () => {
await I.see(heading(error.title))
if (error.description) {
await I.see(text(error.description))
}
await I.see(role('alert'))
await I.see(button('Try again'))
},
})
const I = createActor().extend(withPageError({ title: 'Something went wrong' }))
// ...later: await I.seeError()
See examples/pageActor.ts for a fuller illustration.
For a real-world app that exercises kahraman in its Storybook stories, see
guria/modern-setup.
Failure diagnostics
When an actor call fails, kahraman augments the error to make the failure
readable:
- Step trace — the message ends with every actor call that ran,
✔/✖, with locator labels (e.g.✖ I.see(heading "Welcome")). - Retargeted stack — the code frame points at your story / page-actor call
site, not
kahraman's internals. Internal frames are detected relative to the package's own module URL, so this works whether kahraman is installed or vendored.
Opt-in: tame element-not-found output (kahraman/preview)
Testing Library's getByRole misses dump a listing of every accessible role
on the page — hundreds of lines on a full app mount. The kahraman/preview
annotation filters that to just the queried role's near-misses and caps the DOM
dump. Add it to your Storybook preview:
// .storybook/preview.ts
import kahraman from 'kahraman/preview'
export default {
...kahraman,
// ...your other preview config
}
Tune or opt out per story via parameters:
parameters: {
kahraman: {
captureRoleListing: false, // keep TL's full role listing (still length-capped)
maxLength: 4000, // raise the truncation ceiling
// getElementError: myOwnHandler, // your handler wins entirely
}
}
Outside Storybook you can wire it up imperatively with
configureDiagnostics() from kahraman/preview.
Consumer configuration
Two behaviors are controlled by your Vitest/Storybook setup, not by the package:
VITE_TEST_STEPS— setVITE_TEST_STEPS=true(a Vite/Vitest env var) to log each actor step live as it runs, likecodeceptjs run --steps.kahramanreads it defensively; when unset (or under plain Node), logging is off.screenshotFailures— failure screenshots are a Vitest browser mode feature. Configuretest.browser.screenshotFailuresin your own Vitest config to write a screenshot next to the story on failure.
Testing the pure helpers
kahraman's own test suite unit-tests only the Storybook-free logic:
locator label construction, step-label formatting, role-listing filtering,
message capping, and stack-frame retargeting. The full actor behavior requires a
real Storybook browser context and is exercised by consumers' stories — that
boundary is intentional. The DOM-touching modules (actor.ts, preview.ts) run
only in the browser.
Development
This repo uses the Vite+ / Oxc toolchain:
| Task | Command | Tool |
|---|---|---|
| Typecheck | pnpm typecheck |
tsc (TypeScript 7, native) |
| Lint | pnpm lint |
oxlint (via vp lint) |
| Analyze | pnpm lint:fallow |
fallow (dead-code, dupes, health) |
| Format | pnpm format |
oxfmt (via vp fmt) |
| Test | pnpm test |
Vitest (via vp test) |
| Build | pnpm build |
tsdown (Rolldown) → ESM + .d.ts |
| Everything | pnpm check |
typecheck + lint + fallow + fmt + test |
Formatter and linter configuration lives in the fmt / lint blocks of
vite.config.ts (the Vite+ convention), not in separate .oxfmtrc.json /
.oxlintrc.json files. Notable engineering decisions are recorded under
docs/decisions/ — e.g. why type-aware linting is off by
default (0001).
Node, pnpm, and the git-hook runner are pinned with
mise (mise install). Git hooks run through
hk — configured in hk.pkl, install them once with
hk install (pre-commit: format + lint + typecheck + hygiene; pre-push adds
fallow).
Publish quality is gated by publint and
@arethetypeswrong/cli
against the packed tarball, releases run through
Changesets, and every commit gets an
installable preview via pkg.pr.new.
Acknowledgements
kahraman stands on prior art:
- CodeceptJS (MIT) — the actor DSL is modelled on its
I.see(...)/I.click(...)/hopeThat/retryTo/tryTostyle. The concepts and API shape are borrowed; the implementation here is original (no CodeceptJS code is used or redistributed). - Testing Library — every locator resolves through its accessibility-first queries.
- Storybook — the actor runs against the
storybook/testruntime (portable stories, Vitest browser mode).
License
MIT © Aleksei Gurianov
- guria