Files
orca/mobile/src/session/session-reply-schema.test.ts
T
Jinwoo Hong 3e32b83522 refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)

Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.

What each domain required, and why it required no more:

- notifications (5 readers). All four call sites read the payload through `?.`,
  so every schema is nullish at the top level and no member is required. The
  test-push `reason` and the register `reason` become closed enums, because the
  two comparisons against them are the whole of what they decide and an arm this
  build does not know took the generic copy on main too. The stream unsubscribe
  and the unregister read no body at all.

- components (4). `repo.hooks` requires `source` and nothing else: the drawer
  assigns it straight into `SetupHookDetails.source`, whose type is
  `string | null`, with no guard in between — nullable so the "no hooks file"
  answer keeps its explicit null. `setupTrust` is nullable as well as optional
  because the `components-setup-ask` fixture sends an explicit null, and
  salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
  per repo, so one unreadable repo cannot cost the others their approvals. The
  Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
  scope-and-snapshot validator and splitting it would give one reply two refusal
  rules.

- terminal (4). The send verdict and the viewport pair keep main's exact
  `=== true` projections. `terminalSendAcceptedSchema` moves here from the
  session domain, which now re-exports it: terminal is the lower layer and two
  identical copies could drift on what "delivered" means.
  `terminal-send-rpc-response.ts` is deleted, its projection now being the
  schema's.

- transport (3). `status.get` declares its five members and requires the object;
  the three callers disagree about what an unreadable status means, so each keeps
  its own verdict behind a named reader — the gate wants the failure, and the
  probe and the pairing race must not have it, because both call `interpret`
  inside a `.then` fulfilment handler where a throw becomes a detached rejection.
  `capabilities` salvages whole rather than per element, which is main's own rule
  and what `transport-capability-probe-non-string-capabilities-drop` records.
  The two pairing readers are the shared credential contract itself, moved off
  the four call sites that each ran `.parse()` on the interpreted value; its
  `.strict()` is main's shipped rule for that released surface, not a new one.

- home (2), worktree (2), browser (1). The stats row is checked as an object and
  nothing more, `totalHomeStats` being the reader that says so itself; its
  per-host slot is now typed as the wire row it holds rather than as the computed
  total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
  unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
  browser commands read no body; `browser.goto`'s settled URL stays nullish
  because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
  adapter mounts, and a move there would ship unevidenced.

Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.

`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus and re-record step 7's checked reply readers

`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mutate the workspace catalog's reader back to unchecked

The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.

`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).

One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the push-test reason arms the closed enum constrains

`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.

Each golden was recorded first at the main pin 4b876758d3 and reproduces there
byte for byte, so the `normal` partition pins main's rendering rather than this
branch's. The unknown arm is the load-bearing one: main renders "Could not send
through Orca's push service." for an unrecognised string, and the salvage drop
renders the same sentence, so the closed enum costs a recorded `reply-salvage`
observation and no screen text.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): cite the host union behind each closed reply enum

A closed `z.enum` is only as good as the vocabulary it was checked against, and
mobile's own declared types are written from memory. Each of the three enums now
names the host type it mirrors, so the next reader re-checks it in one grep
rather than trusting the arms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the corpus to the commit that carries the host-union citations

`--record` compares the fenced tree against `baseline`, so a comment in
`mobile/src` moves the pin like any other product edit. Every one of the 762
goldens changes by exactly its `baseline` line and nothing else, which is the
evidence that the citation commit is inert.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the agent-history panel rendering when the catalog omits its rows

`worktrees` is a salvaged member, so it is absent on the three envelopes that
read as an object without rows, and `use-mobile-agent-history-state.ts:61` calls
`.find` on it with no guard. The cast erased that and the corpus recorded the
result: `matrix-aivault.history-screen-worktree.ps-1` carried
`crash: Cannot read properties of undefined (reading 'find')` on
`inner-ok-missing`, `inner-false-string-error` and `inner-false-object-error`.

`?? []` is what the sibling Home card already does. The SAFETY note cited that
card's golden, which is the opposite site, and now cites this panel's own family.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a malformed worktree.ps reply reported as invalid_response

`host-workspace-list-states.tsx` prints this code to the user verbatim, so the
checked reader's throw landing in the generic catch renamed a host-payload
defect into a connectivity failure. STA-3123 exists to make a broken remote host
diagnosable, which `network_error` is not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the browser page commands back their one-line form

Only `browser.goto` reads a reply body, so the reader belongs on a two-argument
wrapper rather than on all thirteen call sites. The exported type of every
command is unchanged, and the doc comment no longer promises a shape the file
did not have. 109 lines to 72.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): import the terminal send verdict from the terminal domain

The re-export forwarded to two callers, which a direct import already gives
them, and the session suite re-pinned three cases `terminal-reply-schema.test.ts`
owns. One definition, one pin, one file hop fewer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move the codex reset-credit pins next to their schema

They sat in the New Workspace suite, so a reader looking for the capability
whole-list drop by filename did not find it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the round-1 fixes

Three checkpoints of `matrix-aivault.history-screen-worktree.ps-1` move from a
recorded white screen to the loading list: `inner-ok-missing`,
`inner-false-string-error` and `inner-false-object-error` answer the panel an
object with no rows, and the `?? []` now seats an empty list instead of letting
`.find` throw. Every other golden changes by its `baseline` line alone, which is
the evidence the other four fixes move nothing the recorder observes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): stop requiring the stats row its own reader guards

Round-2 findings 1 to 3.

`homeHostStatsSchema` required an object that `totalHomeStats` already guards
(`if (!host || typeof host !== 'object') continue`), so the requirement bought
nothing at the read and cost the row upstream: the refusal reached
`fetchMobileHomeStats`'s `.catch`, the per-host slot was never written,
`hostIds.filter` found no host and the Home header drew no stats row where main
drew `0 / 0s / 0`. It takes `.nullish()`, and `HomeStatsRow` admits the
`null | undefined` main always had. The unit pin now says the slot keeps a null
summary and the total skips it, and sums one through `totalHomeStats` to show the
zeroed row survives.

The Home card's `SAFETY:` note claimed the reader proves `worktrees` is an array.
It does not; the `?? []` does. That is the same false sentence round 1 removed
from the agent-history panel, and a reader who believed it would delete the `??`
and reintroduce the white screen.

The `catalogError` branch on `RpcIncompatibleReplyError` had nothing holding it:
no adapter mounts the host screen, so no golden can reach it. One case in the
snapshot client pins the class the `catch` keys on. Mutation-checked by forwarding
the catalog schema as `z.unknown()`, which fails that case alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the round-2 fixes and main's merge

Pin moves to 0b8bd1c3c7, this branch's last fenced commit. The re-record
normalises the ten session create-terminal goldens main re-recorded in #20069
without repinning, and writes the Home stats family's bodies back to what main
records now that the summary reader no longer requires the object its own
consumer guards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record the corpus at the merge with main

The merge took main's goldens verbatim at main's pin, so the four goldens this
branch adds were the only ones whose header did not name a commit in this
history. Repinning to the merge commit and re-recording gives all 764 one pin
and one recorder, which is what the new ancestry guard asks of the corpus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): point the reply schemas' citations at the lines they claim

Every `file:line` in this branch's diff was resolved against the tree and its
target line compared to the claim beside it. Fourteen were wrong. Most had
drifted one to four lines onto a closing brace or the line after the code they
quote, which reads exactly like a good citation and can only be caught by
resolving it.

Two were wrong in kind rather than by a few lines. The viewport comment
credited the `=== true` projection to the refit call site, which reads plain
truthiness; the rule was main's reader, and the refit's two decisions are the
lines now cited. The capability comment cited a line for main's
`every(typeof === 'string')` rule inside the code this change deletes, so it
resolved to an unrelated brace; it now states the behaviour and says why no
line carries it.

Two more pointed at the head or tail of the statement they named and are
tightened to the line that does the work.

Comment-only: no schema, no reader and no call site moves. The corpus is
repinned and re-recorded on top because the recorder fences `mobile/src`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record after the citation fixes

The citation commit is comment-only, and the corpus says so: all 764 goldens
move on the `baseline` header line and nothing else. The re-record is needed
only because the recorder fences `mobile/src`, which a comment is inside.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the three unguarded-site reply schemas total so a malformed result cannot throw where main tolerated it

The ui.get reader is interpreted inside a fire-and-forget IIFE with no catch, and the home
stats and push delivery readers sit behind handlers that would print the reader's own sentence
where main drew a zeroed row or the generic copy. Each schema now decodes any unreadable
result as absent, which lands in the fallback main already took.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin and re-record the corpus over the total schemas

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the host-status gate reader by its export

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 12:40:18 -04:00

517 lines
22 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { z } from 'zod'
import {
clipboardImagePathSchema,
clipboardImageUnreadReplySchema,
clipboardImageUploadSlotSchema
} from './clipboard-image-reply-schema'
import {
branchCompareProjectionSchema,
reviewGitDiffSchema,
reviewGitMutationSchema,
reviewWorktreeMetadataSchema
} from './diff-review-reply-schema'
import {
githubPrMutationConfirmationSchema,
githubPrMutationStatusSchemas
} from './github-pr-mutation-reply-schema'
import {
githubPrForBranchSchema,
githubPrRepoSlugSchema,
githubWorkItemDetailsSchema,
hostedReviewForBranchSchema
} from './github-pr-read-reply-schema'
import { prChecksSchema } from './github-pr-entity-reply-schema'
import {
reviewCreatedTerminalSchema,
reviewTerminalSendAcceptedSchema,
reviewTerminalTabsSchema
} from './review-terminal-reply-schema'
import {
aiVaultResumePreparationSchema,
browserTabCreatedSchema,
fileTapOpenedSchema
} from './session-launch-reply-schema'
import {
detectedAgentsSchema,
markdownTabDocumentSchema,
runtimeRepoListSchema,
sessionTerminalInventorySchema,
sessionWorktreeRecordSchema,
terminalQuickCommandsSchema,
workspaceFilePathsSchema
} from './session-read-reply-schema'
import { sessionCreatedTerminalTabSchema } from './session-write-reply-schema'
// One suite per claim the session schemas make. The three kinds of case here are the three kinds of
// decision the schemas encode: a member a consumer reads unguarded is required, an arm set a reader
// compares against degrades rather than refusing, and a reply whose arms need different members is
// declared as variants.
function reads<T>(schema: z.ZodType<T, unknown>, value: unknown): T {
const parsed = schema.safeParse(value)
if (!parsed.success) {
throw new Error(`expected a readable reply: ${parsed.error.message}`)
}
return parsed.data
}
function refuses(schema: z.ZodType<unknown, unknown>, value: unknown): boolean {
return !schema.safeParse(value).success
}
describe('required members', () => {
it('requires the upload slot the chunk loop is addressed to', () => {
expect(reads(clipboardImageUploadSlotSchema, { uploadId: 'u-1' }).uploadId).toBe('u-1')
expect(refuses(clipboardImageUploadSlotSchema, {})).toBe(true)
expect(refuses(clipboardImageUploadSlotSchema, { uploadId: 7 })).toBe(true)
expect(refuses(clipboardImageUploadSlotSchema, null)).toBe(true)
})
it('requires the commit path to be a string', () => {
expect(reads(clipboardImagePathSchema, '/tmp/a.png')).toBe('/tmp/a.png')
expect(refuses(clipboardImagePathSchema, { path: '/tmp/a.png' })).toBe(true)
expect(refuses(clipboardImagePathSchema, undefined)).toBe(true)
})
it('reads the two clipboard legs whose body nothing reads', () => {
expect(refuses(clipboardImageUnreadReplySchema, undefined)).toBe(false)
expect(refuses(clipboardImageUnreadReplySchema, 'anything')).toBe(false)
expect(refuses(reviewGitMutationSchema, null)).toBe(false)
})
it('requires a created terminal to name the handle the prompt is sent to', () => {
const tab = { id: 'tab-1', type: 'terminal', title: 'codex', terminal: 'terminal-1' }
expect(reads(reviewCreatedTerminalSchema, { tab }).terminal).toBe('terminal-1')
expect(refuses(reviewCreatedTerminalSchema, {})).toBe(true)
expect(refuses(reviewCreatedTerminalSchema, { tab: { ...tab, terminal: '' } })).toBe(true)
expect(refuses(reviewCreatedTerminalSchema, { tab: { ...tab, id: '' } })).toBe(true)
})
it('requires the strip to be able to address the tab a New Tab create seated', () => {
const tab = { id: 'tab-9', type: 'terminal', title: 'codex', terminal: 'terminal-9' }
const created = reads(sessionCreatedTerminalTabSchema, { tab })
expect(created.id).toBe('tab-9')
expect(created.terminal).toBe('terminal-9')
expect(refuses(sessionCreatedTerminalTabSchema, {})).toBe(true)
expect(refuses(sessionCreatedTerminalTabSchema, { tab: { ...tab, id: '' } })).toBe(true)
// A create that answered some other tab kind would be spread into the strip as a terminal.
expect(refuses(sessionCreatedTerminalTabSchema, { tab: { ...tab, type: 'markdown' } })).toBe(
true
)
})
it('keeps a created tab whose handle has not been assigned yet, and passes newer members through', () => {
const tab = { id: 'tab-9', type: 'terminal', terminal: null, status: 'pending-handle' }
const created = reads(sessionCreatedTerminalTabSchema, { tab })
expect(created.terminal).toBeNull()
expect(created.title).toBeUndefined()
expect(created.status).toBe('pending-handle')
// Main guarded each of these, so an unreadable one drops to the guard rather than refusing.
const salvaged = reads(sessionCreatedTerminalTabSchema, {
tab: { ...tab, title: 7, terminal: 3 }
})
expect(salvaged.title).toBeUndefined()
expect(salvaged.terminal).toBeUndefined()
})
it('requires the tab list the send sheet renders, and drops the rows it cannot address', () => {
const tabs = reads(reviewTerminalTabsSchema, {
tabs: [
{ id: 'tab-1', type: 'terminal', terminal: 'terminal-1' },
{ id: 'tab-2', type: 'markdown', title: 'notes.md' },
{ id: 'tab-3', type: 'terminal' }
]
})
expect(tabs).toEqual([{ id: 'tab-1', terminal: 'terminal-1', title: 'Terminal' }])
expect(refuses(reviewTerminalTabsSchema, {})).toBe(true)
expect(refuses(reviewTerminalTabsSchema, { tabs: 'none' })).toBe(true)
})
it('requires the three compare members every summary line reads', () => {
const summary = { baseRef: 'origin/main', compareRef: 'feature', changedFiles: 2 }
expect(reads(branchCompareProjectionSchema, { summary, entries: [] }).summary.baseRef).toBe(
'origin/main'
)
for (const missing of ['baseRef', 'compareRef', 'changedFiles']) {
const partial: Record<string, unknown> = { ...summary }
delete partial[missing]
expect(refuses(branchCompareProjectionSchema, { summary: partial, entries: [] })).toBe(true)
}
expect(refuses(branchCompareProjectionSchema, { summary })).toBe(true)
expect(refuses(branchCompareProjectionSchema, { summary, entries: {} })).toBe(true)
})
it('requires each committed change to have a path and a status it can draw', () => {
const summary = { baseRef: 'origin/main', compareRef: 'feature', changedFiles: 1 }
const compare = reads(branchCompareProjectionSchema, {
summary,
entries: [
{ path: 'a.ts', status: 'modified' },
{ path: '', status: 'modified' },
{ path: 'b.ts', status: 'untracked' },
{ path: 'c.ts' }
]
})
expect(compare.entries.map((entry) => entry.path)).toEqual(['a.ts'])
})
it('requires the three members a markdown tab publishes into its ready state', () => {
const doc = { content: '# hi', version: 'v1', isDirty: false }
expect(reads(markdownTabDocumentSchema, doc).version).toBe('v1')
for (const missing of ['content', 'version', 'isDirty']) {
const partial: Record<string, unknown> = { ...doc }
delete partial[missing]
expect(refuses(markdownTabDocumentSchema, partial)).toBe(true)
}
expect(reads(markdownTabDocumentSchema, { ...doc, editable: 'yes' }).editable).toBeUndefined()
})
it('requires the terminal inventory to be a list of handles', () => {
const inventory = reads(sessionTerminalInventorySchema, {
terminals: [{ handle: 'h1', title: 'One' }, { title: 'no handle' }]
})
expect(inventory.terminals).toEqual([{ handle: 'h1', title: 'One' }])
expect(refuses(sessionTerminalInventorySchema, {})).toBe(true)
expect(refuses(sessionTerminalInventorySchema, { terminals: null })).toBe(true)
})
it('requires the repo list, and keeps only rows a worktree can be matched against', () => {
expect(
reads(runtimeRepoListSchema, {
repos: [{ id: 'repo-1', connectionId: null }, { path: '/x' }]
})
).toEqual([{ id: 'repo-1', connectionId: null }])
expect(refuses(runtimeRepoListSchema, {})).toBe(true)
expect(refuses(runtimeRepoListSchema, { repos: 'one' })).toBe(true)
})
it('requires detected agents to be an array the loader can spread', () => {
expect(reads(detectedAgentsSchema, ['codex', 'claude'])).toEqual(['codex', 'claude'])
expect(refuses(detectedAgentsSchema, 'codex')).toBe(true)
expect(refuses(detectedAgentsSchema, null)).toBe(true)
})
it('requires files to be a list when present, and reads absence as no suggestions', () => {
expect(reads(workspaceFilePathsSchema, { files: [{ relativePath: 'a.ts' }, {}] })).toEqual([
'a.ts'
])
expect(reads(workspaceFilePathsSchema, {})).toEqual([])
expect(refuses(workspaceFilePathsSchema, { files: 'a.ts' })).toBe(false)
expect(reads(workspaceFilePathsSchema, { files: 'a.ts' })).toEqual([])
})
it('requires the open verdict the tap routes on', () => {
expect(reads(fileTapOpenedSchema, { opened: true }).opened).toBe(true)
expect(refuses(fileTapOpenedSchema, {})).toBe(true)
expect(refuses(fileTapOpenedSchema, { opened: 1 })).toBe(true)
})
it('requires the browser tab payload but not the page id inside it', () => {
expect(reads(browserTabCreatedSchema, { browserPageId: 'p1' }).browserPageId).toBe('p1')
expect(reads(browserTabCreatedSchema, {}).browserPageId).toBeUndefined()
expect(refuses(browserTabCreatedSchema, null)).toBe(true)
})
it('reads the resume repin through its guards, absence included', () => {
expect(reads(aiVaultResumePreparationSchema, null)).toBeNull()
expect(reads(aiVaultResumePreparationSchema, undefined)).toBeUndefined()
expect(
reads(aiVaultResumePreparationSchema, { useRealCodexHome: true })?.useRealCodexHome
).toBe(true)
expect(refuses(aiVaultResumePreparationSchema, 'repinned')).toBe(true)
})
it('reads a worktree record it cannot parse as no record, the way main did', () => {
expect(reads(sessionWorktreeRecordSchema, { worktree: 'gone' })).toBeUndefined()
expect(reads(sessionWorktreeRecordSchema, {})).toBeUndefined()
expect(
reads(sessionWorktreeRecordSchema, { worktree: { displayName: 'wt' } })?.displayName
).toBe('wt')
expect(refuses(sessionWorktreeRecordSchema, 7)).toBe(true)
})
it('reads the quick-command member through the container, and refuses a bare reply', () => {
expect(reads(terminalQuickCommandsSchema, { terminalQuickCommands: [] })).toEqual([])
expect(reads(terminalQuickCommandsSchema, null)).toBeUndefined()
expect(refuses(terminalQuickCommandsSchema, 'commands')).toBe(true)
})
it('requires the review notes container but neither member inside it', () => {
expect(reads(reviewWorktreeMetadataSchema, {})).toEqual({
diffComments: undefined,
mobileDiffReview: undefined
})
expect(
reads(reviewWorktreeMetadataSchema, { worktree: { diffComments: [1] } }).diffComments
).toEqual([1])
expect(refuses(reviewWorktreeMetadataSchema, null)).toBe(true)
})
})
describe('degrading arm sets', () => {
it('reads a known check status, degrades one it does not know, and defaults absence', () => {
const review = { provider: 'github', number: 7 }
expect(reads(hostedReviewForBranchSchema, { ...review, status: 'success' })?.status).toBe(
'success'
)
expect(reads(hostedReviewForBranchSchema, { ...review, status: 'flaky' })?.status).toBe(
'pending'
)
expect(reads(hostedReviewForBranchSchema, review)?.status).toBe('pending')
})
it('reads a known mergeable state, degrades one it does not know, and defaults absence', () => {
const review = { provider: 'github', number: 7 }
expect(
reads(hostedReviewForBranchSchema, { ...review, mergeable: 'CONFLICTING' })?.mergeable
).toBe('CONFLICTING')
expect(reads(hostedReviewForBranchSchema, { ...review, mergeable: 'BEHIND' })?.mergeable).toBe(
'UNKNOWN'
)
expect(reads(hostedReviewForBranchSchema, review)?.mergeable).toBe('UNKNOWN')
})
it('reads a provider it does not know as no review rather than as an arm', () => {
expect(reads(hostedReviewForBranchSchema, { provider: 'gitlab', number: 3 })?.provider).toBe(
'gitlab'
)
expect(reads(hostedReviewForBranchSchema, { provider: 'codeberg', number: 3 })).toBeNull()
expect(reads(hostedReviewForBranchSchema, { number: 3 })).toBeNull()
expect(reads(hostedReviewForBranchSchema, null)).toBeNull()
})
// Why these three degrade on a NON-STRING too: `openEnum` is `z.enum(...).or(z.string()...)`,
// so it refuses a number where main mapped it to the conservative arm. Swapping these to
// `openEnum(..., fallback).optional()` keeps every other test green and flips exactly these.
it('degrades a non-string check status to pending rather than refusing the review', () => {
const review = { provider: 'github', number: 7 }
expect(reads(hostedReviewForBranchSchema, { ...review, status: 3 })?.status).toBe('pending')
expect(reads(hostedReviewForBranchSchema, { ...review, status: null })?.status).toBe('pending')
})
it('degrades a non-string mergeable state to UNKNOWN rather than refusing the review', () => {
const review = { provider: 'github', number: 7 }
expect(reads(hostedReviewForBranchSchema, { ...review, mergeable: 3 })?.mergeable).toBe(
'UNKNOWN'
)
})
it('degrades a non-string PR checksStatus to pending rather than refusing the PR', () => {
const found = reads(githubPrForBranchSchema, {
kind: 'found',
pr: { number: 4, state: 'open', checksStatus: 3 }
})
expect(found?.kind === 'found' && found.pr.checksStatus).toBe('pending')
})
it('reads a known compare status, degrades one it does not know, and defaults absence', () => {
const base = { baseRef: 'origin/main', compareRef: 'feature', changedFiles: 0 }
const read = (status?: unknown) =>
reads(branchCompareProjectionSchema, {
summary: status === undefined ? base : { ...base, status },
entries: []
}).summary.status
expect(read('ready')).toBe('ready')
expect(read('shallow-base')).toBe('error')
expect(read()).toBe('error')
})
})
// The other half of the same decision: where main DROPPED the row or block rather than mapping it
// to an arm, the arm set stays closed and required. Defaulting these would invent a status the host
// never sent, so the row leaving is the honest answer.
describe('closed arm sets drop rather than default', () => {
it('drops a check row whose status is an arm the icon cannot draw', () => {
const rows = reads(prChecksSchema, [
{ name: 'ci', status: 'completed', conclusion: 'success' },
{ name: 'drift', status: 'paused' },
{ name: 'lint', status: 'in_progress' }
])
expect(rows.map((row) => row.name)).toEqual(['ci', 'lint'])
})
it('drops a committed change whose status is an arm no badge covers', () => {
const projection = reads(branchCompareProjectionSchema, {
summary: { baseRef: 'origin/main', compareRef: 'feature', changedFiles: 2 },
entries: [
{ path: 'a.ts', status: 'modified' },
{ path: 'b.ts', status: 'teleported' }
]
})
expect(projection.entries.map((entry) => entry.path)).toEqual(['a.ts'])
})
it('refuses a diff whose kind names no arm the screen can render', () => {
expect(refuses(reviewGitDiffSchema, { kind: 'lfs-pointer' })).toBe(true)
})
it('drops a checks summary whose state is an arm the header cannot colour', () => {
const details = reads(githubWorkItemDetailsSchema, {
item: {
id: 'i-1',
number: 7,
type: 'pr',
state: 'open',
checksSummary: { state: 'stalled', total: 3, failed: 1 }
}
})
expect(details?.item.checksSummary).toBeUndefined()
})
it('drops a reaction whose content is an arm with no emoji to draw', () => {
const details = reads(githubWorkItemDetailsSchema, {
item: { id: 'i-1', number: 7, type: 'pr', state: 'open' },
comments: [
{
id: 1,
reactions: [
{ content: '+1', count: 2 },
{ content: 'party', count: 9 }
]
}
]
})
expect(details?.comments[0]?.reactions).toEqual([{ content: '+1', count: 2 }])
})
})
// A third answer, distinct from both: `null` on these two flags is GitHub saying it knows the
// answer and the answer is no, where `undefined` is the host not carrying the member. Coalescing
// the null away would read a well-formed reply differently from main, which preserved it.
describe('tri-state flags keep an explicit null', () => {
const review = { provider: 'github', number: 7 }
const pr = { number: 7, state: 'open' }
it('keeps a null autoMergeAllowed on the branch review and drops a non-boolean', () => {
expect(
reads(hostedReviewForBranchSchema, { ...review, autoMergeAllowed: null })?.autoMergeAllowed
).toBeNull()
expect(
reads(hostedReviewForBranchSchema, { ...review, autoMergeAllowed: false })?.autoMergeAllowed
).toBe(false)
expect(
reads(hostedReviewForBranchSchema, { ...review, autoMergeAllowed: 'no' })?.autoMergeAllowed
).toBeUndefined()
expect(reads(hostedReviewForBranchSchema, review)?.autoMergeAllowed).toBeUndefined()
})
it('keeps a null autoMergeAllowed and mergeQueueRequired on the PR and drops a non-boolean', () => {
const readsPr = (value: unknown) => {
const found = reads(githubPrForBranchSchema, { kind: 'found', pr: value })
return found.kind === 'found' ? found.pr : null
}
const nulled = readsPr({ ...pr, autoMergeAllowed: null, mergeQueueRequired: null })
expect(nulled?.autoMergeAllowed).toBeNull()
expect(nulled?.mergeQueueRequired).toBeNull()
const bad = readsPr({ ...pr, autoMergeAllowed: 'no', mergeQueueRequired: 1 })
expect(bad?.autoMergeAllowed).toBeUndefined()
expect(bad?.mergeQueueRequired).toBeUndefined()
expect(readsPr(pr)?.mergeQueueRequired).toBeUndefined()
})
})
describe('declared variants', () => {
const [envelopeSchema, voidSchema] = githubPrMutationStatusSchemas
it('reads a mutation envelope as the status it is', () => {
expect(reads(envelopeSchema, { ok: false, error: 'nope' })).toEqual({
structured: true,
ok: false,
error: 'nope'
})
expect(reads(envelopeSchema, { ok: true })).toEqual({
structured: true,
ok: true,
error: undefined
})
})
it('refuses the envelope arm for a reply with no ok member, which the void arm takes', () => {
expect(refuses(envelopeSchema, { error: 'nope' })).toBe(true)
expect(refuses(envelopeSchema, [])).toBe(true)
expect(reads(voidSchema, { error: 'nope' })).toEqual({ structured: false })
expect(reads(voidSchema, undefined)).toEqual({ structured: false })
})
it('requires the confirmation mutations to answer a boolean', () => {
expect(reads(githubPrMutationConfirmationSchema, true)).toBe(true)
expect(reads(githubPrMutationConfirmationSchema, false)).toBe(false)
expect(refuses(githubPrMutationConfirmationSchema, undefined)).toBe(true)
expect(refuses(githubPrMutationConfirmationSchema, { ok: true })).toBe(true)
})
it('reads each file-diff arm and refuses a kind the screen cannot render', () => {
expect(
reads(reviewGitDiffSchema, { kind: 'text', originalContent: 'a', modifiedContent: 'b' })
).toEqual({ kind: 'text', originalContent: 'a', modifiedContent: 'b' })
expect(reads(reviewGitDiffSchema, { kind: 'binary' })).toEqual({ kind: 'binary' })
expect(reads(reviewGitDiffSchema, { kind: 'too-large', byteLength: 2048 })).toEqual({
kind: 'too-large',
byteLength: 2048
})
expect(refuses(reviewGitDiffSchema, { kind: 'unknown' })).toBe(true)
expect(refuses(reviewGitDiffSchema, { kind: 'text', originalContent: 'a' })).toBe(true)
})
it('reads each branch-lookup outcome arm, legacy and classified alike', () => {
expect(reads(githubPrForBranchSchema, { kind: 'no-pr' })).toBeNull()
expect(reads(githubPrForBranchSchema, null)).toBeNull()
const upstream = reads(githubPrForBranchSchema, {
kind: 'upstream-error',
message: 'rate limited'
})
expect(upstream?.kind === 'upstream-error' && upstream.message).toBe('rate limited')
const classified = reads(githubPrForBranchSchema, {
kind: 'found',
pr: { number: 4, state: 'open' }
})
expect(classified?.kind === 'found' && classified.pr.number).toBe(4)
const legacy = reads(githubPrForBranchSchema, { number: 9, state: 'merged' })
expect(legacy?.kind === 'found' && legacy.pr.state).toBe('merged')
expect(refuses(githubPrForBranchSchema, { number: 9 })).toBe(true)
})
it('reads the repo slug, and null for a repo with no GitHub remote', () => {
expect(reads(githubPrRepoSlugSchema, { owner: 'o', repo: 'r', host: 'gh.test' })).toEqual({
owner: 'o',
repo: 'r',
host: 'gh.test'
})
expect(reads(githubPrRepoSlugSchema, { owner: 'o', repo: 'r', host: '' })).toEqual({
owner: 'o',
repo: 'r'
})
expect(reads(githubPrRepoSlugSchema, null)).toBeNull()
expect(refuses(githubPrRepoSlugSchema, { owner: 'o' })).toBe(true)
})
// The plain terminal-send envelope is pinned once, in terminal-reply-schema.test.ts; this is the
// review variant only, whose absent arm reads the other way.
it('reads a review terminal send as delivered unless the runtime said otherwise', () => {
expect(reads(reviewTerminalSendAcceptedSchema, { send: { accepted: false } })).toBe(false)
expect(reads(reviewTerminalSendAcceptedSchema, {})).toBe(true)
expect(refuses(reviewTerminalSendAcceptedSchema, null)).toBe(true)
})
})
describe('a newer host is not refused', () => {
it('passes members no reader knows straight through', () => {
const doc = reads(markdownTabDocumentSchema, {
content: 'c',
version: 'v',
isDirty: false,
collaborators: ['a'],
revisionKind: 'crdt'
})
expect(doc.collaborators).toEqual(['a'])
const slot = reads(clipboardImageUploadSlotSchema, { uploadId: 'u', resumeToken: 'r' })
expect(slot.resumeToken).toBe('r')
const inventory = reads(sessionTerminalInventorySchema, {
terminals: [{ handle: 'h', pane: 'split' }],
layoutVersion: 3
})
expect(inventory.layoutVersion).toBe(3)
})
})