mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +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>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
import type { Locator, Page } from '@stablyai/playwright-test'
|
|
import { mkdirSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import { addRepoWithWorkspaces, linkWorkspaces } from './helpers/repo-with-workspaces'
|
|
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build'
|
|
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import {
|
|
markWorkspaceTerminalSlept,
|
|
seedLineageScenario,
|
|
seedWorkspaceAgentStatus,
|
|
seedWorkspaceLiveTerminal
|
|
} from './worktree-lineage-state'
|
|
import { worktreeRow } from './worktree-row-locators'
|
|
|
|
function worktreeOption(page: Page, worktreeId: string) {
|
|
return worktreeRow(page, worktreeId)
|
|
}
|
|
|
|
async function captureEvidence(page: Page, name: string, locator?: Locator): Promise<void> {
|
|
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
|
|
return
|
|
}
|
|
const outputDir = resolve(process.cwd(), 'pr-evidence')
|
|
mkdirSync(outputDir, { recursive: true })
|
|
const path = resolve(outputDir, name)
|
|
if (locator) {
|
|
await locator.screenshot({ path })
|
|
return
|
|
}
|
|
await page.screenshot({ path })
|
|
}
|
|
|
|
async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
|
|
await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first())
|
|
}
|
|
|
|
test.describe('Worktree Lineage', () => {
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeEach(async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
})
|
|
|
|
test('renders existing child lineage in the sidebar', async ({ orcaPage }) => {
|
|
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
|
const parentRow = worktreeOption(orcaPage, parentId)
|
|
const childRow = worktreeOption(orcaPage, childId)
|
|
|
|
await expect(parentRow).toBeVisible()
|
|
await parentRow.click()
|
|
await expect(parentRow).toHaveAttribute('aria-current', 'page')
|
|
|
|
await expect(childRow).toBeVisible()
|
|
const childToggle = parentRow.getByRole('button', { name: 'Hide 1 child workspace' })
|
|
await expect(childToggle).toBeVisible({ timeout: 10_000 })
|
|
await expect(childRow).toBeVisible()
|
|
|
|
const positions = await orcaPage.evaluate(
|
|
({ parentId, childId }) => {
|
|
const rowFor = (worktreeId: string) =>
|
|
[...document.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
|
(element) => element.dataset.worktreeId === worktreeId
|
|
)
|
|
const parent = rowFor(parentId)
|
|
const child = rowFor(childId)
|
|
if (!parent || !child) {
|
|
return null
|
|
}
|
|
return {
|
|
parentTop: parent.getBoundingClientRect().top,
|
|
childTop: child.getBoundingClientRect().top
|
|
}
|
|
},
|
|
{ parentId, childId }
|
|
)
|
|
expect(positions).not.toBeNull()
|
|
expect(positions!.childTop).toBeGreaterThan(positions!.parentTop)
|
|
|
|
await childToggle.click()
|
|
await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible()
|
|
await expect(childRow).toBeHidden()
|
|
|
|
await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click()
|
|
await orcaPage.evaluate(async (childId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
// Why: this test covers lineage row rendering. Clearing through the
|
|
// store keeps it focused on the render contract instead of nested
|
|
// context-menu hit testing.
|
|
await store.getState().updateWorktreeLineage(childId, { noParent: true })
|
|
}, childId)
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
orcaPage.evaluate((childId) => {
|
|
const store = window.__store
|
|
return Boolean(store?.getState().worktreeLineageById[childId])
|
|
}, childId),
|
|
{
|
|
timeout: 10_000,
|
|
message: 'Child lineage entry did not clear from the store'
|
|
}
|
|
)
|
|
.toBe(false)
|
|
await expect(childRow).toBeVisible()
|
|
})
|
|
|
|
test('renders legacy-only inline lineage when side-map hydration is absent', async ({
|
|
orcaPage
|
|
}) => {
|
|
test.skip(
|
|
SIDEBAR_INPUTS_BUILD,
|
|
"the window's legacy inline link is unused when rows come from main"
|
|
)
|
|
const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true })
|
|
const parentRow = worktreeOption(orcaPage, parentId)
|
|
const childRow = worktreeOption(orcaPage, childId)
|
|
|
|
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
|
|
await expect(childRow).toBeVisible()
|
|
await expect
|
|
.poll(async () => {
|
|
const [parentBox, childBox] = await Promise.all([
|
|
parentRow.boundingBox(),
|
|
childRow.boundingBox()
|
|
])
|
|
return parentBox && childBox ? childBox.y > parentBox.y : false
|
|
})
|
|
.toBe(true)
|
|
// The link must come from the records alone, not a side map hydrated after the seed.
|
|
expect(
|
|
await orcaPage.evaluate((id) => window.__store!.getState().worktreeLineageById[id], childId)
|
|
).toBeUndefined()
|
|
await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png')
|
|
})
|
|
|
|
test('injects filtered parents structurally without showing a parent badge', async ({
|
|
orcaPage
|
|
}) => {
|
|
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
|
|
|
await orcaPage.evaluate(
|
|
({ parentId, childId }) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
store.setState((current) => ({
|
|
worktreesByRepo: Object.fromEntries(
|
|
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
|
|
repoId,
|
|
repoWorktrees.map((worktree) =>
|
|
worktree.id === parentId
|
|
? {
|
|
...worktree,
|
|
branch: worktree.branch || 'refs/heads/main',
|
|
isMainWorktree: true
|
|
}
|
|
: worktree
|
|
)
|
|
])
|
|
)
|
|
}))
|
|
const state = store.getState()
|
|
state.setHideDefaultBranchWorkspace(true)
|
|
state.setShowActiveOnly(true)
|
|
state.setActiveWorktree(childId)
|
|
},
|
|
{ parentId, childId }
|
|
)
|
|
|
|
const parentRow = worktreeOption(orcaPage, parentId)
|
|
const childRow = worktreeOption(orcaPage, childId)
|
|
|
|
await expect(parentRow).toBeVisible()
|
|
await expect(childRow).toBeVisible()
|
|
await expect(childRow).not.toContainText(/\bfrom\b/)
|
|
|
|
const positions = await orcaPage.evaluate(
|
|
({ parentId, childId }) => {
|
|
const rowFor = (worktreeId: string) =>
|
|
[...document.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
|
(element) => element.dataset.worktreeId === worktreeId
|
|
)
|
|
const parent = rowFor(parentId)
|
|
const child = rowFor(childId)
|
|
if (!parent || !child) {
|
|
return null
|
|
}
|
|
return {
|
|
parentTop: parent.getBoundingClientRect().top,
|
|
childTop: child.getBoundingClientRect().top
|
|
}
|
|
},
|
|
{ parentId, childId }
|
|
)
|
|
expect(positions).not.toBeNull()
|
|
expect(positions!.childTop).toBeGreaterThan(positions!.parentTop)
|
|
})
|
|
|
|
test('updates nested child preview status when the child terminal sleeps', async ({
|
|
orcaPage
|
|
}) => {
|
|
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
|
const parentRow = worktreeOption(orcaPage, parentId)
|
|
const childRow = worktreeOption(orcaPage, childId)
|
|
|
|
await expect(parentRow).toBeVisible()
|
|
await expect(childRow).toBeVisible()
|
|
|
|
const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId)
|
|
await expect(childRow).toContainText('Active')
|
|
|
|
await markWorkspaceTerminalSlept(orcaPage, { worktreeId: childId, tabId: childTabId })
|
|
await expect(childRow).toContainText('Inactive')
|
|
})
|
|
|
|
test('sleeps a workspace and every descendant from the parent context menu', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
// A repo's own checkout offers no delete, so the parent is one of its worktrees.
|
|
const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 2)
|
|
const [parentId, childId] = [repo.ids[0]!, repo.ids[1]!]
|
|
await linkWorkspaces(orcaPage, [{ childId, parentId }])
|
|
await orcaPage.evaluate(() => window.__store!.getState().setShowSleepingWorkspaces(true))
|
|
const parentTabId = await seedWorkspaceLiveTerminal(orcaPage, parentId)
|
|
const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId)
|
|
|
|
await orcaPage.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is not available')
|
|
}
|
|
store.setState({
|
|
shutdownWorktreeBrowsers: async (worktreeId: string) => {
|
|
store.setState((current) => ({
|
|
browserTabsByWorktree: { ...current.browserTabsByWorktree, [worktreeId]: [] }
|
|
}))
|
|
},
|
|
shutdownWorktreeTerminals: async (worktreeId: string) => {
|
|
const tabIds = (store.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
|
|
store.setState((current) => ({
|
|
ptyIdsByTabId: {
|
|
...current.ptyIdsByTabId,
|
|
...Object.fromEntries(tabIds.map((tabId) => [tabId, []]))
|
|
}
|
|
}))
|
|
}
|
|
})
|
|
window.api.ephemeralVm.suspendWorkspace = async () => null
|
|
})
|
|
|
|
await worktreeOption(orcaPage, parentId).click({ button: 'right' })
|
|
const sleepSubtree = orcaPage.getByRole('menuitem', {
|
|
name: 'Sleep with Descendants (1)'
|
|
})
|
|
await expect(sleepSubtree).toBeVisible()
|
|
await expect(sleepSubtree).toBeEnabled()
|
|
await expect(orcaPage.getByRole('menuitem', { name: 'Delete with Descendants…' })).toBeVisible()
|
|
await captureEvidence(orcaPage, 'workspace-descendant-actions.png')
|
|
await sleepSubtree.click()
|
|
|
|
await expect
|
|
.poll(() =>
|
|
orcaPage.evaluate(
|
|
({ parentTabId, childTabId }) => {
|
|
const state = window.__store?.getState()
|
|
return {
|
|
parentPtys: state?.ptyIdsByTabId[parentTabId],
|
|
childPtys: state?.ptyIdsByTabId[childTabId]
|
|
}
|
|
},
|
|
{ parentTabId, childTabId }
|
|
)
|
|
)
|
|
.toEqual({ parentPtys: [], childPtys: [] })
|
|
})
|
|
|
|
test('shows parent and child agent rows while the parent workspace is active', async ({
|
|
orcaPage
|
|
}) => {
|
|
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
|
const parentRow = worktreeOption(orcaPage, parentId)
|
|
const childRow = worktreeOption(orcaPage, childId)
|
|
|
|
await parentRow.click()
|
|
await expect(parentRow).toHaveAttribute('aria-current', 'page')
|
|
await expect(childRow).toBeVisible()
|
|
|
|
const parentAgentPrompt = await seedWorkspaceAgentStatus(orcaPage, parentId, 'PARENT')
|
|
const childAgentPrompt = await seedWorkspaceAgentStatus(orcaPage, childId, 'CHILD')
|
|
|
|
await expect(
|
|
parentRow.getByRole('treeitem').filter({ hasText: parentAgentPrompt })
|
|
).toBeVisible()
|
|
await expect(childRow.getByRole('treeitem').filter({ hasText: childAgentPrompt })).toBeVisible()
|
|
})
|
|
})
|