theme.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { expect, test } from "bun:test"
  2. import { mkdir, writeFile } from "node:fs/promises"
  3. import path from "node:path"
  4. import type { TerminalColors } from "@opentui/core"
  5. import {
  6. DEFAULT_THEMES,
  7. addTheme,
  8. allThemes,
  9. hasTheme,
  10. parseTheme,
  11. resolveTheme,
  12. setCustomThemes,
  13. upsertTheme,
  14. } from "../src/theme"
  15. import { discoverThemes, themeDirectories } from "../src/theme/discovery"
  16. import { terminalMode } from "../src/theme/system"
  17. import { tmpdir } from "./fixture/fixture"
  18. test("addTheme writes into module theme store", () => {
  19. const name = `plugin-theme-${Date.now()}`
  20. expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
  21. expect(allThemes()[name]).toBe(DEFAULT_THEMES.opencode)
  22. })
  23. test("addTheme keeps first theme for duplicate names", () => {
  24. const name = `plugin-theme-keep-${Date.now()}`
  25. const one = structuredClone(DEFAULT_THEMES.opencode)
  26. const two = structuredClone(DEFAULT_THEMES.opencode)
  27. one.theme.primary = "#101010"
  28. two.theme.primary = "#fefefe"
  29. expect(addTheme(name, one)).toBe(true)
  30. expect(addTheme(name, two)).toBe(false)
  31. expect(allThemes()[name]).toBe(one)
  32. })
  33. test("addTheme ignores values without a V1 theme or version", () => {
  34. const name = `plugin-theme-invalid-${Date.now()}`
  35. expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
  36. expect(addTheme(name, { light: {} })).toBe(false)
  37. expect(allThemes()[name]).toBeUndefined()
  38. })
  39. test("addTheme defers validation of versioned sources", () => {
  40. const name = `plugin-theme-versioned-${Date.now()}`
  41. expect(addTheme(name, { version: 2 })).toBe(true)
  42. expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
  43. })
  44. test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => {
  45. expect(() => parseTheme({})).toThrow()
  46. expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3")
  47. })
  48. test("parses unversioned and explicit V1 themes lazily once", () => {
  49. const unversioned = structuredClone(DEFAULT_THEMES.opencode)
  50. const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
  51. const first = parseTheme(unversioned, "unversioned")
  52. const second = parseTheme(explicit, "explicit")
  53. expect(first.version).toBe(2)
  54. expect(second.version).toBe(2)
  55. expect(parseTheme(unversioned, "unversioned")).toBe(first)
  56. expect(parseTheme(explicit, "explicit")).toBe(second)
  57. })
  58. test("decodes native V2 themes lazily once", () => {
  59. const name = `plugin-theme-v2-${Date.now()}`
  60. const source = { version: 2, light: { categorical: ["red"] } } as const
  61. expect(addTheme(name, source)).toBe(true)
  62. expect(allThemes()[name]).toBe(source)
  63. const document = parseTheme(allThemes()[name]!, name)
  64. expect(document.light?.categorical).toEqual(["red"])
  65. expect(parseTheme(allThemes()[name]!, name)).toBe(document)
  66. })
  67. test("defers invalid V2 errors until parsing", () => {
  68. const name = `plugin-theme-invalid-v2-${Date.now()}`
  69. expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true)
  70. expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
  71. })
  72. test("defers invalid V1 errors until parsing", () => {
  73. const name = `plugin-theme-invalid-v1-${Date.now()}`
  74. const source = structuredClone(DEFAULT_THEMES.opencode)
  75. source.defs = { ...source.defs, one: "two", two: "one" }
  76. source.theme.primary = "one"
  77. expect(addTheme(name, source)).toBe(true)
  78. expect(() => parseTheme(allThemes()[name]!, name)).toThrow("Circular color reference")
  79. })
  80. test("replacement sources receive independent parse caches", () => {
  81. const name = `plugin-theme-replace-${Date.now()}`
  82. const first = structuredClone(DEFAULT_THEMES.opencode)
  83. const second = structuredClone(DEFAULT_THEMES.opencode)
  84. second.theme.primary = "#123456"
  85. expect(addTheme(name, first)).toBe(true)
  86. const previous = parseTheme(allThemes()[name]!, name)
  87. expect(upsertTheme(name, second)).toBe(true)
  88. const next = parseTheme(allThemes()[name]!, name)
  89. expect(next).not.toBe(previous)
  90. expect(parseTheme(allThemes()[name]!, name)).toBe(next)
  91. })
  92. test("custom themes retain precedence over plugin themes", () => {
  93. const name = `plugin-theme-precedence-${Date.now()}`
  94. const plugin = structuredClone(DEFAULT_THEMES.opencode)
  95. const custom = structuredClone(DEFAULT_THEMES.opencode)
  96. expect(addTheme(name, plugin)).toBe(true)
  97. setCustomThemes({ [name]: custom })
  98. expect(allThemes()[name]).toBe(custom)
  99. setCustomThemes({})
  100. expect(allThemes()[name]).toBe(plugin)
  101. })
  102. test("hasTheme checks theme presence", () => {
  103. const name = `plugin-theme-has-${Date.now()}`
  104. expect(hasTheme(name)).toBe(false)
  105. expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
  106. expect(hasTheme(name)).toBe(true)
  107. })
  108. test("resolveTheme rejects circular color refs", () => {
  109. const item = structuredClone(DEFAULT_THEMES.opencode)
  110. item.defs = { ...item.defs, one: "two", two: "one" }
  111. item.theme.primary = "one"
  112. expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
  113. })
  114. test("resolveTheme preserves full theme numeric color and marker semantics", () => {
  115. const item = structuredClone(DEFAULT_THEMES.opencode)
  116. item.theme.primary = 6
  117. delete item.theme.selectedListItemText
  118. const theme = resolveTheme(item, "dark")
  119. expect(theme.primary.intent).toBe("rgb")
  120. expect(theme.selectedListItemText).toBe(theme.background)
  121. expect(theme._hasSelectedListItemText).toBe(false)
  122. })
  123. function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
  124. return {
  125. palette,
  126. defaultForeground: null,
  127. defaultBackground,
  128. cursorColor: null,
  129. mouseForeground: null,
  130. mouseBackground: null,
  131. tekForeground: null,
  132. tekBackground: null,
  133. highlightBackground: null,
  134. highlightForeground: null,
  135. }
  136. }
  137. test("terminalMode derives mode from refreshed background", () => {
  138. expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
  139. expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
  140. })
  141. test("terminalMode does not derive mode from ANSI slot zero", () => {
  142. expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
  143. })
  144. test("custom theme precedence follows directory order", async () => {
  145. await using tmp = await tmpdir()
  146. const global = path.join(tmp.path, "global")
  147. const project = path.join(tmp.path, "project")
  148. await mkdir(path.join(global, "themes"), { recursive: true })
  149. await mkdir(path.join(project, "themes"), { recursive: true })
  150. await writeFile(path.join(global, "themes", "custom.json"), JSON.stringify({ source: "global" }))
  151. await writeFile(path.join(project, "themes", "custom.json"), JSON.stringify({ source: "project" }))
  152. await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
  153. })
  154. test("theme directories include global config before project directories", async () => {
  155. await using tmp = await tmpdir()
  156. const global = path.join(tmp.path, "global")
  157. const project = path.join(tmp.path, "repo", "package")
  158. await mkdir(path.join(global, "themes"), { recursive: true })
  159. await mkdir(path.join(project, ".opencode", "themes"), { recursive: true })
  160. await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
  161. await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
  162. await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
  163. global: { source: "global" },
  164. project: { source: "project" },
  165. })
  166. })