fixtures.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  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 { startBackend } from "./backend"
  7. import {
  8. healthPhase,
  9. cleanupSession,
  10. cleanupTestProject,
  11. createTestProject,
  12. setHealthPhase,
  13. sessionIDFromUrl,
  14. waitSession,
  15. waitSessionIdle,
  16. waitSessionSaved,
  17. waitSlug,
  18. } from "./actions"
  19. import { promptSelector } from "./selectors"
  20. import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
  21. type LLMFixture = {
  22. url: string
  23. push: (...input: (Item | Reply)[]) => Promise<void>
  24. pushMatch: (
  25. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  26. ...input: (Item | Reply)[]
  27. ) => Promise<void>
  28. textMatch: (
  29. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  30. value: string,
  31. opts?: { usage?: Usage },
  32. ) => Promise<void>
  33. toolMatch: (
  34. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  35. name: string,
  36. input: unknown,
  37. ) => Promise<void>
  38. text: (value: string, opts?: { usage?: Usage }) => Promise<void>
  39. tool: (name: string, input: unknown) => Promise<void>
  40. toolHang: (name: string, input: unknown) => Promise<void>
  41. reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
  42. fail: (message?: unknown) => Promise<void>
  43. error: (status: number, body: unknown) => Promise<void>
  44. hang: () => Promise<void>
  45. hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
  46. hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
  47. calls: () => Promise<number>
  48. wait: (count: number) => Promise<void>
  49. inputs: () => Promise<Record<string, unknown>[]>
  50. pending: () => Promise<number>
  51. misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
  52. }
  53. type LLMWorker = LLMFixture & {
  54. reset: () => Promise<void>
  55. }
  56. type AssistantFixture = {
  57. reply: LLMFixture["text"]
  58. tool: LLMFixture["tool"]
  59. toolHang: LLMFixture["toolHang"]
  60. reason: LLMFixture["reason"]
  61. fail: LLMFixture["fail"]
  62. error: LLMFixture["error"]
  63. hang: LLMFixture["hang"]
  64. hold: LLMFixture["hold"]
  65. calls: LLMFixture["calls"]
  66. pending: LLMFixture["pending"]
  67. }
  68. export const settingsKey = "settings.v3"
  69. const seedModel = (() => {
  70. const [providerID = "opencode", modelID = "big-pickle"] = (
  71. process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
  72. ).split("/")
  73. return {
  74. providerID: providerID || "opencode",
  75. modelID: modelID || "big-pickle",
  76. }
  77. })()
  78. function clean(value: string | null) {
  79. return (value ?? "").replace(/\u200B/g, "").trim()
  80. }
  81. async function visit(page: Page, url: string) {
  82. let err: unknown
  83. for (const _ of [0, 1, 2]) {
  84. try {
  85. await page.goto(url)
  86. return
  87. } catch (cause) {
  88. err = cause
  89. if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
  90. await new Promise((resolve) => setTimeout(resolve, 300))
  91. }
  92. }
  93. throw err
  94. }
  95. async function promptSend(page: Page) {
  96. return page
  97. .evaluate(() => {
  98. const win = window as E2EWindow
  99. const sent = win.__opencode_e2e?.prompt?.sent
  100. return {
  101. started: sent?.started ?? 0,
  102. count: sent?.count ?? 0,
  103. sessionID: sent?.sessionID,
  104. directory: sent?.directory,
  105. }
  106. })
  107. .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
  108. }
  109. type ProjectHandle = {
  110. directory: string
  111. slug: string
  112. gotoSession: (sessionID?: string) => Promise<void>
  113. trackSession: (sessionID: string, directory?: string) => void
  114. trackDirectory: (directory: string) => void
  115. sdk: ReturnType<typeof createSdk>
  116. }
  117. type ProjectOptions = {
  118. extra?: string[]
  119. model?: { providerID: string; modelID: string }
  120. setup?: (directory: string) => Promise<void>
  121. beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
  122. }
  123. type ProjectFixture = ProjectHandle & {
  124. open: (options?: ProjectOptions) => Promise<void>
  125. prompt: (text: string) => Promise<string>
  126. user: (text: string) => Promise<string>
  127. shell: (cmd: string) => Promise<string>
  128. }
  129. type TestFixtures = {
  130. llm: LLMFixture
  131. assistant: AssistantFixture
  132. project: ProjectFixture
  133. sdk: ReturnType<typeof createSdk>
  134. gotoSession: (sessionID?: string) => Promise<void>
  135. }
  136. type WorkerFixtures = {
  137. _llm: LLMWorker
  138. backend: {
  139. url: string
  140. sdk: (directory?: string) => ReturnType<typeof createSdk>
  141. }
  142. directory: string
  143. slug: string
  144. }
  145. export const test = base.extend<TestFixtures, WorkerFixtures>({
  146. _llm: [
  147. async ({}, use) => {
  148. const rt = ManagedRuntime.make(TestLLMServer.layer)
  149. try {
  150. const svc = await rt.runPromise(TestLLMServer.asEffect())
  151. await use({
  152. url: svc.url,
  153. push: (...input) => rt.runPromise(svc.push(...input)),
  154. pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
  155. textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
  156. toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
  157. text: (value, opts) => rt.runPromise(svc.text(value, opts)),
  158. tool: (name, input) => rt.runPromise(svc.tool(name, input)),
  159. toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
  160. reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
  161. fail: (message) => rt.runPromise(svc.fail(message)),
  162. error: (status, body) => rt.runPromise(svc.error(status, body)),
  163. hang: () => rt.runPromise(svc.hang),
  164. hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
  165. reset: () => rt.runPromise(svc.reset),
  166. hits: () => rt.runPromise(svc.hits),
  167. calls: () => rt.runPromise(svc.calls),
  168. wait: (count) => rt.runPromise(svc.wait(count)),
  169. inputs: () => rt.runPromise(svc.inputs),
  170. pending: () => rt.runPromise(svc.pending),
  171. misses: () => rt.runPromise(svc.misses),
  172. })
  173. } finally {
  174. await rt.dispose()
  175. }
  176. },
  177. { scope: "worker" },
  178. ],
  179. backend: [
  180. async ({ _llm }, use, workerInfo) => {
  181. const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
  182. try {
  183. await use({
  184. url: handle.url,
  185. sdk: (directory?: string) => createSdk(directory, handle.url),
  186. })
  187. } finally {
  188. await handle.stop()
  189. }
  190. },
  191. { scope: "worker" },
  192. ],
  193. llm: async ({ _llm }, use) => {
  194. await _llm.reset()
  195. await use({
  196. url: _llm.url,
  197. push: _llm.push,
  198. pushMatch: _llm.pushMatch,
  199. textMatch: _llm.textMatch,
  200. toolMatch: _llm.toolMatch,
  201. text: _llm.text,
  202. tool: _llm.tool,
  203. toolHang: _llm.toolHang,
  204. reason: _llm.reason,
  205. fail: _llm.fail,
  206. error: _llm.error,
  207. hang: _llm.hang,
  208. hold: _llm.hold,
  209. hits: _llm.hits,
  210. calls: _llm.calls,
  211. wait: _llm.wait,
  212. inputs: _llm.inputs,
  213. pending: _llm.pending,
  214. misses: _llm.misses,
  215. })
  216. const pending = await _llm.pending()
  217. if (pending > 0) {
  218. throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
  219. }
  220. },
  221. assistant: async ({ llm }, use) => {
  222. await use({
  223. reply: llm.text,
  224. tool: llm.tool,
  225. toolHang: llm.toolHang,
  226. reason: llm.reason,
  227. fail: llm.fail,
  228. error: llm.error,
  229. hang: llm.hang,
  230. hold: llm.hold,
  231. calls: llm.calls,
  232. pending: llm.pending,
  233. })
  234. },
  235. page: async ({ page }, use) => {
  236. let boundary: string | undefined
  237. setHealthPhase(page, "test")
  238. const consoleHandler = (msg: { text(): string }) => {
  239. const text = msg.text()
  240. if (!text.includes("[e2e:error-boundary]")) return
  241. if (healthPhase(page) === "cleanup") {
  242. console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
  243. return
  244. }
  245. boundary ||= text
  246. console.log(text)
  247. }
  248. const pageErrorHandler = (err: Error) => {
  249. console.log(`[e2e:pageerror] ${err.stack || err.message}`)
  250. }
  251. page.on("console", consoleHandler)
  252. page.on("pageerror", pageErrorHandler)
  253. await use(page)
  254. page.off("console", consoleHandler)
  255. page.off("pageerror", pageErrorHandler)
  256. if (boundary) throw new Error(boundary)
  257. },
  258. directory: [
  259. async ({ backend }, use) => {
  260. await use(await getWorktree(backend.url))
  261. },
  262. { scope: "worker" },
  263. ],
  264. slug: [
  265. async ({ directory }, use) => {
  266. await use(dirSlug(directory))
  267. },
  268. { scope: "worker" },
  269. ],
  270. sdk: async ({ directory, backend }, use) => {
  271. await use(backend.sdk(directory))
  272. },
  273. gotoSession: async ({ page, directory, backend }, use) => {
  274. await seedStorage(page, { directory, serverUrl: backend.url })
  275. const gotoSession = async (sessionID?: string) => {
  276. await visit(page, sessionPath(directory, sessionID))
  277. await waitSession(page, {
  278. directory,
  279. sessionID,
  280. serverUrl: backend.url,
  281. allowAnySession: !sessionID,
  282. })
  283. }
  284. await use(gotoSession)
  285. },
  286. project: async ({ page, llm, backend }, use) => {
  287. const item = makeProject(page, llm, backend)
  288. try {
  289. await use(item.project)
  290. } finally {
  291. await item.cleanup()
  292. }
  293. },
  294. })
  295. function makeProject(
  296. page: Page,
  297. llm: LLMFixture,
  298. backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
  299. ) {
  300. let state:
  301. | {
  302. directory: string
  303. slug: string
  304. sdk: ReturnType<typeof createSdk>
  305. sessions: Map<string, string>
  306. dirs: Set<string>
  307. }
  308. | undefined
  309. const need = () => {
  310. if (state) return state
  311. throw new Error("project.open() must be called first")
  312. }
  313. const trackSession = (sessionID: string, directory?: string) => {
  314. const cur = need()
  315. cur.sessions.set(sessionID, directory ?? cur.directory)
  316. }
  317. const trackDirectory = (directory: string) => {
  318. const cur = need()
  319. if (directory !== cur.directory) cur.dirs.add(directory)
  320. }
  321. const gotoSession = async (sessionID?: string) => {
  322. const cur = need()
  323. await visit(page, sessionPath(cur.directory, sessionID))
  324. await waitSession(page, {
  325. directory: cur.directory,
  326. sessionID,
  327. serverUrl: backend.url,
  328. allowAnySession: !sessionID,
  329. })
  330. const current = sessionIDFromUrl(page.url())
  331. if (current) trackSession(current)
  332. }
  333. const open = async (options?: ProjectOptions) => {
  334. if (state) return
  335. const directory = await createTestProject({ serverUrl: backend.url })
  336. const sdk = backend.sdk(directory)
  337. await options?.setup?.(directory)
  338. await seedStorage(page, {
  339. directory,
  340. extra: options?.extra,
  341. model: options?.model,
  342. serverUrl: backend.url,
  343. })
  344. state = {
  345. directory,
  346. slug: "",
  347. sdk,
  348. sessions: new Map(),
  349. dirs: new Set(),
  350. }
  351. await options?.beforeGoto?.({ directory, sdk })
  352. await gotoSession()
  353. need().slug = await waitSlug(page)
  354. }
  355. const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
  356. if (input.noReply) {
  357. const cur = need()
  358. const state = await page.evaluate(() => {
  359. const model = (window as E2EWindow).__opencode_e2e?.model?.current
  360. if (!model) return null
  361. return {
  362. dir: model.dir,
  363. sessionID: model.sessionID,
  364. agent: model.agent,
  365. model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
  366. variant: model.variant ?? undefined,
  367. }
  368. })
  369. const dir = state?.dir ?? cur.directory
  370. const sdk = backend.sdk(dir)
  371. const sessionID = state?.sessionID
  372. ? state.sessionID
  373. : await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
  374. if (!res.data?.id) throw new Error("Failed to create no-reply session")
  375. return res.data.id
  376. })
  377. await sdk.session.prompt({
  378. sessionID,
  379. agent: state?.agent,
  380. model: state?.model,
  381. variant: state?.variant,
  382. noReply: true,
  383. parts: [{ type: "text", text }],
  384. })
  385. await visit(page, sessionPath(dir, sessionID))
  386. const active = await waitSession(page, {
  387. directory: dir,
  388. sessionID,
  389. serverUrl: backend.url,
  390. })
  391. trackSession(sessionID, active.directory)
  392. await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
  393. return sessionID
  394. }
  395. const prev = await promptSend(page)
  396. if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
  397. await llm.text("ok")
  398. }
  399. const prompt = page.locator(promptSelector).first()
  400. const submit = async () => {
  401. await expect(prompt).toBeVisible()
  402. await prompt.click()
  403. if (input.shell) {
  404. await page.keyboard.type("!")
  405. await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
  406. }
  407. await page.keyboard.type(text)
  408. await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
  409. await page.keyboard.press("Enter")
  410. const started = await expect
  411. .poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
  412. .toBeGreaterThan(prev.started)
  413. .then(() => true)
  414. .catch(() => false)
  415. if (started) return
  416. const send = page.getByRole("button", { name: "Send" }).first()
  417. const enabled = await send
  418. .isEnabled()
  419. .then((x) => x)
  420. .catch(() => false)
  421. if (enabled) {
  422. await send.click()
  423. } else {
  424. await prompt.click()
  425. await page.keyboard.press("Enter")
  426. }
  427. await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
  428. }
  429. await submit()
  430. let next: { sessionID: string; directory: string } | undefined
  431. await expect
  432. .poll(
  433. async () => {
  434. const sent = await promptSend(page)
  435. if (sent.count <= prev.count) return ""
  436. if (!sent.sessionID || !sent.directory) return ""
  437. next = { sessionID: sent.sessionID, directory: sent.directory }
  438. return sent.sessionID
  439. },
  440. { timeout: 90_000 },
  441. )
  442. .not.toBe("")
  443. if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
  444. const active = await waitSession(page, {
  445. directory: next.directory,
  446. sessionID: next.sessionID,
  447. serverUrl: backend.url,
  448. })
  449. trackSession(next.sessionID, active.directory)
  450. if (!input.shell) {
  451. await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
  452. }
  453. await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
  454. return next.sessionID
  455. }
  456. const prompt = async (text: string) => {
  457. return send(text, { noReply: false, shell: false })
  458. }
  459. const user = async (text: string) => {
  460. return send(text, { noReply: true, shell: false })
  461. }
  462. const shell = async (cmd: string) => {
  463. return send(cmd, { noReply: false, shell: true })
  464. }
  465. const cleanup = async () => {
  466. const cur = state
  467. if (!cur) return
  468. setHealthPhase(page, "cleanup")
  469. await Promise.allSettled(
  470. Array.from(cur.sessions, ([sessionID, directory]) =>
  471. cleanupSession({ sessionID, directory, serverUrl: backend.url }),
  472. ),
  473. )
  474. await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
  475. await cleanupTestProject(cur.directory)
  476. state = undefined
  477. setHealthPhase(page, "test")
  478. }
  479. return {
  480. project: {
  481. open,
  482. prompt,
  483. user,
  484. shell,
  485. gotoSession,
  486. trackSession,
  487. trackDirectory,
  488. get directory() {
  489. return need().directory
  490. },
  491. get slug() {
  492. return need().slug
  493. },
  494. get sdk() {
  495. return need().sdk
  496. },
  497. },
  498. cleanup,
  499. }
  500. }
  501. async function seedStorage(
  502. page: Page,
  503. input: {
  504. directory: string
  505. extra?: string[]
  506. model?: { providerID: string; modelID: string }
  507. serverUrl?: string
  508. },
  509. ) {
  510. const origin = input.serverUrl ?? serverUrl
  511. await page.addInitScript(
  512. (args: {
  513. directory: string
  514. serverUrl: string
  515. extra: string[]
  516. model: { providerID: string; modelID: string }
  517. }) => {
  518. const key = "opencode.global.dat:server"
  519. const raw = localStorage.getItem(key)
  520. const parsed = (() => {
  521. if (!raw) return undefined
  522. try {
  523. return JSON.parse(raw) as unknown
  524. } catch {
  525. return undefined
  526. }
  527. })()
  528. const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
  529. const list = Array.isArray(store.list) ? store.list : []
  530. const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
  531. const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
  532. const next = { ...(projects as Record<string, unknown>) }
  533. const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
  534. const add = (origin: string, directory: string) => {
  535. const current = next[origin]
  536. const items = Array.isArray(current) ? current : []
  537. const existing = items.filter(
  538. (p): p is { worktree: string; expanded?: boolean } =>
  539. !!p &&
  540. typeof p === "object" &&
  541. "worktree" in p &&
  542. typeof (p as { worktree?: unknown }).worktree === "string",
  543. )
  544. if (existing.some((p) => p.worktree === directory)) return
  545. next[origin] = [{ worktree: directory, expanded: true }, ...existing]
  546. }
  547. for (const directory of [args.directory, ...args.extra]) {
  548. add("local", directory)
  549. add(args.serverUrl, directory)
  550. }
  551. localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
  552. localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
  553. const win = window as E2EWindow
  554. win.__opencode_e2e = {
  555. ...win.__opencode_e2e,
  556. model: { enabled: true },
  557. prompt: { enabled: true },
  558. terminal: { enabled: true, terminals: {} },
  559. }
  560. localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
  561. },
  562. { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
  563. )
  564. }
  565. export { expect }