You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/10 10:48:23 UTC
(maka) branch main updated: perf(desktop): add a Usage tab-entry measurement spec and a 409-record fixture (#5804)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 6b61c55283 perf(desktop): add a Usage tab-entry measurement spec and a 409-record fixture (#5804)
6b61c55283 is described below
commit 6b61c5528350ec60e8cfd1f5a366875f01745b8e
Author: 小七 <gg...@gmail.com>
AuthorDate: Sat Oct 10 18:48:18 2026 +0800
perf(desktop): add a Usage tab-entry measurement spec and a 409-record fixture (#5804)
Why: #4677 item 4 asks for a Usage settings tab-entry median/p95 number plus
renderer-side evidence that the activity table mounts a bounded page instead
of every row.
Fixture (test infra only):
- scenarios-usage.ts pads the 5 handcrafted turns with 394 zero-tool turns
(132/131/131 across the three sessions) so the ledger holds exactly 409
activity rows: 399 model rows + 10 tool rows. Synthetic turns reuse the
handcrafted generators on the fixed clock (60..453 minutes ago).
- e2e-fixture.ts: one catchUpModelCallProjection() pass bounds how many
lagging runs it folds, so a padded seed with hundreds of queued runs left
most of them unfolded after seeding. Fold in a loop until pendingRuns
reaches 0, which is generic for any large canonical seed.
Spec (perf/usage-tab-entry.spec.ts): a custom usageWindow fixture injects
the settings-usage scenario; the test asserts the bounded mount (DOM rows
<= 50 with the tab badge at 409) and then samples 12 warm re-entry rounds
entirely inside the renderer with rAF — the clock runs from the synthetic
click to the activity table's next painted frame, excluding Playwright
round-trips. Results land in $TEMP/usage-tab-entry-results.json.
Numbers (this branch, apache/main base): median 103.9 ms, p95 142.6 ms
(n=12, p95 = max), first-entry DOM rows 50/409. Reference numbers from
#4677 (comment issuecomment-5880047554, measured on a feature branch):
median 134.1 ms, p95 201.3 ms — same magnitude; the branch difference is
noted rather than compared.
Generated-by: GLM-5.3-Flash (ZCode)
fix(desktop): carry the poll budget into the renderer and fix the even-sample median
Two review fixes on the usage-tab-entry perf spec:
- Playwright serializes page.evaluate callbacks without their Node-side
closure, so the MOUNT_POLL_FRAMES guard referenced an undefined constant
inside the renderer: the first poll reaching it threw a ReferenceError
from a requestAnimationFrame callback, leaving the surrounding Promise
pending forever instead of rejecting after 600 frames. Both the unmount
and the entry poll now receive the budget as an explicit evaluate
argument, so the slow path rejects as designed.
- The 12-round summary picked sorted[n/2], the upper middle value, as the
median. For an even-sized sample the median is the mean of the two
middle values; the summary now averages sorted[n/2 - 1] and sorted[n/2]
for even runs (odd runs keep the single middle value).
The PR body's measured numbers were refreshed from a new run of the
corrected spec.
Refs #4677
Generated-by: GLM-5.3-Flash (ZCode)
ci: retrigger the cancelled test run
Merge remote-tracking branch 'upstream/main' into perf/usage-tab-entry-spec
---
apps/desktop/perf/usage-tab-entry.spec.ts | 189 +++++++++++++++++++++
apps/desktop/src/main/e2e-fixture.ts | 7 +-
.../src/main/e2e-fixture/scenarios-usage.ts | 30 ++++
3 files changed, 225 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/perf/usage-tab-entry.spec.ts b/apps/desktop/perf/usage-tab-entry.spec.ts
new file mode 100644
index 0000000000..c572afebd0
--- /dev/null
+++ b/apps/desktop/perf/usage-tab-entry.spec.ts
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { ElectronApplication, Page } from '@playwright/test';
+import { expect, test as base, withE2eWindow } from '../e2e/fixtures';
+
+// #4677 item 4: weigh leaving and re-entering the Usage settings section with
+// a ledger that would once have mounted every activity row. The table is a
+// native <table> (BaseTable), the section tabs render as buttons inside a
+// plain <nav> (no ARIA tabs pattern), and the nav items carry stable
+// assistant targets — so the measurement never leans on the tabs' roles.
+const ACTIVITY_TABLE = '[aria-label="使用统计活动记录表"]';
+const ACTIVITY_TABLE_LOCATOR = `${ACTIVITY_TABLE} tbody tr`;
+const USAGE_NAV = '[data-maka-assistant-target="settings.usage"]';
+const GENERAL_NAV = '[data-maka-assistant-target="settings.general"]';
+const TABS_NAV = '[aria-label="使用统计视图"]';
+const REQUESTS_TAB = /活动记录/;
+/** #4677 item 4: the fixture pads the handcrafted turns to exactly this many activity rows. */
+const EXPECTED_ACTIVITY_ROWS = 409;
+/** usage-settings-view's USAGE_REQUESTS_PAGE_SIZE. */
+const PAGE_SIZE = 50;
+const ROUNDS = 12;
+/**
+ * Frames the in-renderer poll may wait per phase before giving up. Playwright
+ * serializes page.evaluate callbacks without their Node-side closure, so this
+ * must be passed into each callback as an argument.
+ */
+const MOUNT_POLL_FRAMES = 600;
+
+interface UsageWindow {
+ page: Page;
+ app: ElectronApplication;
+}
+
+const test = base.extend<{ usageWindow: UsageWindow }>({
+ usageWindow: async ({}, use, testInfo) => {
+ await withE2eWindow(
+ {
+ testInfo,
+ seed: false,
+ readinessSelector: '[data-maka-contract="settings-sidebar"]',
+ readinessTimeoutMs: 60_000,
+ e2eFixtureScenario: 'settings-usage',
+ locale: 'zh-CN',
+ // rAF is throttled in hidden windows, and the entry sample ends on a
+ // painted frame, so the window must be visible.
+ showWindow: true,
+ },
+ async (page, { app }) => use({ page, app }),
+ );
+ },
+});
+
+test('usage activity tab entry mounts one bounded page of 409 total rows', async ({
+ usageWindow: { page },
+}) => {
+ await page.setViewportSize({ width: 1_400, height: 900 });
+
+ // The fixture's settings.json enables usage details and the 'all' range, so
+ // the activity table renders as soon as the Usage section is open. Navigate
+ // there explicitly anyway: the section can drift after mount, and every
+ // measured entry below re-clicks the same nav item regardless of where the
+ // shell currently sits.
+ await page.locator(USAGE_NAV).first().click();
+ const table = page.locator(ACTIVITY_TABLE);
+ await expect(table).toBeVisible({ timeout: 30_000 });
+
+ // Bounded mount: paginateData puts only the current page's rows in the DOM,
+ // however many rows the ledger holds; the tab badge carries the total.
+ const domRows = await page.locator(ACTIVITY_TABLE_LOCATOR).count();
+ console.log(JSON.stringify({ stage: 'bounded mount', domRows, expectedTotal: EXPECTED_ACTIVITY_ROWS }));
+ expect(domRows, 'the activity table must not mount more than one page').toBeLessThanOrEqual(
+ PAGE_SIZE,
+ );
+ const badge = page.locator(TABS_NAV).getByRole('button', { name: REQUESTS_TAB });
+ const badgeDigits = ((await badge.textContent({ timeout: 10_000 })) ?? '').replace(/\D/g, '');
+ expect(badgeDigits, 'the tab badge must still report every activity row').toBe(
+ String(EXPECTED_ACTIVITY_ROWS),
+ );
+
+ // #4531's repro: leave the Usage section and come back. Each round runs
+ // entirely inside the renderer — the clock starts at the synthetic click and
+ // stops at the mounted table's next painted frame — so it excludes
+ // Playwright round-trips and waits only for what the user would wait for.
+ // Clicking the same nav items every round keeps the measurement immune to
+ // whatever section the shell drifted to between rounds.
+ const entries: number[] = [];
+ for (let round = 1; round <= ROUNDS; round += 1) {
+ const leftMs = await page.evaluate(
+ ([tableSelector, navSelector, pollBudgetFrames]) =>
+ new Promise<number>((resolve, reject) => {
+ const t0 = performance.now();
+ const nav = document.querySelector(navSelector);
+ if (!(nav instanceof HTMLElement)) {
+ reject(new Error('general nav item not found'));
+ return;
+ }
+ nav.click();
+ let frames = 0;
+ const poll = () => {
+ if (!document.querySelector(tableSelector)) {
+ requestAnimationFrame(() => resolve(performance.now() - t0));
+ return;
+ }
+ frames += 1;
+ if (frames > pollBudgetFrames) {
+ reject(new Error('activity table never unmounted'));
+ return;
+ }
+ requestAnimationFrame(poll);
+ };
+ requestAnimationFrame(poll);
+ }),
+ [ACTIVITY_TABLE, GENERAL_NAV, MOUNT_POLL_FRAMES],
+ );
+ const entryMs = await page.evaluate(
+ ([tableSelector, navSelector, pollBudgetFrames]) =>
+ new Promise<number>((resolve, reject) => {
+ const t0 = performance.now();
+ const nav = document.querySelector(navSelector);
+ if (!(nav instanceof HTMLElement)) {
+ reject(new Error('usage nav item not found'));
+ return;
+ }
+ nav.click();
+ let frames = 0;
+ const poll = () => {
+ if (document.querySelector(tableSelector)) {
+ requestAnimationFrame(() => resolve(performance.now() - t0));
+ return;
+ }
+ frames += 1;
+ if (frames > pollBudgetFrames) {
+ reject(new Error('activity table did not mount within the poll budget'));
+ return;
+ }
+ requestAnimationFrame(poll);
+ };
+ requestAnimationFrame(poll);
+ }),
+ [ACTIVITY_TABLE, USAGE_NAV, MOUNT_POLL_FRAMES],
+ );
+ entries.push(entryMs);
+ console.log(JSON.stringify({
+ stage: `re-entry ${round}`,
+ leaveMs: Number(leftMs.toFixed(1)),
+ entryMs: Number(entryMs.toFixed(1)),
+ }));
+ }
+
+ const sorted = [...entries].sort((left, right) => left - right);
+ const mid = Math.floor(sorted.length / 2);
+ // Even sample: the median is the mean of the two middle values, not the
+ // upper one.
+ const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+ const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
+ const summary = {
+ condition:
+ 'Real Electron, real Host, real storage. Warm re-entry: click off the Usage settings section (wait for the table to unmount), click back in; the clock runs from the click to the activity table’s next painted frame, sampled inside the renderer. Fixture: 409 activity rows (399 model + 10 tool) on the fixed clock; table page size 50.',
+ rounds: ROUNDS,
+ entriesMs: entries.map((value) => Number(value.toFixed(1))),
+ medianMs: Number(median.toFixed(1)),
+ p95Ms: Number(p95.toFixed(1)),
+ domRowsAfterFirstEntry: domRows,
+ expectedActivityRows: EXPECTED_ACTIVITY_ROWS,
+ };
+ const output = path.join(process.env.TEMP ?? 'perf-results', 'usage-tab-entry-results.json');
+ await mkdir(path.dirname(output), { recursive: true });
+ await writeFile(output, JSON.stringify(summary, null, 2));
+ console.log(JSON.stringify(summary));
+});
diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts
index bf99d7975c..9a9661efa8 100644
--- a/apps/desktop/src/main/e2e-fixture.ts
+++ b/apps/desktop/src/main/e2e-fixture.ts
@@ -302,7 +302,12 @@ export async function seedE2eFixture(input: {
await runStore.close?.();
// Fold the appended attempts into the read model so the page's first read
// sees canonical usage (production's readCanonicalUsage also repairs).
- await usage.modelCalls.catchUpModelCallProjection();
+ // One pass bounds how many lagging runs it processes, and the padded
+ // fixture creates hundreds of runs, so fold until nothing is pending.
+ for (;;) {
+ const projection = await usage.modelCalls.catchUpModelCallProjection();
+ if (projection.pendingRuns === 0) break;
+ }
await usage.flush();
} finally {
await usage.close();
diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
index c51430eb6e..7da6a0364a 100644
--- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
+++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
@@ -45,6 +45,33 @@ type PersistedToolInvocationRecord = Parameters<
// - 5 models (glm / claude / gpt families) → 模型统计
// - 6 tools with 2 failures → 工具统计 (exercises the error column)
// - a dozen activity rows mixing model + tool + success/error → 活动记录
+//
+// The handcrafted turns are padded to exactly 409 activity rows — the
+// unpaginated mount the #4677 tab-entry measurement weighs. Activity rows are
+// model rows plus tool rows, so the pad is 394 zero-tool turns (one token_usage
+// each, shared 132/131/131 across the three sessions): 394 + 5 handcrafted
+// model turns = 399 model rows, plus the 10 handcrafted tool rows = 409.
+// Synthetic turns reuse the handcrafted generators and stay on the fixed clock
+// minus a minutes offset, so every table stays deterministic.
+
+/**
+ * One synthetic turn contributes exactly one activity row (its token_usage),
+ * so the pad's arithmetic stays checkable: share out the turns across the
+ * three seeded sessions, models follow their session's connection.
+ */
+function syntheticUsageTurns(count: number, offset: number, model: string): UsageTurnSpec[] {
+ return Array.from({ length: count }, (_, index) => ({
+ turnId: `usage-synthetic-${String(offset + index).padStart(3, '0')}`,
+ minutesAgo: 60 + offset + index,
+ model,
+ usage: {
+ input: 1200 + ((offset + index) % 13) * 320,
+ output: 180 + ((offset + index) % 7) * 90,
+ costUsd: 0.0021,
+ },
+ tools: [],
+ }));
+}
interface UsageTurnSpec {
turnId: string;
@@ -173,6 +200,7 @@ export function usageStatsSessions(
{ id: 'usage-glm-2-write', toolName: 'Write', displayName: '写入文件', durationMs: 1_460, isError: true },
],
}),
+ ...syntheticUsageTurns(132, 0, 'glm-5.1').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
{
@@ -203,6 +231,7 @@ export function usageStatsSessions(
{ id: 'usage-claude-2-bash', toolName: 'Bash', displayName: '构建 renderer', durationMs: 5_200 },
],
}),
+ ...syntheticUsageTurns(131, 132, 'claude-sonnet-4.5').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
{
@@ -224,6 +253,7 @@ export function usageStatsSessions(
{ id: 'usage-gpt-1-grep', toolName: 'Grep', displayName: '扫描目录', durationMs: 720, isError: true },
],
}),
+ ...syntheticUsageTurns(131, 263, 'gpt-5.1-mini').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
];
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org