diff --git a/config/test-suites.ts b/config/test-suites.ts index f0a3fb1f..9f3ed295 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -32,6 +32,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', + 'test/split-pane-auto-collapse.browser.test.ts', ]; /** diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 6af60796..6ba51427 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -241,3 +241,17 @@ Object.assign(CodemanApp.prototype, { }); }, }); + +const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted; +CodemanApp.prototype._onSessionDeleted = function (data) { + if (this._splitSessionId === data.id) { + this.closeSplitPane(); + } else if (this._splitPane && this.activeSessionId === data.id) { + // Pane A's session ended: promote Pane B by closing the split and + // selecting its session as the new (single) active pane. + const promoted = this._splitSessionId; + this.closeSplitPane(); + if (promoted) this.selectSession(promoted); + } + return _originalOnSessionDeleted.call(this, data); +}; diff --git a/test/split-pane-auto-collapse.browser.test.ts b/test/split-pane-auto-collapse.browser.test.ts new file mode 100644 index 00000000..c321df56 --- /dev/null +++ b/test/split-pane-auto-collapse.browser.test.ts @@ -0,0 +1,76 @@ +// test/split-pane-auto-collapse.browser.test.ts +/** @fileoverview Real Chromium coverage for split auto-collapse when either session ends (Task 6). */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3177; +const BASE_URL = `http://localhost:${PORT}`; + +describe('split-pane auto-collapse in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + async function createShellSession(): Promise { + return page.evaluate(async () => { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }), + }); + // POST /api/sessions nests the session under data.session, and mode:'shell' + // does not spawn a PTY on creation alone (pid: null, no pane) — an explicit + // POST .../shell is what actually starts it (both found and fixed by Task 4's + // implementer against this exact pattern; carried forward here so this task + // does not rediscover the same two bugs). + const id = (await res.json()).data.session.id; + await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); + return id; + }); + } + + it('deleting the Pane B session auto-collapses the split', async () => { + const idA = await createShellSession(); + const idB = await createShellSession(); + + await page.evaluate((id) => (window as any).app.selectSession(id), idA); + await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 }); + await page.evaluate((id) => (window as any).app.openSplitPane(id), idB); + await page.waitForSelector('.terminal-pane-b', { timeout: 10000 }); + + // Delete Pane B's session from "outside" (simulating the SSE event another + // client's delete would produce, by hitting the DELETE route directly). + await page.evaluate(async (id) => { + await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + }, idB); + await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, { + timeout: 10000, + }); + + const collapsed = await page.evaluate(() => ({ + hasContainer: document.querySelector('.terminal-split-container') === null, + splitPaneNulled: (window as any).app._splitPane === null, + })); + expect(collapsed.hasContainer).toBe(true); + expect(collapsed.splitPaneNulled).toBe(true); + + await page.evaluate(async (id) => { + await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + }, idA); + }); +});