fixtures.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { test as base, expect, type Page } from "@playwright/test"
  2. import { ManagedRuntime } from "effect"
  3. import type { E2EWindow } from "../src/testing/terminal"
  4. import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
  5. import { TestLLMServer } from "../../opencode/test/lib/llm-server"
  6. import {
  7. healthPhase,
  8. cleanupSession,
  9. cleanupTestProject,
  10. createTestProject,
  11. setHealthPhase,
  12. seedProjects,
  13. sessionIDFromUrl,
  14. waitSlug,
  15. waitSession,
  16. } from "./actions"
  17. import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
  18. type LLMFixture = {
  19. url: string
  20. push: (...input: (Item | Reply)[]) => Promise<void>
  21. text: (value: string, opts?: { usage?: Usage }) => Promise<void>
  22. tool: (name: string, input: unknown) => Promise<void>
  23. toolHang: (name: string, input: unknown) => Promise<void>
  24. reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
  25. fail: (message?: unknown) => Promise<void>
  26. error: (status: number, body: unknown) => Promise<void>
  27. hang: () => Promise<void>
  28. hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
  29. hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
  30. calls: () => Promise<number>
  31. wait: (count: number) => Promise<void>
  32. inputs: () => Promise<Record<string, unknown>[]>
  33. pending: () => Promise<number>
  34. }
  35. export const settingsKey = "settings.v3"
  36. const seedModel = (() => {
  37. const [providerID = "opencode", modelID = "big-pickle"] = (
  38. process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
  39. ).split("/")
  40. return {
  41. providerID: providerID || "opencode",
  42. modelID: modelID || "big-pickle",
  43. }
  44. })()
  45. type TestFixtures = {
  46. llm: LLMFixture
  47. sdk: ReturnType<typeof createSdk>
  48. gotoSession: (sessionID?: string) => Promise<void>
  49. withProject: <T>(
  50. callback: (project: {
  51. directory: string
  52. slug: string
  53. gotoSession: (sessionID?: string) => Promise<void>
  54. trackSession: (sessionID: string, directory?: string) => void
  55. trackDirectory: (directory: string) => void
  56. }) => Promise<T>,
  57. options?: {
  58. extra?: string[]
  59. model?: { providerID: string; modelID: string }
  60. setup?: (directory: string) => Promise<void>
  61. },
  62. ) => Promise<T>
  63. }
  64. type WorkerFixtures = {
  65. directory: string
  66. slug: string
  67. }
  68. export const test = base.extend<TestFixtures, WorkerFixtures>({
  69. llm: async ({}, use) => {
  70. const rt = ManagedRuntime.make(TestLLMServer.layer)
  71. try {
  72. const svc = await rt.runPromise(TestLLMServer.asEffect())
  73. await use({
  74. url: svc.url,
  75. push: (...input) => rt.runPromise(svc.push(...input)),
  76. text: (value, opts) => rt.runPromise(svc.text(value, opts)),
  77. tool: (name, input) => rt.runPromise(svc.tool(name, input)),
  78. toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
  79. reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
  80. fail: (message) => rt.runPromise(svc.fail(message)),
  81. error: (status, body) => rt.runPromise(svc.error(status, body)),
  82. hang: () => rt.runPromise(svc.hang),
  83. hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
  84. hits: () => rt.runPromise(svc.hits),
  85. calls: () => rt.runPromise(svc.calls),
  86. wait: (count) => rt.runPromise(svc.wait(count)),
  87. inputs: () => rt.runPromise(svc.inputs),
  88. pending: () => rt.runPromise(svc.pending),
  89. })
  90. } finally {
  91. await rt.dispose()
  92. }
  93. },
  94. page: async ({ page }, use) => {
  95. let boundary: string | undefined
  96. setHealthPhase(page, "test")
  97. const consoleHandler = (msg: { text(): string }) => {
  98. const text = msg.text()
  99. if (!text.includes("[e2e:error-boundary]")) return
  100. if (healthPhase(page) === "cleanup") {
  101. console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
  102. return
  103. }
  104. boundary ||= text
  105. console.log(text)
  106. }
  107. const pageErrorHandler = (err: Error) => {
  108. console.log(`[e2e:pageerror] ${err.stack || err.message}`)
  109. }
  110. page.on("console", consoleHandler)
  111. page.on("pageerror", pageErrorHandler)
  112. await use(page)
  113. page.off("console", consoleHandler)
  114. page.off("pageerror", pageErrorHandler)
  115. if (boundary) throw new Error(boundary)
  116. },
  117. directory: [
  118. async ({}, use) => {
  119. const directory = await getWorktree()
  120. await use(directory)
  121. },
  122. { scope: "worker" },
  123. ],
  124. slug: [
  125. async ({ directory }, use) => {
  126. await use(dirSlug(directory))
  127. },
  128. { scope: "worker" },
  129. ],
  130. sdk: async ({ directory }, use) => {
  131. await use(createSdk(directory))
  132. },
  133. gotoSession: async ({ page, directory }, use) => {
  134. await seedStorage(page, { directory })
  135. const gotoSession = async (sessionID?: string) => {
  136. await page.goto(sessionPath(directory, sessionID))
  137. await waitSession(page, { directory, sessionID })
  138. }
  139. await use(gotoSession)
  140. },
  141. withProject: async ({ page }, use) => {
  142. await use(async (callback, options) => {
  143. const root = await createTestProject()
  144. const sessions = new Map<string, string>()
  145. const dirs = new Set<string>()
  146. await options?.setup?.(root)
  147. await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
  148. const gotoSession = async (sessionID?: string) => {
  149. await page.goto(sessionPath(root, sessionID))
  150. await waitSession(page, { directory: root, sessionID })
  151. const current = sessionIDFromUrl(page.url())
  152. if (current) trackSession(current)
  153. }
  154. const trackSession = (sessionID: string, directory?: string) => {
  155. sessions.set(sessionID, directory ?? root)
  156. }
  157. const trackDirectory = (directory: string) => {
  158. if (directory !== root) dirs.add(directory)
  159. }
  160. try {
  161. await gotoSession()
  162. const slug = await waitSlug(page)
  163. return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
  164. } finally {
  165. setHealthPhase(page, "cleanup")
  166. await Promise.allSettled(
  167. Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
  168. )
  169. await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
  170. await cleanupTestProject(root)
  171. setHealthPhase(page, "test")
  172. }
  173. })
  174. },
  175. })
  176. async function seedStorage(
  177. page: Page,
  178. input: {
  179. directory: string
  180. extra?: string[]
  181. model?: { providerID: string; modelID: string }
  182. },
  183. ) {
  184. await seedProjects(page, input)
  185. await page.addInitScript((model: { providerID: string; modelID: string }) => {
  186. const win = window as E2EWindow
  187. win.__opencode_e2e = {
  188. ...win.__opencode_e2e,
  189. model: {
  190. enabled: true,
  191. },
  192. prompt: {
  193. enabled: true,
  194. },
  195. terminal: {
  196. enabled: true,
  197. terminals: {},
  198. },
  199. }
  200. localStorage.setItem(
  201. "opencode.global.dat:model",
  202. JSON.stringify({
  203. recent: [model],
  204. user: [],
  205. variant: {},
  206. }),
  207. )
  208. }, input.model ?? seedModel)
  209. }
  210. export { expect }