From f4e46383dfb10132b1d774ccaef32489246c60bc Mon Sep 17 00:00:00 2001 From: hanjoonchoe Date: Thu, 30 Jul 2026 10:01:38 +0900 Subject: [PATCH] feat(mobile): add session.tabs.list handler to mock server (#9293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add session.tabs.list handler to mock server The mock WebSocket server had no handler for session.tabs.list, so the session screen of a paired dev client hung on 'Loading tabs' forever — the terminal pane, live input, and command input could never be exercised against the mock. Respond with a single ready terminal tab wired to the existing term-1 fixture so the whole session surface works offline. * fix(mobile): complete the session.tabs.list mock contract The new mock response omitted four non-optional fields of RuntimeMobileSessionTabsResult: publicationEpoch and activeGroupId on the result, and parentTabId and leafId on the terminal tab. Nothing caught it — the object literal had no type annotation, and MobileSessionTabsStreamHealth is generic over both result and tab. A shape-incomplete mock yields untrustworthy repros for exactly the bugs it gets used for (session tabs, split panes, pane-to-tab attribution). Fill the fields with host-realistic values: a per-process publisher epoch, a layout UUID leaf id, and the `${parentTabId}::${leafId}` surface id mobileTerminalSurfaceId actually emits. Pin the shape with an explicit return type so a future required field fails typecheck instead of silently drifting. Move the fixture into its own module: inlining it pushed mock-server-rpc-handlers.ts to 317 lines against a 300-line max-lines cap, which broke `pnpm lint` on the parent commit. It registers through the file's existing delegation chain, after the native-chat scenario so MOCK_NATIVE_CHAT=1 keeps ownership of the method. Co-authored-by: Hanjoon Choe * test(mobile): pin session tabs mock fidelity Normalize the selector-backed worktree ID like the real runtime and cover the complete terminal surface response so future contract drift fails the mobile suite. * fix(mobile): share terminal.list worktree resolution with session tabs Main added `terminalListWorktreeId`, which the rebased session-tabs fixture duplicated with a different no-selector fallback — `terminal.list` resolved to the active fake worktree while `session.tabs.list` returned a literal 'mock', so a session repro saw two different worktree ids for one screen. * test(mobile): cover the bare session-tabs worktree selector Answers the review note that only the `id:`-prefixed path was exercised. * fix(mobile): make the mock publication epoch unique per process Date.now() can repeat across a sub-millisecond restart, so the epoch did not actually guarantee the fresh-publisher identity its comment claimed. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- mobile/scripts/mock-server-rpc-handlers.ts | 4 +- .../mock-server-session-tabs-fixture.ts | 69 +++++++++++++++++ .../mock-server-session-tabs-fixture.test.ts | 77 +++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 mobile/scripts/mock-server-session-tabs-fixture.ts create mode 100644 mobile/src/mock-server-session-tabs-fixture.test.ts diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index d704c79e8..c5aebf054 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' +import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture' import { createMockTerminals, FAKE_SCROLLBACK, @@ -124,7 +125,8 @@ export function handleRequest( handleMockGitRequest(request, respond, success) || handleMockFilePreviewRequest(request, respond, success, error) || handleMockAccountRequest(request, respond, success, error) || - handleMockNativeChatRequest(request, respond, success, error, ws) + handleMockNativeChatRequest(request, respond, success, error, ws) || + handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) ) { return } diff --git a/mobile/scripts/mock-server-session-tabs-fixture.ts b/mobile/scripts/mock-server-session-tabs-fixture.ts new file mode 100644 index 000000000..fbc94c282 --- /dev/null +++ b/mobile/scripts/mock-server-session-tabs-fixture.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers' + +// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it +// must stay stable for the process and change on restart like a real publisher — +// hence a uuid, not a clock read two restarts could land on. +// The `mobile-local:` prefix is reserved for phone-local writes — never use it. +const PUBLICATION_EPOCH = `mock-server:${randomUUID()}` +const GROUP_ID = 'group-1' +const PARENT_TAB_ID = 'tab-1' +// The host only ever publishes terminal-layout UUIDs here; pane-key parsing +// rejects any other shape, so a placeholder would mask pane-attribution bugs. +const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' +// The host publishes terminal surfaces as `${parentTabId}::${leafId}`. +const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}` + +/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full + * `session.tabs.list` contract so mock-server repros of tab, split-pane, and + * pane-attribution bugs aren't shape-incomplete. */ +function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: PUBLICATION_EPOCH, + snapshotVersion: 1, + activeGroupId: GROUP_ID, + activeTabId: SURFACE_TAB_ID, + activeTabType: 'terminal', + // Groups track top-level tabs, so they carry parentTabId, not surface ids. + tabGroups: [ + { + id: GROUP_ID, + activeTabId: PARENT_TAB_ID, + tabOrder: [PARENT_TAB_ID], + recentTabIds: [PARENT_TAB_ID] + } + ], + tabs: [ + { + type: 'terminal', + id: SURFACE_TAB_ID, + title: 'zsh', + parentTabId: PARENT_TAB_ID, + leafId: LEAF_ID, + status: 'ready', + terminal: 'term-1', + isActive: true + } + ] + } +} + +/** Default session-tabs backend: without it the session screen hangs on + * 'Loading tabs'. Returns false for methods it does not own. */ +export function handleMockSessionTabsRequest( + request: RpcRequest, + respond: (response: RpcResponse) => void, + success: (id: string, result: unknown, streaming?: boolean) => RpcResponse, + // Shared with `terminal.list` so both surfaces agree on which worktree an + // absent or `id:`-prefixed selector means. + resolveWorktreeId: (selector: unknown) => string | undefined +): boolean { + if (request.method !== 'session.tabs.list') { + return false + } + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + respond(success(request.id, createMockSessionTabs(worktreeId))) + return true +} diff --git a/mobile/src/mock-server-session-tabs-fixture.test.ts b/mobile/src/mock-server-session-tabs-fixture.test.ts new file mode 100644 index 000000000..2ff9e82ba --- /dev/null +++ b/mobile/src/mock-server-session-tabs-fixture.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { WebSocket } from 'ws' +import { + handleRequest, + type RpcRequest, + type RpcResponse +} from '../scripts/mock-server-rpc-handlers' + +function callRpc(method: string, params?: Record): RpcResponse { + let response: RpcResponse | undefined + const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) } + handleRequest( + request, + (nextResponse) => { + response = nextResponse + }, + {} as WebSocket + ) + expect(response).toBeDefined() + return response! +} + +function listSessionTabs(worktree: string): RpcResponse { + return callRpc('session.tabs.list', { worktree }) +} + +describe('mock server session tabs fixture', () => { + it('returns a contract-complete terminal surface for the requested worktree', () => { + const response = listSessionTabs('id:repo-1::worktree-1') + + expect(response.result).toEqual({ + worktree: 'repo-1::worktree-1', + publicationEpoch: expect.stringMatching(/^mock-server:/), + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'tab-1', + tabOrder: ['tab-1'], + recentTabIds: ['tab-1'] + } + ], + tabs: [ + { + type: 'terminal', + id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479', + title: 'zsh', + parentTabId: 'tab-1', + leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + status: 'ready', + terminal: 'term-1', + isActive: true + } + ] + }) + }) + + it('passes a bare worktree selector through unprefixed', () => { + const response = listSessionTabs('repo-1::worktree-1') + + expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1') + }) + + it('falls back to the same worktree terminal.list uses when no selector is sent', () => { + const terminals = callRpc('terminal.list').result as { + terminals: { worktreeId: string }[] + } + const expected = terminals.terminals[0]?.worktreeId + expect(expected).toBeTruthy() + + const tabs = callRpc('session.tabs.list').result as { worktree: string } + expect(tabs.worktree).toBe(expected) + }) +})