417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
import type { Page } from '@stablyai/playwright-test'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import {
|
|
execInTerminal,
|
|
getTerminalContent,
|
|
waitForActivePanePtyId,
|
|
waitForActiveTerminalManager
|
|
} from './helpers/terminal'
|
|
import {
|
|
ensureTerminalVisible,
|
|
getActiveTabId,
|
|
waitForActiveWorktree,
|
|
waitForSessionReady
|
|
} from './helpers/store'
|
|
import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log'
|
|
import { POST_REPLAY_MODE_RESET } from '../../src/renderer/src/components/terminal-pane/layout-serialization'
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
async function countRenderedTabs(page: Page): Promise<number> {
|
|
return page.locator('[data-testid="sortable-tab"]').count()
|
|
}
|
|
|
|
async function createTerminalTab(page: Page): Promise<string> {
|
|
const tabsBefore = await countRenderedTabs(page)
|
|
const activeBefore = await getActiveTabId(page)
|
|
|
|
await page.getByRole('button', { name: 'New tab' }).click()
|
|
await page
|
|
.getByRole('menuitem', { name: /New Terminal/i })
|
|
.first()
|
|
.click()
|
|
|
|
await expect
|
|
.poll(() => countRenderedTabs(page), {
|
|
timeout: 5_000,
|
|
message: 'New Terminal did not render a new tab in the tab bar'
|
|
})
|
|
.toBe(tabsBefore + 1)
|
|
|
|
let tabId: string | null = null
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
tabId = await getActiveTabId(page)
|
|
return Boolean(tabId && tabId !== activeBefore)
|
|
},
|
|
{
|
|
timeout: 5_000,
|
|
message: 'New Terminal did not become the active tab'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
|
|
if (!tabId) {
|
|
throw new Error('createTerminalTab: active tab id was unavailable after creating terminal')
|
|
}
|
|
|
|
return tabId
|
|
}
|
|
|
|
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
|
await page.evaluate((targetTabId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('activateTerminalTab: window.__store is unavailable')
|
|
}
|
|
const state = store.getState()
|
|
state.setActiveTabType('terminal')
|
|
state.setActiveTab(targetTabId)
|
|
}, tabId)
|
|
|
|
await expect
|
|
.poll(async () => getActiveTabId(page), {
|
|
timeout: 5_000,
|
|
message: `Terminal tab ${tabId} did not become active`
|
|
})
|
|
.toBe(tabId)
|
|
}
|
|
|
|
async function emitBellAndWaitForTitleFlush(
|
|
page: Page,
|
|
ptyId: string,
|
|
markerTitle: string
|
|
): Promise<void> {
|
|
// Why: the OSC title marker is a deterministic byte-stream fence. Once it
|
|
// lands in the renderer, the preceding BEL has traversed the same PTY path.
|
|
// printf is a shell builtin, so this still works in stripped CI PATHs.
|
|
await execInTerminal(page, ptyId, `printf '\\a\\033]0;${markerTitle}\\007'`)
|
|
await expect
|
|
.poll(async () => (await getRendererTitleLog(page)).includes(markerTitle), {
|
|
timeout: 10_000,
|
|
message: 'Marker title did not land — byte stream may not have been flushed'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
async function proveShellReadyWithSingleWrite(page: Page, ptyId: string): Promise<void> {
|
|
const marker = `__SHELL_READY_${Date.now()}__`
|
|
// Why: this is intentionally a single write after the pane has a concrete
|
|
// PTY binding. Retrying here would hide a real lost-write regression.
|
|
await execInTerminal(page, ptyId, `printf '${marker}\\n'`)
|
|
await expect
|
|
.poll(async () => (await getTerminalContent(page)).includes(marker), {
|
|
timeout: 10_000,
|
|
message: 'Terminal did not echo the single shell-ready marker write'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
async function getUnreadTerminalTabIds(page: Page): Promise<string[]> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
return Object.keys(store.getState().unreadTerminalTabs)
|
|
})
|
|
}
|
|
|
|
test.describe('Terminal attention', () => {
|
|
// Why: pty-connection unit tests own raw BEL-byte detection. This E2E owns
|
|
// the cross-component attention contract: background terminal attention
|
|
// raises the tab indicator, and focusing the tab clears it.
|
|
test('background terminal attention marks a tab unread and clears on focus', async ({
|
|
orcaPage
|
|
}) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const firstTabId = await getActiveTabId(orcaPage)
|
|
if (!firstTabId) {
|
|
throw new Error('Expected an initial terminal tab')
|
|
}
|
|
|
|
const secondTabId = await createTerminalTab(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
// Focus the first tab so the second becomes a background tab; attention
|
|
// arriving there should raise its indicator.
|
|
await activateTerminalTab(orcaPage, firstTabId)
|
|
await orcaPage.evaluate((tabId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is unavailable')
|
|
}
|
|
const state = store.getState()
|
|
const ownerWorktreeId =
|
|
Object.entries(state.tabsByWorktree).find(([, tabs]) =>
|
|
tabs.some((tab) => tab.id === tabId)
|
|
)?.[0] ?? null
|
|
if (!ownerWorktreeId) {
|
|
throw new Error(`No owner worktree found for terminal tab ${tabId}`)
|
|
}
|
|
state.markWorktreeUnread(ownerWorktreeId)
|
|
state.markTerminalTabUnread(tabId)
|
|
}, secondTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(secondTabId), {
|
|
timeout: 10_000,
|
|
message: 'Background tab did not become unread after BEL'
|
|
})
|
|
.toBe(true)
|
|
|
|
const secondTabBell = orcaPage
|
|
.locator(
|
|
`[data-testid="sortable-tab"][data-tab-id="${secondTabId}"] [data-testid="tab-activity-bell"]`
|
|
)
|
|
.first()
|
|
await expect(secondTabBell).toBeVisible()
|
|
|
|
// Activating the tab counts as "the user saw it" — the indicator clears.
|
|
await activateTerminalTab(orcaPage, secondTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(secondTabId), {
|
|
timeout: 5_000,
|
|
message: 'Unread state did not clear when the user focused the tab'
|
|
})
|
|
.toBe(false)
|
|
await expect(secondTabBell).toBeHidden()
|
|
})
|
|
|
|
// Why (show-until-interact): ghostty's model fires the bell even on the
|
|
// currently-focused tab — the user only dismisses it by actually engaging
|
|
// with the pane. This test proves the BEL on a focused tab is visible
|
|
// until a pointerdown on the terminal container clears it.
|
|
test('a BEL on the focused tab raises, then clears on click', async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const activeTabId = await getActiveTabId(orcaPage)
|
|
if (!activeTabId) {
|
|
throw new Error('Expected an active terminal tab')
|
|
}
|
|
const activePtyId = await waitForActivePanePtyId(orcaPage)
|
|
await proveShellReadyWithSingleWrite(orcaPage, activePtyId)
|
|
await installRendererTitleLog(orcaPage)
|
|
|
|
await emitBellAndWaitForTitleFlush(
|
|
orcaPage,
|
|
activePtyId,
|
|
`focused-tab-bell-marker-${Date.now()}`
|
|
)
|
|
|
|
// The focused tab is now unread — the bell persists until the user
|
|
// actually interacts with the pane.
|
|
expect((await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId)).toBe(true)
|
|
const activeTabBell = orcaPage
|
|
.locator(
|
|
`[data-testid="sortable-tab"][data-tab-id="${activeTabId}"] [data-testid="tab-activity-bell"]`
|
|
)
|
|
.first()
|
|
await expect(activeTabBell).toBeVisible()
|
|
|
|
// A pointerdown inside the terminal container counts as interaction
|
|
// (matches the pointerdown handler added in TerminalPane.tsx). Drive it
|
|
// via the DOM so we exercise the real listener path rather than bypassing
|
|
// to the store action.
|
|
await orcaPage.evaluate((tabId) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
const container = pane?.container
|
|
if (!container) {
|
|
throw new Error('No active pane container to click')
|
|
}
|
|
container.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }))
|
|
}, activeTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId), {
|
|
timeout: 5_000,
|
|
message: 'Unread state did not clear after interacting with the pane'
|
|
})
|
|
.toBe(false)
|
|
await expect(activeTabBell).toBeHidden()
|
|
})
|
|
|
|
// Why (restart regression guard): the original user-reported bug was that
|
|
// after restarting Orca with a Claude Code session open, clicking between
|
|
// panes on the restored tab produced undismissable bell indicators. Root
|
|
// cause: xterm's SerializeAddon captures the TUI's mode-setting bytes
|
|
// (e.g. `\e[?1004h` for focus reporting) in the scrollback snapshot, and
|
|
// replaying that snapshot on restart re-enables focus reporting in xterm
|
|
// even though the underlying shell is fresh. Pane clicks then emit
|
|
// `\e[I` / `\e[O` into zsh, which rings the bell as unbound-key input.
|
|
//
|
|
// POST_REPLAY_MODE_RESET (in layout-serialization.ts) clears these mode
|
|
// bits after every scrollback replay so the mode state matches the fresh
|
|
// shell. This test pins that fix: after writing a DECSET 1004 byte into
|
|
// the terminal, focus events should NOT be emitted back to the PTY.
|
|
//
|
|
// We drive xterm directly with the focus-enable escape (simulating what
|
|
// the replay would do) and then simulate focus changes — without the
|
|
// reset, xterm would dutifully emit focus escapes; with the reset, mode
|
|
// 1004 is off and nothing leaks to the shell, so no BELs fire.
|
|
test('mode bits replayed into xterm do not leak focus escapes to the shell', async ({
|
|
orcaPage
|
|
}) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const firstTabId = await getActiveTabId(orcaPage)
|
|
if (!firstTabId) {
|
|
throw new Error('Expected an initial terminal tab')
|
|
}
|
|
|
|
const secondTabId = await createTerminalTab(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
// secondTabId is already active after createTerminalTab. Simulate what
|
|
// scrollback replay does: a DECSET 1004 byte landing in xterm. Then
|
|
// install an onData spy so we can observe everything xterm emits from
|
|
// this point on — crucially, the focus escapes `\e[I` / `\e[O` that
|
|
// leak when mode 1004 is still enabled. The POST_REPLAY_MODE_RESET
|
|
// bundle should turn mode 1004 OFF; if it does, no focus escape is
|
|
// emitted on the next blur and the spy's buffer stays empty.
|
|
// Why: xterm's parser is async — bytes passed to `write()` are queued and
|
|
// consumed on a later tick. During the brief window when mode 1004 is
|
|
// enabled, xterm emits a synchronous focus-IN (`\e[I`) because the
|
|
// terminal is focused; that emission MUST NOT land in the spy or the
|
|
// assertion below will false-positive even when the post-replay reset
|
|
// worked correctly.
|
|
//
|
|
// We use xterm's `write(data, callback)` overload: the callback fires
|
|
// AFTER the parser has consumed that write. By installing the spy inside
|
|
// the callback for the POST_REPLAY_MODE_RESET write, we guarantee any
|
|
// transient focus escapes emitted while mode 1004 was briefly on have
|
|
// already fired before the spy exists. No fixed sleep needed.
|
|
await orcaPage.evaluate(
|
|
({ tabId, modeReset }) =>
|
|
new Promise<void>((resolve, reject) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
if (!pane) {
|
|
reject(new Error('No active pane on restored tab'))
|
|
return
|
|
}
|
|
pane.terminal.write('\x1b[?1004h')
|
|
pane.terminal.write(modeReset, () => {
|
|
// Parser has consumed both the DECSET and the reset. Any focus
|
|
// escapes from the brief 1004-ON window have already been emitted
|
|
// (and dropped on the floor, since nothing was listening). Install
|
|
// the spy now to observe only post-reset output.
|
|
const recorded: string[] = []
|
|
;(window as unknown as { __XTERM_ONDATA_SPY__: string[] }).__XTERM_ONDATA_SPY__ =
|
|
recorded
|
|
const disposer = pane.terminal.onData((data) => {
|
|
recorded.push(data)
|
|
})
|
|
;(
|
|
window as unknown as { __XTERM_ONDATA_DISPOSE__?: () => void }
|
|
).__XTERM_ONDATA_DISPOSE__ = () => disposer.dispose()
|
|
resolve()
|
|
})
|
|
}),
|
|
{ tabId: secondTabId, modeReset: POST_REPLAY_MODE_RESET }
|
|
)
|
|
|
|
// Why (try/finally): the onData spy + disposer live on window globals on
|
|
// the shared renderer. If any assertion below throws, we still MUST tear
|
|
// down the spy so it doesn't leak into subsequent tests (which would see
|
|
// stale captured data and/or a dangling xterm onData subscription).
|
|
try {
|
|
// Trigger focus change away from secondTabId. If mode 1004 is still
|
|
// enabled, xterm will emit `\e[O` via onData — captured by the spy above.
|
|
// Also explicitly blur the xterm instance so the DOM focus actually moves
|
|
// (setActiveTab alone doesn't blur focus).
|
|
await activateTerminalTab(orcaPage, firstTabId)
|
|
await orcaPage.evaluate((tabId) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
if (!pane) {
|
|
return
|
|
}
|
|
pane.terminal.blur()
|
|
}, secondTabId)
|
|
|
|
// Why: flush xterm's output queue with a DA1 query — xterm replies via
|
|
// onData with `\e[?...c`. By the time the reply lands in the spy, any
|
|
// focus escape the blur handler would have emitted has also landed.
|
|
// This gives us a deterministic "all-prior-output-processed" signal
|
|
// without a fixed sleep (which expect.poll + .not.toMatch does NOT
|
|
// provide — expect.poll exits as soon as the assertion passes once,
|
|
// so .not.toMatch on an empty buffer would pass instantly at 0ms).
|
|
await orcaPage.evaluate((tabId) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
if (!pane) {
|
|
throw new Error('No active pane on restored tab')
|
|
}
|
|
pane.terminal.write('\x1b[c')
|
|
}, secondTabId)
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const emitted = await orcaPage.evaluate(
|
|
() =>
|
|
(window as unknown as { __XTERM_ONDATA_SPY__: string[] | undefined })
|
|
.__XTERM_ONDATA_SPY__ ?? []
|
|
)
|
|
return emitted.join('')
|
|
},
|
|
{
|
|
timeout: 5_000,
|
|
message: 'DA1 reply never arrived — xterm onData spy did not receive data'
|
|
}
|
|
)
|
|
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
|
.toMatch(/\x1b\[\?.*c/)
|
|
|
|
// By this point all prior xterm output has been observed. Read the
|
|
// final buffer once and assert no focus escape is present. Mode 1004
|
|
// reset succeeded iff no focus escapes are emitted — we assert on the
|
|
// precise byte-level mechanism the fix guards against (`\e[I` focus-in
|
|
// / `\e[O` focus-out), not the tab unread state, because under the
|
|
// show-until-interact model that state can be flipped by unrelated
|
|
// shell-startup BELs.
|
|
const emittedFromXterm = await orcaPage.evaluate(
|
|
() =>
|
|
(window as unknown as { __XTERM_ONDATA_SPY__: string[] | undefined })
|
|
.__XTERM_ONDATA_SPY__ ?? []
|
|
)
|
|
// Join before matching: individual chunks could split an escape
|
|
// across onData calls (unlikely but possible — e.g. if xterm
|
|
// flushes mid-escape).
|
|
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
|
expect(emittedFromXterm.join('')).not.toMatch(/\x1b\[[IO]/)
|
|
} finally {
|
|
// Dispose the onData subscription and clear the globals so nothing leaks
|
|
// across tests on the shared renderer. Runs even if an assertion above
|
|
// failed.
|
|
await orcaPage.evaluate(() => {
|
|
const w = window as unknown as {
|
|
__XTERM_ONDATA_DISPOSE__?: () => void
|
|
__XTERM_ONDATA_SPY__?: string[]
|
|
}
|
|
w.__XTERM_ONDATA_DISPOSE__?.()
|
|
delete w.__XTERM_ONDATA_DISPOSE__
|
|
delete w.__XTERM_ONDATA_SPY__
|
|
})
|
|
}
|
|
})
|
|
})
|