Files

1859 lines
79 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import test from '@playwright/test';
import { readFileSync } from 'fs';
import { ITestContext, createTestContext, openTerminal, pollFor, 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');
const KITTY_MULTICOLOR_200X100_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/multicolor-200x100.png').toString('base64');
const KITTY_MULTICOLOR_200X100_BYTES = Array.from(readFileSync('./addons/addon-image/fixture/kitty/multicolor-200x100.png'));
// 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');
// 5 pixels (1 uint32 block + 1 remainder) — tests block+tail boundary
const RAW_RGB_5X1 = Buffer.from([
255, 0, 0,
0, 255, 0,
0, 0, 255,
255, 255, 0,
255, 0, 255
]).toString('base64');
// 8 pixels (2 full uint32 blocks, 0 remainder) — tests multi-block path
const RAW_RGB_4X2 = Buffer.from([
255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0,
255, 0, 255, 0, 255, 255, 128, 128, 128, 255, 255, 255
]).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');
// 5 pixels — tests RGBA zero-copy with non-power-of-2 count
const RAW_RGBA_5X1 = Buffer.from([
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 255, 0, 255,
255, 0, 255, 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)
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
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('chunked a=T works when subsequent chunks omit i= (spec pattern)', 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=400,m=1;${part1}\x1b\\`);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
});
test('chunked a=t works when subsequent chunks omit i= (spec pattern)', 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=401,m=1;${part1}\x1b\\`);
await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(401)`), true);
});
test('chunked data without i= on subsequent chunks 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=402,m=1;${part1}\x1b\\`);
await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
await timeout(100);
const storedData = await ctx.page.evaluate(async () => {
const blob = (window as any).imageAddon._handlers.get('kitty').images.get(402).data;
const buffer = await blob.arrayBuffer();
return Array.from(new Uint8Array(buffer));
});
deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
});
test('three-chunk transfer with only m= on middle and last chunks', async () => {
const third = Math.floor(KITTY_BLACK_1X1_BASE64.length / 3);
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, third);
const part2End = third + Math.floor((KITTY_BLACK_1X1_BASE64.length - third) / 2);
const alignedPart2End = part2End - (part2End - third) % 4 + third;
const part2 = KITTY_BLACK_1X1_BASE64.substring(third, alignedPart2End);
const part3 = KITTY_BLACK_1X1_BASE64.substring(alignedPart2End);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=403,m=1;${part1}\x1b\\`);
await ctx.proxy.write(`\x1b_Gm=1;${part2}\x1b\\`);
await ctx.proxy.write(`\x1b_Gm=0;${part3}\x1b\\`);
await timeout(100);
const storedData = await ctx.page.evaluate(async () => {
const blob = (window as any).imageAddon._handlers.get('kitty').images.get(403).data;
const buffer = await blob.arrayBuffer();
return Array.from(new Uint8Array(buffer));
});
deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
});
test('chunked a=T without i= on any chunk works (no response)', 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,m=1;${part1}\x1b\\`);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
});
test('chunked transfer responds OK on final chunk when i= on first only', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyResponse = '';
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
});
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=405,m=1;${part1}\x1b\\`);
await timeout(50);
let response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '');
await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
await timeout(100);
response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=405;OK\x1b\\');
});
});
test.describe('Delete commands', () => {
test('delete command (a=d,d=i) 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,d=i,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,d=i,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,d=i,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('d=i selector deletes specific image by id', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=81;${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,d=i,i=80\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(81)`), true);
});
test('d=I selector deletes specific image by id (uppercase)', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=82;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=83;${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,d=I,i=82\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(83)`), true);
});
test('d=a selector deletes all images', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=84;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=85;${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,d=a\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
});
test('d=A selector deletes all images (uppercase)', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=86;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=87;${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,d=A\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
});
test('d=a selector also removes displayed images from storage', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=88;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
});
test('d=i selector also removes displayed image from storage', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=89;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=89\x1b\\`);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
});
test('d=i without id does nothing', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=90;${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,d=i\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
});
test('d=i selector clears pixels from canvas', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=92,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=92\x1b\\`);
await timeout(100);
strictEqual(await getPixel(0, 0, 0, 0), null);
});
test('d=a selector clears all pixels from canvas', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=93,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
await timeout(100);
strictEqual(await getPixel(0, 0, 0, 0), null);
});
test('unsupported delete selector is ignored', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=91;${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,d=c\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
});
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('Error responses for transmit and display', () => {
test('a=t sends EINVAL on decode error when id is specified', 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=110,a=t,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=110;EINVAL:invalid base64 data\x1b\\');
});
test('a=t sends no response on decode error without id', 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_Ga=t,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T sends EINVAL on decode error when id is specified', 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=120,a=T,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=120;EINVAL:invalid base64 data\x1b\\');
});
test('a=T sends no response on decode error without id', 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_Ga=T,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T sends EINVAL when raw pixel render fails (missing 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=130,a=T,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=130;EINVAL:'), true);
});
test('a=T sends OK on successful render with id', 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=140,a=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=140;OK\x1b\\');
});
test('a=t sends OK on successful transmit with id', 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=150,a=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=150;OK\x1b\\');
});
test('a=t EINVAL suppressed by 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=160,a=t,q=2,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T EINVAL suppressed by 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=170,a=T,q=2,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=t OK suppressed by 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=180,a=t,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T OK suppressed by 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=190,a=T,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Transmission medium rejection', () => {
test('query rejects t=f (file transmission)', 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,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=200;EINVAL:'), true);
});
test('query rejects t=s (shared memory)', 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,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=201;EINVAL:'), true);
});
test('query rejects t=t (temp file)', 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,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=202;EINVAL:'), true);
});
test('query accepts t=d (direct transmission)', 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=203,a=q,t=d,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=203;OK\x1b\\');
});
test('query without t key defaults to direct (OK)', 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=204,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=204;OK\x1b\\');
});
test('transmit rejects t=f with id (EINVAL response)', 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=t,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=300;EINVAL:'), true);
});
test('transmit rejects t=s with id (EINVAL response)', 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=t,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=301;EINVAL:'), true);
});
test('transmit rejects t=t with id (EINVAL response)', 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=t,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=302;EINVAL:'), true);
});
test('transmit rejects t=f without id (no response)', 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_Ga=t,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '');
});
test('transmit+display rejects t=f with id (EINVAL response)', 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=310,a=T,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=310;EINVAL:'), true);
});
test('transmit+display rejects t=s with id (EINVAL response)', 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=311,a=T,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=311;EINVAL:'), true);
});
test('transmit+display rejects t=t with id (EINVAL response)', 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=312,a=T,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=312;EINVAL:'), true);
});
test('transmit+display rejects t=f without id (no response)', 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_Ga=T,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '');
});
});
test.describe('Unimplemented action responses', () => {
test('a=p with id responds EINVAL', 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=210,a=p\x1b\\`);
await timeout(100);
const response: string = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=210;EINVAL:'), true);
});
test('a=p without id sends no response', 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_Ga=p\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Cursor positioning', () => {
// NOTE: Current tests document ACTUAL behavior (MVP - cursor doesn't move)
// Per Kitty spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
test('cursor advances past 1x1 image', async () => {
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]);
// 1x1 pixel image occupies 1 column, cursor advances past it
deepStrictEqual(cursorAfter, [1, 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 advances 1 column past the image
deepStrictEqual(await getCursor(), [6, 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);
// Cursor at column 1 (past 1-col image)
deepStrictEqual(await getCursor(), [1, 0]);
await ctx.proxy.write('World');
deepStrictEqual(await getCursor(), [6, 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(), [1, 0]);
await ctx.proxy.write('###');
deepStrictEqual(await getCursor(), [4, 0]);
// 3x1 pixel image: ceil(3/cellWidth)=1 column
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(50);
deepStrictEqual(await getCursor(), [5, 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(), [1, 0]);
await ctx.proxy.write('\n');
deepStrictEqual(await getCursor(), [1, 1]);
});
test('cursor should move right by cols when c specified', async () => {
// c=5: image displayed over 5 columns, r auto = ceil(1/cellHeight) = 1
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
deepStrictEqual(await getCursor(), [5, 0]);
});
test('cursor should move down by rows when r specified', async () => {
// r=3: image displayed over 3 rows, c auto = ceil(1/cellWidth) = 1
await ctx.proxy.write(`\x1b_Ga=T,f=100,r=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
// Cursor at first column after image (col 1), on last row (row 2)
deepStrictEqual(await getCursor(), [1, 2]);
});
test('cursor should move by cols AND rows when both specified', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=4,r=2;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
// cursor at (4, 1): past 4 columns, on last row (row 1)
deepStrictEqual(await getCursor(), [4, 1]);
});
test('cursor should NOT move when C=1 is specified', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5,r=3,C=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
// C=1: cursor stays at origin
deepStrictEqual(await getCursor(), [0, 0]);
});
test('cursor should calculate cols/rows from image size when not specified', async () => {
const dim = await getDimensions();
// 3x1 pixel image: cols = ceil(3/cellWidth), rows = ceil(1/cellHeight)
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(100);
const expectedCols = Math.ceil(3 / dim.cellWidth);
const cursor = await getCursor();
// Cursor advances past image columns, stays on row 0 (single row image)
strictEqual(cursor[0], expectedCols, 'cursor should advance by image columns');
strictEqual(cursor[1], 0, 'cursor should stay on row 0 for single-row image');
});
});
test.describe('Z-index layer placement', () => {
test('default placement (no z key) stores image on top layer', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 0);
});
test('z=0 stores image on top layer', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=0;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 0);
});
test('z=1 (positive) stores image on top layer', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 1);
});
test('z=-1 uses bottom layer even when allowTransparency is disabled', async () => {
await ctx.page.evaluate(`window.term.options.allowTransparency = false`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1);
});
test('z=-1 (negative) stores image on bottom layer when allowTransparency is enabled', async () => {
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1);
});
test('z=-100 (large negative) stores image on bottom layer when allowTransparency is enabled', async () => {
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -100);
});
test('top layer canvas has correct CSS class', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const hasClass = await ctx.page.evaluate(() => {
const el = document.querySelector('.xterm-image-layer-top');
return el !== null;
});
strictEqual(hasClass, true);
});
test('bottom layer canvas has correct CSS class', async () => {
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const hasClass = await ctx.page.evaluate(() => {
const el = document.querySelector('.xterm-image-layer-bottom');
return el !== null;
});
strictEqual(hasClass, true);
});
test('bottom layer canvas is before text canvas in DOM order', async () => {
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
const isFirst = await ctx.page.evaluate(() => {
const screen = document.querySelector('.xterm-screen');
return screen?.firstElementChild?.classList.contains('xterm-image-layer-bottom') ?? false;
});
strictEqual(isFirst, true);
});
});
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('Larger image (200x100 multicolor PNG)', () => {
test.describe('Basic transmission and storage', () => {
test('stores 200x100 PNG with a=T', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
});
test('transmit only (a=t) stores 200x100 image without display', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
});
test('stores with specified image ID', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=400;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(400)`), true);
});
});
test.describe('Chunked transmission', () => {
test('handles 2-chunk transmission', async () => {
const half = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 2);
const part1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, half);
const part2 = KITTY_MULTICOLOR_200X100_BASE64.substring(half);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500,m=1;${part1}\x1b\\`);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
});
test('handles 3-chunk transmission', async () => {
const third = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 3);
const p1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, third);
const p2 = KITTY_MULTICOLOR_200X100_BASE64.substring(third, third * 2);
const p3 = KITTY_MULTICOLOR_200X100_BASE64.substring(third * 2);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501,m=1;${p1}\x1b\\`);
await timeout(50);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501,m=1;${p2}\x1b\\`);
await timeout(50);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
});
test('verifies chunked data assembles correctly', async () => {
const half = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 2);
const part1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, half);
const part2 = KITTY_MULTICOLOR_200X100_BASE64.substring(half);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=502,m=1;${part1}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=502;${part2}\x1b\\`);
await timeout(200);
const storedData = await ctx.page.evaluate(async () => {
const blob = (window as any).imageAddon._handlers.get('kitty').images.get(502).data;
const buffer = await blob.arrayBuffer();
return Array.from(new Uint8Array(buffer));
});
deepStrictEqual(storedData, KITTY_MULTICOLOR_200X100_BYTES);
});
});
test.describe('Cursor positioning', () => {
test('cursor advances past multi-cell image', async () => {
const dim = await getDimensions();
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
const expectedCols = Math.ceil(200 / dim.cellWidth);
const expectedRows = Math.ceil(100 / dim.cellHeight) - 1;
const cursor = await getCursor();
strictEqual(cursor[0], expectedCols, 'cursor should advance by image columns');
strictEqual(cursor[1], expectedRows, 'cursor should be on last row of image');
});
test('cursor does not move with C=1', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getCursor(), [0, 0]);
});
test('cursor uses explicit c and r over image dimensions', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,c=10,r=5;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getCursor(), [10, 4]);
});
});
test.describe('Pixel verification', () => {
// The 200x100 image has 20 colored rectangles in a 10x2 grid.
// Each rectangle is 20px wide x 50px tall.
// Top row (y=0..49): Red, Orange, Yellow, Lime, Green, Cyan, SkyBlue, Blue, Purple, Magenta
// Bottom row (y=50..99): Pink, Brown, Maroon, Olive, Teal, Navy, Gray, DarkGray, LightGray, White
test('renders red rectangle at top-left origin (0,0)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Pixel (0,0) is in the first rectangle: Red
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
});
test('renders top row colors at rectangle centers', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Sample center of each top-row rectangle (y=25, x=10,30,50,...,190)
// All within the first cell row, so we read from the canvas at cell (0,0)
// Red at x=10
deepStrictEqual(await getPixel(0, 0, 10, 25), [255, 0, 0, 255]);
// Orange at x=30
deepStrictEqual(await getPixel(0, 0, 30, 25), [255, 128, 0, 255]);
// Yellow at x=50
deepStrictEqual(await getPixel(0, 0, 50, 25), [255, 255, 0, 255]);
// Lime at x=70
deepStrictEqual(await getPixel(0, 0, 70, 25), [0, 255, 0, 255]);
// Green at x=90
deepStrictEqual(await getPixel(0, 0, 90, 25), [0, 128, 0, 255]);
});
test('renders bottom row colors at rectangle centers', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Bottom row starts at y=50. Center at y=75.
// Pink at x=10
deepStrictEqual(await getPixel(0, 0, 10, 75), [255, 192, 203, 255]);
// Brown at x=30
deepStrictEqual(await getPixel(0, 0, 30, 75), [165, 42, 42, 255]);
// Maroon at x=50
deepStrictEqual(await getPixel(0, 0, 50, 75), [128, 0, 0, 255]);
// Olive at x=70
deepStrictEqual(await getPixel(0, 0, 70, 75), [128, 128, 0, 255]);
// Teal at x=90
deepStrictEqual(await getPixel(0, 0, 90, 75), [0, 128, 128, 255]);
});
test('renders correct colors at rectangle boundaries', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Last pixel of first rectangle (x=19, y=0): still Red
deepStrictEqual(await getPixel(0, 0, 19, 0), [255, 0, 0, 255]);
// First pixel of second rectangle (x=20, y=0): Orange
deepStrictEqual(await getPixel(0, 0, 20, 0), [255, 128, 0, 255]);
// Last pixel of top row (x=199, y=49): Magenta
deepStrictEqual(await getPixel(0, 0, 199, 49), [255, 0, 255, 255]);
// First pixel of bottom row (x=0, y=50): Pink
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
});
test('renders correct color at bottom-right corner', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Bottom-right corner (x=199, y=99): White
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
});
test('renders a strip of top-row pixels via getPixels', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
// Read 3 pixels starting at x=18 y=0, spanning the Red/Orange boundary
const pixels = await getPixels(0, 0, 18, 0, 3, 1);
// x=18,19 -> Red; x=20 -> Orange
deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]); // x=18: Red
deepStrictEqual(pixels?.slice(4, 8), [255, 0, 0, 255]); // x=19: Red
deepStrictEqual(pixels?.slice(8, 12), [255, 128, 0, 255]); // x=20: Orange
});
});
test.describe('Query support', () => {
test('responds with OK for valid 200x100 PNG query', 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=600,a=q,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
const response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=600;OK\x1b\\');
});
test('query does not store the 200x100 image', async () => {
await ctx.page.evaluate(() => {
(window as any).term.onData(() => { /* consume response */ });
});
await ctx.proxy.write(`\x1b_Gi=601,a=q,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(601)`), false);
});
});
test.describe('Delete commands', () => {
test('delete removes 200x100 image by id', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=700;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), true);
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=700\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), false);
});
});
});
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('renders 5x1 row with block+remainder pixel layout', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\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, 2, 0), [0, 0, 255, 255]);
deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 4, 0), [255, 0, 255, 255]);
});
test('renders 4x2 grid with multi-block pixel layout', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\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, 2, 0), [0, 0, 255, 255]);
deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 0, 1), [255, 0, 255, 255]);
deepStrictEqual(await getPixel(0, 0, 1, 1), [0, 255, 255, 255]);
deepStrictEqual(await getPixel(0, 0, 2, 1), [128, 128, 128, 255]);
deepStrictEqual(await getPixel(0, 0, 3, 1), [255, 255, 255, 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('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
});
test('stores image with correct original dimensions (4x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [4, 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('renders 5x1 row with zero-copy pixel layout', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\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, 2, 0), [0, 0, 255, 255]);
deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 4, 0), [255, 0, 255, 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('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
});
});
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\\');
});
});
});
test.describe('Eviction and memory leak prevention', () => {
test('re-transmit with same i= cleans up old storage entry', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=50;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), true);
const oldStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(50)`);
ok(oldStorageId !== undefined);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=50;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), true);
const newStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(50)`);
ok(newStorageId !== undefined);
ok(newStorageId !== oldStorageId);
});
test('memory limit eviction cleans Kitty handler maps', async () => {
// Resize terminal to fit 7 non-overlapping 200x100 images without scrolling.
// Each image ≈ 29 cols × 8 rows at default cell size.
await ctx.page.evaluate(`
window.term.reset();
window.imageAddon?.dispose();
window.term.resize(80, 48);
window.imageAddon = new ImageAddon({ storageLimit: 0.5 });
window.term.loadAddon(window.imageAddon);
`);
// storageLimit 0.5 MB = 125,000 pixels. Each 200x100 image = 20,000 pixels.
// 6 images = 120K pixels (under limit). 7th triggers eviction (140K > 125K).
// Place non-overlapping so tile-count eviction doesn't interfere.
const positions = [[1, 1], [30, 1], [1, 9], [30, 9], [1, 17], [30, 17]];
for (let n = 0; n < 6; n++) {
const [c, r] = positions[n];
const id = 60 + n;
await ctx.proxy.write(`\x1b[${r};${c}H\x1b_Ga=T,f=100,i=${id},C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
}
await pollFor(ctx.page, 'window.imageAddon._storage._images.size', 6);
// 7th image pushes total past 125K pixels — oldest evicted
await ctx.proxy.write(`\x1b[25;1H\x1b_Ga=T,f=100,i=66,C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await pollFor(ctx.page, `window.imageAddon._handlers.get('kitty').images.has(60)`, false);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(60)`), false);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(66)`), true);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(66)`), true);
// Restore terminal size
await ctx.page.evaluate('window.term.resize(80, 24)');
});
test('scrollback eviction cleans Kitty handler maps', async () => {
await ctx.page.evaluate(`
window.term.reset();
window.imageAddon?.dispose();
window.imageAddon = new ImageAddon();
window.term.loadAddon(window.imageAddon);
`);
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=70;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(70)`), true);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(70)`), true);
// Scroll past scrollback + viewport to push image's marker off the buffer
await ctx.page.evaluate(() => new Promise<void>(res => {
const term = (window as any).term;
const amount: number = (term.options.scrollback as number) + (term.rows as number) + 10;
term.write('\n'.repeat(amount), res);
}));
await pollFor(ctx.page, `window.imageAddon._handlers.get('kitty').images.has(70)`, false);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(70)`), false);
});
test('re-transmit with a=t then a=T cleans old storage before display', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
const oldStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(80)`);
ok(oldStorageId !== undefined);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
});
});
});
/**
* 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]);
}