mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #588: perf(terminal): tab switch to a fullscreen Claude tab loads only its current screen
Conflict resolved in config/test-suites.ts: both new browser test globs kept.
This commit is contained in:
commit
7ba1e51c51
8 files changed
+323
-2
No files matched your search
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @fileoverview A tab switch to a pane with no tmux scrollback takes the small capture.
|
||||
*
|
||||
* After the first select of a page, a tab switch to a non-shell session used to
|
||||
* fetch a 1 MiB tail of the server's byte stream. For a fullscreen claude pane
|
||||
* (alternate screen, `#{history_size}` 0) that tail is old repaints of one frame:
|
||||
* measured on live panes it cost 250-1070 ms on the server and 70-510 ms to
|
||||
* parse, and an idle tab parsed it twice (the cached copy, then the fresh one,
|
||||
* which never matched). The `full=1` capture of such a pane is the visible
|
||||
* frame, a few KB, so `selectSession` now takes it whenever the session's last
|
||||
* capture reported `paneHistoryLines: 0`, and goes back to the tail as soon as a
|
||||
* capture reports scrollback again.
|
||||
*
|
||||
* Drives the real client in chromium against a testMode server, with the
|
||||
* terminal route stubbed (the real one needs live tmux to report a hollow pane).
|
||||
*
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||
*
|
||||
* Run: npm run test:browser -- test/fullscreen-tab-switch-capture.browser.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 30_000);
|
||||
|
||||
/** What each session's pane reports, set by the test as the "pane" changes. */
|
||||
type PaneHistory = Record<string, number | undefined>;
|
||||
|
||||
/**
|
||||
* Serve every terminal fetch from a stub and log which shape was asked for.
|
||||
* `source` follows the real route: `full=1` answers `mux-full-history`, anything
|
||||
* else `mux-visible`. No capture geometry, so the geometry retry stays out of it.
|
||||
*/
|
||||
async function stubTerminal(page: Page, history: PaneHistory, log: string[]) {
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const id = url.pathname.split('/')[3];
|
||||
const full = url.searchParams.get('full') === '1';
|
||||
log.push(`${id}:${full ? 'full' : 'tail'}`);
|
||||
const frame = `frame of ${id}\r\n`;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: full ? frame : `${'old repaint\r\n'.repeat(50)}${frame}`,
|
||||
status: 'idle',
|
||||
fullSize: full ? frame.length : 5 * 1024 * 1024,
|
||||
retainedBytes: full ? frame.length : 1024 * 1024,
|
||||
truncated: !full,
|
||||
truncationReason: full ? null : 'tail',
|
||||
source: full ? 'mux-full-history' : 'mux-visible',
|
||||
paneHistoryLines: history[id],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openApp(page: Page): Promise<void> {
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSession(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(async (n) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', name: n }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
||||
}, name);
|
||||
}
|
||||
|
||||
async function select(page: Page, sessionId: string): Promise<void> {
|
||||
await page.evaluate(async (sid) => {
|
||||
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
|
||||
await app.selectSession(sid);
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
async function deleteSession(page: Page, sessionId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
describe('tab switch to a pane that keeps no scrollback', () => {
|
||||
it('takes the small full capture on every switch, and the tail again once the pane keeps history', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
await openApp(page);
|
||||
const hollow = await createSession(page, 'fullscreen-claude');
|
||||
const inline = await createSession(page, 'inline-claude');
|
||||
const history: PaneHistory = { [hollow]: 0, [inline]: 40_000 };
|
||||
const log: string[] = [];
|
||||
await stubTerminal(page, history, log);
|
||||
|
||||
// First select of each on this page: the canonical full replay, as before.
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
expect(log).toEqual([`${hollow}:full`, `${inline}:full`]);
|
||||
|
||||
// Switching back: the hollow pane takes the frame, the inline one the tail.
|
||||
log.length = 0;
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
expect(log).toEqual([`${hollow}:full`, `${inline}:tail`, `${hollow}:full`]);
|
||||
|
||||
// The pane starts keeping history (claude switched to its inline view). The
|
||||
// switch that learns it is still a full capture, which is the canonical
|
||||
// load anyway; the one after goes back to the bounded tail.
|
||||
history[hollow] = 1200;
|
||||
log.length = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
expect(log).toEqual([`${inline}:tail`, `${hollow}:full`, `${inline}:tail`, `${hollow}:tail`]);
|
||||
|
||||
// Unknown is never treated as empty: a response without the field (an
|
||||
// older server, a byte-history fallback) keeps the tail.
|
||||
history[hollow] = 0;
|
||||
log.length = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // learns 0 from this tail response
|
||||
history[hollow] = undefined;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // last report was 0: frame, which reports nothing
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // forgot it: tail
|
||||
expect(log).toEqual([
|
||||
`${inline}:tail`,
|
||||
`${hollow}:tail`,
|
||||
`${inline}:tail`,
|
||||
`${hollow}:full`,
|
||||
`${inline}:tail`,
|
||||
`${hollow}:tail`,
|
||||
]);
|
||||
|
||||
// What the hollow switch actually paints: the frame, without the repaints.
|
||||
history[hollow] = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // learns 0
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
const screen = await page.evaluate(() => {
|
||||
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < t.buffer.active.length; i++) lines.push(t.buffer.active.getLine(i)?.translateToString(true));
|
||||
return lines.join('\n');
|
||||
});
|
||||
expect(screen).toContain(`frame of ${hollow}`);
|
||||
expect(screen).not.toContain('old repaint');
|
||||
|
||||
await deleteSession(page, hollow);
|
||||
await deleteSession(page, inline);
|
||||
await context.close();
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -188,7 +188,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
|
||||
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||
// A shell never takes the full capture on a tab switch. A TUI takes it on its
|
||||
// first select per page, and on every select while its pane keeps no tmux
|
||||
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
|
||||
expect(app).toContain(
|
||||
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
|
||||
);
|
||||
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
|
||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
|
||||
// Every terminal capture now goes through _fetchTerminalCapture, which adds
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @fileoverview What the client remembers about a pane's tmux scrollback.
|
||||
*
|
||||
* `selectSession` serves a tab switch from the small `full=1` capture when the
|
||||
* session's last terminal response reported `paneHistoryLines: 0` (a fullscreen
|
||||
* CLI in the alternate screen), instead of the 1 MiB byte tail. The memory that
|
||||
* decides it must treat ONLY a reported 0 as empty: a missing field (an older
|
||||
* server, a byte-history fallback) has to forget, so the tab switch goes back to
|
||||
* the bounded tail rather than trusting a stale answer.
|
||||
*
|
||||
* Runs the real `_notePaneHistory` / `_clearHistoryTruncation` from app.js in a
|
||||
* `vm`. The switch itself is driven end to end in
|
||||
* fullscreen-tab-switch-capture.browser.test.ts (browser suite).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const APP = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
function methodSource(source: string, method: string): string {
|
||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||
expect(start, `${method} not found`).toBeGreaterThan(-1);
|
||||
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
||||
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const methods = vm.runInContext(
|
||||
`({ ${methodSource(APP, '_notePaneHistory')}, ${methodSource(APP, '_clearHistoryTruncation')} })`,
|
||||
vm.createContext({})
|
||||
) as Record<string, (...args: unknown[]) => void>;
|
||||
return {
|
||||
activeSessionId: null,
|
||||
_paneHistoryLines: new Map<string, number>(),
|
||||
_renderHistoryTruncationBanner() {},
|
||||
...methods,
|
||||
} as Record<string, any>;
|
||||
}
|
||||
|
||||
describe('_notePaneHistory', () => {
|
||||
it('records the count a capture reported, 0 included', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._notePaneHistory('s2', { paneHistoryLines: 40000 });
|
||||
expect(app._paneHistoryLines.get('s1')).toBe(0);
|
||||
expect(app._paneHistoryLines.get('s2')).toBe(40000);
|
||||
});
|
||||
|
||||
it('forgets on a response without a usable count, so unknown never reads as empty', () => {
|
||||
const app = makeApp();
|
||||
for (const payload of [{}, { paneHistoryLines: null }, { paneHistoryLines: NaN }, { paneHistoryLines: -1 }, null]) {
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._notePaneHistory('s1', payload);
|
||||
expect(app._paneHistoryLines.has('s1')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a call without a session', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory(null, { paneHistoryLines: 0 });
|
||||
expect(app._paneHistoryLines.size).toBe(0);
|
||||
});
|
||||
|
||||
it('is dropped with the session', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._clearHistoryTruncation('s1');
|
||||
expect(app._paneHistoryLines.has('s1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every terminal response feeds it (static guard)', () => {
|
||||
it('the tab switch, the refresh and the history re-pull all record the pane', () => {
|
||||
expect(methodSource(APP, 'selectSession')).toContain('this._notePaneHistory?.(sessionId, data);');
|
||||
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
|
||||
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user