mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
test(repro): demonstrate #10142 tab X close bypasses running-process confirmation
Unit repro: closeTerminalTab (the X-button/middle-click entry) never consults inspectRuntimeTerminalProcess and drops a tab with a live child. E2E repro: Cmd+W shows 'Stop running command?' for a tab running sleep 300; cancelling then clicking the tab X closes it silently. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Repro for #10142: tab X button / middle-click bypass the running-process close
|
||||
* confirmation that Cmd+W enforces.
|
||||
*
|
||||
* Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose
|
||||
* -> Terminal.tsx handleCloseTab -> closeTerminalTab() — no process probe.
|
||||
* Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess
|
||||
* -> CloseTerminalDialog.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
requestPinnedTabCloseConfirmMock,
|
||||
getStateMock,
|
||||
inspectRuntimeTerminalProcessMock,
|
||||
isWebRuntimeSessionActiveMock,
|
||||
resolveHostSessionTabIdForWebSessionTabMock
|
||||
} = vi.hoisted(() => ({
|
||||
requestPinnedTabCloseConfirmMock: vi.fn(),
|
||||
getStateMock: vi.fn(),
|
||||
inspectRuntimeTerminalProcessMock: vi.fn(),
|
||||
isWebRuntimeSessionActiveMock: vi.fn(() => false),
|
||||
resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: getStateMock }
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
activateWebRuntimeSessionTab: vi.fn(),
|
||||
closeWebRuntimeSessionTab: vi.fn(),
|
||||
createWebRuntimeSessionTerminal: vi.fn(),
|
||||
isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock,
|
||||
isWebTerminalSurfaceTabId: vi.fn(() => false),
|
||||
toHostSessionTabId: vi.fn((tabId: string) => tabId)
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-session-tabs-sync', () => ({
|
||||
getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'),
|
||||
resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock
|
||||
}))
|
||||
|
||||
import { closeTerminalTab } from './terminal-tab-actions'
|
||||
|
||||
// A non-pinned terminal tab whose PTY has a live child process (e.g. `sleep 300`).
|
||||
function stateWithBusyTerminalTab(closeTab: () => void): Record<string, unknown> {
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true },
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'tab-busy', entityId: 'tab-busy', contentType: 'terminal', isPinned: false },
|
||||
{ id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false }
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-busy': ['pty-busy'] },
|
||||
terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { leaf: 'pty-busy' } } },
|
||||
agentStatusByPaneKey: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-busy',
|
||||
closeTab,
|
||||
requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock,
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveFile: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
setActiveBrowserTab: vi.fn(),
|
||||
setActiveWorktree: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('#10142 close confirmation policy is the same for keyboard and mouse', () => {
|
||||
const closeTab = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getStateMock.mockReturnValue(stateWithBusyTerminalTab(closeTab))
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(false)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null)
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
foregroundProcess: 'sleep',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
// Control: the keyboard entry point does probe for running children.
|
||||
it('keyboard Cmd+W path probes for running child processes before closing', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, '../terminal-pane/TerminalPane.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
const handler = source.slice(source.indexOf('const handleRequestClosePane'))
|
||||
expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain(
|
||||
'inspectRuntimeTerminalProcess'
|
||||
)
|
||||
})
|
||||
|
||||
// Control: the harness does observe a guard when one exists — pinning blocks the same mouse close.
|
||||
it('mouse close routes a pinned tab through its confirmation guard', () => {
|
||||
const state = stateWithBusyTerminalTab(closeTab)
|
||||
;(state.unifiedTabsByWorktree as Record<string, { isPinned: boolean }[]>)['wt-1']![0]!.isPinned =
|
||||
true
|
||||
getStateMock.mockReturnValue(state)
|
||||
|
||||
closeTerminalTab('tab-busy')
|
||||
|
||||
expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalled()
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mouse close (X button / middle-click) consults the running-process probe', async () => {
|
||||
closeTerminalTab('tab-busy')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mouse close (X button / middle-click) does not drop a busy tab without confirmation', async () => {
|
||||
closeTerminalTab('tab-busy')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Repro for #10142: the tab X button bypasses the running-process close
|
||||
* confirmation that Cmd/Ctrl+W enforces on the same tab.
|
||||
*
|
||||
* Both halves run against one tab with a live `sleep 300` child:
|
||||
* 1. Cmd+W -> "Stop running command?" dialog (cancelled, tab survives).
|
||||
* 2. X click -> expected the same dialog; today the tab just disappears.
|
||||
*/
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
waitForSessionReady,
|
||||
waitForActiveWorktree,
|
||||
getActiveTabId,
|
||||
ensureTerminalVisible
|
||||
} from './helpers/store'
|
||||
import {
|
||||
execInTerminal,
|
||||
focusActiveTerminalInput,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneCount,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
|
||||
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
|
||||
const SCREENSHOT_DIR = '/tmp/vbb/10142/.repro'
|
||||
|
||||
function countRenderedTabs(page: Page): Promise<number> {
|
||||
return page.locator(SORTABLE_TAB).count()
|
||||
}
|
||||
|
||||
function closeDialogTitle(page: Page) {
|
||||
return page.getByText(/Stop running command\?|Stop this agent\?/)
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('#10142 tab X button bypasses the Cmd+W running-process confirmation', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.')
|
||||
await waitForPaneCount(orcaPage, 1, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await execInTerminal(orcaPage, ptyId, 'echo repro-10142-ready')
|
||||
await waitForTerminalOutput(orcaPage, 'repro-10142-ready', 20_000)
|
||||
await execInTerminal(orcaPage, ptyId, 'sleep 300')
|
||||
// Only press close once the PTY actually reports the child; otherwise the
|
||||
// probe legitimately sees an idle shell and closing is correct.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await orcaPage.evaluate((id) => window.api.pty.inspectProcess(id), ptyId))
|
||||
.hasChildProcesses,
|
||||
{ timeout: 20_000, message: 'sleep 300 never registered as a child process' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const busyTabId = (await getActiveTabId(orcaPage))!
|
||||
const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first()
|
||||
|
||||
// 1. Keyboard close prompts.
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w')
|
||||
await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 })
|
||||
await orcaPage.screenshot({ path: `${SCREENSHOT_DIR}/10142-1-cmdw-shows-confirm.png` })
|
||||
|
||||
await orcaPage.getByRole('button', { name: /^Cancel$/ }).click()
|
||||
await expect(closeDialogTitle(orcaPage)).toBeHidden()
|
||||
await expect(busyTab).toBeVisible()
|
||||
const tabsBefore = await countRenderedTabs(orcaPage)
|
||||
|
||||
// 2. Same tab, same running child, mouse close.
|
||||
await busyTab.hover()
|
||||
await busyTab.getByRole('button', { name: /^Close tab /i }).click()
|
||||
await orcaPage.waitForTimeout(1_500)
|
||||
await orcaPage.screenshot({ path: `${SCREENSHOT_DIR}/10142-2-x-click-no-confirm.png` })
|
||||
|
||||
expect(
|
||||
{
|
||||
confirmDialogVisible: await closeDialogTitle(orcaPage).isVisible(),
|
||||
tabStillPresent: (await countRenderedTabs(orcaPage)) === tabsBefore
|
||||
},
|
||||
'X-button close must apply the same running-process confirmation as Cmd+W'
|
||||
).toEqual({ confirmDialogVisible: true, tabStillPresent: true })
|
||||
})
|
||||
Reference in New Issue
Block a user