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()