actions.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { expect, test } from "bun:test"
  2. import { BoxRenderable, TextRenderable } from "@opentui/core"
  3. import { Effect } from "effect"
  4. import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
  5. import { SimulationRenderer } from "../src/frontend/renderer"
  6. import { SimulationSemantics } from "../src/frontend/semantics"
  7. test("matches literal screen text", () => {
  8. const harness = { screen: () => "OpenCode [ready].*" }
  9. expect(matches(harness, "OpenCode")).toBe(true)
  10. expect(matches(harness, "[ready].*")).toBe(true)
  11. expect(matches(harness, "OpenCode.*ready")).toBe(false)
  12. expect(matches(harness, "opencode")).toBe(false)
  13. })
  14. test("omits an absent focused renderable from state", () => {
  15. const harness = {
  16. renderer: {
  17. root: { getChildren: () => [] },
  18. currentFocusedRenderable: undefined,
  19. currentFocusedEditor: undefined,
  20. },
  21. } as unknown as Harness
  22. expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
  23. })
  24. test("normalizes named keys for OpenTUI", async () => {
  25. const pressed: Array<readonly [string, object | undefined]> = []
  26. const harness = {
  27. renderer: {
  28. root: { getChildren: () => [] },
  29. currentFocusedRenderable: undefined,
  30. currentFocusedEditor: undefined,
  31. },
  32. mockInput: {
  33. pressKey: (key: string, modifiers?: object) => pressed.push([key, modifiers]),
  34. },
  35. renderOnce: async () => {},
  36. } as unknown as Harness
  37. await Effect.runPromise(
  38. execute(harness, {
  39. type: "ui.press",
  40. key: "escape",
  41. modifiers: { ctrl: true },
  42. }),
  43. )
  44. await Effect.runPromise(execute(harness, { type: "ui.press", key: "x" }))
  45. expect(pressed).toEqual([
  46. ["ESCAPE", { ctrl: true }],
  47. ["x", undefined],
  48. ])
  49. })
  50. test("headless input mirrors the configured kitty keyboard protocol", async () => {
  51. await Effect.runPromise(
  52. Effect.scoped(
  53. Effect.gen(function* () {
  54. const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
  55. const harness = createHarness(renderer)
  56. let key: { readonly name: string; readonly source: string } | undefined
  57. renderer.keyInput.once("keypress", (event) => {
  58. key = event
  59. })
  60. yield* execute(harness, { type: "ui.press", key: "escape" })
  61. expect(key).toMatchObject({ name: "escape", source: "kitty" })
  62. }),
  63. ),
  64. )
  65. })
  66. test("clicks a target at relative coordinates through descendant text", async () => {
  67. await Effect.runPromise(
  68. Effect.scoped(
  69. Effect.gen(function* () {
  70. const renderer = yield* SimulationRenderer.create({})
  71. let clicks = 0
  72. const button = new BoxRenderable(renderer, {
  73. id: "permission.action.once",
  74. width: 12,
  75. height: 1,
  76. onMouseUp: () => clicks++,
  77. })
  78. button.add(new TextRenderable(renderer, { content: "Allow once" }))
  79. renderer.root.add(button)
  80. const harness = createHarness(renderer)
  81. yield* Effect.promise(() => harness.renderOnce())
  82. expect(state(harness).elements).toContainEqual(expect.objectContaining({ id: button.id, clickable: true }))
  83. yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 })
  84. expect(clicks).toBe(1)
  85. renderer.root.remove(button)
  86. const error = yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 }).pipe(Effect.flip)
  87. expect(error.message).toContain("click target is stale or unavailable")
  88. }),
  89. ),
  90. )
  91. })
  92. test("rejects a semantic click when the live identity does not match", async () => {
  93. await Effect.runPromise(
  94. Effect.scoped(
  95. Effect.gen(function* () {
  96. const renderer = yield* SimulationRenderer.create({})
  97. let clicks = 0
  98. const button = new BoxRenderable(renderer, {
  99. id: "session.permission.action.once",
  100. width: 12,
  101. height: 1,
  102. onMouseUp: () => clicks++,
  103. })
  104. SimulationSemantics.bind(() => ({
  105. instance: "permission-2",
  106. role: "option",
  107. label: "Allow once",
  108. }))(button)
  109. renderer.root.add(button)
  110. const harness = createHarness(renderer)
  111. yield* Effect.promise(() => harness.renderOnce())
  112. const error = yield* execute(harness, {
  113. type: "ui.click",
  114. target: button.num,
  115. x: 1,
  116. y: 0,
  117. semantic: {
  118. id: "session.permission.action.once",
  119. instance: "permission-1",
  120. element: button.num,
  121. },
  122. }).pipe(Effect.flip)
  123. expect(error.message).toContain("semantic click target is stale or unavailable")
  124. expect(clicks).toBe(0)
  125. yield* execute(harness, {
  126. type: "ui.click",
  127. target: button.num,
  128. x: 1,
  129. y: 0,
  130. semantic: {
  131. id: "session.permission.action.once",
  132. instance: "permission-2",
  133. element: button.num,
  134. },
  135. })
  136. expect(clicks).toBe(1)
  137. }),
  138. ),
  139. )
  140. })
  141. test("snapshots lazy semantic hierarchy and interaction state", async () => {
  142. await Effect.runPromise(
  143. Effect.scoped(
  144. Effect.gen(function* () {
  145. const renderer = yield* SimulationRenderer.create({})
  146. let selected = "once"
  147. const dialog = new BoxRenderable(renderer, { id: "session.permission" })
  148. const actions = new BoxRenderable(renderer, { id: "session.permission.actions" })
  149. const once = new BoxRenderable(renderer, { id: "session.permission.action.once" })
  150. SimulationSemantics.bind(() => ({
  151. instance: "permission-1",
  152. role: "dialog",
  153. label: "Permission required",
  154. expanded: false,
  155. }))(dialog)
  156. SimulationSemantics.bind(() => ({
  157. instance: "permission-1",
  158. role: "listbox",
  159. label: "Permission choices",
  160. }))(actions)
  161. SimulationSemantics.bind(() => ({
  162. instance: "permission-1",
  163. role: "option",
  164. label: "Allow once",
  165. focused: selected === "once",
  166. selected: selected === "once",
  167. disabled: false,
  168. }))(once)
  169. renderer.root.add(dialog)
  170. dialog.add(actions)
  171. actions.add(once)
  172. expect(snapshot(createHarness(renderer))).toEqual({
  173. format: "opencode-ui-snapshot-v1",
  174. nodes: [
  175. {
  176. id: "session.permission",
  177. instance: "permission-1",
  178. role: "dialog",
  179. label: "Permission required",
  180. element: dialog.num,
  181. expanded: false,
  182. },
  183. {
  184. id: "session.permission.actions",
  185. instance: "permission-1",
  186. parent: "session.permission",
  187. role: "listbox",
  188. label: "Permission choices",
  189. element: actions.num,
  190. },
  191. {
  192. id: "session.permission.action.once",
  193. instance: "permission-1",
  194. parent: "session.permission.actions",
  195. role: "option",
  196. label: "Allow once",
  197. element: once.num,
  198. focused: true,
  199. selected: true,
  200. disabled: false,
  201. },
  202. ],
  203. })
  204. selected = "reject"
  205. expect(snapshot(createHarness(renderer)).nodes.at(-1)).toMatchObject({
  206. id: "session.permission.action.once",
  207. focused: false,
  208. selected: false,
  209. })
  210. dialog.visible = false
  211. expect(snapshot(createHarness(renderer)).nodes).toEqual([])
  212. }),
  213. ),
  214. )
  215. })
  216. test("rejects duplicate semantic identities", async () => {
  217. await Effect.runPromise(
  218. Effect.scoped(
  219. Effect.gen(function* () {
  220. const renderer = yield* SimulationRenderer.create({})
  221. const first = new BoxRenderable(renderer, { id: "duplicate" })
  222. const second = new BoxRenderable(renderer, { id: "duplicate" })
  223. const definition = () => ({ role: "option" })
  224. SimulationSemantics.bind(definition)(first)
  225. SimulationSemantics.bind(definition)(second)
  226. renderer.root.add(first)
  227. renderer.root.add(second)
  228. expect(() => snapshot(createHarness(renderer))).toThrow("duplicate semantic UI id: duplicate")
  229. }),
  230. ),
  231. )
  232. })
  233. test("validates lazy semantic definitions before returning them", async () => {
  234. await Effect.runPromise(
  235. Effect.scoped(
  236. Effect.gen(function* () {
  237. const renderer = yield* SimulationRenderer.create({})
  238. const invalid = new BoxRenderable(renderer, { id: "invalid" })
  239. SimulationSemantics.bind(() => ({ role: "" }))(invalid)
  240. renderer.root.add(invalid)
  241. expect(() => snapshot(createHarness(renderer))).toThrow()
  242. }),
  243. ),
  244. )
  245. })