mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
526 lines
20 KiB
TypeScript
526 lines
20 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 };
|
|
}
|
|
|
|
/**
|
|
* Swap in a terminal whose write() parses ASYNCHRONOUSLY, the way xterm.js does.
|
|
*
|
|
* The real renderer queues the chunk and applies it later, firing the write
|
|
* callback once it has been parsed; a redraw that addresses a row past the
|
|
* viewport (Codex's status line) drags the viewport to the live bottom at that
|
|
* point, not when write() returns. `parse()` runs that pending work.
|
|
*/
|
|
function attachAsyncParsingTerminal(app: any, opts: { viewportY: number; baseY: number }) {
|
|
const buffer = { viewportY: opts.viewportY, baseY: opts.baseY };
|
|
const pending: Array<() => void> = [];
|
|
app.terminal.buffer = { active: buffer };
|
|
app.terminal.write = vi.fn((_data: string, callback?: () => void) => {
|
|
pending.push(() => {
|
|
buffer.viewportY = buffer.baseY; // the redraw lands
|
|
callback?.();
|
|
});
|
|
});
|
|
app.terminal.scrollToLine = vi.fn((line: number) => {
|
|
buffer.viewportY = line;
|
|
});
|
|
app.terminal.scrollToBottom = vi.fn(() => {
|
|
buffer.viewportY = buffer.baseY;
|
|
});
|
|
return {
|
|
buffer,
|
|
parse: () => {
|
|
const queued = pending.splice(0, pending.length);
|
|
for (const run of queued) run();
|
|
},
|
|
};
|
|
}
|
|
|
|
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, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
parse();
|
|
|
|
expect(buffer.viewportY).toBe(40);
|
|
});
|
|
|
|
// Issue #358. xterm.js parses on its own schedule, so the buffer still holds
|
|
// the pre-write viewport the instant write() returns: restoring there compared
|
|
// the anchor against itself, did nothing, and left the redraw free to drag the
|
|
// viewport to the live bottom a tick later. The previous regression passed
|
|
// because its write mock moved the viewport synchronously, which real xterm
|
|
// never does. These drive the callback explicitly instead.
|
|
it('restores the history anchor only AFTER xterm has parsed the write (#358)', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
// Nothing has parsed yet, so nothing may have been restored yet either.
|
|
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
|
expect(buffer.viewportY).toBe(40);
|
|
|
|
parse();
|
|
|
|
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(40);
|
|
expect(buffer.viewportY).toBe(40);
|
|
});
|
|
|
|
it('holds the anchor across consecutive Codex redraws', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
|
|
for (const frame of ['\x1b[55;1H\x1b[2m• Working (6s)', '\x1b[55;1H\x1b[2m• Working (7s)']) {
|
|
app.pendingWrites.push(frame);
|
|
app.flushPendingWrites();
|
|
parse();
|
|
expect(buffer.viewportY).toBe(40);
|
|
}
|
|
});
|
|
|
|
it('holds the anchor across a chunked write whose remainder is deferred', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
// Over the 32KB codex frame budget, so the flush defers a remainder and the
|
|
// second chunk goes out from the write callback's reschedule.
|
|
app.pendingWrites.push('x'.repeat(40000));
|
|
|
|
app.flushPendingWrites();
|
|
parse();
|
|
expect(buffer.viewportY).toBe(40);
|
|
|
|
app.flushPendingWrites();
|
|
parse();
|
|
expect(buffer.viewportY).toBe(40);
|
|
expect(app.pendingWrites).toHaveLength(0);
|
|
});
|
|
|
|
it('drops the anchor when the user switched sessions before the write parsed', () => {
|
|
// The anchor indexes the buffer it came from. selectSession() resets the
|
|
// terminal and chunk-loads a different scrollback, so replaying row 40 into
|
|
// that one is a jump to an arbitrary place, not a restore. Only reachable now
|
|
// that the restore runs a parse later than the write.
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
app.activeSessionId = 'session-2'; // the user clicked another tab
|
|
parse();
|
|
|
|
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
|
expect(buffer.viewportY).toBe(buffer.baseY);
|
|
});
|
|
|
|
it('drops the anchor while a buffer load is replaying history', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
app._isLoadingBuffer = true; // chunkedTerminalWrite owns the viewport now
|
|
parse();
|
|
|
|
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not bounce off the bottom when the sticky flag and an anchor disagree', () => {
|
|
// _wasAtBottomBeforeWrite is captured at the frame's first batchTerminalWrite
|
|
// and the anchor at flush time, so a scroll-up in between leaves both live.
|
|
// The anchor wins: scrolling to the bottom and back would be a visible jump.
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
parse();
|
|
|
|
expect(app.terminal.scrollToBottom).not.toHaveBeenCalled();
|
|
expect(buffer.viewportY).toBe(40);
|
|
});
|
|
});
|