From 0c3807e73b19de7f90a474ffdb1343b050555650 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:10:40 -0400 Subject: [PATCH] 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 * test(e2e): type the seeded hosts as execution host ids Co-Authored-By: Claude * 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 --------- Co-authored-by: Claude --- tests/e2e/agent-send-picker-reveal.spec.ts | 422 ++++++++---------- tests/e2e/helpers/page-evaluate-polled.ts | 39 ++ tests/e2e/helpers/seeded-ssh-workspaces.ts | 108 +++++ .../e2e/worktree-reveal-host-sections.spec.ts | 414 ++++++++++------- 4 files changed, 579 insertions(+), 404 deletions(-) create mode 100644 tests/e2e/helpers/page-evaluate-polled.ts create mode 100644 tests/e2e/helpers/seeded-ssh-workspaces.ts diff --git a/tests/e2e/agent-send-picker-reveal.spec.ts b/tests/e2e/agent-send-picker-reveal.spec.ts index 07468786cf5..14ac4be513a 100644 --- a/tests/e2e/agent-send-picker-reveal.spec.ts +++ b/tests/e2e/agent-send-picker-reveal.spec.ts @@ -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) }) } } diff --git a/tests/e2e/helpers/page-evaluate-polled.ts b/tests/e2e/helpers/page-evaluate-polled.ts new file mode 100644 index 00000000000..1c166ddb0d4 --- /dev/null +++ b/tests/e2e/helpers/page-evaluate-polled.ts @@ -0,0 +1,39 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +type Outcome = + | { 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( + page: Page, + run: (args: Args) => Promise, + args: Args +): Promise { + 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>(`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 +} diff --git a/tests/e2e/helpers/seeded-ssh-workspaces.ts b/tests/e2e/helpers/seeded-ssh-workspaces.ts new file mode 100644 index 00000000000..98ed6c50f30 --- /dev/null +++ b/tests/e2e/helpers/seeded-ssh-workspaces.ts @@ -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 +} + +/** + * 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[1] +): Promise<{ + page: Page + ssh: SeededSshWorkspaces + /** The local test repo's primary workspace. */ + localWorktreeId: string + dispose: () => Promise +}> { + 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 + } +} diff --git a/tests/e2e/worktree-reveal-host-sections.spec.ts b/tests/e2e/worktree-reveal-host-sections.spec.ts index f3396af7426..f211143dce8 100644 --- a/tests/e2e/worktree-reveal-host-sections.spec.ts +++ b/tests/e2e/worktree-reveal-host-sections.spec.ts @@ -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 { + 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 +} + +async function expectRevealsWithinHost( + { page, local, remote, remoteHostId, setRemotePinned }: RevealTopology, + testInfo: TestInfo +): Promise { + 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() + } +})