From 2494a2a0fef7fbf6a1e85fc12ccb958ddf3baed0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:18:29 -0400 Subject: [PATCH] perf: add repeatable issue-detail baseline rig (#10409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issue detail page is a core operator surface where perceived latency directly affects task navigation > - Performance work needs repeatable evidence so later optimizations can be compared against the same scenarios > - The page did not expose stable user-timing marks for its header or first useful content > - There was also no isolated seeded browser rig that measured warm navigation, cold deep links, waterfalls, or server time > - This pull request adds the instrumentation and a one-command Playwright baseline harness > - The benefit is that issue-page performance changes can be validated with reproducible median measurements instead of anecdotes ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: `ui/`, `server/`, and browser performance tooling. **Problem or motivation** The issue detail page performs a large client bootstrap and request fan-out, but the repository lacks stable user-timing boundaries and a repeatable benchmark. That makes performance changes difficult to compare and allows regressions to be judged from anecdotes instead of consistent evidence. **Proposed solution** Add stable header/content paint measures, development/QA-only lifecycle vital reporting, aggregate server timing for the issue endpoint, and a seeded Playwright command that runs warm/cold scenarios under throttled and unthrottled profiles with N≥5 median reporting. **Alternatives considered** Ad hoc DevTools recordings were rejected because they are not repeatable or reviewable. Production telemetry was rejected because this baseline should not change production data collection. A unit-only harness was rejected because it cannot capture browser bootstrap, rendering, and network waterfall costs. **Roadmap alignment** The roadmap calls for agent performance to be measurable over time. This change applies that evidence-first principle to a core operator page and does not duplicate a listed roadmap deliverable. **Additional context** The generated report includes warm and cold medians, TTFB/FCP/LCP where applicable, request and byte totals before first useful content, JavaScript bytes, and issue endpoint server timing. ## What Changed - Added `issue-detail:navigate→header-paint` and `issue-detail:navigate→content-paint` user-timing measures to the issue detail page. - Added development/QA-only TTFB, LCP, and INP console reporting without production telemetry delivery. - Added `Server-Timing` for `GET /api/issues/:id`. - Added `pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts`, which seeds an isolated instance and runs N≥5 warm/cold samples under unthrottled and Fast 4G/4x CPU profiles. - Added Markdown, raw JSON, and Chrome-trace outputs with median baseline tables and waterfall data. ## Verification - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm check:token-gates` - `npx playwright test --config tests/perf/issue-detail/playwright.config.ts --list` - `pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts` — passed 20 samples in 9.4 minutes (5 runs × 2 scenarios × 2 profiles) for the baseline; post-review integrity reruns also exercised the corrected paths, while this shared runner intermittently killed Chromium processes, so the rig now performs one bounded browser-crash retry per sample. - Baseline medians: warm unthrottled 278/447 ms header/content; cold unthrottled 646/646 ms; warm throttled 1240/2060 ms; cold throttled 3932/3933 ms. ## Risks - Low product risk: the new browser measurements are development/QA tooling and the UI timing work does not change visible layout. - `Server-Timing` exposes only aggregate handler duration, not query contents or private identifiers. - Native INP reporting uses supported browser event timing entries and silently no-ops where unsupported. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4, tool-assisted coding and browser execution with reasoning enabled; context-window size is not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Dev Agent --- .gitignore | 1 + server/src/routes/issues.ts | 2 + tests/perf/issue-detail/README.md | 27 ++ .../issue-detail/issue-detail.perf.spec.ts | 339 ++++++++++++++++++ tests/perf/issue-detail/playwright.config.ts | 49 +++ ui/src/lib/issue-detail-performance.ts | 99 +++++ ui/src/pages/IssueDetail.tsx | 36 +- 7 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 tests/perf/issue-detail/README.md create mode 100644 tests/perf/issue-detail/issue-detail.perf.spec.ts create mode 100644 tests/perf/issue-detail/playwright.config.ts create mode 100644 ui/src/lib/issue-detail-performance.ts diff --git a/.gitignore b/.gitignore index f7f6ae75b1..f061953958 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ tests/e2e/test-results/ tests/e2e/playwright-report/ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ +test-results/issue-detail-perf/ tests/storybook-visual/.cache/ tests/storybook-visual/.snapshots/ tests/storybook-visual/baseline-review/ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a7c9d23d8f..c0295a2186 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6026,6 +6026,7 @@ export function issueRoutes( }); router.get("/issues/:id", async (req, res) => { + const requestStartedAt = performance.now(); const id = req.params.id as string; const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found"); if (!issue) return; @@ -6086,6 +6087,7 @@ export function issueRoutes( ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId) : null; const workProducts = await workProductsSvc.listForIssue(issue.id); + res.setHeader("Server-Timing", `paperclip_issue;dur=${(performance.now() - requestStartedAt).toFixed(1)}`); res.json({ ...issue, ...inboxArchiveFields, diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md new file mode 100644 index 0000000000..a5bfb872c7 --- /dev/null +++ b/tests/perf/issue-detail/README.md @@ -0,0 +1,27 @@ +# Issue-detail performance baseline + +Run the repeatable baseline from the repository root: + +```sh +pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts +``` + +The rig starts an isolated seeded Paperclip instance, runs five samples for each scenario/profile, and writes median-ready raw data plus a Markdown table to `test-results/issue-detail-perf/`. + +Scenarios: + +- S1 warm in-app navigation: loads the Issues list, clears the waterfall, and clicks the seeded issue. +- S2 cold open: creates a fresh browser context and deep-links to the seeded issue. + +Profiles: + +- Unthrottled. +- Fast 4G network with 4x CPU slowdown. + +Override the sample count (minimum five) or port when needed: + +```sh +PAPERCLIP_ISSUE_PERF_RUNS=7 PAPERCLIP_ISSUE_PERF_PORT=3210 pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts +``` + +Outputs include `baseline.md`, `baseline.json`, and a Chrome trace for the first run of each scenario/profile. Open `*.trace.json` in Chrome DevTools Performance to inspect the `issue-detail:*` user-timing marks. diff --git a/tests/perf/issue-detail/issue-detail.perf.spec.ts b/tests/perf/issue-detail/issue-detail.perf.spec.ts new file mode 100644 index 0000000000..66cc8df968 --- /dev/null +++ b/tests/perf/issue-detail/issue-detail.perf.spec.ts @@ -0,0 +1,339 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium, expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; + +const requestedRuns = Number(process.env.PAPERCLIP_ISSUE_PERF_RUNS ?? 5); +const RUNS = Number.isFinite(requestedRuns) ? Math.max(5, Math.floor(requestedRuns)) : 5; +const OUTPUT_DIR = path.resolve(process.cwd(), "test-results/issue-detail-perf"); +const PAGE_READY_TIMEOUT_MS = 90_000; +const HEADER_MEASURE = "issue-detail:navigate→header-paint"; +const CONTENT_MEASURE = "issue-detail:navigate→content-paint"; + +type Profile = { + name: "unthrottled" | "fast-4g-4x-cpu"; + latencyMs?: number; + downloadBytesPerSecond?: number; + uploadBytesPerSecond?: number; + cpuSlowdownRate?: number; +}; + +type NetworkRecord = { + requestId: string; + url: string; + method: string; + type: string; + encodedDataLength: number; + mimeType?: string; + status?: number; + responseHeaders?: Record; + ttfbMs?: number; + completedAtEpochMs?: number; +}; + +type RunMetrics = { + scenario: "S1 warm in-app navigation" | "S2 cold open"; + profile: Profile["name"]; + run: number; + headerPaintMs: number; + contentPaintMs: number; + ttfbMs: number | null; + fcpMs: number | null; + lcpMs: number | null; + requestCountBeforeContentPaint: number; + apiRequestCountBeforeContentPaint: number; + bytesBeforeContentPaint: number; + jsBytesBeforeContentPaint: number; + issueApiTtfbMs: number | null; + issueApiServerTiming: string | null; +}; + +type Seed = { + companyId: string; + prefix: string; + issueId: string; + identifier: string; + title: string; +}; + +const PROFILES: Profile[] = [ + { name: "unthrottled" }, + { + name: "fast-4g-4x-cpu", + latencyMs: 50, + downloadBytesPerSecond: Math.floor(9 * 1024 * 1024 / 8), + uploadBytesPerSecond: Math.floor(1.5 * 1024 * 1024 / 8), + cpuSlowdownRate: 4, + }, +]; + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +function formatMs(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)} ms`; +} + +function formatBytes(value: number): string { + return `${(value / 1024).toFixed(1)} KiB`; +} + +async function seed(request: Page["request"]): Promise { + const companyResponse = await request.post("/api/companies", { + data: { name: `Issue Perf ${Date.now()}` }, + }); + expect(companyResponse.ok(), await companyResponse.text()).toBe(true); + const company = await companyResponse.json(); + + const title = `Issue detail performance baseline ${Date.now()}`; + const issueResponse = await request.post(`/api/companies/${company.id}/issues`, { + data: { + title, + description: "A seeded issue description used to measure the first meaningful issue-detail content paint.\n\n".repeat(12), + priority: "high", + status: "todo", + }, + }); + expect(issueResponse.ok(), await issueResponse.text()).toBe(true); + const issue = await issueResponse.json(); + + for (let index = 1; index <= 12; index += 1) { + const commentResponse = await request.post(`/api/issues/${issue.id}/comments`, { + data: { body: `Seeded performance comment ${index}: ${"content ".repeat(24)}` }, + }); + expect(commentResponse.ok(), await commentResponse.text()).toBe(true); + } + + return { + companyId: company.id, + prefix: company.issuePrefix ?? company.prefix, + issueId: issue.id, + identifier: issue.identifier, + title, + }; +} + +async function configureProfile(context: BrowserContext, page: Page, profile: Profile) { + const session = await context.newCDPSession(page); + await session.send("Network.enable"); + if (profile.latencyMs !== undefined) { + await session.send("Network.emulateNetworkConditions", { + offline: false, + latency: profile.latencyMs, + downloadThroughput: profile.downloadBytesPerSecond, + uploadThroughput: profile.uploadBytesPerSecond, + connectionType: "cellular4g", + }); + await session.send("Emulation.setCPUThrottlingRate", { rate: profile.cpuSlowdownRate }); + } + return session; +} + +async function installVitalObserver(page: Page, cold: boolean): Promise { + await page.addInitScript(({ coldStart }) => { + if (coldStart) window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = 0; + (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = undefined; + try { + new PerformanceObserver((list) => { + const latest = list.getEntries().at(-1); + if (latest) (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = latest.startTime; + }).observe({ type: "largest-contentful-paint", buffered: true }); + } catch {} + }, { coldStart: cold }); +} + +async function startNetworkCapture(session: Awaited>) { + const records = new Map(); + let cdpEpochOffsetMs: number | null = null; + session.on("Network.requestWillBeSent", (event) => { + cdpEpochOffsetMs ??= event.wallTime * 1000 - event.timestamp * 1000; + records.set(event.requestId, { + requestId: event.requestId, + url: event.request.url, + method: event.request.method, + type: event.type ?? "Other", + encodedDataLength: 0, + }); + }); + session.on("Network.responseReceived", (event) => { + const record = records.get(event.requestId); + if (!record) return; + record.mimeType = event.response.mimeType; + record.status = event.response.status; + record.responseHeaders = Object.fromEntries( + Object.entries(event.response.headers).map(([key, value]) => [key.toLowerCase(), String(value)]), + ); + if (event.response.timing) { + record.ttfbMs = event.response.timing.receiveHeadersEnd - event.response.timing.sendStart; + } + }); + session.on("Network.loadingFinished", (event) => { + const record = records.get(event.requestId); + if (!record) return; + record.encodedDataLength = event.encodedDataLength; + if (cdpEpochOffsetMs !== null) record.completedAtEpochMs = cdpEpochOffsetMs + event.timestamp * 1000; + }); + return records; +} + +async function writeTrace(tracePath: string, metrics: RunMetrics, records: Map) { + await fs.mkdir(path.dirname(tracePath), { recursive: true }); + const traceEvents = [ + { name: "issue-detail:navigate", cat: "blink.user_timing", ph: "i", ts: 0, pid: 1, tid: 1, s: "t" }, + { name: HEADER_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.headerPaintMs * 1000, pid: 1, tid: 1 }, + { name: CONTENT_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.contentPaintMs * 1000, pid: 1, tid: 1 }, + ...[...records.values()].map((record, index) => ({ + name: record.url, + cat: "loading", + ph: "i", + ts: index + 1, + pid: 1, + tid: 2, + s: "t", + args: { method: record.method, type: record.type, bytes: record.encodedDataLength, status: record.status }, + })), + ]; + await fs.writeFile(tracePath, JSON.stringify({ traceEvents })); +} + +async function readPaintMetrics(page: Page) { + await page.waitForFunction((measureName) => performance.getEntriesByName(measureName).length > 0, CONTENT_MEASURE); + await page.waitForTimeout(150); + return page.evaluate(({ headerMeasure, contentMeasure }) => { + const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + const fcp = performance.getEntriesByName("first-contentful-paint")[0]; + return { + headerPaintMs: performance.getEntriesByName(headerMeasure)[0]?.duration ?? NaN, + contentPaintMs: performance.getEntriesByName(contentMeasure)[0]?.duration ?? NaN, + ttfbMs: navigation ? navigation.responseStart - navigation.requestStart : null, + fcpMs: fcp?.startTime ?? null, + lcpMs: (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp ?? null, + contentPaintEpochMs: performance.timeOrigin + (performance.getEntriesByName(contentMeasure)[0]?.startTime ?? 0) + (performance.getEntriesByName(contentMeasure)[0]?.duration ?? 0), + }; + }, { headerMeasure: HEADER_MEASURE, contentMeasure: CONTENT_MEASURE }); +} + +function summarizeNetwork(records: Map, seedData: Seed, contentPaintEpochMs: number) { + const completed = [...records.values()].filter((record) => + record.encodedDataLength > 0 + && record.completedAtEpochMs !== undefined + && record.completedAtEpochMs <= contentPaintEpochMs + ); + const api = completed.filter((record) => new URL(record.url).pathname.startsWith("/api/")); + const scripts = completed.filter((record) => record.type === "Script" || record.mimeType?.includes("javascript")); + const issueApi = completed.find((record) => { + const pathname = new URL(record.url).pathname; + return record.method === "GET" && (pathname === `/api/issues/${seedData.issueId}` || pathname === `/api/issues/${seedData.identifier}`); + }); + return { + requestCountBeforeContentPaint: completed.length, + apiRequestCountBeforeContentPaint: api.length, + bytesBeforeContentPaint: completed.reduce((sum, record) => sum + record.encodedDataLength, 0), + jsBytesBeforeContentPaint: scripts.reduce((sum, record) => sum + record.encodedDataLength, 0), + issueApiTtfbMs: issueApi?.ttfbMs ?? null, + issueApiServerTiming: issueApi?.responseHeaders?.["server-timing"] ?? null, + }; +} + +async function runScenario(browser: Browser, baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise { + const context = await browser.newContext({ baseURL }); + const page = await context.newPage(); + await installVitalObserver(page, scenario.startsWith("S2")); + const session = await configureProfile(context, page, profile); + const network = await startNetworkCapture(session); + const tracePath = path.join(OUTPUT_DIR, `${scenario.slice(0, 2).toLowerCase()}-${profile.name}-run-${run}.trace.json`); + + if (scenario.startsWith("S1")) { + await page.goto(`/${seedData.prefix}/issues`); + const issueLink = page.locator("[data-inbox-issue-link]", { hasText: seedData.title }).first(); + await expect(issueLink).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS }); + network.clear(); + await page.evaluate(() => { + window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = performance.now(); + }); + await issueLink.click(); + } else { + await page.goto(`/${seedData.prefix}/issues/${seedData.identifier}`); + } + + await expect(page.getByTestId("issue-detail-header")).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS }); + const paint = await readPaintMetrics(page); + const summary = summarizeNetwork(network, seedData, paint.contentPaintEpochMs); + const { contentPaintEpochMs: _contentPaintEpochMs, ...reportedPaint } = paint; + const scenarioPaint = scenario.startsWith("S1") + ? { ...reportedPaint, ttfbMs: null, fcpMs: null, lcpMs: null } + : reportedPaint; + const metrics = { scenario, profile: profile.name, run, ...scenarioPaint, ...summary }; + if (run === 1) await writeTrace(tracePath, metrics, network); + if (scenario.startsWith("S2") && profile.name === "unthrottled" && run === 1) { + await page.screenshot({ path: path.join(OUTPUT_DIR, "issue-detail-loaded.png"), fullPage: true }); + } + await context.close(); + + expect(Number.isFinite(paint.headerPaintMs)).toBe(true); + expect(Number.isFinite(paint.contentPaintMs)).toBe(true); + return metrics; +} + +async function runScenarioWithBrowserRetry(baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise { + for (let attempt = 1; attempt <= 2; attempt += 1) { + const sampleBrowser = await chromium.launch(); + try { + return await runScenario(sampleBrowser, baseURL, seedData, profile, scenario, run); + } catch (error) { + const browserClosed = error instanceof Error + && (/Target page, context or browser has been closed/.test(error.message) || /Channel closed/.test(error.message)); + if (!browserClosed || attempt === 2) throw error; + } finally { + await sampleBrowser.close().catch(() => undefined); + } + } + throw new Error("Unreachable browser retry state"); +} + +function buildMarkdown(results: RunMetrics[]): string { + const rows = PROFILES.flatMap((profile) => ["S1 warm in-app navigation", "S2 cold open"].map((scenario) => { + const samples = results.filter((result) => result.profile === profile.name && result.scenario === scenario); + const nullableMedian = (values: Array) => { + const present = values.filter((value): value is number => value !== null); + return present.length > 0 ? median(present) : null; + }; + const serverTimingMs = nullableMedian(samples.map((sample) => { + const match = sample.issueApiServerTiming?.match(/dur=([0-9.]+)/); + return match ? Number(match[1]) : null; + })); + return `| ${scenario} | ${profile.name} | ${samples.length} | ${formatMs(median(samples.map((sample) => sample.headerPaintMs)))} | ${formatMs(median(samples.map((sample) => sample.contentPaintMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.ttfbMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.fcpMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.lcpMs)))} | ${Math.round(median(samples.map((sample) => sample.requestCountBeforeContentPaint)))} | ${Math.round(median(samples.map((sample) => sample.apiRequestCountBeforeContentPaint)))} | ${formatBytes(median(samples.map((sample) => sample.bytesBeforeContentPaint)))} | ${formatBytes(median(samples.map((sample) => sample.jsBytesBeforeContentPaint)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.issueApiTtfbMs)))} | ${formatMs(serverTimingMs)} |`; + })); + return [ + "# Issue-detail performance baseline", + "", + `Runs per scenario/profile: ${RUNS}`, + "", + "| Scenario | Profile | N | Header paint | Content paint | TTFB | FCP | LCP | Requests before content | API requests before content | Bytes before content | JS bytes before content | GET issue TTFB | GET issue server timing |", + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ...rows, + "", + "Raw samples and Chrome traces are in the same output directory.", + ].join("\n"); +} + +test("issue-detail baseline", async ({ request, baseURL }) => { + expect(baseURL).toBeTruthy(); + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + const seedData = await seed(request); + const results: RunMetrics[] = []; + + for (const profile of PROFILES) { + for (let run = 1; run <= RUNS; run += 1) { + results.push(await runScenarioWithBrowserRetry(baseURL!, seedData, profile, "S1 warm in-app navigation", run)); + results.push(await runScenarioWithBrowserRetry(baseURL!, seedData, profile, "S2 cold open", run)); + } + } + + const report = buildMarkdown(results); + await fs.writeFile(path.join(OUTPUT_DIR, "baseline.json"), JSON.stringify({ seed: seedData, results }, null, 2)); + await fs.writeFile(path.join(OUTPUT_DIR, "baseline.md"), report); + console.log(`\n${report}\n`); +}); diff --git a/tests/perf/issue-detail/playwright.config.ts b/tests/perf/issue-detail/playwright.config.ts new file mode 100644 index 0000000000..fe478fc8af --- /dev/null +++ b/tests/perf/issue-detail/playwright.config.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_ISSUE_PERF_PORT ?? 3201); +const BASE_URL = `http://127.0.0.1:${PORT}`; +const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issue-perf-home-")); +const PAPERCLIP_INSTANCE_ID = "playwright-issue-perf"; +const PAPERCLIP_CONFIG = path.join(PAPERCLIP_HOME, "instances", PAPERCLIP_INSTANCE_ID, "config.json"); + +process.env.PAPERCLIP_HOME = PAPERCLIP_HOME; +process.env.PAPERCLIP_CONFIG = PAPERCLIP_CONFIG; + +export default defineConfig({ + testDir: ".", + testMatch: "issue-detail.perf.spec.ts", + timeout: 30 * 60_000, + workers: 1, + fullyParallel: false, + use: { + baseURL: BASE_URL, + browserName: "chromium", + headless: true, + }, + webServer: { + command: "pnpm paperclipai onboard --yes --run", + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + NODE_ENV: "development", + PORT: String(PORT), + PAPERCLIP_HOME, + PAPERCLIP_INSTANCE_ID, + PAPERCLIP_CONFIG, + PAPERCLIP_AGENT_JWT_SECRET: "playwright-issue-perf-agent-jwt-secret", + PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "playwright-issue-perf-tool-action-signing-secret", + PAPERCLIP_BIND: "loopback", + PAPERCLIP_DEPLOYMENT_MODE: "local_trusted", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", + }, + }, + outputDir: "../../../test-results/issue-detail-perf/playwright", + reporter: [["list"]], +}); diff --git a/ui/src/lib/issue-detail-performance.ts b/ui/src/lib/issue-detail-performance.ts new file mode 100644 index 0000000000..eb493f2948 --- /dev/null +++ b/ui/src/lib/issue-detail-performance.ts @@ -0,0 +1,99 @@ +export const ISSUE_DETAIL_NAVIGATE_MARK = "issue-detail:navigate"; +export const ISSUE_DETAIL_HEADER_PAINT_MARK = "issue-detail:header-paint"; +export const ISSUE_DETAIL_CONTENT_PAINT_MARK = "issue-detail:content-paint"; +export const ISSUE_DETAIL_HEADER_MEASURE = "issue-detail:navigate→header-paint"; +export const ISSUE_DETAIL_CONTENT_MEASURE = "issue-detail:navigate→content-paint"; + +let issueDetailNavigationGeneration = 0; + +declare global { + interface Window { + __PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__?: number; + } +} + +function supportsPerformanceMarks(): boolean { + return typeof window !== "undefined" && typeof performance !== "undefined" && typeof performance.mark === "function"; +} + +export function beginIssueDetailNavigation(): void { + issueDetailNavigationGeneration += 1; + if (!supportsPerformanceMarks()) return; + + performance.clearMarks(ISSUE_DETAIL_NAVIGATE_MARK); + performance.clearMarks(ISSUE_DETAIL_HEADER_PAINT_MARK); + performance.clearMarks(ISSUE_DETAIL_CONTENT_PAINT_MARK); + performance.clearMeasures(ISSUE_DETAIL_HEADER_MEASURE); + performance.clearMeasures(ISSUE_DETAIL_CONTENT_MEASURE); + + const externallyCapturedStart = window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__; + delete window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__; + performance.mark(ISSUE_DETAIL_NAVIGATE_MARK, { + startTime: typeof externallyCapturedStart === "number" ? externallyCapturedStart : performance.now(), + }); +} + +export function scheduleIssueDetailPaintMeasure( + markName: typeof ISSUE_DETAIL_HEADER_PAINT_MARK | typeof ISSUE_DETAIL_CONTENT_PAINT_MARK, + measureName: typeof ISSUE_DETAIL_HEADER_MEASURE | typeof ISSUE_DETAIL_CONTENT_MEASURE, +): void { + if (!supportsPerformanceMarks() || performance.getEntriesByName(measureName).length > 0) return; + + const navigationGeneration = issueDetailNavigationGeneration; + requestAnimationFrame(() => { + if (navigationGeneration !== issueDetailNavigationGeneration) return; + requestAnimationFrame(() => { + if (navigationGeneration !== issueDetailNavigationGeneration) return; + if (performance.getEntriesByName(measureName).length > 0) return; + if (performance.getEntriesByName(ISSUE_DETAIL_NAVIGATE_MARK).length === 0) { + performance.mark(ISSUE_DETAIL_NAVIGATE_MARK); + } + performance.mark(markName); + performance.measure(measureName, ISSUE_DETAIL_NAVIGATE_MARK, markName); + }); + }); +} + +type VitalMetric = { + name: "TTFB" | "LCP" | "INP"; + value: number; +}; + +function reportVital(metric: VitalMetric): void { + console.info("[issue-detail:web-vital]", metric); +} + +export function reportIssueDetailWebVitals(): () => void { + if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return () => undefined; + + const observers: PerformanceObserver[] = []; + const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + if (navigation) { + reportVital({ name: "TTFB", value: navigation.responseStart - navigation.requestStart }); + } + + try { + const lcpObserver = new PerformanceObserver((list) => { + const latest = list.getEntries().at(-1); + if (latest) reportVital({ name: "LCP", value: latest.startTime }); + }); + lcpObserver.observe({ type: "largest-contentful-paint", buffered: true }); + observers.push(lcpObserver); + } catch {} + + try { + let longestInteraction = 0; + const inpObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const interactionEntry = entry as PerformanceEventTiming & { interactionId?: number }; + if (!interactionEntry.interactionId) continue; + longestInteraction = Math.max(longestInteraction, interactionEntry.duration); + } + if (longestInteraction > 0) reportVital({ name: "INP", value: longestInteraction }); + }); + inpObserver.observe({ type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit); + observers.push(inpObserver); + } catch {} + + return () => observers.forEach((observer) => observer.disconnect()); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 5696c9f015..336b7f27ac 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; @@ -40,6 +40,15 @@ import { } from "../lib/issueDetailBreadcrumb"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; +import { + beginIssueDetailNavigation, + ISSUE_DETAIL_CONTENT_MEASURE, + ISSUE_DETAIL_CONTENT_PAINT_MARK, + ISSUE_DETAIL_HEADER_MEASURE, + ISSUE_DETAIL_HEADER_PAINT_MARK, + reportIssueDetailWebVitals, + scheduleIssueDetailPaintMeasure, +} from "../lib/issue-detail-performance"; import { beginLocalInboxArchive, boundLocalInboxArchive, @@ -1712,6 +1721,25 @@ export function IssueDetail() { () => flattenIssueCommentPages(commentPages?.pages), [commentPages?.pages], ); + + useLayoutEffect(() => { + beginIssueDetailNavigation(); + }, [issueId]); + + useEffect(() => { + if (!(import.meta.env.DEV || import.meta.env.MODE === "qa")) return; + return reportIssueDetailWebVitals(); + }, [issueId]); + + useEffect(() => { + if (!issue) return; + scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_HEADER_PAINT_MARK, ISSUE_DETAIL_HEADER_MEASURE); + }, [issue?.id]); + + useEffect(() => { + if (!issue || commentsLoading) return; + scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE); + }, [commentsLoading, issue?.id]); const shouldPrefetchOlderComments = useMemo( () => shouldAutoloadOlderIssueComments({ @@ -4259,7 +4287,10 @@ export function IssueDetail() { ); const issueHeaderBlock = ( -
+