Custom interactors
Once a few tests use the same form or component, give that workflow a name. Keep the definition with your app’s test support—or beside the component—so tests can import it.
import { defineInteractor } from 'dyno';
export let LoginForm = defineInteractor({ actions: { async enterCredentials({ email, password }) { await this.find('Email').then.fill(email); await this.find('Password').then.fill(password); }, async signIn(user) { await this.enterCredentials(user); await this.click('"Log in" $(button)'); }, }, assertions: { hasError: message => ({ root }) => root().querySelector('[role=alert]')?.innerText === message, },});Bind it to the test’s I when you use it:
import { LoginForm } from './interactors/login.js';
let login = LoginForm(I, '$(form)');await step('Sign in', () => login.signIn(user));await login.assert.hasError('Check your password');Share definitions, not bound instances. Each binding gets the test’s scope, assertion settings and cancellation signal. It follows the app after navigation without caching elements or changing other interactors.
Compose smaller components
Section titled “Compose smaller components”A custom interactor can bind inside another one. With your own Post, Composer and selector:
let reply = Composer(Post(I, postSelector(words)), '$([aria-label="Reply"])');await reply.write('I remember this storm.');Use async actions and await each operation. Interactor’s generator actions and .then chains work too, and scope() keeps your custom methods.
Assertions receive $ for the current subject and root() for the component root. Return a boolean, including false when content is missing. Interactor treats undefined as success for callbacks that throw on failure. Custom assertions support .not and failure-message objects.
Keep navigation, fixture requests, screenshots and named steps in the surrounding workflow. TypeScript infers custom method arguments; use InteractorContext when annotating assertion callbacks.