mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* Fix flaky e2e tests with improved locators and synchronization Add explicit waits, use more robust element selectors, and simplify test setup to reduce race conditions. Replace file-based fixtures with programmatic browser creation, use parent-scoped locators for menu interactions, and poll for stable state before assertions. * Add E2E failure triage report for run 33564563164 - Reconciles 14 failed tests against job logs and trace artifacts - Categorizes failures: 8 product bugs, 2 flaky tests, 4 test updates - Documents test-maintenance fixes and diagnostic findings - Files 8 Linear issues with owners and fresh recurrence evidence - Provides next actions for product owners and repository maintenance * rm artifact notes * Refactor browser creation E2E test to use UI interactions - Click through menu instead of manipulating internal store state - Use Playwright's locator and toBeVisible() assertion patterns * Record E2E browser creation pageId before barrier check Move createdPageId assignment before the barrier arm/fire checks. This ensures the pageId is recorded unconditionally when tracking is enabled, allowing tests to distinguish between creations rejected before the host attempt vs those that failed after creation. * Remove browser page reclamation assertion from restart test Simplifies test by removing page ID tracking and poll checking if pages persist after paired runtime restart.
579 lines
22 KiB
TypeScript
579 lines
22 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 { rm } from 'node:fs/promises'
|
|
import type { Page } from '@stablyai/playwright-test'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import {
|
|
waitForSessionReady,
|
|
waitForActiveWorktree,
|
|
getActiveWorktreeId,
|
|
getActiveTabId,
|
|
getActiveTabType,
|
|
getWorktreeTabs,
|
|
getTabBarOrder,
|
|
ensureTerminalVisible,
|
|
waitForStartupWorktreeRefresh
|
|
} 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 waitForStartupWorktreeRefresh(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)
|
|
})
|
|
|
|
test('clicking "+" then "New Markdown" focuses the editor', async ({
|
|
orcaPage,
|
|
registerPostElectronShutdownCleanup
|
|
}) => {
|
|
let createdFilePath: string | null = null
|
|
registerPostElectronShutdownCleanup(async () => {
|
|
if (createdFilePath) {
|
|
await rm(createdFilePath, { force: true })
|
|
}
|
|
})
|
|
|
|
const preExistingFileIds = await orcaPage.evaluate(
|
|
() => window.__store?.getState().openFiles.map((file) => file.id) ?? []
|
|
)
|
|
|
|
await orcaPage.getByRole('button', { name: 'New tab' }).click({ force: true })
|
|
const newMarkdownMenuItem = orcaPage.getByRole('menuitem', { name: /New Markdown/i }).first()
|
|
await newMarkdownMenuItem.click({ force: true })
|
|
await expect(newMarkdownMenuItem).toBeHidden({ timeout: 3_000 })
|
|
|
|
// Why: require an id that did not exist before the click, so an already-open
|
|
// Markdown file can't satisfy the assertions (or be deleted by cleanup), and
|
|
// record the path here so cleanup still has it if a later assertion fails.
|
|
const createdFileHandle = await orcaPage.waitForFunction(
|
|
(knownFileIds) => {
|
|
const state = window.__store?.getState()
|
|
const file = state?.openFiles.find((candidate) => candidate.id === state.activeFileId)
|
|
if (!file || knownFileIds.includes(file.id)) {
|
|
return null
|
|
}
|
|
return { id: file.id, filePath: file.filePath }
|
|
},
|
|
preExistingFileIds,
|
|
{ timeout: 25_000 }
|
|
)
|
|
const createdFile = (await createdFileHandle.jsonValue())!
|
|
createdFilePath = createdFile.filePath
|
|
|
|
const editor = orcaPage.locator('.rich-markdown-editor')
|
|
await expect(editor).toBeVisible({ timeout: 25_000 })
|
|
|
|
await expect
|
|
.poll(() => editor.evaluate((element) => document.activeElement === element), {
|
|
timeout: 5_000,
|
|
message: 'Menu-created Markdown editor did not receive keyboard focus'
|
|
})
|
|
.toBe(true)
|
|
|
|
const sentinel = `autofocus-${Date.now()}`
|
|
// Why: typing through the page keyboard proves focus landed without an editor click.
|
|
await orcaPage.keyboard.type(sentinel)
|
|
await expect(editor).toContainText(sentinel)
|
|
|
|
await orcaPage.evaluate((fileId) => {
|
|
window.__store?.getState().closeFile(fileId)
|
|
}, createdFile.id)
|
|
await expect(editor).toBeHidden()
|
|
})
|
|
|
|
/**
|
|
* 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()
|
|
})
|
|
})
|