From 78bad24d33217df6908b4f8e70fef6ca1fe9ac35 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:29:17 -0400 Subject: [PATCH] test(e2e): nine sidebar specs create workspaces and links through main so they pass with the switch on (#30667) * 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 * test(e2e): parent reorder spec creates its workspaces and links through main Co-Authored-By: Claude * 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 * test(e2e): default-branch and downward-drag specs create their workspaces through main Co-Authored-By: Claude * 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 * 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 * test(e2e): read-back reports an unlisted workspace and compares values structurally Co-Authored-By: Claude * 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 * test(e2e): name the repo wait in its failure message Co-Authored-By: Claude * 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 --------- Co-authored-by: Claude --- config/e2e-typecheck-baseline.txt | 2 - tests/e2e/default-branch-visibility.spec.ts | 112 ++++------- tests/e2e/helpers/page-evaluate-polled.ts | 10 +- tests/e2e/helpers/repo-with-workspaces.ts | 152 ++++++++++++++ ...tree-active-delete-scroll-position.spec.ts | 183 ++++++----------- tests/e2e/worktree-card-downward-drag.spec.ts | 150 ++++---------- .../e2e/worktree-lineage-indentation.spec.ts | 118 ++++++----- tests/e2e/worktree-lineage-state.ts | 99 +++++----- .../worktree-lineage-toggle-scroll.spec.ts | 56 ++---- .../worktree-lineage-toggle-shortcut.spec.ts | 94 +++------ tests/e2e/worktree-lineage.spec.ts | 34 ++-- tests/e2e/worktree-parent-reorder.spec.ts | 124 +++++------- tests/e2e/worktree-scroll-to-current.spec.ts | 186 ++++++++---------- 13 files changed, 591 insertions(+), 729 deletions(-) create mode 100644 tests/e2e/helpers/repo-with-workspaces.ts 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) => {