packages/app/perf/tabs.bench.ts
Rama dev · réplica de lectura del repositorio original.
// Switching between two open session tabs of 200 long markdown exchanges each (headings, tables, task lists and four
// highlighted code fences per answer), loaded in full, at 1440×900.
//
// Set OPENCODE_PERF_SESSION to a session export (`opencode export <id>`) to add two cases on your own session.
import { readFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import type { Page } from "@playwright/test"
import { bench, web, type Case } from "@hona/strobe"
import { pageMessagesFrom, sessionHref } from "../e2e/utils/app"
import { expected, messages } from "../e2e/utils/markdown-sessions"
import { mockOpenCodeServer, type MockSession } from "../e2e/utils/mock-server"
import { fixture, installTimelineSettings } from "../e2e/utils/session-fixture"
import { reviewDiffs } from "./fixtures/diffs"
import { openReview, review } from "./fixtures/review"
import { cached } from "./fixtures/mock"
import { installTabs } from "./fixtures/tabs"
// The last answer, formatted and on screen: what a person waits for after clicking a tab. A cold timeline stays
// hidden (`visibility: hidden`) until it has measured itself at the tail, and strobe waits for what is visible.
const ready = (answerID: string) =>
`[data-timeline-part-id="${answerID}"] [data-component="markdown"][data-markdown-ready]`
const tab = (href: string) => `[data-slot="titlebar-tabs"] a[href="${href}"]`
const composer = '[data-component="composer-editor"]'
type SessionExport = {
info: MockSession & { projectID: string; location: { directory: string } }
messages: SessionMessageInfo[]
}
const exportFile = process.env.OPENCODE_PERF_SESSION
// SAFETY: `opencode export` writes this shape; a file that is not an export fails the first block's load.
const exported = exportFile ? (JSON.parse(readFileSync(exportFile, "utf8")) as SessionExport) : undefined
// The destination was rendered before and is cached: switching back and forth. A cached transcript is not fetched
// again (`net.requests` on the history endpoint stays 0), and the switch moves nothing that was already on screen.
// The run ends on the frame after the answer shows: headless, it ends at its last step, and the wait at the first
// match, so the style and layout the switch left for that frame would count in main.work but not run.duration.
const warm: Case<{}> = {
primary: "main.work",
metrics: ["run.duration", "interaction.p50", "net.requests{/api/session/*/message}", "layout.shift"],
run: ({ page, prepared }) => page.click(tab(prepared.target), { until: [prepared.targetReady, "frame"] }),
async reset({ page, prepared }) {
await page.click(tab(prepared.source), { until: prepared.sourceReady })
await page.idle()
},
}
// The destination was never rendered in this document: a fresh load before every switch.
const cold: Case<{}> = {
...warm,
async reset({ case: name, page, prepared }) {
await page.goto(prepared.source, { until: prepared.sourceReady, timeout: 60_000 })
if (name.includes("review open")) await openReview(page)
// Off the tab strip: a pointer resting on a tab for 100 ms prefetches its transcript, and the switch is no longer cold.
await page.hover(composer)
await page.idle()
},
}
const cases = {
// Click to formatted answer lands on whole frames (about 40 or 47 ms at 165 Hz), too coarse to decide on; the
// main-thread work a switch costs is continuous.
"switch tab": warm,
// The review pane open on 72 changed files beside both timelines.
"switch tab with review open": warm,
// Away from the session with the review open, to one with nothing beside it, and back: both directions a person
// pays when switching between them.
"switch away and back with review open": {
...warm,
async run({ page, prepared }) {
await page.click(tab(prepared.target), { until: prepared.targetReady })
await page.click(tab(prepared.source), { until: [prepared.sourceReady, "frame"] })
},
reset: ({ page }) => page.idle(),
},
// The destination was laid out before the review pane narrowed the timeline, so it lays out again.
"switch tab after review resized it": {
...warm,
async reset({ page, prepared }) {
await page.click(tab(prepared.source), { until: prepared.sourceReady })
await page.click(review.toggle)
await page.idle()
await page.click(tab(prepared.target), { until: prepared.targetReady })
await page.click(tab(prepared.source), { until: prepared.sourceReady })
await openReview(page)
await page.idle()
},
},
"open an unvisited tab": cold,
"open an unvisited tab with review open": cold,
// 1,200 models in the catalog. With `--probes memory --repeat 10`: does memory grow from switch to switch?
"switch tab with a large model catalog": warm,
} satisfies Record<string, Case<{}>>
export default bench({
// What a case's launch sets up: the sessions and catalog it serves, the review pane, and whether setup has rendered
// the target tab already. Cases with the same setup share a launch.
setupKey: ({ case: name }) => ({
export: !!exported && name.endsWith("your session"),
catalog: name.includes("model catalog"),
diffs: name.includes("review"),
review: name.includes("review open"),
warm: !name.startsWith("open"),
resized: name.includes("resized"),
}),
targets: {
web: web({
expect: { title: "OpenCode" },
viewport: { width: 1440, height: 900 },
async prepare(ctx) {
const { setupKey: setup, url } = ctx
const server = new URL(url).origin
const page = cached(ctx)
if (exported && setup.export) return mockExport(page, server, exported)
await mockOpenCodeServer(page, {
server,
sessions: fixture.sessions,
provider: setup.catalog ? catalog(1_200) : fixture.provider,
directory: fixture.directory,
project: fixture.project,
vcsDiff: setup.diffs ? reviewDiffs() : undefined,
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
})
await installTimelineSettings(page)
await installTabs(page, {
server,
directory: fixture.directory,
sessions: [fixture.sourceID, fixture.targetID],
})
return {
source: sessionHref(fixture.sourceID, server),
target: sessionHref(fixture.targetID, server),
sourceReady: ready(expected[fixture.sourceID].answerID),
targetReady: ready(expected[fixture.targetID].answerID),
}
},
url: ({ prepared }) => prepared.source,
}),
},
async setup({ setupKey: setup, page, prepared }) {
await page.waitFor(prepared.sourceReady, { timeout: 60_000 })
if (setup.review) await openReview(page)
if (setup.warm) {
await page.click(tab(prepared.target), { until: prepared.targetReady, timeout: 60_000 })
await page.click(tab(prepared.source), { until: prepared.sourceReady })
}
if (setup.resized) await openReview(page)
await page.idle()
},
cases: exported ? { ...cases, "open your session": cold, "switch to your session": warm } : cases,
})
function catalog(models: number) {
const provider = fixture.provider.all[0]
return {
...fixture.provider,
all: [
{
...provider,
models: {
...provider.models,
...Object.fromEntries(
Array.from({ length: models - 1 }, (_, index) => [
`catalog-model-${index}`,
{
id: `catalog-model-${index}`,
name: `Catalog model ${index}`,
cost: { input: 1, output: 2 },
limit: { context: 200_000, output: 8192 },
variants: { high: { reasoningEffort: "high" } },
},
]),
),
},
},
],
}
}
// The export beside a one-message source session, paginated as the server does.
async function mockExport(page: Page, server: string, session: NonNullable<typeof exported>) {
const directory = session.info.location.directory
const source = { ...session.info, id: "ses_perf_source", title: "Benchmark source" }
const answer = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)
await mockOpenCodeServer(page, {
server,
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: fixture.provider,
sessions: [session.info, source],
pageMessages: pageMessagesFrom({
[session.info.id]: session.messages,
[source.id]: [{ id: "msg_perf_source", type: "user", text: "Benchmark source", time: { created: 1 } }],
}),
})
await installTabs(page, { server, directory, sessions: [source.id, session.info.id] })
return {
source: sessionHref(source.id, server),
target: sessionHref(session.info.id, server),
sourceReady: '[data-slot="user-message-text"]',
targetReady: answer ? ready(`${answer.id}:text:0`) : `[data-message-id="${session.messages.at(-1)?.id}"]`,
}
}