import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
const root = process.cwd();
async function mount(page) {
await page.setContent('');
await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
await page.evaluate(() => {
globalThis.events = [];
globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
label: 'Development terminal',
decide(event) {
const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') ||
(event.type === 'pointer' && event.action === 'move');
return { emit: true, preventDefault };
},
send(event) { globalThis.events.push(event); }
});
});
}
test('applies the Go renderer contract fixture', async ({ page }) => {
await mount(page);
const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
await page.evaluate(value => terminal.apply(value), fixture);
await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']);
await expect(page.locator('.vev-terminal__cursor')).toBeVisible();
await page.evaluate(value => terminal.apply({
...value,
snapshot: false,
rows: [],
styles: [],
cursor: { ...value.cursor, visible: false }
}), fixture);
await expect(page.locator('.vev-terminal__cursor')).toBeHidden();
});
test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => {
await mount(page);
const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
const result = await page.evaluate(value => {
terminal.apply(value);
const original = document.querySelector('.vev-terminal__cell');
const next = structuredClone(value);
next.rows[0].cells[0].text = 'B';
terminal.apply(next);
const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B';
const styled = structuredClone(next);
styled.styles[0].bold = !styled.styles[0].bold;
terminal.apply(styled);
const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell');
const beforeSpace = document.querySelector('.vev-terminal__cell');
const spaces = structuredClone(styled);
spaces.rows[0].cells[0].text = ' ';
terminal.apply(spaces);
const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell');
terminal.apply({ ...next, snapshot: false });
return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent };
}, fixture);
expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' });
});
test('default colors inherit without redundant per-cell properties', async ({ page }) => {
await mount(page);
const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
const result = await page.evaluate(value => {
const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) });
const cell = document.querySelector('.vev-terminal__cell');
const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name));
document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)');
document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)');
return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor };
}, fixture);
expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' });
});
test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {
await mount(page);
await page.evaluate(() => terminal.apply({
schemaVersion: 2, width: 5, height: 1, snapshot: true,
styles: [
{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },
{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }
],
rows: [{row:0, cells:[
{column:0,width:1,text:' ',style:0},
{column:1,width:1,text:'A',style:0},
{column:2,width:1,text:' ',style:1},
{column:3,width:2,text:' ',style:0}
]}],
cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
}));
const cells = page.locator('.vev-terminal__cell');
await expect(cells).toHaveCount(4);
await expect(cells.nth(0)).toHaveCSS('overflow', 'visible');
await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden');
await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden');
expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0);
await expect(cells.nth(3)).toHaveAttribute('data-width', '2');
expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A ');
});
test('keeps wide blanks separate from adjacent space runs', async ({ page }) => {
await mount(page);
const geometry = await page.evaluate(() => {
terminal.apply({
schemaVersion: 2, width: 4, height: 1, snapshot: true,
styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],
rows: [{row:0, cells:[
{column:0,width:1,text:' ',style:0},
{column:1,width:2,text:' ',style:0},
{column:3,width:1,text:' ',style:0}
]}],
cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
});
return [...document.querySelectorAll('.vev-terminal__cell')].map(node => {
const range = document.createRange();
range.selectNodeContents(node);
return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width};
});
});
expect(geometry).toHaveLength(3);
expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1);
expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1);
expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' ');
});
test('applies typed rows without interpreting terminal text as markup', async ({ page }) => {
await mount(page);
const hostile = '
';
const update = {
schemaVersion: 2,
width: 3,
height: 1,
snapshot: true,
styles: [{
foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
background: { kind: 0 },
underlineColor: { kind: 0 },
bold: true
}],
rows: [{ row: 0, cells: [
{ column: 0, width: 1, text: hostile, style: 0 },
{ column: 1, width: 2, text: '界', style: 0 }
] }],
cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true }
};
await page.evaluate(value => terminal.apply(value), update);
await expect(page.locator('.vev-terminal__cell')).toHaveCount(2);
await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile);
await expect(page.locator('img')).toHaveCount(0);
await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal');
await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached();
await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`);
expect(await page.evaluate(() => globalThis.injected)).toBeUndefined();
});
test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => {
await mount(page);
await page.evaluate(() => {
document.body.style.zoom = '125%';
document.querySelector('#terminal').style.fontFamily = 'serif';
terminal.apply({
schemaVersion: 2, width: 4, height: 1, snapshot: true,
styles: [{
bold: true, italic: true, dim: true, blink: true, strikethrough: true,
underline: true, underlineStyle: 3,
foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
background: { kind: 1, index: 4 },
underlineColor: { kind: 1, index: 12 }
}],
rows: [{ row: 0, cells: [
{ column: 0, width: 1, text: 'A', style: 0 },
{ column: 1, width: 2, text: '界', style: 0 },
{ column: 3, width: 1, text: 'B', style: 0 }
] }],
cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true }
});
});
const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width));
expect(widths[1] / widths[0]).toBeCloseTo(2, 1);
expect(widths[2] / widths[0]).toBeCloseTo(1, 1);
const firstCell = page.locator('.vev-terminal__cell').first();
await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/);
expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)');
await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/);
});
test('rejects a malformed update before changing the DOM', async ({ page }) => {
await mount(page);
const snapshot = {
schemaVersion: 2, width: 1, height: 1, snapshot: true,
styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
};
await page.evaluate(value => terminal.apply(value), snapshot);
const message = await page.evaluate(value => {
try {
terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] });
return '';
} catch (error) {
return error.message;
}
}, snapshot);
expect(message).toContain('row coverage');
await expect(page.locator('.vev-terminal__cell')).toHaveText('A');
const staleVersion = await page.evaluate(value => {
try {
terminal.apply({ ...value, schemaVersion: 3 });
return '';
} catch (error) {
return error.message;
}
}, snapshot);
expect(staleVersion).toContain('unsupported update schema');
const duplicateMount = await page.evaluate(() => {
try {
VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' });
return '';
} catch (error) {
return error.message;
}
});
expect(duplicateMount).toContain('already owns a terminal');
});
test('renders ASCII text runs on the column grid and rejects mismatched runs', async ({ page }) => {
await mount(page);
const result = await page.evaluate(() => {
const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
const bold = { ...plain, bold: true };
terminal.apply({
schemaVersion: 2, width: 12, height: 1, snapshot: true, styles: [plain, bold],
rows: [{ row: 0, cells: [
{ column: 0, width: 5, text: 'hello', style: 0 },
{ column: 5, width: 2, text: '界', style: 0 },
{ column: 7, width: 3, text: 'ab ', style: 1 },
{ column: 10, width: 2, text: ' ', style: 0 }
] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
const cells = [...document.querySelectorAll('.vev-terminal__cell')];
const unit = cells[0].getBoundingClientRect().width / 5;
const geometry = cells.map(node => {
const box = node.getBoundingClientRect();
return [Math.round((box.x - cells[0].getBoundingClientRect().x) / unit), Math.round(box.width / unit)];
});
const reject = cells => {
try {
terminal.apply({
schemaVersion: 2, width: 4, height: 1, snapshot: true, styles: [plain],
rows: [{ row: 0, cells }], cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
return '';
} catch (error) {
return error.message;
}
};
return {
texts: cells.map(node => node.textContent),
geometry,
output: document.querySelector('.vev-terminal__accessible-output').textContent,
short: reject([{ column: 0, width: 4, text: 'abc', style: 0 }]),
wide: reject([{ column: 0, width: 4, text: '界界', style: 0 }])
};
});
expect(result.texts).toEqual(['hello', '界', 'ab ', ' ']);
expect(result.geometry).toEqual([[0, 5], [5, 2], [7, 3], [10, 2]]);
expect(result.output).toBe('hello界ab ');
expect(result.short).toContain('text run does not match its width');
expect(result.wide).toContain('text run does not match its width');
});
test('keeps long text runs on the column grid at any font size and zoom', async ({ page }) => {
await mount(page);
const drift = await page.evaluate(async () => {
const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
const root = document.querySelector('#terminal');
const results = [];
for (const [size, zoom] of [[13, 1], [14, 1], [16, 1], [14, 1.25]]) {
root.style.setProperty('--vev-font-size', `${size}px`);
document.body.style.zoom = String(zoom);
terminal.apply({
schemaVersion: 2, width: 240, height: 1, snapshot: true, styles: [plain],
rows: [{ row: 0, cells: [
{ column: 0, width: 238, text: 'x'.repeat(238), style: 0 },
{ column: 238, width: 2, text: '界', style: 0 }
] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const [run, wide] = document.querySelectorAll('.vev-terminal__cell');
const range = document.createRange();
range.selectNodeContents(run);
results.push(Math.abs(range.getBoundingClientRect().right - wide.getBoundingClientRect().left));
}
return results;
});
for (const value of drift) expect(value).toBeLessThan(0.5);
});
test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {
await mount(page);
const result = await page.evaluate(() => {
const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
const bold = { ...plain, bold: true };
const red = { ...plain, foreground: { kind: 1, index: 1 } };
const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
const row = (index, text, style) => ({ row: index, cells: [{ column: 0, width: 4, text, style }] });
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) };
const queue = [
{ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(1, 'B1 ', 0)], cursor: cursor(1) },
{ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [red, plain], rows: [row(1, 'B2 ', 1), row(2, 'C2 ', 0)], cursor: cursor(2) },
{ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(0, 'A3 ', 0)], cursor: cursor(3) }
];
const capture = () => ({
html: document.querySelector('.vev-terminal__viewport').innerHTML,
text: document.querySelector('.vev-terminal__accessible-output').textContent
});
terminal.apply(snapshot);
for (const update of queue) terminal.apply(update);
const sequential = capture();
terminal.apply(snapshot);
const nodes = [...document.querySelectorAll('.vev-terminal__row')];
terminal.applyAll(queue);
const merged = capture();
const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);
terminal.applyAll([queue[0], snapshot, queue[2]]);
const afterSnapshot = capture().text;
const cursorAt = () => {
const node = document.querySelector('.vev-terminal__cursor');
return [node.style.top, node.style.left];
};
const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } });
terminal.apply(snapshot);
for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update);
const cursorSequential = cursorAt();
terminal.apply(snapshot);
terminal.applyAll([queue[0], cursorOnly(3)]);
const cursorMerged = cursorAt();
terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]);
const cursorAfterSnapshot = cursorAt();
const before = capture();
const failures = [
[{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]],
[queue[0], { ...cursorOnly(0), width: 5 }, snapshot]
].map(batch => {
try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; }
});
return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) };
});
expect(result.merged).toEqual(result.sequential);
expect(result.rebuilt).toEqual([true, true, true]);
expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc');
expect(result.cursorMerged).toEqual(result.cursorSequential);
expect(result.cursorAfterSnapshot[1]).toContain('1 *');
expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true);
expect(result.unchanged).toBe(true);
});
test('applyAll applies in order when merged styles exceed maxStyles, atomically', async ({ page }) => {
await page.setContent('');
await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
const result = await page.evaluate(() => {
const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
const bold = { ...plain, bold: true };
const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
const update = (snapshot, rows, style, column) => ({
schemaVersion: 2, width: 4, height: 2, snapshot, styles: [style],
rows: rows.map(([row, text]) => ({ row, cells: [{ column: 0, width: 4, text, style: 0 }] })),
cursor: cursor(column)
});
const capture = () => ({
html: document.querySelector('.vev-terminal__viewport').innerHTML,
text: document.querySelector('.vev-terminal__accessible-output').textContent
});
const mount = maxUpdateBytes => VevTerminal.mount(document.querySelector('#terminal'), {
label: 'Fallback terminal', limits: { maxStyles: 1, maxUpdateBytes }
});
const snapshot = update(true, [[0, 'aaaa'], [1, 'bbbb']], plain, 0);
const queue = [update(false, [[0, 'A1 ']], plain, 1), update(false, [[1, 'B2 ']], bold, 2)];
let terminal = mount(16);
terminal.apply(snapshot);
for (const value of queue) terminal.apply(value);
const sequential = capture();
terminal.destroy();
terminal = mount(16);
terminal.apply(snapshot);
terminal.applyAll(queue);
const merged = capture();
const errors = [];
const attempt = values => {
try { terminal.applyAll(values); return ''; } catch (error) { return error.message; }
};
errors.push(attempt([]), attempt('nope'));
const beforeOverflow = capture();
terminal.destroy();
// A row starting with 'é' holds 5 bytes. The snapshot retains 8 of 9
// bytes; each update alone retains 9, but the batch reaches 10 on its
// second update, after the first would already have changed the DOM.
const accented = (row, style) => ({
schemaVersion: 2, width: 4, height: 2, snapshot: false, styles: [style],
rows: [{ row, cells: [{ column: 0, width: 1, text: 'é', style: 0 }, { column: 1, width: 3, text: ' ', style: 0 }] }],
cursor: cursor(0)
});
terminal = mount(9);
terminal.apply(snapshot);
const before = capture();
const overflow = attempt([accented(0, plain), accented(1, bold)]);
const unchanged = JSON.stringify(capture()) === JSON.stringify(before);
terminal.destroy();
terminal = mount(16);
const unmounted = attempt([queue[0]]);
terminal.destroy();
return { sequential, merged, errors, beforeOverflow, overflow, unchanged, unmounted };
});
expect(result.merged).toEqual(result.sequential);
expect(result.errors[0]).toBe('');
expect(result.errors[1]).toContain('updates must be an array');
expect(result.beforeOverflow).toEqual(result.merged);
expect(result.overflow).toContain('retained text exceeds');
expect(result.unchanged).toBe(true);
expect(result.unmounted).toContain('dimensions do not match');
});
test('rejects colors with a payload from another color kind', async ({ page }) => {
await mount(page);
const message = await page.evaluate(() => {
const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };
try {
terminal.apply({
schemaVersion: 2, width: 1, height: 1, snapshot: true,
styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],
rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
return '';
} catch (error) {
return error.message;
}
});
expect(message).toContain('indexed color has an rgb payload');
});
test('emits text and synchronously decides key default prevention', async ({ page }) => {
await mount(page);
const input = page.locator('.vev-terminal__input');
await input.focus();
await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: 'é'
})));
const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowUp',
code: 'ArrowUp'
})));
const events = await page.evaluate(() => globalThis.events);
expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy();
expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy();
expect(keyDispatchResult).toBeFalsy();
});
test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => {
await mount(page);
const input = page.locator('.vev-terminal__input');
await input.focus();
await input.evaluate(node => {
node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true }));
for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) {
node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true }));
}
});
await page.keyboard.press('Backspace');
await page.keyboard.press('Enter');
const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key));
expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']);
expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px');
});
test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => {
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await mount(page);
await page.evaluate(() => {
terminal.destroy();
globalThis.events = [];
globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
label: 'Bounded terminal',
limits: { maxTextBytes: 1, maxPasteBytes: 1 },
send(event) { globalThis.events.push(event); }
});
});
const input = page.locator('.vev-terminal__input');
const rejected = await input.evaluate(node => {
const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertText', data: 'é'
}));
const paste = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } });
const pasteAccepted = node.dispatchEvent(paste);
return { textAccepted, pasteAccepted };
});
expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false });
expect(await page.evaluate(() => globalThis.events)).toEqual([]);
expect(errors).toEqual([]);
});
test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {
await mount(page);
const snapshot = {
schemaVersion: 2, width: 2, height: 1, snapshot: true,
styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
rows: [{ row: 0, cells: [
{ column: 0, width: 1, text: 'A', style: 0 },
{ column: 1, width: 1, text: 'B', style: 0 }
] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
};
await page.evaluate(value => terminal.apply(value), snapshot);
const input = page.locator('.vev-terminal__input');
await input.focus();
const compositionValue = await input.evaluate(node => {
node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
node.value = 'e';
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true }));
const retainedValue = node.value;
node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' }));
node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' }));
node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' }));
const paste = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } });
node.dispatchEvent(paste);
return retainedValue;
});
const viewport = page.locator('.vev-terminal__viewport');
const box = await viewport.boundingBox();
await page.mouse.click(box.x + 1, box.y + 1);
const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1
})));
await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 });
await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy();
await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy();
const events = await page.evaluate(() => globalThis.events);
expect(compositionValue).toBe('e');
expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1);
expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy();
expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy();
expect(pointerMoveAccepted).toBeFalsy();
expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy();
expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy();
});
test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => {
const violations = [];
page.on('console', message => {
if (message.text().includes('Content Security Policy')) violations.push(message.text());
});
const [css, script] = await Promise.all([
fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'),
fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8')
]);
await page.route('http://vev.test/**', async route => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css });
if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script });
return route.fulfill({
contentType: 'text/html',
headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" },
body: '
'
});
});
await page.goto('http://vev.test/');
await page.waitForFunction(() => globalThis.VevTerminal);
await page.evaluate(() => {
globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });
terminal.apply({
schemaVersion: 2, width: 1, height: 1, snapshot: true,
styles: [{
foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
background: { kind: 1, index: 4 },
underlineColor: { kind: 0 }
}],
rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
});
await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)');
expect(violations).toEqual([]);
});
test('restores host attributes and theme properties on destroy', async ({ page }) => {
await page.setContent('');
await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
await page.evaluate(() => {
const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' });
const color = { r: 1, g: 2, b: 3 };
instance.setTheme({
foreground: color, background: color, cursor: color, selection: color,
selectionText: color, palette: Array.from({ length: 16 }, () => color)
});
instance.destroy();
});
await expect(page.locator('#terminal')).toHaveAttribute('role', 'region');
await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label');
expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)');
});
test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => {
await mount(page);
const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
await page.evaluate(value => terminal.apply(value), {
schemaVersion: 2, width: 1, height: 1, snapshot: true, styles: [style],
rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
await page.evaluate(value => terminal.apply(value), {
schemaVersion: 2, width: 1, height: 1, snapshot: false, styles: [style],
rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],
cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
});
await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B');
const color = { r: 1, g: 2, b: 3 };
await page.evaluate(value => terminal.setTheme(value), {
foreground: color, background: color, cursor: color, selection: color,
selectionText: color, palette: Array.from({ length: 16 }, () => color)
});
expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3');
await page.evaluate(() => terminal.destroy());
await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0);
await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/);
});