mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
* fix(e2e): unblock golden file-link hover and Windows worktree activate Mac/Windows tmp paths wrap across xterm rows, so locateLink never found the full absolute path. Print ./package.json instead. createGoldenWorktree used os.tmpdir() (Windows 8.3 RUNNER~1) while Git listed the long path, so activateGoldenWorktree never matched. Realpath after worktree add and compare on the Node side. * fix(e2e): handle realpath failures in golden worktree creation Ensure half-built worktrees and branches are rolled back when realpathSync fails, preventing leaks into later test runs. Extract error handling into rollbackGoldenWorktree() for consistent cleanup.
192 lines
7.3 KiB
TypeScript
192 lines
7.3 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()
|
|
return {
|
|
x: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols),
|
|
y: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows)
|
|
}
|
|
}, 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 resolvedDestination =
|
|
process.platform === 'win32' ? filePath.replaceAll('\\', '/') : filePath
|
|
// Why: Mac/Windows tmp paths soft-wrap across xterm rows; locateLink only
|
|
// indexOf's each physical row, so print a cwd-relative path that stays on one.
|
|
const printedPath = './package.json'
|
|
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 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(printedPath)
|
|
|
|
let probe: LinkProbe | null = null
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
probe = await locateLink(orcaPage, printedPath)
|
|
return probe ? hoverLink(orcaPage, probe) : null
|
|
},
|
|
{ timeout: 10_000, message: 'cwd-relative 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: destination is the resolved absolute path; Windows may use `\`.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
(await actionPopover.locator('[data-terminal-link-destination]').textContent()) ?? ''
|
|
).replaceAll('\\', '/'),
|
|
{ message: 'terminal link destination did not resolve to package.json' }
|
|
)
|
|
.toContain(resolvedDestination)
|
|
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)
|
|
}
|
|
})
|