mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
fix(mobile): never open an unreachable server workspace, and prove the relay in e2e
A server workspace the phone can't reach now gets one explicit screen at its route and no row tap or action-sheet entry opens it; native routes read the route's host, a census pins every workspace route, and the e2e reads files and git on the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
|
||||
import {
|
||||
useWorkspaceExecutionHost,
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileDiffReviewRouteScreen } from '../../../../src/session/MobileDiffReviewRouteScreen'
|
||||
import { firstReviewParam } from '../../../../src/session/mobile-diff-review-screen-model'
|
||||
@@ -32,20 +36,23 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
scope?: string | string[]
|
||||
file?: string | string[]
|
||||
area?: string | string[]
|
||||
executionHost?: string | string[]
|
||||
}>()
|
||||
const hostId = firstReviewParam(params.hostId)
|
||||
const worktreeId = firstReviewParam(params.worktreeId)
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const native = <MobileDiffReviewRouteScreen />
|
||||
|
||||
// The four query params are read by the screen itself, so they are carried across whole rather
|
||||
// than re-derived here; each is omitted when empty, because the screen's own normalizers treat an
|
||||
// absent scope, file or area differently from one named nothing.
|
||||
const routeParams = Object.fromEntries(
|
||||
(['name', 'scope', 'file', 'area', 'executionHost'] as const)
|
||||
.map((key) => [key, firstReviewParam(params[key])] as const)
|
||||
.filter(([, value]) => value !== '')
|
||||
)
|
||||
const routeParams: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
(['name', 'scope', 'file', 'area'] as const)
|
||||
.map((key) => [key, firstReviewParam(params[key])] as const)
|
||||
.filter(([, value]) => value !== '')
|
||||
),
|
||||
...workspaceRouteParams(executionHost)
|
||||
}
|
||||
const route =
|
||||
hostId && worktreeId
|
||||
? shellScreenRoute({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
|
||||
import {
|
||||
useWorkspaceExecutionHost,
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useCallback } from 'react'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
|
||||
@@ -39,10 +43,10 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
created?: string | string[]
|
||||
warning?: string | string[]
|
||||
paneKey?: string | string[]
|
||||
executionHost?: string | string[]
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const router = useRouter()
|
||||
const native = <MobileSessionRouteScreen />
|
||||
const paneKey = firstParam(params.paneKey) ?? ''
|
||||
@@ -66,11 +70,14 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
// the create flow sets to `1`, `warning` is the host's own text, `name` is a label the screen
|
||||
// otherwise derives from the workspace, and `paneKey` empty is exactly what the notification hook
|
||||
// writes back to say the tap is spent.
|
||||
const routeParams = Object.fromEntries(
|
||||
(['name', 'created', 'warning', 'paneKey', 'executionHost'] as const)
|
||||
.map((key) => [key, firstParam(params[key])] as const)
|
||||
.filter(([, value]) => value !== '')
|
||||
)
|
||||
const routeParams: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
(['name', 'created', 'warning', 'paneKey'] as const)
|
||||
.map((key) => [key, firstParam(params[key])] as const)
|
||||
.filter(([, value]) => value !== '')
|
||||
),
|
||||
...workspaceRouteParams(executionHost)
|
||||
}
|
||||
const route =
|
||||
hostId && worktreeId
|
||||
? shellScreenRoute({
|
||||
|
||||
@@ -33,6 +33,28 @@ describe('the worktree row action sheet', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('opens a server workspace on its server, and offers nothing when that server is unreachable', () => {
|
||||
const forHost = (executionHost: 'runtime:vm' | null) => {
|
||||
const targets: string[] = []
|
||||
const actions = buildWorktreeNavigationActions({
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'w',
|
||||
executionHost,
|
||||
hostCapabilities: [MOBILE_AI_VAULT_CAPABILITY],
|
||||
navigate: (target) => targets.push(target),
|
||||
onDone: () => {}
|
||||
})
|
||||
actions.forEach((action) => action.onPress())
|
||||
return targets
|
||||
}
|
||||
expect(forHost('runtime:vm')).toEqual([
|
||||
'/h/host-a/source-control/wt-1?name=w&origin=host&executionHost=runtime%3Avm',
|
||||
'/h/host-a/agent-history/wt-1?name=w&executionHost=runtime%3Avm'
|
||||
])
|
||||
expect(forHost(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('offers only source control on a host that does not', () => {
|
||||
const actions = buildWorktreeNavigationActions({
|
||||
hostId: 'host-a',
|
||||
|
||||
@@ -10,8 +10,8 @@ type Args = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
/** The row's server when it runs on one, so the opened screen works there. */
|
||||
executionHost?: ExecutionHostId
|
||||
/** The row's server when it runs on one, so the opened screen works there; null when unreachable. */
|
||||
executionHost?: ExecutionHostId | null
|
||||
hostCapabilities: readonly string[]
|
||||
navigate: (target: string) => void
|
||||
onDone: () => void
|
||||
@@ -23,6 +23,10 @@ type Args = {
|
||||
// navigate to a screen that would call a missing RPC method. Extracted from the
|
||||
// host index action sheet to keep that file under its max-lines budget.
|
||||
export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] {
|
||||
if (args.executionHost === null) {
|
||||
return []
|
||||
}
|
||||
const executionHost = args.executionHost
|
||||
const actions: ActionSheetAction[] = [
|
||||
{
|
||||
label: 'Source Control',
|
||||
@@ -32,7 +36,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
|
||||
args.navigate(
|
||||
workspaceRouteHref(
|
||||
`/h/${encodeURIComponent(args.hostId)}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
|
||||
args.executionHost
|
||||
executionHost
|
||||
)
|
||||
)
|
||||
args.onDone()
|
||||
@@ -49,7 +53,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
|
||||
args.navigate(
|
||||
workspaceRouteHref(
|
||||
`/h/${encodeURIComponent(args.hostId)}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
|
||||
args.executionHost
|
||||
executionHost
|
||||
)
|
||||
)
|
||||
args.onDone()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
import { useForceReconnect } from '../transport/client-context'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
import { connectionRetryAction } from '../transport/connection-retry-action'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
@@ -44,7 +45,8 @@ export function MobileFileExplorerPanel(props: {
|
||||
}) {
|
||||
const { hostId, worktreeId, name, embedded, onRequestClose } = props
|
||||
const router = useWorkspaceRouteHandoff()
|
||||
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
|
||||
const { client, state: connState } = useWorkspaceClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const scopeRef = useRef('')
|
||||
const scope = `${hostId}:${worktreeId}`
|
||||
scopeRef.current = scope
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, Save } from 'lucide-react-native'
|
||||
import { useRouteHandoff } from '../navigation/route-handoff'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { useForceReconnect } from '../transport/client-context'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
import { connectionRetryAction } from '../transport/connection-retry-action'
|
||||
import {
|
||||
@@ -37,7 +38,8 @@ type Props = {
|
||||
export function MobileFilePreviewScreen({ route }: Props) {
|
||||
const router = useRouteHandoff()
|
||||
const previewParams = route.ok ? route.params : null
|
||||
const { client, state: connState, forceReconnect } = useWorkspaceClient(previewParams?.hostId)
|
||||
const { client, state: connState } = useWorkspaceClient(previewParams?.hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
|
||||
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { openExternalLink } from '../platform/external-link'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Check, Moon } from 'lucide-react-native'
|
||||
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
|
||||
import { canTargetExecutionHost } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -165,11 +165,11 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
hostId,
|
||||
worktreeId: actionTarget.worktreeId,
|
||||
worktreeName: actionTarget.displayName || actionTarget.repo,
|
||||
executionHost:
|
||||
client &&
|
||||
canTargetExecutionHost(client, hostCapabilities, actionTarget.hostId)
|
||||
? actionTarget.hostId
|
||||
: undefined,
|
||||
executionHost: workspaceRouteExecutionHost(
|
||||
client,
|
||||
hostCapabilities,
|
||||
actionTarget.hostId
|
||||
),
|
||||
hostCapabilities,
|
||||
navigate: actions.navigateFromHostList,
|
||||
onDone: () => state.setActionTarget(null)
|
||||
|
||||
@@ -290,6 +290,16 @@ describe("a desktop's server workspaces on the phone", () => {
|
||||
expect(screen.notice()).toBe('Update Orca on ThinkPad to open its workspaces from your phone.')
|
||||
})
|
||||
|
||||
it('opens nothing for a listed server workspace once the phone can no longer reach it', async () => {
|
||||
const screen = await mountScreen(desktop(), RELAYS)
|
||||
const oldShell = desktop({ carriesExecutionHost: false })
|
||||
await screen.swapClient(oldShell)
|
||||
expect(screen.rows().map((entry) => entry.worktreeId)).toContain('runtime:vm-wt')
|
||||
screen.open('runtime:vm-wt')
|
||||
expect(screen.navigations).toEqual([])
|
||||
expect(calledMethods(oldShell)).not.toContain('worktree.activate')
|
||||
})
|
||||
|
||||
it('pins a server workspace on that server, as the desktop does', async () => {
|
||||
const client = desktop()
|
||||
const screen = await mountScreen(client, RELAYS)
|
||||
|
||||
@@ -7,8 +7,8 @@ import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
|
||||
import { isPageHostRemovalUnavailable } from '../transport/page-host-removal-refusal'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
canTargetExecutionHost,
|
||||
rpcClientForExecutionHost
|
||||
rpcClientForExecutionHost,
|
||||
workspaceRouteExecutionHost
|
||||
} from '../transport/execution-host-scoped-rpc-client'
|
||||
import { serverUpdateNeededNotice } from '../host-route-notice'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -231,6 +231,10 @@ export function useHostWorktreeActions(args: {
|
||||
setServerNotice(serverUpdateNeededNotice(server.label))
|
||||
return
|
||||
}
|
||||
const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, item.hostId)
|
||||
if (routeHost === null) {
|
||||
return
|
||||
}
|
||||
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
|
||||
const rowClient = clientForRow(item)
|
||||
if (rowClient && connState === 'connected') {
|
||||
@@ -245,10 +249,7 @@ export function useHostWorktreeActions(args: {
|
||||
// `?? ''` and not a cast: the hook takes `hostId` optional and every other member guards it,
|
||||
// so an absent one builds `/h//session/...` — a pathname the shell's segment rule refuses —
|
||||
// rather than the string "undefined", which it would accept as a host named undefined.
|
||||
const executionHost =
|
||||
client && canTargetExecutionHost(client, hostCapabilities, item.hostId)
|
||||
? `&executionHost=${encodeURIComponent(item.hostId)}`
|
||||
: ''
|
||||
const executionHost = routeHost ? `&executionHost=${encodeURIComponent(routeHost)}` : ''
|
||||
const target = `/h/${encodeURIComponent(hostId ?? '')}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}${executionHost}`
|
||||
navigateFromHostList(target)
|
||||
},
|
||||
|
||||
@@ -26,13 +26,19 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
* `.web.tsx` sibling, and there a bare replace navigates inside the WebView to a route the page
|
||||
* does not carry instead of leaving it. The seam posts the target to the shell.
|
||||
*/
|
||||
export function PageRouteUnavailableScreen({ hostId }: { hostId: string }) {
|
||||
export function PageRouteUnavailableScreen({
|
||||
hostId,
|
||||
message = 'This workspace screen is not available on this host.'
|
||||
}: {
|
||||
hostId: string
|
||||
message?: string
|
||||
}) {
|
||||
const router = useRouteHandoff()
|
||||
const target = hostId === '' ? '/' : hostStackHostRoute(hostId)
|
||||
const label = hostId === '' ? 'Back to hosts' : 'Back to workspaces'
|
||||
return (
|
||||
<View style={styles.root} testID="mobile-web-page-route-unavailable">
|
||||
<Text style={styles.message}>This workspace screen is not available on this host.</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -35,6 +35,10 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
// The workspace route reads the desktop's connection to decide whether a server is reachable.
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
|
||||
@@ -37,6 +37,10 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
// The workspace route reads the desktop's connection to decide whether a server is reachable.
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
// The workspace route reads the desktop's connection to decide whether a server is reachable.
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
useRouter: () => ({
|
||||
|
||||
@@ -56,6 +56,10 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
// The workspace route reads the desktop's connection to decide whether a server is reachable.
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
|
||||
@@ -10,24 +10,22 @@ const route = vi.hoisted(() => {
|
||||
navigate: (href: unknown) => opened.push(['navigate', href]),
|
||||
back: () => opened.push(['back'])
|
||||
}
|
||||
const state: { params: Record<string, string>; opened: unknown[]; router: typeof router } = {
|
||||
params: {},
|
||||
const state: { opened: unknown[]; router: typeof router } = {
|
||||
opened,
|
||||
router
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
|
||||
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => route.router }))
|
||||
|
||||
import { useWorkspaceRouteHandoff } from './workspace-route-handoff'
|
||||
import { WorkspaceRoute } from './workspace-route'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
|
||||
|
||||
async function workspaceRouterFor(
|
||||
params: Record<string, string>
|
||||
): Promise<ReturnType<typeof useWorkspaceRouteHandoff>> {
|
||||
route.params = params
|
||||
route.opened.length = 0
|
||||
const held: { router: ReturnType<typeof useWorkspaceRouteHandoff> | null } = { router: null }
|
||||
function Probe(): null {
|
||||
@@ -35,7 +33,13 @@ async function workspaceRouterFor(
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
create(createElement(WorkspaceRoute, null, createElement(Probe)))
|
||||
create(
|
||||
createElement(
|
||||
WorkspaceExecutionHostContext.Provider,
|
||||
{ value: normalizeExecutionHostId(params.executionHost) ?? undefined },
|
||||
createElement(Probe)
|
||||
)
|
||||
)
|
||||
})
|
||||
if (!held.router) {
|
||||
throw new Error('probe did not render')
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const seen = vi.hoisted(() => {
|
||||
const hosts: (string | undefined)[] = []
|
||||
return hosts
|
||||
})
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'README.md',
|
||||
executionHost: 'runtime:vm'
|
||||
})
|
||||
}))
|
||||
// Each workspace screen stands in as a probe reporting the server its subtree names.
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
|
||||
vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
|
||||
vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
|
||||
vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
|
||||
vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
MobileAgentSessionHistoryPanel: Probe
|
||||
}))
|
||||
|
||||
import { useWorkspaceExecutionHost } from './workspace-execution-host'
|
||||
|
||||
function Probe(): null {
|
||||
seen.push(useWorkspaceExecutionHost())
|
||||
return null
|
||||
}
|
||||
|
||||
const SCREENS = {
|
||||
files: () => import('../../app/h/[hostId]/files/[worktreeId].web'),
|
||||
'file preview': () => import('../../app/h/[hostId]/files/preview/[worktreeId].web'),
|
||||
'source control': () => import('../../app/h/[hostId]/source-control/[worktreeId].web'),
|
||||
review: () => import('../../app/h/[hostId]/review/[worktreeId].web'),
|
||||
session: () => import('../../app/h/[hostId]/session/[worktreeId].web'),
|
||||
'agent history': () => import('../../app/h/[hostId]/agent-history/[worktreeId].web')
|
||||
}
|
||||
|
||||
describe('a workspace route', () => {
|
||||
it.each(Object.entries(SCREENS))(
|
||||
'names its server to everything under the %s screen',
|
||||
async (_name, load) => {
|
||||
const Screen = (await load()).default
|
||||
seen.length = 0
|
||||
await act(async () => {
|
||||
create(createElement(Screen))
|
||||
})
|
||||
expect(seen).toEqual(['runtime:vm'])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createElement, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const route = vi.hoisted(() => {
|
||||
const state: {
|
||||
params: Record<string, string>
|
||||
hostCapabilities: string[]
|
||||
statusPending: boolean
|
||||
seen: (string | undefined)[]
|
||||
shellRoutes: { params?: Record<string, string> }[]
|
||||
redirects: { params: Record<string, string | undefined> }[]
|
||||
} = {
|
||||
params: {},
|
||||
hostCapabilities: [],
|
||||
statusPending: false,
|
||||
seen: [],
|
||||
shellRoutes: [],
|
||||
redirects: []
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => route.params,
|
||||
useRouter: () => ({ setParams: () => {}, replace: () => {} }),
|
||||
Redirect: ({ href }: { href: { params: Record<string, string | undefined> } }) => {
|
||||
route.redirects.push(href)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View',
|
||||
Text: 'Text',
|
||||
Pressable: 'Pressable'
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: {}, state: 'connected' })
|
||||
}))
|
||||
vi.mock('../components/host-protocol-gates-context', () => ({
|
||||
useOptionalHostProtocolGates: () => ({
|
||||
hostCapabilities: route.hostCapabilities,
|
||||
statusPending: route.statusPending
|
||||
})
|
||||
}))
|
||||
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) }))
|
||||
// The native switch always hands the page its route, so the census reads what the page is told.
|
||||
vi.mock('../mobile-web-shell/shell-switch-decision', () => ({
|
||||
useShellSwitchDecision: (shellRoute: unknown) =>
|
||||
shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' }
|
||||
}))
|
||||
vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({
|
||||
ShellSwitchPendingScreen: () => null
|
||||
}))
|
||||
vi.mock('../mobile-web-shell/MobileWebShellScreen', () => ({
|
||||
MobileWebShellScreen: ({ route: shellRoute }: { route: { params?: Record<string, string> } }) => {
|
||||
route.shellRoutes.push(shellRoute)
|
||||
return createElement(Probe)
|
||||
}
|
||||
}))
|
||||
// Each workspace screen stands in as a probe reporting the server its subtree names.
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
|
||||
vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
|
||||
vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
|
||||
vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
|
||||
vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
MobileAgentSessionHistoryPanel: Probe
|
||||
}))
|
||||
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { useWorkspaceExecutionHost } from './workspace-execution-host'
|
||||
import { WorkspaceRoute } from './workspace-route'
|
||||
|
||||
function Probe(): null {
|
||||
route.seen.push(useWorkspaceExecutionHost())
|
||||
return null
|
||||
}
|
||||
|
||||
const HOST_ROUTES = fileURLToPath(new URL('../../app/h/[hostId]/', import.meta.url))
|
||||
// Every screen keyed by a workspace, native and page, found on disk so a new one is counted.
|
||||
const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encoding: 'utf8' })
|
||||
.map((entry) => entry.replaceAll('\\', '/'))
|
||||
.filter((entry) => /(^|\/)\[worktreeId\](\.web)?\.tsx$/.test(entry))
|
||||
.sort()
|
||||
|
||||
async function render(element: ReactElement): Promise<ReactTestRenderer> {
|
||||
route.seen.length = 0
|
||||
route.shellRoutes.length = 0
|
||||
route.redirects.length = 0
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(element)
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
function texts(renderer: ReactTestRenderer): string[] {
|
||||
return renderer.root
|
||||
.findAll(() => true)
|
||||
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
|
||||
}
|
||||
|
||||
describe('every workspace route', () => {
|
||||
it('is found on disk', () => {
|
||||
expect(WORKSPACE_ROUTE_FILES.length).toBeGreaterThanOrEqual(14)
|
||||
})
|
||||
|
||||
it.each(WORKSPACE_ROUTE_FILES)('keeps %s on the server its route names', async (file) => {
|
||||
route.params = {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'README.md',
|
||||
// Spelled loosely, so a screen reading the raw param instead of its route is caught.
|
||||
executionHost: ' runtime:vm '
|
||||
}
|
||||
route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
route.statusPending = false
|
||||
const Screen = (await import(/* @vite-ignore */ `${HOST_ROUTES}${file}`)).default
|
||||
await render(createElement(Screen))
|
||||
if (route.redirects.length > 0) {
|
||||
expect(route.redirects.map((href) => href.params.executionHost?.trim())).toEqual([
|
||||
'runtime:vm'
|
||||
])
|
||||
return
|
||||
}
|
||||
expect(route.seen).toEqual(['runtime:vm'])
|
||||
for (const shellRoute of route.shellRoutes) {
|
||||
expect(shellRoute.params?.executionHost).toBe('runtime:vm')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a server workspace the phone cannot reach', () => {
|
||||
const child = createElement(Probe)
|
||||
|
||||
it('says so once the desktop has answered, instead of waiting forever', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
route.hostCapabilities = []
|
||||
route.statusPending = false
|
||||
const renderer = await render(createElement(WorkspaceRoute, null, child))
|
||||
expect(route.seen).toEqual([])
|
||||
expect(texts(renderer)).toContain(
|
||||
"Your phone can't reach this workspace's server through this desktop."
|
||||
)
|
||||
})
|
||||
|
||||
it('waits while the desktop has not answered, and opens a reachable one', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
route.hostCapabilities = []
|
||||
route.statusPending = true
|
||||
await render(createElement(WorkspaceRoute, null, child))
|
||||
expect(route.seen).toEqual(['runtime:vm'])
|
||||
route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
route.statusPending = false
|
||||
await render(createElement(WorkspaceRoute, null, child))
|
||||
expect(route.seen).toEqual(['runtime:vm'])
|
||||
})
|
||||
|
||||
it('never stands in for the desktop’s own workspaces', async () => {
|
||||
route.params = { hostId: 'host-1' }
|
||||
route.hostCapabilities = []
|
||||
route.statusPending = false
|
||||
await render(createElement(WorkspaceRoute, null, child))
|
||||
expect(route.seen).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,37 @@ import { useLocalSearchParams } from 'expo-router'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { firstParam } from './route-param-reader'
|
||||
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
|
||||
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
|
||||
import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
|
||||
|
||||
const UNREACHABLE_SERVER_MESSAGE =
|
||||
"Your phone can't reach this workspace's server through this desktop."
|
||||
|
||||
/**
|
||||
* A workspace route's server, read once from its `executionHost` param: every call made and every
|
||||
* route pushed below it stays on that server. Absent for the desktop's own workspaces.
|
||||
* route pushed below it stays on that server. Absent for the desktop's own workspaces; a server
|
||||
* this phone can't reach (older desktop or shell, unreadable status) gets one explicit dead end.
|
||||
*/
|
||||
export function WorkspaceRoute({ children }: { children: ReactNode }) {
|
||||
const params = useLocalSearchParams<{ executionHost?: string | string[] }>()
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
executionHost?: string | string[]
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
|
||||
const { client, state } = useHostClient(hostId)
|
||||
const gates = useOptionalHostProtocolGates()
|
||||
// Why: once the desktop has answered, a server it can't reach for this phone is a dead end, not a spinner.
|
||||
if (
|
||||
state === 'connected' &&
|
||||
gates &&
|
||||
!gates.statusPending &&
|
||||
workspaceRouteExecutionHost(client, gates.hostCapabilities, executionHost) === null
|
||||
) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} message={UNREACHABLE_SERVER_MESSAGE} />
|
||||
}
|
||||
return (
|
||||
<WorkspaceExecutionHostContext.Provider value={executionHost}>
|
||||
{children}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MobileDiffReviewScreenView } from '../components/MobileDiffReviewScreen
|
||||
import { firstReviewParam, normalizeReviewFilterParam } from './mobile-diff-review-screen-model'
|
||||
import { normalizeReviewAreaParam } from './mobile-diff-review-positioning'
|
||||
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
|
||||
import { useForceReconnect } from '../transport/client-context'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
import { useHostProtocolGates } from '../components/HostProtocolGate'
|
||||
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
@@ -42,7 +43,8 @@ export function MobileDiffReviewRouteScreen() {
|
||||
// Not `useRouter`: inside the shell's page the session screen is native, so that replace has to
|
||||
// be handed back to the app rather than posted into a document that does not render it.
|
||||
const router = useWorkspaceRouteHandoff()
|
||||
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
|
||||
const { client, state: connState } = useWorkspaceClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
|
||||
const openSession = useCallback(() => {
|
||||
const query = name ? `?${new URLSearchParams({ name }).toString()}` : ''
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useForceReconnect } from '../transport/client-context'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import { useMobileGitRequests } from './use-mobile-git-requests'
|
||||
@@ -32,8 +33,6 @@ import {
|
||||
type MobileBranchEntryView
|
||||
} from './mobile-source-control-screen-state'
|
||||
|
||||
type MobileGitLocalBranches = RuntimeGitLocalBranches
|
||||
|
||||
export type MobileSourceControlStateParams = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
@@ -61,12 +60,13 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
||||
onOpenHistory
|
||||
} = params
|
||||
const insets = useSafeAreaInsets()
|
||||
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
|
||||
const { client, state: connState } = useWorkspaceClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
const [generatingMessage, setGeneratingMessage] = useState(false)
|
||||
const [showBranchPicker, setShowBranchPicker] = useState(false)
|
||||
const [localBranches, setLocalBranches] = useState<MobileGitLocalBranches | null>(null)
|
||||
const [localBranches, setLocalBranches] = useState<RuntimeGitLocalBranches | null>(null)
|
||||
const [createdPrUrl, setCreatedPrUrl] = useState<string | null>(null)
|
||||
const [createdPrWarning, setCreatedPrWarning] = useState<string | null>(null)
|
||||
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import {
|
||||
canTargetExecutionHost,
|
||||
scopeRpcClientToExecutionHost
|
||||
scopeRpcClientToExecutionHost,
|
||||
workspaceRouteExecutionHost
|
||||
} from './execution-host-scoped-rpc-client'
|
||||
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
@@ -151,9 +151,11 @@ describe('executionHost on the envelope', () => {
|
||||
})
|
||||
|
||||
function fakeClient(carries?: boolean) {
|
||||
const sendRequest = vi.fn(
|
||||
async (): Promise<RpcResponse> => ({ id: 'reply', ok: true, result: null })
|
||||
)
|
||||
const sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
|
||||
id: 'reply',
|
||||
ok: true,
|
||||
result: null
|
||||
}))
|
||||
const subscribe = vi.fn(() => () => {})
|
||||
const close = vi.fn()
|
||||
const client = {
|
||||
@@ -194,14 +196,15 @@ describe('scopeRpcClientToExecutionHost', () => {
|
||||
expect(scopeRpcClientToExecutionHost(client, 'runtime:env-2')).not.toBe(view)
|
||||
})
|
||||
|
||||
it('targets only server workspaces on a relaying desktop over a transport that keeps the field', () => {
|
||||
it('routes a server workspace only through a relaying desktop over a transport that keeps the field', () => {
|
||||
const relays = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
expect(canTargetExecutionHost(fakeClient(), relays, SERVER)).toBe(true)
|
||||
expect(canTargetExecutionHost(fakeClient(true), relays, SERVER)).toBe(true)
|
||||
expect(canTargetExecutionHost(fakeClient(false), relays, SERVER)).toBe(false)
|
||||
expect(canTargetExecutionHost(fakeClient(), [], SERVER)).toBe(false)
|
||||
expect(canTargetExecutionHost(fakeClient(), relays, 'local')).toBe(false)
|
||||
expect(canTargetExecutionHost(fakeClient(), relays, 'ssh:box')).toBe(false)
|
||||
expect(canTargetExecutionHost(fakeClient(), relays, undefined)).toBe(false)
|
||||
expect(workspaceRouteExecutionHost(fakeClient(), relays, SERVER)).toBe(SERVER)
|
||||
expect(workspaceRouteExecutionHost(fakeClient(true), relays, SERVER)).toBe(SERVER)
|
||||
expect(workspaceRouteExecutionHost(fakeClient(false), relays, SERVER)).toBeNull()
|
||||
expect(workspaceRouteExecutionHost(fakeClient(), [], SERVER)).toBeNull()
|
||||
expect(workspaceRouteExecutionHost(null, relays, SERVER)).toBeNull()
|
||||
expect(workspaceRouteExecutionHost(fakeClient(), relays, 'local')).toBeUndefined()
|
||||
expect(workspaceRouteExecutionHost(fakeClient(), relays, 'ssh:box')).toBeUndefined()
|
||||
expect(workspaceRouteExecutionHost(fakeClient(), relays, undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,13 +15,19 @@ export function relaysToServers(client: RpcClient, hostCapabilities: readonly st
|
||||
)
|
||||
}
|
||||
|
||||
/** Local and SSH workspaces are the desktop's own; only a server's are named, and only if reachable. */
|
||||
export function canTargetExecutionHost(
|
||||
client: RpcClient,
|
||||
/**
|
||||
* The `executionHost` a route into a workspace carries: none for the desktop's own (local, SSH),
|
||||
* the server for a reachable one, and null for one this phone cannot reach, which must not open.
|
||||
*/
|
||||
export function workspaceRouteExecutionHost(
|
||||
client: RpcClient | null,
|
||||
hostCapabilities: readonly string[],
|
||||
executionHost: ExecutionHostId | undefined
|
||||
): executionHost is `runtime:${string}` {
|
||||
return executionHost?.startsWith('runtime:') === true && relaysToServers(client, hostCapabilities)
|
||||
): ExecutionHostId | undefined | null {
|
||||
if (!executionHost?.startsWith('runtime:')) {
|
||||
return undefined
|
||||
}
|
||||
return client && relaysToServers(client, hostCapabilities) ? executionHost : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,10 +79,9 @@ export function rpcClientForExecutionHost(
|
||||
hostCapabilities: readonly string[],
|
||||
executionHost: ExecutionHostId | undefined
|
||||
): RpcClient | null {
|
||||
if (!executionHost?.startsWith('runtime:')) {
|
||||
const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, executionHost)
|
||||
if (routeHost === undefined) {
|
||||
return client
|
||||
}
|
||||
return canTargetExecutionHost(client, hostCapabilities, executionHost)
|
||||
? scopeRpcClientToExecutionHost(client, executionHost)
|
||||
: null
|
||||
return routeHost && scopeRpcClientToExecutionHost(client, routeHost)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const route = vi.hoisted(() => {
|
||||
const state: { params: Record<string, string>; capabilities: string[] } = {
|
||||
params: {},
|
||||
const state: { capabilities: string[] } = {
|
||||
capabilities: []
|
||||
}
|
||||
return state
|
||||
@@ -14,18 +13,16 @@ const desktop = vi.hoisted(() => {
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
|
||||
vi.mock('react-native', () => ({}))
|
||||
vi.mock('../components/host-protocol-gates-context', () => ({
|
||||
useOptionalHostProtocolGates: () => ({ hostCapabilities: route.capabilities })
|
||||
}))
|
||||
vi.mock('./client-context', () => ({
|
||||
useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' }),
|
||||
useForceReconnect: () => () => undefined
|
||||
useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' })
|
||||
}))
|
||||
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { WorkspaceRoute } from '../navigation/workspace-route'
|
||||
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
|
||||
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { useWorkspaceClient } from './use-workspace-client'
|
||||
@@ -34,7 +31,6 @@ async function workspaceClientFor(
|
||||
params: Record<string, string>,
|
||||
capabilities: string[]
|
||||
): Promise<{ client: RpcClient | null; session: FakeSession }> {
|
||||
route.params = params
|
||||
route.capabilities = capabilities
|
||||
const session = new FakeSession('connected')
|
||||
desktop.client = session
|
||||
@@ -44,7 +40,13 @@ async function workspaceClientFor(
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
create(createElement(WorkspaceRoute, null, createElement(Probe)))
|
||||
create(
|
||||
createElement(
|
||||
WorkspaceExecutionHostContext.Provider,
|
||||
{ value: normalizeExecutionHostId(params.executionHost) ?? undefined },
|
||||
createElement(Probe)
|
||||
)
|
||||
)
|
||||
})
|
||||
return { client: held.client, session }
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
|
||||
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
|
||||
import { useForceReconnect, useHostClient } from './client-context'
|
||||
import { useHostClient } from './client-context'
|
||||
import { rpcClientForExecutionHost } from './execution-host-scoped-rpc-client'
|
||||
|
||||
const NO_CAPABILITIES: readonly string[] = []
|
||||
|
||||
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
|
||||
export function useWorkspaceClient(
|
||||
hostId: string | undefined
|
||||
): ReturnType<typeof useHostClient> & { forceReconnect: ReturnType<typeof useForceReconnect> } {
|
||||
export function useWorkspaceClient(hostId: string | undefined): ReturnType<typeof useHostClient> {
|
||||
const host = useHostClient(hostId)
|
||||
const hostCapabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? NO_CAPABILITIES
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const forceReconnect = useForceReconnect()
|
||||
return {
|
||||
...host,
|
||||
forceReconnect,
|
||||
client: host.client && rpcClientForExecutionHost(host.client, hostCapabilities, executionHost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* the server's workspaces through the desktop by naming them with `executionHost: runtime:<env>`.
|
||||
* The desktop relays as the phone's own delegated device on the server.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
@@ -22,6 +23,8 @@ const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId
|
||||
const HostsSchema = z.object({
|
||||
hosts: z.array(z.looseObject({ hostId: z.string(), relay: z.string() }))
|
||||
})
|
||||
const DirectorySchema = z.array(z.looseObject({ name: z.string() }))
|
||||
const GitStatusSchema = z.looseObject({ entries: z.array(z.looseObject({ path: z.string() })) })
|
||||
const HostWorktreesSchema = z.looseObject({
|
||||
worktrees: z.array(
|
||||
z.looseObject({ worktreeId: z.string(), path: z.string(), hostId: z.string() })
|
||||
@@ -132,6 +135,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
const serverFolder = testInfo.outputPath('server-folder')
|
||||
mkdirSync(serverFolder, { recursive: true })
|
||||
writeFileSync(path.join(serverFolder, 'README.md'), 'server workspace\n')
|
||||
// A git repo on the server too, so Source Control has a change to read there.
|
||||
const serverRepo = testInfo.outputPath('server-repo')
|
||||
mkdirSync(serverRepo, { recursive: true })
|
||||
writeFileSync(path.join(serverRepo, 'NOTES.md'), 'first\n')
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
|
||||
git('init')
|
||||
git('add', 'NOTES.md')
|
||||
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
|
||||
writeFileSync(path.join(serverRepo, 'NOTES.md'), 'changed on the server\n')
|
||||
|
||||
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
|
||||
{ phoneTo: 'desktop', pinnedServePort: true },
|
||||
@@ -139,6 +151,7 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
)
|
||||
try {
|
||||
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
|
||||
await host.client.call('repo.add', { path: serverRepo, kind: 'git' })
|
||||
await new RuntimeClient(desktop.userDataDir, 5_000).call('repo.add', {
|
||||
path: testRepoPath,
|
||||
kind: 'git'
|
||||
@@ -181,12 +194,45 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
expect(hosts).toContainEqual(expect.objectContaining({ hostId: serverHostId, relay: 'ready' }))
|
||||
let listed: Awaited<ReturnType<typeof hostWorktrees>> = null
|
||||
await expect
|
||||
.poll(async () => (listed = await hostWorktrees(socket, serverHostId))?.stale)
|
||||
.toBe(false)
|
||||
.poll(async () => {
|
||||
listed = await hostWorktrees(socket, serverHostId)
|
||||
return listed?.stale === false && listed.worktrees.some((row) => row.path === serverRepo)
|
||||
})
|
||||
.toBe(true)
|
||||
const serverRow = listed!.worktrees.find((row) => row.path === serverFolder)
|
||||
const serverRepoRow = listed!.worktrees.find((row) => row.path === serverRepo)
|
||||
expect(serverRow, 'the phone lists the server workspace through the desktop').toBeDefined()
|
||||
expect(new Set(listed!.worktrees.map((row) => row.hostId))).toEqual(new Set([serverHostId]))
|
||||
|
||||
// Files and Source Control read the server's workspace there, and never on the desktop.
|
||||
const readDir = (worktreeId: string) => ({
|
||||
method: 'files.readDir',
|
||||
params: { worktree: `id:${worktreeId}`, relativePath: '' }
|
||||
})
|
||||
const gitStatus = (worktreeId: string) => ({
|
||||
method: 'git.status',
|
||||
params: { worktree: `id:${worktreeId}` }
|
||||
})
|
||||
const entries = await call(
|
||||
socket,
|
||||
{ id: 'files', ...readDir(serverRow!.worktreeId), executionHost: serverHostId },
|
||||
DirectorySchema
|
||||
)
|
||||
expect(entries.map((entry) => entry.name)).toContain('README.md')
|
||||
const changes = await call(
|
||||
socket,
|
||||
{ id: 'git', ...gitStatus(serverRepoRow!.worktreeId), executionHost: serverHostId },
|
||||
GitStatusSchema
|
||||
)
|
||||
expect(changes.entries.map((entry) => entry.path)).toContain('NOTES.md')
|
||||
for (const [id, request] of [
|
||||
['files-desktop', readDir(serverRow!.worktreeId)],
|
||||
['git-desktop', gitStatus(serverRepoRow!.worktreeId)]
|
||||
] as const) {
|
||||
socket.send(id, request.method, request.params)
|
||||
expect((await reply(socket, id)).ok, `${request.method} untargeted`).toBe(false)
|
||||
}
|
||||
|
||||
const relayed = await openTerminal(socket, serverRow!.worktreeId, serverHostId)
|
||||
await relayed.runMarker('MIRROR')
|
||||
const relayedMs: number[] = []
|
||||
@@ -229,6 +275,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
const stopped = await reply(socket, 'ps-stopped', 60_000)
|
||||
expect(stopped.ok).toBe(false)
|
||||
expect(stopped.error?.code).toBe('remote_runtime_unavailable')
|
||||
for (const [id, request] of [
|
||||
['files-stopped', readDir(serverRow!.worktreeId)],
|
||||
['git-stopped', gitStatus(serverRepoRow!.worktreeId)]
|
||||
] as const) {
|
||||
socket.send(id, request.method, request.params, serverHostId)
|
||||
const failed = await reply(socket, id, 60_000)
|
||||
expect(failed.ok, `${request.method} while the server is down`).toBe(false)
|
||||
expect(failed.error?.code).toBe('remote_runtime_unavailable')
|
||||
}
|
||||
}
|
||||
})
|
||||
// Back up: the listing is fresh again, and the same phone socket reaches the server.
|
||||
|
||||
Reference in New Issue
Block a user