opencode/packages/tui/test/mini/theme.test.ts
2026-10-19 16:47:33 +02:00

438 lines
16 KiB
TypeScript

import { afterAll, beforeAll, expect, test } from "bun:test"
import path from "node:path"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
import {
RUN_THEME_MONO,
RUN_THEME_FALLBACK,
RUN_THEME_FALLBACK_LIGHT,
resolveRunTheme,
type RunTheme,
} from "../../src/mini/theme"
import { DEFAULT_THEMES, getOpenCodeTheme, parseTheme } from "../../src/theme"
import { generateSystem } from "../../src/theme/system"
import { tmpdir } from "../fixture/fixture"
const tmp = await tmpdir()
const resources = await createTestRenderer({})
afterAll(async () => {
resources.renderer.destroy()
await resources.renderer.closed
})
const previousConfig = process.env.OPENCODE_CONFIG_DIR
beforeAll(() => {
process.env.OPENCODE_CONFIG_DIR = tmp.path
})
afterAll(async () => {
if (previousConfig === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = previousConfig
await tmp[Symbol.asyncDispose]()
})
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
function terminalColors(input: Partial<TerminalColors> = {}, mode: "light" | "dark" = "dark"): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => palette[index % palette.length]!),
defaultBackground: mode === "light" ? "#fbf1c7" : "#1a1b26",
defaultForeground: mode === "light" ? "#3c3836" : "#c0caf5",
cursorColor: "#ff9e64",
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: "#33467c",
highlightForeground: "#c0caf5",
...input,
}
}
const emptyColors = terminalColors({
palette: Array.from({ length: 256 }, () => null),
defaultBackground: null,
defaultForeground: null,
cursorColor: null,
highlightBackground: null,
highlightForeground: null,
})
test("theme lookup does not allocate resources after renderer shutdown", async () => {
const setup = await createTestRenderer({})
const palette = Promise.withResolvers<TerminalColors>()
setup.renderer.getPalette = () => palette.promise
const pending = resolveRunTheme(setup.renderer)
setup.renderer.destroy()
await setup.renderer.closed
palette.resolve(terminalColors())
expect((await pending).block.syntax).toBeUndefined()
})
function renderer(
input: {
themeMode?: "dark" | "light"
resolvedThemeMode?: "dark" | "light"
colors?: TerminalColors
fail?: boolean
} = {},
) {
return {
nativeScene: resources.renderer.nativeScene,
get themeMode() {
return input.themeMode
},
waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null,
getPalette: async () => {
if (input.fail) throw new Error("boom")
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function rgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) throw new Error("expected RGBA")
return color
}
function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
const expected = {
text: theme.text.base,
muted: theme.text.muted,
warning: theme.text.feedback.warning.base,
error: theme.text.feedback.error.base,
actionSecondaryText: theme.text.action.secondary.base,
actionFocusedBg: theme.background.action.primary.focused,
actionFocusedText: theme.text.action.primary.focused,
formfieldText: theme.text.formfield.base,
formfieldFocusedBg: theme.background.formfield.focused,
formfieldFocusedText: theme.text.formfield.focused,
selection: theme.text.formfield.selected,
running: theme.hue.interactive[200],
question: theme.hue.accent[200],
permission: theme.hue.accent[200],
success: theme.text.feedback.success.base,
link: theme.markdown.link,
shade: theme.background.raised.base,
surface: theme.background.raised.base,
pane: theme.background.raised.high,
border: theme.border.base,
line: theme.background.raised.high,
}
Object.entries(expected).forEach(([key, color]) => {
expect(rgba(actual.footer[key as keyof typeof expected]).toInts()).toEqual(color.toInts())
})
expect(rgba(actual.background).intent).toBe("default")
}
test.each(["light", "dark"] as const)("preserves %s monochrome terminal defaults", async (mode) => {
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "unknown", mode }, true)
expect(theme.block.syntax).toBeUndefined()
expect(rgba(theme.footer.text).toInts().slice(0, 3)).toEqual(mode === "light" ? [0, 0, 0] : [255, 255, 255])
for (const color of [
theme.background,
...Object.values(theme.footer).flat(),
...Object.values(theme.splash),
...Object.values(theme.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
...Object.values(theme.block),
]) {
expect(rgba(color).intent).toBe("default")
}
if (mode === "dark") expect(await resolveRunTheme(renderer(), undefined, true)).toBe(RUN_THEME_MONO)
expect(await resolveRunTheme(renderer({ fail: true, resolvedThemeMode: mode }), undefined, true)).toBe(theme)
const term = renderer({ themeMode: mode })
let queries = 0
term.getPalette = async () => {
queries++
return terminalColors()
}
expect(await resolveRunTheme(term, { mode: mode === "light" ? "dark" : "light" }, true)).toBe(theme)
expect(queries).toBe(0)
})
test.each(["light", "dark"] as const)("uses shared %s defaults and named built-in themes", async (mode) => {
const colors = terminalColors({}, mode)
for (const name of [undefined, "opencode", "tokyonight"] as const) {
const theme = await resolveRunTheme(renderer({ colors }), { name, mode })
const expected = resolveThemeDocument(
name === undefined || name === "opencode" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES[name]),
mode,
)
try {
expectFooter(theme, expected)
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
expect(theme.footer.categorical.map((color) => rgba(color).toInts())).toEqual(
expected.categorical.map((scale) => scale[200].toInts()),
)
expect(theme.block.syntax?.getAllStyles().size).toBeGreaterThan(0)
for (const color of [
theme.entry.user.body,
theme.entry.assistant.body,
theme.block.text,
...Object.values(theme.splash),
]) {
expect(rgba(color).intent).toBe("indexed")
expect(rgba(color).slot).toBeLessThan(256)
}
expect(rgba(theme.footer.text).intent).toBe("rgb")
for (const style of theme.block.syntax!.getAllStyles().values()) {
if (style.fg && style.fg.a !== 0) expect(style.fg.intent).toBe("indexed")
if (style.bg && style.bg.a !== 0) expect(style.bg.intent).toBe("indexed")
}
} finally {
theme.block.syntax?.destroy()
}
}
})
test.each(["light", "dark"] as const)("shares the %s system scheme while retaining terminal intent", async (mode) => {
const colors = terminalColors({
defaultBackground: mode === "light" ? "#fbf1c7" : "#0f172a",
defaultForeground: mode === "light" ? "#3c3836" : "#e2e8f0",
})
const theme = await resolveRunTheme(renderer({ colors }), { name: "system", mode })
try {
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, mode)), mode))
expect(rgba(theme.footer.text).intent).toBe("default")
expect(rgba(theme.entry.user.body).intent).toBe("default")
expect(rgba(theme.entry.assistant.body).intent).toBe("default")
expect(theme.block.syntax?.getStyle("default")?.fg?.intent).toBe("default")
expect(rgba(theme.footer.surface).intent).toBe("rgb")
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
Object.values(theme.splash).forEach((color) => expect(rgba(color).intent).toBe("indexed"))
} finally {
theme.block.syntax?.destroy()
}
})
test.each(["light", "dark"] as const)(
"loads complete %s custom theme files",
async (mode) => {
const base = selectTheme(getOpenCodeTheme(), mode)
const definition = {
...base,
text: {
...base.text,
base: "#123456",
action: {
...base.text.action,
primary: { ...base.text.action.primary, $focused: "#56789a" },
},
formfield: {
...base.text.formfield,
base: "#234567",
$selected: "#345678",
$focused: "#6789ab",
},
feedback: {
...base.text.feedback,
warning: { ...base.text.feedback.warning, base: "#456789" },
},
},
background: {
...base.background,
action: {
...base.background.action,
primary: { ...base.background.action.primary, $focused: "#789abc" },
},
formfield: { ...base.background.formfield, $focused: "#89abcd" },
},
}
const { hue, ...tokens } = definition
const source = {
base: tokens,
[mode]: { hue },
}
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), { name: "mini-custom", mode })
try {
expectFooter(theme, resolveThemeDocument(parseTheme(source), mode))
} finally {
theme.block.syntax?.destroy()
}
},
)
test.each(["light", "dark"] as const)(
"falls back to shared %s defaults for unknown or invalid themes",
async (mode) => {
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const source of [
{ [mode]: { categorical: [] } },
{ [mode]: { text: { base: "$missing" } } },
undefined,
]) {
if (source) await Bun.write(path.join(tmp.path, "themes", "mini-invalid.json"), JSON.stringify(source))
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), {
name: source ? "mini-invalid" : "mini-unknown",
mode,
})
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %s terminal", async (mode) => {
const colors = terminalColors({
defaultBackground: mode === "light" ? "#ffffff" : "#0f0f0f",
defaultForeground: mode === "light" ? "#000000" : "#edecee",
})
const theme = await resolveRunTheme(renderer({ colors }), { name: "aura" })
try {
expectFooter(
theme,
resolveThemeDocument(mode === "light" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES.aura), mode),
)
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
} finally {
theme.block.syntax?.destroy()
}
})
test.each(["light", "dark"] as const)(
"falls back only for unsupported modes of a %s-only custom theme",
async (mode) => {
const base = selectTheme(getOpenCodeTheme(), mode)
const definition = { ...base, text: { ...base.text, base: "#123456" } }
const { hue, ...tokens } = definition
const source = { base: tokens, [mode]: { hue } }
await Bun.write(path.join(tmp.path, "themes", "mini-one-mode.json"), JSON.stringify(source))
for (const requested of ["light", "dark"] as const) {
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, requested) }), {
name: "mini-one-mode",
mode: requested,
})
try {
expectFooter(
theme,
resolveThemeDocument(requested === mode ? parseTheme(source) : getOpenCodeTheme(), requested),
)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test.each(["light", "dark"] as const)("handles unavailable palettes in %s mode", async (mode) => {
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const input of [
{ fail: true },
{ colors: emptyColors },
{ colors: { ...emptyColors, defaultBackground: terminalColors({}, mode).defaultBackground } },
{ colors: { ...emptyColors, defaultForeground: terminalColors({}, mode).defaultForeground } },
]) {
const fallback = await resolveRunTheme(renderer(input), { name: "system", mode })
expect(fallback).toBe(mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK)
expectFooter(fallback, expected)
}
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "opencode", mode })
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
})
test("uses refreshed background brightness rather than stale mode or ANSI slot zero", async () => {
for (const colors of [
terminalColors({}, "light"),
terminalColors({ defaultBackground: null, palette: ["#000000", ...terminalColors().palette.slice(1)] }),
]) {
const theme = await resolveRunTheme(renderer({ themeMode: colors.defaultBackground ? "dark" : "light", colors }), {
name: "system",
})
try {
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, "light")), "light"))
} finally {
theme.block.syntax?.destroy()
}
}
})
test.each(["light", "dark"] as const)("follows physical %s mode over opposite configuration", async (mode) => {
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const fail of [false, true]) {
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode), themeMode: mode, fail }), {
mode: mode === "light" ? "dark" : "light",
})
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
})
test.each(["system", "opencode"])(
"prefers a new renderer mode over cached %s colors after failed probes",
async (name) => {
const colors = terminalColors()
const expected = resolveThemeDocument(
name === "system" ? parseTheme(generateSystem(colors, "light")) : getOpenCodeTheme(),
"light",
)
const input = { colors, fail: false, themeMode: "dark" as "dark" | "light" }
const term = renderer(input)
const initial = await resolveRunTheme(term, { name })
initial.block.syntax?.destroy()
input.themeMode = "light"
input.colors = emptyColors
for (const fail of [true, false]) {
input.fail = fail
const theme = await resolveRunTheme(term, { name })
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test("retains a usable system palette after background-only and empty probes", async () => {
const input = { colors: terminalColors() }
const expected = resolveThemeDocument(parseTheme(generateSystem(input.colors, "dark")), "dark")
const term = renderer(input)
const initial = await resolveRunTheme(term, { name: "system" })
initial.block.syntax?.destroy()
for (const colors of [{ ...emptyColors, defaultBackground: "#202020" }, emptyColors]) {
input.colors = colors
const theme = await resolveRunTheme(term, { name: "system" })
try {
expectFooter(theme, expected)
expect(rgba(theme.footer.text).intent).toBe("default")
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
expect(rgba(theme.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
} finally {
theme.block.syntax?.destroy()
}
}
})
test.each(["system", "opencode"])(
"retains and refreshes the %s scrollback palette across probe failures",
async (name) => {
const input = { fail: false, colors: terminalColors() }
const term = renderer(input)
const initial = await resolveRunTheme(term, { name })
input.fail = true
const retained = await resolveRunTheme(term, { name, mode: "light" })
input.fail = false
input.colors = terminalColors({ palette: Array.from({ length: 256 }, () => "#ff00ff") })
const refreshed = await resolveRunTheme(term, { name })
try {
expect(rgba(retained.footer.surface).toInts()).toEqual(rgba(initial.footer.surface).toInts())
expect(rgba(retained.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
expect(rgba(refreshed.entry.reasoning.body).toInts()).not.toEqual(rgba(initial.entry.reasoning.body).toInts())
} finally {
initial.block.syntax?.destroy()
retained.block.syntax?.destroy()
refreshed.block.syntax?.destroy()
}
},
)