Files
Codeman/test/split-pane-orchestration.browser.test.ts
timkjrandClaude Sonnet 5 1678386f50 test(split-pane): cover the blank-Pane-B and stale-width-Pane-A fixes
Real-browser regression coverage for the previous commit:

- SplitTerminalPane connects onto an already-quiet session and shows its
  existing scrollback with no new output, proving the ?full=1 fetch (not
  a live echo) populated the pane.
- openSplitPane() force-resizes Pane A synchronously as part of opening
  a split.
- Dragging the divider force-resizes Pane A once, at drag end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00

220 lines
9.0 KiB
TypeScript

/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
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 = 3176;
const BASE_URL = `http://localhost:${PORT}`;
describe('split-pane orchestration 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<string> {
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('opening and closing a split reparents and restores .terminal-wrap', 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 });
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
const duringSplit = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') !== null,
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
}));
expect(duringSplit.hasContainer).toBe(true);
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
expect(duringSplit.hasPaneB).toBe(true);
await page.evaluate(() => (window as any).app.closeSplitPane());
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
timeout: 10000,
});
const afterClose = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') === null,
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
}));
expect(afterClose.hasContainer).toBe(true);
expect(afterClose.wrapRestored).toBe(true);
await page.evaluate(
async (ids) => {
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
},
{ a: idA, b: idB }
);
});
it('the split picker excludes the active session', async () => {
const id = await createShellSession();
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
const pickerExcludesActive = await page.evaluate((sid) => {
(window as any).app.openSplitPicker();
const items = Array.from(document.querySelectorAll('.split-picker-item'));
return !items.some((el) => el.getAttribute('data-session-id') === sid);
}, id);
expect(pickerExcludesActive).toBe(true);
await page.evaluate(async (sid) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, id);
});
it('force-resizes Pane A immediately when a split opens', async () => {
// Regression guard: opening a split moved Pane A from full width to 50%
// in the DOM, but nothing told its session's PTY/tmux window about the
// new size — only the passive, 300ms-debounced ResizeObserver in
// terminal-ui.js eventually caught up, leaving stale-width content on
// screen until the user manually hit "Redraw Terminal". openSplitPane()
// now force-resizes Pane A synchronously as part of the same call.
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(() => {
const app = window as any as { app: any };
(window as any).__resizeCalls = [];
(window as any).__origSendResize = (window as any).app.sendResize;
(window as any).app.sendResize = function (...args: any[]) {
(window as any).__resizeCalls.push(args);
return (window as any).__origSendResize.apply(app.app, args);
};
});
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
const forcedResize = await page.evaluate(
(id) =>
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
([sessionId, opts]) => sessionId === id && opts?.force === true
),
idA
);
expect(forcedResize).toBe(true);
await page.evaluate(() => {
(window as any).app.sendResize = (window as any).__origSendResize;
});
await page.evaluate(() => (window as any).app.closeSplitPane());
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
timeout: 10000,
});
await page.evaluate(
async (ids) => {
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
},
{ a: idA, b: idB }
);
});
it('force-resizes Pane A once at the end of a divider drag', async () => {
// Regression guard: the divider's onMove handler only called
// fitAddon.fit() for Pane A — a LOCAL xterm reflow that never told Pane
// A's own PTY/tmux window the new size, so existing content stayed laid
// out for the pre-drag width. onUp now force-resizes Pane A once, at
// drag end (not per-move, to avoid flooding the PTY with SIGWINCHes
// during a fast drag).
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('.split-divider', { timeout: 10000 });
await page.evaluate(() => {
const app = window as any as { app: any };
(window as any).__resizeCalls = [];
(window as any).__origSendResize = (window as any).app.sendResize;
(window as any).app.sendResize = function (...args: any[]) {
(window as any).__resizeCalls.push(args);
return (window as any).__origSendResize.apply(app.app, args);
};
});
const divider = await page.$('.split-divider');
const box = await divider!.boundingBox();
if (!box) throw new Error('divider has no bounding box');
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 80, startY, { steps: 5 });
await page.mouse.up();
const forcedResize = await page.evaluate(
(id) =>
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
([sessionId, opts]) => sessionId === id && opts?.force === true
),
idA
);
expect(forcedResize).toBe(true);
await page.evaluate(() => {
(window as any).app.sendResize = (window as any).__origSendResize;
});
await page.evaluate(() => (window as any).app.closeSplitPane());
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
timeout: 10000,
});
await page.evaluate(
async (ids) => {
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
},
{ a: idA, b: idB }
);
});
});