blob: 3f6bef557b034c696c21b236c5ece186ac76234a [file]
/*
* 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 { test, expect, type CDPSession, type Locator, type Page } from '@playwright/test';
import { withE2eWindow, COMPOSER_INPUT } from '../../apps/desktop/e2e/fixtures';
import { report, summarize } from './report.mjs';
const warmups = 30;
const streams = 5;
const idleSeconds = 5;
const rows: Array<Record<string, unknown>> = [];
async function activate(locator: Locator) {
await expect(locator).toBeVisible();
await expect(locator).toBeEnabled();
await locator.evaluate((el) => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
});
}
async function input(page: Page, text: string) {
await page.locator(COMPOSER_INPUT).evaluate((el, value) => {
(el as HTMLElement).focus();
el.textContent = value;
el.dispatchEvent(
new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }),
);
}, text);
await expect(page.locator(COMPOSER_INPUT)).toHaveText(text);
}
function row(scenario: string, metric: string, values: number[]) {
rows.push({ scenario, metric, ...summarize(values) });
}
type Metrics = Record<string, number>;
async function metrics(cdp: CDPSession): Promise<Metrics> {
const { metrics } = await cdp.send('Performance.getMetrics');
return Object.fromEntries(metrics.map((m) => [m.name, m.value]));
}
/** Per-second rates between two CDP samples, normalized by their own timestamps. */
function rates(before: Metrics, after: Metrics) {
const seconds = after.Timestamp - before.Timestamp;
const per = (name: string, scale = 1) => ((after[name] - before[name]) * scale) / seconds;
return {
'style-recalcs-per-second': per('RecalcStyleCount'),
'layouts-per-second': per('LayoutCount'),
'renderer-task-ms-per-second': per('TaskDuration', 1000),
'script-ms-per-second': per('ScriptDuration', 1000),
};
}
test.afterEach(async ({}, info) => {
if (rows.length)
await report(
'frontend-streaming-render',
{
status: info.status,
fixture: `fresh seeded session with ${warmups} short prompts, normal fake stream (9 characters/45ms)`,
repetitions: streams,
viewport: '1400x900',
theme: 'light',
motion: 'no-preference',
conditions: `One fresh Electron + real Host. Sidebar expanded. ${warmups} short prompts build the prompt rail, then ${streams} sequential streams of the same 679-character prompt, each measured from the first delta until the streaming bubble is gone; then ${idleSeconds} one-second idle samples.`,
limits:
'Counts and CDP renderer-thread time, not power, GPU frames or wakeups. xvfb drives frames at 60Hz; a 120Hz display doubles per-frame costs. Compositor-only animations do not show in style recalcs, hence the separate running-animation count.',
},
rows,
);
});
test('streaming in a session with a long prompt rail, then idle', {
annotation: { type: 'perf-report', description: 'frontend-streaming-render' },
}, async () => {
test.setTimeout(180_000);
await withE2eWindow(
{
seed: true,
readinessSelector: COMPOSER_INPUT,
locale: 'zh-CN',
showWindow: true,
},
async (page) => {
await page.setViewportSize({ width: 1400, height: 900 });
await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'no-preference' });
expect(
await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
).toBe(false);
const expand = page.getByRole('button', { name: '展开侧边栏', exact: true });
if (await expand.isVisible()) await activate(expand);
const send = async (text: string) => {
await input(page, text);
await activate(page.getByRole('button', { name: '发送', exact: true }));
};
const streaming = page.locator('.maka-bubble-streaming');
for (let i = 0; i < warmups; i++) {
await send('performance warmup ' + i);
await expect(page.getByRole('log')).toContainText(
'Fake backend received: performance warmup ' + i,
{ timeout: 20_000 },
);
await expect(streaming).toHaveCount(0);
}
await expect(page.locator('.maka-prompt-rail-tick')).toHaveCount(warmups);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await page.evaluate(() => {
const counts = { style: 0, railStyle: 0 };
(window as any).__perfStyleWrites = counts;
new MutationObserver((records) => {
for (const record of records) {
counts.style++;
if ((record.target as Element).closest('.maka-prompt-rail')) counts.railStyle++;
}
}).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });
});
const styleWrites = () =>
page.evaluate(
() => ({ ...(window as any).__perfStyleWrites }) as { style: number; railStyle: number },
);
const infiniteAnimations = () =>
page.evaluate(
() =>
document
.getAnimations()
.filter(
(animation) =>
animation.playState === 'running' &&
animation.effect?.getComputedTiming().endTime === Infinity,
).length,
);
const prompt = ('performance fixture ' + 'abcdefghij '.repeat(60)).trimEnd();
const perStream: Record<string, number[]> = {};
const push = (metric: string, value: number) => (perStream[metric] ??= []).push(value);
for (let i = 0; i < streams; i++) {
await send(prompt);
await expect(streaming).toContainText('Fake backend received');
const before = await metrics(cdp);
const writesBefore = await styleWrites();
let animations = 0;
while (await streaming.count()) {
animations = Math.max(animations, await infiniteAnimations());
await page.waitForTimeout(250);
}
const after = await metrics(cdp);
const writesAfter = await styleWrites();
const seconds = after.Timestamp - before.Timestamp;
expect(seconds).toBeGreaterThan(1);
for (const [metric, value] of Object.entries(rates(before, after))) push(metric, value);
push(
'style-attribute-writes-per-second',
(writesAfter.style - writesBefore.style) / seconds,
);
push(
'prompt-rail-style-writes-per-second',
(writesAfter.railStyle - writesBefore.railStyle) / seconds,
);
push('max-running-infinite-animations', animations);
await expect(page.locator('[data-turn-id]').last()).toContainText(
'renderer loop are connected.',
);
}
for (const [metric, values] of Object.entries(perStream)) row('streaming', metric, values);
await page.waitForTimeout(1000);
const idle: Record<string, number[]> = {};
let before = await metrics(cdp);
for (let sample = 0; sample < idleSeconds; sample++) {
await page.waitForTimeout(1000);
const after = await metrics(cdp);
for (const [metric, value] of Object.entries(rates(before, after)))
(idle[metric] ??= []).push(value);
before = after;
}
idle['running-infinite-animations'] = [await infiniteAnimations()];
for (const [metric, values] of Object.entries(idle))
row('idle-after-streaming', metric, values);
await cdp.detach();
},
);
});