mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
test(e2e): reveal specs create workspaces through main so they pass with the sidebar switch on (#30642)
* test(e2e): create reveal-spec workspaces through main so they pass with the sidebar switch on The agent-send picker and host-section reveal specs injected fake repos and workspaces into the window store. With ORCA_SIDEBAR_INPUTS=main the sidebar lists main's rows, so those workspaces never appeared. The SSH host is now an unreachable target seeded into the stopped profile, and the paired-server host is a real headless orca serve host; pins, lineage and notes go through main. Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): type the seeded hosts as execution host ids Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): poll the reveal specs' page setup instead of awaiting it across CDP An evaluate that awaits a preload promise sometimes fails with "Resulting promise was garbage collected" in switch-on runs. evaluatePolled runs the work in the page, polls its outcome, and still fails on a rejected or failed write. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,255 +1,195 @@
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady,
|
||||
waitForStartupWorktreeRefresh
|
||||
} from './helpers/store'
|
||||
import { evaluatePolled } from './helpers/page-evaluate-polled'
|
||||
import { launchWithUnreachableSshWorkspaces } from './helpers/seeded-ssh-workspaces'
|
||||
|
||||
for (const hostId of ['local', 'ssh:picker-builder'] as const) {
|
||||
for (const host of ['local', 'ssh'] as const) {
|
||||
for (const hiddenBy of ['host', 'parent'] as const) {
|
||||
test(`agent send picker reveals ${hostId} target hidden by ${hiddenBy} and restores collapse on close`, async ({
|
||||
orcaPage
|
||||
test(`agent send picker reveals ${host} target hidden by ${hiddenBy} and restores collapse on close`, async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await waitForStartupWorktreeRefresh(orcaPage)
|
||||
const ids = await orcaPage.evaluate(
|
||||
async ({ hostId, hiddenBy }) => {
|
||||
const store = window.__store!
|
||||
const state = store.getState()
|
||||
const sourceRepo = state.repos[0]
|
||||
const source = state.worktreesByRepo[sourceRepo.id][0]
|
||||
const remoteHostId = hostId === 'local' ? 'ssh:picker-builder' : hostId
|
||||
const remoteRepo = {
|
||||
...sourceRepo,
|
||||
id: 'picker-remote-repo',
|
||||
displayName: 'Remote project',
|
||||
executionHostId: remoteHostId,
|
||||
connectionId: remoteHostId.startsWith('ssh:') ? 'picker-builder' : null
|
||||
}
|
||||
const local = {
|
||||
...source,
|
||||
hostId: 'local' as const,
|
||||
isPinned: false,
|
||||
displayName: 'Local workspace'
|
||||
}
|
||||
const remote = {
|
||||
...source,
|
||||
id: 'picker-remote-parent',
|
||||
repoId: remoteRepo.id,
|
||||
hostId: remoteHostId,
|
||||
instanceId: 'remote-parent-instance',
|
||||
isMainWorktree: false,
|
||||
isPinned: false,
|
||||
displayName: 'Remote workspace'
|
||||
}
|
||||
const parent = hostId === 'local' ? local : remote
|
||||
const now = Date.now()
|
||||
const localChild = state.worktreesByRepo[sourceRepo.id].find(
|
||||
(worktree) => worktree.id !== source.id
|
||||
)
|
||||
const child =
|
||||
hostId === 'local'
|
||||
? localChild
|
||||
: {
|
||||
...parent,
|
||||
id: 'picker-child',
|
||||
instanceId: 'picker-child-instance',
|
||||
isMainWorktree: false
|
||||
}
|
||||
if (hiddenBy === 'parent' && !child) {
|
||||
throw new Error('Seeded child workspace missing')
|
||||
}
|
||||
const targetId = hiddenBy === 'parent' ? child!.id : parent.id
|
||||
const target = {
|
||||
...parent,
|
||||
...(hiddenBy === 'parent' ? child : {}),
|
||||
displayName: hiddenBy === 'parent' ? 'Child workspace' : parent.displayName,
|
||||
diffComments: [
|
||||
{
|
||||
id: 'picker-note',
|
||||
worktreeId: targetId,
|
||||
filePath: 'README.md',
|
||||
source: 'diff' as const,
|
||||
lineNumber: 1,
|
||||
body: 'Check sidebar disclosure',
|
||||
side: 'modified' as const,
|
||||
createdAt: now
|
||||
}
|
||||
]
|
||||
}
|
||||
if (hostId === 'local') {
|
||||
await state.updateWorktreeMeta(
|
||||
target.id,
|
||||
{
|
||||
displayName: target.displayName,
|
||||
isPinned: false,
|
||||
diffComments: target.diffComments
|
||||
},
|
||||
{ executionHostId: 'local' }
|
||||
)
|
||||
if (hiddenBy === 'parent') {
|
||||
await state.assignWorktreeParent(target.id, { parentWorktreeId: parent.id })
|
||||
test.setTimeout(3 * 60_000)
|
||||
const launched = await launchWithUnreachableSshWorkspaces(testInfo, testRepoPath, {
|
||||
label: 'Remote builder',
|
||||
workspaces: [
|
||||
{ name: 'parent', displayName: 'Remote workspace' },
|
||||
{ name: 'child', displayName: 'Remote child' }
|
||||
]
|
||||
})
|
||||
try {
|
||||
const { page, ssh, localWorktreeId } = launched
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
const ids = await evaluatePolled(
|
||||
page,
|
||||
async ({ host, hiddenBy, ssh, localWorktreeId }) => {
|
||||
const store = window.__store!
|
||||
const state = store.getState()
|
||||
const localChild = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.hostId === 'local' && worktree.id !== localWorktreeId)
|
||||
const hostId = host === 'local' ? 'local' : ssh.hostId
|
||||
const parent = host === 'local' ? localWorktreeId : ssh.worktreeIds.parent!
|
||||
const child = host === 'local' ? localChild?.id : ssh.worktreeIds.child
|
||||
if (hiddenBy === 'parent' && !child) {
|
||||
throw new Error('Seeded child workspace missing')
|
||||
}
|
||||
}
|
||||
const localWorktrees =
|
||||
hostId === 'local' ? (hiddenBy === 'parent' ? [local, target] : [target]) : [local]
|
||||
const remoteWorktrees =
|
||||
hostId !== 'local' ? (hiddenBy === 'parent' ? [remote, target] : [target]) : [remote]
|
||||
const collapsedGroups = [
|
||||
hiddenBy === 'host' ? `host:${hostId}` : `lineage:${hostId}|${parent.id}`
|
||||
]
|
||||
const worktreeLineageById =
|
||||
hiddenBy === 'parent'
|
||||
? {
|
||||
[target.id]: {
|
||||
worktreeId: target.id,
|
||||
worktreeInstanceId: target.instanceId!,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId!,
|
||||
origin: 'orchestration' as const,
|
||||
capture: {
|
||||
source: 'orchestration-context' as const,
|
||||
confidence: 'explicit' as const
|
||||
},
|
||||
const target = hiddenBy === 'parent' ? child! : parent
|
||||
const now = Date.now()
|
||||
const noted = await state.updateWorktreeMeta(
|
||||
target,
|
||||
{
|
||||
...(hiddenBy === 'parent' ? { displayName: 'Child workspace' } : {}),
|
||||
isPinned: false,
|
||||
diffComments: [
|
||||
{
|
||||
id: 'picker-note',
|
||||
worktreeId: target,
|
||||
filePath: 'README.md',
|
||||
source: 'diff' as const,
|
||||
lineNumber: 1,
|
||||
body: 'Check sidebar disclosure',
|
||||
side: 'modified' as const,
|
||||
createdAt: now
|
||||
}
|
||||
}
|
||||
: {}
|
||||
const tabId = 'picker-agent-tab'
|
||||
const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const paneKey = `${tabId}:${leafId}`
|
||||
const ptyId = 'picker-agent-pty'
|
||||
await window.api.ui.set({ groupBy: 'none', collapsedGroups })
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
state.setRightSidebarTab('source-control')
|
||||
state.setRightSidebarOpen(true)
|
||||
store.setState({
|
||||
groupBy: 'none',
|
||||
repos: [sourceRepo, remoteRepo],
|
||||
worktreesByRepo: { [sourceRepo.id]: localWorktrees, [remoteRepo.id]: remoteWorktrees },
|
||||
worktreeLineageById,
|
||||
visibleWorkspaceHostIds: ['local', remoteHostId],
|
||||
workspaceHostScope: 'all',
|
||||
activeRepoId: target.repoId,
|
||||
activeWorktreeId: target.id,
|
||||
activeWorkspaceKey: `worktree:${target.id}`,
|
||||
activeWorkspaceExecutionHostId: hostId,
|
||||
pendingRevealWorktree: null,
|
||||
agentSendPopoverTargetMode: null,
|
||||
collapsedGroups: new Set(collapsedGroups),
|
||||
agentActivityDisplayMode: 'compact',
|
||||
worktreeCardProperties: [
|
||||
...state.worktreeCardProperties.filter((property) => property !== 'inline-agents'),
|
||||
'inline-agents'
|
||||
],
|
||||
sshTargetLabels: new Map([['picker-builder', 'Remote builder']]),
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'picker-builder',
|
||||
{
|
||||
targetId: 'picker-builder',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
]
|
||||
]),
|
||||
tabsByWorktree: {
|
||||
...state.tabsByWorktree,
|
||||
[target.id]: [
|
||||
...(state.tabsByWorktree[target.id] ?? []),
|
||||
{
|
||||
id: tabId,
|
||||
worktreeId: target.id,
|
||||
ptyId,
|
||||
title: 'Claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: now
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...state.terminalLayoutsByTabId,
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { ...state.ptyIdsByTabId, [tabId]: [ptyId] },
|
||||
agentStatusByPaneKey: {
|
||||
...state.agentStatusByPaneKey,
|
||||
[paneKey]: {
|
||||
state: 'working',
|
||||
prompt: 'Check sidebar disclosure',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
worktreeId: target.id,
|
||||
stateHistory: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{ executionHostId: hostId }
|
||||
)
|
||||
if (!noted.ok) {
|
||||
throw new Error(`Writing the note failed: ${noted.error}`)
|
||||
}
|
||||
})
|
||||
return {
|
||||
target: target.id,
|
||||
control: hostId === 'local' ? remote.id : local.id,
|
||||
collapsedGroups
|
||||
}
|
||||
},
|
||||
{ hostId, hiddenBy }
|
||||
)
|
||||
const targetRow = orcaPage.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.target)}]`
|
||||
)
|
||||
const controlRow = orcaPage.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.control)}]`
|
||||
)
|
||||
await expect(targetRow).toHaveCount(0)
|
||||
await expect(controlRow).toBeVisible()
|
||||
const sendButton = orcaPage.getByRole('button', {
|
||||
name: 'Send notes to an agent',
|
||||
exact: true
|
||||
})
|
||||
await expect(sendButton).toBeEnabled()
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('before-picker.png'),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await sendButton.click()
|
||||
await expect(orcaPage.getByText('Send notes to', { exact: true })).toBeVisible()
|
||||
try {
|
||||
await expect(targetRow).toBeVisible()
|
||||
await expect(targetRow.locator('[data-agent-send-target="eligible"]')).toBeVisible()
|
||||
} finally {
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('after-picker.png'),
|
||||
if (hiddenBy === 'parent') {
|
||||
await state.assignWorktreeParent(target, { parentWorktreeId: parent })
|
||||
}
|
||||
const targetRepoId = Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === target)!.repoId
|
||||
const collapsedGroups = [
|
||||
hiddenBy === 'host' ? `host:${hostId}` : `lineage:${hostId}|${parent}`
|
||||
]
|
||||
const tabId = 'picker-agent-tab'
|
||||
const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const paneKey = `${tabId}:${leafId}`
|
||||
const ptyId = 'picker-agent-pty'
|
||||
await window.api.ui.set({ groupBy: 'none', collapsedGroups })
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
state.setRightSidebarTab('source-control')
|
||||
state.setRightSidebarOpen(true)
|
||||
const current = store.getState()
|
||||
store.setState({
|
||||
groupBy: 'none',
|
||||
visibleWorkspaceHostIds: ['local', ssh.hostId],
|
||||
workspaceHostScope: 'all',
|
||||
activeRepoId: targetRepoId,
|
||||
activeWorktreeId: target,
|
||||
activeWorkspaceKey: `worktree:${target}`,
|
||||
activeWorkspaceExecutionHostId: hostId,
|
||||
pendingRevealWorktree: null,
|
||||
agentSendPopoverTargetMode: null,
|
||||
collapsedGroups: new Set(collapsedGroups),
|
||||
agentActivityDisplayMode: 'compact',
|
||||
worktreeCardProperties: [
|
||||
...current.worktreeCardProperties.filter(
|
||||
(property) => property !== 'inline-agents'
|
||||
),
|
||||
'inline-agents'
|
||||
],
|
||||
tabsByWorktree: {
|
||||
...current.tabsByWorktree,
|
||||
[target]: [
|
||||
...(current.tabsByWorktree[target] ?? []),
|
||||
{
|
||||
id: tabId,
|
||||
worktreeId: target,
|
||||
ptyId,
|
||||
title: 'Claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: now
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...current.terminalLayoutsByTabId,
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { ...current.ptyIdsByTabId, [tabId]: [ptyId] },
|
||||
agentStatusByPaneKey: {
|
||||
...current.agentStatusByPaneKey,
|
||||
[paneKey]: {
|
||||
state: 'working',
|
||||
prompt: 'Check sidebar disclosure',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
agentType: 'claude',
|
||||
paneKey,
|
||||
worktreeId: target,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
target,
|
||||
control: host === 'local' ? ssh.worktreeIds.parent! : localWorktreeId,
|
||||
collapsedGroups
|
||||
}
|
||||
},
|
||||
{ host, hiddenBy, ssh, localWorktreeId }
|
||||
)
|
||||
const targetRow = page.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.target)}]`
|
||||
)
|
||||
const controlRow = page.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.control)}]`
|
||||
)
|
||||
await expect(targetRow).toHaveCount(0)
|
||||
await expect(controlRow).toBeVisible()
|
||||
const sendButton = page.getByRole('button', {
|
||||
name: 'Send notes to an agent',
|
||||
exact: true
|
||||
})
|
||||
await expect(sendButton).toBeEnabled()
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('before-picker.png'),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await sendButton.click()
|
||||
await expect(page.getByText('Send notes to', { exact: true })).toBeVisible()
|
||||
try {
|
||||
await expect(targetRow).toBeVisible()
|
||||
await expect(targetRow.locator('[data-agent-send-target="eligible"]')).toBeVisible()
|
||||
} finally {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('after-picker.png'),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
}
|
||||
await expect(controlRow).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__store!.getState().pendingRevealWorktree))
|
||||
.toBeNull()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__store!.getState().agentSendPopoverTargetMode))
|
||||
.toBeNull()
|
||||
await expect(targetRow).toHaveCount(0)
|
||||
expect(await page.evaluate(() => [...window.__store!.getState().collapsedGroups])).toEqual(
|
||||
ids.collapsedGroups
|
||||
)
|
||||
expect(
|
||||
await page.evaluate(async () => (await window.api.ui.get()).collapsedGroups)
|
||||
).toEqual(ids.collapsedGroups)
|
||||
} finally {
|
||||
await launched.dispose()
|
||||
}
|
||||
await expect(controlRow).toBeVisible()
|
||||
await expect
|
||||
.poll(() => orcaPage.evaluate(() => window.__store!.getState().pendingRevealWorktree))
|
||||
.toBeNull()
|
||||
await orcaPage.keyboard.press('Escape')
|
||||
await expect
|
||||
.poll(() => orcaPage.evaluate(() => window.__store!.getState().agentSendPopoverTargetMode))
|
||||
.toBeNull()
|
||||
await expect(targetRow).toHaveCount(0)
|
||||
expect(
|
||||
await orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups])
|
||||
).toEqual(ids.collapsedGroups)
|
||||
expect(
|
||||
await orcaPage.evaluate(async () => (await window.api.ui.get()).collapsedGroups)
|
||||
).toEqual(ids.collapsedGroups)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from './orca-app'
|
||||
|
||||
type Outcome<Result> =
|
||||
| { state: 'pending' }
|
||||
| { state: 'done'; value: Result }
|
||||
| { state: 'failed'; error: string }
|
||||
|
||||
let nextKey = 0
|
||||
|
||||
/** page.evaluate whose outcome is polled: awaiting a preload promise across CDP can be garbage-collected.
|
||||
* `args` cross as JSON. */
|
||||
export async function evaluatePolled<Args, Result>(
|
||||
page: Page,
|
||||
run: (args: Args) => Promise<Result>,
|
||||
args: Args
|
||||
): Promise<Result> {
|
||||
nextKey += 1
|
||||
const key = JSON.stringify(`__e2eEvaluatePolled${nextKey}`)
|
||||
await page.evaluate(`(() => {
|
||||
const run = ${run.toString()};
|
||||
globalThis[${key}] = { state: 'pending' };
|
||||
Promise.resolve()
|
||||
.then(() => run(${JSON.stringify(args)}))
|
||||
.then(
|
||||
(value) => { globalThis[${key}] = { state: 'done', value }; },
|
||||
(error) => { globalThis[${key}] = { state: 'failed', error: String(error) }; }
|
||||
);
|
||||
})()`)
|
||||
const read = () => page.evaluate<Outcome<Result>>(`globalThis[${key}]`)
|
||||
await expect
|
||||
.poll(async () => (await read()).state, { message: 'page work never settled' })
|
||||
.not.toBe('pending')
|
||||
const outcome = await read()
|
||||
if (outcome.state !== 'done') {
|
||||
throw new Error(outcome.state === 'failed' ? outcome.error : 'page work never settled')
|
||||
}
|
||||
return outcome.value
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import type { Repo } from '../../../src/shared/repo-types'
|
||||
import { toSshExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { expect } from './orca-app'
|
||||
import { seedRelayEraTarget } from './orcad-upgrade-profile'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './orca-restart'
|
||||
import { mutateStoppedProfileState } from './persisted-profile-state'
|
||||
import { waitForSessionReady, waitForStartupWorktreeRefresh } from './store'
|
||||
|
||||
export type SeededSshWorkspaces = {
|
||||
targetId: string
|
||||
hostId: `ssh:${string}`
|
||||
repoId: string
|
||||
/** Each seeded workspace's id, by its name. */
|
||||
worktreeIds: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* An SSH host with one project and the workspaces main stores for it, seeded into a stopped profile.
|
||||
* Nothing listens on its port, so the next launch lists exactly these stored rows.
|
||||
*/
|
||||
function seedUnreachableSshWorkspaces(
|
||||
userDataDir: string,
|
||||
input: { label: string; workspaces: { name: string; displayName: string }[] }
|
||||
): SeededSshWorkspaces {
|
||||
const targetId = seedRelayEraTarget(userDataDir, {
|
||||
label: input.label,
|
||||
host: '127.0.0.1',
|
||||
port: 1,
|
||||
username: 'nobody'
|
||||
})
|
||||
const hostId = toSshExecutionHostId(targetId)
|
||||
const repoPath = '/srv/seeded-ssh-repo'
|
||||
const repo: Repo = {
|
||||
id: `repo-${targetId}`,
|
||||
path: repoPath,
|
||||
displayName: 'Remote project',
|
||||
badgeColor: '#737373',
|
||||
addedAt: Date.now(),
|
||||
connectionId: targetId
|
||||
}
|
||||
const worktreeIds = Object.fromEntries(
|
||||
input.workspaces.map(({ name }) => [name, `${repo.id}::${repoPath}/${name}`])
|
||||
)
|
||||
mutateStoppedProfileState(userDataDir, (state) => {
|
||||
state.repos = [...(Array.isArray(state.repos) ? state.repos : []), repo]
|
||||
state.worktreeMeta = {
|
||||
...(state.worktreeMeta && typeof state.worktreeMeta === 'object' ? state.worktreeMeta : {}),
|
||||
...Object.fromEntries(
|
||||
input.workspaces.map(({ name, displayName }) => [
|
||||
worktreeIds[name],
|
||||
{ hostId, instanceId: `${name}-instance`, displayName }
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
return { targetId, hostId, repoId: repo.id, worktreeIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* A desktop with the local test repo and an unreachable SSH host's seeded workspaces, both held by
|
||||
* main as a user's would be. `dispose` closes the app and removes the profile.
|
||||
*/
|
||||
export async function launchWithUnreachableSshWorkspaces(
|
||||
testInfo: TestInfo,
|
||||
localRepoPath: string,
|
||||
input: Parameters<typeof seedUnreachableSshWorkspaces>[1]
|
||||
): Promise<{
|
||||
page: Page
|
||||
ssh: SeededSshWorkspaces
|
||||
/** The local test repo's primary workspace. */
|
||||
localWorktreeId: string
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const session = createRestartSession(testInfo)
|
||||
let app: ElectronApplication | undefined
|
||||
try {
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
await waitForSessionReady(first.page)
|
||||
const localWorktreeId = await attachRepoAndOpenTerminal(first.page, localRepoPath)
|
||||
await session.close(app)
|
||||
app = undefined
|
||||
const ssh = seedUnreachableSshWorkspaces(session.userDataDir, input)
|
||||
const launched = await session.launch()
|
||||
app = launched.app
|
||||
await waitForSessionReady(launched.page)
|
||||
await waitForStartupWorktreeRefresh(launched.page)
|
||||
const seededIds = Object.values(ssh.worktreeIds)
|
||||
await expect
|
||||
.poll(() =>
|
||||
launched.page.evaluate((ids) => {
|
||||
const listed = new Set(
|
||||
Object.values(window.__store!.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
)
|
||||
return ids.every((id) => listed.has(id))
|
||||
}, seededIds)
|
||||
)
|
||||
.toBe(true)
|
||||
const running = app
|
||||
return { page: launched.page, ssh, localWorktreeId, dispose: () => session.dispose(running) }
|
||||
} catch (error) {
|
||||
await session.dispose(app)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady,
|
||||
waitForStartupWorktreeRefresh
|
||||
} from './helpers/store'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import { launchPairedElectronClient } from './helpers/paired-electron-client'
|
||||
import { attachRepoAndOpenTerminal } from './helpers/orca-restart'
|
||||
import { evaluatePolled } from './helpers/page-evaluate-polled'
|
||||
import { launchWithUnreachableSshWorkspaces } from './helpers/seeded-ssh-workspaces'
|
||||
import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store'
|
||||
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../src/shared/execution-host'
|
||||
|
||||
const sections = [
|
||||
{ groupBy: 'none', key: 'pinned', label: 'Pinned', pinned: true },
|
||||
@@ -17,163 +23,245 @@ const sections = [
|
||||
{ groupBy: 'pr-status', key: 'pr:in-progress', label: 'In progress', pinned: false }
|
||||
] as const
|
||||
|
||||
for (const section of sections) {
|
||||
test(`reveals ${section.key} workspaces without expanding another host's section`, async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await waitForStartupWorktreeRefresh(orcaPage)
|
||||
for (const hostId of ['ssh:reveal-builder', 'runtime:reveal-builder'] as const) {
|
||||
for (const collapsedHost of [false, true]) {
|
||||
const scenario = `${hostId.split(':')[0]}-collapsed-${collapsedHost}`
|
||||
await test.step(scenario, async () => {
|
||||
const ids = await orcaPage.evaluate(
|
||||
async ({ hostId, collapsedHost, section }) => {
|
||||
const store = window.__store!
|
||||
const state = store.getState()
|
||||
const sourceRepo = state.repos[0]
|
||||
const sourceWorktree = state.worktreesByRepo[sourceRepo.id][0]
|
||||
await state.updateWorktreeMeta(sourceWorktree.id, {
|
||||
isPinned: section.pinned,
|
||||
displayName: 'Local workspace'
|
||||
})
|
||||
const local = {
|
||||
...sourceWorktree,
|
||||
hostId: 'local' as const,
|
||||
isPinned: section.pinned,
|
||||
displayName: 'Local workspace'
|
||||
}
|
||||
const remoteRepo = {
|
||||
...sourceRepo,
|
||||
id: 'reveal-remote-repo',
|
||||
executionHostId: hostId,
|
||||
connectionId: hostId.startsWith('ssh:') ? 'reveal-builder' : null,
|
||||
displayName: 'Remote project'
|
||||
}
|
||||
const remote = {
|
||||
...sourceWorktree,
|
||||
id: 'reveal-remote-workspace',
|
||||
repoId: remoteRepo.id,
|
||||
hostId,
|
||||
isPinned: section.pinned,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Remote workspace'
|
||||
}
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
await window.api.ui.set({ groupBy: section.groupBy })
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
const collapsedGroups = [
|
||||
section.key,
|
||||
`${section.key}:host:${hostId}`,
|
||||
...(collapsedHost ? [`host:${hostId}`] : [])
|
||||
]
|
||||
await window.api.ui.set({ collapsedGroups })
|
||||
store.setState({
|
||||
groupBy: section.groupBy,
|
||||
repos: [sourceRepo, remoteRepo],
|
||||
worktreesByRepo: { [sourceRepo.id]: [local], [remoteRepo.id]: [remote] },
|
||||
sshTargetLabels: new Map([['reveal-builder', 'Remote builder']]),
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'reveal-builder',
|
||||
{
|
||||
targetId: 'reveal-builder',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
]
|
||||
]),
|
||||
visibleWorkspaceHostIds: ['local', hostId],
|
||||
workspaceHostScope: 'all',
|
||||
activeRepoId: remoteRepo.id,
|
||||
activeWorktreeId: remote.id,
|
||||
activeWorkspaceKey: `worktree:${remote.id}`,
|
||||
activeWorkspaceExecutionHostId: hostId,
|
||||
pendingRevealWorktree: null,
|
||||
collapsedGroups: new Set(collapsedGroups)
|
||||
})
|
||||
return { local: local.id, remote: remote.id }
|
||||
},
|
||||
{ hostId, collapsedHost, section }
|
||||
)
|
||||
const headers = orcaPage.getByRole('button', { name: new RegExp(`^${section.label}`) })
|
||||
const localRow = orcaPage.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.local)}]`
|
||||
)
|
||||
const remoteRow = orcaPage.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.remote)}]`
|
||||
)
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
await expect(localRow).toHaveCount(0)
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath(`${scenario}-before-reveal.png`),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click()
|
||||
await expect(remoteRow).toBeVisible()
|
||||
await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath(`${scenario}-after-reveal.png`),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await expect(headers).toHaveCount(2)
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.last()).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
await expect(localRow).toHaveCount(0)
|
||||
|
||||
await headers.last().click()
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
await orcaPage.evaluate((localId) => {
|
||||
const store = window.__store!
|
||||
const local = Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === localId)!
|
||||
store.setState({
|
||||
activeRepoId: local.repoId,
|
||||
activeWorktreeId: localId,
|
||||
activeWorkspaceKey: `worktree:${localId}`,
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
pendingRevealWorktree: null
|
||||
})
|
||||
}, ids.local)
|
||||
await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click()
|
||||
await expect(localRow).toBeVisible()
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(headers.last()).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
// Finish the reveal and its persistence before resetting the next scenario.
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(async () => ({
|
||||
pendingRevealWorktree: window.__store!.getState().pendingRevealWorktree,
|
||||
collapsedGroups: [...window.__store!.getState().collapsedGroups].sort(),
|
||||
persistedCollapsedGroups: (await window.api.ui.get()).collapsedGroups?.sort()
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
pendingRevealWorktree: null,
|
||||
collapsedGroups: [`${section.key}:host:${hostId}`],
|
||||
persistedCollapsedGroups: [`${section.key}:host:${hostId}`]
|
||||
})
|
||||
await expect(localRow).toBeVisible()
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
})
|
||||
/** Pins through main and waits for its answer. */
|
||||
async function pinWorkspace(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
isPinned: boolean,
|
||||
executionHostId: ExecutionHostId
|
||||
): Promise<void> {
|
||||
await evaluatePolled(
|
||||
page,
|
||||
async (args) => {
|
||||
const written = await window.__store!.getState().updateWorktreeMeta(
|
||||
args.worktreeId,
|
||||
{ isPinned: args.isPinned },
|
||||
{
|
||||
executionHostId: args.executionHostId
|
||||
}
|
||||
)
|
||||
if (!written.ok) {
|
||||
throw new Error(`Pinning failed: ${written.error}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ worktreeId, isPinned, executionHostId }
|
||||
)
|
||||
}
|
||||
|
||||
/** A desktop whose main holds a local workspace and one on another host, as a user's would. */
|
||||
type RevealTopology = {
|
||||
page: Page
|
||||
local: string
|
||||
remote: string
|
||||
remoteHostId: ExecutionHostId
|
||||
setRemotePinned: (pinned: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
async function expectRevealsWithinHost(
|
||||
{ page, local, remote, remoteHostId, setRemotePinned }: RevealTopology,
|
||||
testInfo: TestInfo
|
||||
): Promise<void> {
|
||||
for (const section of sections) {
|
||||
for (const collapsedHost of [false, true]) {
|
||||
const scenario = `${section.key}-collapsed-${collapsedHost}`
|
||||
await test.step(scenario, async () => {
|
||||
await setRemotePinned(section.pinned)
|
||||
await pinWorkspace(page, local, section.pinned, 'local')
|
||||
// The window must show both pins before the reveal, or the row opens in the other section.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(ids) =>
|
||||
Object.values(window.__store!.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.filter((worktree) => ids.includes(worktree.id))
|
||||
.map((worktree) => worktree.isPinned),
|
||||
[local, remote]
|
||||
)
|
||||
)
|
||||
.toEqual([section.pinned, section.pinned])
|
||||
const collapsedGroups = [
|
||||
section.key,
|
||||
`${section.key}:host:${remoteHostId}`,
|
||||
...(collapsedHost ? [`host:${remoteHostId}`] : [])
|
||||
]
|
||||
await evaluatePolled(
|
||||
page,
|
||||
async ({ remote, remoteHostId, collapsedGroups, section }) => {
|
||||
const store = window.__store!
|
||||
const state = store.getState()
|
||||
const remoteRepoId = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === remote)!.repoId
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
await window.api.ui.set({ groupBy: section.groupBy, collapsedGroups })
|
||||
store.setState({
|
||||
groupBy: section.groupBy,
|
||||
visibleWorkspaceHostIds: ['local', remoteHostId],
|
||||
workspaceHostScope: 'all',
|
||||
activeRepoId: remoteRepoId,
|
||||
activeWorktreeId: remote,
|
||||
activeWorkspaceKey: `worktree:${remote}`,
|
||||
activeWorkspaceExecutionHostId: remoteHostId,
|
||||
pendingRevealWorktree: null,
|
||||
collapsedGroups: new Set(collapsedGroups)
|
||||
})
|
||||
},
|
||||
{ remote, remoteHostId, collapsedGroups, section }
|
||||
)
|
||||
const headers = page.getByRole('button', { name: new RegExp(`^${section.label}`) })
|
||||
const localRow = page.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(local)}]`
|
||||
)
|
||||
const remoteRow = page.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(remote)}]`
|
||||
)
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
await expect(localRow).toHaveCount(0)
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${scenario}-before-reveal.png`),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await page.getByRole('button', { name: 'Reveal active workspace' }).click()
|
||||
await expect(remoteRow).toBeVisible()
|
||||
await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${scenario}-after-reveal.png`),
|
||||
clip: { x: 0, y: 170, width: 280, height: 500 }
|
||||
})
|
||||
await expect(headers).toHaveCount(2)
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(headers.last()).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
await expect(localRow).toHaveCount(0)
|
||||
|
||||
await headers.last().click()
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
await page.evaluate((localId) => {
|
||||
const store = window.__store!
|
||||
const worktree = Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === localId)!
|
||||
store.setState({
|
||||
activeRepoId: worktree.repoId,
|
||||
activeWorktreeId: localId,
|
||||
activeWorkspaceKey: `worktree:${localId}`,
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
pendingRevealWorktree: null
|
||||
})
|
||||
}, local)
|
||||
await page.getByRole('button', { name: 'Reveal active workspace' }).click()
|
||||
await expect(localRow).toBeVisible()
|
||||
if (section.pinned || section.groupBy === 'workspace-status') {
|
||||
await expect(headers.first()).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(headers.last()).toHaveAttribute('aria-expanded', 'false')
|
||||
}
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
// Finish the reveal and its persistence before resetting the next scenario.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => ({
|
||||
pendingRevealWorktree: window.__store!.getState().pendingRevealWorktree,
|
||||
collapsedGroups: [...window.__store!.getState().collapsedGroups].sort(),
|
||||
persistedCollapsedGroups: (await window.api.ui.get()).collapsedGroups?.sort()
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
pendingRevealWorktree: null,
|
||||
collapsedGroups: [`${section.key}:host:${remoteHostId}`],
|
||||
persistedCollapsedGroups: [`${section.key}:host:${remoteHostId}`]
|
||||
})
|
||||
await expect(localRow).toBeVisible()
|
||||
await expect(remoteRow).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("reveals an unreachable SSH host's workspaces without expanding another host's section", async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(4 * 60_000)
|
||||
const launched = await launchWithUnreachableSshWorkspaces(testInfo, testRepoPath, {
|
||||
label: 'Remote builder',
|
||||
workspaces: [{ name: 'feature', displayName: 'Remote workspace' }]
|
||||
})
|
||||
try {
|
||||
const { page, ssh, localWorktreeId } = launched
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
const remote = ssh.worktreeIds.feature!
|
||||
await expectRevealsWithinHost(
|
||||
{
|
||||
page,
|
||||
local: localWorktreeId,
|
||||
remote,
|
||||
remoteHostId: ssh.hostId,
|
||||
setRemotePinned: (isPinned) => pinWorkspace(page, remote, isPinned, ssh.hostId)
|
||||
},
|
||||
testInfo
|
||||
)
|
||||
} finally {
|
||||
await launched.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reveals a paired server's workspaces without expanding another host's section", async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(4 * 60_000)
|
||||
const serverRepo = testInfo.outputPath('server-repo')
|
||||
mkdirSync(serverRepo, { recursive: true })
|
||||
writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n')
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
|
||||
git('init', '-b', 'main')
|
||||
git('add', 'README.md')
|
||||
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
|
||||
|
||||
const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'runtime' })
|
||||
try {
|
||||
const desktop = await launchPairedElectronClient(host.offer, testInfo, 'reveal-host-sections')
|
||||
try {
|
||||
const { page } = desktop
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await waitForSessionReady(page)
|
||||
const local = await attachRepoAndOpenTerminal(page, testRepoPath)
|
||||
await waitForStartupWorktreeRefresh(page)
|
||||
const added = await host.client.call<{ repo: { id: string } }>('repo.add', {
|
||||
path: serverRepo,
|
||||
kind: 'git'
|
||||
})
|
||||
const created = await host.client.call<{ worktree: { id: string } }>('worktree.create', {
|
||||
repo: `id:${added.result.repo.id}`,
|
||||
name: 'feature',
|
||||
displayName: 'Remote workspace',
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
const remote = created.result.worktree.id
|
||||
const remoteHostId = toRuntimeExecutionHostId(desktop.environmentId)
|
||||
await expectRevealsWithinHost(
|
||||
{
|
||||
page,
|
||||
local,
|
||||
remote,
|
||||
remoteHostId,
|
||||
setRemotePinned: async (isPinned) => {
|
||||
await host.client.call('worktree.set', { worktree: `id:${remote}`, isPinned })
|
||||
}
|
||||
},
|
||||
testInfo
|
||||
)
|
||||
} finally {
|
||||
await desktop.dispose()
|
||||
}
|
||||
} finally {
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user