Skip to content

TNC-2412 / v2.2 / feat(testing): script answers with mock.call, mock.query, mock.job and mock.emit - #56

Merged
aervin merged 5 commits into
mainfrom
feat/testing-mock-answers
Sep 14, 2026
Merged

aervin merged 5 commits into
mainfrom
feat/testing-mock-answers

Conversation

@agent-heimdall

Copy link
Copy Markdown
Collaborator

Second slice of the testing entry, on top of #54. Still no package exportsrc/index.ts is untouched and nothing here reaches dist, so this cannot affect a consumer's bundle yet.

What it adds

client.mock, typed by the client's own directory: an unknown method or a wrong-shaped fixture is a compile error.

mock.call(method, answer) answers one call method
mock.query(method, rows) feeds query, queryOne and queryCount from one set of rows
mock.job(method, updates) scripts a job's updates and returns its id
mock.emit(event, change) pushes a change to subscribers

Each entry registers an answer to a frame, not to a verb. The client's real dispatch, real job correlation and real subscription bookkeeping run either way; only what comes back is scripted. connection.reply and connection.receive remain, and mock is a shorthand over them rather than a second path.

mock.query feeds all three query verbs from one fixture because on the wire they are one method separated by their options — queryOne sends get: true, queryCount sends count: true. Scripting them apart would let a spec disagree with itself about what the collection holds.

mock.job is three registrations, not one: starting a job is not request/response. callAndGetJobId reads no reply at all and correlates on a core.get_jobs event naming the frame it sent, and trackJob opens with a core.get_jobs snapshot read before the live updates matter. The core.get_jobs answer is one dispatcher rather than one registration per job, because autoReply is keyed by method and last-write-wins — a registration per job would leave the last one answering for all of them, and trackJob(999) would resolve with some other job's state.

fakeJob completes each update into a whole Job. Job has eighteen required fields, so a three-field fixture cast into place emits an object whose progress is undefined — which TrueNasApi.job's own documented example, bar.set(job.progress.percent ?? 0), then throws on.

mock.job replays; it does not simulate

Decided with @aervin after four review rounds on this one function. Every HIGH those rounds found was inside it, and none was in mock.call, mock.query or mock.emit: progress folding, forcing a successful job to 100, the description middleware keeps because set_progress's branch is if description:, a cursor that ran ahead of the events it stood for. Each of those is a claim about the appliance rather than about the client, and I got one wrong in a way that reached a commit message — set_state emits nothing, so there is no SUCCESS frame before the forced one.

The proposal put simulating middleware semantics under non-goals. This is the function that had drifted across it, and narrowing it took HIGH counts from 3 → 5 → 4 → 4 to 2 → 2 → 1 → 0 → 0.

What stays is protocol rather than semantics: the three registrations a job start actually needs, the id a spec can choose and reach through trackJob, and the cursor that remembers which updates have already been replayed.

Worth a reviewer's attention

differential.spec.ts runs each verb twice — once scripted, once driven frame by frame through connection.reply/receive — and compares the emissions. It caught a real ordering bug on its first run: mock.job reported only a job's terminal state where a hand-driven job reported the whole walk, because delivering the id runs all of job() synchronously and trackJob's opening read is on the wire before the next line of the registration is reached. The direct test did not catch it — it asserted the terminal state, which was right either way.

mock.call does not offer query methods at all. They live in the call directory, so mock.call('user.query', 3) would be well-typed — a query method's response is the five-way union the server may return, and a number is one arm of it — and would then answer all three query verbs from that one value, queryCount resolving an array typed number. Excluding them from the signature makes that unrepresentable rather than merely discouraged.

The errors are the appliance's own. A get that matches nothing raises what do_get raises: MatchNotFound is a bare IndexError, so it lands in rpc.py's generic arm as EINVAL with repr(e) for the reason. An earlier version invented a friendlier ENOENT: no results match, which made this the only place that text existed — with the repo's own tests asserting it.

TrueNasMessage['error'].extra is widened to (string | number)[] | null: rpc.py's generic arm sets extra = None for any exception it cannot adapt, and the declared type had no room for it.

Every fixture was as never, which accepts { state: 'NOT_A_STATE', progress: { percent: 'half' } } — so the slice's headline guarantee had no test at all. All 38 are gone, and there are now @ts-expect-error proofs that mock.call('pool.dataset.query', …) and mock.query('user.query', [{ uid: 'not-a-number' }]) do not compile.

Not in this slice

UnmockedCallError, withSpies, the remaining fixture builders and the ./testing subpath export with its packaging checks. fakeJob arrives here rather than with the other builders because mock.job cannot be correct without it.

Known and deliberate: mock has no way to script a failing call (connection.replyError is the route until it does); mock.query does not evaluate filters, select, order_by, limit or offset; and an unmocked method hangs rather than failing, because making send throw would break every spec that answers by hand with reply — the two need to be told apart first.

Verification

Ten review rounds. 547 tests (36 files), and all eight gates exit 0: tsc base/spec/scripts, eslint, vitest, tsup, check-dist, typedoc --emit none.

Five of the tests I wrote on this branch could not fail — four found by mutation, one by a reviewer replacing a method body with return;. Every guard here is mutation-checked now: reverted in isolation, each fails exactly one test, on its own assertion.

🤖 Generated with Claude Code

…d mock.emit

Second slice of the testing entry. Still no package export — `src/index.ts` is
untouched and nothing here reaches `dist`.

## Answers, not verbs

`client.mock` is typed by the client's own directory, so an unknown method or a
wrong-shaped fixture is a compile error. Each entry registers an answer to a
*frame*: the client's real `dispatch`, real job correlation and real
subscription bookkeeping run either way, and only what comes back is scripted.
`connection.reply` and `connection.receive` remain, and `mock` is a shorthand
over them rather than a second path.

`mock.query` feeds all three query verbs from one set of rows, because on the
wire they are one method separated by their options — `queryOne` sends
`get: true`, `queryCount` sends `count: true`. Scripting them apart would let a
spec disagree with itself about what the collection holds.

`mock.job` is three registrations, not one. Starting a job is not
request/response: `callAndGetJobId` reads no reply at all and correlates on a
`core.get_jobs` event naming the frame it sent, and `trackJob` then opens with
a `core.get_jobs` snapshot read before the live updates matter. It returns the
job's id and honours one the first update names, because without that a
scripted job cannot be handed to `trackJob` or `callAndGetJobId` — the two
verbs it exists to model.

Every update is completed into a whole `Job` by `fakeJob`. `Job` has eighteen
required fields — `message_ids` is the only optional one, and a three-field fixture cast into place emits an object
whose `progress` is `undefined` — which `TrueNasApi.job`'s own example,
`bar.set(job.progress.percent ?? 0)`, then throws on. `result` defaults to
`null` for the same reason: that is what the appliance sends while a job runs
and on failure, and `undefined` is a shape it never produces.

The `core.get_jobs` answer is one dispatcher rather than one registration per
job. `autoReply` is keyed by method and last-write-wins, so a registration per
job would leave the last one answering for all of them, and `trackJob(999)`
would resolve with some other job's state and complete — worse than hanging.
The dispatcher reads the id out of the read's filter, chains to whatever
answered that method before it, and answers `[]` when nothing does, which is
what middleware sends for an id it has reaped. It serves the whole walk from
the registry, so a job reached through `trackJob` rather than started gets the
same updates a started one does.

Scripting `core.get_jobs` *after* a job is refused rather than allowed to
answer wrongly: the later registration would take the method over and a
scripted job would complete with someone else's row. The other order composes,
through the chain.

`mock.job` replays what it is told. Each update is completed into a whole
`Job` by `fakeJob` from the *defaults*, not from the update before it: nothing
folds forward, and a successful job is not forced to 100.

That is a deliberate narrowing, decided with @aervin after four review rounds
on this one function. Every HIGH those rounds found was in it, and none was in
`mock.call`, `mock.query` or `mock.emit`: progress folding, the forced 100, the
description middleware keeps because `set_progress`'s branch is
`if description:`, a cursor that ran ahead of the events it stood for. Each is
a claim about the appliance rather than about the client, and I got one of them
wrong in a way that reached a commit message — `set_state` emits nothing, so
there is no `SUCCESS` frame before the forced one. The proposal put simulating
middleware semantics under non-goals; this is the function that had drifted
across it.

What stays is protocol rather than semantics: the three registrations a job
start actually needs, the id a spec can choose and reach through `trackJob`,
and the cursor that remembers which updates have already been replayed.

An update field that is literally `undefined` falls back to the builder's
default rather than being applied. `Partial<Job>` makes every field optional,
so a fixture built with a conditional puts `undefined` where a `JobState` is
declared — and the job then never finishes, because `isJobFinished` is false
for a state that is not there.

A snapshot read reports where the job has got to rather than where it started,
so a second reader after the walk has run gets the finished job and completes.
The cursor advances with each event rather than ahead of them: set to the end
before the updates go out, a reader arriving in that window is told the job
finished while the tracker that started it has not yet seen it run. Reads are
shaped by their `get`/`count` options on both paths — a spec should not get one
shape for a scripted job, another for an unknown id, and a third from a
fallback. A `get` that matched nothing raises rather than answering
`undefined`, because `do_get` raises `MatchNotFound` on an empty result and
never returns null; a plain read still answers `[]`, which is what lets
`trackJob` on an unknown id complete instead of hanging.

The error it raises is the appliance's own. `MatchNotFound` is a bare
`IndexError` with no errno, so `adapt_exception` passes and it lands in
`rpc.py`'s generic arm as `EINVAL` with `str(e) or repr(e)` for a reason —
which is the repr, because a bare `IndexError` stringifies to nothing. An
earlier version invented a friendlier `ENOENT: no results match`, which made
this the only place that text exists and had the repo's own tests asserting it.
That was the last piece of middleware simulation left in the module.

`mock.call` does not offer query methods at all. They live in the call
directory, so `mock.call('user.query', 3)` would be well-typed — a query
method's response is the five-way union the server may return, and a number is
one arm of it — and would then answer all three query verbs from that one
value, `queryCount` resolving an array typed `number`. Excluding them from the
signature makes that unrepresentable rather than merely discouraged, and the
runtime refusal an earlier attempt used is gone with it: it only caught the
case where a `mock.query` had claimed the method first, which is the half that
was already harmless.

Scripting a second job on a method that already has one is refused. Starting
the method would correlate both onto a single id, an id reuse the appliance
cannot produce — the same argument the other two collision shapes are refused
on, which this one was quietly missing.

Auto-allocated ids step over any a spec has claimed, and a second job claiming
an id another holds is refused. Ids are how a spec reaches a job through
`trackJob`, so two jobs cannot share one.

`mock.query` raises on `queryOne` when nothing matches, rather than answering
`undefined` against a declared non-nullable projection.

## What the differential tests caught, immediately

`differential.spec.ts` runs each verb twice — once scripted, once driven frame
by frame — and compares the emissions. It is in the proposal because a
shorthand that answers at a different moment than the primitive it wraps makes
specs pass for reasons the hand-written version would not.

It found that on the first attempt. `mock.job` reported only a job's terminal
state where a hand-driven job reported the whole walk, because delivering the
id runs all of `job()` synchronously — `callAndGetJobId` emits, `trackJob`
subscribes, and its opening read is on the wire before the next line of the
registration is reached. So the snapshot answer is registered *before* the id
goes out, and the remaining updates wait until that read arrives.

The direct test did not catch it: it asserted the terminal state, which was
right in both versions. It asserts the walk now, and both tests die when the
ordering is reverted.

`TrueNasMessage['error'].extra` is widened to `(string | number)[] | null`.
`rpc.py`'s generic arm sets `extra = None` for any exception it cannot adapt,
which is the shape this sends and the shape an appliance sends; the declared
type had no room for it, and a cast was standing in.

## The fixtures are typed, and now that is tested

Every fixture in the new specs was `as never`, which accepts
`{ state: 'NOT_A_STATE', progress: { percent: 'half' } }` — so the slice's
headline guarantee, that an unknown method or a wrong-shaped fixture is a
compile error, had no test and no regeneration would have failed the file.
They are gone: `mock.call('core.ping', 'not-a-pong')`,
`mock.job('app.nonexistent', …)` and `mock.query('user.query', [{ uid: 'x' }])`
all fail `tsc -p tsconfig.spec.json` now, and where a fixture genuinely needs
help it names the type it is standing in for rather than erasing it. The
guarantee is weaker on rows whose entity ends in an index signature —
`PoolDatasetEntry` does — so the row test names `user.query`, which does not.

## Not in this slice

`UnmockedCallError`, `withSpies` and the subpath export. `fakeJob` arrives here
rather than with the other fixture builders because `mock.job` cannot be
correct without it.

`mock` has no way to script a *failing* call; `connection.replyError` is the
route until it does. `mock.query` does not evaluate `filters`, `select`,
`order_by`, `limit` or `offset` — the rows given are the rows answered — and
the frame still carries what the caller sent, so a spec asserting on `sent`
sees the real request.
An unmocked method still hangs rather than failing, which is worth fixing and
is a design decision rather than an omission: making `send` throw would break
every spec that answers by hand with `reply`, so the two need to be told apart
first.
@agent-heimdall agent-heimdall self-assigned this Sep 11, 2026
@bugclerk bugclerk changed the title feat(testing): script answers with mock.call, mock.query, mock.job and mock.emit TNC-2412 / v2.2 / feat(testing): script answers with mock.call, mock.query, mock.job and mock.emit Sep 11, 2026
@bugclerk

Copy link
Copy Markdown

Comment thread src/testing/mock-answers.ts Outdated
// nobody listening — which is what the differential test caught,
// scripted jobs reporting only their terminal state where a
// hand-driven one reported the whole walk.
connection.receive(jobEvent(sequence[0], frame.id));

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 — Starting a scripted job method a second time replays only the terminal state.

This handler always emits sequence[0] under the same id, and position / walking are keyed by that id and never reset per start. So a second api.job(method, …):

  1. gets jobEvent(sequence[0], frame.id)callAndGetJobId correlates onto the same id;
  2. trackJob(id) sends its opening read → answerSnapshot reads at = position.get(id), which the first run left at sequence.length - 1;
  3. the snapshot is the terminal job, at >= sequence.length - 1 skips the walk, takeWhile completes immediately.
c.mock.job('app.delete', [
  { state: JobState.Running, progress: { percent: 50 } },
  { state: JobState.Success },
]);

const first  = await lastValueFrom(c.api.job('app.delete', ['plex']).pipe(toArray()));
const second = await lastValueFrom(c.api.job('app.delete', ['plex']).pipe(toArray()));
// first.length === 2, second.length === 1 — the retry never reports RUNNING/50.

it('reports a finished job to a later reader') already pins step 2 for the trackJob route, so this follows from behaviour the suite asserts; nothing covers the re-start route.

Against an appliance the second app.delete is a new job with a new id and the full walk, so this is the one divergence the module's contract ("real dispatch, real job correlation run either way") is meant to exclude — and it is the same id reuse mock.job refuses a second registration over. Either allocate a fresh id per start (resetting the cursor for it) or reject the second start with the same kind of loud error the registration collisions get; silently truncating the walk is the worst of the three.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 398b9a7. I reproduced it exactly as written before changing anything — first start RUNNING → SUCCESS, second start SUCCESS alone.

Taking the "allocate a fresh id per start" option rather than refusing the second start: on an appliance the second app.delete is a second job, and a spec exercising retry behaviour is a legitimate thing to write. A start now takes a walk nobody has begun — the registered id for the first, because that is the id mock.job returned and the one a spec reaches the started job by, and a freshly allocated one after that with the sequence copied under it.

The predicate is three conditions because a walk can be consumed three ways, and your comment named two of them:

const fresh = started.has(id) || position.has(id) || walking.has(id);

position/walking alone would miss two starts in the same tick — both precede any read, so the cursor has not moved yet. started alone would miss a start that follows a bare trackJob(id), which consumes the walk without starting anything; it('walks a job tracked by id rather than started') is exactly that read, so the gap was reachable from the suite as it stood.

Three tests, each mutation-checked. Dropping either half of the predicate fails exactly one of them and nothing else — started kills the same-tick test, position/walking kills the read-then-start test — and the original ordering (never fresh) kills all three. 550 tests, all eight gates exit 0.

The two other LOWs I've left for now, per direction on this PR: nextJobId's module scope and the two doc/naming ones. Happy to take the counter into createMockAnswers if you'd rather have it in this PR — it is a small move, but it('does not burn an id on a registration it refuses') and the cross-client skip test both read that shared counter, so it is not a one-liner.

Comment thread src/testing/mock-answers.ts Outdated
}

/** Ids for jobs a spec did not number itself. */
let nextJobId = 1;

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.

LOWnextJobId is module-global and never reset, so the id mock.job hands back depends on how many jobs every other client in the process registered first. sequences (the skip guard below) is per-client, so it cannot see those.

Nothing is wrong today — specs use the returned id — but it makes mock.job's return value non-reproducible run-to-run once vitest reuses a module across files, and it('does not burn an id on a registration it refuses') asserts on a difference of two allocations from that shared counter. A counter closed over in createMockAnswers would be per-client, which is the scope the collision checks already use.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4ee98fenextJobId now lives in createMockAnswers, so ids are per client like every other registry in there.

The interesting part is what it did to the two tests that measured it. Both were reading the shared counter across clients, and both were weaker than they looked:

  • steps auto-allocation over an id a spec has claimed claimed the id on a second client — an allocator that never had to step over it. Once the counter is per client that test passes with the skip removed entirely. Rewritten onto one client, it now fails against exactly that mutant.
  • does not burn an id on a registration it refuses compared allocations from two different counters, so its difference meant nothing. Also rewritten onto one client, with the refusal still sitting between the two measured allocations, and mutation-checked against an id burned before the refusals.

So this was not only a reproducibility nit: it was holding up two assertions that could not fail.

Comment thread src/testing/mock-answers.spec.ts Outdated
for (const made of built.splice(0, built.length)) made.connection.close();
});

it('answers a call, and can read the params it was given', async () => {

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 name promises two things and the body asserts one: nothing here reads the params. Reading them is the next test's subject (lets the answer depend on the params), so the clause is dead weight that makes the suite look like it covers a param assertion twice.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4ee98fe — renamed to answers a call. Reading the params is the next test's subject, as you say.

Comment on lines +110 to 114
* client.api.call('system.info').subscribe(info => …);
*
* // or drive the frames directly, which is what `mock` does underneath
* client.api.call('system.info').subscribe(info => …);
* client.connection.reply('system.info', { hostname: 'truenas.local' });

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 two halves read as one runnable snippet but cannot both be run on this client: the mock.call above is still registered, so the second api.call is auto-answered and the connection.reply that follows delivers a duplicate to an already-completed take(1). Splitting them onto separate clients (or saying "on a client with nothing scripted") keeps the "or" honest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4ee98fe. The two halves are now two blocks on two clients, and the prose between them says why rather than leaving it implied: with a mock.call still registered the scripted answer arrives first, and the reply lands after the caller has already seen its answer.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Three findings: three LOW. A human should look at this before it merges — see below.

The fake is doing the thing it set out to do: mock.* registers answers to frames rather than replacing verbs, so the client's real dispatch, job correlation and subscription bookkeeping still run underneath, and differential.spec.ts pins that claim rather than asserting it in a comment. Correcting TrueNasMessage['error'] to the versioned frame — code/message outside, payload under data — is the right fix in the right place, and typing replyError to it is what makes the legacy shape unrepresentable instead of merely discouraged.

  • LOWsrc/testing/mock-answers.ts:282: with mock.query('core.get_jobs', …) scripted first, a trackJob for an id no scripted job holds is answered from the fallback's unfiltered rows and completes with an unrelated job, where the comment beside it promises [].
  • LOWsrc/testing/mock-answers.ts:377: query re-implements the options destructuring readOptions provides and the count/get/plain shaping answerRead already does; the copy without the core.get_jobs tests behind it is the one that will drift.
  • LOWsrc/testing/differential.spec.ts:100: sent?.id degrades a missing request frame into a 5s timeout instead of a named failure.

Human review: src/types/truenas-message.type.ts changes TrueNasMessage['error'] from the flat { error, errname, extra, reason } to TrueNasErrorFrame. TrueNasMessage is not in src/index.ts's curated list, but it is reachable through it — TrueNasApiClient.connection is readonly and public, and connection.messages$ is Observable<TrueNasMessage> — so a consumer reading msg.error.reason or msg.error.errname off that stream stops compiling. The change looks correct against /api/<version>; whether it ships as a breaking change is the call to make deliberately.

Starting a scripted job method a second time replayed only its terminal
state. The start handler always emitted `sequence[0]` under the
registered id, and the walk cursor is keyed by id and never reset — so
the second `api.job(method, …)` correlated onto the same id, `trackJob`'s
opening read was answered from a cursor already at the end of the walk,
and `takeWhile` completed on one emission where the first start got the
whole thing.

Silently, which is the worst of the three things it could have done: the
spec sees a job that completed, just not the walk it scripted. Both runs
end on SUCCESS, so nothing asserting the final state notices.

On an appliance a second start is a second job — a new id, a new walk —
and this is the same id reuse `mock.job` already refuses a second
*registration* over. So a start now takes a walk nobody has begun: the
registered id for the first, because that is the id `mock.job` returned
and the one a spec reaches the started job by, and a freshly allocated
one after that, with the sequence copied under it.

"Nobody has begun" is three conditions because a walk can be consumed
three ways: started (`started`), reported past its first update
(`position`), or mid-replay (`walking`). A `trackJob` read alone consumes
the walk without starting anything, so the start flag is not enough on
its own; two starts in the same tick both precede any read, so the
cursor is not enough either.

Found by the CI review on #56. Three tests, each mutation-checked: the
two halves of the predicate fail exactly one test each when dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/testing/mock-answers.ts Outdated
// to the end here, a reader arriving before the microtask ran would be
// told the job had finished while the tracker that started it had not
// yet seen it run.
if (at >= sequence.length - 1 || walking.has(id)) return;

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 — a get/count read of core.get_jobs consumes the walk, so a later trackJob sees only its tail.

answerSnapshot is registered for every core.get_jobs frame, and readJobId parses the filter out of queryOne/queryCount frames just as happily as out of trackJob's plain read — the options object is params[1], and nothing here looks at it before deciding to replay. So a one-shot RPC that has no subscription on jobEvents, and structurally cannot observe a single collection_update, still sets walking, queues the microtask and advances position to the end of the sequence.

Repro, against the same fixture the specs above use:

const id = c.mock.job('app.delete', [
  { state: JobState.Running, progress: { percent: 10 } },
  { state: JobState.Success },
]);

await firstValueFrom(c.api.queryCount('core.get_jobs', [['id', '=', id]])); // 1

const walk = await lastValueFrom(c.api.trackJob(id).pipe(toArray()));
// scripted [Running, Success]; actual [Success]

The ordering is deterministic rather than a race. The queryCount answer resolves its promise in the same turn as walking.add(id), so the await continuation runs before the walk microtask: trackJob is subscribed by the time the events go out, receives SUCCESS, and takeWhile(…, true) completes it on that one emission. Its own opening read arrives afterwards, finds at === sequence.length - 1, and answers a finished job to nobody.

This is the same silent truncation 398b9a7 just fixed for a second start — the spec sees a job that completed, just not the walk it scripted — reached through a read instead of a start. it('honours get and count on a read for a scripted job') does not catch it because it scripts a single-update job, where at >= sequence.length - 1 short-circuits before the walk.

Shaping the answer by get/count is right and should stay; triggering the replay on those shapes is what does not follow. trackJob only ever sends the plain read (dispatch('core.get_jobs', [[['id', '=', jobId]]]), no options object), so gating the walk on that is enough and breaks nothing existing:

Suggested change
if (at >= sequence.length - 1 || walking.has(id)) return;
const [, walkOptions] = (read.params ?? []) as [unknown, QueryOptionsFrame?];
if (walkOptions?.get || walkOptions?.count) return;
if (at >= sequence.length - 1 || walking.has(id)) return;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4ee98fe. Your repro is exact — a queryCount before tracking, and the walk that follows reports SUCCESS alone. A queryOne does it too, on the other branch of answerRead.

The walk is released only to the shape a tracker opens with now: trackJob sends [[['id', '=', jobId]]] and nothing else, then listens on jobEvents, so a read carrying get or count is answered from the cursor but leaves the walk where it is.

if (!isTracking(read) || at >= sequence.length - 1 || walking.has(id)) return;

Two tests, one per option, because they take different branches — each mutation-checked, and a gate that ignores only get or only count fails exactly the test that names it.

You're right that this is the same defect as the second-start one, and I want to name the pattern rather than just fix the instance: both are state released to something that was not going to consume it. position is a promise that the updates it skips past have been delivered to somebody, and nothing in the module was checking that the somebody existed. Two commits ago it was a start that had no walk of its own; here it is a read with no subscriber. I fixed the first without asking what else could advance that cursor, which is how the second one survived.

@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. 4 finding(s): 1 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.

`answerSnapshot` replayed the rest of the walk for any `core.get_jobs`
read whose filter named a scripted job, including the `get` and `count`
shapes. Those are one-shot RPCs with nobody subscribed to `jobEvents`
behind them, so the remaining updates went out to an empty room and left
the cursor at the end of the sequence — and the `trackJob(id)` that
followed then completed on the terminal state alone.

`await firstValueFrom(api.queryCount('core.get_jobs', [['id', '=', id]]))`
before tracking a two-update job is enough to see it, deterministically.
It is the same silent truncation the previous commit fixed for a second
start, reached through a read instead: state released to something that
was not going to consume it.

Only the shape a tracker opens with releases it now. `trackJob` sends
`[[['id', '=', jobId]]]` and nothing else, then listens; a read carrying
`get` or `count` is answered from the cursor but leaves the walk where it
is. Two tests, one per option, because they take different branches of
`answerRead` — each mutation-checked, and dropping either half of the
predicate fails exactly the one that names it.

Also from the same review, all three LOWs:

`nextJobId` moves into `createMockAnswers`, so ids are per client like
every other registry here. Module-global, the id `mock.job` returned
depended on how many jobs every other client in the process had
registered first, which made it move as files were added.

Both tests that measured it were reading that shared counter across
clients, and both are now on one client. They were weaker than they
looked: `steps auto-allocation over an id a spec has claimed` claimed the
id on a *second* client, which the first allocator never had to step
over — it passes with the skip removed entirely once the counter is per
client. Each now fails against its own mutant.

`answers a call, and can read the params it was given` promised an
assertion its body never made; reading the params is the next test's
subject. Renamed to what it checks.

`createFakeClient`'s JSDoc showed "or drive the frames directly" on a
client that still had the `mock.call` registered, so the two halves could
not both run as written — the scripted answer would arrive first and the
`reply` after the caller had already seen it. Split onto two clients,
with the reason named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/testing/mock-answers.ts Outdated
Comment on lines +188 to +191
const isTracking = (read: TrueNasMessage): boolean => {
const options = readOptions(read);
return !options?.get && !options?.count;
};

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.

HIGHisTracking admits a plain api.query('core.get_jobs', …), so the walk is still released to a one-shot read and the trackJob that follows is still truncated.

4ee98fe closed this for get and count, but the predicate is written as "no shape switch" rather than "the shape trackJob sends", and those are not the same set. trackJob sends [[['id','=',jobId]]] — a one-element params array (truenas-api.ts:470). query sends [filters ?? [], options ?? {}] (truenas-api.ts:252), so its options object is {}: !options?.get && !options?.count is true, and the read is treated as a tracker's.

Same failure as the one already fixed, reached through the third verb:

const id = c.mock.job('app.delete', [
  { state: JobState.Running, progress: { percent: 10 } },
  { state: JobState.Success },
]);

await firstValueFrom(c.api.query('core.get_jobs', [['id', '=', id]]));
const walk = await lastValueFrom(c.api.trackJob(id).pipe(toArray()));
// walk.map(j => j.state) === [SUCCESS]   — the RUNNING update went to an empty room

Nobody is on jobEvents behind that query, so the microtask drives position to the end of the sequence and the updates are dropped; trackJob then reads the terminal snapshot, takes the at >= sequence.length - 1 early return, and completes on one emission. Silent, and both runs end on SUCCESS, so anything asserting the final state passes — which is exactly why the earlier occurrence was worth pinning.

{ select: [...] } or { order_by: [...] } on that same query do it too; the branch is reached by anything that is not get/count.

Deciding on the shape trackJob actually sends closes the set rather than enumerating it:

Suggested change
const isTracking = (read: TrueNasMessage): boolean => {
const options = readOptions(read);
return !options?.get && !options?.count;
};
const isTracking = (read: TrueNasMessage): boolean => {
// The presence of an options object at all, not the absence of `get` and
// `count`: `query` sends `{}` there, so a predicate written as "no shape
// switch" hands the walk to a one-shot read too.
return readOptions(read) === undefined;
};

it.each([['count', …], ['get', …]]) at mock-answers.spec.ts:328 is the test that would have caught it — a third row for api.query(…) fails against the current predicate and passes against the one above.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 7fe7e7d. Reproduced first, exactly as written: [SUCCESS] where the fixture scripts [RUNNING, SUCCESS].

I took your predicate, expressed as the params shape it comes down to:

const isTracking = (read: TrueNasMessage): boolean =>
  ((read.params ?? []) as unknown[]).length === 1;

trackJob sends the filters and no second argument; every query verb sends one — query passes options ?? {}, queryOne adds get, queryCount adds count. So the absence of that element is the mark, and your point about { select: [...] } reaching the same branch is the proof that enumerating switches was never going to close the set.

Third row added to the it.each table, as you suggested. The old gate now fails exactly that row and nothing else; a gate that always returns true fails all three; one that always returns false hangs the job tests, which is the right shape of failure for "the walk is never released".

Three rounds, three versions of one defect, and the pattern is mine rather than the code's. Each fix named the cases I had just been shown — a second start, then get and count — and left the next unnamed case behind. Asking what a tracker is has no such tail, and that is the question I should have asked when the first one landed. It is the same error as fixing a symptom next to the claim instead of the claim itself, which is a note I already keep.

@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. 1 finding(s): 1 HIGH.

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

…ions

`4ee98fe` gated the walk on "the options set no shape switch", which is
not the same question as "this is the read `trackJob` sends".
`api.query('core.get_jobs', [['id', '=', id]])` sends `{}` for its
options — no `get`, no `count` — so it passed the gate, took the walk it
was never going to deliver, and left the cursor at the end for the
`trackJob` that followed.

Every query verb sends an options element: `query` passes `options ?? {}`,
`queryOne` adds `get`, `queryCount` adds `count`. `trackJob` sends
`[[['id', '=', jobId]]]` and no second argument at all. So the absence of
that element is what marks a tracker, and the test is now positive —
`params.length === 1` — rather than an exclusion of the two shapes that
had been noticed.

That is the defect behind all three rounds of this, stated once: the
first fix named the cases it had seen (a second start), the second named
the cases it had seen (`get`, `count`), and each left the next unnamed
case behind. Asking what a tracker *is* has no such tail.

The spec table gains its third row. The old gate now fails exactly that
row and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
const runId = fresh ? allocateId() : id;
started.add(runId);
if (fresh) sequences.set(runId, sequence.map(job => ({ ...job, id: runId })));
const run = sequences.get(runId) ?? sequence;

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 ?? sequence arm is unreachable. Both branches have already put runId in sequences: the fresh branch on the line above, and the !fresh branch at registration (sequences.set(id, sequence)), with nothing anywhere deleting from the map. So the fallback is dead, and it reads as if sequences.get(runId) could miss — which would be a real hazard if it could, since run[0] is the frame the whole correlation hangs off.

Naming the walk once removes both the dead arm and the set-then-get round trip:

Suggested change
const run = sequences.get(runId) ?? sequence;
const run = fresh ? sequence.map(job => ({ ...job, id: runId })) : sequence;
if (fresh) sequences.set(runId, run);

(the if (fresh) sequences.set(...) line above would go)

query(method, rows) {
refuseJobMethodTakeover(method);
connection.autoReply(method, frame => {
const [, options] = (frame.params ?? []) as [unknown, QueryOptionsFrame?];

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 readOptions (line 176) written out again, same cast and same tuple destructure, in the same closure that defines it. answerRead goes through the helper; this one does not, so a change to how the options element is located — a third shape switch, an options object that stops being positional — has to be made in two places and will compile if it is only made in one.

Suggested change
const [, options] = (frame.params ?? []) as [unknown, QueryOptionsFrame?];
const options = readOptions(frame);

@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:

  • mock-answers.ts: the description presents the mock.job replay-not-simulate narrowing as a decision taken with a named reviewer, not a mechanical change.
  • mock-answers.ts: the description's "Known and deliberate" omissions (no failing call, no filter evaluation, unmocked methods hang) fix scope for later slices.

@github-actions
github-actions Bot dismissed stale reviews from themself September 11, 2026 20:14

Superseded by the review of 7fe7e7d.

The fake has been scripting errors in a shape this client can never
receive.

A failed method call on `/api/<version>` answers with a JSON-RPC error:
`code: -32001`, `message: "Method call error"`, and the TrueNAS payload
under `data`. The flat `{error, errname, extra, reason}` this repo has
been writing is the legacy `/websocket` frame — a different handler on a
route this client never opens. Verified against middleware master at
`4303dc8`: `rpc.py:81-124` builds the envelope, `:408,422` passes that
message for both the `CallError` and generic arms, `websocket_app.py:74-95`
builds the flat one, `main.py:553` routes every `/api/{version}` to the
first, and `jsonrpc.rst` documents `-32001`.

Nothing broke on it, which is why three review rounds went past it:
`getApiErrorMessage` finds a `reason` at either depth and answers with
the same string, so every assertion on a thrown message passed either
way. A consumer branching on `error.code` or `error.data.errname` would
have found it — against an appliance, not against us.

`TrueNasMessage['error']` now declares the frame, with `TrueNasErrorData`
for the payload beside `ApiError` in `api-error.type.ts`, where a
consumer can reach it. `data` is optional because the dataless codes are
real: `-32601 "Method does not exist"`, `-32600 "Invalid request"`,
`-32000 "too many concurrent calls"`.

**Correcting this branch's own second commit.** It widened `extra` to
`(string | number)[] | null` and justified it with `rpc.py`'s generic arm
setting `extra = None`. The observation was right and the location was
wrong — that is `error.data.extra`, not `error.extra` — and the element
type was a guess. `rpc.py` types it `list[Any]` and a validation error
puts the validation errors themselves there, so it is `unknown[] | null`.

`replyError` takes the frame type and nothing else. It was the loose
`ApiError` union, which admits both shapes, and the flat one now fails to
compile — there is a `@ts-expect-error` proof of that, and widening the
parameter again fails the build on an unused directive. No escape hatch
for the legacy shape: a spec that could script it would be scripting a
frame the appliance cannot send, and nothing at runtime would say so.

The cast inside `replyError` is gone with it, and so is the one in
`truenas-api.spec.ts`, which was already writing a real dataless frame
and casting past the type that disagreed.

`mock-answers`' `notFound` sends the envelope, and its test asserts the
whole frame rather than the payload: asserting the payload alone passes
against the legacy shape, which is exactly how this survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@agent-heimdall

Copy link
Copy Markdown
Collaborator Author

de59c06 fixes the error mocking, per @aervin's call, and closes the legacy shape rather than keeping an escape hatch for it.

The fake has been scripting errors in a shape this client can never receive. A failed method call on /api/<version> answers with a JSON-RPC error — code: -32001, message: "Method call error", the TrueNAS payload under data. The flat {error, errname, extra, reason} this repo has been writing is the legacy /websocket frame, a different handler on a route this client never opens.

Verified against middleware master at 4303dc8: rpc.py:81-124 builds the envelope, :408,422 passes that message for both the CallError and generic arms, websocket_app.py:74-95 builds the flat one, main.py:553 routes every /api/{version} to the first, and jsonrpc.rst documents -32001.

Nothing broke on it, which is why three rounds of review went past it: getApiErrorMessage finds a reason at either depth and answers with the same string, so every assertion on a thrown message passed either way. A consumer branching on error.code or error.data.errname would have found it — against an appliance, not against us.

What changed

  • TrueNasMessage['error'] declares the frame, with TrueNasErrorData for the payload beside ApiError in api-error.type.ts where a consumer can reach it. data is optional because the dataless codes are real: -32601, -32600, -32000.
  • replyError takes that frame type and nothing else. It was the loose ApiError union, which admits both shapes. There is a @ts-expect-error proof that the flat shape no longer compiles, and widening the parameter again fails the build on an unused directive — so the hatch cannot be reopened quietly.
  • notFound sends the envelope, and its test asserts the whole frame rather than the payload. Asserting the payload alone passes against the legacy shape, which is precisely how this survived.
  • Two casts are gone: the one inside replyError, and the one in truenas-api.spec.ts, which was already writing a real dataless frame and casting past the type that disagreed with it.

A correction to this branch's own second commit. 5af28e8 widened extra to (string | number)[] | null and justified it with rpc.py's generic arm setting extra = None. The observation was right and the location was wrong — that is error.data.extra, not error.extra — and the element type was a guess. rpc.py types it list[Any], and a validation error puts the validation errors themselves there, so it is unknown[] | null.

554 tests, all eight gates exit 0.

const id = readJobId(read);
const sequence = id === undefined ? undefined : sequences.get(id);
if (!sequence || id === undefined) {
if (fallback) return fallback(read);

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 chained order reopens, for unknown ids, the same silent-wrong-job failure the reverse order is refused for.

mock.query('core.get_jobs', …) does not apply filters — "the rows given are the rows answered" — so when it is the fallback, a read for an id no scripted job holds is answered with the whole row set, and trackJob takes jobs[0]:

c.mock.query('core.get_jobs', [fakeJob({ id: 999, state: JobState.Failed }) as ]);
c.mock.job('app.delete', { state: JobState.Success });

await lastValueFrom(c.api.trackJob(12345));   // emits job 999 and completes

The comment two lines above promises the opposite for this case — "with nothing behind it, an unknown id gets [], which is what middleware sends for an id it has reaped" — and that only holds when nothing was scripted first. refuseJobMethodTakeover exists precisely to stop job() "completing with a job it never started, silently"; this is the narrower version of it reached through trackJob.

Filtering the fallback's answer by readJobId before handing it back — or at least answering [] for a tracker's read the registry does not know — would close it without giving up the chaining.

query(method, rows) {
refuseJobMethodTakeover(method);
connection.autoReply(method, frame => {
const [, options] = (frame.params ?? []) as [unknown, QueryOptionsFrame?];

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 third copy of "read the options element, then shape the answer by count / get". readOptions (line 176) already does the destructuring, and answerRead (line 201) already does the count → number, get → row-or-notFound, otherwise → array branch, including the notFound for an empty get.

query could be answerRead's caller rather than its twin:

const [, options] = ;                // -> readOptions(frame)
if (options?.count) return answer(frame, rows.length);   // -> answerRead's count arm

Worth collapsing because the two copies encode the same wire fact: if a fourth shape switch ever lands, or get's empty-result frame changes, only one of the two is going to be updated — and the core.get_jobs path is the one with the tests, so the mock.query copy is the one that would drift unnoticed.


const driven = client();
const pending = lastValueFrom(driven.api.job('app.delete', ['plex']).pipe(toArray()));
const sent = driven.connection.sent.find(m => m.method === 'app.delete');

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.

LOWsent?.id turns the one precondition this half of the differential rests on into a timeout.

If the app.delete frame is not on the wire when this runs, messageId is undefined, the event goes out with no message_ids, callAndGetJobId's filter never matches, and pending never settles — so the failure surfaces as a 5s vitest timeout on the whole test rather than as "the request was never sent". That is the slowest and least legible way for this file to report a regression in dispatch/callAndGetJobId send timing, which is exactly what a differential spec is here to catch.

An assertion on the frame first (expect(sent).toBeDefined(), or a non-null read) keeps the failure pointed at the cause.

@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. 3 finding(s): 3 LOW.

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

  • truenas-message.type.ts narrows TrueNasMessage['error'] to TrueNasErrorFrame; reachable via exported TrueNasApiClient.connection.messages$, so consumers reading msg.error.reason break.

@aervin
aervin merged commit 67c33ae into main Sep 14, 2026
7 checks passed
@aervin
aervin deleted the feat/testing-mock-answers branch September 14, 2026 16:46
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 6.0.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

aervin added a commit that referenced this pull request Sep 15, 2026
…Spies, UnmockedCallError and the fixture builders (#59)

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.

```ts
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](https://claude.com/claude-code)

---------

Co-authored-by: Agent Heimdall <agentheimdall@truenas.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Aaron Ervin <aaron.ervin@truenas.com>
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