perf: add repeatable issue-detail baseline rig (#10409)
## 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 <noreply@paperclip.ing> Co-authored-by: Dev Agent <dev@paperclip.ing>
This commit is contained in:
parent
5bb2490b86
commit
2494a2a0fe
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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<string, string>;
|
||||
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<Seed> {
|
||||
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<void> {
|
||||
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<ReturnType<typeof configureProfile>>) {
|
||||
const records = new Map<string, NetworkRecord>();
|
||||
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<string, NetworkRecord>) {
|
||||
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<string, NetworkRecord>, 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<RunMetrics> {
|
||||
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<RunMetrics> {
|
||||
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<number | null>) => {
|
||||
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`);
|
||||
});
|
||||
|
|
@ -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"]],
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 = (
|
||||
<div className={cn("space-y-3", shellSectionClass)}>
|
||||
<div
|
||||
data-testid="issue-detail-header"
|
||||
className={cn("space-y-3", shellSectionClass)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
|
|
@ -4990,6 +5021,7 @@ export function IssueDetail() {
|
|||
(symmetric, so the centered column keeps the same axis) and the
|
||||
scrollbar sits flush against the properties-pane border. */}
|
||||
<TabsContent
|
||||
data-testid="issue-detail-content"
|
||||
value="chat"
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
|
|
|
|||
Loading…
Reference in New Issue