mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
// Moving around the app at 1440×900: Home, new sessions, child sessions, restored tabs and the command palette, on the
|
||
// stress fixture (a 12-turn and a 72-turn session with every tool and diffs up to 300 lines, and a subagent's child
|
||
// session), paginated as the server does. Every case starts from a fresh document, so each run pays what the first
|
||
// visit pays: lazy chunks, the first history page and the first render.
|
||
import { bench, web, type Case } from "@hona/strobe"
|
||
import { draftHref, sessionHref } from "../e2e/utils/app"
|
||
import { messages } from "../e2e/utils/markdown-sessions"
|
||
import { mockOpenCodeServer } from "../e2e/utils/mock-server"
|
||
import { fixture, installTimelineSettings, mockStressTimeline } from "../e2e/utils/session-fixture"
|
||
import { installProjects } from "./fixtures/home"
|
||
import { cached } from "./fixtures/mock"
|
||
import { installTabs } from "./fixtures/tabs"
|
||
|
||
const draftID = "draft_perf_navigation"
|
||
|
||
const last = (ids: string[]) => `[data-message-id="${ids.at(-1)}"]`
|
||
|
||
const source = last(fixture.expected.sourceMessageIDs)
|
||
|
||
const target = last(fixture.expected.targetMessageIDs)
|
||
|
||
const child = last(fixture.expected.childMessageIDs)
|
||
|
||
const tab = (href: string) => `[data-slot="titlebar-tabs"] a[href="${href}"]`
|
||
|
||
const tabItem = (href: string) => `[data-slot="titlebar-tabs"] [data-slot="titlebar-tab-item"]:has(a[href="${href}"])`
|
||
|
||
const home = '[data-component="home-session-search"]'
|
||
|
||
const homeRow = (sessionID: string) =>
|
||
`[data-component="home-session-row-container"][data-session-id="${sessionID}"] [data-component="home-session-row"]`
|
||
|
||
const newSession = '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]'
|
||
|
||
const model = '[data-component="new-session"] [data-action="composer-model"]'
|
||
|
||
const palette = '[role="dialog"] [role="combobox"]'
|
||
|
||
const selected = '[role="dialog"] [role="option"][aria-selected="true"]'
|
||
|
||
const paletteList = '[role="dialog"] [data-component="menu-scroll"] > [data-scrollable]'
|
||
|
||
// Eight restored tabs of a normal 20-message first page each (44 parts, 139 KB).
|
||
const restored = Array.from({ length: 8 }, (_, index) => ({
|
||
...fixture.sessions[0],
|
||
id: `ses_perf_restored_${index}`,
|
||
title: `Renderer review ${index}`,
|
||
}))
|
||
|
||
const pages = Object.fromEntries(
|
||
restored.map((session) => [
|
||
session.id,
|
||
messages[fixture.targetID].slice(-20).map((message) => ({ ...message, id: `${message.id}_${session.id}` })),
|
||
]),
|
||
)
|
||
|
||
const answer = (sessionID: string) =>
|
||
`[data-timeline-part-id="${pages[sessionID].at(-1)?.id}:text:0"] [data-component="markdown"][data-markdown-ready]`
|
||
|
||
type Scene = { tabs: string[]; draft?: boolean; first: (server: string) => string; ready: string }
|
||
|
||
const sourcePage = (server: string) => sessionHref(fixture.sourceID, server)
|
||
|
||
const inSource: Scene = { tabs: [fixture.sourceID], first: sourcePage, ready: source }
|
||
|
||
const onHome: Scene = { tabs: [], first: () => "/", ready: homeRow(fixture.targetID) }
|
||
|
||
const restoring: Scene = {
|
||
tabs: restored.map((session) => session.id),
|
||
first: (server) => sessionHref(restored[0].id, server),
|
||
ready: answer(restored[0].id),
|
||
}
|
||
|
||
/** Where each case starts: its open tabs, its first page and what that page shows once ready. */
|
||
const scenes = new Map<string, Scene>([
|
||
["open a session from home", onHome],
|
||
["close the last tab", inSource],
|
||
["new session from home", { ...onHome, ready: '[data-action="home-new-session"]' }],
|
||
["new session from a session", inSource],
|
||
["open the new session page", { ...inSource, draft: true }],
|
||
[
|
||
"leave the new session page",
|
||
{ tabs: [fixture.sourceID, fixture.targetID], draft: true, first: () => draftHref(draftID), ready: newSession },
|
||
],
|
||
["open a child session", inSource],
|
||
["restore 8 tabs", restoring],
|
||
["activate a restored tab", restoring],
|
||
["search commands in a session", inSource],
|
||
["search commands on home", onHome],
|
||
["scroll the command palette", inSource],
|
||
])
|
||
|
||
const fresh: Pick<Case<{}>, "reset"> = {
|
||
async reset({ page, prepared }) {
|
||
await page.goto(prepared.first, { until: prepared.ready, timeout: 60_000 })
|
||
await page.idle()
|
||
},
|
||
}
|
||
|
||
const metrics = ["run.duration", "interaction.p50"]
|
||
|
||
export default bench({
|
||
targets: {
|
||
web: web({
|
||
expect: { title: "OpenCode" },
|
||
viewport: { width: 1440, height: 900 },
|
||
async prepare(ctx) {
|
||
const { case: name, url } = ctx
|
||
const server = new URL(url).origin
|
||
const scene = scenes.get(name)
|
||
const page = cached(ctx)
|
||
|
||
if (!scene) throw new Error(`navigation.bench.ts: no scene for "${name}"`)
|
||
|
||
if (scene === restoring)
|
||
await mockOpenCodeServer(page, {
|
||
server,
|
||
sessions: restored,
|
||
provider: fixture.provider,
|
||
directory: fixture.directory,
|
||
project: fixture.project,
|
||
pageMessages: (sessionID) => ({ items: pages[sessionID] ?? [] }),
|
||
})
|
||
else await mockStressTimeline(page, { server })
|
||
await installTimelineSettings(page)
|
||
await installProjects(page, [fixture.directory])
|
||
await installTabs(page, {
|
||
server,
|
||
directory: fixture.directory,
|
||
sessions: scene.tabs,
|
||
draft: scene.draft ? draftID : undefined,
|
||
})
|
||
|
||
return { server, first: scene.first(server), ready: scene.ready }
|
||
},
|
||
url: ({ prepared }) => prepared.first,
|
||
}),
|
||
},
|
||
|
||
async setup({ case: name, page, prepared }) {
|
||
await page.waitFor(prepared.ready, { timeout: 60_000 })
|
||
|
||
if (name === "scroll the command palette") await page.press("ControlOrMeta+Shift+P", { until: paletteList })
|
||
await page.idle()
|
||
},
|
||
|
||
cases: {
|
||
"open a session from home": {
|
||
primary: "main.work",
|
||
metrics,
|
||
async run({ page, prepared }) {
|
||
await page.click(homeRow(fixture.targetID), { until: target })
|
||
await page.waitFor(tab(sessionHref(fixture.targetID, prepared.server)))
|
||
},
|
||
...fresh,
|
||
},
|
||
|
||
// As a person closes it: the close button shows on hover.
|
||
"close the last tab": {
|
||
primary: "main.work",
|
||
metrics,
|
||
async run({ page, prepared }) {
|
||
const item = tabItem(sessionHref(fixture.sourceID, prepared.server))
|
||
const close = `${item} [data-slot="tab-close"] button`
|
||
await page.hover(item, { until: close })
|
||
await page.click(close, { until: home })
|
||
await page.waitFor(homeRow(fixture.targetID))
|
||
},
|
||
...fresh,
|
||
},
|
||
|
||
// Ready to type: the composer is editable with the default model.
|
||
"new session from home": {
|
||
primary: "main.work",
|
||
metrics,
|
||
async run({ page }) {
|
||
await page.click('[data-action="home-new-session"]', { until: newSession })
|
||
await page.waitFor(model, { text: "Claude Opus 4.6" })
|
||
},
|
||
...fresh,
|
||
},
|
||
|
||
"new session from a session": {
|
||
primary: "main.work",
|
||
metrics,
|
||
async run({ page }) {
|
||
await page.click('button[aria-label="New session"]', { until: newSession })
|
||
await page.waitFor(model, { text: "Claude Opus 4.6" })
|
||
},
|
||
...fresh,
|
||
},
|
||
|
||
// The new session page's module loads on its first use.
|
||
"open the new session page": {
|
||
primary: "main.work",
|
||
metrics,
|
||
run: ({ page }) => page.click(tab(draftHref(draftID)), { until: newSession }),
|
||
...fresh,
|
||
},
|
||
|
||
"leave the new session page": {
|
||
primary: "main.work",
|
||
metrics,
|
||
run: ({ page, prepared }) => page.click(tab(sessionHref(fixture.targetID, prepared.server)), { until: target }),
|
||
...fresh,
|
||
},
|
||
|
||
// From the subagent card in the source's last turn.
|
||
"open a child session": {
|
||
primary: "main.work",
|
||
metrics,
|
||
run: ({ page, prepared }) =>
|
||
page.click(
|
||
`a[href="${sessionHref(fixture.childID, prepared.server)}"]:has([data-component="task-tool-card"])`,
|
||
{
|
||
until: child,
|
||
},
|
||
),
|
||
...fresh,
|
||
},
|
||
|
||
// Loading the app with eight tabs to restore, to the active one's last answer on screen. Transcripts fetched
|
||
// (`net.requests` on the history endpoint): the active tab's, not one per restored tab.
|
||
"restore 8 tabs": {
|
||
primary: "main.work",
|
||
metrics: ["run.duration", "net.requests{/api/session/*/message}"],
|
||
run: ({ page, prepared }) => page.goto(prepared.first, { until: prepared.ready, timeout: 60_000 }),
|
||
},
|
||
|
||
"activate a restored tab": {
|
||
primary: "main.work",
|
||
metrics: [...metrics, "net.requests{/api/session/*/message}"],
|
||
run: ({ page, prepared }) =>
|
||
page.click(tab(sessionHref(restored[1].id, prepared.server)), { until: answer(restored[1].id) }),
|
||
async reset({ page, prepared }) {
|
||
await page.goto(prepared.first, { until: prepared.ready, timeout: 60_000 })
|
||
// Off the tab strip: a pointer resting on a tab for 100 ms prefetches its transcript.
|
||
await page.hover('[data-component="composer-editor"]')
|
||
await page.idle()
|
||
},
|
||
},
|
||
|
||
"search commands in a session": {
|
||
primary: "main.work",
|
||
metrics: ["run.duration", "interaction.p95"],
|
||
async run({ page }) {
|
||
await page.press("ControlOrMeta+Shift+P", { until: palette })
|
||
await page.type(palette, "copy session", { delay: 50 })
|
||
await page.waitFor(selected, { text: "Copy Session ID" })
|
||
},
|
||
async reset({ page }) {
|
||
await page.press("Escape")
|
||
await page.idle()
|
||
},
|
||
},
|
||
|
||
"search commands on home": {
|
||
primary: "main.work",
|
||
metrics: ["run.duration", "interaction.p95"],
|
||
async run({ page }) {
|
||
await page.press("ControlOrMeta+Shift+P", { until: palette })
|
||
await page.type(palette, "open settings", { delay: 50 })
|
||
await page.waitFor(selected, { text: "Open settings" })
|
||
},
|
||
async reset({ page }) {
|
||
await page.press("Escape")
|
||
await page.idle()
|
||
},
|
||
},
|
||
|
||
// The palette's result list scrolls in a MenuScroll, which moves its thumb on every scroll frame.
|
||
"scroll the command palette": {
|
||
primary: "main.busy",
|
||
metrics: ["main.work", "frames.dropped.pct"],
|
||
async run({ page }) {
|
||
await page.scroll(paletteList, { by: 3000, speed: 3000 })
|
||
await page.scroll(paletteList, { by: -3000, speed: 3000 })
|
||
},
|
||
// Opens the palette again each run, so a run starts at the top even if something closed it.
|
||
async reset({ page }) {
|
||
await page.press("Escape")
|
||
await page.press("ControlOrMeta+Shift+P", { until: paletteList })
|
||
await page.idle()
|
||
},
|
||
},
|
||
},
|
||
})
|