fixture.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  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. await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
  192. },
  193. }
  194. }
  195. function describeEvent(event: EventPayload) {
  196. if (event.payload.type === "message.part.updated") {
  197. const part = event.payload.properties.part
  198. return [
  199. event.payload.type,
  200. part.id,
  201. part.type === "tool" ? part.tool : part.type,
  202. part.type === "tool" ? part.state.status : undefined,
  203. ]
  204. .filter(Boolean)
  205. .join(":")
  206. }
  207. if (event.payload.type === "session.status") {
  208. const status = event.payload.properties.status
  209. return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
  210. .filter((value) => value !== undefined)
  211. .join(":")
  212. }
  213. return event.payload.type
  214. }
  215. export function event<const Type extends TimelinePayload["type"]>(
  216. type: Type,
  217. properties: Extract<TimelinePayload, { type: Type }>["properties"],
  218. ): TimelineEvent
  219. export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
  220. return validateTimelineEvent({
  221. directory,
  222. payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
  223. })
  224. }
  225. export function validateTimelineEvent(input: unknown): TimelineEvent {
  226. return decodeEvent(input, decodeOptions)
  227. }
  228. export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
  229. input.forEach((message) => decodeMessage(message, decodeOptions))
  230. const messages = [...input]
  231. const messageIDs = new Set<string>()
  232. const partIDs = new Set<string>()
  233. const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
  234. messages.forEach((message) => {
  235. if (messageIDs.has(message.info.id))
  236. throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
  237. messageIDs.add(message.info.id)
  238. if (message.info.role === "assistant" && !users.has(message.info.parentID))
  239. throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
  240. message.parts.forEach((part) => {
  241. if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
  242. throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
  243. if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
  244. throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  245. if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
  246. throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  247. if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
  248. partIDs.add(part.id)
  249. })
  250. })
  251. return messages
  252. }
  253. export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
  254. await page.waitForFunction(
  255. ({ selectors, stableFrames }) => {
  256. const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
  257. if (elements.some((element) => !element)) return false
  258. return new Promise<boolean>((resolve) => {
  259. let stable = 0
  260. let previous = ""
  261. const sample = () => {
  262. const signature = JSON.stringify(
  263. elements.map((element) => {
  264. const rect = element!.getBoundingClientRect()
  265. return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
  266. }),
  267. )
  268. stable = signature === previous ? stable + 1 : 0
  269. previous = signature
  270. const ordered = elements
  271. .slice(1)
  272. .every(
  273. (element, index) =>
  274. elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
  275. )
  276. if (stable >= stableFrames && ordered) return resolve(true)
  277. requestAnimationFrame(sample)
  278. }
  279. requestAnimationFrame(sample)
  280. })
  281. },
  282. { selectors, stableFrames },
  283. )
  284. }
  285. export function historyMessages(count: number): TimelineMessage[] {
  286. return Array.from({ length: count }, (_, index) => {
  287. const value = String(index).padStart(4, "0")
  288. const historyUserID = `msg_0${value}_history_a_user`
  289. return [
  290. userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
  291. assistantMessage(
  292. [
  293. {
  294. id: `prt_0${value}_history_text`,
  295. type: "text",
  296. text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
  297. },
  298. ],
  299. {
  300. id: `msg_0${value}_history_b_assistant`,
  301. parentID: historyUserID,
  302. created: 1690000001000 + index * 10_000,
  303. },
  304. ),
  305. ]
  306. }).flat()
  307. }
  308. export function partUpdated(part: Part | PartSeed<"assistant">) {
  309. const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
  310. decodePart(owned, decodeOptions)
  311. return event("message.part.updated", {
  312. sessionID,
  313. part: owned,
  314. time: 1700000002000,
  315. })
  316. }
  317. export function partDelta(partID: string, delta: string, messageID = assistantID) {
  318. return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
  319. }
  320. export function messageUpdated(info: Message) {
  321. return event("message.updated", { sessionID, info })
  322. }
  323. export function status(type: SessionStatus["type"], attempt = 1) {
  324. return event("session.status", {
  325. sessionID,
  326. status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
  327. })
  328. }
  329. export function userMessage(
  330. parts?: PartSeed<"user">[],
  331. input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
  332. ): Extract<TimelineMessage, { info: { role: "user" } }> {
  333. const id = input.id ?? userID
  334. const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
  335. const message = {
  336. info: {
  337. id,
  338. sessionID,
  339. role: "user",
  340. time: { created: input.created ?? 1700000000000 },
  341. summary: input.summary ?? { diffs: [] },
  342. agent: "build",
  343. model,
  344. },
  345. parts: seeds.map((part) => ({
  346. ...part,
  347. sessionID,
  348. messageID: id,
  349. })),
  350. } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
  351. decodeMessage(message, decodeOptions)
  352. return message
  353. }
  354. export function assistantMessage(
  355. parts: PartSeed<"assistant">[] = [],
  356. input: {
  357. id?: string
  358. parentID?: string
  359. completed?: boolean
  360. error?: AssistantMessage["error"]
  361. created?: number
  362. } = {},
  363. ): Extract<TimelineMessage, { info: { role: "assistant" } }> {
  364. const id = input.id ?? assistantID
  365. const message = {
  366. info: {
  367. id,
  368. sessionID,
  369. role: "assistant",
  370. time: {
  371. created: input.created ?? 1700000001000,
  372. ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
  373. },
  374. parentID: input.parentID ?? userID,
  375. modelID: model.modelID,
  376. providerID: model.providerID,
  377. mode: "build",
  378. agent: "build",
  379. path: { cwd: directory, root: directory },
  380. cost: 0.01,
  381. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  382. variant: "max",
  383. ...(input.error ? { error: input.error } : {}),
  384. },
  385. parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
  386. } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
  387. decodeMessage(message, decodeOptions)
  388. return message
  389. }
  390. export function userText(
  391. text: string,
  392. input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
  393. ): Extract<PartSeed<"user">, { type: "text" }> {
  394. return { id: "prt_user_text", type: "text", text, ...input }
  395. }
  396. export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
  397. return { id, type: "text", text }
  398. }
  399. export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
  400. return { id, type: "reasoning", text, time: { start: 1700000001000 } }
  401. }
  402. export function toolPart(
  403. id: string,
  404. tool: string,
  405. state: "pending",
  406. input: Record<string, unknown>,
  407. options?: ToolOptions<"pending">,
  408. ): Omit<ToolPart, "sessionID" | "messageID">
  409. export function toolPart(
  410. id: string,
  411. tool: string,
  412. state: "running",
  413. input: Record<string, unknown>,
  414. options?: ToolOptions<"running">,
  415. ): Omit<ToolPart, "sessionID" | "messageID">
  416. export function toolPart(
  417. id: string,
  418. tool: string,
  419. state: "completed",
  420. input: Record<string, unknown>,
  421. options?: ToolOptions<"completed">,
  422. ): Omit<ToolPart, "sessionID" | "messageID">
  423. export function toolPart(
  424. id: string,
  425. tool: string,
  426. state: "error",
  427. input: Record<string, unknown>,
  428. options?: ToolOptions<"error">,
  429. ): Omit<ToolPart, "sessionID" | "messageID">
  430. export function toolPart(
  431. id: string,
  432. tool: string,
  433. state: ToolStatus,
  434. input: Record<string, unknown>,
  435. options: ToolOptions<ToolStatus> = {},
  436. ): Omit<ToolPart, "sessionID" | "messageID"> {
  437. const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
  438. if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
  439. if (state === "running")
  440. return {
  441. ...base,
  442. state: {
  443. status: state,
  444. input,
  445. title: options.title,
  446. metadata: options.metadata ?? {},
  447. time: { start: 1700000001000 },
  448. },
  449. }
  450. if (state === "error")
  451. return {
  452. ...base,
  453. state: {
  454. status: state,
  455. input,
  456. error: options.error ?? "Tool failed",
  457. metadata: options.metadata ?? {},
  458. time: { start: 1700000001000, end: 1700000002000 },
  459. },
  460. }
  461. return {
  462. ...base,
  463. state: {
  464. status: state,
  465. input,
  466. output: options.output ?? "Completed",
  467. title: options.title ?? tool,
  468. metadata: options.metadata ?? {},
  469. time: { start: 1700000001000, end: 1700000002000 },
  470. },
  471. }
  472. }
  473. export function shell(
  474. id: string,
  475. state: ToolStatus,
  476. output = "",
  477. command = `echo ${id}`,
  478. ): Omit<ToolPart, "sessionID" | "messageID"> {
  479. if (state === "pending") return toolPart(id, "bash", state, { command })
  480. if (state === "running")
  481. return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
  482. if (state === "error")
  483. return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
  484. return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
  485. }
  486. export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
  487. return { ...info, time: { ...info.time, completed: 1700000003000 } }
  488. }
  489. export function project() {
  490. return {
  491. id: projectID,
  492. worktree: directory,
  493. vcs: "git",
  494. name: "timeline-stability",
  495. time: { created: 1700000000000, updated: 1700000000000 },
  496. sandboxes: [],
  497. }
  498. }
  499. export function session(input: Partial<Session> = {}): Session {
  500. return {
  501. id: sessionID,
  502. slug: "timeline-stability",
  503. projectID,
  504. directory,
  505. title,
  506. version: "dev",
  507. time: { created: 1700000000000, updated: 1700000000000 },
  508. ...input,
  509. }
  510. }
  511. function eventSchema<
  512. const Type extends TimelinePayload["type"],
  513. const Properties extends Schema.Codec<unknown, unknown>,
  514. >(type: Type, properties: Properties) {
  515. return Schema.Struct({
  516. directory: Schema.String,
  517. project: Schema.optional(Schema.String),
  518. workspace: Schema.optional(Schema.String),
  519. payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
  520. })
  521. }
  522. function provider() {
  523. return {
  524. all: [
  525. {
  526. id: "opencode",
  527. name: "OpenCode",
  528. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  529. },
  530. ],
  531. connected: ["opencode"],
  532. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  533. }
  534. }