feat: add testing entrypoint with recordHooks - #52
Merged
Merged
Conversation
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>
Deploying feathers-utils with
|
| 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 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
The record is indexed by hook type and then method —
calls.before·calls.after·calls.error·calls.around— plusallin 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:What to record is narrowed by the same criteria as
isContext, each one value or many:typedoubles as the registration: only the requested hooks are registered. It defaults to['before', 'after', 'error']— the three phases of a call — socalls.afteris populated without asking.aroundstays opt-in: at the point it records, it sees whatbeforesees.Waiting instead of sleeping
waitForis the part that replacessleepin a test:contexttakesisContextcriteria directly, or any predicate — as doesresetcount(default 1) is a lower bound;0inverts the wait and rejects on the first matchsince: '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 therequietForresolves once nothing matching has arrived for that long, with every match it saw — the exact assertioncountcannot give. It has to be shorter thantimeout, which stays the hard deadlineresetBefore/resetAfterforget the matching calls around the wait; a failed wait keeps the record, since that is the evidencetimeoutfollowswaitForServiceEvent: milliseconds orfalse, default 5000 — exceptcount: 0, which always waits its window out, so it defaults to 50 and refusesfalseCounting is by call, not by recorded entry: a call the recorder saw in
beforeand again inaftercounts once, and the wait resolves with one context per call. Combinations that could never settle are refused with aTypeErrorrather 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.isContextgrew anidIt could narrow by path, type and method, but not by the record a
get/update/patch/removeaddresses:nullis 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, onlyundefinedmeans "not given". Ids compare strictly.checkContexttakes 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.allholds 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, andwaitForcounts calls.context.typeback to'around'once the chain moves on, so a recorded context would otherwise forget which hook saw it. Pinning is what keepsisContext({ type })honest on the record. Withoutsnapshot, the carrier is a view whose prototype is the live context, sodata,resultandparamsstill read through to it.Also in here
TestingTable,docs/testing/testingtag and acontexttag in the closed tag vocabulary@exampleon a page with several of them now gets a### Example Nheading, so a single example can be linked tofix(docs): the category vocabulary moved out ofutilities.ts, which reads the source tree withfs/typescript/prettier— a value import from a theme component put all of that into the client bundle and killed every page indocs:devwithcreateRequire is not a functiontest/index.test.tsasserts the new entrypoint's export surface, anddist/testing.mjsis a separate build entry. 56 runtime tests plus type-level tests forrecordHooksalone;pnpm testis green (record-hooks at 100% statements, 96% branches).🤖 Generated with Claude Code