Files
opentui/packages/core/src/buffer.test.ts
T
Simon Klee ddb7fbd691 native: reject invalid blended cell coordinates (#1556)
Negative JavaScript coordinates arrive through Bun FFI as wrapped u32
values. Reject them before signed conversion so off-screen blended
writes
cannot overflow or address an invalid cell.

Fix #1552
2026-10-01 22:12:39 +02:00

480 lines
16 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach } from "bun:test"
import { OptimizedBuffer } from "./buffer.js"
import { RGBA } from "./lib/RGBA.js"
import { NativeImage } from "./image.js"
describe("OptimizedBuffer", () => {
let buffer: OptimizedBuffer
beforeEach(() => {
buffer = OptimizedBuffer.create(20, 5, "unicode", { id: "test-buffer" })
})
afterEach(() => {
buffer.destroy()
})
it("preserves u32 attributes across per-cell FFI calls", () => {
const fg = RGBA.fromInts(255, 255, 255)
const bg = RGBA.fromInts(0, 0, 0)
const attributes = [0x8000_00ff, 0x4000_01fe, 0x2000_02fd]
buffer.setCell(0, 0, "S", fg, bg, attributes[0])
buffer.setCellWithAlphaBlending(1, 0, "A", fg, bg, attributes[1])
buffer.drawChar("D".codePointAt(0)!, 2, 0, fg, bg, attributes[2])
expect([...buffer.buffers.attributes.slice(0, attributes.length)]).toEqual(attributes)
})
it("clips draws at negative positions", () => {
// Node FFI rejects a negative u32 argument, so every call here also checks that positions cross FFI as i32.
const target = OptimizedBuffer.create(3, 2, "unicode", { id: "negative-positions" })
try {
const white = RGBA.fromInts(255, 255, 255)
const black = RGBA.fromInts(0, 0, 0)
target.clear(black)
target.setCell(-1, 0, "S", white, black)
target.setCellWithAlphaBlending(0, -1, "A", white, black)
target.drawChar("D".codePointAt(0)!, -1, -1, white, black)
target.drawSuperSampleBuffer(-1, -1, new Uint8Array(16), 16, "rgba8unorm", 8)
target.drawPackedBuffer(new Uint8Array(48), 48, -1, -1, 1, 1)
target.drawText("ABCD", -2, 1, white, black)
target.fillRect(-1, -1, 2, 2, RGBA.fromInts(255, 0, 0))
expect(new TextDecoder().decode(target.getRealCharBytes(true))).toBe(" \nCD \n")
expect([0, 1, 3].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([255, 0, 0])
} finally {
target.destroy()
}
})
it("fills nothing for a non-positive extent", () => {
// Bun wraps a negative u32 argument into a huge extent, so the wrapper must return before the FFI call.
const target = OptimizedBuffer.create(3, 2, "unicode", { id: "empty-extents" })
try {
const red = RGBA.fromInts(255, 0, 0)
target.clear(RGBA.fromInts(0, 0, 0))
target.fillRect(1, 0, -1, 1, red)
target.fillRect(1, 0, 1, -1, red)
target.fillRect(1, 0, 0, 1, red)
expect([0, 1, 2, 3, 4, 5].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([0, 0, 0, 0, 0, 0])
} finally {
target.destroy()
}
})
it("draws images as reserved cells with resolved fallback glyphs", () => {
const image = NativeImage.fromRgba(
Uint8Array.of(255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255),
2,
2,
)
try {
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
const marker = buffer.buffers.char[0]
expect(marker >>> 30).toBe(1)
expect(new TextDecoder().decode(buffer.getRealCharBytes())).not.toContain("�")
buffer.setCell(0, 0, "X", RGBA.fromInts(255, 255, 255), RGBA.fromInts(0, 0, 0))
expect(buffer.buffers.char[0]).toBe("X".codePointAt(0)!)
} finally {
image.dispose()
}
})
it("retains drawn images until the buffer releases them", () => {
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
let raw: ReturnType<NativeImage["takeRaw"]> | undefined
try {
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
expect(() => image.takeRaw()).toThrow("native buffers retain the image")
buffer.destroy()
raw = image.takeRaw()
expect([...raw.data]).toEqual([1, 2, 3, 255])
} finally {
raw?.dispose()
image.dispose()
}
})
it("releases drawn images when cleared", () => {
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
let raw: ReturnType<NativeImage["takeRaw"]> | undefined
try {
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
expect(() => image.takeRaw()).toThrow("native buffers retain the image")
buffer.clear()
raw = image.takeRaw()
expect([...raw.data]).toEqual([1, 2, 3, 255])
} finally {
raw?.dispose()
image.dispose()
}
})
it("rejects invalid image draw geometry before FFI", () => {
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
try {
expect(() => buffer.drawImage(image, 0, 0, Number.POSITIVE_INFINITY, 1)).toThrow(RangeError)
expect(() => buffer.drawImage(image, 0, 0, -1, 1)).toThrow(RangeError)
expect(() => buffer.drawImage(image, 0.5, 0, 1, 1)).toThrow(RangeError)
expect(() => buffer.drawImage(image, 0, 0, 0x80000000, 1)).toThrow(RangeError)
expect(() => buffer.drawImage(image, 0x7fffffff, 0, 1, 1)).toThrow(RangeError)
} finally {
image.dispose()
}
})
describe("encodeUnicode", () => {
it("should encode simple ASCII text", () => {
const encoded = buffer.encodeUnicode("Hello")
expect(encoded).not.toBeNull()
expect(encoded!.data.length).toBe(5)
expect(encoded!.data[0]).toEqual({ width: 1, char: 72 }) // 'H'
expect(encoded!.data[1]).toEqual({ width: 1, char: 101 }) // 'e'
expect(encoded!.data[2]).toEqual({ width: 1, char: 108 }) // 'l'
expect(encoded!.data[3]).toEqual({ width: 1, char: 108 }) // 'l'
expect(encoded!.data[4]).toEqual({ width: 1, char: 111 }) // 'o'
buffer.freeUnicode(encoded!)
})
it("should encode emoji with correct width", () => {
const encoded = buffer.encodeUnicode("👋")
expect(encoded).not.toBeNull()
expect(encoded!.data.length).toBe(1)
expect(encoded!.data[0].width).toBe(2)
// Should be a packed grapheme (has high bit set)
expect(encoded!.data[0].char).toBeGreaterThan(0x80000000)
buffer.freeUnicode(encoded!)
})
it("should encode mixed ASCII and emoji", () => {
const encoded = buffer.encodeUnicode("Hi 👋 World")
expect(encoded).not.toBeNull()
expect(encoded!.data.length).toBe(10) // H, i, space, emoji, space, W, o, r, l, d
// Check ASCII chars
expect(encoded!.data[0].width).toBe(1)
expect(encoded!.data[0].char).toBe(72) // 'H'
// Check emoji
expect(encoded!.data[3].width).toBe(2)
expect(encoded!.data[3].char).toBeGreaterThan(0x80000000)
buffer.freeUnicode(encoded!)
})
it("should handle empty string", () => {
const encoded = buffer.encodeUnicode("")
expect(encoded).not.toBeNull()
expect(encoded!.data.length).toBe(0)
buffer.freeUnicode(encoded!)
})
it("should encode monkey emoji frames and draw in a line", () => {
const frames = ["🙈 ", "🙈 ", "🙉 ", "🙊 "]
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
buffer.clear(bg)
let x = 0
for (const frame of frames) {
const encoded = buffer.encodeUnicode(frame)
expect(encoded).not.toBeNull()
for (const encodedChar of encoded!.data) {
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
x += encodedChar.width
}
buffer.freeUnicode(encoded!)
}
const frameBytes = buffer.getRealCharBytes(false)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toContain("🙈")
expect(frameText).toContain("🙉")
expect(frameText).toContain("🙊")
})
})
describe("drawChar", () => {
it("should draw a simple ASCII character", () => {
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
buffer.drawChar(72, 0, 0, fg, bg) // 'H'
const chars = buffer.buffers.char
expect(chars[0]).toBe(72)
})
it("should draw encoded characters from encodeUnicode", () => {
const encoded = buffer.encodeUnicode("Hello")
expect(encoded).not.toBeNull()
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
// Draw each character
for (let i = 0; i < encoded!.data.length; i++) {
buffer.drawChar(encoded!.data[i].char, i, 0, fg, bg)
}
// Verify buffer content
const frameBytes = buffer.getRealCharBytes(false)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toContain("Hello")
buffer.freeUnicode(encoded!)
})
it("should draw emoji using encoded char", () => {
const encoded = buffer.encodeUnicode("👋")
expect(encoded).not.toBeNull()
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
buffer.drawChar(encoded!.data[0].char, 0, 0, fg, bg)
const frameBytes = buffer.getRealCharBytes(false)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toContain("👋")
buffer.freeUnicode(encoded!)
})
})
describe("snapshot tests with unicode encoding", () => {
it("should render ASCII text correctly", () => {
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
const encoded = buffer.encodeUnicode("Hello")
expect(encoded).not.toBeNull()
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
let x = 0
for (const encodedChar of encoded!.data) {
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
x += encodedChar.width
}
const frameBytes = buffer.getRealCharBytes(true)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toMatchSnapshot("ASCII text rendering")
buffer.freeUnicode(encoded!)
})
it("should render emoji text correctly", () => {
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
const encoded = buffer.encodeUnicode("Hi 👋 🌍")
expect(encoded).not.toBeNull()
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
let x = 0
for (const encodedChar of encoded!.data) {
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
x += encodedChar.width
}
const frameBytes = buffer.getRealCharBytes(true)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toMatchSnapshot("Emoji text rendering")
buffer.freeUnicode(encoded!)
})
it("should handle multiline text with unicode", () => {
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
const lines = ["Hi 世界", "🌟 Star"]
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
for (let y = 0; y < lines.length; y++) {
const encoded = buffer.encodeUnicode(lines[y])
expect(encoded).not.toBeNull()
let x = 0
for (const encodedChar of encoded!.data) {
buffer.drawChar(encodedChar.char, x, y, fg, bg)
x += encodedChar.width
}
buffer.freeUnicode(encoded!)
}
const frameBytes = buffer.getRealCharBytes(true)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toMatchSnapshot("Multiline unicode rendering")
})
it("should respect character widths in positioning", () => {
const encoded = buffer.encodeUnicode("A👋B")
expect(encoded).not.toBeNull()
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
// 'A' at x=0, emoji at x=1 (width 2), 'B' at x=3
buffer.drawChar(encoded!.data[0].char, 0, 0, fg, bg) // 'A'
buffer.drawChar(encoded!.data[1].char, 1, 0, fg, bg) // emoji
buffer.drawChar(encoded!.data[2].char, 3, 0, fg, bg) // 'B'
const frameBytes = buffer.getRealCharBytes(false)
const frameText = new TextDecoder().decode(frameBytes)
expect(frameText).toContain("A👋B")
buffer.freeUnicode(encoded!)
})
})
describe("drawChar with alpha blending", () => {
it("should blend semi-transparent foreground", () => {
const fg = RGBA.fromValues(1, 0, 0, 0.5)
const bg = RGBA.fromValues(0, 0, 0, 1)
buffer.drawChar(65, 0, 0, fg, bg) // 'A'
const fgBuffer = buffer.buffers.fg
// Foreground alpha is flattened against the final opaque cell background.
expect(fgBuffer[0] & 0xff).toBe(128)
expect(fgBuffer[3] & 0xff).toBe(255)
})
it("should blend semi-transparent background", () => {
buffer.setRespectAlpha(true)
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(1, 0, 0, 0.5)
buffer.drawChar(65, 0, 0, fg, bg) // 'A'
const bgBuffer = buffer.buffers.bg
// Background should reflect the alpha
expect(bgBuffer[3] & 0xff).toBeLessThan(255)
})
})
describe("grapheme pool churn across drawFrameBuffer", () => {
it("should not crash with WrongGeneration after many grapheme alloc cycles", () => {
const parent = OptimizedBuffer.create(40, 5, "unicode", { id: "parent" })
const child = OptimizedBuffer.create(40, 5, "unicode", { id: "child", respectAlpha: true })
const fg = RGBA.fromValues(1, 1, 1, 1)
const bg = RGBA.fromValues(0, 0, 0, 1)
for (let cycle = 0; cycle < 50; cycle++) {
parent.clear(bg)
if (cycle % 2 === 0) {
child.drawText("╭────────────────────────────────────╮", 0, 0, fg, bg)
child.drawText("│ ◇ Select Files ▫ src/ ▪ file.ts │", 0, 1, fg, bg)
child.drawText("│ ↑↓ navigate ⏎ select esc close │", 0, 2, fg, bg)
child.drawText("╰────────────────────────────────────╯", 0, 3, fg, bg)
} else {
child.drawText(" Your Name ", 0, 0, fg, bg)
child.drawText(" John Doe ", 0, 1, fg, bg)
child.drawText(" ", 0, 2, fg, bg)
child.drawText(" Select Files ", 0, 3, fg, bg)
}
parent.drawFrameBuffer(0, 0, child)
const frameBytes = parent.getRealCharBytes(true)
const text = new TextDecoder().decode(frameBytes)
expect(text.length).toBeGreaterThan(0)
}
child.destroy()
parent.destroy()
})
})
describe("draw text encoding", () => {
const white = RGBA.fromInts(255, 255, 255)
const black = RGBA.fromInts(0, 0, 0)
const rows = (target: OptimizedBuffer) =>
new TextDecoder()
.decode(target.getRealCharBytes(true))
.split("\n")
.map((row) => row.trimEnd())
it("draws non-string text as TextEncoder converts it", () => {
buffer.clear(black)
buffer.drawText(123 as never, 0, 0, white)
buffer.drawText(["a", "b"] as never, 0, 1, white)
buffer.drawText(undefined as never, 0, 2, white)
buffer.drawText(null as never, 0, 3, white)
expect(rows(buffer).slice(0, 4)).toEqual(["123", "a,b", "", "null"])
})
it("draws only the latest text after longer and multi-byte text", () => {
buffer.clear(black)
buffer.drawText("é漢😀 wide", 0, 0, white)
buffer.drawText("x".repeat(5000), 0, 1, white)
buffer.drawText("ok", 0, 1, white)
buffer.drawText("é漢😀", 0, 2, white)
buffer.drawText("ab", 0, 2, white)
expect(rows(buffer).slice(0, 3)).toEqual(["é漢😀 wide", "ok" + "x".repeat(18), "ab 😀"])
})
it("draws all of a multi-byte text whose UTF-8 is longer than its UTF-16 length", () => {
const wide = OptimizedBuffer.create(3000, 1, "unicode", { id: "wide-buffer" })
try {
const text = "漢".repeat(1400)
wide.drawText(text, 0, 0, white)
expect(rows(wide)[0]).toBe(text)
} finally {
wide.destroy()
}
})
it("converts both box titles before a title's toString can draw", () => {
const other = OptimizedBuffer.create(12, 1, "unicode", { id: "other-buffer" })
try {
other.clear(black)
buffer.clear(black)
const bottomTitle = {
toString() {
other.drawText("XYZ", 0, 0, white)
return "BOT"
},
}
buffer.drawBox({
x: 0,
y: 0,
width: 12,
height: 3,
border: true,
borderColor: white,
backgroundColor: black,
title: "TOP",
bottomTitle: bottomTitle as never,
})
const [top, , bottom] = rows(buffer)
expect(top).toContain("TOP")
expect(bottom).toContain("BOT")
expect(rows(other)[0]).toBe("XYZ")
} finally {
other.destroy()
}
})
})
})