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