1
0

actions.test.ts 8.3 KB

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