mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* 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> * test(e2e): parent reorder spec creates its workspaces and links through main Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): lineage specs create their workspaces and links through main seedLineageScenario names and orders the two seeded workspaces and links them with assignWorktreeParent. Extra families and surrounding rows come from a fresh repo with real worktrees; pins and stale instances are main writes. Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): default-branch and downward-drag specs create their workspaces through main Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): reveal and active-delete specs create workspaces through main The 60-row reveal, the folder-workspace reveal and both active-delete tests now use real workspaces and folder workspaces added through main, ordered with metadata writes. The deletes go through the real removal path, so the row removal sampler waits by time rather than a frame count. Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): address review on the switch-on spec follow-up writeWorkspaceMeta now fails unless main lists the written values; the sleep test's parent is a real worktree instead of a store-faked one; the legacy inline-lineage case stays window-only and is skipped with the switch on; the parent-reorder collapse key is built from the row's host. Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): read-back reports an unlisted workspace and compares values structurally Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): wait for the added repo before showing its worktrees updateRepo returns false without writing when the store does not list the repo yet. On CI the add's own refresh superseded the helper's fetch, so the visibility write was skipped and every discovered worktree stayed hidden. The helper now waits until the store lists the repo and fails if the write is refused. Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): name the repo wait in its failure message Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): size the setup poll budget to the number of main writes evaluatePolled waited a fixed 10 s, sized for one write. Linking 53 children takes about 8 s locally and 36 folder workspaces about 4.5 s, so CI ran past it ("page work never settled"). Bulk setup now passes a budget of 10 s plus 500 ms per write. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import { mkdirSync, realpathSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { runProcess } from '@orca/process-host'
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import { evaluatePolled } from './helpers/page-evaluate-polled'
|
|
import {
|
|
addRepoWithWorkspaces,
|
|
bulkWriteTimeout,
|
|
writeWorkspaceMeta
|
|
} from './helpers/repo-with-workspaces'
|
|
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
|
|
async function prepareSidebarForScrollTest(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
|
|
const state = store.getState()
|
|
state.setActiveView('terminal')
|
|
state.setSidebarOpen(true)
|
|
state.setGroupBy('none')
|
|
state.setSortBy('recent')
|
|
state.setShowActiveOnly(false)
|
|
state.setShowSleepingWorkspaces(true)
|
|
state.setHideDefaultBranchWorkspace(false)
|
|
state.setFilterRepoIds([])
|
|
})
|
|
}
|
|
|
|
test.describe('Reveal active workspace button', () => {
|
|
test.beforeEach(async ({ orcaPage }) => {
|
|
// Why: headless Electron under xvfb never ticks a smooth-scroll animation,
|
|
// so the reveal's `scrollTo({ behavior: 'smooth' })` would never reach its
|
|
// target. Reduced-motion makes the reveal jump instantly (see
|
|
// worktree-sidebar-reveal.ts) so the geometry assertions are deterministic.
|
|
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
})
|
|
|
|
// Note: the "clipped in the production sidebar" pixel-containment test was
|
|
// removed — it forced a ~44px synthetic viewport and asserted ±1px scroll
|
|
// precision, which the row virtualizer can't guarantee under CI saturation
|
|
// (not a scenario real users hit). Reveal-into-view is covered robustly by
|
|
// the "outside the virtualized window" test below.
|
|
|
|
test('adjusts sidebar filters before revealing a hidden current workspace', async ({
|
|
orcaPage,
|
|
testRepoPath
|
|
}, testInfo) => {
|
|
const filterRepoPath = testInfo.outputPath('filter-repo')
|
|
mkdirSync(filterRepoPath, { recursive: true })
|
|
for (const args of [
|
|
['init', filterRepoPath],
|
|
[
|
|
'-C',
|
|
filterRepoPath,
|
|
'-c',
|
|
'user.name=E2E',
|
|
'-c',
|
|
'user.email=e2e@test.local',
|
|
'commit',
|
|
'--allow-empty',
|
|
'-m',
|
|
'Filter fixture'
|
|
]
|
|
]) {
|
|
const result = await runProcess({ program: 'git', args })
|
|
expect(result.code, result.stderr).toBe(0)
|
|
}
|
|
const filterRepoId = await orcaPage.evaluate(async (repoPath) => {
|
|
const result = await window.api.repos.add({ path: repoPath })
|
|
if ('error' in result) {
|
|
throw new Error(result.error)
|
|
}
|
|
return result.repo.id
|
|
}, filterRepoPath)
|
|
await expect
|
|
.poll(() =>
|
|
orcaPage.evaluate(async (id) => {
|
|
await window
|
|
.__store!.getState()
|
|
.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true })
|
|
return window.__store!.getState().repos.some((repo) => repo.id === id)
|
|
}, filterRepoId)
|
|
)
|
|
.toBe(true)
|
|
await prepareSidebarForScrollTest(orcaPage)
|
|
|
|
// Other specs can add worktrees to the shared repository before this test runs.
|
|
const targetId = await orcaPage.evaluate((repoPath) => {
|
|
const state = window.__store!.getState()
|
|
const repo = state.repos.find((candidate) => candidate.path === repoPath)
|
|
return repo
|
|
? state.worktreesByRepo[repo.id]?.find(
|
|
(worktree) => worktree.branch === 'refs/heads/e2e-secondary'
|
|
)?.id
|
|
: undefined
|
|
}, testRepoPath)
|
|
if (!targetId) {
|
|
throw new Error('Seeded secondary worktree is missing')
|
|
}
|
|
const targetRepoId = await orcaPage.evaluate(
|
|
(repoPath) => window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id,
|
|
testRepoPath
|
|
)
|
|
if (!targetRepoId) {
|
|
throw new Error('Seeded repository is missing')
|
|
}
|
|
|
|
const targetRows = orcaPage.locator(
|
|
`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`
|
|
)
|
|
const targetRow = targetRows.first()
|
|
await expect(targetRows.and(orcaPage.getByRole('option'))).toHaveCount(1)
|
|
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
|
|
|
|
await orcaPage.evaluate((targetId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
const state = store.getState()
|
|
const target = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.find((worktree) => worktree.id === targetId)
|
|
if (!target) {
|
|
throw new Error(`Target workspace ${targetId} not found`)
|
|
}
|
|
store.setState({
|
|
activeRepoId: target.repoId,
|
|
activeWorktreeId: target.id,
|
|
activeWorkspaceKey: `worktree:${target.id}`,
|
|
pendingRevealWorktree: null
|
|
})
|
|
}, targetId)
|
|
await expect(targetRow).toHaveAttribute('aria-current', 'page')
|
|
|
|
// Catalog refreshes prune nonexistent IDs, so use a real repo to keep the filter applied.
|
|
await orcaPage.evaluate((repoId) => {
|
|
window.__store!.getState().setFilterRepoIds([repoId])
|
|
}, filterRepoId)
|
|
await expect(targetRows).toHaveCount(0)
|
|
|
|
await revealButton.click()
|
|
await orcaPage
|
|
.getByRole('dialog', { name: 'Reveal hidden workspace?' })
|
|
.getByRole('button', { name: 'Adjust filters and reveal' })
|
|
.click()
|
|
|
|
await expect(targetRow).toBeVisible()
|
|
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
return store.getState().filterRepoIds
|
|
}),
|
|
{
|
|
timeout: 10_000,
|
|
message:
|
|
'Reveal button should preserve selected repos while revealing the current workspace'
|
|
}
|
|
)
|
|
.toEqual([filterRepoId, targetRepoId])
|
|
})
|
|
|
|
test('reveals the current workspace when it starts outside the virtualized window', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
await prepareSidebarForScrollTest(orcaPage)
|
|
|
|
const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 60)
|
|
await writeWorkspaceMeta(
|
|
orcaPage,
|
|
repo.ids.map((worktreeId, index) => ({
|
|
worktreeId,
|
|
updates: {
|
|
displayName: `Virtual reveal ${String(index).padStart(2, '0')}`,
|
|
sortOrder: 10_000 - index,
|
|
manualOrder: 10_000 - index
|
|
}
|
|
}))
|
|
)
|
|
const targetId = repo.ids.at(-1)!
|
|
await orcaPage.evaluate(
|
|
({ repoId, targetId }) => {
|
|
window.__store!.setState({
|
|
activeRepoId: repoId,
|
|
activeWorktreeId: targetId,
|
|
activeWorkspaceKey: `worktree:${targetId}`,
|
|
pendingRevealWorktree: null,
|
|
sortBy: 'manual'
|
|
})
|
|
},
|
|
{ repoId: repo.repoId, targetId }
|
|
)
|
|
|
|
const scroller = orcaPage.locator('[data-worktree-sidebar]')
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate(() => {
|
|
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
|
return scroller?.scrollTop ?? null
|
|
}),
|
|
{ timeout: 10_000, message: 'sidebar scroller did not mount' }
|
|
)
|
|
.not.toBeNull()
|
|
|
|
await scroller.evaluate((element) => {
|
|
element.scrollTop = 0
|
|
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
|
})
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate((targetId) => {
|
|
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
|
const target = [...document.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
|
(candidate) => candidate.dataset.worktreeId === targetId
|
|
)
|
|
if (!scroller || !target) {
|
|
return false
|
|
}
|
|
const scrollerBounds = scroller.getBoundingClientRect()
|
|
const targetBounds = target.getBoundingClientRect()
|
|
return (
|
|
targetBounds.top >= scrollerBounds.top - 1 &&
|
|
targetBounds.bottom <= scrollerBounds.bottom + 1
|
|
)
|
|
}, targetId),
|
|
{ timeout: 10_000, message: 'target workspace should start outside the sidebar viewport' }
|
|
)
|
|
.toBe(false)
|
|
|
|
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
|
|
await expect(revealButton).toBeVisible()
|
|
await expect(revealButton).toBeEnabled()
|
|
|
|
await revealButton.click()
|
|
const targetRow = orcaPage
|
|
.locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`)
|
|
.first()
|
|
await expect(targetRow).toBeVisible()
|
|
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
|
})
|
|
|
|
test('uses the active workspace key when the legacy active worktree id is not set', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
await prepareSidebarForScrollTest(orcaPage)
|
|
|
|
// The oldest of 36 folder workspaces sorts last under Recent.
|
|
const groupPath = testInfo.outputPath('reveal-folder-group')
|
|
const folderPaths = Array.from({ length: 36 }, (_, index) => {
|
|
const folderPath = path.join(groupPath, `folder-${String(index).padStart(2, '0')}`)
|
|
mkdirSync(folderPath, { recursive: true })
|
|
return realpathSync(folderPath)
|
|
})
|
|
const folderWorktreeId = await evaluatePolled(
|
|
orcaPage,
|
|
async ({ parentPath, folderPaths }) => {
|
|
const store = window.__store!
|
|
const group = await window.api.projectGroups.create({
|
|
name: 'Reveal folder group',
|
|
parentPath,
|
|
createdFrom: 'manual'
|
|
})
|
|
await store
|
|
.getState()
|
|
.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true })
|
|
const ids: string[] = []
|
|
for (const [index, folderPath] of folderPaths.entries()) {
|
|
const folder = await store.getState().createFolderWorkspace(
|
|
{
|
|
projectGroupId: group.id,
|
|
name: `Reveal folder workspace ${String(index).padStart(2, '0')}`,
|
|
folderPath
|
|
},
|
|
{ runtimeEnvironmentId: null }
|
|
)
|
|
if (!folder) {
|
|
throw new Error(`Folder workspace ${index} was not created`)
|
|
}
|
|
ids.push(folder.id)
|
|
}
|
|
const targetKey = `folder:${ids[0]}` as const
|
|
store.setState({
|
|
activeRepoId: null,
|
|
activeWorktreeId: null,
|
|
activeWorkspaceKey: targetKey
|
|
})
|
|
store.getState().setGroupBy('repo')
|
|
return targetKey
|
|
},
|
|
{ parentPath: realpathSync(groupPath), folderPaths },
|
|
{ timeout: bulkWriteTimeout(folderPaths.length) }
|
|
)
|
|
|
|
const scroller = orcaPage.locator('[data-worktree-sidebar]')
|
|
await scroller.evaluate((element) => {
|
|
element.scrollTop = 0
|
|
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
|
})
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate((targetId) => {
|
|
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
|
const target = [...document.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
|
(candidate) => candidate.dataset.worktreeId === targetId
|
|
)
|
|
if (!scroller || !target) {
|
|
return false
|
|
}
|
|
const scrollerBounds = scroller.getBoundingClientRect()
|
|
const targetBounds = target.getBoundingClientRect()
|
|
return (
|
|
targetBounds.top >= scrollerBounds.top - 1 &&
|
|
targetBounds.bottom <= scrollerBounds.bottom + 1
|
|
)
|
|
}, folderWorktreeId),
|
|
{ timeout: 10_000, message: 'target folder workspace should start outside the viewport' }
|
|
)
|
|
.toBe(false)
|
|
|
|
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
|
|
await expect(revealButton).toBeVisible()
|
|
await expect(revealButton).toBeEnabled()
|
|
await revealButton.click()
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate((targetId) => {
|
|
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
|
const target = [...document.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
|
(candidate) => candidate.dataset.worktreeId === targetId
|
|
)
|
|
if (!scroller || !target) {
|
|
return false
|
|
}
|
|
const scrollerBounds = scroller.getBoundingClientRect()
|
|
const targetBounds = target.getBoundingClientRect()
|
|
return (
|
|
targetBounds.top >= scrollerBounds.top - 1 &&
|
|
targetBounds.bottom <= scrollerBounds.bottom + 1
|
|
)
|
|
}, folderWorktreeId),
|
|
{
|
|
timeout: 10_000,
|
|
message: 'Reveal button should scroll to the active folder workspace'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
})
|
|
})
|