fix(terminal): preserve OSC 8 links across cold parking (#13382)
* fix(terminal): preserve OSC 8 links across cold parking * test(terminal): make OSC 8 e2e cross-platform * test(terminal): focus OSC restore e2e on activation
This commit is contained in:
parent
44cccb8cd9
commit
774bbc788f
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,7 @@ patchedDependencies:
|
|||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
|
||||
'@xterm/addon-serialize@0.15.0-beta.287':
|
||||
hash: 96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd
|
||||
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
|
|
@ -45,7 +45,7 @@ importers:
|
|||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
|
|
@ -9605,7 +9605,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// OSC 8 metadata used to disappear from hidden-terminal snapshots while its styling survived.
|
||||
import './xterm-env-polyfill'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
|
||||
type TerminalHarness = { terminal: Terminal; addon: SerializeAddon }
|
||||
type OscLinkData = { id?: string; uri: string }
|
||||
|
||||
function createTerminal(): TerminalHarness {
|
||||
const terminal = new Terminal({ cols: 80, rows: 5, scrollback: 100, allowProposedApi: true })
|
||||
const addon = new SerializeAddon()
|
||||
terminal.loadAddon(addon)
|
||||
return { terminal, addon }
|
||||
}
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, () => resolve()))
|
||||
}
|
||||
|
||||
async function replay(data: string): Promise<Terminal> {
|
||||
const { terminal } = createTerminal()
|
||||
await write(terminal, data)
|
||||
return terminal
|
||||
}
|
||||
|
||||
function oscLinkAt(terminal: Terminal, row: number, col: number): OscLinkData | null {
|
||||
const cell = terminal.buffer.active.getLine(row)?.getCell(col) as
|
||||
| { extended?: { urlId?: number } }
|
||||
| undefined
|
||||
const linkId = cell?.extended?.urlId ?? 0
|
||||
if (!linkId) {
|
||||
return null
|
||||
}
|
||||
const internals = terminal as unknown as {
|
||||
_core?: { _oscLinkService?: { getLinkData: (id: number) => OscLinkData | undefined } }
|
||||
}
|
||||
return internals._core?._oscLinkService?.getLinkData(linkId) ?? null
|
||||
}
|
||||
|
||||
describe('OSC 8 hyperlink snapshot round-trip', () => {
|
||||
it('retains a closed link URI without linking surrounding text', async () => {
|
||||
const url = 'https://github.com/stablyai/orca/issues/12345'
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `before \x1b]8;;${url}\x1b\\#12345\x1b]8;;\x1b\\ after`)
|
||||
|
||||
const restored = await replay(addon.serialize())
|
||||
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe('before #12345 after')
|
||||
expect(oscLinkAt(restored, 0, 6)).toBeNull()
|
||||
expect(oscLinkAt(restored, 0, 7)?.uri).toBe(url)
|
||||
expect(oscLinkAt(restored, 0, 12)?.uri).toBe(url)
|
||||
expect(oscLinkAt(restored, 0, 13)).toBeNull()
|
||||
})
|
||||
|
||||
it('retains an explicit OSC link id', async () => {
|
||||
const url = 'https://example.com/identified'
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `\x1b]8;id=review-42;${url}\x1b\\review\x1b]8;;\x1b\\`)
|
||||
|
||||
const restored = await replay(addon.serialize())
|
||||
expect(oscLinkAt(restored, 0, 0)).toEqual({ id: 'review-42', uri: url })
|
||||
})
|
||||
|
||||
it('keeps an open link active for output arriving after replay', async () => {
|
||||
const url = 'https://example.com/streaming-link'
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `\x1b]8;id=stream;${url}\x1b\\linked`)
|
||||
|
||||
const restored = await replay(addon.serialize())
|
||||
await write(restored, 'Z\x1b]8;;\x1b\\')
|
||||
expect(oscLinkAt(restored, 0, 6)).toEqual({ id: 'stream', uri: url })
|
||||
})
|
||||
|
||||
it('retains the URI across repeated serialize and replay cycles', async () => {
|
||||
const url = 'https://example.com/repeated'
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `\x1b]8;;${url}\x1b\\again\x1b]8;;\x1b\\`)
|
||||
|
||||
const first = await replay(addon.serialize())
|
||||
const secondAddon = new SerializeAddon()
|
||||
first.loadAddon(secondAddon)
|
||||
const second = await replay(secondAddon.serialize())
|
||||
expect(oscLinkAt(second, 0, 0)?.uri).toBe(url)
|
||||
})
|
||||
|
||||
it('does not leak an open alternate-screen link into its unlinked prefix', async () => {
|
||||
const url = 'https://example.com/alternate'
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `normal\x1b[?1049h\x1b[Hprefix \x1b]8;id=alternate;${url}\x1b\\linked`)
|
||||
|
||||
const restored = await replay(addon.serialize())
|
||||
expect(restored.buffer.active.type).toBe('alternate')
|
||||
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe('prefix linked')
|
||||
expect(oscLinkAt(restored, 0, 0)).toBeNull()
|
||||
expect(oscLinkAt(restored, 0, 7)).toEqual({ id: 'alternate', uri: url })
|
||||
await write(restored, 'Z\x1b]8;;\x1b\\')
|
||||
expect(oscLinkAt(restored, 0, 13)).toEqual({ id: 'alternate', uri: url })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
getBrowserTabs,
|
||||
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'
|
||||
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
type LinkProbe = { clientX: number; clientY: number; tabId: string }
|
||||
|
||||
async function locateLink(page: Page, label: string): Promise<LinkProbe> {
|
||||
return page.evaluate((label) => {
|
||||
const state = window.__store?.getState()
|
||||
const tabId = state?.activeTabId ?? null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
|
||||
if (!tabId || !pane || !screen) {
|
||||
throw new Error('active terminal pane unavailable')
|
||||
}
|
||||
|
||||
const buffer = pane.terminal.buffer.active
|
||||
for (let row = pane.terminal.rows - 1; row >= 0; row -= 1) {
|
||||
const line = buffer.getLine(buffer.viewportY + row)
|
||||
const col = line?.translateToString(true).lastIndexOf(label) ?? -1
|
||||
if (col >= 0) {
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return {
|
||||
clientX: rect.left + (col + label.length / 2) * (rect.width / pane.terminal.cols),
|
||||
clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows),
|
||||
tabId
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('OSC 8 label not visible in terminal viewport')
|
||||
}, label)
|
||||
}
|
||||
|
||||
async function readLinkState(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
label: string
|
||||
): Promise<{
|
||||
bufferType: string
|
||||
serializedUri: boolean
|
||||
underlined: boolean
|
||||
uri: string | null
|
||||
}> {
|
||||
return page.evaluate(
|
||||
({ label, tabId }) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('terminal pane unavailable')
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
for (let row = buffer.viewportY; row < buffer.viewportY + pane.terminal.rows; row += 1) {
|
||||
const line = buffer.getLine(row)
|
||||
const col = line?.translateToString(true).lastIndexOf(label) ?? -1
|
||||
if (col < 0) {
|
||||
continue
|
||||
}
|
||||
const cell = line?.getCell(col) as
|
||||
| (ReturnType<NonNullable<typeof line>['getCell']> & {
|
||||
extended?: { urlId?: number }
|
||||
})
|
||||
| undefined
|
||||
const linkId = cell?.extended?.urlId ?? 0
|
||||
const terminal = pane.terminal as unknown as {
|
||||
_core?: {
|
||||
_oscLinkService?: { getLinkData: (id: number) => { uri: string } | undefined }
|
||||
}
|
||||
}
|
||||
const uri = linkId
|
||||
? (terminal._core?._oscLinkService?.getLinkData(linkId)?.uri ?? null)
|
||||
: null
|
||||
return {
|
||||
bufferType: buffer.type,
|
||||
serializedUri: uri ? pane.serializeAddon.serialize().includes(uri) : false,
|
||||
underlined: !!cell?.isUnderline(),
|
||||
uri
|
||||
}
|
||||
}
|
||||
throw new Error('OSC 8 label disappeared from terminal buffer')
|
||||
},
|
||||
{ label, tabId }
|
||||
)
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Orca store unavailable')
|
||||
}
|
||||
state.setActiveTabType('terminal')
|
||||
state.setActiveTab(tabId)
|
||||
}, tabId)
|
||||
await expect.poll(() => getActiveTabId(page)).toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
}
|
||||
|
||||
test('restores and opens an OSC 8 link after its terminal is cold-parked', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await orcaPage.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({
|
||||
openLinksInApp: true,
|
||||
openLinksInAppPreferencePrompted: true
|
||||
})
|
||||
})
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const tabId = await getActiveTabId(orcaPage)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
|
||||
const label = `#${randomUUID().slice(0, 6)}`
|
||||
const url = `https://example.com/orca-osc8-${randomUUID()}`
|
||||
const linkedOutput = `\x1b[?1049h\x1b[2J\x1b[H\x1b]8;id=cold-park;${url}\x1b\\${label}\x1b]8;;\x1b\\\n`
|
||||
await sendToTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
`${nodeTerminalCommand(['-e', `process.stdout.write(${JSON.stringify(linkedOutput)})`])}\r`
|
||||
)
|
||||
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
|
||||
|
||||
const baselineProbe = await locateLink(orcaPage, label)
|
||||
await orcaPage.mouse.move(baselineProbe.clientX, baselineProbe.clientY)
|
||||
await expect
|
||||
.poll(() => readLinkState(orcaPage, tabId, label))
|
||||
.toMatchObject({
|
||||
bufferType: 'alternate',
|
||||
serializedUri: true,
|
||||
underlined: true,
|
||||
uri: url
|
||||
})
|
||||
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
|
||||
parkDelayMs: PARKING_DELAY_MS
|
||||
})
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
|
||||
|
||||
const restoredProbe = await locateLink(orcaPage, label)
|
||||
await orcaPage.mouse.move(restoredProbe.clientX, restoredProbe.clientY)
|
||||
await expect
|
||||
.poll(() => readLinkState(orcaPage, tabId, label))
|
||||
.toMatchObject({
|
||||
bufferType: 'alternate',
|
||||
serializedUri: true,
|
||||
underlined: true,
|
||||
uri: url
|
||||
})
|
||||
|
||||
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await orcaPage.keyboard.down(modifier)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.mouse.up()
|
||||
await orcaPage.keyboard.up(modifier)
|
||||
await expect
|
||||
.poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).some((tab) => tab.url === url))
|
||||
.toBe(true)
|
||||
})
|
||||
Loading…
Reference in New Issue