diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 205faa49957..af7ffb12081 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -67,6 +67,7 @@ jobs: if: runner.os == 'Linux' run: | xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi @@ -78,6 +79,7 @@ jobs: if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi @@ -91,6 +93,7 @@ jobs: run: | $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c881765b340..e79e3c64184 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -876,6 +876,7 @@ jobs: - name: Run terminal rendering golden on Linux if: runner.os == 'Linux' run: | + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -886,6 +887,7 @@ jobs: - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' run: | + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -899,6 +901,7 @@ jobs: run: | $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden pnpm run --if-present test:e2e:source-control-golden diff --git a/package.json b/package.json index 368e3660de6..ed44984d97f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", + "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index cea4e7717a7..9b028ee572f 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -17,13 +17,18 @@ import path from 'node:path' import os from 'node:os' import { prepareDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +export const E2E_TEST_REPO_PATH_FILE_ENV = 'ORCA_E2E_TEST_REPO_PATH_FILE' /** Temp file where the test repo path is stored for the fixture to read. */ -export const TEST_REPO_PATH_FILE = path.join(os.tmpdir(), 'orca-e2e-test-repo-path.txt') +export const TEST_REPO_PATH_FILE = + process.env[E2E_TEST_REPO_PATH_FILE_ENV] ?? + path.join(os.tmpdir(), `orca-e2e-test-repo-path-${randomUUID()}.txt`) const ELECTRON_E2E_BUILD_TIMEOUT_MS = 300_000 const CLI_E2E_BUILD_TIMEOUT_MS = 120_000 const WEB_E2E_BUILD_TIMEOUT_MS = 300_000 export default function globalSetup(): void { + // Why: workers and teardown need this run's path, never another concurrent run's. + process.env[E2E_TEST_REPO_PATH_FILE_ENV] = TEST_REPO_PATH_FILE const root = process.cwd() const outMain = path.join(root, 'out', 'main', 'index.js') const outCli = path.join(root, 'out', 'cli', 'index.js') diff --git a/tests/e2e/golden-quit-relaunch-session.spec.ts b/tests/e2e/golden-quit-relaunch-session.spec.ts new file mode 100644 index 00000000000..4f00f5af055 --- /dev/null +++ b/tests/e2e/golden-quit-relaunch-session.spec.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { createTerminalTabFromMenu, SORTABLE_TAB } from './helpers/terminal-tab-menu' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { splitMarkerEchoCommand } from './terminal-marker-echo-command' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +function seededRepoPath(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + if (!repoPath || !existsSync(repoPath)) { + throw new Error('Golden restart E2E requires the seeded test repo from global setup') + } + return repoPath +} + +test('restores the exact file and live extra terminal after quit and relaunch @golden', async (// oxlint-disable-next-line no-empty-pattern -- this test owns both Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = seededRepoPath() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + const primaryWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + const extraTerminalId = await createTerminalTabFromMenu(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + const extraTerminalPtyId = await waitForActivePanePtyId(first.page, 30_000) + expect(extraTerminalPtyId).toContain(PTY_SESSION_ID_SEPARATOR) + const preQuitMarkerPrefix = 'GOLDEN_RELAUNCH_PERSISTED' + const preQuitMarkerSuffix = `_${Date.now()}` + const preQuitMarker = `${preQuitMarkerPrefix}${preQuitMarkerSuffix}` + await execInTerminal( + first.page, + extraTerminalPtyId, + splitMarkerEchoCommand(preQuitMarkerPrefix, preQuitMarkerSuffix) + ) + await waitForTerminalOutput(first.page, preQuitMarker, 20_000) + + await openFileExplorer(first.page) + const fileRow = first.page + .locator('[data-file-explorer-row]') + .filter({ hasText: 'package.json' }) + .first() + await expect(fileRow).toBeVisible({ timeout: 15_000 }) + await fileRow.click() + await expect(first.page.locator('.editor-header-path').first()).toContainText('package.json', { + timeout: 20_000 + }) + const tabCountAtQuit = await first.page.locator(SORTABLE_TAB).count() + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await expect( + second.page.locator(`[role="option"][data-worktree-id="${primaryWorktreeId}"]`) + ).toHaveAttribute('aria-current', 'page', { timeout: 30_000 }) + await expect(second.page.locator(SORTABLE_TAB)).toHaveCount(tabCountAtQuit, { + timeout: 30_000 + }) + await expect(second.page.locator('.editor-header-path').first()).toContainText('package.json', { + timeout: 30_000 + }) + + const restoredExtraTab = second.page.locator( + `${SORTABLE_TAB}[data-tab-id="${extraTerminalId}"]` + ) + await expect(restoredExtraTab).toBeVisible() + await restoredExtraTab.click({ force: true }) + await waitForActiveTerminalManager(second.page, 30_000) + expect(await waitForActivePanePtyId(second.page, 30_000)).toBe(extraTerminalPtyId) + await waitForTerminalOutput(second.page, preQuitMarker, 20_000) + await expect(second.page.getByRole('button', { name: /Reconnect/i })).toHaveCount(0) + await focusActiveTerminalInput(second.page) + const marker = `GOLDEN_RELAUNCH_LIVE_${Date.now()}` + const command = process.platform === 'win32' ? `Write-Output ${marker}` : `echo ${marker}` + await second.page.keyboard.type(command) + await second.page.keyboard.press('Enter') + await waitForTerminalOutput(second.page, marker, 20_000) + } finally { + for (const app of [secondApp, firstApp]) { + if (app) { + await session.close(app).catch(() => undefined) + } + } + await session.dispose() + } +}) diff --git a/tests/e2e/golden-terminal-file-link.spec.ts b/tests/e2e/golden-terminal-file-link.spec.ts new file mode 100644 index 00000000000..eeb0742c4d6 --- /dev/null +++ b/tests/e2e/golden-terminal-file-link.spec.ts @@ -0,0 +1,188 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { nodeTerminalCommand } from './terminal-node-command' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +type LinkProbe = { col: number; row: number; tabId: string } +type LinkClientPoint = { x: number; y: number } + +const LINK_SCAN_CHAR_LIMIT = 12_000 + +async function locateLink(page: Page, needle: string): Promise { + return page.evaluate((needle) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? state?.activeTabIdByWorktree?.[worktreeId] + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + return null + } + const buffer = pane.terminal.buffer.active + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + const col = line?.translateToString(true).indexOf(needle) ?? -1 + if (col >= 0) { + return { col: col + Math.floor(needle.length / 2), row, tabId } + } + } + return null + }, needle) +} + +async function linkClientPoint(page: Page, probe: LinkProbe): Promise { + return page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('terminal link surface unavailable') + } + const rect = screen.getBoundingClientRect() + const cell = pane.terminal.dimensions?.css.cell + if (!cell?.width || !cell.height) { + throw new Error('terminal cell dimensions unavailable') + } + return { + x: rect.left + (col + 0.5) * cell.width, + y: rect.top + (row + 0.5) * cell.height + } + }, probe) +} + +async function hoverLink(page: Page, probe: LinkProbe): Promise { + const point = await linkClientPoint(page, probe) + return page.evaluate( + ({ tabId, x, y }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('terminal link surface unavailable') + } + screen.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y }) + ) + const terminal = pane.terminal as unknown as { + _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } + } + return terminal._core?.linkifier?.currentLink?.link?.text ?? null + }, + { tabId: probe.tabId, ...point } + ) +} + +async function clickLink(page: Page, probe: LinkProbe): Promise { + const target = await linkClientPoint(page, probe) + await page.mouse.move(target.x, target.y) + await page.mouse.click(target.x, target.y) +} + +test('opens a terminal file link and observes an external edit @golden', async ({ orcaPage }) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const worktreePath = await orcaPage.evaluate((id) => { + return ( + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === id)?.path ?? '' + ) + }, worktreeId) + const filePath = path.join(worktreePath, 'package.json') + const original = readFileSync(filePath, 'utf8') + const clickablePath = process.platform === 'win32' ? filePath.replaceAll('\\', '/') : filePath + const changedMarker = `golden-external-edit-${Date.now()}` + + try { + await openFileExplorer(orcaPage) + const explorerRow = orcaPage + .locator('[data-file-explorer-row]') + .filter({ hasText: 'package.json' }) + .first() + await expect(explorerRow).toBeVisible({ timeout: 15_000 }) + + const pathsToPrint = process.platform === 'win32' ? [filePath, clickablePath] : [filePath] + for (const printedPath of pathsToPrint) { + const command = nodeTerminalCommand(['-e', `console.log(${JSON.stringify(printedPath)})`]) + await sendToTerminal(orcaPage, ptyId, `${command}\r`) + } + await expect + .poll(() => getTerminalContent(orcaPage, LINK_SCAN_CHAR_LIMIT), { timeout: 15_000 }) + .toContain(clickablePath) + + let probe: LinkProbe | null = null + await expect + .poll( + async () => { + probe = await locateLink(orcaPage, clickablePath) + return probe ? hoverLink(orcaPage, probe) : null + }, + { timeout: 10_000, message: 'absolute file path did not become clickable' } + ) + .toContain('package.json') + if (!probe) { + throw new Error('terminal file link disappeared before activation') + } + await clickLink(orcaPage, probe) + + const actionPopover = orcaPage.locator('[data-terminal-link-action-popover]') + await expect(actionPopover).toBeVisible() + // Why: the popover echoes the link text verbatim, which on Windows is the forward-slash form. + await expect(actionPopover.locator('[data-terminal-link-destination]')).toContainText( + clickablePath + ) + await actionPopover.getByRole('button', { name: /Open file/i }).click() + + const editorHeader = orcaPage.locator('.editor-header-path').first() + await expect(editorHeader).toContainText('package.json', { timeout: 20_000 }) + await expect(explorerRow).toHaveAttribute('data-selected', 'true', { timeout: 10_000 }) + await expect + .poll( + () => + orcaPage.evaluate( + (expectedPath) => window.__monacoEditorE2E?.filePath === expectedPath, + filePath + ), + { timeout: 20_000, message: 'Monaco opened a different file identity' } + ) + .toBe(true) + + writeFileSync(filePath, `${original.trimEnd()}\n\n${changedMarker}\n`) + await expect + .poll( + async () => { + const snapshot = await orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot()) + const reloadVisible = await orcaPage + .getByRole('button', { name: 'Reload from Disk' }) + .isVisible() + .catch(() => false) + return Boolean(snapshot?.valueTail.includes(changedMarker) || reloadVisible) + }, + { timeout: 20_000, message: 'editor stayed silently stale after the external edit' } + ) + .toBe(true) + } finally { + writeFileSync(filePath, original) + } +}) diff --git a/tests/e2e/golden-worktree-create-switch.spec.ts b/tests/e2e/golden-worktree-create-switch.spec.ts new file mode 100644 index 00000000000..15d9f516c92 --- /dev/null +++ b/tests/e2e/golden-worktree-create-switch.spec.ts @@ -0,0 +1,77 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getActiveWorktreeId, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { createTerminalTabFromMenu } from './helpers/terminal-tab-menu' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { splitMarkerEchoCommand } from './terminal-marker-echo-command' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +async function createWorkspace(page: Page, name: string): Promise { + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(dialog).toBeVisible() + await dialog.getByPlaceholder(/Type a name/i).fill(name) + await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click() + await expect(dialog).toBeHidden({ timeout: 20_000 }) +} + +async function removeCreatedWorktree(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + await window.__store?.getState().removeWorktree(id, true) + }, worktreeId) +} + +test('creates a worktree, keeps its terminal isolated, and switches back @golden', async ({ + orcaPage +}) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const originalWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const parentPtyId = await waitForActivePanePtyId(orcaPage) + const workspaceName = `golden-switch-${Date.now()}` + let childWorktreeId: string | null = null + + try { + await createWorkspace(orcaPage, workspaceName) + await expect( + orcaPage.locator('[role="option"][aria-current="page"]').filter({ hasText: workspaceName }) + ).toBeVisible({ timeout: 30_000 }) + childWorktreeId = await waitForActiveWorktree(orcaPage) + // Why: the cleanup force-removes childWorktreeId, so it must never resolve to the original. + expect(childWorktreeId).not.toBe(originalWorktreeId) + await expect( + orcaPage.locator(`[role="option"][data-worktree-id="${childWorktreeId}"]`) + ).toHaveAttribute('aria-current', 'page') + + await createTerminalTabFromMenu(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const childPtyId = await waitForActivePanePtyId(orcaPage) + expect(childPtyId).not.toBe(parentPtyId) + await waitForPtyShellEcho(orcaPage, childPtyId, 15_000) + await execInTerminal(orcaPage, childPtyId, splitMarkerEchoCommand('worktree', '-b')) + await waitForTerminalOutput(orcaPage, 'worktree-b') + + await orcaPage.locator(`[role="option"][data-worktree-id="${originalWorktreeId}"]`).click() + await expect( + orcaPage.locator(`[role="option"][data-worktree-id="${originalWorktreeId}"]`) + ).toHaveAttribute('aria-current', 'page', { timeout: 20_000 }) + await waitForActiveTerminalManager(orcaPage, 30_000) + expect(await waitForActivePanePtyId(orcaPage, 30_000)).toBe(parentPtyId) + } finally { + if (childWorktreeId) { + if ((await getActiveWorktreeId(orcaPage).catch(() => null)) !== originalWorktreeId) { + await orcaPage + .locator(`[role="option"][data-worktree-id="${originalWorktreeId}"]`) + .click() + .catch(() => undefined) + } + await removeCreatedWorktree(orcaPage, childWorktreeId).catch(() => undefined) + } + } +}) diff --git a/tests/e2e/helpers/terminal-tab-menu.ts b/tests/e2e/helpers/terminal-tab-menu.ts new file mode 100644 index 00000000000..49e8524b7c7 --- /dev/null +++ b/tests/e2e/helpers/terminal-tab-menu.ts @@ -0,0 +1,62 @@ +import { expect } from '@stablyai/playwright-test' +import type { Locator, Page } from '@stablyai/playwright-test' +import { getActiveTabId } from './store' + +export const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +// Why: split groups and hidden worktrees keep extra tab bars mounted, so the "+" has to come from +// the active group's strip; the pre-layout titlebar fallback has no strip to scope to. +async function activeTabBarRoot(page: Page): Promise { + const groupId = await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!worktreeId) { + return null + } + return ( + state?.activeGroupIdByWorktree?.[worktreeId] ?? + state?.groupsByWorktree?.[worktreeId]?.[0]?.id ?? + null + ) + }) + if (!groupId) { + return page.locator('body') + } + const strip = page.locator(`[data-tab-group-strip-id="${groupId}"]`) + return (await strip.count()) > 0 ? strip : page.locator('body') +} + +export async function createTerminalTabFromMenu(page: Page): Promise { + const tabBar = await activeTabBarRoot(page) + const tabsBefore = await tabBar.locator(SORTABLE_TAB).count() + const activeBefore = await getActiveTabId(page) + + await tabBar.getByRole('button', { name: 'New tab' }).click() + await page + .getByRole('menuitem', { name: /New Terminal/i }) + .first() + .click() + + await expect + .poll(() => tabBar.locator(SORTABLE_TAB).count(), { + timeout: 10_000, + message: 'New Terminal did not render a new tab in the tab bar' + }) + .toBe(tabsBefore + 1) + + await expect + .poll( + async () => { + const current = await getActiveTabId(page) + return Boolean(current && current !== activeBefore) + }, + { timeout: 10_000, message: 'New Terminal did not become the active tab' } + ) + .toBe(true) + + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('New Terminal tab id was unavailable after creation') + } + return tabId +} diff --git a/tests/e2e/terminal-marker-echo-command.ts b/tests/e2e/terminal-marker-echo-command.ts new file mode 100644 index 00000000000..feba80c410f --- /dev/null +++ b/tests/e2e/terminal-marker-echo-command.ts @@ -0,0 +1,8 @@ +// Why: the shell echoes the command line back into the buffer, so a marker written as one literal +// would satisfy an output assertion even if the command never ran. Splitting it into two fragments +// that only rejoin in the command's output makes the assertion proof of execution. +export function splitMarkerEchoCommand(prefix: string, suffix: string): string { + return process.platform === 'win32' + ? `Write-Output ('${prefix}' + '${suffix}')` + : `echo "${prefix}""${suffix}"` +}