Skip to content

feat: add testing entrypoint with recordHooks - #52

Merged
fratzinger merged 9 commits into
mainfrom
feat/testing-record-hooks
Sep 11, 2026
Merged

fratzinger merged 9 commits into
mainfrom
feat/testing-record-hooks

Conversation

@fratzinger

@fratzinger fratzinger commented Sep 11, 2026

Copy link
Copy Markdown
Member

Adds an eighth barrel, feathers-utils/testing, for test-only helpers. It is deliberately absent from the root barrel, so it cannot ride along in a production bundle.

recordHooks(appOrService, options?)

Records every call that passes through a service or an application, so a test can assert what was requested — and how often — without standing up a spy per method. The typical use is proving a cache, a debounce or a local-first store did not go to the server, which is awkward to assert on results alone.

import { recordHooks } from 'feathers-utils/testing'

const calls = recordHooks(app)

await app.service('users').find({ query: { name: 'jane' } })

expect(calls.before.find).toHaveLength(1)
expect(calls.before.create).toHaveLength(0)
expect(calls.before.find[0].params.query).toEqual({ name: 'jane' })

The record is indexed by hook type and then method — calls.before · calls.after · calls.error · calls.around — plus all in the order things were recorded. Every entry is a plain array and a method nothing was recorded under reads as [], so assertions need no guard and compose with any predicate:

calls.before.find.filter(isContext({ path: 'users' }))

What to record is narrowed by the same criteria as isContext, each one value or many:

const calls = recordHooks(app, {
  path: 'users',
  method: ['create', 'patch'],
  type: ['before', 'after'],
  snapshot: true,
})

type doubles as the registration: only the requested hooks are registered. It defaults to ['before', 'after', 'error'] — the three phases of a call — so calls.after is populated without asking. around stays opt-in: at the point it records, it sees what before sees.

Waiting instead of sleeping

waitFor is the part that replaces sleep in a test:

// the first matching call, already recorded or still to come
const [context] = await calls.waitFor({ context: { method: 'create' } })

// nothing reaches the service at all — fails the moment something does
await calls.waitFor({ context: { path: 'users' }, count: 0 })

// "one request went out, prove no second one follows", evidence intact
await app.service('users').find({})
const quiet = calls.waitFor({ context: { path: 'users' }, count: 0, since: 'now' })
await readThroughCache()
await quiet
expect(calls.before.find).toHaveLength(1)

// let a debounce settle, then assert the exact number of calls
const finds = await calls.waitFor({ context: { path: 'users' }, quietFor: 250 })
expect(finds).toHaveLength(1)
  • context takes isContext criteria directly, or any predicate — as does reset
  • count (default 1) is a lower bound; 0 inverts the wait and rejects on the first match
  • since: 'now' takes the baseline at the wait instead of at the record, which is how "no further call" is said without forgetting what is already there
  • quietFor resolves once nothing matching has arrived for that long, with every match it saw — the exact assertion count cannot give. It has to be shorter than timeout, which stays the hard deadline
  • resetBefore / resetAfter forget the matching calls around the wait; a failed wait keeps the record, since that is the evidence
  • timeout follows waitForServiceEvent: milliseconds or false, default 5000 — except count: 0, which always waits its window out, so it defaults to 50 and refuses false

Counting is by call, not by recorded entry: a call the recorder saw in before and again in after counts once, and the wait resolves with one context per call. Combinations that could never settle are refused with a TypeError rather than hanging, and a wait for a hook type this recorder does not record rejects immediately and names the fix.

The rest of the surface: reset(match?) forgets the matching calls (everything without criteria), stop() / start() bracket the calls a test makes to set itself up.

isContext grew an id

It could narrow by path, type and method, but not by the record a get/update/patch/remove addresses:

isContext({ method: 'patch', id: 1 })
isContext({ method: ['patch', 'remove'], id: null })

null is a value to match rather than a way of saying "no criterion", since that is what Feathers puts on the context for the multi variants — so unlike the other criteria, only undefined means "not given". Ids compare strictly. checkContext takes the criterion along and now names a mismatching id in its error, which it would otherwise have reported as an empty pair of parentheses.

Two behaviours worth knowing at review time

  • calls.all holds one entry per recorded hook, so a successful call is two entries with the default types. The per-type indexes are the per-call view, and waitFor counts calls.
  • The hook type a call was recorded in is pinned onto the recorded context. Feathers keeps one context per call and sets context.type back to 'around' once the chain moves on, so a recorded context would otherwise forget which hook saw it. Pinning is what keeps isContext({ type }) honest on the record. Without snapshot, the carrier is a view whose prototype is the live context, so data, result and params still read through to it.

Also in here

  • docs plumbing for the new category: nav and sidebar entry, TestingTable, docs/testing/
  • a testing tag and a context tag in the closed tag vocabulary
  • each @example on a page with several of them now gets a ### Example N heading, so a single example can be linked to
  • fix(docs): the category vocabulary moved out of utilities.ts, which reads the source tree with fs/typescript/prettier — a value import from a theme component put all of that into the client bundle and killed every page in docs:dev with createRequire is not a function

test/index.test.ts asserts the new entrypoint's export surface, and dist/testing.mjs is a separate build entry. 56 runtime tests plus type-level tests for recordHooks alone; pnpm test is green (record-hooks at 100% statements, 96% branches).

🤖 Generated with Claude Code

Adds an eighth barrel, `feathers-utils/testing`, for test-only helpers. It is
deliberately absent from the root barrel so it cannot ride along in a
production bundle.

`recordHooks(appOrService, options?)` registers one hook per requested type and
records every call that passes through, indexed by hook type and method:

    const calls = recordHooks(app)
    await app.service('users').find({ query: { name: 'jane' } })

    expect(calls.before.find).toHaveLength(1)
    expect(calls.before.create).toHaveLength(0)

- what to record is narrowed by the same criteria as `isContext` — `path`,
  `method` and `type`, each one value or many; `type` also decides which hooks
  get registered, `around` included
- every entry is a plain array, and an unrecorded method reads as `[]`, so it
  composes with any predicate without a guard:
  `calls.before.find.filter(isContext({ path: 'users' }))`
- `reset(predicate?)` forgets what the predicate matches, `stop()`/`start()`
  bracket the calls a test makes to set itself up
- the hook type a call was recorded in is pinned onto the recorded context,
  because feathers keeps one context per call and sets `context.type` back to
  `'around'` once the chain moves on
- `snapshot: true` records a copy, for assertions made after the call resolved

Also adds the docs plumbing for the new category and a `testing` tag, on
`recordHooks` and `waitForServiceEvent`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploying feathers-utils with  Cloudflare Pages  Cloudflare Pages

Latest commit: 128a613
Status: ✅  Deploy successful!
Preview URL: https://e7cc00e3.feathers-utils.pages.dev
Branch Preview URL: https://feat-testing-record-hooks.feathers-utils.pages.dev

View logs

fratzinger and others added 8 commits September 11, 2026 09:47
A utility with several `@example` tags rendered them as one flat run of code
blocks under `## Examples`, so only the section as a whole was addressable.
Each example now gets a `### Example N` subheading — and with it an id — so a
single example can be linked to directly. A page with one example keeps the
plain `## Example` heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs build is what verifies the generated markdown, and it does so on the
real pages rather than on a fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TaggedUtilities.vue` imported `utilityCategories` from `utilities.ts`, a module
that reads the source tree with `fs`, `typescript` and `prettier`. That put the
whole thing into the client graph, where vite replaces the node built-ins with
stubs — and the first `createRequire` call from typescript took every page down
with `createRequire is not a function`, since the component is registered
globally. Only `docs:dev` was affected: the production build tree-shakes the
unused imports away, while the dev server serves whole modules.

The vocabulary now lives in its own dependency-free module, beside the tag
vocabulary it mirrors. `utilities.ts` does not re-export it, so the only way to
reach it is the safe one; its types stay where they are, being erased anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isContext` narrowed by path, type and method, but not by the record a
`get`/`update`/`patch`/`remove` addresses:

    isContext({ method: 'patch', id: 1 })
    isContext({ method: ['patch', 'remove'], id: null })

`null` is a value to match rather than a way of saying "no criterion", since
that is what feathers puts on the context for the multi variants — so unlike
the other criteria, only `undefined` means "not given" here. Ids are compared
strictly: `3` does not match `'3'`.

`checkContext` accepts the criterion along with the rest of `IsContextOptions`
and now names a mismatching id in its error, which it would otherwise have
reported as an empty pair of parentheses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The record could only be read after the fact, so a test that wanted the first
call — or wanted to prove that no call happened — had to sleep first.

    const [context] = await calls.waitFor({ context: { method: 'create' } })
    await calls.waitFor({ context: { path: 'users' }, count: 0 })

`count` is how many matching calls to wait for, counting the ones already in
the record. `0` inverts the wait: it resolves once the window has passed
without a match and rejects the moment one arrives, so the test fails
immediately instead of sleeping and asserting afterwards.
`resetBefore`/`resetAfter` forget the matching calls around the wait, and both
`waitFor` and `reset` take either `isContext` criteria or a predicate.

Timeouts follow `waitForServiceEvent` — a number of milliseconds or `false` —
with one exception: `count: 0` always waits its window out, so it defaults to
a short one and refuses `false`, which could never settle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An axis the Context group was missing: utilities that read the call itself —
`method`, `type`, `path`, `id`, `service` — rather than one of its payloads.
Tags `isContext`, `checkContext`, `contextToJson` and `debug`.
Three things a test could not say before, all of them from real use:

- `waitFor({ count: 0 })` rejected on a match already in the record, and the
  only way around it forgot the very call the test still needed as evidence.
  `since: 'now'` takes the baseline at the wait instead, leaving the record
  alone: `{ count: 0, since: 'now' }` is "no *further* call".
- `quietFor` resolves once nothing matching has arrived for that long, with
  every match it saw — the debounce-shaped wait, and the exact assertion that
  `count` cannot give, since `count` is a lower bound. It has to be shorter
  than `timeout`, which stays the hard deadline.
- `recordHooks` registered the `before` hook only, so `calls.after` was
  silently empty and waiting for it could only time out. It now records
  `before`, `after` and `error` by default — so `calls.all` holds two entries
  per call, while the per-type indexes stay the per-call view. `around` is
  still opt-in: at the point it records it sees what `before` sees. A wait for
  a type that is not recorded now rejects immediately and names the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recording `before` and `after` by default made one call two entries, and
`waitFor` counted those: a single call satisfied `count: 2`, and `quietFor`
resolved with two contexts for it.

A recorded entry now maps back to the context of the call it came from — the
one object feathers reuses for the whole call — so a call counts once and the
wait resolves with one context per call, the first entry recorded for it. The
timeout message counts the same way.

Two consequences fall out of that mapping. With `since: 'now'`, the calls
already out are remembered, so one of them coming back is not a further call
and neither counts nor trips `count: 0`. And `resetAfter` together with
`since: 'now'` is refused: one keeps the evidence that is already recorded,
the other forgets it.
@fratzinger
fratzinger merged commit 67e1172 into main Sep 11, 2026
9 checks passed
@fratzinger
fratzinger deleted the feat/testing-record-hooks branch September 11, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant