diff --git a/config/e2e-typecheck-baseline.txt b/config/e2e-typecheck-baseline.txt index b3d3be0879b..e22e466ed32 100644 --- a/config/e2e-typecheck-baseline.txt +++ b/config/e2e-typecheck-baseline.txt @@ -118,8 +118,6 @@ 2 tests/e2e/terminal-tui-wheel-reports.spec.ts 1 tests/e2e/terminal-warm-reattach-split-live-output.spec.ts 1 tests/e2e/workspace-space-git-status.spec.ts -1 tests/e2e/worktree-parent-reorder.spec.ts -1 tests/e2e/worktree-scroll-to-current.spec.ts 1 tests/tools/omp-child-history-rendered/fixture.tsx 1 tests/tools/omp-fresh-launch-windows-smoke.ts 1 tests/tools/omp-remote-sidebar-rendered/fixture.tsx diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts index 166dc419431..21c3794bba4 100644 --- a/tests/e2e/default-branch-visibility.spec.ts +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -4,9 +4,10 @@ * while a sleeping feature workspace is still swept. */ -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' +import { waitForSessionReady } from './helpers/store' import { worktreeRow } from './worktree-row-locators' type SidebarVisibilityScenario = { @@ -14,92 +15,45 @@ type SidebarVisibilityScenario = { featureId: string } -async function seedSidebarVisibilityScenario(page: Page): Promise { - return page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Sidebar visibility E2E needs a seeded repo') - } - - const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0] - if (!currentWorktree) { - throw new Error('Sidebar visibility E2E needs a seeded worktree') - } - - const defaultBranchId = 'e2e-default-branch-visibility-main' - const featureId = 'e2e-default-branch-visibility-feature' - const currentId = currentWorktree.id - - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: [ - { - ...currentWorktree, - id: currentId, - displayName: 'Current workspace', - isMainWorktree: false, - branch: 'refs/heads/current', - lastActivityAt: 3 - }, - { - ...currentWorktree, - id: defaultBranchId, - displayName: 'Default branch workspace', - isMainWorktree: true, - branch: 'refs/heads/main', - lastActivityAt: 2 - }, - { - ...currentWorktree, - id: featureId, - displayName: 'Feature workspace', - isMainWorktree: false, - branch: 'refs/heads/feature', - lastActivityAt: 1 - } - ] - }, - tabsByWorktree: { - ...current.tabsByWorktree, - [defaultBranchId]: [], - [featureId]: [] - }, - browserTabsByWorktree: { - ...current.browserTabsByWorktree, - [defaultBranchId]: [], - [featureId]: [] - } - })) - - const nextState = store.getState() - nextState.setActiveView('terminal') - nextState.setSidebarOpen(true) - nextState.setGroupBy('none') - nextState.setSortBy('recent') - nextState.setShowActiveOnly(false) - nextState.setFilterRepoIds([]) - - return { defaultBranchId, featureId } - }) +async function seedSidebarVisibilityScenario( + page: Page, + testInfo: TestInfo +): Promise { + // The repo's own checkout is its default branch workspace. + const repo = await addRepoWithWorkspaces(page, testInfo, 2) + const [currentId, featureId] = repo.ids + await writeWorkspaceMeta(page, [ + { worktreeId: currentId!, updates: { displayName: 'Current workspace', lastActivityAt: 3 } }, + { + worktreeId: repo.mainId, + updates: { displayName: 'Default branch workspace', lastActivityAt: 2 } + }, + { worktreeId: featureId!, updates: { displayName: 'Feature workspace', lastActivityAt: 1 } } + ]) + await page.evaluate((currentId) => { + const state = window.__store!.getState() + state.setActiveWorktree(currentId) + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowActiveOnly(false) + state.setFilterRepoIds([]) + }, currentId!) + return { defaultBranchId: repo.mainId, featureId: featureId! } } +test.use({ seedTestRepo: false }) + test.describe('Default branch visibility', () => { test.beforeEach(async ({ orcaPage }) => { await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) }) test('keeps the default branch visible when sleeping workspaces are hidden', async ({ orcaPage - }) => { - const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage) + }, testInfo) => { + const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage, testInfo) const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId) const featureRow = worktreeRow(orcaPage, featureId) diff --git a/tests/e2e/helpers/page-evaluate-polled.ts b/tests/e2e/helpers/page-evaluate-polled.ts index 1c166ddb0d4..8e8032d6ce7 100644 --- a/tests/e2e/helpers/page-evaluate-polled.ts +++ b/tests/e2e/helpers/page-evaluate-polled.ts @@ -9,11 +9,12 @@ type Outcome = let nextKey = 0 /** page.evaluate whose outcome is polled: awaiting a preload promise across CDP can be garbage-collected. - * `args` cross as JSON. */ + * `args` cross as JSON; bulk work passes a `timeout` that grows with its size. */ export async function evaluatePolled( page: Page, run: (args: Args) => Promise, - args: Args + args: Args, + options: { timeout?: number } = {} ): Promise { nextKey += 1 const key = JSON.stringify(`__e2eEvaluatePolled${nextKey}`) @@ -29,7 +30,10 @@ export async function evaluatePolled( })()`) const read = () => page.evaluate>(`globalThis[${key}]`) await expect - .poll(async () => (await read()).state, { message: 'page work never settled' }) + .poll(async () => (await read()).state, { + message: 'page work never settled', + timeout: options.timeout + }) .not.toBe('pending') const outcome = await read() if (outcome.state !== 'done') { diff --git a/tests/e2e/helpers/repo-with-workspaces.ts b/tests/e2e/helpers/repo-with-workspaces.ts new file mode 100644 index 00000000000..528b8a8dc2c --- /dev/null +++ b/tests/e2e/helpers/repo-with-workspaces.ts @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, realpathSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import type { WorktreeMeta } from '../../../src/shared/worktree/meta-types' +import { expect } from './orca-app' +import { evaluatePolled } from './page-evaluate-polled' +import { loadWorktreesUntilPathsPresent } from './worktree-registration' + +/** Poll budget for setup that makes one main write per item. */ +export function bulkWriteTimeout(items: number): number { + return 10_000 + items * 500 +} + +export type RepoWithWorkspaces = { + repoId: string + /** The repo's own checkout, listed as its main workspace. */ + mainId: string + /** One workspace id per requested count, in order. */ + ids: string[] +} + +/** + * A fresh git repo with `count` worktrees, added through main as a user adds a project that already + * has checkouts. Its worktrees are its own, so a spec never grows the worker's shared repo. + */ +export async function addRepoWithWorkspaces( + page: Page, + testInfo: TestInfo, + count: number +): Promise { + const root = testInfo.outputPath('repo-with-workspaces') + const repoPath = path.join(root, 'repo') + mkdirSync(repoPath, { recursive: true }) + const git = (...args: string[]) => execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) + git('init', '-b', 'main') + writeFileSync(path.join(repoPath, 'README.md'), 'workspaces\n') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + const realRoot = realpathSync(root) + const worktreePaths = Array.from({ length: count }, (_, index) => { + const worktreePath = path.join(realRoot, `workspace-${index}`) + git('worktree', 'add', '-q', '-b', `workspace-${index}`, worktreePath) + return worktreePath + }) + const realRepoPath = realpathSync(repoPath) + const repoId = await evaluatePolled( + page, + async (repoPath) => { + const added = await window.api.repos.add({ path: repoPath }) + if ('error' in added) { + throw new Error(added.error) + } + return added.repo.id + }, + realRepoPath + ) + // The add's own refresh can supersede the helper's fetch, and updateRepo skips a repo the store lacks. + await expect + .poll( + () => + page.evaluate(async (repoId) => { + const store = window.__store! + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + return store.getState().repos.some((repo) => repo.id === repoId) + }, repoId), + { message: 'the added repo never reached the store' } + ) + .toBe(true) + await evaluatePolled( + page, + async (repoId) => { + // Checkouts made outside Orca's workspace root are hidden by default. + const shown = await window + .__store!.getState() + .updateRepo(repoId, { externalWorktreeVisibility: 'show' }) + if (!shown) { + throw new Error(`could not show external worktrees for ${repoId}`) + } + }, + repoId + ) + await loadWorktreesUntilPathsPresent(page, repoId, [realRepoPath, ...worktreePaths]) + const idByPath = await page.evaluate( + (repoId) => + Object.fromEntries( + (window.__store!.getState().worktreesByRepo[repoId] ?? []).map((worktree) => [ + worktree.path, + worktree.id + ]) + ), + repoId + ) + return { + repoId, + mainId: idByPath[realRepoPath]!, + ids: worktreePaths.map((worktreePath) => idByPath[worktreePath]!) + } +} + +/** Writes workspace metadata through main, all at once, and fails unless main lists it. */ +export async function writeWorkspaceMeta( + page: Page, + updates: { worktreeId: string; updates: Partial }[] +): Promise { + await evaluatePolled( + page, + async (updates) => { + // The store keeps its own copy when main refuses, so check main's listing. + await window.__store!.getState().updateWorktreesMeta(updates) + const repoIds = [...new Set(updates.map(({ worktreeId }) => worktreeId.split('::')[0]!))] + const listed = new Map( + (await Promise.all(repoIds.map((repoId) => window.api.worktrees.list({ repoId })))) + .flat() + .map((worktree) => [worktree.id, worktree]) + ) + for (const { worktreeId, updates: written } of updates) { + const worktree = listed.get(worktreeId) + if (!worktree) { + throw new Error(`main does not list ${worktreeId}`) + } + const kept = new Map(Object.entries(worktree)) + for (const [field, value] of Object.entries(written)) { + if (JSON.stringify(kept.get(field)) !== JSON.stringify(value)) { + throw new Error(`main did not keep ${field} for ${worktreeId}`) + } + } + } + }, + updates, + { timeout: bulkWriteTimeout(updates.length) } + ) +} + +/** Links each child under its parent through main, as "Set parent workspace" does. */ +export async function linkWorkspaces( + page: Page, + links: { childId: string; parentId: string }[] +): Promise { + await evaluatePolled( + page, + async (links) => { + for (const { childId, parentId } of links) { + await window + .__store!.getState() + .assignWorktreeParent(childId, { parentWorktreeId: parentId }) + } + }, + links, + { timeout: bulkWriteTimeout(links.length) } + ) +} diff --git a/tests/e2e/worktree-active-delete-scroll-position.spec.ts b/tests/e2e/worktree-active-delete-scroll-position.spec.ts index 15cf6c97914..e8e166665a0 100644 --- a/tests/e2e/worktree-active-delete-scroll-position.spec.ts +++ b/tests/e2e/worktree-active-delete-scroll-position.spec.ts @@ -1,14 +1,17 @@ -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' const TARGET_INDEX = 24 const SYNTHETIC_COUNT = 40 const VISUAL_PROOF_PAUSE_MS = 1_200 const POST_REMOVAL_SAMPLE_FRAMES = 20 -const MAX_REMOVAL_WAIT_FRAMES = 300 +// A real delete runs git, so wait by time: a frame count shrinks on high-refresh displays. +const MAX_REMOVAL_WAIT_MS = 20_000 -test.use({ minimumSeededWorktreeCount: 1 }) +test.use({ seedTestRepo: false }) type RowRemovalFrame = { animationCount: number @@ -31,109 +34,60 @@ async function pauseForVisualProof(page: Page): Promise { } } -async function seedActiveDeletionRows(page: Page): Promise<{ +async function seedActiveDeletionRows( + page: Page, + testInfo: TestInfo +): Promise<{ belowId: string successorId: string targetId: string }> { - return page.evaluate( - ({ count, targetIndex }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') + const repo = await addRepoWithWorkspaces(page, testInfo, SYNTHETIC_COUNT) + await writeWorkspaceMeta( + page, + repo.ids.map((worktreeId, index) => ({ + worktreeId, + updates: { + displayName: `Active delete row ${String(index).padStart(2, '0')}`, + sortOrder: SYNTHETIC_COUNT - index, + manualOrder: SYNTHETIC_COUNT - index } + })) + ) + const targetId = repo.ids[TARGET_INDEX]! + const belowId = repo.ids[TARGET_INDEX + 1]! + const successorId = repo.ids[0]! + await evaluatePolled( + page, + async ({ repoId, targetId, successorId }) => { + const store = window.__store! const state = store.getState() - const repo = state.repos[0] - const source = repo - ? state.worktreesByRepo[repo.id]?.find((worktree) => worktree.isMainWorktree) - : null - if (!repo || !source || !state.settings) { - throw new Error('Expected a seeded e2e worktree and hydrated settings') - } - - const now = Date.now() - const worktrees = Array.from({ length: count }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - ...source, - id: `${repo.id}::active-delete-${suffix}`, - instanceId: `active-delete-${suffix}`, - path: source.path, - displayName: `Active delete row ${suffix}`, - branch: `active-delete-${suffix}`, - isMainWorktree: false, - isPinned: false, - isUnread: false, - sortOrder: count - index, - manualOrder: count - index, - lastActivityAt: now - index, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null - } + await state.updateSettings({ skipDeleteWorktreeConfirm: true }) + // A setup script keeps the setup guide from opening over the sidebar. + await state.updateRepo(repoId, { + hookSettings: { mode: 'auto', scripts: { setup: 'true', archive: '' } } }) - const target = worktrees[targetIndex] - const below = worktrees[targetIndex + 1] - const successor = worktrees[0] - if (!target || !below || !successor) { - throw new Error('Synthetic worktree fixture is too small') - } - const targetId = target.id - const belowId = below.id - const successorId = successor.id - store.setState({ - activeRepoId: repo.id, + activeRepoId: repoId, activeView: 'terminal', activeWorktreeId: targetId, activeWorkspaceKey: `worktree:${targetId}`, filterRepoIds: [], groupBy: 'none', hideDefaultBranchWorkspace: false, - lastVisitedAtByWorktreeId: { [successorId]: now + 1_000 }, + lastVisitedAtByWorktreeId: { [successorId]: Date.now() + 1_000 }, pendingRevealSidebarRow: null, pendingRevealWorktree: null, - repos: state.repos.map((candidate) => - candidate.id === repo.id - ? { - ...candidate, - hookSettings: { - mode: candidate.hookSettings?.mode ?? 'auto', - ...candidate.hookSettings, - scripts: { - archive: candidate.hookSettings?.scripts.archive ?? '', - setup: 'true' - } - } - } - : candidate - ), - settings: { ...state.settings, skipDeleteWorktreeConfirm: true }, - setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repo.id}`], + setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repoId}`], showActiveOnly: false, showSleepingWorkspaces: true, sidebarOpen: true, - sortBy: 'manual', - worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: worktrees }, - removeWorktree: async (target) => { - const id = typeof target === 'string' ? target : target.id - store.setState((current) => ({ - activeWorktreeId: current.activeWorktreeId === id ? null : current.activeWorktreeId, - activeWorkspaceKey: current.activeWorktreeId === id ? null : current.activeWorkspaceKey, - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: (current.worktreesByRepo[repo.id] ?? []).filter( - (worktree) => worktree.id !== id - ) - } - })) - return { ok: true } - } + sortBy: 'manual' }) - return { belowId, successorId, targetId } }, - { count: SYNTHETIC_COUNT, targetIndex: TARGET_INDEX } + { repoId: repo.repoId, targetId, successorId } ) + return { belowId, successorId, targetId } } async function prepareScrolledActiveRow(page: Page, targetId: string): Promise { @@ -176,7 +130,7 @@ async function startRowRemovalSampling( belowId: string ): Promise { await page.evaluate( - ({ belowId, maxRemovalWaitFrames, postRemovalSampleFrames, targetId }) => { + ({ belowId, maxRemovalWaitMs, postRemovalSampleFrames, targetId }) => { const sample = async (): Promise => { const readFrame = (): RowRemovalFrame => { const scroller = document.querySelector('[data-worktree-sidebar]') @@ -199,7 +153,8 @@ async function startRowRemovalSampling( } const frames: RowRemovalFrame[] = [readFrame()] let framesAfterRemoval = 0 - for (let index = 0; index < maxRemovalWaitFrames + postRemovalSampleFrames; index += 1) { + const deadline = performance.now() + maxRemovalWaitMs + while (performance.now() < deadline) { await new Promise((resolve) => requestAnimationFrame(() => resolve())) const frame = readFrame() frames.push(frame) @@ -214,7 +169,7 @@ async function startRowRemovalSampling( }, { belowId, - maxRemovalWaitFrames: MAX_REMOVAL_WAIT_FRAMES, + maxRemovalWaitMs: MAX_REMOVAL_WAIT_MS, postRemovalSampleFrames: POST_REMOVAL_SAMPLE_FRAMES, targetId } @@ -233,10 +188,10 @@ async function finishRowRemovalSampling(page: Page): Promise test('deleting the active scrolled worktree preserves position and closes the row gap', async ({ orcaPage -}) => { +}, testInfo) => { await waitForSessionReady(orcaPage) await orcaPage.setViewportSize({ width: 1_200, height: 800 }) - const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage) + const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage, testInfo) await prepareScrolledActiveRow(orcaPage, targetId) const target = orcaPage.locator( `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` @@ -286,47 +241,27 @@ test('deleting the active scrolled worktree preserves position and closes the ro test('reduced motion removes the active row without animating its neighbor', async ({ orcaPage -}) => { +}, testInfo) => { await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) await waitForSessionReady(orcaPage) - const { belowId, targetId } = await seedActiveDeletionRows(orcaPage) + const { belowId, targetId } = await seedActiveDeletionRows(orcaPage, testInfo) await prepareScrolledActiveRow(orcaPage, targetId) - const animationCount = await orcaPage.evaluate( - async ({ belowId, targetId }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') + await startRowRemovalSampling(orcaPage, targetId, belowId) + await evaluatePolled( + orcaPage, + async (targetId) => { + const removed = await window + .__store!.getState() + .removeWorktree({ id: targetId, executionHostId: 'local' }) + if (!removed.ok) { + throw new Error(removed.error) } - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - const repoId = repo.id - const worktrees = state.worktreesByRepo[repoId] - if (!worktrees) { - throw new Error('Expected seeded e2e worktrees') - } - store.setState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - worktreesByRepo: { - ...state.worktreesByRepo, - [repoId]: worktrees.filter((worktree) => worktree.id !== targetId) - } - }) - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const below = document.querySelector( - `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` - ) - return ( - below?.closest('[data-worktree-virtual-row]')?.firstElementChild?.getAnimations().length ?? - 0 - ) }, - { belowId, targetId } + targetId ) + const frames = await finishRowRemovalSampling(orcaPage) - expect(animationCount).toBe(0) + expect(frames.some((frame) => !frame.targetExists)).toBe(true) + expect(Math.max(...frames.map((frame) => frame.animationCount))).toBe(0) }) diff --git a/tests/e2e/worktree-card-downward-drag.spec.ts b/tests/e2e/worktree-card-downward-drag.spec.ts index 8ee77548aee..54d54c8c9c6 100644 --- a/tests/e2e/worktree-card-downward-drag.spec.ts +++ b/tests/e2e/worktree-card-downward-drag.spec.ts @@ -1,9 +1,11 @@ -import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' -const SYNTHETIC_COUNT = 60 +const WORKSPACE_COUNT = 60 + +test.use({ seedTestRepo: false }) type PreviewOffsetSample = { worktreeId: string @@ -11,107 +13,37 @@ type PreviewOffsetSample = { renderedOffset: number } -async function seedVirtualizedManualWorktrees(page: Page): Promise<{ - sourceId: string - nextId: string - idPrefix: string -}> { - const repo = await page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - return { id: repo.id, path: repo.path } - }) - const now = Date.now() - const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => { - const suffix = String(index).padStart(2, '0') - const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`) - return { - id: `${repo.id}::downward-drag-${suffix}`, - instanceId: `downward-drag-${suffix}`, - repoId: repo.id, - path: worktreePath, - displayName: `Downward drag ${suffix}`, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 100_000 - index, - manualOrder: 100_000 - index, - lastActivityAt: now - index, - head: '0000000000000000000000000000000000000000', - branch: `downward-drag-${suffix}`, - isBare: false, - isMainWorktree: false - } - }) - await page.evaluate( - ({ repoId, worktrees }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({ - ...worktree, - manualOrder: -1_000 - index - })) - - store.setState({ - groupBy: 'none', - sortBy: 'manual', - showActiveOnly: false, - showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, - filterRepoIds: [], - sidebarOpen: true, - worktreesByRepo: { - ...state.worktreesByRepo, - [repoId]: [...worktrees, ...seededWorktrees] - }, - // Stands in for the real action only to skip its IPC persistence tail: these 60 rows - // are store-only, so persisting them would fail and refetch them away mid-drag. - updateWorktreesMeta: async (batchUpdates) => { - if (batchUpdates.length === 0) { - return - } - // executionHostId is ignored on purpose: every seeded row is host-less, which the - // real action treats as local (worktree-meta-host-match.ts). - const updatesByWorktreeId = new Map( - batchUpdates.map((batchUpdate) => [batchUpdate.worktreeId, batchUpdate.updates]) - ) - store.setState((current) => ({ - sortEpoch: current.sortEpoch + 1, - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ - repoId, - worktrees.map((worktree) => ({ - ...worktree, - ...updatesByWorktreeId.get(worktree.id) - })) - ]) - ) - })) +async function seedVirtualizedManualWorktrees( + page: Page, + testInfo: TestInfo +): Promise<{ sourceId: string; nextId: string; ids: string[] }> { + const repo = await addRepoWithWorkspaces(page, testInfo, WORKSPACE_COUNT) + await writeWorkspaceMeta(page, [ + { worktreeId: repo.mainId, updates: { manualOrder: -1_000, sortOrder: -1_000 } }, + ...repo.ids.map((worktreeId, index) => { + const suffix = String(index).padStart(2, '0') + return { + worktreeId, + updates: { + displayName: `Downward drag ${suffix}`, + sortOrder: 100_000 - index, + manualOrder: 100_000 - index } - }) - }, - { repoId: repo.id, worktrees: syntheticWorktrees } - ) - const sourceId = syntheticWorktrees[0]!.id - return { - sourceId, - nextId: syntheticWorktrees[1]!.id, - idPrefix: sourceId.slice(0, -2) - } + } + }) + ]) + await page.evaluate(() => { + window.__store!.setState({ + groupBy: 'none', + sortBy: 'manual', + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + sidebarOpen: true + }) + }) + return { sourceId: repo.ids[0]!, nextId: repo.ids[1]!, ids: repo.ids } } async function sampleMountedPreviewOffsets( @@ -159,10 +91,12 @@ async function sampleMountedPreviewOffsets( }, sourceId) } -test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => { +test('dragging a virtualized worktree downward keeps rows stable', async ({ + orcaPage +}, testInfo) => { await waitForSessionReady(orcaPage) await orcaPage.setViewportSize({ width: 1_000, height: 620 }) - const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage) + const { sourceId, nextId, ids } = await seedVirtualizedManualWorktrees(orcaPage, testInfo) const scroller = orcaPage.locator('[data-worktree-sidebar]') const source = orcaPage.locator( `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]` @@ -276,11 +210,11 @@ test('dragging a virtualized worktree downward keeps rows stable', async ({ orca scroller .locator('[role="option"]') .evaluateAll( - (options, prefix) => + (options, ids) => options .map((option) => option.getAttribute('data-worktree-id')) - .find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null, - idPrefix + .find((worktreeId) => worktreeId !== null && ids.includes(worktreeId)) ?? null, + ids ), { message: 'The downward drop did not move the source away from the first slot' } ) diff --git a/tests/e2e/worktree-lineage-indentation.spec.ts b/tests/e2e/worktree-lineage-indentation.spec.ts index f769b512eda..e7f05d8636e 100644 --- a/tests/e2e/worktree-lineage-indentation.spec.ts +++ b/tests/e2e/worktree-lineage-indentation.spec.ts @@ -1,81 +1,72 @@ import { test, expect } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' import { LINEAGE_IMMEDIATE_PARENT_STEP } from '../../src/renderer/src/components/sidebar/worktree-list/rows/indentation' -test.use({ minimumSeededWorktreeCount: 1 }) +test.use({ seedTestRepo: false }) for (const compactCards of [false, true]) { test(`legacy lineage keeps a local title step (${compactCards ? 'compact' : 'detailed'})`, async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 4) + await writeWorkspaceMeta( + orcaPage, + repo.ids.map((worktreeId, index) => ({ + worktreeId, + updates: { displayName: `Level ${index} workspace`, sortOrder: index } + })) + ) + await linkWorkspaces( + orcaPage, + repo.ids.slice(1).map((childId, index) => ({ childId, parentId: repo.ids[index]! })) + ) for (const showStatus of [false, true]) { for (const group of ['none', 'repo', 'manual', 'deep-manual', 'deep-folder'] as const) { - await orcaPage.evaluate( - ({ compactCards, showStatus, group }) => { - const store = window.__store - if (!store) { - throw new Error('Missing app store') - } + await evaluatePolled( + orcaPage, + async ({ compactCards, showStatus, group, repoId }) => { + const store = window.__store! const state = store.getState() - if (!state.settings) { - throw new Error('Missing app settings') + // Each pass starts from an ungrouped project, so the last pass's groups go. + await window.api.projectGroups.moveProject({ projectId: repoId, groupId: null }) + for (const stale of await window.api.projectGroups.list()) { + await window.api.projectGroups.delete({ groupId: stale.id }) } - const repo = state.repos[0]! - const template = state.worktreesByRepo[repo.id]![0]! const depth = group.startsWith('deep-') ? 3 : group === 'manual' ? 1 : 0 - const projectGroups = Array.from({ length: depth }, (_, index) => ({ - id: `indent-group-${index}`, - name: `Indent group ${index}`, - parentPath: repo.path, - parentGroupId: index > 0 ? `indent-group-${index - 1}` : null, - createdFrom: group === 'deep-folder' ? ('folder-scan' as const) : ('manual' as const), - tabOrder: index, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - })) - const worktrees = Array.from({ length: 4 }, (_, index) => ({ - ...template, - id: `${repo.id}::indent-${index}`, - instanceId: `indent-instance-${index}`, - displayName: `Level ${index} workspace`, - branch: `indent-${index}`, - isMainWorktree: false, - isPinned: false, - isArchived: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - linkedIssue: null, - linkedPR: null, - linkedGitLabMR: null, - linkedLinearIssue: null, - comment: '', - sortOrder: index - })) - const lineage = Object.fromEntries( - worktrees.slice(1).map((child, index) => [ - child.id, - { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: worktrees[index]!.id, - parentWorktreeInstanceId: worktrees[index]!.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: 1 - } - ]) - ) + let parentGroupId: string | null = null + for (let index = 0; index < depth; index++) { + const created = await window.api.projectGroups.create({ + name: `Indent group ${index}`, + parentPath: state.repos.find((entry) => entry.id === repoId)!.path, + parentGroupId, + createdFrom: group === 'deep-folder' ? 'folder-scan' : 'manual' + }) + parentGroupId = created.id + } + if (parentGroupId) { + await window.api.projectGroups.moveProject({ + projectId: repoId, + groupId: parentGroupId + }) + } + await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + await state.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + // Persist the view first: its echo re-hydrates the window, which must land before the seed. + await window.api.ui.set({ + groupBy: group === 'none' ? 'none' : 'repo', + sortBy: 'manual', + collapsedGroups: [] + }) store.setState({ - repos: [{ ...repo, projectGroupId: projectGroups.at(-1)?.id ?? null }], - worktreesByRepo: { [repo.id]: worktrees }, - worktreeLineageById: lineage, - projectGroups, settings: { - ...state.settings, + ...store.getState().settings!, experimentalNewWorktreeCardStyle: false, compactWorktreeCards: compactCards }, @@ -86,13 +77,14 @@ for (const compactCards of [false, true]) { activeWorktreeId: null, showActiveOnly: false, showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, + // The repo's own checkout is not part of the chain. + hideDefaultBranchWorkspace: true, filterRepoIds: [], workspaceHostScope: 'all', collapsedGroups: new Set() }) }, - { compactCards, showStatus, group } + { compactCards, showStatus, group, repoId: repo.repoId } ) const sidebar = orcaPage.locator('[data-worktree-sidebar]') diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 219bb07c27e..e4876ce9a45 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -1,4 +1,5 @@ import type { Page } from '@stablyai/playwright-test' +import { linkWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' export type LineageScenario = { parentId: string @@ -9,7 +10,7 @@ export async function seedLineageScenario( page: Page, options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {} ): Promise { - return page.evaluate(({ inlineOnly, preserveGrouping }) => { + const family = await page.evaluate((preserveGrouping) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -40,50 +41,60 @@ export async function seedLineageScenario( if (!parent.instanceId || !child.instanceId) { throw new Error('Worktree lineage E2E needs instance-stamped worktrees') } - const lineage = { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: Date.now() + return { + parentId: parent.id, + childId: child.id, + parentInstanceId: parent.instanceId, + childInstanceId: child.instanceId } - store.setState((current) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ - repoId, - repoWorktrees.map((worktree) => { - if (worktree.id === parent.id) { - return { - ...worktree, - displayName: 'E2E lineage parent', - sortOrder: 0, - ...(inlineOnly - ? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null } - : {}) - } - } - if (worktree.id === child.id) { - return { - ...worktree, - displayName: 'E2E lineage child', - sortOrder: 1, - ...(inlineOnly - ? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage } - : {}) - } - } - return worktree - }) - ]) - ), - worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage } - })) - - store.getState().setActiveWorktree(parent.id) - return { parentId: parent.id, childId: child.id } - }, options) + }, options.preserveGrouping ?? false) + const { parentId, childId } = family + await writeWorkspaceMeta(page, [ + { worktreeId: parentId, updates: { displayName: 'E2E lineage parent', sortOrder: 0 } }, + { worktreeId: childId, updates: { displayName: 'E2E lineage child', sortOrder: 1 } } + ]) + if (!options.inlineOnly) { + await linkWorkspaces(page, [{ childId, parentId }]) + } + if (options.inlineOnly) { + // Window-only on purpose: the records' inline link with no side map, which main cannot produce. + await page.evaluate(({ parentId, childId, parentInstanceId, childInstanceId }) => { + const lineage = { + worktreeId: childId, + worktreeInstanceId: childInstanceId, + parentWorktreeId: parentId, + parentWorktreeInstanceId: parentInstanceId, + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: Date.now() + } + window.__store!.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ + repoId, + repoWorktrees.map((worktree) => + worktree.id === parentId + ? { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + } + : worktree.id === childId + ? { ...worktree, parentWorktreeId: parentId, childWorktreeIds: [], lineage } + : worktree + ) + ]) + ), + worktreeLineageById: {} + })) + }, family) + } + await page.evaluate( + (parentId) => window.__store!.getState().setActiveWorktree(parentId), + parentId + ) + return { parentId, childId } } export async function seedWorkspaceAgentStatus( diff --git a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts index 0e7b078f1a1..8c743762c54 100644 --- a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { seedLineageScenario } from './worktree-lineage-state' import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness' @@ -19,45 +20,22 @@ test('keyboard and chip preserve the scrolled parent, and unfolding opens the si await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) await orcaPage.setViewportSize({ width: 1200, height: 800 }) const family = await seedLineageScenario(orcaPage) - await orcaPage.evaluate(async ({ parentId, childId }) => { - const store = window.__store - if (!store) { - throw new Error('Missing store') - } - const state = store.getState() - const parent = Object.values(state.worktreesByRepo) - .flat() - .find((row) => row.id === parentId) - if (!parent) { - throw new Error('Missing parent') - } - const surrounding = Array.from({ length: 40 }, (_, index) => ({ - ...parent, - id: `scroll-card-${index}`, - instanceId: `scroll-instance-${index}`, - displayName: `Surrounding workspace ${index}`, - isMainWorktree: false, - isPinned: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - sortOrder: 40 - index - })) - store.setState({ - sortBy: 'manual', - worktreesByRepo: { - ...state.worktreesByRepo, - [parent.repoId]: [ - ...state.worktreesByRepo[parent.repoId].map((row) => ({ - ...row, - sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder - })), - ...surrounding - ] - } - }) - await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) - }, family) + const surrounding = await addRepoWithWorkspaces(orcaPage, testInfo, 40) + const order = (worktreeId: string, rank: number, displayName?: string) => ({ + worktreeId, + updates: { sortOrder: rank, manualOrder: rank, ...(displayName ? { displayName } : {}) } + }) + await writeWorkspaceMeta(orcaPage, [ + order(family.parentId, 16.5), + order(family.childId, 16.4), + order(surrounding.mainId, 0), + ...surrounding.ids.map((id, index) => order(id, 40 - index, `Surrounding workspace ${index}`)) + ]) + await orcaPage.evaluate(async () => { + const state = window.__store!.getState() + state.setSortBy('manual') + await state.setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + }) const sidebar = orcaPage.locator('[data-worktree-sidebar]') const parentRow = sidebar diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts index 6187e43e2fe..622255020b0 100644 --- a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -5,6 +5,11 @@ import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { readPaneIdentitySnapshot } from './helpers/terminal' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) @@ -192,55 +197,19 @@ test.describe('Toggle Child Workspaces shortcut', () => { await orcaPage.keyboard.press('Escape') }) - test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => { - const { parentId, childId } = await seedLineageScenario(orcaPage) + test('a hovered parent takes precedence over another active family', async ({ + orcaPage + }, testInfo) => { + const { childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - const other = await orcaPage.evaluate( - ({ parentId, childId }) => { - const store = window.__store - if (!store) { - throw new Error('Missing store') - } - const state = store.getState() - const worktrees = Object.values(state.worktreesByRepo).flat() - const parent = worktrees.find((worktree) => worktree.id === parentId) - const child = worktrees.find((worktree) => worktree.id === childId) - const edge = state.worktreeLineageById[childId] - if (!parent || !child || !edge) { - throw new Error('Missing seeded family') - } - const otherParent = { - ...parent, - id: `${parentId}-other`, - instanceId: 'other-parent', - displayName: 'Another parent' - } - const otherChild = { - ...child, - id: `${childId}-other`, - instanceId: 'other-child', - displayName: 'Another child' - } - store.setState({ - worktreesByRepo: { - ...state.worktreesByRepo, - [parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild] - }, - worktreeLineageById: { - ...state.worktreeLineageById, - [otherChild.id]: { - ...edge, - worktreeId: otherChild.id, - worktreeInstanceId: otherChild.instanceId, - parentWorktreeId: otherParent.id, - parentWorktreeInstanceId: otherParent.instanceId - } - } - }) - return { parentId: otherParent.id, childId: otherChild.id } - }, - { parentId, childId } - ) + const otherRepo = await addRepoWithWorkspaces(orcaPage, testInfo, 2) + const other = { parentId: otherRepo.ids[0]!, childId: otherRepo.ids[1]! } + await writeWorkspaceMeta(orcaPage, [ + { worktreeId: other.parentId, updates: { displayName: 'Another parent' } }, + { worktreeId: other.childId, updates: { displayName: 'Another child' } } + ]) + await linkWorkspaces(orcaPage, [other]) + await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeVisible() await sidebarWorktreeRow(orcaPage, other.parentId).hover() await orcaPage.keyboard.press(CHORD) await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden() @@ -268,18 +237,10 @@ test.describe('Toggle Child Workspaces shortcut', () => { }) => { const { parentId, childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - await orcaPage.evaluate((childId) => { - window.__store?.setState((state) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => - row.id === childId ? { ...row, instanceId: 'recreated-child' } : row - ) - ]) - ) - })) - }, childId) + // Stands in for recreating the child at the same path: its new instance no longer matches the link. + await writeWorkspaceMeta(orcaPage, [ + { worktreeId: childId, updates: { instanceId: 'recreated-child' } } + ]) const parentRow = sidebarWorktreeRow(orcaPage, parentId) await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) await parentRow.click() @@ -375,21 +336,14 @@ test.describe('Toggle Child Workspaces shortcut', () => { }) => { const { parentId, childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - await orcaPage.evaluate(async (parentId) => { + await writeWorkspaceMeta(orcaPage, [{ worktreeId: parentId, updates: { isPinned: true } }]) + await orcaPage.evaluate(async () => { await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true }) - window.__store?.setState((state) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row)) - ]) - ) - })) const sidebar = document.querySelector('[data-worktree-sidebar]') sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => { sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1) }) - }, parentId) + }) const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true }) await expect(chips).toHaveCount(2) const natural = orcaPage diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 0f9ddb33496..55727c136d4 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -2,6 +2,8 @@ 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, @@ -110,6 +112,10 @@ test.describe('Worktree Lineage', () => { 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) @@ -125,6 +131,10 @@ test.describe('Worktree Lineage', () => { 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') }) @@ -211,24 +221,12 @@ test.describe('Worktree Lineage', () => { test('sleeps a workspace and every descendant from the parent context menu', async ({ orcaPage - }) => { - const { parentId, childId } = await seedLineageScenario(orcaPage) - await orcaPage.evaluate((parentId) => { - 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, worktrees]) => [ - repoId, - worktrees.map((worktree) => - worktree.id === parentId ? { ...worktree, isMainWorktree: false } : worktree - ) - ]) - ) - })) - }, parentId) + }, 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) diff --git a/tests/e2e/worktree-parent-reorder.spec.ts b/tests/e2e/worktree-parent-reorder.spec.ts index 21f96f0c0d3..1eddc06de22 100644 --- a/tests/e2e/worktree-parent-reorder.spec.ts +++ b/tests/e2e/worktree-parent-reorder.spec.ts @@ -1,82 +1,64 @@ import { test, expect } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' +test.use({ seedTestRepo: false }) + for (const newCardStyle of [false, true]) { test(`reorders a collapsed parent with 53 children (${newCardStyle ? 'new' : 'legacy'} cards)`, async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) - const ids = await orcaPage.evaluate(async (newCardStyle) => { - const store = window.__store - if (!store) { - throw new Error('Missing app store') - } - const state = store.getState() - const repo = state.repos[0]! - const template = state.worktreesByRepo[repo.id]![0]! - const makeRow = (name: string, rank: number) => ({ - ...template, - id: `${repo.id}::${name}`, - instanceId: name, - displayName: name, - branch: `refs/heads/${name}`, - isMainWorktree: false, - isPinned: false, - isArchived: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - manualOrder: rank, - sortOrder: rank - }) - const first = makeRow('First workspace', 100_000) - const parent = makeRow('Parent with 53 children', 90_000) - const last = makeRow('Last workspace', 10_000) - const children = Array.from({ length: 53 }, (_, index) => { - const child = makeRow(`Child ${index}`, 80_000 - index * 100) - const lineage = { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: Date.now() - } - return { ...child, parentWorktreeId: parent.id, lineage } - }) - const collapsedGroups = [ - `lineage:${parent.hostId ? `${parent.hostId}|${parent.id}` : parent.id}` - ] - await window.api.ui.set({ groupBy: 'none', collapsedGroups }) - store.setState({ - groupBy: 'none', - sortBy: 'manual', - sidebarOpen: true, - settings: { ...state.settings, experimentalNewWorktreeCardStyle: newCardStyle }, - showActiveOnly: false, - showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, - filterRepoIds: [], - collapsedGroups: new Set(collapsedGroups), - worktreesByRepo: { [repo.id]: [first, parent, ...children, last] }, - worktreeLineageById: Object.fromEntries(children.map((child) => [child.id, child.lineage])), - // Synthetic rows exercise the real drag path without persisting nonexistent checkouts. - updateWorktreesMeta: async (updates) => { - const byId = new Map(updates.map((update) => [update.worktreeId, update.updates])) - store.setState((current) => ({ - sortEpoch: current.sortEpoch + 1, - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => ({ ...row, ...byId.get(row.id) })) - ]) - ) - })) - } - }) - return { first: first.id, parent: parent.id, last: last.id } - }, newCardStyle) + // First, parent, 53 children, last; the repo's own checkout sorts below them all. + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 56) + const [firstId, parentId, ...rest] = repo.ids + const childIds = rest.slice(0, 53) + const lastId = rest[53]! + const rank = (id: string, name: string, manualOrder: number) => ({ + worktreeId: id, + updates: { displayName: name, manualOrder, sortOrder: manualOrder } + }) + await writeWorkspaceMeta(orcaPage, [ + rank(repo.mainId, 'Main workspace', 1), + rank(firstId!, 'First workspace', 100_000), + rank(parentId!, 'Parent with 53 children', 90_000), + ...childIds.map((id, index) => rank(id, `Child ${index}`, 80_000 - index * 100)), + rank(lastId, 'Last workspace', 10_000) + ]) + await linkWorkspaces( + orcaPage, + childIds.map((childId) => ({ childId, parentId: parentId! })) + ) + await evaluatePolled( + orcaPage, + async ({ newCardStyle, parentId }) => { + const store = window.__store! + const state = store.getState() + const parent = state.allWorktrees().find((worktree) => worktree.id === parentId)! + const collapsedGroups = [ + `lineage:${parent.hostId ? `${parent.hostId}|${parent.id}` : parent.id}` + ] + await window.api.ui.set({ groupBy: 'none', collapsedGroups }) + await state.updateSettings({ experimentalNewWorktreeCardStyle: newCardStyle }) + store.setState({ + groupBy: 'none', + sortBy: 'manual', + sidebarOpen: true, + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + collapsedGroups: new Set(collapsedGroups) + }) + }, + { newCardStyle, parentId: parentId! } + ) + const ids = { first: firstId!, parent: parentId!, last: lastId } const sidebar = orcaPage.locator('[data-worktree-sidebar]') const parent = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.parent)}]`) const last = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.last)}]`) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 07cc6340897..4d7abd0bb1c 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,7 +1,14 @@ -import { mkdirSync } from 'node:fs' +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 { @@ -167,64 +174,34 @@ test.describe('Reveal active workspace button', () => { test('reveals the current workspace when it starts outside the virtualized window', async ({ orcaPage - }) => { + }, testInfo) => { await prepareSidebarForScrollTest(orcaPage) - const targetId = await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - - const now = Date.now() - const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] - const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - id: `${repo.id}::/virtual-reveal-${suffix}`, - instanceId: `virtual-reveal-${suffix}`, - repoId: repo.id, - path: `${repo.path}/../virtual-reveal-${suffix}`, - displayName: `Virtual reveal ${suffix}`, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, + 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, - lastActivityAt: now - index - 100, - head: '0000000000000000000000000000000000000000', - branch: `virtual-reveal-${suffix}`, - isBare: false, - isMainWorktree: false + manualOrder: 10_000 - index } - }) - const target = syntheticWorktrees.at(-1) - if (!target) { - throw new Error('Expected a synthetic target worktree') - } - - store.setState({ - activeRepoId: repo.id, - activeWorktreeId: target.id, - activeWorkspaceKey: `worktree:${target.id}`, - pendingRevealWorktree: null, - sortBy: 'manual', - worktreesByRepo: { - ...state.worktreesByRepo, - [repo.id]: [...seededWorktrees, ...syntheticWorktrees] - } - }) - return target.id - }) + })) + ) + 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 @@ -278,62 +255,55 @@ test.describe('Reveal active workspace button', () => { test('uses the active workspace key when the legacy active worktree id is not set', async ({ orcaPage - }) => { + }, testInfo) => { await prepareSidebarForScrollTest(orcaPage) - const folderWorktreeId = await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const now = Date.now() - const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - id: `reveal-folder-workspace-${suffix}`, - projectGroupId: 'reveal-folder-group', - name: `Reveal folder workspace ${suffix}`, - folderPath: `/tmp/reveal-folder-workspace-${suffix}`, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1_000 - index, - lastActivityAt: now - index, - createdAt: now, - updatedAt: now - } - }) - const targetFolder = folderWorkspaces.at(-1) - if (!targetFolder) { - throw new Error('Expected a target folder workspace') - } - const folderWorktreeId = `folder:${targetFolder.id}` - store.setState({ - activeRepoId: null, - activeWorktreeId: null, - activeWorkspaceKey: folderWorktreeId, - projectGroups: [ - { - id: 'reveal-folder-group', - name: 'Reveal folder group', - parentPath: '/tmp/reveal-folder-group', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: now, - updatedAt: now - } - ], - folderWorkspaces - }) - store.getState().setGroupBy('repo') - return folderWorktreeId + // 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) => {