SPB Git

Repository Public

scrollback.test.ts 20.5 KB 372 lines typescript

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
Files main
Menu

Theme