mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
test(e2e): add golden E2E tests for workspace session management (#14304)
* test(e2e): add golden E2E tests for workspace session management - Restore exact file and terminal state after quit/relaunch - Verify terminal file link activation and external edit detection - Test worktree creation and switching with isolated terminals - Isolate test repo paths between concurrent CI runs with UUIDs * Add platform-aware marker echo command utility - Create splitMarkerEchoCommand() to generate shell commands that safely echo test markers across Windows and Unix platforms - Split markers into prefix/suffix fragments so output assertions prove execution, not just shell echo-back - Consolidate SORTABLE_TAB export and improve tab bar locator logic - Refactor terminal link helpers to extract client point calculation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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<LinkProbe | null> {
|
||||
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<LinkClientPoint> {
|
||||
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<HTMLElement>('.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<string | null> {
|
||||
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<HTMLElement>('.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<void> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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<Locator> {
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
@@ -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}"`
|
||||
}
|
||||
Reference in New Issue
Block a user