diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index eb202908..b4420098 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -675,6 +675,22 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { } else { this._storage.addImage(bitmap); } + + // TODO: Implement cursor movement per Kitty graphics protocol spec + // Per 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." + // + // Default behavior (C=0 or unspecified): Move cursor by cols/rows + // With C=1: Don't move cursor at all + // + // Implementation would need: + // 1. Get placement.C value (cursor movement policy) + // 2. Calculate cols = placement.columns || Math.ceil(w / cellWidth) + // 3. Calculate rows = placement.rows || Math.ceil(h / cellHeight) + // 4. If C !== 1: Move cursor right by cols and down by rows + // this._bufferService.buffer.x += cols; + // this._bufferService.buffer.y += rows; } /** diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 55282d46..d6178ff5 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -75,11 +75,6 @@ const TESTDATA_IIP: [string, [number, number]][] = [ [readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]] ]; -// 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'); - let ctx: ITestContext; test.beforeAll(async ({ browser }) => { ctx = await createTestContext(browser); @@ -305,310 +300,6 @@ test.describe('ImageAddon', () => { deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); }); }); - - test.describe('Kitty graphics support', () => { - 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('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(`Array.from(window.imageAddon._handlers.get('kitty').images.get(99).data)`); - 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('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.describe('Kitty 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('Kitty 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); - - const pixel = await ctx.page.evaluate(() => { - const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0); - if (!canvas) return null; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - return Array.from(ctx.getImageData(0, 0, 1, 1).data); - }); - - deepStrictEqual(pixel, [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 ctx.page.evaluate(() => { - const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0); - if (!canvas) return null; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - const imageData = ctx.getImageData(0, 0, 3, 1).data; - return { - red: Array.from(imageData.slice(0, 4)), - green: Array.from(imageData.slice(4, 8)), - blue: Array.from(imageData.slice(8, 12)) - }; - }); - - deepStrictEqual(pixels?.red, [255, 0, 0, 255]); - deepStrictEqual(pixels?.green, [0, 255, 0, 255]); - deepStrictEqual(pixels?.blue, [0, 0, 255, 255]); - }); - }); }); /** diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts new file mode 100644 index 00000000..c5ef6364 --- /dev/null +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -0,0 +1,553 @@ +/** + * 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, 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): 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'); + +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 different compression formats (f=24, f=32) + // 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(`Array.from(window.imageAddon._handlers.get('kitty').images.get(99).data)`); + 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.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); + + const pixel = await ctx.page.evaluate(() => { + const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0); + if (!canvas) return null; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + return Array.from(ctx.getImageData(0, 0, 1, 1).data); + }); + + deepStrictEqual(pixel, [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 ctx.page.evaluate(() => { + const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0); + if (!canvas) return null; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const imageData = ctx.getImageData(0, 0, 3, 1).data; + return { + red: Array.from(imageData.slice(0, 4)), + green: Array.from(imageData.slice(4, 8)), + blue: Array.from(imageData.slice(8, 12)) + }; + }); + + deepStrictEqual(pixels?.red, [255, 0, 0, 255]); + deepStrictEqual(pixels?.green, [0, 255, 0, 255]); + deepStrictEqual(pixels?.blue, [0, 0, 255, 255]); + }); + }); +}); + +/** + * Helper functions + */ +async function getDimensions(): Promise { + 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 { + return ctx.page.evaluate('window.imageAddon._storage._images.size'); +} + +async function getOrigSize(id: number): Promise<[number, number]> { + return ctx.page.evaluate(`[ + window.imageAddon._storage._images.get(${id}).orig.width, + window.imageAddon._storage._images.get(${id}).orig.height + ]`); +}