From 0aaa70e5e930be04dfa11c4c7cdc7d8d5ea953ef Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 9 Sep 2026 02:45:37 -0400 Subject: [PATCH] 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 --- .../mobile-session-route-parity.test.ts | 4 +- ...ession-route-source-family.test-support.ts | 12 +++- ...github-project-host-routing-source.test.ts | 64 +++++++++++++++++-- mobile/src/tasks/smart-source-fan-out.test.ts | 53 +++++++-------- ...host-workspace-creation-operations.test.ts | 15 +++++ 5 files changed, 109 insertions(+), 39 deletions(-) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index dbf204dcd96..83cd257b671 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -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) diff --git a/mobile/src/session/mobile-session-route-source-family.test-support.ts b/mobile/src/session/mobile-session-route-source-family.test-support.ts index 41f2d8b9c2f..5db4a95cfcb 100644 --- a/mobile/src/session/mobile-session-route-source-family.test-support.ts +++ b/mobile/src/session/mobile-session-route-source-family.test-support.ts @@ -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 { diff --git a/mobile/src/tasks/github-project-host-routing-source.test.ts b/mobile/src/tasks/github-project-host-routing-source.test.ts index 92e80628b6a..550964e47a1 100644 --- a/mobile/src/tasks/github-project-host-routing-source.test.ts +++ b/mobile/src/tasks/github-project-host-routing-source.test.ts @@ -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)') } }) diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts index 241b3bb1428..94918d7707f 100644 --- a/mobile/src/tasks/smart-source-fan-out.test.ts +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -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 } +/** 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, calls: Call[] ): HostWorkspaceCreationOperations { - const invoke = async (method: string, params: Record): Promise => { - 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 }) + 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 } diff --git a/mobile/src/worktree/native-host-workspace-creation-operations.test.ts b/mobile/src/worktree/native-host-workspace-creation-operations.test.ts index 03f726f61d9..899bccec171 100644 --- a/mobile/src/worktree/native-host-workspace-creation-operations.test.ts +++ b/mobile/src/worktree/native-host-workspace-creation-operations.test.ts @@ -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() + .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()