fixture.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { Event } from "@opencode-ai/schema/event"
  3. import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
  4. import { SessionV1 } from "@opencode-ai/schema/session-v1"
  5. import type {
  6. AssistantMessage,
  7. GlobalEvent,
  8. Message,
  9. Part,
  10. Session,
  11. SessionStatus,
  12. ToolPart,
  13. ToolState,
  14. UserMessage,
  15. } from "@opencode-ai/sdk/v2/client"
  16. import { expect, type Page } from "@playwright/test"
  17. import { Schema } from "effect"
  18. import { mockOpenCodeServer } from "../../utils/mock-server"
  19. import { installSseTransport } from "../../utils/sse-transport"
  20. import { expectSessionTitle } from "../../utils/waits"
  21. export const directory = "C:/OpenCode/TimelineStability"
  22. export const projectID = "proj_timeline_stability"
  23. export const sessionID = "ses_timeline_stability"
  24. export const userID = "msg_1000_timeline_user"
  25. export const assistantID = "msg_1001_timeline_assistant"
  26. export const title = "Timeline visual stability"
  27. export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
  28. type TimelinePayload = Extract<
  29. GlobalEvent["payload"],
  30. {
  31. type:
  32. | "message.updated"
  33. | "message.removed"
  34. | "message.part.updated"
  35. | "message.part.removed"
  36. | "message.part.delta"
  37. | "session.status"
  38. }
  39. >
  40. type DeepReadonly<Value> = Value extends readonly unknown[]
  41. ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
  42. : Value extends object
  43. ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
  44. : Value
  45. export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
  46. export type EventPayload = TimelineEvent
  47. export type ToolStatus = ToolState["status"]
  48. export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
  49. type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
  50. type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
  51. type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
  52. export type PartSeed<Owner extends Message["role"]> =
  53. OwnedPart<Owner> extends infer Candidate
  54. ? Candidate extends Part
  55. ? Omit<Candidate, "sessionID" | "messageID">
  56. : never
  57. : never
  58. type ToolOptions<State extends ToolStatus> = State extends "pending"
  59. ? { output?: never; title?: never; metadata?: never; error?: never }
  60. : State extends "running"
  61. ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
  62. : State extends "error"
  63. ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
  64. : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
  65. const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
  66. const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
  67. const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
  68. const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
  69. const timelineEventSchema = Schema.Union([
  70. eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
  71. eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
  72. eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
  73. eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
  74. eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
  75. eventSchema("session.status", SessionStatusEvent.Status.data),
  76. ])
  77. const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
  78. let eventSequence = 0
  79. export async function setupTimeline(
  80. page: Page,
  81. input: {
  82. messages?: TimelineMessage[]
  83. settings?: Record<string, boolean>
  84. sessions?: Session[]
  85. cpuRate?: number
  86. viewport?: { width: number; height: number }
  87. eventRetry?: number
  88. reducedMotion?: boolean
  89. locale?: string
  90. deviceScaleFactor?: number
  91. seedHistory?: boolean
  92. protocol?: "v1" | "v2"
  93. } = {},
  94. ) {
  95. const sessions = input.sessions ?? [session()]
  96. const messages = validateTimelineMessages([
  97. ...(input.seedHistory ? historyMessages(18) : []),
  98. ...(input.messages ?? [userMessage(), assistantMessage()]),
  99. ])
  100. const active = messages.findLast((message) => message.info.role === "assistant")
  101. const initialStatus = decodeStatus(
  102. active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
  103. decodeOptions,
  104. )
  105. const transport = await installSseTransport<EventPayload>(page, {
  106. server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
  107. retry: input.eventRetry ?? 20,
  108. })
  109. await mockOpenCodeServer(page, {
  110. protocol: input.protocol,
  111. directory,
  112. project: project(),
  113. provider: provider(),
  114. sessions,
  115. sessionStatus: { [sessionID]: initialStatus },
  116. pageMessages: () => ({
  117. items: messages,
  118. }),
  119. })
  120. await page.addInitScript((settings) => {
  121. localStorage.setItem(
  122. "settings.v3",
  123. JSON.stringify({
  124. general: {
  125. editToolPartsExpanded: false,
  126. shellToolPartsExpanded: false,
  127. showReasoningSummaries: false,
  128. showSessionProgressBar: true,
  129. ...settings,
  130. },
  131. }),
  132. )
  133. if (settings.newLayoutDesigns === false) {
  134. localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
  135. }
  136. }, input.settings ?? {})
  137. if (input.locale) {
  138. await page.addInitScript((locale) => {
  139. localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
  140. }, input.locale)
  141. }
  142. if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
  143. await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
  144. if (input.deviceScaleFactor) {
  145. const devtools = await page.context().newCDPSession(page)
  146. const viewport = input.viewport ?? { width: 1400, height: 900 }
  147. await devtools.send("Emulation.setDeviceMetricsOverride", {
  148. width: viewport.width,
  149. height: viewport.height,
  150. deviceScaleFactor: input.deviceScaleFactor,
  151. mobile: false,
  152. })
  153. }
  154. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  155. await transport.waitForConnection()
  156. await expectSessionTitle(page, title)
  157. if (input.cpuRate && input.cpuRate > 1) {
  158. const devtools = await page.context().newCDPSession(page)
  159. await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
  160. }
  161. return {
  162. transport,
  163. async send(event: TimelineEvent, delay = 0) {
  164. const valid = validateTimelineEvent(event)
  165. await transport.send(valid, { marker: describeEvent(valid) })
  166. if (delay) await page.waitForTimeout(delay)
  167. },
  168. async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
  169. for (const item of sequence) {
  170. const valid = validateTimelineEvent(item.event)
  171. await transport.send(valid, { marker: describeEvent(valid) })
  172. await page.waitForTimeout(item.delay)
  173. }
  174. },
  175. async settle(frames = 3) {
  176. await page.evaluate(
  177. (frames) =>
  178. new Promise<void>((resolve) => {
  179. let remaining = frames
  180. const tick = () => {
  181. remaining--
  182. if (remaining <= 0) return resolve()
  183. requestAnimationFrame(tick)
  184. }
  185. requestAnimationFrame(tick)
  186. }),
  187. frames,
  188. )
  189. },
  190. async waitForPart(partID: string) {
  191. const part = page.locator(`[data-timeline-part-id="${partID}"]`)
  192. await expect(part).toHaveCount(1)
  193. await expect(part).toBeVisible()
  194. },
  195. }
  196. }
  197. function describeEvent(event: EventPayload) {
  198. if (event.payload.type === "message.part.updated") {
  199. const part = event.payload.properties.part
  200. return [
  201. event.payload.type,
  202. part.id,
  203. part.type === "tool" ? part.tool : part.type,
  204. part.type === "tool" ? part.state.status : undefined,
  205. ]
  206. .filter(Boolean)
  207. .join(":")
  208. }
  209. if (event.payload.type === "session.status") {
  210. const status = event.payload.properties.status
  211. return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
  212. .filter((value) => value !== undefined)
  213. .join(":")
  214. }
  215. return event.payload.type
  216. }
  217. export function event<const Type extends TimelinePayload["type"]>(
  218. type: Type,
  219. properties: Extract<TimelinePayload, { type: Type }>["properties"],
  220. ): TimelineEvent
  221. export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
  222. return validateTimelineEvent({
  223. directory,
  224. payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
  225. })
  226. }
  227. export function validateTimelineEvent(input: unknown): TimelineEvent {
  228. return decodeEvent(input, decodeOptions)
  229. }
  230. export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
  231. input.forEach((message) => decodeMessage(message, decodeOptions))
  232. const messages = [...input]
  233. const messageIDs = new Set<string>()
  234. const partIDs = new Set<string>()
  235. const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
  236. messages.forEach((message) => {
  237. if (messageIDs.has(message.info.id))
  238. throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
  239. messageIDs.add(message.info.id)
  240. if (message.info.role === "assistant" && !users.has(message.info.parentID))
  241. throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
  242. message.parts.forEach((part) => {
  243. if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
  244. throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
  245. if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
  246. throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  247. if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
  248. throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  249. if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
  250. partIDs.add(part.id)
  251. })
  252. })
  253. return messages
  254. }
  255. export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
  256. await page.waitForFunction(
  257. ({ selectors, stableFrames }) => {
  258. const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
  259. if (elements.some((element) => !element)) return false
  260. return new Promise<boolean>((resolve) => {
  261. let stable = 0
  262. let previous = ""
  263. const sample = () => {
  264. const signature = JSON.stringify(
  265. elements.map((element) => {
  266. const rect = element!.getBoundingClientRect()
  267. return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
  268. }),
  269. )
  270. stable = signature === previous ? stable + 1 : 0
  271. previous = signature
  272. const ordered = elements
  273. .slice(1)
  274. .every(
  275. (element, index) =>
  276. elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
  277. )
  278. if (stable >= stableFrames && ordered) return resolve(true)
  279. requestAnimationFrame(sample)
  280. }
  281. requestAnimationFrame(sample)
  282. })
  283. },
  284. { selectors, stableFrames },
  285. )
  286. }
  287. export function historyMessages(count: number): TimelineMessage[] {
  288. return Array.from({ length: count }, (_, index) => {
  289. const value = String(index).padStart(4, "0")
  290. const historyUserID = `msg_0${value}_history_a_user`
  291. return [
  292. userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
  293. assistantMessage(
  294. [
  295. {
  296. id: `prt_0${value}_history_text`,
  297. type: "text",
  298. text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
  299. },
  300. ],
  301. {
  302. id: `msg_0${value}_history_b_assistant`,
  303. parentID: historyUserID,
  304. created: 1690000001000 + index * 10_000,
  305. },
  306. ),
  307. ]
  308. }).flat()
  309. }
  310. export function partUpdated(part: Part | PartSeed<"assistant">) {
  311. const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
  312. decodePart(owned, decodeOptions)
  313. return event("message.part.updated", {
  314. sessionID,
  315. part: owned,
  316. time: 1700000002000,
  317. })
  318. }
  319. export function partDelta(partID: string, delta: string, messageID = assistantID) {
  320. return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
  321. }
  322. export function messageUpdated(info: Message) {
  323. return event("message.updated", { sessionID, info })
  324. }
  325. export function status(type: SessionStatus["type"], attempt = 1) {
  326. return event("session.status", {
  327. sessionID,
  328. status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
  329. })
  330. }
  331. export function userMessage(
  332. parts?: PartSeed<"user">[],
  333. input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
  334. ): Extract<TimelineMessage, { info: { role: "user" } }> {
  335. const id = input.id ?? userID
  336. const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
  337. const message = {
  338. info: {
  339. id,
  340. sessionID,
  341. role: "user",
  342. time: { created: input.created ?? 1700000000000 },
  343. summary: input.summary ?? { diffs: [] },
  344. agent: "build",
  345. model,
  346. },
  347. parts: seeds.map((part) => ({
  348. ...part,
  349. sessionID,
  350. messageID: id,
  351. })),
  352. } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
  353. decodeMessage(message, decodeOptions)
  354. return message
  355. }
  356. export function assistantMessage(
  357. parts: PartSeed<"assistant">[] = [],
  358. input: {
  359. id?: string
  360. parentID?: string
  361. completed?: boolean
  362. error?: AssistantMessage["error"]
  363. created?: number
  364. } = {},
  365. ): Extract<TimelineMessage, { info: { role: "assistant" } }> {
  366. const id = input.id ?? assistantID
  367. const message = {
  368. info: {
  369. id,
  370. sessionID,
  371. role: "assistant",
  372. time: {
  373. created: input.created ?? 1700000001000,
  374. ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
  375. },
  376. parentID: input.parentID ?? userID,
  377. modelID: model.modelID,
  378. providerID: model.providerID,
  379. mode: "build",
  380. agent: "build",
  381. path: { cwd: directory, root: directory },
  382. cost: 0.01,
  383. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  384. variant: "max",
  385. ...(input.error ? { error: input.error } : {}),
  386. },
  387. parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
  388. } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
  389. decodeMessage(message, decodeOptions)
  390. return message
  391. }
  392. export function userText(
  393. text: string,
  394. input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
  395. ): Extract<PartSeed<"user">, { type: "text" }> {
  396. return { id: "prt_user_text", type: "text", text, ...input }
  397. }
  398. export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
  399. return { id, type: "text", text }
  400. }
  401. export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
  402. return { id, type: "reasoning", text, time: { start: 1700000001000 } }
  403. }
  404. export function toolPart(
  405. id: string,
  406. tool: string,
  407. state: "pending",
  408. input: Record<string, unknown>,
  409. options?: ToolOptions<"pending">,
  410. ): Omit<ToolPart, "sessionID" | "messageID">
  411. export function toolPart(
  412. id: string,
  413. tool: string,
  414. state: "running",
  415. input: Record<string, unknown>,
  416. options?: ToolOptions<"running">,
  417. ): Omit<ToolPart, "sessionID" | "messageID">
  418. export function toolPart(
  419. id: string,
  420. tool: string,
  421. state: "completed",
  422. input: Record<string, unknown>,
  423. options?: ToolOptions<"completed">,
  424. ): Omit<ToolPart, "sessionID" | "messageID">
  425. export function toolPart(
  426. id: string,
  427. tool: string,
  428. state: "error",
  429. input: Record<string, unknown>,
  430. options?: ToolOptions<"error">,
  431. ): Omit<ToolPart, "sessionID" | "messageID">
  432. export function toolPart(
  433. id: string,
  434. tool: string,
  435. state: ToolStatus,
  436. input: Record<string, unknown>,
  437. options: ToolOptions<ToolStatus> = {},
  438. ): Omit<ToolPart, "sessionID" | "messageID"> {
  439. const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
  440. if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
  441. if (state === "running")
  442. return {
  443. ...base,
  444. state: {
  445. status: state,
  446. input,
  447. title: options.title,
  448. metadata: options.metadata ?? {},
  449. time: { start: 1700000001000 },
  450. },
  451. }
  452. if (state === "error")
  453. return {
  454. ...base,
  455. state: {
  456. status: state,
  457. input,
  458. error: options.error ?? "Tool failed",
  459. metadata: options.metadata ?? {},
  460. time: { start: 1700000001000, end: 1700000002000 },
  461. },
  462. }
  463. return {
  464. ...base,
  465. state: {
  466. status: state,
  467. input,
  468. output: options.output ?? "Completed",
  469. title: options.title ?? tool,
  470. metadata: options.metadata ?? {},
  471. time: { start: 1700000001000, end: 1700000002000 },
  472. },
  473. }
  474. }
  475. export function shell(
  476. id: string,
  477. state: ToolStatus,
  478. output = "",
  479. command = `echo ${id}`,
  480. ): Omit<ToolPart, "sessionID" | "messageID"> {
  481. if (state === "pending") return toolPart(id, "bash", state, { command })
  482. if (state === "running")
  483. return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
  484. if (state === "error")
  485. return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
  486. return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
  487. }
  488. export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
  489. return { ...info, time: { ...info.time, completed: 1700000003000 } }
  490. }
  491. export function project() {
  492. return {
  493. id: projectID,
  494. worktree: directory,
  495. vcs: "git",
  496. name: "timeline-stability",
  497. time: { created: 1700000000000, updated: 1700000000000 },
  498. sandboxes: [],
  499. }
  500. }
  501. export function session(input: Partial<Session> = {}): Session {
  502. return {
  503. id: sessionID,
  504. slug: "timeline-stability",
  505. projectID,
  506. directory,
  507. title,
  508. version: "dev",
  509. time: { created: 1700000000000, updated: 1700000000000 },
  510. ...input,
  511. }
  512. }
  513. function eventSchema<
  514. const Type extends TimelinePayload["type"],
  515. const Properties extends Schema.Codec<unknown, unknown>,
  516. >(type: Type, properties: Properties) {
  517. return Schema.Struct({
  518. directory: Schema.String,
  519. project: Schema.optional(Schema.String),
  520. workspace: Schema.optional(Schema.String),
  521. payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
  522. })
  523. }
  524. function provider() {
  525. return {
  526. all: [
  527. {
  528. id: "opencode",
  529. name: "OpenCode",
  530. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  531. },
  532. ],
  533. connected: ["opencode"],
  534. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  535. }
  536. }