Ctrl K
ComunidadGuías…

packages/app/perf/tabs.bench.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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}"]`,
  }
}