Files
orca/mobile/src/tasks/task-project-board-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

334 lines
14 KiB
TypeScript

import { z } from 'zod'
import type { GitHubProjectOwnerType } from '../../../src/shared/github/project-types'
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
import {
assignableUserListSchema,
detailCheckListSchema,
detailCommentListSchema,
detailFileListSchema,
reviewSummaryListSchema
} from './task-provider-entity-reply-schema'
// The sixteen `github.project.*` replies the Projects board reads. Every one of them is an
// accepted result carrying its own `{ ok, error }` envelope, published by
// src/main/runtime/rpc/methods/github-project-methods.ts and typed in
// src/shared/github/project-result-types.ts.
//
// Two shapes appear here, and which one a reply gets is decided by its consumer, not by the host:
//
// - a discriminated union, where the consumer reads a member off BOTH arms without a guard
// (`result.error.message` on the refusal arm and the payload on the success arm). `ok` is
// load-bearing there, so an envelope without it is an incompatible reply rather than the
// property-read TypeError main threw;
// - a flat passthrough object, where the consumer guards every member it reads
// (`result.error?.message ?? '...'`, `result.labels ?? []`). Nothing is required there beyond
// the container, because requiring a member the consumer already defaults would refuse a reply
// main rendered.
//
// Required members are only ever ones a recorded golden shows the host sending AND a consumer
// reads unguarded. Nothing deeper is declared: the project table's rows, for instance, have no
// recorded row to check a deeper requirement against, so requiring one would be a claim about the
// wire that this corpus cannot support.
/**
* `owner`/`ownerType`/`number` compose the persisted project key, so every row carries them.
*
* CLOSED, and the one enum here that is. The two arms are GitHubProjectOwnerType verbatim
* (src/shared/github/project-types.ts:8), which is the type the handler's own params and results
* are declared in — not mobile's restatement of it. It stays closed because a decoded `ownerType`
* is echoed straight back into the params of the next project call
* (mobile-tasks-github-project-pickers.tsx:227, use-mobile-tasks-project-loading-actions.tsx:75,
* :110, :269), and remote-wire-compatibility.md rule 4 forbids letting a fallback shape a param
* the host reads back. An unknown arm therefore drops its row rather than inventing an owner type.
* The corpus exercises `'organization'` (`tk-project-board-load`); `'user'` is unexercised but
* inside the set, so no reply can drop on it.
*/
// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc.
export const PROJECT_OWNER_TYPE = hostUnionArms<GitHubProjectOwnerType>({
organization: true,
user: true
})
const projectMessage = (name: string) => salvagedOptional(name, z.string())
const projectCount = (name: string) => salvagedOptional(name, z.number())
/** The refusal arm's message, read unguarded as `result.error.message`. */
const requiredProjectError = z
.looseObject({ message: projectMessage('message') })
.transform((error) => ({
...error,
message: error.message ?? ''
}))
/** The refusal arm's message where the consumer already spells `?? 'Failed to …'`. */
const optionalProjectError = salvagedOptional(
'error',
z.looseObject({ message: projectMessage('message') })
)
/** `ok` where the consumer tests it rather than switching on it: absent still reads as refused. */
const optionalOk = salvagedOptional('ok', z.boolean())
/**
* One accessible project.
*
* `owner` is required because githubProjectIdentityKey calls `.toLowerCase()` on it
* (src/shared/github/project-identity.ts:13) for every row the picker stores or compares, and
* `ownerType`/`number` are the rest of that key. `title` is required for the same reason one step
* later: the picker's search spells `project.title.toLowerCase()` with no guard
* (use-mobile-tasks-provider-view-projection.tsx:213), it is non-optional on GitHubProjectSummary
* (src/shared/github/project-types.ts:200-209), and the recorded reply carries it. A row without
* any of the four is dropped rather than failing the list, which keeps the banner and the
* remaining projects.
*
* `id`, `url` and `source` stay undeclared: the recorded reply (`tk-project-board-load`,
* `github.project.listAccessible#1`) carries none of them, so requiring what the shared type
* declares would refuse main's own fixture.
*/
const projectSummary = z.looseObject({
owner: z.string(),
ownerType: z.enum(PROJECT_OWNER_TYPE),
number: z.number(),
title: z.string(),
host: projectMessage('host')
})
/**
* The accessible-project list.
*
* `projects` is required on the success arm: use-mobile-tasks-project-loading-actions.tsx:62 hands
* it straight to `setGithubProjects`, and `partialFailures` is not, because :64 spells `?? []`.
* `error.message` is required on the refusal arm because :59 throws it unguarded.
*/
export const taskProjectAccessibleListSchema = z.union([
z.looseObject({
ok: z.literal(true),
projects: salvagingArray(projectSummary),
partialFailures: salvagedOptional(
'partialFailures',
salvagingArray(
z.looseObject({ owner: projectMessage('owner'), message: projectMessage('message') })
)
)
}),
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
])
/**
* A project's views.
*
* `views` is required: :84 publishes it and :85 returns it, and :191/:196/:210/:220 run `find` and
* `filter` over the same array with no guard. A view needs the `id` the selection is committed
* under (:225 → githubProjectSettings.lastViewByProject), so a row without one drops.
*
* `layout` is a plain string, not an enum. Every reader is an equality test against
* `'TABLE_LAYOUT'` (:196/:204/:210/:220), so a layout arm this build has not heard of already
* reads as "not supported" — closing the set would instead drop the row and change the count the
* "no supported views" copy is decided by. That is remote-wire-compatibility.md rule 4.
*/
export const taskProjectViewListSchema = z.union([
z.looseObject({
ok: z.literal(true),
views: salvagingArray(
z.looseObject({
id: z.string(),
number: projectCount('number'),
name: projectMessage('name'),
layout: projectMessage('layout')
})
)
}),
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
])
/**
* The board table.
*
* `data` and `data.selectedView` are required because :126 reads `data.selectedView.filter` and
* :128-:137 read four more members off it, all unguarded — a reply without the container was a
* property read on undefined. `project.id` is required for the same reason at
* use-mobile-tasks-project-metadata-actions.tsx:160/:172, which sends it as `projectId` on every field
* mutation.
*
* Nothing inside `selectedView` or `rows` is required. The recorded table
* (`tk-project-board-load`, `github.project.viewTable#1`) carries a `project` with no `owner`,
* `ownerType` or `url` and an empty `rows`, so this corpus has no evidence for a deeper claim.
*/
export const taskProjectViewTableSchema = z.union([
z.looseObject({
ok: z.literal(true),
data: z.looseObject({
project: z.looseObject({ id: z.string() }),
selectedView: z.looseObject({
id: projectMessage('id'),
number: projectCount('number'),
name: projectMessage('name'),
layout: projectMessage('layout'),
filter: projectMessage('filter')
}),
rows: salvagedOptional('rows', salvagingArray(z.looseObject({ id: z.string() })))
})
}),
z.looseObject({
ok: z.literal(false),
error: requiredProjectError,
totalCount: projectCount('totalCount')
})
])
/**
* A pasted project URL or `owner/number`, resolved.
*
* `owner`, `ownerType` and `number` are required:
* use-mobile-tasks-project-loading-actions.tsx:267-:272 forward all three into
* `selectGitHubProject`, which puts them in the key and in the next `github.project.listViews`
* params. `ownerType` is therefore a CLOSED enum with no fallback — it is echoed into a param, and
* remote-wire-compatibility.md rule 4 forbids a reply-schema fallback from shaping one. The arm
* set is genuinely closed host-side: project-view-listing.ts:23 answers `validation_error` for any
* other value, so degrading an unknown arm would only put a value the host refuses on the wire.
*
* `title` is optional because no mobile consumer reads it. `host` is optional because
* use-mobile-tasks-project-loading-actions.tsx:271 coalesces it (`result.host ?? parsed.host`),
* and `viewNumber` because :190 tests `typeof options.viewNumber === 'number'` before using it.
*/
export const taskProjectRefSchema = z.union([
z.looseObject({
ok: z.literal(true),
owner: z.string(),
ownerType: z.enum(PROJECT_OWNER_TYPE),
number: z.number(),
title: projectMessage('title'),
host: projectMessage('host'),
viewNumber: projectCount('viewNumber')
}),
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
])
/**
* A board row's detail pane.
*
* `details` is required: use-mobile-tasks-project-detail-loading.tsx:110-:121 reads eleven members
* off it, each defaulted but the container itself never guarded. `error.message` is required
* because :102 throws it.
*
* `item.reviewDecision` is nullable AND optional and stays that way. :121 forwards it verbatim
* into `projectRowDetail`, where `null` ("reviewed, no decision") and absent ("this host does not
* report one") are different states — collapsing either into the other with a `??` here would be
* the null-collapse the session domain shipped and had caught two review rounds later.
*/
export const taskProjectRowDetailSchema = z.union([
z.looseObject({
ok: z.literal(true),
details: z.looseObject({
body: projectMessage('body'),
comments: salvagedOptional('comments', detailCommentListSchema),
item: salvagedOptional(
'item',
z.looseObject({
labels: salvagedOptional('labels', salvagingArray(z.string())),
reviewDecision: salvagedOptional('reviewDecision', z.string().nullable()),
reviewRequests: salvagedOptional('reviewRequests', assignableUserListSchema),
latestReviews: salvagedOptional('latestReviews', reviewSummaryListSchema)
})
),
assignees: salvagedOptional('assignees', salvagingArray(z.string())),
headSha: projectMessage('headSha'),
baseSha: projectMessage('baseSha'),
pullRequestId: projectMessage('pullRequestId'),
checks: salvagedOptional('checks', detailCheckListSchema),
files: salvagedOptional('files', detailFileListSchema)
})
}),
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
])
/**
* The repo label list.
*
* Flat, not a union: use-mobile-tasks-project-metadata-loading.tsx:53 spells
* `result.error?.message ?? 'Failed to load labels'` and :55 spells `result.labels ?? []`, so
* every member is already defaulted and an envelope with no `ok` still reads as refused. What the
* schema adds is the container and the element type — a `labels` that is not an array of strings
* reached the label picker as rendered garbage.
*/
export const taskProjectLabelListSchema = z.looseObject({
ok: optionalOk,
labels: salvagedOptional('labels', salvagingArray(z.string())),
error: optionalProjectError
})
/** The assignable-user list, guarded the same way at :105-:110. The row is
* `assignableUserListSchema`, the same entity the item sheet's reviewer picker decodes: `login` is
* what both key on, and `name`/`avatarUrl` keep their explicit `null`. */
export const taskProjectAssignableUserListSchema = z.looseObject({
ok: optionalOk,
users: salvagedOptional('users', assignableUserListSchema),
error: optionalProjectError
})
/** The repo issue types, guarded the same way at :159-:164. `id` is the mutation's own param
* (use-mobile-tasks-project-metadata-actions.tsx:240), so a row without one cannot be applied. */
export const taskProjectIssueTypeListSchema = z.looseObject({
ok: optionalOk,
types: salvagedOptional(
'types',
salvagingArray(z.looseObject({ id: z.string(), name: projectMessage('name') }))
),
error: optionalProjectError
})
/**
* The five board mutations whose reply is only a verdict: the issue and pull-request metadata
* writes, the issue-type write, and the field set/clear pair.
*
* Every consumer tests `result.ok === false` and then `result.error?.message ?? '…'`
* (use-mobile-tasks-project-metadata-actions.tsx:63/:182/:245 and
* use-mobile-tasks-project-workspace-comment-actions.tsx:142), so nothing but the container is
* required. The container is the change: `project.update-metadata`'s `b2` seed answers
* `result: null`, which main read as `null.ok` and #20563 left recorded as a TypeError. It is now
* named as an incompatible `github.project.updateIssueBySlug` reply instead.
*/
export const taskProjectMutationStatusSchema = z.looseObject({
ok: optionalOk,
error: optionalProjectError
})
/**
* The added comment.
*
* `comment` is optional because :225 gates on it before appending. It carries `id` and `body`
* because the thread renderer keys and prints them, and because the recorded reply
* (`tk-project-row-comments-issue`) carries both — `id` as a NUMBER there, which is why the
* schema takes either rather than the string the mobile type leads with.
*/
export const taskProjectCommentWriteSchema = z.looseObject({
ok: optionalOk,
comment: salvagedOptional(
'comment',
z.looseObject({
id: z.union([z.string(), z.number()]),
body: projectMessage('body'),
author: projectMessage('author'),
createdAt: projectMessage('createdAt')
})
),
error: optionalProjectError
})
/**
* The comment edit and delete verdicts.
*
* `error` is a string OR an envelope, because both consumers read it that way
* (use-mobile-tasks-project-workspace-comment-actions.tsx:271 and
* use-mobile-tasks-project-thread-reply-actions.tsx:62 both branch on `typeof result.error ===
* 'string'`). Declaring only the envelope would salvage the string away and replace a host message
* the user has always seen with this app's fallback copy.
*/
export const taskProjectCommentMutationSchema = z.looseObject({
ok: optionalOk,
error: salvagedOptional(
'error',
z.union([z.string(), z.looseObject({ message: projectMessage('message') })])
)
})