How to write a Hook
A hook is a TypeScript function you own, anchored to the Transaction — or the set of Transactions — it applies to.
Where hooks live
Section titled “Where hooks live”mkdir -p .thymian/sampler/hooksAny file under hooks/ is scanned, at any depth, named anything you like:
Directory.thymian/sampler/hooks/
- auth.ts
Directorylaunches/
- seed.ts
- assertions.ts
The layout carries no meaning — it is for your benefit. What decides where a hook applies is its target, in the code.
State is shared within a run
Section titled “State is shared within a run”Hook files are evaluated once per scan, and so is every module they import: a module two hook files share is one instance, and a value one hook writes to it is the value another reads.
// .thymian/sampler/hooks/state.ts — one copy, shared by every hook fileexport const fixtures: { userId?: number } = {};So a beforeAll in auth.ts that writes fixtures.userId hands it to a
beforeEach in seed.ts that reads it — provided the beforeAll has run,
which the lifecycle guarantees before any
request-scoped hook. What the plugin does not carry over is the process:
thymian serve re-evaluates the hook files, and the modules they import, on
every scan, so state is one run’s and never the next’s. Prefer
utils.request for anything that has to
exist on the server; keep module state for what only the hooks need.
The six hook kinds
Section titled “The six hook kinds”| Kind | Runs | Use it for |
|---|---|---|
defineSample |
when the request is generated | bodies, fixture files, the authorize flag |
beforeEach |
before each request | headers, path parameters, seeding |
afterEach |
after each response | assertions, observations |
authorize |
before a request that wants credentials | credentials, and nothing else |
beforeAll |
once, before the first request | run-wide setup |
afterAll |
once, when the run closes | run-wide teardown |
Every hook returns nothing and mutates in place, and every registration is an export:
import { beforeEach } from '@thymian/hooks';
export const addTraceId = beforeEach( 'GET /launches -> 200 (application/json)', (request) => { request.headers['x-trace-id'] = 'abc'; },);Choosing a target
Section titled “Choosing a target”One Transaction
Section titled “One Transaction”Pass its Selector. This is the tightest and the most precisely typed option — inside the callback, the request and response types are that Transaction’s.
export const shape = beforeEach( 'POST /launches (application/json) -> 201 (application/json)', (request) => { request.body = { missionName: 'Artemis II' }; },);A few Transactions
Section titled “A few Transactions”Pass a list of Selectors.
export const shared = beforeEach( [ 'GET /launches -> 200 (application/json)', 'GET /astronauts -> 200 (application/json)', ], (request) => { request.headers['x-shared'] = 'yes'; },);A set of Transactions
Section titled “A set of Transactions”Pass a filter. Fields AND-combine, a list within a field OR-combines, and not
excludes.
export const negativeCases = afterEach( { statusClass: '4XX', not: { status: 401 } }, (response, ctx, utils) => { utils.info(`negative case responded ${response.statusCode}`); },);A whole subtree
Section titled “A whole subtree”Use a path glob. * is exactly one segment; a trailing ** is one or more.
export const admin = beforeEach({ path: '/admin/**' }, (request) => { request.headers['x-admin'] = 'yes';});/admin/** covers everything under /admin, including paths added to your API
description later — but not /admin itself.
See the filter reference for every field and the full glob grammar.
Finding a Selector
Section titled “Finding a Selector”npx thymian sampler show 'GET /launches -> 200 (application/json)'It prints the request that Transaction would send. Get the Selector slightly wrong and it prints the nearest matches, which is the fastest way to find the exact spelling.
Composing several hooks
Section titled “Composing several hooks”Several hooks of the same kind on one Transaction all run, in registration order:
file order on the outside, and source order within each file. defineSample is
the exception — at most one per Transaction, and a second is reported as a
conflict when the hooks load.
When a hook stops matching
Section titled “When a hook stops matching”A Selector that names nothing, a glob that matches nothing, and a filter whose values intersect nothing all fail the run, naming the hook file and the export:
1 sampler hook does not resolve against the loaded API description.Try this: * seed.ts (export "seedAstronaut"): beforeEach targets the selector "GET /astronauts/{id} -> 200 (application/json)", which names no transaction in the loaded API description * Did you mean one of these selectors? * "GET /astronauts/{astronautId} -> 200 (application/json)"A hook is never quietly re-bound to a nearby Transaction. That is the point of anchoring to a fully-qualified Selector.