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/); });