plugin-structure.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { expect, test } from "bun:test"
  2. import type { SlotClaim } from "@opencode-ai/plugin/tui/context"
  3. import { resolveSlots, type Claim, type PlacementKind } from "../src/plugin/structure"
  4. // Type-level canaries, checked by `bun typecheck`: exactly one placement key,
  5. // absolute paths only, and the render input follows the targeted path.
  6. export const canaries = () => {
  7. const claims: SlotClaim[] = []
  8. claims.push({ append: "prompt.footer", render: (input) => (input.mode === "shell" ? null : null) })
  9. claims.push({ after: "prompt.footer.status", render: () => null })
  10. // @ts-expect-error two placement keys cannot coexist
  11. claims.push({ append: "prompt.footer", before: "prompt.footer.status", render: () => null })
  12. // @ts-expect-error replace does not combine with an anchor
  13. claims.push({ replace: "prompt.footer.status", after: "prompt.footer.file", render: () => null })
  14. // @ts-expect-error targets must be absolute published paths
  15. claims.push({ after: "status", render: () => null })
  16. // @ts-expect-error the render input is the targeted slot's input
  17. claims.push({ append: "prompt.footer", render: (input: { mode: number }) => null })
  18. return claims
  19. }
  20. // The resolver is generic over render values; strings make layout assertions
  21. // read as layouts. Placements are written in the public claim shape and
  22. // normalized here, like the plugin API does.
  23. function claim(plugin: string, placement: Partial<Record<PlacementKind, string>>, render: string): Claim<string> {
  24. const kind = (["prepend", "append", "before", "after", "replace"] as const).find((item) => placement[item])!
  25. return { key: `${plugin}/${render}`, plugin, placement: { kind, target: placement[kind]! }, render }
  26. }
  27. // A host slot tree for tests: a node's children are its child slots, a leaf's
  28. // content is its own name. Mirrors how nested <Slot> components mount.
  29. type Node = { readonly path: string; readonly children?: ReadonlyArray<Node> }
  30. function paths(nodes: ReadonlyArray<Node>, into = new Set<string>()): Set<string> {
  31. for (const node of nodes) {
  32. into.add(node.path)
  33. paths(node.children ?? [], into)
  34. }
  35. return into
  36. }
  37. // Fold the tree with a resolution into the flat render order, mirroring the
  38. // <Slot> component: before + (replace | prepend + own content + append) + after.
  39. function layout(nodes: ReadonlyArray<Node>, resolved: ReturnType<typeof resolveSlots<string>>): ReadonlyArray<string> {
  40. return nodes.flatMap((node) => {
  41. const slotted = resolved.slotted.get(node.path)
  42. const own = node.children ? layout(node.children, resolved) : [leafName(node.path)]
  43. const inside = slotted?.replace
  44. ? [slotted.replace.render]
  45. : [
  46. ...(slotted?.prepend ?? []).map((item) => item.render),
  47. ...own,
  48. ...(slotted?.append ?? []).map((item) => item.render),
  49. ]
  50. return [
  51. ...(slotted?.before ?? []).map((item) => item.render),
  52. ...inside,
  53. ...(slotted?.after ?? []).map((item) => item.render),
  54. ]
  55. })
  56. }
  57. function leafName(path: string) {
  58. return path.slice(path.lastIndexOf(".") + 1)
  59. }
  60. function resolve(tree: ReadonlyArray<Node>, claims: ReadonlyArray<Claim<string>>) {
  61. return resolveSlots({ paths: paths(tree), claims })
  62. }
  63. const footer: Node[] = [
  64. {
  65. path: "prompt.footer",
  66. children: [{ path: "prompt.footer.status" }, { path: "prompt.footer.file" }],
  67. },
  68. ]
  69. const tree: Node[] = [
  70. {
  71. path: "prompt.footer",
  72. children: [
  73. { path: "prompt.footer.left", children: [{ path: "prompt.footer.left.mode" }] },
  74. {
  75. path: "prompt.footer.right",
  76. children: [
  77. { path: "prompt.footer.right.directory" },
  78. { path: "prompt.footer.right.model" },
  79. { path: "prompt.footer.right.tokens" },
  80. ],
  81. },
  82. ],
  83. },
  84. ]
  85. test("no claims renders the host tree in order", () => {
  86. const result = resolve(footer, [])
  87. expect(layout(footer, result)).toEqual(["status", "file"])
  88. expect(result.suppressed).toEqual([])
  89. expect(result.degraded).toEqual([])
  90. })
  91. test("prepend and append land inside a boundary's edges, several in enable order", () => {
  92. const result = resolve(footer, [
  93. claim("a", { append: "prompt.footer" }, "a1"),
  94. claim("b", { prepend: "prompt.footer" }, "b1"),
  95. claim("a", { append: "prompt.footer" }, "a2"),
  96. ])
  97. expect(layout(footer, result)).toEqual(["b1", "status", "file", "a1", "a2"])
  98. })
  99. test("before and after anchor to a slot, wherever the host keeps it", () => {
  100. const result = resolve(footer, [
  101. claim("a", { after: "prompt.footer.status" }, "chip"),
  102. claim("b", { before: "prompt.footer.status" }, "vim"),
  103. ])
  104. expect(layout(footer, result)).toEqual(["vim", "status", "chip", "file"])
  105. })
  106. test("a missing anchor degrades to the nearest surviving ancestor's end", () => {
  107. const result = resolve(footer, [claim("a", { after: "prompt.footer.tokens" }, "chip")])
  108. expect(layout(footer, result)).toEqual(["status", "file", "chip"])
  109. expect(result.degraded).toEqual([
  110. { claim: claim("a", { after: "prompt.footer.tokens" }, "chip"), to: "prompt.footer" },
  111. ])
  112. })
  113. test("an additive claim with no surviving ancestor is suppressed", () => {
  114. const result = resolve(footer, [claim("a", { append: "session.composer.top" }, "chip")])
  115. expect(layout(footer, result)).toEqual(["status", "file"])
  116. expect(result.suppressed).toEqual([{ claim: claim("a", { append: "session.composer.top" }, "chip") }])
  117. })
  118. test("a missing replacement is suppressed, never degraded into a widget", () => {
  119. const result = resolve(footer, [claim("a", { replace: "prompt.footer.tokens" }, "cost")])
  120. expect(layout(footer, result)).toEqual(["status", "file"])
  121. expect(result.suppressed).toEqual([{ claim: claim("a", { replace: "prompt.footer.tokens" }, "cost") }])
  122. expect(result.degraded).toEqual([])
  123. })
  124. test("replacing a slot swaps content but keeps the boundary and its outside anchors", () => {
  125. const fancy = claim("a", { replace: "prompt.footer.status" }, "fancy-status")
  126. const result = resolve(footer, [fancy, claim("b", { after: "prompt.footer.status" }, "chip")])
  127. expect(layout(footer, result)).toEqual(["fancy-status", "chip", "file"])
  128. expect(result.suppressed).toEqual([])
  129. })
  130. test("inside contributions to a replaced boundary are suppressed", () => {
  131. const takeover = claim("a", { replace: "prompt.footer" }, "powerline")
  132. const badge = claim("b", { append: "prompt.footer" }, "badge")
  133. const result = resolve(footer, [badge, takeover])
  134. expect(layout(footer, result)).toEqual(["powerline"])
  135. expect(result.suppressed).toEqual([{ claim: badge, by: takeover }])
  136. })
  137. test("same target: the last-enabled replacement wins and the loser is recorded", () => {
  138. const first = claim("a", { replace: "prompt.footer.status" }, "first")
  139. const second = claim("b", { replace: "prompt.footer.status" }, "second")
  140. const result = resolve(footer, [first, second])
  141. expect(layout(footer, result)).toEqual(["second", "file"])
  142. expect(result.suppressed).toEqual([{ claim: first, by: second }])
  143. })
  144. test("container takeover suppresses everything anchored in the subtree", () => {
  145. const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
  146. const chip = claim("pr", { after: "prompt.footer.right.model" }, "chip")
  147. const inner = claim("x", { replace: "prompt.footer.right.tokens" }, "cost")
  148. const result = resolve(tree, [takeover, chip, inner])
  149. expect(layout(tree, result)).toEqual(["mode", "my-right"])
  150. expect(result.suppressed).toEqual([
  151. { claim: inner, by: takeover },
  152. { claim: chip, by: takeover },
  153. ])
  154. })
  155. test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => {
  156. // The descendant replace was enabled after the container takeover; the
  157. // container still wins because its path contains the descendant's.
  158. const inner = claim("x", { replace: "prompt.footer.right.model" }, "swap-model")
  159. const outer = claim("theme", { replace: "prompt.footer.right" }, "my-right")
  160. const result = resolve(tree, [outer, inner])
  161. expect(layout(tree, result)).toEqual(["mode", "my-right"])
  162. expect(result.suppressed).toEqual([{ claim: inner, by: outer }])
  163. })
  164. test("root takeover: nothing original survives, all inside claims suppressed", () => {
  165. const theme = claim("powerline", { replace: "prompt.footer" }, "powerline")
  166. const chip = claim("pr", { append: "prompt.footer" }, "chip")
  167. const result = resolve(tree, [chip, theme])
  168. expect(layout(tree, result)).toEqual(["powerline"])
  169. expect(result.suppressed).toEqual([{ claim: chip, by: theme }])
  170. })
  171. test("a degraded claim landing inside a replaced boundary is suppressed, not shown", () => {
  172. const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
  173. const stray = claim("pr", { after: "prompt.footer.right.gone" }, "chip")
  174. const result = resolve(tree, [takeover, stray])
  175. expect(layout(tree, result)).toEqual(["mode", "my-right"])
  176. expect(result.suppressed).toEqual([{ claim: stray, by: takeover }])
  177. expect(result.degraded).toEqual([])
  178. })
  179. test("anchors on a container wrap its whole span", () => {
  180. const result = resolve(tree, [
  181. claim("a", { before: "prompt.footer.right" }, "divider"),
  182. claim("b", { after: "prompt.footer.right" }, "clock"),
  183. ])
  184. expect(layout(tree, result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"])
  185. })