actions.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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("clicks a target at relative coordinates through descendant text", async () => {
  41. await Effect.runPromise(
  42. Effect.scoped(
  43. Effect.gen(function* () {
  44. const renderer = yield* SimulationRenderer.create({})
  45. let clicks = 0
  46. const button = new BoxRenderable(renderer, {
  47. id: "permission.action.once",
  48. width: 12,
  49. height: 1,
  50. onMouseUp: () => clicks++,
  51. })
  52. button.add(new TextRenderable(renderer, { content: "Allow once" }))
  53. renderer.root.add(button)
  54. const harness = createHarness(renderer)
  55. yield* Effect.promise(() => harness.renderOnce())
  56. expect(state(harness).elements).toContainEqual(expect.objectContaining({ id: button.id, clickable: true }))
  57. yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 })
  58. expect(clicks).toBe(1)
  59. renderer.root.remove(button)
  60. const error = yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 }).pipe(Effect.flip)
  61. expect(error.message).toContain("click target is stale or unavailable")
  62. }),
  63. ),
  64. )
  65. })
  66. test("rejects a semantic click when the live identity does not match", 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: "session.permission.action.once",
  74. width: 12,
  75. height: 1,
  76. onMouseUp: () => clicks++,
  77. })
  78. SimulationSemantics.bind(() => ({
  79. instance: "permission-2",
  80. role: "option",
  81. label: "Allow once",
  82. }))(button)
  83. renderer.root.add(button)
  84. const harness = createHarness(renderer)
  85. yield* Effect.promise(() => harness.renderOnce())
  86. const error = yield* execute(harness, {
  87. type: "ui.click",
  88. target: button.num,
  89. x: 1,
  90. y: 0,
  91. semantic: {
  92. id: "session.permission.action.once",
  93. instance: "permission-1",
  94. element: button.num,
  95. },
  96. }).pipe(Effect.flip)
  97. expect(error.message).toContain("semantic click target is stale or unavailable")
  98. expect(clicks).toBe(0)
  99. yield* execute(harness, {
  100. type: "ui.click",
  101. target: button.num,
  102. x: 1,
  103. y: 0,
  104. semantic: {
  105. id: "session.permission.action.once",
  106. instance: "permission-2",
  107. element: button.num,
  108. },
  109. })
  110. expect(clicks).toBe(1)
  111. }),
  112. ),
  113. )
  114. })
  115. test("snapshots lazy semantic hierarchy and interaction state", async () => {
  116. await Effect.runPromise(
  117. Effect.scoped(
  118. Effect.gen(function* () {
  119. const renderer = yield* SimulationRenderer.create({})
  120. let selected = "once"
  121. const dialog = new BoxRenderable(renderer, { id: "session.permission" })
  122. const actions = new BoxRenderable(renderer, { id: "session.permission.actions" })
  123. const once = new BoxRenderable(renderer, { id: "session.permission.action.once" })
  124. SimulationSemantics.bind(() => ({
  125. instance: "permission-1",
  126. role: "dialog",
  127. label: "Permission required",
  128. expanded: false,
  129. }))(dialog)
  130. SimulationSemantics.bind(() => ({
  131. instance: "permission-1",
  132. role: "listbox",
  133. label: "Permission choices",
  134. }))(actions)
  135. SimulationSemantics.bind(() => ({
  136. instance: "permission-1",
  137. role: "option",
  138. label: "Allow once",
  139. focused: selected === "once",
  140. selected: selected === "once",
  141. disabled: false,
  142. }))(once)
  143. renderer.root.add(dialog)
  144. dialog.add(actions)
  145. actions.add(once)
  146. expect(snapshot(createHarness(renderer))).toEqual({
  147. format: "opencode-ui-snapshot-v1",
  148. nodes: [
  149. {
  150. id: "session.permission",
  151. instance: "permission-1",
  152. role: "dialog",
  153. label: "Permission required",
  154. element: dialog.num,
  155. expanded: false,
  156. },
  157. {
  158. id: "session.permission.actions",
  159. instance: "permission-1",
  160. parent: "session.permission",
  161. role: "listbox",
  162. label: "Permission choices",
  163. element: actions.num,
  164. },
  165. {
  166. id: "session.permission.action.once",
  167. instance: "permission-1",
  168. parent: "session.permission.actions",
  169. role: "option",
  170. label: "Allow once",
  171. element: once.num,
  172. focused: true,
  173. selected: true,
  174. disabled: false,
  175. },
  176. ],
  177. })
  178. selected = "reject"
  179. expect(snapshot(createHarness(renderer)).nodes.at(-1)).toMatchObject({
  180. id: "session.permission.action.once",
  181. focused: false,
  182. selected: false,
  183. })
  184. dialog.visible = false
  185. expect(snapshot(createHarness(renderer)).nodes).toEqual([])
  186. }),
  187. ),
  188. )
  189. })
  190. test("rejects duplicate semantic identities", async () => {
  191. await Effect.runPromise(
  192. Effect.scoped(
  193. Effect.gen(function* () {
  194. const renderer = yield* SimulationRenderer.create({})
  195. const first = new BoxRenderable(renderer, { id: "duplicate" })
  196. const second = new BoxRenderable(renderer, { id: "duplicate" })
  197. const definition = () => ({ role: "option" })
  198. SimulationSemantics.bind(definition)(first)
  199. SimulationSemantics.bind(definition)(second)
  200. renderer.root.add(first)
  201. renderer.root.add(second)
  202. expect(() => snapshot(createHarness(renderer))).toThrow("duplicate semantic UI id: duplicate")
  203. }),
  204. ),
  205. )
  206. })
  207. test("validates lazy semantic definitions before returning them", async () => {
  208. await Effect.runPromise(
  209. Effect.scoped(
  210. Effect.gen(function* () {
  211. const renderer = yield* SimulationRenderer.create({})
  212. const invalid = new BoxRenderable(renderer, { id: "invalid" })
  213. SimulationSemantics.bind(() => ({ role: "" }))(invalid)
  214. renderer.root.add(invalid)
  215. expect(() => snapshot(createHarness(renderer))).toThrow()
  216. }),
  217. ),
  218. )
  219. })