mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
A capture load now replays its queued tail, and that replay runs through `batchTerminalWrite`, which samples `_wasAtBottomBeforeWrite` before it queues. It runs inside `chunkedTerminalWrite`, before that promise resolves, with the terminal freshly reset and rewritten — so the sample is always true. The caller then restored the reader's position and the next `flushPendingWrites` scrolled straight back to the bottom off the latched flag, undoing it. The only thing in the way was `_hasRecentUserScrollUp()`, a 1500ms window a server-triggered refresh is usually past. `_syncStickyScrollBaseline()` re-takes the flag from wherever the viewport now sits, and the two paths that restore a position call it right after doing so: `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory`. Those are the paths #259 and #205 exist for, and they are also where a non-empty queue is most likely, since a needsRefresh fires when output is flooding. Re-taking rather than suppressing the sampling: suppressing leaves whatever stale value the flag held from before the load, which on the full-history re-pull has no reason to be false. `selectSession` and `_onSessionClearTerminal` deliberately end at the bottom, so the sampled true is already the truth there and they do not call it. `_bufferLoadFinishOpts` gains the coverage the CI gate can see: both mux sources flush, `history` does not, and a payload naming no source does not. Its only coverage was the browser suite, which CI does not run. The JSDoc and the changeset now record the one duplicate window this cutoff cannot close. The server appends output to the byte buffer in the same tick it emits, but broadcasts on a batch timer — 8ms over WebSocket, 16 to 50ms over SSE — so a batch pending when `capture-pane` ran leaves the server after the reply and is replayed although the capture holds it. It is one batch interval wide against a recovery window spanning the whole chunked write, and closing it means flushing that batch server side before the capture. The second browser test asserts its session was created, so a failed create fails it instead of passing with zero hits. docs/architecture-invariants.md no longer claims the replay leaves the queued-event discard window alone. That clause now describes what decides how a load ends, the baseline rule, the batch window, and the three covering tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
function loadTerminalUiHarness(mode: string) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
window: {},
|
|
CodemanApp,
|
|
console: { warn: vi.fn(), log: vi.fn() },
|
|
_crashDiag: { log: vi.fn() },
|
|
performance: { now: () => 0 },
|
|
requestAnimationFrame: (_fn: () => void) => 1,
|
|
setTimeout: (_fn: () => void) => 1,
|
|
Blob: function Blob() {},
|
|
URL: {
|
|
createObjectURL: () => 'blob:yield',
|
|
revokeObjectURL: () => {},
|
|
},
|
|
Worker: function Worker(this: any) {
|
|
this.postMessage = () => {};
|
|
},
|
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
|
});
|
|
|
|
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
|
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
const writes: string[] = [];
|
|
app.activeSessionId = 'session-1';
|
|
app.sessions = new Map([['session-1', { mode }]]);
|
|
app.pendingWrites = [];
|
|
app.writeFrameScheduled = false;
|
|
app._wasAtBottomBeforeWrite = false;
|
|
app._workerYield = () => {};
|
|
app._chunkedWriteGen = 0;
|
|
app.terminal = {
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
callback?.();
|
|
},
|
|
scrollToBottom: () => {},
|
|
scrollToLine: () => {},
|
|
};
|
|
|
|
return { app, writes };
|
|
}
|
|
|
|
function loadAppHarness() {
|
|
const dir = resolve(import.meta.dirname, '../src/web/public');
|
|
const fetchMock = vi.fn();
|
|
const context = vm.createContext({
|
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
performance: { now: () => 0 },
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
setTimeout,
|
|
clearTimeout,
|
|
requestAnimationFrame: vi.fn(),
|
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
WebSocket: { OPEN: 1 },
|
|
fetch: fetchMock,
|
|
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
MobileDetection: { isTouchDevice: () => false },
|
|
});
|
|
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
|
|
const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8');
|
|
vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
|
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
|
return { CodemanApp, fetchMock };
|
|
}
|
|
|
|
describe('terminal flush budget', () => {
|
|
it('counts incoming, loading, and xterm in-flight bytes before accepting live output', () => {
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
const start = source.indexOf('_onSessionTerminal(data)');
|
|
const body = source.slice(start, source.indexOf('\n // ═', start));
|
|
|
|
expect(body).toContain('this._loadBufferQueue?.reduce');
|
|
expect(body).toContain('this._terminalWriteInFlightBytes || 0');
|
|
expect(body).toContain('queued + data.data.length > 131072');
|
|
});
|
|
|
|
it('drops redundant SSE terminal events whenever WebSocket owns terminal I/O', () => {
|
|
const { CodemanApp } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
app._wsReady = true;
|
|
app._onSessionTerminal = vi.fn();
|
|
app._onSessionNeedsRefresh = vi.fn();
|
|
app._onSessionClearTerminal = vi.fn();
|
|
|
|
app._onSSETerminal({ id: 'session-1', data: 'duplicate' });
|
|
app._onSSENeedsRefresh({});
|
|
app._onSSEClearTerminal({ id: 'session-1' });
|
|
|
|
expect(app._onSessionTerminal).not.toHaveBeenCalled();
|
|
expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled();
|
|
expect(app._onSessionClearTerminal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs at most one buffer recovery per session and ignores stale-session events', async () => {
|
|
const { CodemanApp, fetchMock } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
app.activeSessionId = 'session-1';
|
|
app.sessions = new Map([['session-1', { mode: 'shell' }]]);
|
|
app.terminal = {};
|
|
app._isLoadingBuffer = false;
|
|
app._terminalRefreshOwner = null;
|
|
|
|
let releaseFetch!: () => void;
|
|
fetchMock.mockImplementation(
|
|
() =>
|
|
new Promise((resolveFetch) => {
|
|
releaseFetch = () => resolveFetch({ json: async () => ({ data: { terminalBuffer: '' } }) });
|
|
})
|
|
);
|
|
|
|
await app._onSessionNeedsRefresh({ id: 'stale-session' });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
const first = app._onSessionNeedsRefresh({ id: 'session-1' });
|
|
const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' });
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576');
|
|
|
|
releaseFetch();
|
|
await Promise.all([first, duplicate]);
|
|
expect(app._terminalRefreshOwner).toBe(null);
|
|
});
|
|
|
|
it('drains a large final batch without waiting for unrelated terminal output', () => {
|
|
const { app, writes } = loadTerminalUiHarness('codex');
|
|
const scheduled: Array<() => void> = [];
|
|
app._safeYield = (callback: () => void) => {
|
|
scheduled.push(callback);
|
|
};
|
|
app.isTerminalAtBottom = () => true;
|
|
|
|
app.batchTerminalWrite('x'.repeat(96 * 1024));
|
|
expect(scheduled).toHaveLength(1);
|
|
|
|
while (scheduled.length > 0) {
|
|
scheduled.shift()?.();
|
|
}
|
|
|
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024]);
|
|
expect(app.pendingWrites).toEqual([]);
|
|
expect(app.writeFrameScheduled).toBe(false);
|
|
});
|
|
|
|
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
|
|
const { app, writes } = loadTerminalUiHarness('codex');
|
|
app.pendingWrites.push('x'.repeat(96 * 1024));
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(writes).toHaveLength(1);
|
|
expect(writes[0]).toHaveLength(32 * 1024);
|
|
expect(app.pendingWrites.join('')).toHaveLength(64 * 1024);
|
|
});
|
|
|
|
it('keeps the larger first-frame write budget for non-Codex terminal output', () => {
|
|
const { app, writes } = loadTerminalUiHarness('claude');
|
|
app.pendingWrites.push('x'.repeat(96 * 1024));
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(writes).toHaveLength(1);
|
|
expect(writes[0]).toHaveLength(64 * 1024);
|
|
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
|
});
|
|
|
|
it('waits for xterm to parse a live chunk before submitting the next one', () => {
|
|
const { app, writes } = loadTerminalUiHarness('shell');
|
|
const scheduled: Array<() => void> = [];
|
|
let parsed: (() => void) | undefined;
|
|
app._safeYield = (callback: () => void) => scheduled.push(callback);
|
|
app.isTerminalAtBottom = () => true;
|
|
app.terminal.write = (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
parsed = callback;
|
|
};
|
|
|
|
app.batchTerminalWrite('x'.repeat(96 * 1024));
|
|
scheduled.shift()?.();
|
|
|
|
expect(writes.map((write) => write.length)).toEqual([64 * 1024]);
|
|
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
|
expect(scheduled).toHaveLength(0);
|
|
expect(app._terminalWriteInFlightBytes).toBe(64 * 1024);
|
|
|
|
parsed?.();
|
|
|
|
expect(scheduled).toHaveLength(1);
|
|
scheduled.shift()?.();
|
|
expect(writes.map((write) => write.length)).toEqual([64 * 1024, 32 * 1024]);
|
|
});
|
|
|
|
it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
|
|
const { app, writes } = loadTerminalUiHarness('codex');
|
|
let writeDone: (() => void) | undefined;
|
|
let resolved = false;
|
|
const finishBufferLoad = vi.fn();
|
|
app._finishBufferLoad = finishBufferLoad;
|
|
app.terminal.write = (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
writeDone = callback;
|
|
};
|
|
|
|
const promise = app.chunkedTerminalWrite('fresh tmux pane frame').then(() => {
|
|
resolved = true;
|
|
});
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(writes).toEqual(['fresh tmux pane frame']);
|
|
expect(writeDone).toBeTypeOf('function');
|
|
expect(resolved).toBe(false);
|
|
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
|
|
|
writeDone?.();
|
|
await promise;
|
|
|
|
expect(resolved).toBe(true);
|
|
});
|
|
|
|
it('paces a large enqueue and releases live output before the parse marker completes', async () => {
|
|
const { app, writes } = loadTerminalUiHarness('shell');
|
|
const scheduled: Array<() => void> = [];
|
|
let parseDone: (() => void) | undefined;
|
|
let resolved = false;
|
|
let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined;
|
|
app._safeYield = (callback: () => void) => scheduled.push(callback);
|
|
app.isTerminalAtBottom = () => true;
|
|
app.terminal.buffer = { active: { length: 37 } };
|
|
app.terminal.write = (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
if (callback) parseDone = callback;
|
|
};
|
|
|
|
const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => {
|
|
result = value;
|
|
resolved = true;
|
|
});
|
|
expect(writes).toEqual([]);
|
|
expect(scheduled).toHaveLength(1);
|
|
|
|
scheduled.shift()?.();
|
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024]);
|
|
expect(scheduled).toHaveLength(1);
|
|
|
|
scheduled.shift()?.();
|
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]);
|
|
expect(resolved).toBe(false);
|
|
|
|
scheduled.shift()?.();
|
|
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]);
|
|
expect(app._isLoadingBuffer).toBe(false);
|
|
expect(resolved).toBe(false);
|
|
|
|
app.batchTerminalWrite('new output after snapshot');
|
|
expect(app._loadBufferQueue).toBe(null);
|
|
expect(app.pendingWrites).toEqual(['new output after snapshot']);
|
|
|
|
parseDone?.();
|
|
app.terminal.buffer.active.length = 42;
|
|
await promise;
|
|
expect(resolved).toBe(true);
|
|
expect(result?.bufferLength).toBe(37);
|
|
expect(result?.completed).toBe(true);
|
|
});
|
|
|
|
it('marks a parse callback stale when a newer replay supersedes it', async () => {
|
|
const { app } = loadTerminalUiHarness('shell');
|
|
let parseDone: (() => void) | undefined;
|
|
app.terminal.write = (_data: string, callback?: () => void) => {
|
|
parseDone = callback;
|
|
};
|
|
|
|
const firstReplay = app.chunkedTerminalWrite('old snapshot');
|
|
app._chunkedWriteGen += 1;
|
|
parseDone?.();
|
|
|
|
await expect(firstReplay).resolves.toMatchObject({ completed: false });
|
|
});
|
|
|
|
it('scans xterm rows from buffer.length instead of double-counting baseY', () => {
|
|
const { app } = loadTerminalUiHarness('shell');
|
|
const getLine = vi.fn((index: number) =>
|
|
index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined
|
|
);
|
|
app.terminal = {
|
|
rows: 24,
|
|
buffer: { active: { baseY: 76, length: 100, getLine } },
|
|
scrollToBottom: vi.fn(),
|
|
scrollToLine: vi.fn(),
|
|
};
|
|
|
|
app.scrollToLastNonEmptyLine();
|
|
|
|
expect(getLine.mock.calls[0]?.[0]).toBe(99);
|
|
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77);
|
|
});
|
|
|
|
it('keeps stale buffer load owners from finishing a newer load', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
|
|
app._beginBufferLoad('select-1');
|
|
app._beginBufferLoad('select-2');
|
|
|
|
expect(app._finishBufferLoad('select-1')).toBe(false);
|
|
expect(app._isLoadingBuffer).toBe(true);
|
|
expect(app._bufferLoadOwner).toBe('select-2');
|
|
|
|
expect(app._finishBufferLoad('select-2')).toBe(true);
|
|
expect(app._isLoadingBuffer).toBe(false);
|
|
expect(app._bufferLoadOwner).toBe(null);
|
|
});
|
|
|
|
// ── Which payloads end their load by replaying the queue ──
|
|
//
|
|
// A pane capture is current only up to capture time, so the tail that arrived
|
|
// after the response exists nowhere else and has to be replayed. The server's
|
|
// accumulated byte history is current up to the response, so replaying on top
|
|
// of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
|
|
// decides this, for all four paths that fetch a terminal buffer and write it.
|
|
|
|
it('replays the tail for a visible-pane capture', () => {
|
|
const { CodemanApp } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
|
|
expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
|
|
flushQueued: true,
|
|
since: 1234,
|
|
});
|
|
});
|
|
|
|
it('replays the tail for a full-history capture', () => {
|
|
const { CodemanApp } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
|
|
expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
|
|
flushQueued: true,
|
|
since: 1234,
|
|
});
|
|
});
|
|
|
|
it('discards the queue for the accumulated byte history', () => {
|
|
const { CodemanApp } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
|
|
expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
|
|
});
|
|
|
|
it('discards the queue for a payload that names no source', () => {
|
|
// Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
|
|
// while a dropped tail is repaired by the CLI's next full repaint.
|
|
const { CodemanApp } = loadAppHarness();
|
|
const app = Object.create(CodemanApp.prototype) as any;
|
|
|
|
expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
|
|
expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
|
|
});
|
|
|
|
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const scrollToBottom = vi.fn();
|
|
app.terminal.scrollToBottom = scrollToBottom;
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(scrollToBottom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const buffer = { viewportY: 40, baseY: 100 };
|
|
app.terminal.buffer = { active: buffer };
|
|
app.terminal.write = vi.fn(() => {
|
|
buffer.viewportY = buffer.baseY;
|
|
});
|
|
app.terminal.scrollToLine = vi.fn((line: number) => {
|
|
buffer.viewportY = line;
|
|
});
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(buffer.viewportY).toBe(40);
|
|
});
|
|
});
|