main ab6686c

terminal.spec.js RawHistoryFolder

1import { test, expect } from '@playwright/test';
2import fs from 'node:fs/promises';
3import path from 'node:path';
4
5const root = process.cwd();
6
7async function mount(page) {
8  await page.setContent('<main><div id="terminal"></div></main>');
9  await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
10  await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
11  await page.evaluate(() => {
12    globalThis.events = [];
13    globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
14      label: 'Development terminal',
15      decide(event) {
16        const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') ||
17          (event.type === 'pointer' && event.action === 'move');
18        return { emit: true, preventDefault };
19      },
20      send(event) { globalThis.events.push(event); }
21    });
22  });
23}
24
25test('applies the Go renderer contract fixture', async ({ page }) => {
26  await mount(page);
27  const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
28  await page.evaluate(value => terminal.apply(value), fixture);
29  await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']);
30  await expect(page.locator('.vev-terminal__cursor')).toBeVisible();
31  await page.evaluate(value => terminal.apply({
32    ...value,
33    snapshot: false,
34    rows: [],
35    styles: [],
36    cursor: { ...value.cursor, visible: false }
37  }), fixture);
38  await expect(page.locator('.vev-terminal__cursor')).toBeHidden();
39});
40
41test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => {
42  await mount(page);
43  const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
44  const result = await page.evaluate(value => {
45    terminal.apply(value);
46    const original = document.querySelector('.vev-terminal__cell');
47    const next = structuredClone(value);
48    next.rows[0].cells[0].text = 'B';
49    terminal.apply(next);
50    const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B';
51    const styled = structuredClone(next);
52    styled.styles[0].bold = !styled.styles[0].bold;
53    terminal.apply(styled);
54    const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell');
55    const beforeSpace = document.querySelector('.vev-terminal__cell');
56    const spaces = structuredClone(styled);
57    spaces.rows[0].cells[0].text = ' ';
58    terminal.apply(spaces);
59    const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell');
60    terminal.apply({ ...next, snapshot: false });
61    return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent };
62  }, fixture);
63  expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' });
64});
65
66test('default colors inherit without redundant per-cell properties', async ({ page }) => {
67  await mount(page);
68  const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
69  const result = await page.evaluate(value => {
70    const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
71    terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) });
72    const cell = document.querySelector('.vev-terminal__cell');
73    const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name));
74    document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)');
75    document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)');
76    return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor };
77  }, fixture);
78  expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' });
79});
80
81test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {
82  await mount(page);
83  await page.evaluate(() => terminal.apply({
84    schemaVersion: 2, width: 5, height: 1, snapshot: true,
85    styles: [
86      { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },
87      { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }
88    ],
89    rows: [{row:0, cells:[
90      {column:0,width:1,text:' ',style:0},
91      {column:1,width:1,text:'A',style:0},
92      {column:2,width:1,text:' ',style:1},
93      {column:3,width:2,text:'  ',style:0}
94    ]}],
95    cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
96  }));
97  const cells = page.locator('.vev-terminal__cell');
98  await expect(cells).toHaveCount(4);
99  await expect(cells.nth(0)).toHaveCSS('overflow', 'visible');
100  await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden');
101  await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden');
102  expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0);
103  await expect(cells.nth(3)).toHaveAttribute('data-width', '2');
104  expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A   ');
105});
106
107test('keeps wide blanks separate from adjacent space runs', async ({ page }) => {
108  await mount(page);
109  const geometry = await page.evaluate(() => {
110    terminal.apply({
111      schemaVersion: 2, width: 4, height: 1, snapshot: true,
112      styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],
113      rows: [{row:0, cells:[
114        {column:0,width:1,text:' ',style:0},
115        {column:1,width:2,text:' ',style:0},
116        {column:3,width:1,text:' ',style:0}
117      ]}],
118      cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
119    });
120    return [...document.querySelectorAll('.vev-terminal__cell')].map(node => {
121      const range = document.createRange();
122      range.selectNodeContents(node);
123      return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width};
124    });
125  });
126  expect(geometry).toHaveLength(3);
127  expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1);
128  expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1);
129  expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe('   ');
130});
131
132test('applies typed rows without interpreting terminal text as markup', async ({ page }) => {
133  await mount(page);
134  const hostile = '<img src=x onerror="globalThis.injected=true">';
135  const update = {
136    schemaVersion: 2,
137    width: 3,
138    height: 1,
139    snapshot: true,
140    styles: [{
141      foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
142      background: { kind: 0 },
143      underlineColor: { kind: 0 },
144      bold: true
145    }],
146    rows: [{ row: 0, cells: [
147      { column: 0, width: 1, text: hostile, style: 0 },
148      { column: 1, width: 2, text: '界', style: 0 }
149    ] }],
150    cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true }
151  };
152
153  await page.evaluate(value => terminal.apply(value), update);
154  await expect(page.locator('.vev-terminal__cell')).toHaveCount(2);
155  await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile);
156  await expect(page.locator('img')).toHaveCount(0);
157  await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal');
158  await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached();
159  await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`);
160  expect(await page.evaluate(() => globalThis.injected)).toBeUndefined();
161});
162
163test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => {
164  await mount(page);
165  await page.evaluate(() => {
166    document.body.style.zoom = '125%';
167    document.querySelector('#terminal').style.fontFamily = 'serif';
168    terminal.apply({
169      schemaVersion: 2, width: 4, height: 1, snapshot: true,
170      styles: [{
171        bold: true, italic: true, dim: true, blink: true, strikethrough: true,
172        underline: true, underlineStyle: 3,
173        foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
174        background: { kind: 1, index: 4 },
175        underlineColor: { kind: 1, index: 12 }
176      }],
177      rows: [{ row: 0, cells: [
178        { column: 0, width: 1, text: 'A', style: 0 },
179        { column: 1, width: 2, text: '界', style: 0 },
180        { column: 3, width: 1, text: 'B', style: 0 }
181      ] }],
182      cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true }
183    });
184  });
185  const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width));
186  expect(widths[1] / widths[0]).toBeCloseTo(2, 1);
187  expect(widths[2] / widths[0]).toBeCloseTo(1, 1);
188  const firstCell = page.locator('.vev-terminal__cell').first();
189  await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/);
190  expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)');
191  await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/);
192});
193
194test('rejects a malformed update before changing the DOM', async ({ page }) => {
195  await mount(page);
196  const snapshot = {
197    schemaVersion: 2, width: 1, height: 1, snapshot: true,
198    styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
199    rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
200    cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
201  };
202  await page.evaluate(value => terminal.apply(value), snapshot);
203
204  const message = await page.evaluate(value => {
205    try {
206      terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] });
207      return '';
208    } catch (error) {
209      return error.message;
210    }
211  }, snapshot);
212  expect(message).toContain('row coverage');
213  await expect(page.locator('.vev-terminal__cell')).toHaveText('A');
214  const staleVersion = await page.evaluate(value => {
215    try {
216      terminal.apply({ ...value, schemaVersion: 3 });
217      return '';
218    } catch (error) {
219      return error.message;
220    }
221  }, snapshot);
222  expect(staleVersion).toContain('unsupported update schema');
223
224  const duplicateMount = await page.evaluate(() => {
225    try {
226      VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' });
227      return '';
228    } catch (error) {
229      return error.message;
230    }
231  });
232  expect(duplicateMount).toContain('already owns a terminal');
233});
234
235test('renders ASCII text runs on the column grid and rejects mismatched runs', async ({ page }) => {
236  await mount(page);
237  const result = await page.evaluate(() => {
238    const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
239    const bold = { ...plain, bold: true };
240    terminal.apply({
241      schemaVersion: 2, width: 12, height: 1, snapshot: true, styles: [plain, bold],
242      rows: [{ row: 0, cells: [
243        { column: 0, width: 5, text: 'hello', style: 0 },
244        { column: 5, width: 2, text: '界', style: 0 },
245        { column: 7, width: 3, text: 'ab ', style: 1 },
246        { column: 10, width: 2, text: '  ', style: 0 }
247      ] }],
248      cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
249    });
250    const cells = [...document.querySelectorAll('.vev-terminal__cell')];
251    const unit = cells[0].getBoundingClientRect().width / 5;
252    const geometry = cells.map(node => {
253      const box = node.getBoundingClientRect();
254      return [Math.round((box.x - cells[0].getBoundingClientRect().x) / unit), Math.round(box.width / unit)];
255    });
256    const reject = cells => {
257      try {
258        terminal.apply({
259          schemaVersion: 2, width: 4, height: 1, snapshot: true, styles: [plain],
260          rows: [{ row: 0, cells }], cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
261        });
262        return '';
263      } catch (error) {
264        return error.message;
265      }
266    };
267    return {
268      texts: cells.map(node => node.textContent),
269      geometry,
270      output: document.querySelector('.vev-terminal__accessible-output').textContent,
271      short: reject([{ column: 0, width: 4, text: 'abc', style: 0 }]),
272      wide: reject([{ column: 0, width: 4, text: '界界', style: 0 }])
273    };
274  });
275  expect(result.texts).toEqual(['hello', '界', 'ab ', '  ']);
276  expect(result.geometry).toEqual([[0, 5], [5, 2], [7, 3], [10, 2]]);
277  expect(result.output).toBe('hello界ab   ');
278  expect(result.short).toContain('text run does not match its width');
279  expect(result.wide).toContain('text run does not match its width');
280});
281
282test('keeps long text runs on the column grid at any font size and zoom', async ({ page }) => {
283  await mount(page);
284  const drift = await page.evaluate(async () => {
285    const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
286    const root = document.querySelector('#terminal');
287    const results = [];
288    for (const [size, zoom] of [[13, 1], [14, 1], [16, 1], [14, 1.25]]) {
289      root.style.setProperty('--vev-font-size', `${size}px`);
290      document.body.style.zoom = String(zoom);
291      terminal.apply({
292        schemaVersion: 2, width: 240, height: 1, snapshot: true, styles: [plain],
293        rows: [{ row: 0, cells: [
294          { column: 0, width: 238, text: 'x'.repeat(238), style: 0 },
295          { column: 238, width: 2, text: '界', style: 0 }
296        ] }],
297        cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
298      });
299      await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
300      const [run, wide] = document.querySelectorAll('.vev-terminal__cell');
301      const range = document.createRange();
302      range.selectNodeContents(run);
303      results.push(Math.abs(range.getBoundingClientRect().right - wide.getBoundingClientRect().left));
304    }
305    return results;
306  });
307  for (const value of drift) expect(value).toBeLessThan(0.5);
308});
309
310test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {
311  await mount(page);
312  const result = await page.evaluate(() => {
313    const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
314    const bold = { ...plain, bold: true };
315    const red = { ...plain, foreground: { kind: 1, index: 1 } };
316    const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
317    const row = (index, text, style) => ({ row: index, cells: [{ column: 0, width: 4, text, style }] });
318    const snapshot = { schemaVersion: 2, width: 4, height: 3, snapshot: true, styles: [plain], rows: [row(0, 'aaaa', 0), row(1, 'bbbb', 0), row(2, 'cccc', 0)], cursor: cursor(0) };
319    const queue = [
320      { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(1, 'B1  ', 0)], cursor: cursor(1) },
321      { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [red, plain], rows: [row(1, 'B2  ', 1), row(2, 'C2  ', 0)], cursor: cursor(2) },
322      { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(0, 'A3  ', 0)], cursor: cursor(3) }
323    ];
324    const capture = () => ({
325      html: document.querySelector('.vev-terminal__viewport').innerHTML,
326      text: document.querySelector('.vev-terminal__accessible-output').textContent
327    });
328    terminal.apply(snapshot);
329    for (const update of queue) terminal.apply(update);
330    const sequential = capture();
331    terminal.apply(snapshot);
332    const nodes = [...document.querySelectorAll('.vev-terminal__row')];
333    terminal.applyAll(queue);
334    const merged = capture();
335    const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);
336    terminal.applyAll([queue[0], snapshot, queue[2]]);
337    const afterSnapshot = capture().text;
338    const cursorAt = () => {
339      const node = document.querySelector('.vev-terminal__cursor');
340      return [node.style.top, node.style.left];
341    };
342    const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } });
343    terminal.apply(snapshot);
344    for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update);
345    const cursorSequential = cursorAt();
346    terminal.apply(snapshot);
347    terminal.applyAll([queue[0], cursorOnly(3)]);
348    const cursorMerged = cursorAt();
349    terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]);
350    const cursorAfterSnapshot = cursorAt();
351    const before = capture();
352    const failures = [
353      [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]],
354      [queue[0], { ...cursorOnly(0), width: 5 }, snapshot]
355    ].map(batch => {
356      try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; }
357    });
358    return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) };
359  });
360  expect(result.merged).toEqual(result.sequential);
361  expect(result.rebuilt).toEqual([true, true, true]);
362  expect(result.afterSnapshot).toBe('A3  \nbbbb\ncccc');
363  expect(result.cursorMerged).toEqual(result.cursorSequential);
364  expect(result.cursorAfterSnapshot[1]).toContain('1 *');
365  expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true);
366  expect(result.unchanged).toBe(true);
367});
368
369test('applyAll applies in order when merged styles exceed maxStyles, atomically', async ({ page }) => {
370  await page.setContent('<main><div id="terminal"></div></main>');
371  await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
372  await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
373  const result = await page.evaluate(() => {
374    const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
375    const bold = { ...plain, bold: true };
376    const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
377    const update = (snapshot, rows, style, column) => ({
378      schemaVersion: 2, width: 4, height: 2, snapshot, styles: [style],
379      rows: rows.map(([row, text]) => ({ row, cells: [{ column: 0, width: 4, text, style: 0 }] })),
380      cursor: cursor(column)
381    });
382    const capture = () => ({
383      html: document.querySelector('.vev-terminal__viewport').innerHTML,
384      text: document.querySelector('.vev-terminal__accessible-output').textContent
385    });
386    const mount = maxUpdateBytes => VevTerminal.mount(document.querySelector('#terminal'), {
387      label: 'Fallback terminal', limits: { maxStyles: 1, maxUpdateBytes }
388    });
389    const snapshot = update(true, [[0, 'aaaa'], [1, 'bbbb']], plain, 0);
390    const queue = [update(false, [[0, 'A1  ']], plain, 1), update(false, [[1, 'B2  ']], bold, 2)];
391
392    let terminal = mount(16);
393    terminal.apply(snapshot);
394    for (const value of queue) terminal.apply(value);
395    const sequential = capture();
396    terminal.destroy();
397
398    terminal = mount(16);
399    terminal.apply(snapshot);
400    terminal.applyAll(queue);
401    const merged = capture();
402    const errors = [];
403    const attempt = values => {
404      try { terminal.applyAll(values); return ''; } catch (error) { return error.message; }
405    };
406    errors.push(attempt([]), attempt('nope'));
407    const beforeOverflow = capture();
408    terminal.destroy();
409
410    // A row starting with 'é' holds 5 bytes. The snapshot retains 8 of 9
411    // bytes; each update alone retains 9, but the batch reaches 10 on its
412    // second update, after the first would already have changed the DOM.
413    const accented = (row, style) => ({
414      schemaVersion: 2, width: 4, height: 2, snapshot: false, styles: [style],
415      rows: [{ row, cells: [{ column: 0, width: 1, text: 'é', style: 0 }, { column: 1, width: 3, text: '   ', style: 0 }] }],
416      cursor: cursor(0)
417    });
418    terminal = mount(9);
419    terminal.apply(snapshot);
420    const before = capture();
421    const overflow = attempt([accented(0, plain), accented(1, bold)]);
422    const unchanged = JSON.stringify(capture()) === JSON.stringify(before);
423    terminal.destroy();
424
425    terminal = mount(16);
426    const unmounted = attempt([queue[0]]);
427    terminal.destroy();
428    return { sequential, merged, errors, beforeOverflow, overflow, unchanged, unmounted };
429  });
430  expect(result.merged).toEqual(result.sequential);
431  expect(result.errors[0]).toBe('');
432  expect(result.errors[1]).toContain('updates must be an array');
433  expect(result.beforeOverflow).toEqual(result.merged);
434  expect(result.overflow).toContain('retained text exceeds');
435  expect(result.unchanged).toBe(true);
436  expect(result.unmounted).toContain('dimensions do not match');
437});
438
439test('rejects colors with a payload from another color kind', async ({ page }) => {
440  await mount(page);
441  const message = await page.evaluate(() => {
442    const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };
443    try {
444      terminal.apply({
445        schemaVersion: 2, width: 1, height: 1, snapshot: true,
446        styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],
447        rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
448        cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
449      });
450      return '';
451    } catch (error) {
452      return error.message;
453    }
454  });
455  expect(message).toContain('indexed color has an rgb payload');
456});
457
458test('emits text and synchronously decides key default prevention', async ({ page }) => {
459  await mount(page);
460  const input = page.locator('.vev-terminal__input');
461  await input.focus();
462  await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', {
463    bubbles: true,
464    cancelable: true,
465    inputType: 'insertText',
466    data: 'é'
467  })));
468  const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', {
469    bubbles: true,
470    cancelable: true,
471    key: 'ArrowUp',
472    code: 'ArrowUp'
473  })));
474
475  const events = await page.evaluate(() => globalThis.events);
476  expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy();
477  expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy();
478  expect(keyDispatchResult).toBeFalsy();
479});
480
481test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => {
482  await mount(page);
483  const input = page.locator('.vev-terminal__input');
484  await input.focus();
485  await input.evaluate(node => {
486    node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true }));
487    for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) {
488      node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true }));
489    }
490  });
491  await page.keyboard.press('Backspace');
492  await page.keyboard.press('Enter');
493  const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key));
494  expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']);
495  expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px');
496});
497
498test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => {
499  const errors = [];
500  page.on('pageerror', error => errors.push(error.message));
501  await mount(page);
502  await page.evaluate(() => {
503    terminal.destroy();
504    globalThis.events = [];
505    globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
506      label: 'Bounded terminal',
507      limits: { maxTextBytes: 1, maxPasteBytes: 1 },
508      send(event) { globalThis.events.push(event); }
509    });
510  });
511  const input = page.locator('.vev-terminal__input');
512  const rejected = await input.evaluate(node => {
513    const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', {
514      bubbles: true, cancelable: true, inputType: 'insertText', data: 'é'
515    }));
516    const paste = new Event('paste', { bubbles: true, cancelable: true });
517    Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } });
518    const pasteAccepted = node.dispatchEvent(paste);
519    return { textAccepted, pasteAccepted };
520  });
521  expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false });
522  expect(await page.evaluate(() => globalThis.events)).toEqual([]);
523  expect(errors).toEqual([]);
524});
525
526test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {
527  await mount(page);
528  const snapshot = {
529    schemaVersion: 2, width: 2, height: 1, snapshot: true,
530    styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
531    rows: [{ row: 0, cells: [
532      { column: 0, width: 1, text: 'A', style: 0 },
533      { column: 1, width: 1, text: 'B', style: 0 }
534    ] }],
535    cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
536  };
537  await page.evaluate(value => terminal.apply(value), snapshot);
538  const input = page.locator('.vev-terminal__input');
539  await input.focus();
540  const compositionValue = await input.evaluate(node => {
541    node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
542    node.value = 'e';
543    node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true }));
544    const retainedValue = node.value;
545    node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' }));
546    node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' }));
547    node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' }));
548    const paste = new Event('paste', { bubbles: true, cancelable: true });
549    Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } });
550    node.dispatchEvent(paste);
551    return retainedValue;
552  });
553
554  const viewport = page.locator('.vev-terminal__viewport');
555  const box = await viewport.boundingBox();
556  await page.mouse.click(box.x + 1, box.y + 1);
557  const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', {
558    bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1
559  })));
560  await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 });
561  await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy();
562  await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy();
563
564  const events = await page.evaluate(() => globalThis.events);
565  expect(compositionValue).toBe('e');
566  expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1);
567  expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy();
568  expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy();
569  expect(pointerMoveAccepted).toBeFalsy();
570  expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy();
571  expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy();
572});
573
574test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => {
575  const violations = [];
576  page.on('console', message => {
577    if (message.text().includes('Content Security Policy')) violations.push(message.text());
578  });
579  const [css, script] = await Promise.all([
580    fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'),
581    fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8')
582  ]);
583  await page.route('http://vev.test/**', async route => {
584    const pathname = new URL(route.request().url()).pathname;
585    if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css });
586    if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script });
587    return route.fulfill({
588      contentType: 'text/html',
589      headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" },
590      body: '<link rel="stylesheet" href="/terminal.css"><div id="terminal"></div><script src="/terminal.js"></script>'
591    });
592  });
593  await page.goto('http://vev.test/');
594  await page.waitForFunction(() => globalThis.VevTerminal);
595  await page.evaluate(() => {
596    globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });
597    terminal.apply({
598      schemaVersion: 2, width: 1, height: 1, snapshot: true,
599      styles: [{
600        foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
601        background: { kind: 1, index: 4 },
602        underlineColor: { kind: 0 }
603      }],
604      rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
605      cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
606    });
607  });
608  await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)');
609  expect(violations).toEqual([]);
610});
611
612test('restores host attributes and theme properties on destroy', async ({ page }) => {
613  await page.setContent('<div id="terminal" role="region" aria-label="Host label" style="--vev-fg:rgb(9 8 7)"></div>');
614  await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
615  await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
616  await page.evaluate(() => {
617    const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' });
618    const color = { r: 1, g: 2, b: 3 };
619    instance.setTheme({
620      foreground: color, background: color, cursor: color, selection: color,
621      selectionText: color, palette: Array.from({ length: 16 }, () => color)
622    });
623    instance.destroy();
624  });
625  await expect(page.locator('#terminal')).toHaveAttribute('role', 'region');
626  await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label');
627  expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)');
628});
629
630test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => {
631  await mount(page);
632  const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
633  await page.evaluate(value => terminal.apply(value), {
634    schemaVersion: 2, width: 1, height: 1, snapshot: true, styles: [style],
635    rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
636    cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
637  });
638  await page.evaluate(value => terminal.apply(value), {
639    schemaVersion: 2, width: 1, height: 1, snapshot: false, styles: [style],
640    rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],
641    cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
642  });
643  await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B');
644
645  const color = { r: 1, g: 2, b: 3 };
646  await page.evaluate(value => terminal.setTheme(value), {
647    foreground: color, background: color, cursor: color, selection: color,
648    selectionText: color, palette: Array.from({ length: 16 }, () => color)
649  });
650  expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3');
651
652  await page.evaluate(() => terminal.destroy());
653  await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0);
654  await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/);
655});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close