877 lines
36 KiB
TypeScript
877 lines
36 KiB
TypeScript
/**
|
|
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import test from '@playwright/test';
|
|
import { readFileSync } from 'fs';
|
|
import { ITestContext, createTestContext, openTerminal, timeout } from '../../../test/playwright/TestUtils';
|
|
import { deepStrictEqual, ok, strictEqual } from 'assert';
|
|
|
|
/**
|
|
* Plugin ctor options.
|
|
*/
|
|
export interface IImageAddonOptions {
|
|
enableSizeReports: boolean;
|
|
pixelLimit: number;
|
|
storageLimit: number;
|
|
showPlaceholder: boolean;
|
|
sixelSupport: boolean;
|
|
sixelScrolling: boolean;
|
|
sixelPaletteLimit: number;
|
|
sixelSizeLimit: number;
|
|
iipSupport: boolean;
|
|
iipSizeLimit: number;
|
|
kittySupport: boolean;
|
|
kittySizeLimit: number;
|
|
}
|
|
|
|
// eslint-disable-next-line
|
|
declare const ImageAddon: {
|
|
new(options?: Partial<IImageAddonOptions>): any;
|
|
};
|
|
|
|
interface IDimensions {
|
|
cellWidth: number;
|
|
cellHeight: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
// Kitty graphics test images
|
|
const KITTY_BLACK_1X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png').toString('base64');
|
|
const KITTY_BLACK_1X1_BYTES = Array.from(readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png'));
|
|
const KITTY_RGB_3X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/rgb-3x1.png').toString('base64');
|
|
|
|
// Raw RGB pixel data (f=24): 3 bytes per pixel, no header — requires s= and v=
|
|
const RAW_RGB_1X1_BLACK = Buffer.from([0, 0, 0]).toString('base64');
|
|
const RAW_RGB_1X1_RED = Buffer.from([255, 0, 0]).toString('base64');
|
|
const RAW_RGB_3X1 = Buffer.from([
|
|
255, 0, 0,
|
|
0, 255, 0,
|
|
0, 0, 255
|
|
]).toString('base64');
|
|
const RAW_RGB_2X2 = Buffer.from([
|
|
255, 0, 0, 0, 255, 0,
|
|
0, 0, 255, 255, 255, 0
|
|
]).toString('base64');
|
|
|
|
// Raw RGBA pixel data (f=32): 4 bytes per pixel, no header — requires s= and v=
|
|
const RAW_RGBA_1X1_WHITE = Buffer.from([255, 255, 255, 255]).toString('base64');
|
|
const RAW_RGBA_1X1_RED = Buffer.from([255, 0, 0, 255]).toString('base64');
|
|
const RAW_RGBA_1X1_TRANSPARENT = Buffer.from([0, 0, 0, 0]).toString('base64');
|
|
const RAW_RGBA_3X1 = Buffer.from([
|
|
255, 0, 0, 255,
|
|
0, 255, 0, 255,
|
|
0, 0, 255, 255
|
|
]).toString('base64');
|
|
const RAW_RGBA_2X2 = Buffer.from([
|
|
255, 0, 0, 255, 0, 255, 0, 255,
|
|
0, 0, 255, 255, 255, 255, 0, 255
|
|
]).toString('base64');
|
|
|
|
let ctx: ITestContext;
|
|
test.beforeAll(async ({ browser }) => {
|
|
ctx = await createTestContext(browser);
|
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
|
});
|
|
test.afterAll(async () => await ctx.page.close());
|
|
|
|
test.describe('Kitty Graphics Protocol', () => {
|
|
// TODO: Add tests for larger images with various dimensions
|
|
// TODO: Add tests for image placement keys (x, y, w, h, X, Y, c, r)
|
|
// TODO: Add tests for virtual placement (U=1)
|
|
// TODO: Add tests for animation frames
|
|
// TODO: Add performance tests for streaming large images
|
|
// TODO: Implement cursor movement per Kitty spec - cursor should move by cols/rows after placement (unless C=1)
|
|
|
|
test.beforeEach(async ({}, testInfo) => {
|
|
// DEBT: This test never worked on webkit
|
|
if (ctx.browser.browserType().name() === 'webkit') {
|
|
testInfo.skip();
|
|
return;
|
|
}
|
|
await ctx.page.evaluate(`
|
|
window.term.reset()
|
|
window.imageAddon?.dispose();
|
|
window.imageAddon = new ImageAddon({ sixelPaletteLimit: 512 });
|
|
window.term.loadAddon(window.imageAddon);
|
|
`);
|
|
});
|
|
|
|
test.describe('Basic transmission and storage', () => {
|
|
test('stores 1x1 black PNG with a=T (transmit and display)', async () => {
|
|
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [1, 1]);
|
|
});
|
|
|
|
test('stores 3x1 RGB PNG with a=T', async () => {
|
|
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
|
});
|
|
|
|
test('transmit only (a=t) does not display but stores in handler', async () => {
|
|
const seq = `\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
|
});
|
|
|
|
test('uses specified image ID', async () => {
|
|
const seq = `\x1b_Ga=t,f=100,i=42;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(42)`), true);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), false);
|
|
});
|
|
|
|
test('assigns auto-incrementing IDs when not specified', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), true);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(2)`), true);
|
|
});
|
|
|
|
test('defaults to transmit action when action is omitted', async () => {
|
|
const seq = `\x1b_Gf=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
|
});
|
|
|
|
test('ignores command when action is empty string', async () => {
|
|
const seq = `\x1b_Ga=,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
|
});
|
|
});
|
|
|
|
test.describe('Chunked transmission', () => {
|
|
test('handles chunked transmission (m=1)', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
|
|
|
const seq1 = `\x1b_Ga=T,f=100,i=99,m=1;${part1}\x1b\\`;
|
|
const seq2 = `\x1b_Ga=T,f=100,i=99;${part2}\x1b\\`;
|
|
|
|
await ctx.proxy.write(seq1);
|
|
await timeout(50);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
|
|
await ctx.proxy.write(seq2);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
});
|
|
|
|
test('verifies chunked data is assembled correctly', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99,m=1;${part1}\x1b\\`);
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99;${part2}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
const storedData = await ctx.page.evaluate(async () => {
|
|
const blob = (window as any).imageAddon._handlers.get('kitty').images.get(99).data;
|
|
const buffer = await blob.arrayBuffer();
|
|
return Array.from(new Uint8Array(buffer));
|
|
});
|
|
deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
|
|
});
|
|
|
|
test('enforces size limit across chunked transmissions', async () => {
|
|
// Create a custom addon with very small size limit (100 bytes)
|
|
// The 1x1 PNG is ~164 bytes base64, so 2 chunks should exceed 100
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).smallLimitAddon = new ImageAddon({
|
|
kittySupport: true,
|
|
kittySizeLimit: 100 // Very small limit
|
|
});
|
|
(window as any).term.loadAddon((window as any).smallLimitAddon);
|
|
});
|
|
|
|
// Split the base64 data into two chunks
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
|
|
|
// Send chunked data - first chunk (~82 bytes) is under limit
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=777,m=1;${part1}\x1b\\`);
|
|
await timeout(50);
|
|
|
|
// Second chunk brings total to ~164 bytes, exceeding 100 byte limit
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=777;${part2}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Image should NOT be stored due to size limit
|
|
strictEqual(await ctx.page.evaluate(`window.smallLimitAddon._handlers.get('kitty').images.has(777)`), false);
|
|
|
|
// Cleanup
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).smallLimitAddon.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Delete commands', () => {
|
|
test('delete command (a=d) removes specific image by id', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d,i=10\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
|
});
|
|
|
|
test('delete command (a=d) removes all images when no id specified', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=2;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
|
});
|
|
|
|
test('delete by id aborts in-flight chunked upload', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=50,m=1;${part1}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d,i=50\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
|
|
});
|
|
|
|
test('delete by id only aborts targeted upload, not others', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=55,m=1;${part1}\x1b\\`);
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=56,m=1;${part1}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d,i=55\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.has(56)`), true);
|
|
});
|
|
|
|
test('delete all aborts in-flight chunked upload', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=60,m=1;${part1}\x1b\\`);
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=61,m=1;${part1}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
|
|
});
|
|
|
|
test('chunks sent after delete are not assembled with previous data', async () => {
|
|
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
|
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
|
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70,m=1;${part1}\x1b\\`);
|
|
await timeout(50);
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
|
|
await timeout(50);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70;${part2}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(70)`), true);
|
|
const storedSize: number = await ctx.page.evaluate(async () => {
|
|
const blob = (window as any).imageAddon._handlers.get('kitty').images.get(70).data;
|
|
return blob.size;
|
|
});
|
|
ok(storedSize < KITTY_BLACK_1X1_BYTES.length, 'stored data should be smaller than full image (only second half)');
|
|
});
|
|
});
|
|
|
|
test.describe('Query support (a=q)', () => {
|
|
test('responds with OK for capability query without payload', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
|
|
});
|
|
|
|
test('responds with OK for valid PNG query', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
|
|
});
|
|
|
|
test('query does NOT store the image (unlike transmit)', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).term.onData(() => { /* consume response */ });
|
|
});
|
|
|
|
await ctx.proxy.write(`\x1b_Gi=50,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), false);
|
|
});
|
|
|
|
test('responds with error for invalid base64', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
|
|
});
|
|
|
|
test('responds with error for RGB data without dimensions', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
|
|
});
|
|
|
|
test('suppresses OK response when q=1', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyGotResponse = false;
|
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
|
});
|
|
|
|
await ctx.proxy.write(`\x1b_Gi=80,a=q,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
|
});
|
|
|
|
test('suppresses error response when q=2', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyGotResponse = false;
|
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
|
});
|
|
|
|
await ctx.proxy.write('\x1b_Gi=90,a=q,q=2,f=100;!!!invalid!!!\x1b\\');
|
|
await timeout(100);
|
|
|
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
|
});
|
|
|
|
test('responds with EINVAL when both i and I keys are specified', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
// Per spec: "Specifying both i and I keys in any command is an error"
|
|
await ctx.proxy.write(`\x1b_Gi=100,I=200,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=100;EINVAL:cannot specify both i and I keys\x1b\\');
|
|
});
|
|
|
|
test('responds with EINVAL for i+I conflict even without payload', async () => {
|
|
let response = '';
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
|
|
// Delete command with both i and I (no payload case)
|
|
await ctx.proxy.write('\x1b_Gi=101,I=201,a=d\x1b\\');
|
|
await timeout(100);
|
|
|
|
response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=101;EINVAL:cannot specify both i and I keys\x1b\\');
|
|
});
|
|
});
|
|
|
|
test.describe('Cursor positioning', () => {
|
|
// NOTE: Current tests document ACTUAL behavior (MVP - cursor doesn't move)
|
|
// Kitty spec says cursor SHOULD move by cols/rows unless C=1 is specified
|
|
// See skipped tests below for spec-compliant behavior
|
|
|
|
test('cursor remains at origin after transmit and display (a=T) - CURRENT MVP BEHAVIOR', async () => {
|
|
// TODO: This test documents current incomplete behavior
|
|
// Per Kitty spec, cursor should move, but MVP implementation doesn't move it
|
|
const cursorBefore = await getCursor();
|
|
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
const cursorAfter = await getCursor();
|
|
deepStrictEqual(cursorBefore, [0, 0]);
|
|
deepStrictEqual(cursorAfter, [0, 0]);
|
|
});
|
|
|
|
test('cursor advances with text before image', async () => {
|
|
await ctx.proxy.write('Hello');
|
|
deepStrictEqual(await getCursor(), [5, 0]);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should remain at position after "Hello"
|
|
deepStrictEqual(await getCursor(), [5, 0]);
|
|
});
|
|
|
|
test('cursor advances with text after image', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getCursor(), [0, 0]);
|
|
|
|
await ctx.proxy.write('World');
|
|
deepStrictEqual(await getCursor(), [5, 0]);
|
|
});
|
|
|
|
test('cursor position with multiple images on same line', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(50);
|
|
deepStrictEqual(await getCursor(), [0, 0]);
|
|
|
|
await ctx.proxy.write('###');
|
|
deepStrictEqual(await getCursor(), [3, 0]);
|
|
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
|
await timeout(50);
|
|
deepStrictEqual(await getCursor(), [3, 0]);
|
|
});
|
|
|
|
test('cursor advances on newline after image', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getCursor(), [0, 0]);
|
|
|
|
await ctx.proxy.write('\n');
|
|
deepStrictEqual(await getCursor(), [0, 1]);
|
|
});
|
|
|
|
test('cursor with placement at specific column (C key)', async () => {
|
|
// Move cursor to column 10
|
|
await ctx.proxy.write('\x1b[11G'); // CHA - move to column 10 (1-indexed)
|
|
deepStrictEqual(await getCursor(), [10, 0]);
|
|
|
|
// Place image with column specification
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,C=5;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should still be at column 10 (Kitty protocol doesn't move cursor)
|
|
deepStrictEqual(await getCursor(), [10, 0]);
|
|
});
|
|
|
|
// ============================================================================
|
|
// SPEC-COMPLIANT CURSOR MOVEMENT TESTS (Currently failing - to be implemented)
|
|
// ============================================================================
|
|
// Per Kitty spec: "After placing an image on the screen the cursor must be
|
|
// moved to the right by the number of cols in the image placement rectangle
|
|
// and down by the number of rows in the image placement rectangle."
|
|
|
|
test.skip('cursor should move right by cols when image placed (SPEC BEHAVIOR)', async () => {
|
|
// TODO: Implement cursor movement per Kitty spec
|
|
// When c=5 (5 columns), cursor should move right by 5
|
|
const dim = await getDimensions();
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should move right by 5 columns
|
|
deepStrictEqual(await getCursor(), [5, 0]);
|
|
});
|
|
|
|
test.skip('cursor should move down by rows when image placed (SPEC BEHAVIOR)', async () => {
|
|
// TODO: Implement cursor movement per Kitty spec
|
|
// When r=3 (3 rows), cursor should move down by 3
|
|
const dim = await getDimensions();
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,r=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should move down by 3 rows
|
|
deepStrictEqual(await getCursor(), [0, 3]);
|
|
});
|
|
|
|
test.skip('cursor should move by cols AND rows when both specified (SPEC BEHAVIOR)', async () => {
|
|
// TODO: Implement cursor movement per Kitty spec
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=4,r=2;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should move right by 4 cols and down by 2 rows
|
|
deepStrictEqual(await getCursor(), [4, 2]);
|
|
});
|
|
|
|
test.skip('cursor should NOT move when C=1 is specified (SPEC BEHAVIOR)', async () => {
|
|
// TODO: Implement cursor movement per Kitty spec
|
|
// C=1 means "no cursor movement"
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5,r=3,C=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// With C=1, cursor should stay at origin even though c=5,r=3
|
|
deepStrictEqual(await getCursor(), [0, 0]);
|
|
});
|
|
|
|
test.skip('cursor should calculate cols/rows from image size when not specified (SPEC BEHAVIOR)', async () => {
|
|
// TODO: Implement cursor movement per Kitty spec
|
|
// When c and r are not specified, they should be calculated from image size
|
|
const dim = await getDimensions();
|
|
|
|
// 3x1 image at default cell size should occupy certain columns
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
// Cursor should move based on image pixel size / cell size
|
|
// For 3x1 pixel image, this would be Math.ceil(3/cellWidth) cols and Math.ceil(1/cellHeight) rows
|
|
const expectedCols = Math.ceil(3 / dim.cellWidth);
|
|
const expectedRows = Math.ceil(1 / dim.cellHeight);
|
|
const cursor = await getCursor();
|
|
|
|
// For a 3x1 pixel image, cursor should move at least 1 column and row
|
|
// Exact values depend on cell dimensions
|
|
strictEqual(cursor[0] >= 1, true, 'cursor should move at least 1 column');
|
|
strictEqual(cursor[1] >= 1, true, 'cursor should move at least 1 row');
|
|
});
|
|
});
|
|
|
|
test.describe('Pixel verification', () => {
|
|
test('renders 1x1 black PNG at cursor position', async () => {
|
|
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
|
|
});
|
|
|
|
test('renders 3x1 RGB PNG (red, green, blue pixels)', async () => {
|
|
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
|
|
await ctx.proxy.write(seq);
|
|
await timeout(100);
|
|
|
|
const pixels = await getPixels(0, 0, 0, 0, 3, 1);
|
|
|
|
deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
|
|
});
|
|
});
|
|
|
|
test.describe('Raw RGB pixel format (f=24)', () => {
|
|
test.describe('Pixel verification', () => {
|
|
test('renders 1x1 black pixel with alpha set to 255', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1,v=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
|
|
});
|
|
|
|
test('renders 1x1 red pixel with alpha set to 255', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1,v=1;${RAW_RGB_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
|
});
|
|
|
|
test('renders 3x1 strip (red, green, blue)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
const pixels = await getPixels(0, 0, 0, 0, 3, 1);
|
|
deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
|
|
});
|
|
|
|
test('renders 2x2 grid with correct pixel layout', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 1), [0, 0, 255, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 1, 1), [255, 255, 0, 255]);
|
|
});
|
|
});
|
|
|
|
test.describe('Storage and dimensions', () => {
|
|
test('stores image with correct original dimensions (3x1)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
|
});
|
|
|
|
test('stores image with correct original dimensions (2x2)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [2, 2]);
|
|
});
|
|
});
|
|
|
|
test.describe('Validation', () => {
|
|
test('does not render without width (s=)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,v=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render without height (v=)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render without either dimension', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render with insufficient byte count', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('query returns EINVAL without dimensions', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=200,a=q,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=200;EINVAL:width and height required for raw pixel data\x1b\\');
|
|
});
|
|
|
|
test('query returns EINVAL for insufficient pixel data', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=201,a=q,f=24,s=2,v=2;${RAW_RGB_1X1_BLACK}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=201;EINVAL:insufficient pixel data\x1b\\');
|
|
});
|
|
|
|
test('query returns OK for valid RGB data with correct dimensions', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=202,a=q,f=24,s=1,v=1;${RAW_RGB_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=202;OK\x1b\\');
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Raw RGBA pixel format (f=32)', () => {
|
|
test.describe('Pixel verification', () => {
|
|
test('renders 1x1 opaque white pixel', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_WHITE}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 255, 255, 255]);
|
|
});
|
|
|
|
test('renders 1x1 opaque red pixel', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
|
});
|
|
|
|
test('preserves full transparency (alpha=0)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_TRANSPARENT}\x1b\\`);
|
|
await timeout(100);
|
|
const pixel = await getPixel(0, 0, 0, 0);
|
|
strictEqual(pixel?.[3], 0);
|
|
});
|
|
|
|
test('renders 3x1 strip (red, green, blue opaque)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
|
|
await timeout(100);
|
|
|
|
const pixels = await getPixels(0, 0, 0, 0, 3, 1);
|
|
deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
|
|
deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
|
|
});
|
|
|
|
test('renders 2x2 grid with correct pixel layout', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
|
|
await timeout(100);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 0, 1), [0, 0, 255, 255]);
|
|
deepStrictEqual(await getPixel(0, 0, 1, 1), [255, 255, 0, 255]);
|
|
});
|
|
});
|
|
|
|
test.describe('Storage and dimensions', () => {
|
|
test('stores image with correct original dimensions (3x1)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
|
});
|
|
|
|
test('stores image with correct original dimensions (2x2)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 1);
|
|
deepStrictEqual(await getOrigSize(1), [2, 2]);
|
|
});
|
|
});
|
|
|
|
test.describe('Validation', () => {
|
|
test('does not render without width (s=)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render without height (v=)', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render without either dimension', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('does not render with insufficient byte count', async () => {
|
|
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
strictEqual(await getImageStorageLength(), 0);
|
|
});
|
|
|
|
test('query returns EINVAL without dimensions', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=300,a=q,f=32;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=300;EINVAL:width and height required for raw pixel data\x1b\\');
|
|
});
|
|
|
|
test('query returns EINVAL for insufficient pixel data', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=301,a=q,f=32,s=2,v=2;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=301;EINVAL:insufficient pixel data\x1b\\');
|
|
});
|
|
|
|
test('query returns OK for valid RGBA data with correct dimensions', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
(window as any).kittyResponse = '';
|
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
|
});
|
|
await ctx.proxy.write(`\x1b_Gi=302,a=q,f=32,s=1,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
|
|
await timeout(100);
|
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
|
strictEqual(response, '\x1b_Gi=302;OK\x1b\\');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Helper functions
|
|
*/
|
|
async function getDimensions(): Promise<IDimensions> {
|
|
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
|
|
return {
|
|
cellWidth: Math.round(dimensions.css.cell.width),
|
|
cellHeight: Math.round(dimensions.css.cell.height),
|
|
width: Math.round(dimensions.css.canvas.width),
|
|
height: Math.round(dimensions.css.canvas.height)
|
|
};
|
|
}
|
|
|
|
async function getCursor(): Promise<[number, number]> {
|
|
return ctx.page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]');
|
|
}
|
|
|
|
async function getImageStorageLength(): Promise<number> {
|
|
return ctx.page.evaluate('window.imageAddon._storage._images.size');
|
|
}
|
|
|
|
async function getOrigSize(id: number): Promise<[number, number]> {
|
|
return ctx.page.evaluate<any>(`[
|
|
window.imageAddon._storage._images.get(${id}).orig.width,
|
|
window.imageAddon._storage._images.get(${id}).orig.height
|
|
]`);
|
|
}
|
|
|
|
async function getPixel(col: number, row: number, x: number, y: number): Promise<number[] | null> {
|
|
return ctx.page.evaluate(([col, row, x, y]: number[]) => {
|
|
const canvas = (window as any).imageAddon.getImageAtBufferCell(col, row);
|
|
if (!canvas) return null;
|
|
const ctx2d = canvas.getContext('2d');
|
|
if (!ctx2d) return null;
|
|
return Array.from(ctx2d.getImageData(x, y, 1, 1).data);
|
|
}, [col, row, x, y]);
|
|
}
|
|
|
|
async function getPixels(col: number, row: number, x: number, y: number, w: number, h: number): Promise<number[] | null> {
|
|
return ctx.page.evaluate(([col, row, x, y, w, h]: number[]) => {
|
|
const canvas = (window as any).imageAddon.getImageAtBufferCell(col, row);
|
|
if (!canvas) return null;
|
|
const ctx2d = canvas.getContext('2d');
|
|
if (!ctx2d) return null;
|
|
return Array.from(ctx2d.getImageData(x, y, w, h).data);
|
|
}, [col, row, x, y, w, h]);
|
|
}
|