test(mobile): restore the four assertions the extraction weakened

- The Project host oracle no longer accepts a bare `payload` identifier, which
  every call site satisfies by construction. Inline payloads must show a host
  or a slug builder; the four typed-payload reads are named explicitly and
  their payload types are pinned to carry the host.
- `prRepo` is pinned per mutation and bounded to that adapter method, so
  dropping it from one of six fails instead of needing all six.
  `github.addPRReviewComment` is pinned again, with the row repository.
- The smart-source fan-out fake now delegates to the shipped search functions
  over a scripted transport, so the repoId stamping under test is the real one
  rather than the fake's copy.
- The retired-name read's method and `id:` prefix are asserted again, in the
  adapter test, since the hook now takes a callback.
- The session adapters join the route parity family, bringing the runtime
  strings and identity fields that moved out of the hooks back into scope.

Each restored assertion was verified to fail under the mutation it guards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-09 02:45:37 -04:00
parent d07fe48b4f
commit 0aaa70e5e9
5 changed files with 109 additions and 39 deletions
@@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'f7e7d7a99d506589d1b620b86f39bbce97223ab7ad4143b1f06a6a8d8ab81f5b'
'390b26467725abc0514fdbbee2cba3ded2586d471438cea0b113182465484d5b'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = 'd5f1ef0db57c63eb3e4ee7c98e8483bc21882a151ce0ca24e42c7d1234e1dace'
const HEAD_STYLE_REFERENCE_SHA256 =
@@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(522)
expect(strings).toHaveLength(593)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
@@ -43,7 +43,17 @@ export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [
'./MobileSessionContentRow.tsx',
'./MobileSessionActiveContent.tsx',
'./MobileSessionCommandDock.tsx',
'./MobileSessionSheets.tsx'
'./MobileSessionSheets.tsx',
// The adapters the route's RPCs moved into. Without them the runtime strings and identity
// fields that left the hooks would leave this family's scope entirely.
'./native-host-session-browser-operations.ts',
'./native-host-session-file-operations.ts',
'./native-host-session-markdown-operations.ts',
'./native-host-session-native-chat-operations.ts',
'./native-host-session-quick-command-operations.ts',
'./native-host-session-tab-operations.ts',
'./native-host-session-terminal-file-operations.ts',
'./native-host-session-terminal-operations.ts'
] as const
export function readMobileSessionRouteSource(relativePath: string): string {
@@ -19,7 +19,22 @@ const compositionSource = [
const projectReadAdapter = readSource('./native-host-task-project-read-operations.ts')
const projectMutationAdapter = readSource('./native-host-task-project-mutation-operations.ts')
const projectFileAdapter = readSource('./native-host-task-project-file-operations.ts')
const adapterSource = [projectReadAdapter, projectMutationAdapter, projectFileAdapter].join('\n')
const projectPayloadTypes = readSource('./host-task-project-payloads.ts')
/** The fallback map lists every method name as a key; those are not call sites. */
const mutationCallSites = projectMutationAdapter.replace(
/const PROJECT_MUTATION_FALLBACKS[\s\S]*?\n\}\n/,
''
)
const adapterSource = [projectReadAdapter, mutationCallSites, projectFileAdapter].join('\n')
/** These four pass a typed payload rather than an inline object, so their host is guaranteed by
* the payload type asserted below. Pinned by name so a new call cannot silently join them. */
const TYPED_PAYLOAD_CALLS = new Set([
'github.project.workItemDetailsBySlug',
'github.project.listLabelsBySlug',
'github.project.listAssignableUsersBySlug',
'github.project.listIssueTypesBySlug'
])
describe('mobile GitHub Project host routing boundary', () => {
it('keeps every Project RPC behind the adapter layer', () => {
@@ -31,12 +46,31 @@ describe('mobile GitHub Project host routing boundary', () => {
it('host-qualifies every Project RPC request', () => {
const calls = [...adapterSource.matchAll(/['"](github\.project\.[^'"]+)['"]/g)]
expect(calls.length).toBeGreaterThan(10)
for (const call of calls) {
if (TYPED_PAYLOAD_CALLS.has(call[1])) {
continue
}
const request = adapterSource.slice(call.index, call.index + 700)
expect(request, `${call[1]} must carry a host`).toMatch(
/\bhost:|slugPayload\(target\)|\bpayload\b/
/\bhost:|slugPayload\(target\)|repoPayload\(/
)
}
// The typed-payload calls get their host from the type, so pin the type instead.
expect(projectPayloadTypes).toMatch(
/export type HostTaskProjectSlugPayload = \{\s*owner: string\s*repo: string\s*host: string/
)
for (const derived of [
'HostTaskProjectItemDetailPayload',
'HostTaskProjectAssignableUsersPayload'
]) {
expect(projectPayloadTypes, `${derived} must inherit the host`).toMatch(
new RegExp(`export type ${derived} = HostTaskProjectSlugPayload &`)
)
}
expect(projectPayloadTypes).toMatch(
/export type HostTaskProjectTablePayload = GitHubProjectRef &/
)
// slugPayload is the single place a row identity becomes a wire payload.
expect(projectMutationAdapter).toMatch(
/function slugPayload\(target: HostTaskProjectItemTarget\) \{\s*return \{\s*owner: target\.owner,\s*repo: target\.repo,\s*host: target\.host,/
@@ -54,10 +88,10 @@ describe('mobile GitHub Project host routing boundary', () => {
}
// The target type carries the host that the PR mutations forward as prRepo, and a row with
// no slug forwards null rather than being refused.
expect(projectMutationAdapter).toContain('prRepo: prRepoPayload(target)')
expect(projectMutationAdapter).toMatch(
/function prRepoPayload\(target: HostTaskProjectItemTarget\) \{\s*return target\.owner && target\.repo/
)
// Per method, not once per file: dropping prRepo from a single mutation must fail here.
for (const method of [
'fetchResolveReviewThread',
'fetchAddPRReviewCommentReply',
@@ -66,10 +100,28 @@ describe('mobile GitHub Project host routing boundary', () => {
'fetchRerunPRChecks',
'fetchMergePR'
]) {
expect(projectMutationAdapter, `${method} must remain wired`).toContain(method)
const offset = projectMutationAdapter.indexOf(`${method}(`)
expect(offset, `${method} must remain wired`).toBeGreaterThan(-1)
// Bounded to this adapter method, so a neighbour's prRepo cannot satisfy it.
const end = projectMutationAdapter.indexOf('\n },', offset)
expect(
projectMutationAdapter.slice(offset, end === -1 ? undefined : end),
`${method} must carry prRepo`
).toContain('prRepo: prRepoPayload(target)')
}
for (const method of ['github.prChecks', 'github.setPRFileViewed', 'github.prFileContents']) {
expect(projectFileAdapter, `${method} must remain wired`).toContain(method)
for (const method of [
'github.prChecks',
'github.setPRFileViewed',
'github.prFileContents',
'github.addPRReviewComment'
]) {
const offset = projectFileAdapter.indexOf(`'${method}'`)
expect(offset, `${method} must remain wired`).toBeGreaterThan(-1)
const end = projectFileAdapter.indexOf('\n },', offset)
expect(
projectFileAdapter.slice(offset, end === -1 ? undefined : end),
`${method} must carry the row repository`
).toContain('repoPayload(target, repoId)')
}
})
+23 -30
View File
@@ -1,45 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { HostWorkspaceCreationOperations } from '../worktree/host-workspace-creation-operations'
import { fanOutSmartSearch } from './smart-source-fan-out'
import type { RpcRequestSender } from '../transport/rpc-client'
import {
searchBranches,
searchGitHubItems,
searchGitLabItems,
searchLinearIssues
} from './smart-source-search-requests'
type Call = { method: string; params: Record<string, unknown> }
/** Delegates to the shipped search functions over a scripted transport, so the `repoId`
* stamping and the envelope handling under test are the real ones rather than the fake's. */
function fakeOperations(
byMethod: Record<string, unknown>,
calls: Call[]
): HostWorkspaceCreationOperations {
const invoke = async <T>(method: string, params: Record<string, unknown>): Promise<T> => {
calls.push({ method, params })
const result = byMethod[method]
if (result instanceof Error) {
throw result
const client = {
async sendRequest(method: string, params: unknown) {
calls.push({ method, params: params as Record<string, unknown> })
const scripted = byMethod[method]
if (scripted instanceof Error) {
return { ok: false as const, error: { code: 'failed', message: scripted.message } }
}
return { ok: true as const, result: scripted ?? {} }
}
return result as T
}
} as unknown as RpcRequestSender
return {
searchGitHubItems: async (repoId, query) => {
const result = await invoke<{ items?: never[] }>('github.listWorkItems', { repoId, query })
return (result?.items ?? []).map((item) => ({ ...item, repoId }))
},
searchGitLabItems: async (repoId, query, state) => {
const result = await invoke<{ items?: never[] }>('gitlab.listWorkItems', {
repoId,
query,
state
})
return (result?.items ?? []).map((item) => ({ ...item, repoId }))
},
searchLinearIssues: async (query, linearWorkspaceId) => {
const result = await invoke<{ items?: never[] }>('linear.searchIssues', {
query,
linearWorkspaceId
})
return result?.items ?? []
},
searchBranches: async (repoId, query) => {
const result = await invoke<{ refDetails?: never[] }>('repo.searchRefs', { repoId, query })
return result?.refDetails ?? []
}
searchGitHubItems: (repoId, query) => searchGitHubItems(client, repoId, query),
searchGitLabItems: (repoId, query, state) => searchGitLabItems(client, repoId, query, state),
searchLinearIssues: (query, linearWorkspaceId) =>
searchLinearIssues(client, query, linearWorkspaceId),
searchBranches: (repoId, query) => searchBranches(client, repoId, query)
} as unknown as HostWorkspaceCreationOperations
}
@@ -3,6 +3,21 @@ import type { RpcClient } from '../transport/rpc-client'
import { nativeHostWorkspaceCreationOperations } from './native-host-workspace-creation-operations'
describe('native host workspace creation operations', () => {
it('addresses the retired-name read by method and id-prefixed repo', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValue({ ok: true, result: { retiredNamesByRepo: { 'repo-1': ['spent'] } } })
const operations = nativeHostWorkspaceCreationOperations({
sendRequest
} as unknown as RpcClient)
// The hook takes a callback now, so this is the only place the wire shape is pinned.
await expect(operations.readRetiredWorktreeNames('repo-1')).resolves.toMatchObject({
names: ['spent']
})
expect(sendRequest.mock.calls).toEqual([['worktree.listRetiredNames', { repo: 'id:repo-1' }]])
})
it('maps named repository and environment reads to existing RPC authority', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()