Files
orca/mobile/src/tasks/task-provider-entity-reply-schema.ts
T
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

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

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

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

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

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

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

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

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

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

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

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

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

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

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

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

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

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

* docs(mobile): describe the census and correct the baseline count

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

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

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

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

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

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

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

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

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

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

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

177 lines
8.5 KiB
TypeScript

import { z } from 'zod'
import type { GitHubPRFile } from '../../../src/shared/github/pull-request-types'
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
import { prCount, prFlag, prNullableText, prText } from '../session/github-pr-entity-reply-schema'
// The entities the tasks screen's provider replies are built out of: the mutation envelope every
// GitHub/GitLab/Linear write answers with, a conversation comment, an assignable user, a review
// summary, a check row and a changed file. Checked against src/main/github/issue-create.ts,
// issue-update.ts, issue-comment.ts, client/create/add-pr-review-comment.ts and the shared
// GitHubCommentResult / GitHubCreateIssueResult types the host returns from them.
//
// Two rules run through this file and the four schema modules beside it.
//
// 1. A member is required only where a tasks consumer reads it with no guard. Everything the
// consumer reaches through `?.`, `??` or a `typeof` test stays optional, because main read it
// that way and a reply without it rendered the same fallback it renders now.
// 2. No member is required that the site's own recorded `normal` reply does not carry. The corpus
// is the only evidence of what a host really sends at each site, and requiring a member absent
// from that control would turn a good reply into an incompatible one. Where that rule holds a
// schema looser than the host's own type, the schema says so at the member.
//
// Member helpers come from the session domain's entity module rather than a second copy: they are
// plain salvaged-member combinators over zod-salvage, and one definition is what keeps "absent
// stays absent, malformed reads as absent" identical on both surfaces.
// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc.
export const DETAIL_FILE_STATUS = hostUnionArms<GitHubPRFile['status']>({
added: true,
modified: true,
removed: true,
renamed: true,
copied: true,
changed: true,
unchanged: true
})
/**
* One conversation comment, as every task sheet holds it.
*
* `id` and `body` are the only required members, and they are the two `DetailComment` declares
* non-optional: the timeline keys rows by id and renders body unguarded. Every other member is
* reached through `?.` or `??` — commentAuthor (mobile-tasks-item-comments.tsx:47) is the shape of
* all of them — so it stays optional and is passed through exactly as the host sent it.
*
* A reaction's `content` is forwarded, not matched against an arm set. Three producers feed this
* one list and they disagree: GitHub sends `GitHubReactionContent` (`'+1'`, `'-1'`, `laugh`, ...,
* src/shared/github/comment-types.ts:3-17, normalised from GraphQL in
* src/main/github/comment-reactions.ts:19-27), and GitLab sends `GitLabReaction { name, count }`
* with no `content` at all (src/shared/gitlab-types.ts:60-72). An arm set drawn from either one
* drops the other producer's rows outright, so the reader keeps `count` — the only member read
* unguarded, by the `count > 0` filter at mobile-tasks-item-comments.tsx:145 — and hands `content`
* to the glyph lookup exactly as it arrived.
*/
export const detailCommentSchema = z.looseObject({
id: z.union([z.string(), z.number().finite()]),
author: prText('author'),
authorAvatarUrl: prText('authorAvatarUrl'),
user: salvagedOptional('user', z.looseObject({ displayName: prText('displayName') })),
isBot: prFlag('isBot'),
body: z.string(),
createdAt: prText('createdAt'),
url: prText('url'),
reactions: salvagedOptional(
'reactions',
salvagingArray(z.looseObject({ content: prText('content'), count: z.number().finite() }))
),
path: prText('path'),
line: prCount('line'),
startLine: prCount('startLine'),
threadId: prText('threadId'),
isResolved: prFlag('isResolved')
})
export const detailCommentListSchema = salvagingArray(detailCommentSchema)
/**
* A user the item can be assigned to or asked to review.
*
* `login` is the identity: the reviewer merge reads `reviewer.login.trim()` with no guard
* (use-mobile-tasks-hosted-comment-review-actions.tsx:172), so a row without one drops rather than
* taking the whole picker down. `name` and `avatarUrl` keep an explicit `null` — the recorded
* `github.listAssignableUsers` reply sends `avatarUrl: null`, and collapsing it to `''` would
* change what the avatar row renders for a good reply.
*/
const assignableUserSchema = z.looseObject({
login: z.string(),
name: prNullableText('name'),
avatarUrl: prNullableText('avatarUrl')
})
export const assignableUserListSchema = salvagingArray(assignableUserSchema)
/** One review, keyed by `login` the same way. Nested `author.login` is not accepted here: this
* surface never read it, and inventing the fallback would widen what the sheet shows. */
export const reviewSummaryListSchema = salvagingArray(
z.looseObject({
login: z.string(),
state: prNullableText('state'),
avatarUrl: prNullableText('avatarUrl')
})
)
/**
* One check row. `name` labels it and `status` drives its icon, and both are read unguarded by the
* checks list and by summarizeProviderChecks.
*
* `status` is a free string rather than the host's `queued | in_progress | completed` arm set,
* because this surface is fed by two different producers: the recorded `github.prChecks` reply
* sends `COMPLETED` / `SUCCESS` in caps, and `GitHubDetailCheck` declares both as strings.
*/
export const detailCheckListSchema = salvagingArray(
z.looseObject({
name: z.string(),
status: z.string(),
conclusion: prNullableText('conclusion'),
url: prNullableText('url')
})
)
/**
* One changed file. `path` is what the expansion, the viewed toggle and the comment anchor are all
* keyed on, so a row without one can never match and drops.
*
* No scenario reply in the corpus carries a file row, so no golden can observe either of the two
* vocabularies below; both rest on their unit pins and on the host's shared types.
*
* `viewerViewedState` is forwarded as text rather than closed against `GitHubPRFileViewedState`
* (three arms, src/shared/github/pull-request-types.ts:128). Nothing sends it back and both
* consumers test `=== 'VIEWED'` (mobile-tasks-item-review-sections.tsx:237,
* mobile-tasks-project-review-panels.tsx:209), so an arm this build predates reaches that test as
* itself, exactly as main's unchecked reader passed it, instead of vanishing.
*
* `status` stays closed, and the arms are not mobile's invention: the host validates this same
* seven-arm set on the way back in, as a zod enum on `github.prFileContents`' own params
* (src/shared/rpc-contract/github-pull-request-params.ts:63, matching `GitHubPRFile.status` at
* github/pull-request-types.ts:133). Its only consumer is that request
* (use-mobile-tasks-github-check-file-actions.tsx:196,
* use-mobile-tasks-project-file-merge-actions.tsx:78), so a forwarded eighth arm could not reach
* the wire without a cast, and if it did the host would refuse the call on its own params. Dropping
* to absent sends `?? 'modified'` instead, which fetches both sides and renders a diff.
*/
export const detailFileListSchema = salvagingArray(
z.looseObject({
path: z.string(),
oldPath: prText('oldPath'),
status: salvagedOptional('status', z.enum(DETAIL_FILE_STATUS)),
additions: prCount('additions'),
deletions: prCount('deletions'),
isBinary: prFlag('isBinary'),
viewerViewedState: prText('viewerViewedState')
})
)
/**
* The envelope every task mutation answers with, and the reason there is one of it rather than
* twenty.
*
* `ok === false` is the only failure test any of these call sites makes, and `error` is the only
* text any of them raises. Both stay optional and tri-state: a reply with no `ok` is the success
* main read, and `ok: false` is a refusal the caller reports with the host's own sentence.
* `error` is a string because that is what every call site interpolates into `new Error(...)`; a
* host that answers an object there now reaches the call site's own fallback copy instead of
* rendering `[object Object]`.
*/
export const taskMutationEnvelopeSchema = z.looseObject({
ok: prFlag('ok'),
error: prText('error')
})
/** The mutation envelope a comment write adds its created row to. The row is salvaged, so a
* comment the host could not describe leaves the call site's local echo in place. */
export const taskCommentWriteEnvelopeSchema = z.looseObject({
ok: prFlag('ok'),
error: prText('error'),
comment: salvagedOptional('comment', detailCommentSchema)
})