Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type CliEnv = BaseEnv & {

// Terminal-specific
KITTY_WINDOW_ID?: string
KONSOLE_VERSION?: string
SIXEL_SUPPORT?: string
ZED_NODE_ENV?: string
ZED_TERM?: string
Expand Down
214 changes: 214 additions & 0 deletions cli/src/utils/__tests__/image-pipeline-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'
import os from 'os'
import path from 'path'

import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import { Jimp } from 'jimp'

import { setProjectRoot } from '../../project-files'
import { processImageFile } from '../image-handler'
import { MAX_IMAGE_BASE64_SIZE } from '@codebuff/common/constants/images'

// Mock the logger to prevent analytics initialization errors in tests
mock.module('../logger', () => ({
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
},
}))

let TEST_DIR: string

beforeEach(async () => {
TEST_DIR = mkdtempSync(path.join(os.tmpdir(), 'cli-image-integrity-'))
mkdirSync(path.join(TEST_DIR, 'debug'), { recursive: true })
setProjectRoot(TEST_DIR)
})

afterEach(() => {
try {
rmSync(TEST_DIR, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})

/** Fill an image with high-entropy content so its PNG encoding is large
* enough to exercise the compression path. */
function addNoise(image: InstanceType<typeof Jimp>, amount = 0.9): void {
image.scan(0, 0, image.bitmap.width, image.bitmap.height, (x, y, idx) => {
if (Math.random() < amount) {
image.bitmap.data[idx] = Math.floor(Math.random() * 256)
image.bitmap.data[idx + 1] = Math.floor(Math.random() * 256)
image.bitmap.data[idx + 2] = Math.floor(Math.random() * 256)
// Leave alpha as-is
}
})
}

describe('image pipeline integrity', () => {
test('small PNG passes through byte-identical and decodes', async () => {
const image = new Jimp({ width: 200, height: 100, color: 0x1e90ffff })
// Draw a readable "UI-like" pattern: white bar + dark text-like stripes
image.scan(0, 0, 200, 100, (x, y, idx) => {
if (x >= 20 && x < 180 && y >= 30 && y < 70) {
image.bitmap.data[idx] = 0xff
image.bitmap.data[idx + 1] = 0xff
image.bitmap.data[idx + 2] = 0xff
}
})
const filePath = path.join(TEST_DIR, 'small-200x100.png') as `${string}.${string}`
await image.write(filePath)
const originalBytes = readFileSync(filePath)

const result = await processImageFile('small-200x100.png', TEST_DIR)

expect(result.success).toBe(true)
expect(result.imagePart).toBeDefined()
expect(result.wasCompressed).toBe(false)

const part = result.imagePart!
expect(part.width).toBe(200)
expect(part.height).toBe(100)
expect(part.mediaType).toBe('image/png')

// The passthrough path must not mutate the payload.
const decoded = Buffer.from(part.image, 'base64')
expect(decoded.equals(originalBytes)).toBe(true)

// And it must re-decode as a valid PNG with the right dimensions.
const reread = await Jimp.read(decoded)
expect(reread.bitmap.width).toBe(200)
expect(reread.bitmap.height).toBe(100)
})

test('large noisy image is compressed to a valid JPEG that still decodes', async () => {
// 1600x1600 noise produces a PNG far above MAX_IMAGE_BASE64_SIZE.
const image = new Jimp({ width: 1600, height: 1600, color: 0x000000ff })
addNoise(image)
const filePath = path.join(TEST_DIR, 'big-noisy.png') as `${string}.${string}`
await image.write(filePath)

const originalBase64 = readFileSync(filePath).toString('base64')
expect(originalBase64.length).toBeGreaterThan(MAX_IMAGE_BASE64_SIZE)

const result = await processImageFile('big-noisy.png', TEST_DIR)

expect(result.success).toBe(true)
expect(result.wasCompressed).toBe(true)
const part = result.imagePart!
expect(part.mediaType).toBe('image/jpeg')
expect(part.image.length).toBeLessThanOrEqual(MAX_IMAGE_BASE64_SIZE)
expect(part.width).toBeDefined()
expect(part.height).toBeDefined()

// The compressed payload must decode as a real JPEG.
const decoded = Buffer.from(part.image, 'base64')
expect(decoded[0]).toBe(0xff)
expect(decoded[1]).toBe(0xd8)

const reread = await Jimp.read(decoded)
// Aspect ratio preserved (1600x1600 → square at reduced dimension).
expect(reread.bitmap.width).toBe(reread.bitmap.height)
expect(reread.bitmap.width).toBe(part.width!)
expect(reread.bitmap.height).toBe(part.height!)
// Resized to the largest dimension that fit the budget.
expect(reread.bitmap.width).toBeGreaterThan(0)
expect(reread.bitmap.width).toBeLessThanOrEqual(1600)

// Content must survive: average luminance should stay high (noise).
let sum = 0
let count = 0
reread.scan(0, 0, reread.bitmap.width, reread.bitmap.height, (_x, _y, idx) => {
sum += reread.bitmap.data[idx]
count++
})
const avg = sum / count
expect(avg).toBeGreaterThan(30)
expect(avg).toBeLessThan(235)
})

test('landscape compression preserves aspect ratio', async () => {
const image = new Jimp({ width: 2400, height: 1200, color: 0x00ff00ff })
addNoise(image, 0.7)
const filePath = path.join(TEST_DIR, 'wide-2400x1200.png') as `${string}.${string}`
await image.write(filePath)

const result = await processImageFile('wide-2400x1200.png', TEST_DIR)
expect(result.success).toBe(true)
expect(result.wasCompressed).toBe(true)

const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64'))
expect(reread.bitmap.width / reread.bitmap.height).toBeCloseTo(2, 1)
})

test('portrait compression preserves aspect ratio', async () => {
const image = new Jimp({ width: 1200, height: 2400, color: 0xff0000ff })
addNoise(image, 0.7)
const filePath = path.join(TEST_DIR, 'tall-1200x2400.png') as `${string}.${string}`
await image.write(filePath)

const result = await processImageFile('tall-1200x2400.png', TEST_DIR)
expect(result.success).toBe(true)
expect(result.wasCompressed).toBe(true)

const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64'))
expect(reread.bitmap.height / reread.bitmap.width).toBeCloseTo(2, 1)
})

test('transparent PNG is compressed without crashing and keeps dimensions', async () => {
const image = new Jimp({ width: 800, height: 600, color: 0x00000000 })
// Transparent background with a small opaque shape — common for pasted
// UI elements; JPEG re-encode must still produce a decodable image.
image.scan(0, 0, 800, 600, (x, y, idx) => {
if (x > 100 && x < 300 && y > 100 && y < 300) {
image.bitmap.data[idx] = 0x00
image.bitmap.data[idx + 1] = 0x80
image.bitmap.data[idx + 2] = 0xff
image.bitmap.data[idx + 3] = 0xff
}
})
const filePath = path.join(TEST_DIR, 'alpha-800x600.png') as `${string}.${string}`
await image.write(filePath)

const result = await processImageFile('alpha-800x600.png', TEST_DIR)
expect(result.success).toBe(true)
if (result.wasCompressed) {
const part = result.imagePart!
const reread = await Jimp.read(Buffer.from(part.image, 'base64'))
expect(reread.bitmap.width).toBe(part.width!)
expect(reread.bitmap.height).toBe(part.height!)
}
})

test('jpeg input is accepted and re-encodes validly when compressed', async () => {
const image = new Jimp({ width: 1400, height: 900, color: 0x808080ff })
addNoise(image, 0.8)
const filePath = path.join(TEST_DIR, 'photo-1400x900.jpg') as `${string}.${string}`
await image.write(filePath)

const result = await processImageFile('photo-1400x900.jpg', TEST_DIR)
expect(result.success).toBe(true)
const part = result.imagePart!
expect(part.mediaType).toBe('image/jpeg')

const reread = await Jimp.read(Buffer.from(part.image, 'base64'))
expect(reread.bitmap.width).toBe(part.width!)
expect(reread.bitmap.height).toBe(part.height!)
})

test('oversized file is rejected with a clear error, not corrupted', async () => {
// MAX_IMAGE_FILE_SIZE is 10MB; write a file that exceeds it and confirm
// we get a descriptive error instead of a silent failure.
const bigPath = path.join(TEST_DIR, 'huge.png')
const chunk = Buffer.alloc(1024 * 1024, 0x89)
writeFileSync(bigPath, Buffer.concat(Array(11).fill(chunk)))

const result = await processImageFile('huge.png', TEST_DIR)
expect(result.success).toBe(false)
expect(result.error).toContain('too large')
})
})
164 changes: 164 additions & 0 deletions cli/src/utils/__tests__/terminal-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, test, expect, beforeEach } from 'bun:test'

import {
detectTerminalImageSupport,
renderInlineImage,
resetTerminalImageSupportCache,
} from '../terminal-images'

/** Detect kitty support and keep it cached so renderInlineImage uses it. */
const useKitty = () => {
resetTerminalImageSupportCache()
expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe(
'kitty',
)
}

/** Detect iTerm2 support and keep it cached so renderInlineImage uses it. */
const useITerm2 = () => {
resetTerminalImageSupportCache()
expect(detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any)).toBe(
'iterm2',
)
}

describe('detectTerminalImageSupport', () => {
beforeEach(() => {
resetTerminalImageSupportCache()
})

test('detects iTerm2', () => {
expect(
detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any),
).toBe('iterm2')
})

test('detects kitty by TERM', () => {
expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe(
'kitty',
)
})

test('detects kitty by KITTY_WINDOW_ID', () => {
expect(
detectTerminalImageSupport({ KITTY_WINDOW_ID: '1' } as any),
).toBe('kitty')
})

test('detects WezTerm', () => {
expect(
detectTerminalImageSupport({ TERM_PROGRAM: 'WezTerm' } as any),
).toBe('kitty')
})

test('detects Ghostty', () => {
expect(
detectTerminalImageSupport({ TERM_PROGRAM: 'Ghostty' } as any),
).toBe('kitty')
})

test('detects Warp', () => {
expect(
detectTerminalImageSupport({ TERM_PROGRAM: 'WarpTerminal' } as any),
).toBe('kitty')
})

test('detects Konsole', () => {
expect(
detectTerminalImageSupport({ KONSOLE_VERSION: '230604' } as any),
).toBe('kitty')
})

test('unknown terminal (e.g. Windows Terminal) falls back to none', () => {
expect(
detectTerminalImageSupport({
TERM: 'xterm-256color',
TERM_PROGRAM: 'Windows Terminal',
WT_SESSION: 'abc',
} as any),
).toBe('none')
})
})

describe('generateKittyImageSequence (via renderInlineImage)', () => {
beforeEach(() => {
resetTerminalImageSupportCache()
})

test('single chunk carries m=0 to close the transmission', () => {
useKitty()
const seq = renderInlineImage('aGVsbG8=', {
width: 4,
height: 3,
})
expect(seq).toContain('a=T')
expect(seq).toContain('f=100')
expect(seq).toContain('t=d')
expect(seq).toContain('c=4')
expect(seq).toContain('r=3')
expect(seq).toContain('m=0')
expect(seq).toEndWith('aGVsbG8=\x1b\\')
})

test('multi-chunk: full control only on first chunk; m=1 middle; m=0 last', () => {
useKitty()
// 9000 base64 chars → 3 chunks (4096 + 4096 + 808)
const seq = renderInlineImage('A'.repeat(9000), {
width: 10,
height: 5,
})
expect(seq).toContain('f=100')
expect(seq).toContain('c=10')

const parts = seq!.split('\x1b\\').filter(Boolean)
expect(parts).toHaveLength(3)

// First chunk: full control data + m=1
expect(parts[0]).toContain('a=T')
expect(parts[0]).toContain('f=100')
expect(parts[0]).toContain('m=1')
// Middle chunk: m only — no a=, no f=, no c=, no r=
expect(parts[1]).toMatch(/^\x1b_Gm=1;A{4096}$/)
// Last chunk: m=0
expect(parts[2]).toMatch(/^\x1b_Gm=0;A{808}$/)
})

test('subsequent chunks never repeat a=T / f= / c= (kitty spec)', () => {
useKitty()
// 9000 chars → 3 chunks, so index 1 is a true middle chunk.
const seq = renderInlineImage('B'.repeat(9000), {
})
const middle = seq!.split('\x1b\\')[1]
expect(middle).not.toContain('a=T')
expect(middle).not.toContain('f=')
expect(middle).not.toContain('c=')
expect(middle).toMatch(/^\x1b_Gm=1;/)
})
})

describe('generateITerm2ImageSequence (via renderInlineImage)', () => {
beforeEach(() => {
resetTerminalImageSupportCache()
})

test('size param is the decoded byte length, not the base64 length', () => {
useITerm2()
const seq = renderInlineImage('aGVsbG8=', { filename: 'x.png' })
// 'hello' → 5 decoded bytes; base64 'aGVsbG8=' → 8 chars
expect(seq).toContain('size=5')
expect(seq).not.toContain('size=8')
expect(seq).toContain('inline=1')
expect(seq).toContain('name=eC5wbmc=')
})

test('returns null when the terminal does not support inline images', () => {
// Prime the cache with an explicit 'none' so the assertion doesn't depend
// on whatever terminal this test happens to run inside.
resetTerminalImageSupportCache()
expect(
detectTerminalImageSupport({ TERM: 'xterm-256color' } as any),
).toBe('none')
const seq = renderInlineImage('aGVsbG8=', {})
expect(seq).toBeNull()
})
})
Loading
Loading