Files
2026-07-13 13:05:33 +08:00

513 lines
20 KiB
TypeScript

/**
* E2E tests for tab management: creating, switching, reordering, and closing tabs.
*
* User Prompt:
* - New tab works
* - dragging tabs around to reorder them
* - closing tabs works
* - double-click a tab to rename it inline
*
* Why these specs assert on the DOM: a prior version of this file drove every
* flow through `window.__store` and read the same fields back — a tautology
* that would have passed even if the tab bar had stopped rendering (the same
* pattern that let PR #1186's `StartFromField` render crash ship past the
* E2E suite in #1193). The rule in tests/e2e/AGENTS.md is that the final
* `expect()` must target user-observable DOM. Store calls are only used here
* for *setup* (e.g. to guarantee >= N tabs exist) or when the real user-facing
* action genuinely can't be driven via DOM in hidden-window Electron runs
* (dnd-kit reorder); in those cases a DOM assertion still follows.
*/
import { test, expect } from './helpers/orca-app'
import type { Page } from '@stablyai/playwright-test'
import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
getActiveTabId,
getActiveTabType,
getWorktreeTabs,
getTabBarOrder,
ensureTerminalVisible
} from './helpers/store'
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
function tabLocator(page: Page, tabId: string) {
return page.locator(`${SORTABLE_TAB}[data-tab-id="${tabId}"]`).first()
}
/** Count rendered tabs in the tab bar (user-visible, not store-level). */
async function countRenderedTabs(page: Page): Promise<number> {
return page.locator(SORTABLE_TAB).count()
}
/**
* Read the DOM's active-tab id from the `data-active` attribute exposed by
* SortableTab. We assert on DOM rather than `activeTabId` in the store so a
* render-layer regression (e.g. the active indicator failing to paint on the
* correct tab) cannot silently pass.
*/
async function getDomActiveTabId(page: Page): Promise<string | null> {
return page.evaluate((selector) => {
const match = document.querySelector(`${selector}[data-active="true"]`)
return match?.getAttribute('data-tab-id') ?? null
}, SORTABLE_TAB)
}
async function getFocusedTerminalTabId(page: Page): Promise<string | null> {
return page.evaluate(() => {
const active = document.activeElement
if (!(active instanceof HTMLElement) || !active.classList.contains('xterm-helper-textarea')) {
return null
}
return active.closest('[data-terminal-tab-id]')?.getAttribute('data-terminal-tab-id') ?? null
})
}
test.describe('Tabs', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
})
/**
* User Prompt:
* - New tab works
*
* Why: asserting on a new `[data-testid="sortable-tab"]` in the DOM (not
* `tabsByWorktree.length` in the store) is the guard that would have caught
* a tab-bar render regression. Clicking the real "+" button and then "New
* Terminal" drives the same code path a user takes.
*/
test('clicking "+" then "New Terminal" creates a new terminal tab', async ({ orcaPage }) => {
const tabsBefore = await countRenderedTabs(orcaPage)
// Why: hidden-window Electron can keep the animated terminal surface
// invalidating Playwright's "stable" actionability check even though the
// tab-bar button is visible and enabled.
await orcaPage.getByRole('button', { name: 'New tab' }).click({ force: true })
// Why: the "+" dropdown uses Radix <DropdownMenuItem>, which exposes the
// label text as the accessible name once the menu is open.
const newTerminalMenuItem = orcaPage.getByRole('menuitem', { name: /New Terminal/i }).first()
await newTerminalMenuItem.click({ force: true })
await expect(newTerminalMenuItem).toBeHidden({ timeout: 3_000 })
// Final assertion is on the rendered tab count — the tab bar itself must
// gain an element, not just the store.
await expect
.poll(() => countRenderedTabs(orcaPage), {
timeout: 5_000,
message: 'Clicking + → New Terminal did not render a new tab in the tab bar'
})
.toBeGreaterThan(tabsBefore)
const activeType = await getActiveTabType(orcaPage)
expect(activeType).toBe('terminal')
const storeActiveId = await getActiveTabId(orcaPage)
expect(storeActiveId).not.toBeNull()
await expect(tabLocator(orcaPage, storeActiveId!)).toBeVisible()
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(storeActiveId)
await expect
.poll(() => getFocusedTerminalTabId(orcaPage), {
timeout: 5_000,
message: 'Menu-created terminal tab did not receive keyboard focus'
})
.toBe(storeActiveId)
})
/**
* User Prompt:
* - New tab works
*/
test('Cmd/Ctrl+T creates a new terminal tab', async ({ orcaPage }) => {
const isMac = process.platform === 'darwin'
const mod = isMac ? 'Meta' : 'Control'
const tabsBefore = await countRenderedTabs(orcaPage)
// Why: focus body first so the window-level keydown handler on Terminal.tsx
// actually sees the event. Without focus the key may be eaten by an
// unrelated input (e.g. a stale search field from a previous test).
await orcaPage.evaluate(() => document.body.focus())
await orcaPage.keyboard.press(`${mod}+t`)
// DOM-level count increased — confirms a new tab actually rendered.
await expect
.poll(() => countRenderedTabs(orcaPage), {
timeout: 5_000,
message: `${mod}+T did not add a tab to the tab bar`
})
.toBe(tabsBefore + 1)
// The newly-rendered active tab must be a terminal (tab-type is visible as
// the active surface behind the strip; we rely on the store flag here only
// to disambiguate terminal vs. editor vs. browser — the fact that *some*
// tab is active is already proved by the DOM assertion below).
const activeType = await getActiveTabType(orcaPage)
expect(activeType).toBe('terminal')
// The DOM must have exactly one active tab and it must match the store's
// activeTabId — this is the load-bearing check that the render layer and
// the state layer agree on what is selected.
const storeActiveId = await getActiveTabId(orcaPage)
expect(storeActiveId).not.toBeNull()
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(storeActiveId)
})
/**
* User Prompt:
* - New tab works
*
* Why: we still use the store's `setActiveTab` for the switch itself (the
* hotkey path that used to be here turned out to target bracket-chord next/
* prev tab cycling, not arbitrary tab selection), but the final assertion
* checks DOM `data-active` to prove the selection actually paints onto the
* right tab element.
*/
test('Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ switch between tabs', async ({ orcaPage }) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
// Ensure we have at least 2 tabs — use the real "+" flow so a render
// regression would fail setup before we even start the cycle check.
if ((await countRenderedTabs(orcaPage)) < 2) {
await orcaPage.getByRole('button', { name: 'New tab' }).click()
await orcaPage
.getByRole('menuitem', { name: /New Terminal/i })
.first()
.click()
await expect
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
}
const firstTabId = await getActiveTabId(orcaPage)
const orderedTabs = await getWorktreeTabs(orcaPage, worktreeId)
const secondTabId = orderedTabs.find((tab) => tab.id !== firstTabId)?.id
expect(secondTabId).toBeTruthy()
await orcaPage.evaluate((tabId) => {
window.__store?.getState().setActiveTab(tabId)
}, secondTabId)
// DOM assertion — the second tab must actually show the active indicator.
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(secondTabId)
// Switch back.
await orcaPage.evaluate((tabId) => {
window.__store?.getState().setActiveTab(tabId)
}, firstTabId)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
})
/**
* User Prompt:
* - dragging tabs around to reorder them
*
* Why the reorder is still store-driven: real dnd-kit pointer events are
* unreliable in the hidden-window Electron mode we run E2E in (pointer
* capture + collision detection interact poorly with `window.show()` being
* suppressed). We seed the post-drag state via `reorderUnifiedTabs` — the
* same action dnd-kit calls on drop — and then assert the tab bar's DOM
* order matches the new sequence. That final DOM check is what makes this
* a real test: a pure store round-trip would not catch a regression where
* the tab strip stopped re-rendering in the store's new order.
*/
test('dragging a tab to a new position reorders it', async ({ orcaPage }) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
if ((await countRenderedTabs(orcaPage)) < 2) {
await orcaPage.getByRole('button', { name: 'New tab' }).click()
await orcaPage
.getByRole('menuitem', { name: /New Terminal/i })
.first()
.click()
await expect
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
}
const domOrderBefore = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
)
expect(domOrderBefore.length).toBeGreaterThanOrEqual(2)
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
const groups = state.groupsByWorktree[targetWorktreeId] ?? []
const activeGroupId = state.activeGroupIdByWorktree[targetWorktreeId]
const activeGroup = activeGroupId
? groups.find((group) => group.id === activeGroupId)
: groups[0]
if (activeGroup?.tabOrder?.length >= 2) {
const nextOrder = [
activeGroup.tabOrder[1],
activeGroup.tabOrder[0],
...activeGroup.tabOrder.slice(2)
]
state.reorderUnifiedTabs(activeGroup.id, nextOrder)
return
}
const terminalOrder = (state.tabsByWorktree[targetWorktreeId] ?? []).map((tab) => tab.id)
if (terminalOrder.length >= 2) {
state.setTabBarOrder(targetWorktreeId, [
terminalOrder[1],
terminalOrder[0],
...terminalOrder.slice(2)
])
}
}, worktreeId)
// Final assertion: the tab strip must re-render with the swapped order.
// Keying off `data-tab-id` makes this independent of title formatting.
await expect
.poll(
async () =>
orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
),
{ timeout: 3_000, message: 'Tab bar DOM order did not reflect the reorder' }
)
.toEqual([domOrderBefore[1], domOrderBefore[0], ...domOrderBefore.slice(2)])
})
test('clicking tabs still switches after dragging a terminal tab to reorder', async ({
orcaPage
}) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
const existing = (state.tabsByWorktree[targetWorktreeId] ?? []).length
for (let i = existing; i < 2; i++) {
state.createTab(targetWorktreeId)
}
}, worktreeId)
await expect
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
const domOrderBefore = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
)
const [firstTabId, secondTabId] = domOrderBefore
expect(firstTabId).toBeTruthy()
expect(secondTabId).toBeTruthy()
await tabLocator(orcaPage, firstTabId).click({ force: true })
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
const firstTabBox = await tabLocator(orcaPage, firstTabId).boundingBox()
const secondTabBox = await tabLocator(orcaPage, secondTabId).boundingBox()
expect(firstTabBox).not.toBeNull()
expect(secondTabBox).not.toBeNull()
const startX = firstTabBox!.x + firstTabBox!.width / 2
const startY = firstTabBox!.y + firstTabBox!.height / 2
const endX = secondTabBox!.x + secondTabBox!.width * 0.75
const endY = secondTabBox!.y + secondTabBox!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
// Why: this mirrors the release repro: drag a terminal tab across another
// tab far enough for dnd-kit to commit a reorder, then release on the tab
// strip before clicking tabs again.
await orcaPage.mouse.move(endX, endY, { steps: 8 })
await orcaPage.mouse.up()
await expect
.poll(
async () =>
orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
),
{ timeout: 5_000, message: 'Terminal tab drag did not reorder the tab strip' }
)
.toEqual([secondTabId, firstTabId, ...domOrderBefore.slice(2)])
await tabLocator(orcaPage, firstTabId).click({ force: true })
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
await tabLocator(orcaPage, secondTabId).click({ force: true })
await expect
.poll(() => getDomActiveTabId(orcaPage), {
timeout: 5_000,
message: 'Tab click did not activate after a terminal tab reorder drag'
})
.toBe(secondTabId)
})
/**
* Regression: after a drag-reorder, Cmd/Ctrl+Shift+[ must walk tabs in
* the new visible order. The pre-fix bug read a stale legacy order
* (`tabBarOrderByWorktree`), so pressing "left" three times cycled
* 3 → 1 → 2 instead of 3 → 2 → 1 once tabs had been rearranged.
*
* The DOM assertion (`data-active` matching the expected tab element) is
* the load-bearing check — it fails if the shortcut walks the right store
* id but the tab bar stops painting the active indicator on that tab.
*/
test('Cmd/Ctrl+Shift+[ walks tabs in drag-reordered order', async ({ orcaPage }) => {
const isMac = process.platform === 'darwin'
const mod = isMac ? 'Meta' : 'Control'
const worktreeId = (await getActiveWorktreeId(orcaPage))!
// Ensure at least 3 terminal tabs so the order cycle is non-trivial.
// Why store-driven: we only need >=3 tabs to exist; the "+" flow is
// already exercised by other tests in this file.
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
const existing = (state.tabsByWorktree[targetWorktreeId] ?? []).length
for (let i = existing; i < 3; i++) {
state.createTab(targetWorktreeId)
}
}, worktreeId)
await expect
.poll(async () => (await getWorktreeTabs(orcaPage, worktreeId)).length, { timeout: 5_000 })
.toBeGreaterThanOrEqual(3)
const initialOrder = await getTabBarOrder(orcaPage, worktreeId)
expect(initialOrder.length).toBeGreaterThanOrEqual(3)
const [a, b, c] = initialOrder
// Reorder via the same store call drag/drop uses: move the first tab to
// the end so the visible order becomes [b, c, a].
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
const groups = state.groupsByWorktree[targetWorktreeId] ?? []
const activeGroupId = state.activeGroupIdByWorktree[targetWorktreeId]
const activeGroup = activeGroupId
? groups.find((group) => group.id === activeGroupId)
: groups[0]
if (!activeGroup) {
return
}
const [first, ...rest] = activeGroup.tabOrder
state.reorderUnifiedTabs(activeGroup.id, [...rest, first])
}, worktreeId)
await expect
.poll(async () => getTabBarOrder(orcaPage, worktreeId), { timeout: 3_000 })
.toEqual([b, c, a])
// Activate the last tab in the new visible order, then walk left twice.
// Expected cycle: a → c → b (i.e. walks the *new* order in reverse).
await orcaPage.evaluate((tabId) => {
window.__store?.getState().setActiveTab(tabId)
}, a)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(a)
await orcaPage.keyboard.press(`${mod}+Shift+BracketLeft`)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(c)
await expect(tabLocator(orcaPage, c)).toHaveAttribute('data-active', 'true')
await orcaPage.keyboard.press(`${mod}+Shift+BracketLeft`)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(b)
await expect(tabLocator(orcaPage, b)).toHaveAttribute('data-active', 'true')
})
/**
* User Prompt:
* - closing tabs works
*
* Why: clicking the real per-tab close (X) button exercises the same path a
* user takes and catches regressions where the button silently unmounts.
* The final assertion counts rendered `[data-testid="sortable-tab"]` nodes
* so the test fails if the store cleared the tab but the DOM didn't
* re-render.
*/
test('closing a tab removes it from the tab bar', async ({ orcaPage }) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
// Need a second tab so we can close one without deactivating the worktree.
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
if ((state.tabsByWorktree[targetWorktreeId] ?? []).length < 2) {
state.createTab(targetWorktreeId)
}
}, worktreeId)
await expect
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
const tabsBefore = await countRenderedTabs(orcaPage)
const activeId = await getActiveTabId(orcaPage)
expect(activeId).not.toBeNull()
const activeTab = tabLocator(orcaPage, activeId!)
// Why: hover the tab first so the close button reveals its hover style.
// The button is interactive regardless but hovering matches real user
// behaviour and keeps click coordinates stable.
await activeTab.hover()
await activeTab.getByRole('button', { name: /^Close tab /i }).click()
await expect
.poll(() => countRenderedTabs(orcaPage), {
timeout: 5_000,
message: 'Clicking close did not remove the tab element from the DOM'
})
.toBe(tabsBefore - 1)
})
/**
* User Prompt:
* - closing tabs works
*
* The DOM check (`data-active="true"` lands on a different element) proves
* the tab bar re-paints the active indicator after a close — a store-only
* check would pass even if the indicator failed to shift.
*/
test('closing the active tab activates a neighbor tab', async ({ orcaPage }) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
await orcaPage.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
if ((state.tabsByWorktree[targetWorktreeId] ?? []).length < 2) {
state.createTab(targetWorktreeId)
}
}, worktreeId)
await expect
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
const activeTabBefore = await getActiveTabId(orcaPage)
expect(activeTabBefore).not.toBeNull()
const activeTab = tabLocator(orcaPage, activeTabBefore!)
await activeTab.hover()
await activeTab.getByRole('button', { name: /^Close tab /i }).click()
// Final DOM assertion: some *other* tab element now carries data-active.
await expect
.poll(() => getDomActiveTabId(orcaPage), {
timeout: 5_000,
message: 'After closing the active tab, no neighbor tab took over the active indicator'
})
.not.toBe(activeTabBefore)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 5_000 }).not.toBeNull()
})
})