mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
787 lines
28 KiB
TypeScript
787 lines
28 KiB
TypeScript
import { createElement, Suspense, type ReactElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
|
|
const asyncStorage = vi.hoisted(() => ({
|
|
getItem: vi.fn().mockResolvedValue(null),
|
|
setItem: vi.fn().mockResolvedValue(undefined),
|
|
removeItem: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
AppState: { currentState: 'active' },
|
|
FlatList: 'FlatList',
|
|
Image: 'Image',
|
|
Keyboard: { dismiss: vi.fn() },
|
|
Linking: { openURL: vi.fn() },
|
|
Modal: 'Modal',
|
|
ScrollView: 'ScrollView',
|
|
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: <T,>(styles: T) => styles },
|
|
Switch: 'Switch',
|
|
Text: 'Text',
|
|
TextInput: 'TextInput',
|
|
View: 'View'
|
|
}))
|
|
// Every icon in the drawer tree renders as a host element named after itself.
|
|
vi.mock(
|
|
'lucide-react-native',
|
|
() =>
|
|
new Proxy(
|
|
{},
|
|
{
|
|
get: (_target, name) => (typeof name === 'string' ? name : undefined),
|
|
has: () => true
|
|
}
|
|
)
|
|
)
|
|
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
|
vi.mock('./bottom-drawer-modal-host', () => ({ BottomDrawerModalHost: 'BottomDrawerModalHost' }))
|
|
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
|
|
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
|
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
|
|
|
import { setCachedRepos } from '../cache/repo-cache'
|
|
import { recordHostDescriptor } from '../transport/host-descriptor-store'
|
|
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
|
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
|
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
|
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
|
import { NewWorktreeModal } from './NewWorktreeModal'
|
|
import { NewWorktreeModalController } from './NewWorktreeModalController'
|
|
|
|
const RelayedOptionsSchema = z.object({ envelope: z.object({ executionHost: z.string() }) })
|
|
|
|
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
|
|
|
const repos = [
|
|
{
|
|
id: 'repo-1',
|
|
displayName: 'orca',
|
|
path: '/src/orca',
|
|
kind: 'git',
|
|
upstream: { owner: 'stablyai', repo: 'orca' }
|
|
}
|
|
]
|
|
|
|
function pickerItems(
|
|
renderer: ReactTestRenderer,
|
|
title: string
|
|
): { label: string; detail: string }[] {
|
|
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
|
|
const picker = pickers.find((node) => node.props.title === title)
|
|
return picker?.props.items ?? []
|
|
}
|
|
|
|
function sourceInputs(renderer: ReactTestRenderer) {
|
|
return renderer.root.findAll(
|
|
(node) =>
|
|
node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
|
|
)
|
|
}
|
|
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests.
|
|
const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient
|
|
|
|
function runTargetPicker(renderer: ReactTestRenderer) {
|
|
return renderer.root
|
|
.findAll((node) => node.type === 'PickerListDrawer')
|
|
.find((node) => node.props.title === 'Run on')!
|
|
}
|
|
|
|
async function flushUpdates(): Promise<void> {
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
describe('NewWorktreeModal project targets', () => {
|
|
let renderer: ReactTestRenderer
|
|
|
|
beforeEach(() => {
|
|
setCachedRepos('host-1', repos)
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
})
|
|
|
|
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
|
|
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.reject(new Error('connection closed'))
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
return new Promise(() => {})
|
|
})
|
|
const client = { sendRequest } as unknown as RpcClient
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
await Promise.resolve()
|
|
})
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(sendRequest).toHaveBeenCalledWith('repo.list')
|
|
expect(pickerItems(renderer, 'Project')).toEqual([
|
|
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
|
])
|
|
expect(pickerItems(renderer, 'Run on')).toEqual([
|
|
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
|
])
|
|
})
|
|
|
|
it('groups same-name checkouts under one project with separate run targets', async () => {
|
|
const listedRepos = [
|
|
...repos,
|
|
{
|
|
id: 'repo-2',
|
|
displayName: 'orca',
|
|
path: '/home/dev/orca',
|
|
connectionId: 'build-server',
|
|
kind: 'git',
|
|
upstream: { owner: 'stablyai', repo: 'orca' }
|
|
}
|
|
]
|
|
const client = {
|
|
sendRequest: vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
return new Promise(() => {})
|
|
})
|
|
} as unknown as RpcClient
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(pickerItems(renderer, 'Project')).toEqual([
|
|
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
|
])
|
|
expect(pickerItems(renderer, 'Run on')).toEqual([
|
|
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
|
|
expect.objectContaining({
|
|
label: 'build-server',
|
|
detail: '/home/dev/orca'
|
|
})
|
|
])
|
|
})
|
|
|
|
it('loads SSH state before detecting agents on the selected remote target', async () => {
|
|
const remoteRepo = {
|
|
id: 'repo-remote',
|
|
displayName: 'orca',
|
|
path: '/home/dev/orca',
|
|
connectionId: 'build-server',
|
|
kind: 'git',
|
|
upstream: { owner: 'stablyai', repo: 'orca' }
|
|
}
|
|
setCachedRepos('host-ssh', [remoteRepo])
|
|
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
|
|
}
|
|
if (method === 'ssh.getState') {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
result: {
|
|
state: {
|
|
targetId: 'build-server',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
}
|
|
})
|
|
}
|
|
if (method === 'preflight.detectRemoteAgents') {
|
|
return Promise.resolve({ ok: true, result: ['codex'] })
|
|
}
|
|
if (method === 'settings.get') {
|
|
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
|
}
|
|
if (method === 'ui.get') {
|
|
return Promise.resolve({ ok: true, result: { ui: {} } })
|
|
}
|
|
if (method === 'preflight.check') {
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
if (method === 'linear.status') {
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
|
|
}
|
|
if (method === 'repo.hooks') {
|
|
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
|
}
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
})
|
|
const client = { sendRequest } as unknown as RpcClient
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client,
|
|
hostId: 'host-ssh',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
|
|
expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
|
|
expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
|
|
connectionId: 'build-server'
|
|
})
|
|
})
|
|
|
|
it('keeps folder workspaces on the local execution and text-only source path', async () => {
|
|
const folderRepo = {
|
|
id: 'folder-1',
|
|
displayName: 'notes',
|
|
path: '/src/notes',
|
|
kind: 'folder'
|
|
}
|
|
setCachedRepos('host-folder', [folderRepo])
|
|
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
|
|
}
|
|
if (method === 'preflight.detectAgents') {
|
|
return Promise.resolve({ ok: true, result: ['codex'] })
|
|
}
|
|
if (method === 'settings.get') {
|
|
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
|
}
|
|
if (method === 'ui.get') {
|
|
return Promise.resolve({ ok: true, result: { ui: {} } })
|
|
}
|
|
if (method === 'preflight.check') {
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
if (method === 'linear.status') {
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
if (method === 'repo.hooks') {
|
|
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
|
}
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
})
|
|
const client = { sendRequest } as unknown as RpcClient
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client,
|
|
hostId: 'host-folder',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
|
|
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
|
|
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
|
|
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
|
|
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain(
|
|
'worktree.prefetchCreateBase'
|
|
)
|
|
})
|
|
|
|
it("warms the host's create for the picked repo as soon as the form opens", async () => {
|
|
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos } })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
if (method === 'repo.hooks') {
|
|
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
|
}
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
})
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client: requestsOnly(sendRequest),
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {},
|
|
openExternalUrl: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
|
|
// Without this the host fetches the base and checks out every file inside worktree.create.
|
|
const prefetches = sendRequest.mock.calls.filter(
|
|
([method]) => method === 'worktree.prefetchCreateBase'
|
|
)
|
|
expect(prefetches).toHaveLength(1)
|
|
expect(prefetches[0]?.[1]).toEqual({ repo: 'id:repo-1' })
|
|
})
|
|
|
|
it("creates a server repo's workspace on that server and names it for the route", async () => {
|
|
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
|
|
const serverSshRepo = { ...serverRepo, id: 'server-ssh', connectionId: 'its-own-ssh' }
|
|
const reply = (method: string, listed: unknown[], hostPlatform: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos: listed } })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform } })
|
|
}
|
|
if (method === 'worktree.create') {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
result: { worktree: { id: 'srv-wt', displayName: 'srv-wt', path: '/srv/orca-wt' } }
|
|
})
|
|
}
|
|
if (method === 'settings.get') {
|
|
return Promise.resolve({ ok: true, result: { settings: {} } })
|
|
}
|
|
return Promise.resolve({ ok: true, result: {} })
|
|
}
|
|
const desktop = vi.fn((method: string) => reply(method, repos, 'darwin'))
|
|
const server = vi.fn((method: string) => reply(method, [serverRepo, serverSshRepo], 'linux'))
|
|
const onCreated = vi.fn()
|
|
recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' })
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client: requestsOnly(desktop),
|
|
serverClients: new Map([['runtime:env-1', requestsOnly(server)]]),
|
|
hostId: 'host-1',
|
|
hostLabelById: new Map([['runtime:env-1', 'scratch-server']]),
|
|
onCreated,
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
|
|
const runTargets = pickerItems(renderer, 'Run on')
|
|
expect(runTargets.map((item) => [item.label, item.detail])).toEqual([
|
|
[LOCAL_HOST_LABEL, '/src/orca'],
|
|
['scratch-server', '/srv/orca']
|
|
])
|
|
const serverTarget = runTargetPicker(renderer)
|
|
const iconOf = (item: unknown) => {
|
|
let icon: ReactTestRenderer | undefined
|
|
act(() => {
|
|
icon = create(serverTarget.props.renderIcon(item))
|
|
})
|
|
return icon?.toJSON()
|
|
}
|
|
expect(serverTarget.props.items.map(iconOf)).toMatchObject([
|
|
{ type: 'Monitor' },
|
|
{ type: 'Server' }
|
|
])
|
|
await act(async () => serverTarget.props.onSelect(serverTarget.props.items[1]))
|
|
await flushUpdates()
|
|
// The Run on field shows the picked server's glyph, as its row does.
|
|
expect(renderer.root.findAll((node) => node.type === 'Server')).toHaveLength(1)
|
|
expect(pickerItems(renderer, 'Run on')[0]?.label).toBe(LOCAL_HOST_LABEL)
|
|
act(() => sourceInputs(renderer)[0]!.props.onChangeText('srv-wt'))
|
|
const form = renderer.root.find((node) => typeof node.props.onCreate === 'function')
|
|
await act(async () => form.props.onCreate())
|
|
await flushUpdates()
|
|
|
|
expect(server).toHaveBeenCalledWith(
|
|
'worktree.create',
|
|
expect.objectContaining({ repo: 'id:server-repo' }),
|
|
expect.anything()
|
|
)
|
|
expect(desktop).not.toHaveBeenCalledWith(
|
|
'worktree.create',
|
|
expect.anything(),
|
|
expect.anything()
|
|
)
|
|
expect(onCreated).toHaveBeenCalledWith('srv-wt', expect.any(String), undefined, 'runtime:env-1')
|
|
})
|
|
|
|
it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => {
|
|
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
|
|
// One desktop: its own repos, its server list, and a server's repos relayed through it.
|
|
const desktop = new FakeSession('connected')
|
|
desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => {
|
|
const relayed = RelayedOptionsSchema.safeParse(rest[1]).data?.envelope.executionHost
|
|
const result =
|
|
method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
|
? {
|
|
hosts: [
|
|
{ hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' }
|
|
]
|
|
}
|
|
: method === 'repo.list'
|
|
? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos }
|
|
: {}
|
|
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
|
})
|
|
const listCalls = () =>
|
|
desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD)
|
|
// The host list's own names: no server rows have loaded behind a sheet opened from Home.
|
|
const sheet = (routeVisible: boolean) =>
|
|
createElement(NewWorktreeModalController, {
|
|
routeVisible,
|
|
client: desktop,
|
|
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
|
|
hostId: 'host-1',
|
|
hostLabelById: new Map(),
|
|
openExternalUrl: () => {},
|
|
onCreated: () => {},
|
|
onRouteVisibleChange: () => {}
|
|
})
|
|
|
|
await act(async () => {
|
|
renderer = create(sheet(false))
|
|
})
|
|
await flushUpdates()
|
|
// Closed, it asks nothing of the desktop.
|
|
expect(listCalls()).toHaveLength(0)
|
|
|
|
await act(async () => renderer.update(sheet(true)))
|
|
await flushUpdates()
|
|
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
|
|
[expect.any(String), '/src/orca'],
|
|
['scratch-vm', '/srv/orca']
|
|
])
|
|
})
|
|
|
|
it("lists the desktop's repos at once while a server has not answered", async () => {
|
|
const desktop = vi.fn((method: string) =>
|
|
method === 'repo.list'
|
|
? Promise.resolve({ ok: true, result: { repos } })
|
|
: new Promise(() => {})
|
|
)
|
|
const hungServer = vi.fn(() => new Promise(() => {}))
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client: requestsOnly(desktop),
|
|
serverClients: new Map([['runtime:env-1', requestsOnly(hungServer)]]),
|
|
hostId: 'host-cold',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
|
|
expect(hungServer).toHaveBeenCalledWith('repo.list')
|
|
expect(pickerItems(renderer, 'Run on').map((item) => item.detail)).toEqual(['/src/orca'])
|
|
})
|
|
|
|
it('keeps a server repo picked when the desktop lists a repo with the same id', async () => {
|
|
const serverRepo = { ...repos[0]!, path: '/srv/orca' }
|
|
const listing = (listed: unknown[]) =>
|
|
vi.fn((method: string) =>
|
|
method === 'repo.list'
|
|
? Promise.resolve({ ok: true, result: { repos: listed } })
|
|
: new Promise(() => {})
|
|
)
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(NewWorktreeModal, {
|
|
visible: true,
|
|
client: requestsOnly(listing(repos)),
|
|
serverClients: new Map([['runtime:env-1', requestsOnly(listing([serverRepo]))]]),
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
})
|
|
)
|
|
})
|
|
await flushUpdates()
|
|
const picker = runTargetPicker(renderer)
|
|
await act(async () => picker.props.onSelect(picker.props.items[1]))
|
|
await flushUpdates()
|
|
|
|
expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1')
|
|
expect(
|
|
renderer.root.find((node) => node.props.runTarget !== undefined).props.runTarget.detail
|
|
).toBe('/srv/orca')
|
|
})
|
|
|
|
it('ignores a stale repo list after the client changes', async () => {
|
|
let resolveOldList: ((value: unknown) => void) | undefined
|
|
const oldList = new Promise((resolve) => {
|
|
resolveOldList = resolve
|
|
})
|
|
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
|
|
const oldClient = {
|
|
sendRequest: vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return oldList
|
|
}
|
|
return new Promise(() => {})
|
|
})
|
|
} as unknown as RpcClient
|
|
const freshClient = {
|
|
sendRequest: vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
return new Promise(() => {})
|
|
})
|
|
} as unknown as RpcClient
|
|
const modalProps = {
|
|
visible: true,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
}
|
|
|
|
await act(async () => {
|
|
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
|
|
})
|
|
const sourceInput = sourceInputs(renderer)[0]!
|
|
act(() => sourceInput.props.onChangeText('stale-client-name'))
|
|
expect(sourceInput.props.value).toBe('stale-client-name')
|
|
await act(async () => {
|
|
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
|
|
await Promise.resolve()
|
|
})
|
|
// The swap must not restart the form session — see the reconnect test below.
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
|
|
'stale-client-name',
|
|
'stale-client-name'
|
|
])
|
|
resolveOldList?.({ ok: true, result: { repos } })
|
|
await flushUpdates()
|
|
|
|
expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
|
|
})
|
|
|
|
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
|
|
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
|
|
const client = { sendRequest } as unknown as RpcClient
|
|
const modalProps = {
|
|
client,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
}
|
|
|
|
await act(async () => {
|
|
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
|
})
|
|
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
|
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
|
|
|
// One repo.list per opening. A third means the previous session's instance
|
|
// re-ran its visible-gated effects before the remount key caught up.
|
|
const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
|
|
expect(repoListCalls).toHaveLength(2)
|
|
})
|
|
|
|
// A reconnect / forceReconnect / foreground revival hands the same host a NEW
|
|
// RpcClient object (see useHostClient). Keying the form session on that object
|
|
// remounted the modal mid-edit and silently threw away the picked source.
|
|
it('keeps the picked source when a reconnect swaps the client for the same host', async () => {
|
|
const makeClient = () =>
|
|
({
|
|
sendRequest: vi.fn().mockImplementation((method: string) => {
|
|
if (method === 'repo.list') {
|
|
return Promise.resolve({ ok: true, result: { repos } })
|
|
}
|
|
if (method === 'status.get') {
|
|
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
|
}
|
|
return new Promise(() => {})
|
|
})
|
|
}) as unknown as RpcClient
|
|
const modalProps = {
|
|
visible: true,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
}
|
|
|
|
await act(async () => {
|
|
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
|
|
})
|
|
act(() => sourceInputs(renderer)[0]!.props.onChangeText('feat/keep-me'))
|
|
|
|
await act(async () => {
|
|
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
|
|
'feat/keep-me',
|
|
'feat/keep-me'
|
|
])
|
|
})
|
|
|
|
// react-native-screens freezes a blurred screen by suspending its subtree
|
|
// (react-freeze), so React runs this component and then throws that render away.
|
|
// Anything the render mutated in place outlives the work React discarded.
|
|
function suspendableTree(frozen: () => boolean, child: ReactElement) {
|
|
const never = new Promise<void>(() => {})
|
|
const Freezer = () => {
|
|
if (frozen()) {
|
|
throw never
|
|
}
|
|
return null
|
|
}
|
|
// The modal renders first, so its render completes before the freeze throws.
|
|
return createElement(Suspense, { fallback: null }, child, createElement(Freezer, null))
|
|
}
|
|
|
|
it('keeps the form when a host switch renders but never commits', async () => {
|
|
setCachedRepos('host-2', repos)
|
|
const client = {
|
|
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
|
|
let frozen = false
|
|
const tree = (hostId: string) =>
|
|
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, hostId }))
|
|
|
|
await act(async () => {
|
|
renderer = create(tree('host-1'))
|
|
})
|
|
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me'))
|
|
|
|
frozen = true
|
|
await act(async () => {
|
|
renderer.update(tree('host-2'))
|
|
})
|
|
frozen = false
|
|
await act(async () => {
|
|
renderer.update(tree('host-1'))
|
|
})
|
|
|
|
// host-2 never committed, so host-1's session was never superseded.
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['keep-me', 'keep-me'])
|
|
})
|
|
|
|
it('keeps the form when a close renders but never commits', async () => {
|
|
const client = {
|
|
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
const modalProps = {
|
|
client,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
}
|
|
let frozen = false
|
|
const tree = (visible: boolean) =>
|
|
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, visible }))
|
|
|
|
await act(async () => {
|
|
renderer = create(tree(true))
|
|
})
|
|
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me-too'))
|
|
|
|
frozen = true
|
|
await act(async () => {
|
|
renderer.update(tree(false))
|
|
})
|
|
frozen = false
|
|
await act(async () => {
|
|
renderer.update(tree(true))
|
|
})
|
|
|
|
// The drawer never committed a closed state, so this is not a reopening.
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
|
|
'keep-me-too',
|
|
'keep-me-too'
|
|
])
|
|
})
|
|
|
|
it('starts a fresh form session when the modal switches hosts', async () => {
|
|
setCachedRepos('host-2', repos)
|
|
const client = {
|
|
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
|
|
|
|
await act(async () => {
|
|
renderer = create(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-1' }))
|
|
})
|
|
act(() => sourceInputs(renderer)[0]!.props.onChangeText('host-one-name'))
|
|
|
|
await act(async () => {
|
|
renderer.update(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-2' }))
|
|
})
|
|
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
|
})
|
|
|
|
it('starts with fresh form state after closing and reopening', async () => {
|
|
const client = {
|
|
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
const modalProps = {
|
|
client,
|
|
hostId: 'host-1',
|
|
onCreated: () => {},
|
|
onClose: () => {}
|
|
}
|
|
|
|
await act(async () => {
|
|
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
|
})
|
|
const sourceInput = sourceInputs(renderer)[0]!
|
|
act(() => sourceInput.props.onChangeText('previous-workspace'))
|
|
|
|
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
|
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
|
|
|
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
|
})
|
|
})
|