mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* 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
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
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)
|
|
}
|
|
})
|