diff --git a/src/renderer/src/components/terminal/repro-10142-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/repro-10142-close-confirm-keyboard-vs-mouse.test.ts new file mode 100644 index 00000000000..8b6e1187100 --- /dev/null +++ b/src/renderer/src/components/terminal/repro-10142-close-confirm-keyboard-vs-mouse.test.ts @@ -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 { + 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)['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() + }) +}) diff --git a/tests/e2e/repro-10142-tab-close-confirm-bypass.spec.ts b/tests/e2e/repro-10142-tab-close-confirm-bypass.spec.ts new file mode 100644 index 00000000000..908f1c46c40 --- /dev/null +++ b/tests/e2e/repro-10142-tab-close-confirm-bypass.spec.ts @@ -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 { + 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 }) +})