fixture.ts 19 KB

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