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>
This commit is contained in:
timkjr
2026-09-20 13:10:30 -05:00
co-authored by Claude Sonnet 5
parent 3859506f9b
commit 1678386f50
2 changed files with 190 additions and 0 deletions
@@ -102,4 +102,118 @@ describe('split-pane orchestration in a real browser', () => {
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 }
);
});
});
+76
View File
@@ -91,4 +91,80 @@ describe('SplitTerminalPane in a real browser', () => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
it('shows existing scrollback immediately on connect, before any new output', async () => {
// Regression guard: connect() previously only opened the WS and waited for
// live 'terminal' events (ws-routes.ts sends nothing on connect), so a pane
// opened onto an already-quiet session stayed blank until either new output
// arrived or a resize happened to trigger a tmux repaint. Writing a marker
// and letting the echo settle BEFORE connect() proves the fetched buffer,
// not a live echo, is what populates the pane.
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
// Write directly to the session (not through SplitTerminalPane, which
// does not exist yet). Poll the real ?full=1 capture (same endpoint
// connect() below will use) rather than a fixed delay — the shell's
// own startup can race an early write and, on this box, a startup
// script issues a `clear` that erases scrollback (modern ncurses
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
await fetch(`/api/sessions/${id}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }),
});
const deadline = Date.now() + 5000;
for (;;) {
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
if (buffer.includes('PRE_EXISTING_MARKER')) break;
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
await new Promise((r) => setTimeout(r, 200));
}
return id;
});
const hasMarker = await page.evaluate(async (id) => {
const mount = document.createElement('div');
mount.style.width = '400px';
mount.style.height = '300px';
document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount);
await pane.connect();
// xterm's write() parses asynchronously (it queues data and processes it
// on a later microtask/frame), so the fetched buffer connect() writes is
// not necessarily in the rendered buffer the instant connect() resolves.
// Poll rather than check once — no new input is sent here, so any pass
// still comes from the ?full=1 fetch inside connect(), never a live echo.
let found = false;
const deadline = Date.now() + 3000;
while (!found && Date.now() < deadline) {
const buf = pane.terminal.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('PRE_EXISTING_MARKER')) {
found = true;
break;
}
}
if (!found) await new Promise((r) => setTimeout(r, 50));
}
pane.destroy();
document.body.removeChild(mount);
return found;
}, sessionId);
expect(hasMarker).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
});