Fix terminal paste cancellation on transient blur (#6232)
Fix terminal paste cancellation on transient blur
This commit is contained in:
parent
7c9943959e
commit
1b6e65c4ef
|
|
@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element {
|
|||
} as unknown as Element
|
||||
}
|
||||
|
||||
function makePaneContainerFor(acceptedElements: readonly Element[]): Element {
|
||||
return {
|
||||
contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element))
|
||||
} as unknown as Element
|
||||
}
|
||||
|
||||
function makeElement(tagName: string, classNames: string[] = []): Element {
|
||||
return {
|
||||
tagName,
|
||||
classList: {
|
||||
contains: (className: string) => classNames.includes(className)
|
||||
}
|
||||
} as unknown as Element
|
||||
}
|
||||
|
||||
describe('terminal paste target state', () => {
|
||||
it('accepts the same mounted pane, transport, and PTY identity', () => {
|
||||
const transport = makeTransport()
|
||||
|
|
@ -128,6 +143,51 @@ describe('terminal paste target state', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps keyboard-owned paste current across transient document blur', () => {
|
||||
const terminalInput = {} as Element
|
||||
const body = makeElement('body')
|
||||
const paneContainer = makePaneContainer(terminalInput)
|
||||
|
||||
expect(
|
||||
isTerminalPanePasteFocusCurrent({
|
||||
requireSameFocusedElement: true,
|
||||
activeElementAtDispatch: terminalInput,
|
||||
paneContainer,
|
||||
activeElement: body
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => {
|
||||
const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
|
||||
const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
|
||||
const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput])
|
||||
|
||||
expect(
|
||||
isTerminalPanePasteFocusCurrent({
|
||||
requireSameFocusedElement: true,
|
||||
activeElementAtDispatch: originalTerminalInput,
|
||||
paneContainer,
|
||||
activeElement: replacementTerminalInput
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects keyboard-owned paste when focus moves to another control in the pane', () => {
|
||||
const terminalInput = makeElement('textarea', ['xterm-helper-textarea'])
|
||||
const searchInput = makeElement('input')
|
||||
const paneContainer = makePaneContainerFor([terminalInput, searchInput])
|
||||
|
||||
expect(
|
||||
isTerminalPanePasteFocusCurrent({
|
||||
requireSameFocusedElement: true,
|
||||
activeElementAtDispatch: terminalInput,
|
||||
paneContainer,
|
||||
activeElement: searchInput
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not require focus continuity for programmatic terminal paste', () => {
|
||||
const terminalInput = {} as Element
|
||||
const otherInput = {} as Element
|
||||
|
|
|
|||
|
|
@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({
|
|||
if (!requireSameFocusedElement || activeElementAtDispatch === null) {
|
||||
return true
|
||||
}
|
||||
// Why: clipboard reads are async, so focus may leave the terminal before
|
||||
// execution. In that case the stale terminal must not receive the payload.
|
||||
return (
|
||||
activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch)
|
||||
)
|
||||
if (!paneContainer.contains(activeElementAtDispatch)) {
|
||||
return false
|
||||
}
|
||||
if (activeElement === activeElementAtDispatch) {
|
||||
return true
|
||||
}
|
||||
// Why: macOS dictation and clipboard permission handoffs can transiently
|
||||
// blur xterm to body, and xterm may replace its helper textarea mid-paste.
|
||||
if (isInertDocumentFocus(activeElement)) {
|
||||
return true
|
||||
}
|
||||
if (activeElement === paneContainer) {
|
||||
return true
|
||||
}
|
||||
return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement)
|
||||
}
|
||||
|
||||
function isInertDocumentFocus(element: Element | null): boolean {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
const tagName = element.tagName?.toUpperCase()
|
||||
return tagName === 'BODY' || tagName === 'HTML'
|
||||
}
|
||||
|
||||
function isXtermHelperTextarea(element: Element | null): boolean {
|
||||
return element?.classList?.contains('xterm-helper-textarea') === true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
|
|
@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise<void> {
|
|||
await page.mouse.click(point.x, point.y, { button: 'right' })
|
||||
}
|
||||
|
||||
async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow, ipcMain }) => {
|
||||
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
|
||||
const global = globalThis as unknown as {
|
||||
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
|
||||
}
|
||||
const invokeHandlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, ClipboardReadHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
const handler = invokeHandlers?.get('clipboard:readText')
|
||||
if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) {
|
||||
return
|
||||
}
|
||||
global.__orcaOriginalClipboardReadTextHandler = handler
|
||||
invokeHandlers.set('clipboard:readText', async (event, ...args) => {
|
||||
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed())
|
||||
await Promise.all(
|
||||
windows.map((window) =>
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`
|
||||
(document.activeElement && document.activeElement.blur && document.activeElement.blur());
|
||||
document.body.tabIndex = -1;
|
||||
document.body.focus();
|
||||
`
|
||||
)
|
||||
.catch(() => undefined)
|
||||
)
|
||||
)
|
||||
// Why: reproduce the focus churn window before the async clipboard read resolves.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
return global.__orcaOriginalClipboardReadTextHandler!(event, ...args)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
|
||||
const global = globalThis as unknown as {
|
||||
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
|
||||
}
|
||||
const invokeHandlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, ClipboardReadHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) {
|
||||
invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler)
|
||||
delete global.__orcaOriginalClipboardReadTextHandler
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openTerminalContextMenu(page: Page): Promise<void> {
|
||||
const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows'))
|
||||
const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac'))
|
||||
|
|
@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('keyboard paste survives transient terminal blur during clipboard read', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await installTerminalPtyWriteSpy(electronApp)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, pasteEchoScript(runId))
|
||||
let scriptStarted = false
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
scriptStarted = true
|
||||
await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000)
|
||||
|
||||
const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}`
|
||||
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
|
||||
await clearTerminalPtyWriteLog(electronApp)
|
||||
await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await installClipboardReadTerminalBlurRepro(electronApp)
|
||||
|
||||
await orcaPage.keyboard.press(keyboardPasteChords()[0])
|
||||
await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000)
|
||||
|
||||
const writes = (await readTerminalPtyWrites(electronApp)).join('')
|
||||
expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1)
|
||||
} finally {
|
||||
await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined)
|
||||
if (scriptStarted) {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('terminal context-menu Paste sends clipboard text exactly once', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
|
|
|
|||
Loading…
Reference in New Issue