Files
orca/mobile/src/session/mobile-native-chat-open-file.test.ts
T
Jinwoo HongandOrcaWin 5ed45739e9 fix(runtime): make sibling-workspace terminal-path resolution an explicit client opt-in (#12616)
files.resolveTerminalPath began returning a foreign worktree id + relativePath
for absolute paths owned by a sibling workspace, with no protocol or capability
gate. Mobile 0.0.36 in the field ignores resolved.worktree and reuses its own
worktree id for the follow-up files.open, so a tap on a sibling-worktree path
opened the WRONG worktree's copy of that file (on 1.4.168 the tap was a safe
no-op).

Gate the sibling-workspace lookup behind a new optional crossWorkspace request
field: clients that honor resolved.worktree opt in; everything else keeps the
pre-sibling-resolution contract. Old servers strip the unknown field (zod), so
every version pairing degrades to the safe legacy behavior. Optional-field
addition, so no RUNTIME_PROTOCOL_VERSION bump per protocol-version.ts rules.

The terminal-path RPC tests move to files-terminal-path-resolution.test.ts
because files.test.ts sits at the max-lines cap.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 18:46:36 -07:00

154 lines
4.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function activationState(activated: boolean) {
return {
activated,
activationSeq: 1,
latestActivationSeq: 1,
sourceTerminalHandle: 'terminal-1',
activeTerminalHandle: 'terminal-1',
activeTabType: 'terminal'
}
}
function baseOptions(client: { sendRequest: ReturnType<typeof vi.fn> }) {
return {
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activationState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed: vi.fn()
}
}
function worktreeFileResolution(relativePath: string) {
return ok({
worktree: 'wt-1',
relativePath,
absolutePath: `/repo/${relativePath}`,
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath,
absolutePath: `/repo/${relativePath}`
}
})
}
describe('openMobileNativeChatFileTap', () => {
it('resolves against the worktree root: no terminal handle and no cwd', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
})
it('parses a :line:col citation and opens the mobile preview route', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
source: 'worktree',
relativePath: 'src/app.ts',
line: '120',
column: '7'
})
})
expect(options.onOpenFailed).not.toHaveBeenCalled()
})
it('surfaces a resolve miss instead of a silent no-op', async () => {
const sendRequest = vi.fn(async () =>
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
)
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' })
await Promise.resolve()
await Promise.resolve()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).not.toHaveBeenCalled()
expect(options.triggerOpenFeedback).not.toHaveBeenCalled()
})
it('surfaces a rejected resolve request', async () => {
const sendRequest = vi.fn(async () => {
throw new Error('Request timed out')
})
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
await Promise.resolve()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
})
it('opens a plain path through files.open with tab activation', async () => {
const responses: unknown[] = [worktreeFileResolution('src/app.ts'), ok({ opened: true })]
const sendRequest = vi.fn(async () => responses.shift())
const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' }
const switchSessionTab = vi.fn()
const options = {
...baseOptions({ sendRequest }),
getSessionTabs: () => [openedTab],
getActiveSessionTabId: () => 'terminal-tab',
switchSessionTab,
scheduleDelayedAction: vi.fn((callback: () => void) => callback())
}
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'src/app.ts' },
{ timeoutMs: 15_000 }
)
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
expect(options.onOpenFailed).not.toHaveBeenCalled()
})
})