TNC-2412 / v2.2 / feat(testing): script answers with mock.call, mock.query, mock.job and mock.emit - #56
Conversation
…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.
| // 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)); |
There was a problem hiding this comment.
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, …):
- gets
jobEvent(sequence[0], frame.id)→callAndGetJobIdcorrelates onto the same id; trackJob(id)sends its opening read →answerSnapshotreadsat = position.get(id), which the first run left atsequence.length - 1;- the snapshot is the terminal job,
at >= sequence.length - 1skips the walk,takeWhilecompletes 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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /** Ids for jobs a spec did not number itself. */ | ||
| let nextJobId = 1; |
There was a problem hiding this comment.
LOW — nextJobId 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.
There was a problem hiding this comment.
Fixed in 4ee98fe — nextJobId 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 claimedclaimed 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 refusescompared 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.
| 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 () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 4ee98fe — renamed to answers a call. Reading the params is the next test's subject, as you say.
| * 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' }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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:
Human review: |
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>
| // 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; |
There was a problem hiding this comment.
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:
| 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; |
There was a problem hiding this comment.
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.
`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>
| const isTracking = (read: TrueNasMessage): boolean => { | ||
| const options = readOptions(read); | ||
| return !options?.get && !options?.count; | ||
| }; |
There was a problem hiding this comment.
HIGH — isTracking 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 roomNobody 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:
| 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.
There was a problem hiding this comment.
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.
…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; |
There was a problem hiding this comment.
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:
| 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?]; |
There was a problem hiding this comment.
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.
| const [, options] = (frame.params ?? []) as [unknown, QueryOptionsFrame?]; | |
| const options = readOptions(frame); |
There was a problem hiding this comment.
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.
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>
|
The fake has been scripting errors in a shape this client can never receive. A failed method call on Verified against middleware master at Nothing broke on it, which is why three rounds of review went past it: What changed
A correction to this branch's own second commit. 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); |
There was a problem hiding this comment.
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 completesThe 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?]; |
There was a problem hiding this comment.
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 armWorth 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'); |
There was a problem hiding this comment.
LOW — sent?.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.
There was a problem hiding this comment.
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.
|
🎉 This PR is included in version 6.0.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
…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>
Second slice of the testing entry, on top of #54. Still no package export —
src/index.tsis untouched and nothing here reachesdist, 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)mock.query(method, rows)query,queryOneandqueryCountfrom one set of rowsmock.job(method, updates)mock.emit(event, change)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.replyandconnection.receiveremain, andmockis a shorthand over them rather than a second path.mock.queryfeeds all three query verbs from one fixture because on the wire they are one method separated by their options —queryOnesendsget: true,queryCountsendscount: true. Scripting them apart would let a spec disagree with itself about what the collection holds.mock.jobis three registrations, not one: starting a job is not request/response.callAndGetJobIdreads no reply at all and correlates on acore.get_jobsevent naming the frame it sent, andtrackJobopens with acore.get_jobssnapshot read before the live updates matter. Thecore.get_jobsanswer is one dispatcher rather than one registration per job, becauseautoReplyis keyed by method and last-write-wins — a registration per job would leave the last one answering for all of them, andtrackJob(999)would resolve with some other job's state.fakeJobcompletes each update into a wholeJob.Jobhas eighteen required fields, so a three-field fixture cast into place emits an object whoseprogressisundefined— whichTrueNasApi.job's own documented example,bar.set(job.progress.percent ?? 0), then throws on.mock.jobreplays; it does not simulateDecided 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.queryormock.emit: progress folding, forcing a successful job to 100, the description middleware keeps becauseset_progress's branch isif 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_stateemits nothing, so there is noSUCCESSframe 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.tsruns each verb twice — once scripted, once driven frame by frame throughconnection.reply/receive— and compares the emissions. It caught a real ordering bug on its first run:mock.jobreported only a job's terminal state where a hand-driven job reported the whole walk, because delivering the id runs all ofjob()synchronously andtrackJob'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.calldoes not offer query methods at all. They live in the call directory, somock.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,queryCountresolving an array typednumber. Excluding them from the signature makes that unrepresentable rather than merely discouraged.The errors are the appliance's own. A
getthat matches nothing raises whatdo_getraises:MatchNotFoundis a bareIndexError, so it lands inrpc.py's generic arm asEINVALwithrepr(e)for the reason. An earlier version invented a friendlierENOENT: no results match, which made this the only place that text existed — with the repo's own tests asserting it.TrueNasMessage['error'].extrais widened to(string | number)[] | null:rpc.py's generic arm setsextra = Nonefor 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-errorproofs thatmock.call('pool.dataset.query', …)andmock.query('user.query', [{ uid: 'not-a-number' }])do not compile.Not in this slice
UnmockedCallError,withSpies, the remaining fixture builders and the./testingsubpath export with its packaging checks.fakeJobarrives here rather than with the other builders becausemock.jobcannot be correct without it.Known and deliberate:
mockhas no way to script a failing call (connection.replyErroris the route until it does);mock.querydoes not evaluatefilters,select,order_by,limitoroffset; and an unmocked method hangs rather than failing, because makingsendthrow would break every spec that answers by hand withreply— the two need to be told apart first.Verification
Ten review rounds. 547 tests (36 files), and all eight gates exit 0:
tscbase/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