diff --git a/test/split-pane-orchestration.browser.test.ts b/test/split-pane-orchestration.browser.test.ts index ea70cfa7..a8c3190d 100644 --- a/test/split-pane-orchestration.browser.test.ts +++ b/test/split-pane-orchestration.browser.test.ts @@ -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 } + ); + }); }); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index 2b3ee22b..f747c0b0 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -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); + }); });