Skip to content

TNC-2432 / v2.2 / feat(testing): publish the testing entry, with withSpies, UnmockedCallError and the fixture builders - #59

Merged
aervin merged 13 commits into
mainfrom
feat/testing-entry-export
Sep 15, 2026
Merged

aervin merged 13 commits into
mainfrom
feat/testing-entry-export

Conversation

@agent-heimdall

@agent-heimdall agent-heimdall commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Third slice, on top of #56, and the one that makes the entry real: @truenas/api-client/testing is a published subpath now, so it moves under the same semver rules as the main entry.

import { createFakeClient, fakeApiError, withSpies } from '@truenas/api-client/testing';

What it adds

./testing subpath export built by tsup alongside the main entry, covered by check:dist, publint and attw
UnmockedCallError createFakeClient({ strict: true }) fails an unscripted call instead of hanging
withSpies(client, vi.fn) wraps the client's verbs in the caller's spies, in place
fakeApiError, fakeApiVersion, fakeAuthResponse the remaining fixture builders from the proposal, beside fakeJob
AuthResponseType now a value export from both entries — a string enum rejects its own literals, so without it only the default arm of fakeAuthResponse / failNextLogin was reachable
TypeDoc documents and link-checks the testing entry; both modules are named after their import paths

Two packaging findings worth a reviewer's attention

splitting: true is load-bearing, not tidy. Two entries built from one source each bundle their own copy of every class they use — tsup splits for ESM by default, for CJS it does not. The values still compare equal, because the enums are strings, so nothing looks wrong until a consumer writes expect(client).toBeInstanceOf(TrueNasApiClient) against a fake from the other entry under Jest and is told it is not one. check-dist.mjs now builds a fake client and asserts it is an instance of the main entry's class, in both formats; removing the flag fails that check while every other gate stays green.

The alias guard had stopped covering the output. It scanned a hardcoded list of eight files, which was the whole output until splitting arrived — after it, dist/index.js is an 858-byte re-export stub and the code and shared types live in content-hashed chunks no list can name. An import("@/…") in the emitted .d.ts passed in silence. It walks dist now and fails if the walk finds fewer files than the two entries in two formats plus their types.

Docs URLs move on the next release. With a second entry point TypeDoc switches to module mode, so every page path gains a module prefix — classes/TrueNasApiClient.html becomes classes/_truenas_api-client.TrueNasApiClient.html. Existing deep links to the reference will 404; worth a line in the release notes.

typesVersions maps the subpath for node10, which attw fails without: modern Node honours exports at runtime, but ts-jest's legacy resolution finds the subpath's types only through that mapping. All four resolution modes are 🟢 for both entries.

UnmockedCallError is opt-in, against the proposal's line

The proposal said send throws for an unregistered method, full stop. It cannot: at send time a frame that mock will answer and a frame that connection.reply will answer two lines later are indistinguishable. Refusing unconditionally would break every hand-driven spec — 24 in this repo's own suite. So it is createFakeClient({ strict: true }), which is the spec's claim that it scripted everything. The client's own core.subscribe frames are exempt.

withSpies installs unbound, deliberately

Both runners invoke the implementation with the this of the call, so a verb reached as client.api.call(…) gets its own object either way. Binding would make a detached const { call } = client.api work under spies where it throws without them — a spied client more permissive than an unspied one is the divergence this package exists to prevent, in the helper meant to observe it.

Two things follow that took a while to get right, and both have tests:

  • SpyFactory is exported so a consumer can pass something other than vi.fn, and the contract it cannot express in the type is that the returned function must forward this. An arrow does not, nor does (impl) => vi.fn((...args) => impl(...args)) — the shape you reach for wanting a spy and something of your own. Both type-check. The wrapper names the problem instead of letting the real method fail on its own first line with Cannot read properties of undefined.
  • The wrapper calls the method on every path rather than refusing on a missing this. callAndGetJobId's body is inside a defer, so it tolerates a detached call and returns an observable — refusing made the spied client stricter than the real one, which is the same divergence pointing the other way.

The fixtures are checked against middleware, not against each other

fakeApiError produces the frame /api/<version> actually sends — a JSON-RPC error with the TrueNAS payload under data, code: -32001. Its trace is an object rather than null, because both arms that send -32001 pass sys.exc_info(); and class and repr are derived so the pair is one an appliance could send, with formatted explicitly outside that guarantee.

That derivation needs a Python repr(), and pythonRepr is checked against python3 rather than against expectations written here — a differential over every code point plus random strings. The one limit it cannot escape is documented on the class it uses: \p{Cn} is a property of a code point in a Unicode version, and the engine's table and the appliance's Python's are not the same, so the two disagree on code points one has assigned and the other has not.

fakeAuthResponse builds per arm, because auth.login_ex returns a discriminated union: SUCCESS carries user_info/authenticator/reconnect_token, OTP_REQUIRED carries username, REDIRECT carries urls, and AUTH_ERR and EXPIRED carry nothing. The arms are a Record keyed by the enum, so a member added to AuthResponseType fails the build rather than silently taking an empty arm.

Two type gaps this surfaced, both outside the diff

  • AuthResponse declares max_session_age and max_inactivity. Neither is on any arm of middleware's union at any version — max_session_age exists only as an internal AAL attribute. The builder never invents them.
  • AuthResponseType is two arms short: AuthLoginExResult.result has seven, and DENIED and SCRAM_RESPONSE are missing, so this package cannot name two of the responses a v26+ appliance can send. AuthRespScram requires scram_type and rfc_str, so adding it is not just an enum edit — which is why ARMS fails the build when it arrives.

Happy to take either as a follow-up.

Verification

Seven local review rounds before the CI review, one after. 642 tests (43 files), and all eleven gates exit 0: tsc base/spec/scripts, eslint, vitest, tsup, check-dist, typedoc, publint, pack, attw.

Round 7 is the first clean one, and the honest summary of the other six is that the top finding in each was inside the previous round's repair — a shared array introduced by an exhaustiveness fix, a repr quoter that escaped three of thirty-three characters, a this guard that renamed every spied verb. Every one is now pinned by a test that fails when the mechanism is broken.

The CI review then found two HIGHs, both fixed in 4ba575f: AuthResponseType was on neither entry, and fakeApiVersion was the one builder not going through present(), so an explicit undefined landed on a required field. The testing entry's runtime exports are now pinned by src/testing/index.spec.ts, as the main barrel's are — the enum went missing because every spec imported it from @/types. The two cheap LOWs were taken too (JobUpdate aliases FakeJobOverrides; TypeDoc covers the entry). A local review against the CI prompt and rubric came back with no findings at MEDIUM or above.

Six LOWs are open and not addressed, per direction on the previous PR:

  1. callAndGetJobId is the one verb a non-forwarding spy factory still fails opaquely on, and two docblocks read as though it does not.
  2. A comment says both runners copy the implementation's name and length; jest copies length, not name.
  3. Wrapping a thrown error changes its class, so "the outcome is always the real method's" is true of a return and not of a throw.
  4. this === null still reaches the opaque failure rather than the named one.
  5. FakeAuthenticator's own answers (success(), succeedNextLogin, failNextLogin) still build bare responses rather than going through fakeAuthResponse, so an unscripted login has no user_info.
  6. The last line of the synthetic trace.formatted is the repr; a real traceback ends with Class: reason, or the bare class.

🤖 Generated with Claude Code

@agent-heimdall agent-heimdall self-assigned this Sep 15, 2026
@bugclerk bugclerk changed the title feat(testing): publish the testing entry, with withSpies, UnmockedCallError and the fixture builders TNC-2432 / v2.2 / feat(testing): publish the testing entry, with withSpies, UnmockedCallError and the fixture builders Sep 15, 2026
@bugclerk

Copy link
Copy Markdown

Comment thread src/testing/index.ts Outdated
* classes and a fake client really is an instance of the exported one —
* `scripts/check-dist.mjs` fails the build if that stops being true.
*
* **This is public surface.** A breaking change here breaks consumers' suites,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — This entry becomes public surface here, but typedoc.config.mjs still has entryPoints: ['src/index.ts'], and src/index.ts exports nothing from src/testing. TypeDoc only walks what is reachable from an entry point, so none of the fifteen symbols below appear in the published docs, and docs:check (which runs with validation.invalidLink: true and treatWarningsAsErrors: true) never looks at these files.

That second half is load-bearing rather than cosmetic: src/testing/create-fake-client.ts now writes {@link UnmockedCallError} and {@link FakeConnectionOptions.strict} in the strict docblock, and that file imports neither name. Both would be reported as unresolved links the moment the entry point is added — which is the check the rest of the package already gets.

Adding 'src/testing/index.ts' to entryPoints covers both, at the cost of fixing those two links first.

Comment thread src/testing/with-spies.ts
// is added only to a failure that was going to happen anyway.
const method = original as (this: unknown, ...args: unknown[]) => unknown;
const forwarded = function (this: unknown, ...args: unknown[]): unknown {
if (this !== undefined) return method.apply(this, args);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — The diagnosis only reaches verbs that read this synchronously. callAndGetJobId's body is entirely inside a defer, so with a non-forwarding factory it neither throws here nor returns anything usable — the failure surfaces on subscribe, as Cannot read properties of undefined (reading 'dispatch') out of a bundled chunk, naming neither spies nor this.

Concretely, with the shape the SpyFactory docblock calls out as the one a consumer reaches for:

const c = withSpies(createFakeClient({ version: 'v27.0.0' }), impl => vi.fn((...a) => impl(...a)));
c.api.callAndGetJobId('app.delete', ['plex']).subscribe();  // TypeError, unexplained

with-spies.spec.ts exercises the guard through c.api.call only, so the gap is not visible from the suite either. Worth either narrowing the SpyFactory docblock's "withSpies detects that and says so" to the synchronous case, or wrapping the returned value when it is an Observable so the deferred verbs get the same sentence.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Three findings: three LOW. No human review required.

The packaging work is the strongest part of this change. check-dist.mjs going from a hardcoded list of eight files to readdirSync(recursive) is a real fix — with splitting: true the alias guard was scanning stubs and skipping the chunks that hold the shared code — and the instanceof assertion across both formats turns "splitting is tidy" into a gate that fails if the flag is removed. Exporting AuthResponseType as a value from both entries closes a genuine hole: a string enum rejects its own literals, so without it only the default arm of fakeAuthResponse and failNextLogin was reachable from outside the package.

The three findings, all inline:

  • LOWfake-auth-response.ts:61: FakeAuthenticator.success() / failNextLogin still hand-build { response_type }, so every unscripted login answers with the bare shape fakeAuthResponse was added to retire — and sessionLifetime falls through to the client default rather than the user_info.attributes.preferences.lifetime a real appliance sends. fake-authenticator.ts is outside this diff, so it is a reuse gap, not a regression.
  • LOWwith-spies.ts:60: CONNECTION_METHODS is the one of the three lists with no pin against its class. API_VERBS has a runtime walk and an Exclude, AUTHENTICATOR_METHODS has an Exclude; this has neither, so a new outbound method on TrueNasConnection would be classified by fake-connection.spec.ts and still go unspied.
  • LOWfake-auth-response.ts:39: reconnect_token: null is unconditional and present() drops an undefined override, so no success response can be built without the field — which v25.10 never sends. The docblock note saying so was dropped in 3739898.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a human review. 2 finding(s): 2 LOW.

Nothing blocks, but this change is one a person should decide on:

  • package.json:20 adds ./testing to exports, placing fifteen new symbols permanently under the package's semver rules
  • tsup.config.ts:9 enables splitting, reshaping the already-published main entry into stubs over chunks via tsup's experimental CJS splitting

Agent Heimdall and others added 11 commits September 15, 2026 09:52
Third slice, part one. Still no package export — `src/index.ts` is
untouched and nothing here reaches `dist`.

`withSpies(client, vi.fn)` wraps the client's verbs in the caller's spies,
in place, and returns the same client. The spy factory is an argument
rather than an import, because this package does not depend on a runner
and must not start; both runners' `fn` take an implementation and return
something callable with the same signature, which is all this needs to
know about them.

The implementation is installed *unbound*. Both runners invoke it with
the `this` of the call, so a verb reached as `client.api.call(…)` gets its
own object either way — and binding would make a detached
`const { call } = client.api; call(…)` work under spies where it throws
without them. A spied client that is more permissive than an unspied one
is the divergence this package exists to prevent, in the helper meant to
observe it. There is a test for that, and it fails if the bind comes back.

Which verbs get spied is enumerated rather than discovered, and the list
is pinned the way `fake-connection.spec.ts` pins the connection's: a
runtime walk plus a type-level `Exclude`, so a member added to
`TrueNasApi` fails the test until someone classifies it. That found two
`private` members the walk sees and `keyof` does not, and corrected two
names I had put on the list that the class does not have.

`fakeApiVersion` goes through `parseApiVersion` rather than writing out
the year/minor/patch split, so there is one statement of that rule and
the fixture cannot drift from it. It throws on a string the parser
rejects, naming it, instead of handing back the `null` a spec then
carries a non-null assertion for.

`fakeAuthResponse` completes an `AuthResponse`, whose `user_info` has
twenty-three required fields and three nested objects. Every spec in this
repo that needed one wrote three of them and cast the rest away with
`as unknown as AuthResponse` — which accepts a `response_type` that is not
one, and is what a consumer copies out of our specs.

`user_info` follows `response_type`: middleware sends it on success and
not otherwise, so asking for `AUTH_ERR` gets a response without one
rather than a successful login wearing a failure's label. Passing one
explicitly still works — the builder declines to add it, it does not take
it away.

`present()` moves out of `fake-job.ts` into its own module, since all
three builders need it. It is the rule that an override of literally
`undefined` means "unchanged": a `Partial<…>` built with a conditional
puts `undefined` where a required field is declared, and spreading that
over the defaults emits an object that fails wherever it is read rather
than where it was built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd it

Third slice, part two. `@truenas/api-client/testing` is now a real
subpath export, and with it the entry stops being a private corner of
this repo and becomes public surface under the same semver rules as the
main one.

## An unmocked call can fail instead of hanging

`createFakeClient({ strict: true })` makes a frame nothing is scripted to
answer fail with `UnmockedCallError`, naming the method. It is a named
class so a consumer's global guard can tell "the spec forgot a mock" from
a failure the code under test produced — the two are otherwise the same
shape, and a suite that cannot tell them apart reports a missing fixture
as a bug in the thing being tested.

It cannot travel as an error *frame*: `dispatch` reduces any frame's
error to `new Error(getApiErrorMessage(…))`, which delivers the message
and drops the class. Thrown from `send` instead, which `dispatch` calls
inside a `defer`, so the caller still gets a failing observable rather
than an exception where the call was written.

**Opt-in, against the proposal's line.** The proposal said `send` throws
for an unregistered method, full stop. It cannot: the two ways to answer
are indistinguishable at `send` time. A spec using `mock` registers its
answer before the frame goes out; a spec driving frames by hand calls
`reply` after it. Refusing an unregistered method unconditionally would
refuse every hand-driven spec, including 24 in this repo's own suite.
Strictness is the spec's claim that it scripted everything, and only the
spec can make it.

The client's own `core.subscribe` frames are exempt, because a spec
cannot script what it does not know the client sends. `core.unsubscribe`
is never sent and the 20-second `core.ping` cannot fire without a socket,
so neither is on that list — if either starts arriving, a strict spec
fails naming it, which is how we would want to find out.

## Packaging

`tsup` gains the second entry and `splitting: true`, and that flag is
load-bearing rather than tidy. Two entries built from one source each
bundle their own copy of every class they use unless the build splits
them out; for ESM tsup splits by default, for CJS it does not. The values
still compare equal — the enums are strings — so nothing looks wrong
until a consumer writes `expect(client).toBeInstanceOf(TrueNasApiClient)`
against a fake from the other entry and is told it is not one.

`check-dist.mjs` now covers both entries rather than one, which the
proposal called for: the alias-leak guard reads the testing files too,
both entries are load-tested in both formats, and a third check builds a
fake client and asserts it is an instance of the main entry's
`TrueNasApiClient`. Without `splitting: true` that check fails for CJS
while every other check in the repo passes — verified by removing it.

`typesVersions` maps the subpath for node10 resolution, which is what
`attw` fails without: modern Node honours `exports` at runtime, but
TypeScript's legacy `node` resolution — which ts-jest still uses — finds
the subpath's types only through that mapping. All four resolution modes
are green for both entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sends

The last fixture the proposal asked for, and the one that turned up a
divergence in the two slices before it.

A failed method call on `/api/<version>` answers with a JSON-RPC error
whose `data` carries the TrueNAS payload:

    {"code": -32001, "message": "Method call error",
     "data": {"error": 22, "errname": "EINVAL", "reason": "…",
              "trace": null, "extra": null}}

The flat `{error, errname, extra, reason}` this repo has been writing is
what `/websocket` sends — a different handler on a different route, which
this client never opens. Verified against middleware master at `4303dc8`:
`api/base/server/ws_handler/rpc.py:81-124` builds the envelope and
`:408,422` passes `"Method call error"` for both the `CallError` and the
generic arms, `apps/websocket_app.py:74-95` builds the flat one,
`main.py:553` routes every `/api/{version}` to the first, and
`middlewared_docs/docs/jsonrpc.rst` documents `-32001`.

Nothing in the client breaks on either, which is why it went unnoticed:
`getApiErrorMessage` reads `data.reason` for one and `reason` for the
other and answers with the same string, so a spec cannot tell them apart
by the message. A consumer branching on `error.code` or
`error.data.errname` can, and would pass against our fake and fail
against an appliance.

So the builder produces the envelope, and its spec pins the difference
rather than the message: the assertion that survives a flat fixture is
the envelope's own `message`, which a flat payload does not have. A test
that only checked `getApiErrorMessage` would pass for both, and the one
test here that still passes against a flat fixture is the one that drives
a real client — which is the finding, stated as a test.

`error` and `errname` are the caller's to keep consistent. Middleware
derives the name from the number with `get_errname`, so reproducing the
pairing would mean carrying a copy of Python's errno table — which this
package has declined to do elsewhere, for roles, on the same grounds.

What this does *not* change: `TrueNasMessage['error']` still declares the
flat shape, and `mock-answers`' own `notFound` still sends it. Both are
wrong in the same way and both are waiting on a decision about where the
fix lands, since the type was widened on the open slice-2 PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One statement of the envelope, now that both branches have one.

`fakeApiError` declared its own `TrueNasErrorData` because the type did
not exist yet when it was written; the error-shape fix on the slice-2
branch put it in `api-error.type.ts`, next to `ApiError`, where a
consumer can reach it. The builder takes it from there and returns the
frame type rather than the looser `JsonRpcError`.

`mock-answers`' `notFound` was the second place spelling out
`code: -32001` and `"Method call error"`. It calls the builder now, so
the only thing it states is the part that is its own: the reason
`MatchNotFound()`, which is what `rpc.py`'s generic arm makes of a bare
`IndexError`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four passed every gate, which is the point of the round.

## The alias guard stopped covering the output

`splitting: true` — added two commits ago so both entries share one copy
of the client — left `dist/index.js` an 858-byte re-export stub and put
the code, and the types both entries share, in content-hashed chunks.
`check-dist.mjs` scanned a hardcoded list of eight files, so an
`import("@/…")` in the emitted `.d.ts` passed in silence while the script
printed a guarantee it had stopped enforcing. It walks `dist` now, keeps
the `.map` exclusion by extension rather than by name, and fails if the
walk finds fewer files than the two entries in two formats plus types —
a glob that matches nothing otherwise passes every check under it.

## The spy inventory pinned a copy of the list

`with-spies.spec.ts` restated `API_VERBS`' nine strings instead of
reading them. So half the mechanism worked — a verb added to
`TrueNasApi` failed the test — and the other half did not: deleting
`'queryOne'` from `API_VERBS` left the whole suite and all three tsc
projects green while the verb silently stopped being spied, which is the
one state the inventory exists to make impossible.

The lists are exported now and the spec builds its classification from
them. The authenticator had the same hole one level down: the test
iterated `AUTHENTICATOR_METHODS`, so a shorter list was a shorter test.
It gets the type-level half of the same instrument, which fails on a
dropped method by name.

## Two fixtures produced shapes the appliance does not send

`fakeApiError` defaulted `trace: null`. Both arms that send `-32001`
pass `sys.exc_info()`, which inside an `except` is always truthy, so
`format_truenas_error` always builds one — `trace: null` on that code is
not a frame the versioned endpoint produces, and it is the field a
consumer would branch on to tell a clean `CallError` from a crash. It
carries a trace object now; the contents are synthetic, because a fixture
has no Python stack to format, and the docblock says so rather than
implying the whole thing was verified.

`fakeAuthResponse` enforced "follows `response_type`" for `user_info` and
filled the rest of the envelope regardless, so an `AUTH_ERR` came back
carrying `authenticator: 'LEVEL_1'`. `auth.login_ex` returns a
discriminated union and each arm carries only its own fields
(`4303dc8:src/middlewared/middlewared/api/v27_0_0/auth.py:187-243`):
`SUCCESS` has `user_info`, `authenticator` and `reconnect_token`,
`OTP_REQUIRED` has `username`, `REDIRECT` has `urls`, and `AUTH_ERR` and
`EXPIRED` have nothing at all. It builds per arm now.

Worse than the round reported: `max_session_age` and `max_inactivity` are
on *no* arm at any version — middleware has `max_session_age` only as an
internal AAL attribute — and the builder defaulted both on every
response. They are settable and never defaulted, and
`src/types/auth.type.ts` declaring them is worth its own look.

## And the smaller ones

`TrueNasErrorFrame` and `TrueNasErrorData` are exported from both
entries. The slice-2 commit said the type went "next to `ApiError`, where
a consumer can reach it"; it was not exported, so they could not.

`FakeApiErrorOverrides` is picked field by field rather than
`Partial<TrueNasErrorData>`, which inherited that type's index signature
and turned off excess-property checking: `fakeApiError({ resaon: … })`
typechecked, put `resaon` in the payload and left `reason` at its
default. There is a `@ts-expect-error` proof of the refusal.

`UnmockedCallError`'s docblock promised a failing observable for the
whole client. That holds for every `TrueNasApi` verb, which dispatches
inside a `defer`; the authenticator sends from its method bodies, so
`logout` and `newApiKey` throw where the call is written. Both fail and
both name the method, but only one is catchable off the observable, and
now the class says which is which — with a test.

`check-dist.mjs` stops naming a version, and the comment above
`API_VERBS` stops claiming `generateToken` is left off a list it is on.

**One finding rejected.** The round reported that the middleware
citations do not resolve — `rpc.py:408,422` past the end of a 403-line
file, `main.py:553` landing elsewhere. At `4303dc8` that file is 435
lines, `:408` and `:422` are both the `"Method call error"` sends, and
`main.py:553` is the `/api/{version}` route. The coordinates it offered
are the working tree's, which sits on `fix/dump-api-keep-refs-25.10` —
a path read without its ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roducible

Round 2. The MEDIUM is new — introduced by round 1's own fix, which is the
pattern this loop keeps finding.

## The per-arm builder had no exhaustiveness guard

Round 1's fix was a ternary chain over `AuthResponseType` ending in an
empty arm, so a member added to the enum would take that arm silently:
no compile error, no failing test. The spec's five rows were a list of
today's arms rather than a check that they are all of them — the same
"pinned a copy" shape the previous commit removed from
`with-spies.spec.ts`, put back one file over.

Not hypothetical. `AuthLoginExResult.result`
(`4303dc8:src/middlewared/middlewared/api/v27_0_0/auth.py:335-338`) is a
seven-arm union; `AuthResponseType` names five. Adding the missing two
is the ordinary next step, and `AuthRespScram` (`:245-262`) requires
`scram_type` and `rfc_str` — so the builder would have answered with
`{ response_type: 'SCRAM_RESPONSE' }`, a frame that arm cannot be. That
is the defect the previous commit claimed to fix, arriving through the
door the fix left open.

It is a `Record<AuthResponseType, Partial<AuthResponse>>` now: adding
either member fails the build naming it. The spec keeps its explicit
rows, because deriving them from that record would assert the builder
emits what its own table says — and gains a test that every arm in the
record has a row.

## The synthetic trace named a class that cannot produce it

`class: 'CallError'` with `repr: <reason>` is not a pair an appliance
sends. `CallError.__init__` is `(errmsg, errno=EFAULT, extra=None)` and
always passes all three to `super()`
(`4303dc8:src/middlewared/middlewared/service_exception.py:15-21`), so it
is never argument-free and its repr is never the reason; and its
`__str__` is `[ERRNAME] errmsg` (`:22-24`), so a real frame classed
`CallError` has a reason starting `[EINVAL] `.

The docblock hedged the contents — "only the shape is faithful" — and
then made exactly one content claim, about `repr`, which the class it
chose made false.

`class` and `repr` are derived from the reason now, and the rule is the
one middleware's own code implies: the generic arm sends
`str(error) or repr(error)`, so a reason that reads as a bare repr —
`MatchNotFound()` — means an argument-free exception, and the class is
its name. Anything else is `str(e)` of an exception with arguments, so
the class is `ValueError` and the repr is that call written out. Only
`formatted` stays synthetic, and the docblock says so.

`mock-answers`' `notFound` gets the right pair for free: an empty `get`
raises `MatchNotFound`, and its test now pins `class` instead of
accepting any string.

## Prose the code did not support

- `check-dist.mjs` called its old list "the four entry files" sixteen
  lines above calling their types four; it was eight.
- `fakeAuthResponse` said `user_info` has twenty-three required fields.
  Twenty-two.
- Its `satisfies AuthResponse` claim covered "a field added"; only
  *required* ones fail there, and every member but `response_type` is
  optional. The `user_info` literal is the half that does bite.
- The arm table was presented as middleware's union. It is this package's
  enum, which is two arms short of it, and saying so is what makes the
  gap visible rather than invisible.
- `UnmockedCallError` blamed the call-site throw on where the
  authenticator sends from. All six of its methods send from their own
  bodies; the reason only `logout` and `newApiKey` throw is that
  `FakeAuthenticator` auto-replies `auth.login_ex` and not those two.
- `FakeConnection` said a housekeeping frame that started arriving would
  fail a strict spec naming it. True for `core.unsubscribe`; `core.ping`
  goes out through `ws.next(…)` rather than `send`, so it cannot reach
  the check at all.
- `withSpies`' public docblock said each spy wraps the method "bound to
  its own object", directly contradicting the comment and the test that
  make the point of leaving it unbound.
- One docblock in `with-spies.spec.ts` was orphaned by the round-1 edit
  and explained nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3. Both findings are in round 2's repair, which is now three
rounds in a row where the fix is where the next defect lands.

## The exhaustiveness fix made one array shared by every response

Moving the per-arm literals into a module-level `Record` moved their
allocation to module load, and a spread copies references — so every
`REDIRECT` response handed back the *same* `urls` array. A spec pushing a
second SSO URL onto one response, or code under test calling `.sort()` on
it, changed every later response in that file, and the failure landed in
a test that did not cause it.

The chain this replaced built its literal per call and did not have the
problem. Each entry is a function now, which keeps both properties: the
`Record` still fails on an enum member nobody has handled, and each call
still gets its own objects.

## The producible-triple fix built a repr Python cannot produce

`ValueError('${reason}')` is not `repr()`. CPython picks `"` when the
string contains a `'` and no `"`, and escapes the backslash, the chosen
quote and the control characters. Both shapes that hit it are the common
case rather than the exotic one: middleware names things with `{x!r}` in
f-strings, so single quotes are everywhere in `CallError` messages, and
`adapt_exception` puts a newline in every `CalledProcessError` message
(`4303dc8:src/middlewared/middlewared/service_exception.py:114`).

So the commit that set out to make the triple producible emitted
`ValueError('Unsupported idmap type 'RFC2307'')` — not a value `repr()`
returns — one line away from the sentence claiming it was.

There is a quoter now, and its test compares against CPython's own output
for the five shapes that matter rather than against hand-written
expectations. Nothing had tested this: every reason in both specs was
free of quotes, backslashes and newlines, and `trace.formatted` was
pinned by nothing at all — mutating it to `''` left all 597 green. It has
an assertion now too.

## Two claims narrowed to what is true

The docblock said a frame classed `CallError` always has a reason
starting `[EINVAL] `. It starts `[get_errname(self.errno)] `, and the
constructor's default errno is `EFAULT`; `EINVAL` is what *this fixture*
defaults to, which is a different fact wearing the same name. And on the
adapted path the reason and the trace describe different exceptions
entirely — `adapt_exception` returns a new `CallError` whose `str()`
becomes the reason while `sys.exc_info()` is still the original. Both are
now stated as the limits of the default rather than left implied.

`satisfies UserInfo` "anything added there fails on the spot" repeated,
two sentences later, exactly the overclaim the same paragraph had just
corrected for `satisfies AuthResponse`. Optional additions pass there
too, and `AuthUserInfo` extends a model middleware adds defaulted fields
to.

## And an apology to the next reviewer

The previous commit ran prettier over `with-spies.spec.ts` and committed
an 88-line diff whose substance was a 10-line docblock merge — double
quotes and trailing commas that no gate here enforces and that no other
file in `src/testing/` uses. This repo has no prettier config; running it
was my mistake, not a convention. That file is back to its previous form
with only the docblock change, and `check-dist.mjs` keeps a comment line
that was pasted rather than reflowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 4. The MEDIUM is in round 3's repair — fourth round running.

## The quoter escaped three characters of the thirty-three

Round 3's fix replaced string interpolation with a quoter, and its
docblock said it escapes "the backslash, the quote it chose, and the
control characters". It escaped `\n`, `\r` and `\t`. CPython escapes
every C0 control, `DEL`, and every code point `str.isprintable()`
rejects — as `\xNN`, `\uNNNN` or `\UNNNNNNNN` — while leaving printable
non-ASCII alone.

The inputs that reach it are the ordinary ones, and they arrive by the
same channel round 3 cited for newlines: `adapt_exception` interpolates a
command's decoded stderr into the message, and plugins do the same by
hand, so ANSI colour, a `\x00` or a stray `\x7f` lands in a reason with
nothing stripping it.

The rule is implemented now — `str.isprintable()`'s categories, the three
short forms, and the three escape widths. It is checked against `python3`
rather than against expectations written here: a differential run over
5058 inputs — every C0 control, `DEL`, lone surrogates, astral code
points printable and not, and 5000 random strings — matches CPython's
`repr()` exactly, zero mismatches.

The spec's rows are CPython's output too, generated rather than typed,
and they now cover each branch. The `\U` form had no row at all: the only
astral input was an emoji, which is *printable*, so it never reached the
escape at all — replacing `\U` with `\u` left every test green. There is
an unprintable astral row now, and dropping any one branch fails exactly
the row that names it.

## And the smaller ones

The identity guard round 3 asked for checked `urls` and was named "its
own arrays". The `SUCCESS` arm returns eleven more references —
`user_info` and its `privilege`, `group` and `attributes` sub-objects
among them. It walks every reference in every arm now; sharing any one of
them fails it.

Two docblocks were left attached to the wrong thing, one of them the same
back-to-back artefact round 2 filed and the last commit apologised for. A
129-character line was pasted rather than reflowed — also the same defect
the same commit claimed to fix.

"What the default guarantees is that the triple it does produce is one an
appliance could send" counted `formatted` inside a guarantee the sentence
two paragraphs up excludes it from. It says `class` and `repr` now, and
says what `formatted` is not.

"Interpolating into single quotes produces none of these" produced one of
them: the double-quote row is what the naive version emits, by accident.

**And a correction to the previous commit message.** It says
`check-dist.mjs` "keeps a comment line that was pasted rather than
reflowed". It does not — that commit reflowed it. The sentence described
the state before the edit I had just made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…repr claim

Round 5. One finding inside round 4's repair, as every round has been —
and one that no earlier round could have found, because it came from
reading the diff the way a consumer will.

`SpyFactory` is exported precisely so a consumer can pass something other
than `vi.fn`, and its docblock asked only that the factory return
something callable. The load-bearing requirement — that the returned
function invoke the implementation with its own `this` — was stated in a
comment inside a non-exported function.

An arrow satisfies the type and does not forward. So does the shape a
consumer reaches for when they want a spy *and* something of their own,
`(impl) => vi.fn((...args) => impl(...args))`. Both type-check, and then
every verb runs against a `this` of `undefined` and fails on the real
method's first line with `Cannot read properties of undefined (reading
'dispatch')` out of a bundled chunk — naming neither spies nor `this`.

The requirement is on the exported type now, and the failure has a
sentence: the installed wrapper checks `this` and says that either the
verb was detached — which the real client fails at too — or the factory
does not forward. Binding instead would have made a detached verb work
under spies where it throws without them, which is the divergence this
helper exists to observe rather than create.

`\p{Cn}` is not a property of a code point but of a code point in a
Unicode version, and the two sides read different tables: this class is
evaluated against the JS engine's, the appliance's `repr()` against its
Python's. Measured here, that is 9,988 code points — everything assigned
between Python 3.13's Unicode 15.1 and Node 26's 17.0 — where Python
escapes and this does not. A dataset or share name from a script added
since the appliance's Python reaches a `CallError` message by the same
channel as a control character.

`\p{Cn}` stays, because it is right for the code points unassigned in
both tables and those are nearly all of them. What changes is the claim:
the docblock now says which table the class is evaluated against, that
the appliance has its own, and that everything below U+0100 and any
reason made of ordinary prose is exact while a character from a new
script may not be. A fixture may not promise a fidelity its environment
cannot deliver.

The category had no test at all, which is how it got in. There is a row
now for a code point unassigned in *both* tables — the half they agree on
— and deliberately none for the gap.

`pythonRepr` was exported with nothing importing it. It has its own spec
now, which is also where the four `UNPRINTABLE` categories awkward to
carry in a `reason` are covered: private use, line and paragraph
separator, and a lone surrogate. Both it and `ARMS` carry the sentence
`API_VERBS` already had, saying the export is for a test and not part of
the entry.

`BARE_REPR`'s anchors were the whole classifier and nothing pinned them:
unanchored, `'pool.import_pool() failed'` was read as an argument-free
exception and the bare branch returned it verbatim — a `repr` that is not
a Python string literal. The one bare-repr test passed either way,
because `'MatchNotFound()'` matches both patterns. There is a negative
row now.

`syntheticTrace`'s own docblock still called `class`, `repr` and
`formatted` "a triple an appliance could send" — the sentence the
previous commit corrected seventy lines down and left standing on the
function that builds all three.

`escapeCodePoint`'s `?? 0` was unreachable padding that would have
silently emitted `\x00`; `fakeJob` and `fakeApiVersion` now export named
override types, as the other two builders already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 6. Both HIGHs are inside round 5's repair, and both are in the same
wrapper — neither is the error message it was written for, both are
things the wrapper changed on its way to producing it.

## Refusing on a missing `this` made the spied client stricter

Fifteen of the sixteen spied methods touch `this` on their first line, so
for those the guard replaced an opaque `TypeError` with a sentence and
changed nothing. `callAndGetJobId` is the sixteenth: its whole body is
inside a `defer`, so it reads no `this` until someone subscribes, and a
detached call returns an observable. The guard threw instead.

Binding was rejected two rounds ago for making a detached verb *work*
under spies where the real one throws. This made one *fail* under spies
where the real one does not — the same divergence pointing the other way,
in the helper whose stated job is not to have one. The test named for
that property could not see it, because it uses `call`, which throws
either way.

The wrapper calls the method either way now and adds its sentence only to
a failure that was going to happen anyway, as a `cause`. The outcome is
always the real method's.

The message lost a clause with it. It said "the real one fails that way
too", which was false for the one verb where the guard changed anything.

## Every spied verb reported the wrapper's name

Both runners copy the implementation's `name` and `length` onto the mock.
Handing the factory a local `function forwarded` therefore renamed all
sixteen: `client.api.call.getMockName()` returned `'forwarded'`, arity
dropped to 0, and a failed assertion printed `expected "forwarded" to be
called with arguments` — or, worse, `expected "forwarded" to be called
once, but got 0 times`, which names nothing.

That is the output this helper exists to produce; its own docblock
example is an `expect` on a spied verb, and the moment that example
matters is the moment it fails. `name` and `length` are defined on the
wrapper now, and there is a row per verb, since nothing in the suite read
either property before.

## Three LOWs

The bare-repr test killed the pair of anchors but neither one alone: its
reason fails `^` and `$` both. A reason ending in a call survives a
missing `^`, one beginning with a call survives a missing `$`, and both
produce the unquoted `repr` the test's own docblock describes. Both rows
are there now.

The `\p{Cn}` note said a character assigned in "the newer table" comes
through raw here and escaped there. Neither side is reliably the newer
one — this package supports Node 22 upward and an appliance's Python
moves on its own schedule — so it names the sides instead.

The two non-forwarding factory rows were cast with `as SpyFactory`, which
suppressed the half of the claim that is about the type: that those
shapes type-check is *why* the runtime guard exists. `satisfies` asserts
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebased onto `main`, which gained `truenas/max-comment-lines` in #57 — ten
text lines per comment block, blank lines not counted, JSDoc included. This
branch was cut from `67c33ae`, before that landed, so every one of these
blocks was written against the old standard and sixteen of them are over.

Condensed rather than suppressed. Where a block was carrying more than one
subject, the parts move to the constructs they describe instead of being
deleted: `fakeApiError`'s 55-line docblock is now the envelope and the
`error`/`errname` pairing on the function, the derivation and the
`formatted` caveat on `syntheticTrace`; `fakeAuthResponse`'s is the arm
table on the function and the defaults, the never-defaulted fields and the
`satisfies` limit inside it.

What is gone is restatement and worked examples, not the reasons — the
citations that make a claim checkable are all still there, and each block
still says why the code is the way it is rather than what it does.

640 tests, eleven gates green on Node 22 and 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@agent-heimdall
agent-heimdall force-pushed the feat/testing-entry-export branch from 3f04f3f to 3739898 Compare September 15, 2026 16:57
Comment thread src/testing/index.ts
export type { FakeApiErrorOverrides } from './fake-api-error';
export { fakeApiVersion } from './fake-api-version';
export type { FakeApiVersionOverrides } from './fake-api-version';
export { fakeAuthResponse } from './fake-auth-response';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGHAuthResponseType is not exported from either entry, so no consumer can name any arm but the default.

FakeAuthResponseOverrides['response_type'] is the string enum AuthResponseType, declared in src/types/auth.type.ts and re-exported by neither src/index.ts (which exports only type { AuthResponse }) nor this file. A string enum does not accept its own literal, so from outside the package:

import { fakeAuthResponse } from '@truenas/api-client/testing';

fakeAuthResponse({ response_type: 'AUTH_ERR' });
//                               ^ Type '"AUTH_ERR"' is not assignable to type 'AuthResponseType'

and there is no import that fixes it — the value simply is not on the surface. Four of the five arms the function's own JSDoc table documents (OTP_REQUIRED, REDIRECT, AUTH_ERR, EXPIRED) are unreachable without a cast through NonNullable<FakeAuthResponseOverrides['response_type']>.

FakeAuthenticator (line 16) has the same gap: failNextLogin(responseType: AuthResponseType) compiles only at its default, and succeedNextLogin(Partial<AuthResponse>) cannot set a response_type either.

This is exactly the argument src/index.ts already makes for AppState: "Value export, not type-only: … without the enum itself a consumer has nothing to compare it against." The fix is one line — export { AuthResponseType } from '@/types/auth.type'; in src/index.ts, and/or here alongside the other re-export at line 29.

Comment thread src/testing/fake-api-version.ts Outdated
);
}

return { ...parsed, ...overrides };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — the one builder in this PR that does not go through present(), and every field it can drop is required.

FakeApiVersionOverrides = Partial<ApiVersion> and exactOptionalPropertyTypes is off, so an explicit undefined type-checks and this spread applies it:

fakeApiVersion('v27.0.0', { websocketPath: legacy ? '/websocket' : undefined })
// → { version: 'v27.0.0', year: 27, minor: 0, patch: 0, websocketPath: undefined }

ApiVersion.websocketPath is string, so that object is a lie its consumers read — createFakeClient would hand it to new FakeConnection({ websocketPath: undefined }), which silently falls back to '/api/current', and a spec asserting on the path it asked for fails somewhere other than here. All five fields of ApiVersion are required, so all five are exposed.

This is the failure present.ts was extracted in this PR to prevent, and its docblock claims the guard is universal — "Every builder here takes a Partial<…> … Dropping them makes an absent field mean 'unchanged', which is what a partial means everywhere else here." fakeJob, fakeApiError and fakeAuthResponse all apply it; this one was missed.

Suggested change
return { ...parsed, ...overrides };
return { ...parsed, ...present(overrides) };

(plus import { present } from './present';)

Comment thread src/testing/fake-job.ts
Comment on lines +9 to 12
export interface FakeJobOverrides<R = unknown>
extends Partial<Omit<Job<R>, 'progress'>> {
progress?: Partial<JobProgress>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOWFakeJobOverrides<R> and MockAnswers' JobUpdate<R> (mock-answers.ts:89) are now the same type written twice, and both are exported from the entry:

export type JobUpdate<R> = Partial<Omit<Job<R>, 'progress'>> & {
  progress?: Partial<JobProgress>;
};

Same fields, same one-level-down progress, same stated reason in both docblocks. Since mock.job completes each update through fakeJob, JobUpdate<R> = FakeJobOverrides<R> is not just a coincidence of shape — it is the same contract. Aliasing it (export type JobUpdate<R> = FakeJobOverrides<R>;) keeps the name a consumer already reads while leaving one statement of the shape; as it stands a change to one silently leaves the other behind, and consumers see two public names for one thing.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested. 5 finding(s): 2 HIGH, 3 LOW.

Findings at MEDIUM and above are on the lines they are about. Fix them, or say on the PR why one was mis-rated.

aervin and others added 2 commits September 15, 2026 13:43
…gh present()

Addresses the CI review on #59.

- `AuthResponseType` was on neither entry, so `fakeAuthResponse` and
  `failNextLogin` could only be scripted at their defaults. Exported from both,
  like `AppState`. The testing entry's runtime exports are now pinned the way
  the main barrel's are.
- `fakeApiVersion` spread its overrides raw, so an explicit `undefined` landed
  on a required field. Its error message also suggested overrides could get
  past the parser, which they cannot.
- `JobUpdate` is now an alias of `FakeJobOverrides` rather than a copy.
- TypeDoc documents and link-checks the testing entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Local review round 1: no findings at MEDIUM or above, four LOW.

This one came from adding the second entry point. With two, TypeDoc titles
each module after its file, so the main one was "index". `@module` names
both after the specifier a consumer imports. Page paths still move
(`classes/TrueNasApiClient.html` becomes
`classes/_truenas_api-client.TrueNasApiClient.html`) whatever the names are,
which is worth a line in the release notes.

The other three LOWs are left, like the ones listed on the PR: jest not
copying `name`, `FakeAuthenticator` not answering through
`fakeAuthResponse`, and the last line of the synthetic `trace.formatted`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | `REDIRECT` | `urls` |
* | `AUTH_ERR`, `EXPIRED` | none |
*/
export function fakeAuthResponse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the builder lands, but the one place in this package that actually produces an AuthResponse still hand-builds a bare one.

FakeAuthenticator.success() returns { response_type: AuthResponseType.Success }, and failNextLogin returns { response_type } — the same three-field-and-cast shape this docblock exists to retire. It is the default answer for every unscripted login, so createFakeClient() plus client.authenticator.loginWithUserPass('root', 'pw') hands the real TrueNasAuthenticator a SUCCESS with no user_info, and truenas-authenticator.ts:280-282 falls through to DefaultSessionLifetime instead of reading user_info.attributes.preferences.lifetime — which fakeAuthResponse() supplies (300) and a real appliance always sends on success.

fake-authenticator.ts is not in this diff, so this is a reuse gap rather than a regression: success()fakeAuthResponse(), failNextLoginfakeAuthResponse({ response_type }), and succeedNextLogin(response)fakeAuthResponse(response) would all be drop-ins, and would make the fake authenticator's answers the same shape as the ones a spec scripts by hand.

Comment thread src/testing/with-spies.ts
* driven by the fake and already recorded: `connection.sent` holds the frames
* whether or not a spy is installed.
*/
export const CONNECTION_METHODS = ['send'] as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — this is the one of the three lists with nothing pinning it against the class it describes.

API_VERBS gets the runtime walk in with-spies.spec.ts:112 and a type-level Exclude over keyof TrueNasApi; AUTHENTICATOR_METHODS gets the Exclude over keyof TrueNasAuthenticator. CONNECTION_METHODS gets neither, so the claim above — that send is the whole of the connection's outbound surface — is unenforced.

fake-connection.spec.ts's DRIVEN/INERT inventory does fail when a member is added to TrueNasConnection, but it only asks is it driven or inert; nothing routes a new outbound method here, so it would go unspied and a spec reaching for it would fail with "not a spy" rather than with anything about the call. A keyof TrueNasConnection-based Exclude in the spec, matching the two it already has, would close it.

export const ARMS: Record<AuthResponseType, () => Partial<AuthResponse>> = {
[AuthResponseType.Success]: () => ({
authenticator: 'LEVEL_1',
reconnect_token: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOWreconnect_token is unconditional on the SUCCESS arm and, because present() drops an override of literally undefined, there is no way to build a success response without the field: fakeAuthResponse({ reconnect_token: undefined }) still returns null. v25.10 does not declare it at all (auth.type.ts:24 — "Absent below v26"), so a spec pinned to that version gets a shape the appliance never sends, and 'reconnect_token' in response is true on every response this builder makes.

Nothing in the client branches on its presence, so the blast radius is a consumer's own assertion. The note that used to say this — "this builder has no way to say that … do not read 'reconnect_token' in response as a claim about the version" — went out with the docblock condensing in 3739898, so the limitation is now undocumented on a helper that is published public surface as of this PR. Worth a line in the table above rather than a code change.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. 3 finding(s): 3 LOW.

Nothing blocks and no human review is needed.

@aervin
aervin merged commit 4642837 into main Sep 15, 2026
8 checks passed
@aervin
aervin deleted the feat/testing-entry-export branch September 15, 2026 19:08
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 6.0.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants