Repository Public
1/**2 * Browser scrollback: what a real xterm (headless) holds after receiving exactly the bytes the3 * runtime sends over the attach WebSocket. Real tmux, real PTY, no mocks.4 */5import { execFileSync } from 'node:child_process';6import { afterAll, beforeAll, describe, expect, it } from 'vitest';7import type { RtSessionInfo } from '@cr/shared';8import type { Runtime } from './main.js';9import { buildInjection, LineState, plainText, StreamBoundary } from './core-scrollback.js';10import { makeTestEnv, request, sessionSpec, startRuntime, waitUntil, type TestEnv } from './core-testkit.js';11import { XtermClient, allLines, flush, newTerm, screen, type Term } from './core-xterm-testkit.js';12import { ATTACH_TERMINAL_OVERRIDES, Tmux } from './tmux.js';1314/** Numbers of the `n<number>` lines, in buffer order (scrollback first, then the screen). */15const numbered = (term: Term): number[] => allLines(term).flatMap((l) => (/^n(\d+)$/.exec(l.trim()) ? [Number(l.trim().slice(1))] : []));16const range = (from: number, to: number): number[] => Array.from({ length: to - from + 1 }, (_, i) => from + i);1718describe('scrollback primitives (unit, headless xterm)', () => {19 it('pushes lines into the scrollback through a two-row region and leaves the rest of the screen alone', async () => {20 const term = newTerm(40, 10);21 await flush(term, range(1, 10).map((n) => `screen-row-${n}`).join('\r\n'));22 await flush(term, '\x1b[5;7H\x1b[1;35m'); // tmux left the cursor here, bold magenta23 const long = 'W'.repeat(95); // 3 rows at 40 columns24 const lines = ['hist-1', '\x1b[41mred-bg', '\x1b[49mafter-red', long, '', 'hist-6']; // as `capture-pane -e` writes them25 await flush(term, buildInjection({ lines, clear: false, live: true, top: ['screen-row-1', 'screen-row-2'], cursor: { x: 6, y: 4 } }));26 const b = term.buffer.active;27 expect(b.baseY).toBe(8); // 5 single rows + 3 wrapped rows28 expect(allLines(term).slice(0, 8)).toEqual(['hist-1', 'red-bg', 'after-red', 'W'.repeat(40), 'W'.repeat(40), 'W'.repeat(15), '', 'hist-6']);29 expect([b.getLine(3)?.isWrapped, b.getLine(4)?.isWrapped, b.getLine(5)?.isWrapped, b.getLine(6)?.isWrapped]).toEqual([false, true, true, false]);30 expect(screen(term)).toEqual(range(1, 10).map((n) => `screen-row-${n}`));31 expect([b.cursorX, b.cursorY]).toEqual([6, 4]);32 // The red background stays on its own line: neither the next line nor the pushed rows inherit it.33 expect(b.getLine(1)?.getCell(0)?.getBgColor()).toBe(1);34 expect(b.getLine(1)?.getCell(30)?.isBgDefault()).toBe(true);35 expect(b.getLine(2)?.getCell(0)?.isBgDefault()).toBe(true);36 expect(b.getLine(2)?.getCell(30)?.isBgDefault()).toBe(true);37 // Attributes are restored for whatever tmux writes next.38 await flush(term, 'X');39 const cell = b.getLine(b.baseY + 4)?.getCell(6);40 expect([cell?.getChars(), cell?.isBold() !== 0, cell?.getFgColor()]).toEqual(['X', true, 5]);41 // A scroll region set for the push is gone: a line feed on the last row scrolls the whole screen.42 await flush(term, '\x1b[10;1H\n');43 expect(b.baseY).toBe(9);44 expect(allLines(term)[8]).toBe('screen-row-1');45 term.dispose();46 });4748 it('clear drops the old scrollback first; a replay into a blank terminal needs no cursor handling', async () => {49 const term = newTerm(20, 5);50 await flush(term, range(1, 30).map((n) => `old-${n}`).join('\r\n'));51 expect(term.buffer.active.baseY).toBe(25);52 await flush(term, buildInjection({ lines: ['a', 'b', 'c'], clear: true, live: true, top: null }));53 expect(term.buffer.active.baseY).toBe(3);54 expect(allLines(term).slice(0, 3)).toEqual(['a', 'b', 'c']);55 const blank = newTerm(20, 5);56 const bytes = buildInjection({ lines: range(1, 12).map((n) => `h${n}`), clear: true, live: false, top: null });57 expect(bytes.includes(0x18)).toBe(false);58 await flush(blank, bytes);59 expect(blank.buffer.active.baseY).toBe(12);60 expect(allLines(blank).slice(0, 12)).toEqual(range(1, 12).map((n) => `h${n}`));61 expect(screen(blank).every((l) => l === '')).toBe(true);62 expect(buildInjection({ lines: [], clear: false, live: false, top: null }).toString()).toBe('\x1b[m');63 term.dispose();64 blank.dispose();65 });6667 it('a partial escape sequence before an injection is cancelled', async () => {68 const term = newTerm(20, 5);69 await flush(term, 'abc\x1b[3'); // tmux was cut in the middle of a CSI70 await flush(term, buildInjection({ lines: ['pushed'], clear: false, live: true, top: null }));71 expect(allLines(term)[0]).toBe('pushed');72 expect(term.buffer.active.baseY).toBe(1);73 term.dispose();74 });7576 it('LineState tracks the background and the charset shift; plainText strips sequences', () => {77 const st = new LineState();78 st.feed('\x1b[1;38;5;48;44mx');79 expect(st.bg).toBe('44'); // 38;5;48 is a foreground, not a background80 st.feed('\x1b[48;2;1;2;3m');81 expect(st.bg).toBe('48;2;1;2;3');82 st.feed('\x1b[38;2;40;41;42m\x1b[48;5;200m\x0e');83 expect([st.bg, st.shiftOut]).toEqual(['48;5;200', true]);84 st.feed('\x1b[49m\x0f');85 expect([st.bg, st.shiftOut]).toEqual([null, false]);86 st.feed('\x1b[105m');87 expect(st.bg).toBe('105');88 st.feed('\x1b[m');89 expect(st.bg).toBeNull();90 expect(plainText('\x1b[31mred\x1b[0m \x1b]8;;https://x.test\x1b\\link\x1b]8;;\x1b\\\x0e')).toBe('red link');91 });9293 it('StreamBoundary knows when the stream is between sequences and characters', () => {94 const feed = (b: StreamBoundary, s: string | number[]): boolean => {95 b.feed(typeof s === 'string' ? Buffer.from(s, 'latin1') : Uint8Array.from(s));96 return b.clean;97 };98 const b = new StreamBoundary();99 expect(b.clean).toBe(true);100 expect(feed(b, 'plain text\r\n')).toBe(true);101 expect(feed(b, '\x1b')).toBe(false);102 expect(feed(b, '[12;')).toBe(false);103 expect(feed(b, '5H')).toBe(true);104 expect(feed(b, '\x1b]11;?')).toBe(false);105 expect(feed(b, '\x1b')).toBe(false);106 expect(feed(b, '\\')).toBe(true);107 expect(feed(b, '\x1b]0;title\x07')).toBe(true);108 expect(feed(b, '\x1b(')).toBe(false);109 expect(feed(b, 'B')).toBe(true);110 expect(feed(b, [0xe2, 0x94])).toBe(false); // first two bytes of "─"111 expect(feed(b, [0x80])).toBe(true);112 expect(feed(b, [0xf0, 0x9f, 0x98])).toBe(false);113 expect(feed(b, [0x80, 0x41])).toBe(true);114 expect(feed(b, '\x1b[?2026')).toBe(false);115 expect(feed(b, '\x18')).toBe(true); // CAN aborts116 });117});118119describe('scrollback with real tmux', () => {120 let env: TestEnv;121 let rt: Runtime;122 const tmux = (...args: string[]): string => execFileSync('tmux', ['-L', env.config.tmuxSocketName, ...args], { encoding: 'utf8' });123 const create = async (extra: Record<string, unknown> = {}): Promise<string> => {124 const res = await request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env, extra) });125 expect(res.status).toBe(200);126 return res.json<RtSessionInfo>().id;127 };128 const hsize = (id: string): number => Number(tmux('display-message', '-p', '-t', `=cr-${id}:`, '#{history_size}').trim());129 const sendLine = (id: string, text: string): void => {130 tmux('send-keys', '-t', `=cr-${id}:`, '-l', text);131 tmux('send-keys', '-t', `=cr-${id}:`, 'Enter');132 };133134 beforeAll(async () => {135 env = makeTestEnv();136 rt = await startRuntime(env.config);137 });138 afterAll(async () => {139 await rt.stop();140 env.cleanup();141 });142143 it('a fresh attach gets the whole history back: 300 numbered lines, none missing, none twice', async () => {144 const id = await create();145 sendLine(id, "seq -f 'n%.0f' 1 300");146 await waitUntil(() => hsize(id) > 270, 10_000, 'lines to scroll into the tmux history');147 const c = await XtermClient.open(env, id);148 await c.until((t) => numbered(t).length >= 300 && screen(t).some((l) => l.trim() === 'n300'), 'history + screen');149 expect(numbered(c.term)).toEqual(range(1, 300));150 expect(c.term.buffer.active.type).toBe('normal'); // native scrollback: no alternate screen151 expect(c.term.buffer.active.baseY).toBe(hsize(id));152 // The visible screen is tmux's, below the replayed history.153 expect(screen(c.term).filter((l) => /^n\d+$/.test(l.trim())).at(-1)?.trim()).toBe('n300');154155 // A second browser (or a reconnect, which starts from a reset terminal) gets the same.156 const d = await XtermClient.open(env, id);157 await d.until((t) => numbered(t).length >= 300, 'second client history');158 expect(numbered(d.term)).toEqual(range(1, 300));159 await d.close();160161 // Lines printed while attached: a burst (tmux skips most of it on screen)...162 c.type("seq -f 'n%.0f' 301 900\r");163 await c.until((t) => numbered(t).at(-1) === 900 && numbered(t).length >= 900, 'burst to reach the scrollback');164 expect(numbered(c.term)).toEqual(range(1, 900));165 // ...and a slow trickle, one line at a time.166 c.type("for i in $(seq 901 960); do echo n$i; sleep 0.03; done\r");167 await c.until((t) => numbered(t).at(-1) === 960 && numbered(t).length >= 960, 'trickle to reach the scrollback');168 expect(numbered(c.term)).toEqual(range(1, 960));169 await c.until((t) => t.buffer.active.baseY === hsize(id), 'scrollback length = tmux history');170 await c.close();171 });172173 it('keeps colours and soft-wrapped lines of the history', async () => {174 const id = await create();175 const c = await XtermClient.open(env, id, 80, 24);176 await c.until((t) => screen(t).some((l) => l.includes('$')), 'prompt');177 c.type(`printf '\\033[31;44mRED-ON-BLUE\\033[0m plain \\033[1;38;5;208mORANGE\\033[0m\\n'; printf 'L%.0s' $(seq 1 100); echo; seq -f 'n%.0f' 1 60\r`);178 await c.until((t) => numbered(t).at(-1) === 60 && t.buffer.active.baseY >= 40, 'output in scrollback');179 const b = c.term.buffer.active;180 const lines = allLines(c.term);181 const row = lines.findIndex((l) => l.startsWith('RED-ON-BLUE'));182 expect(row).toBeGreaterThanOrEqual(0);183 expect(row).toBeLessThan(b.baseY);184 const cell = (x: number) => b.getLine(row)?.getCell(x);185 expect([cell(0)?.getFgColor(), cell(0)?.getBgColor(), cell(0)?.isFgPalette(), cell(0)?.isBgPalette()]).toEqual([1, 4, true, true]);186 expect([cell(12)?.isFgDefault(), cell(12)?.isBgDefault()]).toEqual([true, true]);187 const orange = lines[row]?.indexOf('ORANGE') ?? -1;188 expect([cell(orange)?.getFgColor(), cell(orange)?.isBold() !== 0, cell(orange)?.isBgDefault()]).toEqual([208, true, true]);189 // 100 characters on 80 columns: two rows, the second one marked as a continuation.190 const wrapped = lines.findIndex((l) => l === 'L'.repeat(80));191 expect(wrapped).toBeGreaterThan(row);192 expect(lines[wrapped + 1]).toBe('L'.repeat(20));193 expect([b.getLine(wrapped)?.isWrapped, b.getLine(wrapped + 1)?.isWrapped, b.getLine(wrapped + 2)?.isWrapped]).toEqual([false, true, false]);194 expect(numbered(c.term)).toEqual(range(1, 60));195 await c.close();196 });197198 it('full-screen programs (alternate screen in the pane) render, restore on exit and leave the scrollback alone', async (ctx) => {199 const id = await create();200 const c = await XtermClient.open(env, id);201 c.type("seq -f 'n%.0f' 1 100\r");202 await c.until((t) => numbered(t).length === 100 && t.buffer.active.baseY === hsize(id), 'history');203 const before = c.term.buffer.active.baseY;204205 c.type(`printf '\\033[?1049h\\033[2J\\033[HALT-SCREEN-TOP\\033[10;5Hmiddle'; sleep 1.5; printf '\\033[?1049l'\r`);206 await c.until((t) => screen(t)[0] === 'ALT-SCREEN-TOP' && (screen(t)[9] ?? '').includes('middle'), 'alternate screen content');207 expect(screen(c.term).some((l) => /^n\d+$/.test(l.trim()))).toBe(false);208 expect(c.term.buffer.active.baseY).toBe(before);209 await c.until((t) => !screen(t).includes('ALT-SCREEN-TOP') && screen(t).some((l) => l.trim() === 'n100'), 'screen restored');210 await c.until((t) => t.buffer.active.baseY === hsize(id), 'scrollback = history after the program exited');211 expect(allLines(c.term).some((l) => l.includes('ALT-SCREEN-TOP') && !l.includes('printf'))).toBe(false);212 expect(numbered(c.term)).toEqual(range(1, 100));213214 let less = '';215 try {216 less = execFileSync('/bin/sh', ['-c', 'command -v less'], { encoding: 'utf8' }).trim();217 } catch {218 /* no less on this host */219 }220 if (!less) return ctx.skip();221 const beforeLess = c.term.buffer.active.baseY;222 c.type("seq -f 'pager-line-%.0f' 1 500 | less\r");223 await c.until((t) => screen(t)[0] === 'pager-line-1', 'less first page');224 c.type(' '); // next page225 await c.until((t) => /^pager-line-\d+$/.test(screen(t)[0] ?? '') && screen(t)[0] !== 'pager-line-1', 'less second page');226 for (let i = 0; i < 40; i++) c.type('j');227 await c.until((t) => Number((screen(t)[0] ?? '').split('-')[2]) > 60, 'less scrolled by lines');228 const top = Number((screen(c.term)[0] ?? '').split('-')[2]);229 expect(screen(c.term).slice(0, 29)).toEqual(range(top, top + 28).map((n) => `pager-line-${n}`));230 expect(c.term.buffer.active.baseY).toBe(beforeLess); // scrolling inside less does not leak into the scrollback231 c.type('q');232 await c.until((t) => !screen(t).some((l) => l.startsWith('pager-line-')) && screen(t).some((l) => l.trim() === 'n100'), 'shell screen back after less');233 await c.until((t) => t.buffer.active.baseY === hsize(id), 'scrollback = history after less');234 expect(allLines(c.term).some((l) => l.startsWith('pager-line-'))).toBe(false);235 expect(numbered(c.term)).toEqual(range(1, 100));236 await c.close();237 });238239 it('resize reaches tmux and the scrollback is rebuilt for the new geometry', async () => {240 const id = await create();241 const c = await XtermClient.open(env, id, 100, 30);242 c.type("seq -f 'n%.0f' 1 200\r");243 await c.until((t) => numbered(t).length === 200, 'history');244 for (const [cols, rows] of [[60, 20], [140, 45], [100, 12]] as const) {245 c.resize(cols, rows);246 await waitUntil(async () => {247 const s = (await request(env.config.socketPath, 'GET', `/sessions/${id}`, { token: env.config.token })).json<RtSessionInfo>();248 return s.cols === cols && s.rows === rows;249 }, 5000, `tmux window ${cols}x${rows}`);250 await c.until((t) => t.buffer.active.baseY === hsize(id) && numbered(t).length === 200, `scrollback after ${cols}x${rows}`);251 expect(numbered(c.term)).toEqual(range(1, 200));252 }253 c.type('echo after-resize-$((6*7))\r');254 await c.until((t) => screen(t).some((l) => l.trim() === 'after-resize-42'), 'io after resize');255 c.type("seq -f 'n%.0f' 201 260\r");256 await c.until((t) => numbered(t).at(-1) === 260 && numbered(t).length >= 260, 'more lines after resize');257 expect(numbered(c.term)).toEqual(range(1, 260));258 await c.close();259 });260261 it('two browsers of different sizes do not fight over the window size; the idle one catches up when it is used', async () => {262 const id = await create();263 const size = async (): Promise<string> => {264 const i = (await request(env.config.socketPath, 'GET', `/sessions/${id}`, { token: env.config.token })).json<RtSessionInfo>();265 return `${i.cols}x${i.rows}`;266 };267 const clients = async (): Promise<number> => (await request(env.config.socketPath, 'GET', `/sessions/${id}`, { token: env.config.token })).json<RtSessionInfo>().attached;268 const a = await XtermClient.open(env, id, 100, 30);269 await waitUntil(async () => (await clients()) === 1, 5000, 'a attached to tmux'); // b must be the later tmux client270 const b = await XtermClient.open(env, id, 80, 20);271 await waitUntil(async () => (await clients()) === 2, 5000, 'b attached to tmux');272 await waitUntil(async () => (await size()) === '80x20', 5000, 'latest client (b) sizes the window');273 a.type("for i in $(seq 1 150); do echo n$i; [ $((i % 10)) = 0 ] && sleep 0.1; done\r");274 await waitUntil(async () => (await size()) === '100x30', 5000, 'typing in a makes it the latest client');275 // While output flows and both scrollbacks are being fed, the window keeps a's size.276 const seen = new Set<string>();277 const until = Date.now() + 2500;278 while (Date.now() < until) {279 seen.add(await size());280 await new Promise((r) => setTimeout(r, 40));281 }282 expect([...seen]).toEqual(['100x30']);283 await a.until((t) => numbered(t).at(-1) === 150 && numbered(t).length === 150, 'a has everything');284 expect(numbered(a.term)).toEqual(range(1, 150));285 expect(await size()).toBe('100x30');286 // b is used: the window takes its size and its scrollback is rebuilt.287 b.type('echo from-b\r');288 await waitUntil(async () => (await size()) === '80x20', 5000, 'typing in b makes it the latest client');289 await b.until((t) => numbered(t).length === 150 && screen(t).some((l) => l.trim() === 'from-b'), 'b caught up');290 expect(numbered(b.term)).toEqual(range(1, 150));291 await a.close();292 await b.close();293 });294295 it('follows tmux when the history is cleared or trimmed at history-limit', async () => {296 const id = await create();297 tmux('set-option', '-t', `=cr-${id}:`, 'history-limit', '400'); // applies to the existing pane too298 const c = await XtermClient.open(env, id);299 // Slow enough for incremental syncs, long enough to cross the limit several times (trim = 40 lines).300 c.type("for i in $(seq 1 700); do echo n$i; [ $((i % 10)) = 0 ] && sleep 0.05; done\r");301 // The scrollback catches up with the history within a fraction of a second: no gap, no duplicate.302 const contiguous = (n: number[]): boolean => n.every((v, i) => i === 0 || v === (n[i - 1] as number) + 1);303 await c.until((t) => numbered(t).at(-1) === 700 && contiguous(numbered(t)), 'all lines, contiguous', 30_000);304 const nums = numbered(c.term);305 expect(nums.length).toBeGreaterThanOrEqual(400); // at least everything tmux still has306 expect(hsize(id)).toBeLessThanOrEqual(400);307308 tmux('clear-history', '-t', `=cr-${id}:`);309 c.type('echo cleared\r');310 await c.until((t) => t.buffer.active.baseY === hsize(id) && screen(t).some((l) => l.trim() === 'cleared'), 'scrollback cleared like the tmux history');311 expect(c.term.buffer.active.baseY).toBeLessThan(5);312 c.type("seq -f 'n%.0f' 1001 1100\r");313 await c.until((t) => numbered(t).at(-1) === 1100 && numbered(t).filter((n) => n > 1000).length === 100, 'lines after clear');314 expect(numbered(c.term).filter((n) => n > 1000)).toEqual(range(1001, 1100));315 await c.close();316 });317});318319describe('CR_REPLAY_LINES', () => {320 it('bounds the history replayed on attach', async () => {321 const saved = process.env.CR_REPLAY_LINES;322 process.env.CR_REPLAY_LINES = '50';323 const env = makeTestEnv();324 const rt = await startRuntime(env.config);325 try {326 const res = await request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env) });327 const id = res.json<RtSessionInfo>().id;328 const target = `=cr-${id}:`;329 execFileSync('tmux', ['-L', env.config.tmuxSocketName, 'send-keys', '-t', target, '-l', "seq -f 'n%.0f' 1 300"]);330 execFileSync('tmux', ['-L', env.config.tmuxSocketName, 'send-keys', '-t', target, 'Enter']);331 await waitUntil(() => Number(execFileSync('tmux', ['-L', env.config.tmuxSocketName, 'display-message', '-p', '-t', target, '#{history_size}'], { encoding: 'utf8' })) > 270, 10_000, 'history');332 const c = await XtermClient.open(env, id);333 await c.until((t) => numbered(t).at(-1) === 300, 'screen');334 expect(c.term.buffer.active.baseY).toBe(50);335 const nums = numbered(c.term);336 expect(nums).toEqual(range(nums[0] as number, 300));337 expect(nums.length).toBeGreaterThanOrEqual(50 + 28);338 await c.close();339 } finally {340 await rt.stop();341 env.cleanup();342 if (saved === undefined) delete process.env.CR_REPLAY_LINES;343 else process.env.CR_REPLAY_LINES = saved;344 }345 });346});347348describe('tmux server that outlived the runtime', () => {349 it('gets the attach-client terminal overrides re-applied (the config file is only read at server start)', async () => {350 const env = makeTestEnv();351 const run = (...args: string[]): string => execFileSync('tmux', ['-L', env.config.tmuxSocketName, ...args], { encoding: 'utf8' });352 try {353 // A server started by an older runtime: no overrides for the attach client.354 run('-f', '/dev/null', 'new-session', '-d', '-s', 'cr-old', '-x', '80', '-y', '24', '/bin/sh');355 run('set-option', '-s', 'terminal-overrides', 'linux*:AX@');356 expect(run('show-options', '-s', 'terminal-overrides')).not.toContain('smcup@');357 const rt = await startRuntime(env.config);358 try {359 expect(run('show-options', '-s', 'terminal-overrides')).toContain(ATTACH_TERMINAL_OVERRIDES);360 } finally {361 await rt.stop();362 }363 // No server at all is not an error.364 run('kill-server');365 const tmux = await Tmux.create(env.config);366 await expect(tmux.applyServerOptions()).resolves.toBeUndefined();367 } finally {368 env.cleanup();369 }370 });371});372