feat(mobile): add session.tabs.list handler to mock server (#9293)

* 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 <hanjoonchoe@gmail.com>

* 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>
This commit is contained in:
hanjoonchoe 2026-07-30 10:01:38 +09:00 committed by GitHub
parent 270c5ad3fa
commit f4e46383df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 149 additions and 1 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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<string, unknown>): 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)
})
})