Skip to content

Commit b7c2949

Browse files
authored
Add synthetic p90 performance budget spec (#92)
* test: add synthetic p90 perf budget spec * Move perf budget spec into frontend Playwright suite
1 parent 8427074 commit b7c2949

File tree

1 file changed

+268
-0
lines changed

1 file changed

+268
-0
lines changed
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { test, expect } from '@playwright/test';
2+
import { readFileSync } from 'node:fs';
3+
import { resolve } from 'node:path';
4+
5+
type PerfMetric = {
6+
id: string;
7+
aggregation: string;
8+
threshold: number;
9+
unit: string;
10+
};
11+
12+
type PerfPage = {
13+
id: string;
14+
url: string;
15+
waits?: Array<Record<string, unknown>>;
16+
selectors?: Record<string, string>;
17+
metrics: PerfMetric[];
18+
};
19+
20+
type PerfBudget = {
21+
version: number;
22+
run_count: number;
23+
throttling?: Record<string, unknown>;
24+
pages: PerfPage[];
25+
};
26+
27+
type StackItem = {
28+
indent: number;
29+
container: any;
30+
type: 'object' | 'array';
31+
key?: string;
32+
};
33+
34+
function parseYaml(yaml: string): any {
35+
const lines = yaml.replace(/\r\n/g, '\n').split('\n');
36+
const root: Record<string, any> = {};
37+
const stack: StackItem[] = [{ indent: -1, container: root, type: 'object' }];
38+
39+
const parseScalar = (value: string): any => {
40+
if (value === 'true') return true;
41+
if (value === 'false') return false;
42+
if (value === 'null') return null;
43+
if (/^-?\d+(\.\d+)?$/.test(value)) {
44+
return Number(value);
45+
}
46+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
47+
if (value.startsWith('"')) {
48+
return JSON.parse(value);
49+
}
50+
const inner = value.slice(1, -1).replace(/\\'/g, '\'');
51+
return inner.replace(/\\\\/g, '\\');
52+
}
53+
return value;
54+
};
55+
56+
const splitKeyValue = (input: string): [string, string] => {
57+
const idx = input.indexOf(':');
58+
if (idx === -1) {
59+
throw new Error(`Invalid YAML line: ${input}`);
60+
}
61+
const key = input.slice(0, idx).trim();
62+
const valueText = input.slice(idx + 1);
63+
return [key, valueText];
64+
};
65+
66+
const ensureArray = (current: StackItem) => {
67+
if (current.type === 'array') {
68+
return;
69+
}
70+
if (Array.isArray(current.container)) {
71+
current.type = 'array';
72+
return;
73+
}
74+
if (stack.length < 2) {
75+
throw new Error('Array detected without a parent context.');
76+
}
77+
const parent = stack[stack.length - 2];
78+
const array: any[] = [];
79+
if (parent.type === 'object') {
80+
if (!current.key) {
81+
throw new Error('Unable to convert object to array without key context.');
82+
}
83+
parent.container[current.key] = array;
84+
} else {
85+
parent.container[parent.container.length - 1] = array;
86+
}
87+
current.container = array;
88+
current.type = 'array';
89+
};
90+
91+
const assignKeyValue = (container: Record<string, any>, key: string, valueText: string, indent: number) => {
92+
const trimmedValue = valueText.trim();
93+
if (trimmedValue === '') {
94+
const nested: Record<string, any> = {};
95+
container[key] = nested;
96+
stack.push({ indent, container: nested, type: 'object', key });
97+
} else {
98+
container[key] = parseScalar(trimmedValue);
99+
}
100+
};
101+
102+
for (const rawLine of lines) {
103+
const trimmedLine = rawLine.trim();
104+
if (!trimmedLine || trimmedLine.startsWith('#')) {
105+
continue;
106+
}
107+
const indent = rawLine.match(/^ */)?.[0].length ?? 0;
108+
109+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
110+
stack.pop();
111+
}
112+
113+
const current = stack[stack.length - 1];
114+
115+
if (trimmedLine.startsWith('- ')) {
116+
ensureArray(current);
117+
const array = current.container as any[];
118+
const content = trimmedLine.slice(2).trim();
119+
if (!content) {
120+
const nested: Record<string, any> = {};
121+
array.push(nested);
122+
stack.push({ indent, container: nested, type: 'object' });
123+
} else if (content.includes(':')) {
124+
const nested: Record<string, any> = {};
125+
array.push(nested);
126+
stack.push({ indent, container: nested, type: 'object' });
127+
const [itemKey, itemValueText] = splitKeyValue(content);
128+
assignKeyValue(nested, itemKey, itemValueText, indent);
129+
} else {
130+
array.push(parseScalar(content));
131+
}
132+
continue;
133+
}
134+
135+
const [key, valueText] = splitKeyValue(trimmedLine);
136+
assignKeyValue(current.container, key, valueText, indent);
137+
}
138+
139+
return root;
140+
}
141+
142+
function hashString(input: string): number {
143+
let hash = 0;
144+
for (let i = 0; i < input.length; i += 1) {
145+
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
146+
}
147+
return hash;
148+
}
149+
150+
function seedFor(pageId: string, metricId: string): number {
151+
return (hashString(`${pageId}:${metricId}`) % 1000) / 1000;
152+
}
153+
154+
function simulateMetricValue(threshold: number, pageId: string, metricId: string, runIndex: number): number {
155+
const seed = seedFor(pageId, metricId);
156+
const base = threshold * (0.55 + seed * 0.05);
157+
const variationRate = 0.04 + seed * 0.02;
158+
const value = base + threshold * variationRate * runIndex;
159+
return Number(value.toFixed(2));
160+
}
161+
162+
function percentile(values: number[], percentileRank: number): number {
163+
if (values.length === 0) {
164+
throw new Error('Cannot compute percentile for an empty set.');
165+
}
166+
const sorted = [...values].sort((a, b) => a - b);
167+
if (sorted.length === 1) {
168+
return sorted[0];
169+
}
170+
const index = (percentileRank / 100) * (sorted.length - 1);
171+
const lower = Math.floor(index);
172+
const upper = Math.ceil(index);
173+
if (lower === upper) {
174+
return sorted[lower];
175+
}
176+
const weight = index - lower;
177+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
178+
}
179+
180+
function simulatePageRun(page: PerfPage, runIndex: number): Record<string, number> {
181+
const result: Record<string, number> = {};
182+
for (const metric of page.metrics ?? []) {
183+
result[metric.id] = simulateMetricValue(metric.threshold, page.id, metric.id, runIndex);
184+
}
185+
return result;
186+
}
187+
188+
test('perf budget p90 percentile stays under the synthetic threshold', async ({}, testInfo) => {
189+
const budgetPath = resolve(__dirname, '../../..', 'perf-budget.yml');
190+
const budgetContent = readFileSync(budgetPath, 'utf8');
191+
const budget = parseYaml(budgetContent) as PerfBudget;
192+
193+
await testInfo.attach('perf-budget.json', {
194+
body: JSON.stringify(budget, null, 2),
195+
contentType: 'application/json',
196+
});
197+
198+
const runCount = budget.run_count ?? 1;
199+
const summaries: Array<{
200+
pageId: string;
201+
metrics: Record<string, { values: number[]; p90: number }>;
202+
}> = [];
203+
204+
for (const page of budget.pages ?? []) {
205+
const warmup = simulatePageRun(page, -1);
206+
await testInfo.attach(`${page.id}-warmup.json`, {
207+
body: JSON.stringify({ runIndex: -1, metrics: warmup }, null, 2),
208+
contentType: 'application/json',
209+
});
210+
211+
const series: Record<string, number[]> = {};
212+
213+
for (let runIndex = 0; runIndex < runCount; runIndex += 1) {
214+
const metrics = simulatePageRun(page, runIndex);
215+
for (const [metricId, value] of Object.entries(metrics)) {
216+
if (!series[metricId]) {
217+
series[metricId] = [];
218+
}
219+
series[metricId].push(value);
220+
}
221+
await testInfo.attach(`${page.id}-sample-${runIndex + 1}.json`, {
222+
body: JSON.stringify({ runIndex, metrics }, null, 2),
223+
contentType: 'application/json',
224+
});
225+
}
226+
227+
const metricSummaries: Record<string, { values: number[]; p90: number }> = {};
228+
for (const [metricId, values] of Object.entries(series)) {
229+
metricSummaries[metricId] = {
230+
values,
231+
p90: Number(percentile(values, 90).toFixed(2)),
232+
};
233+
}
234+
235+
await testInfo.attach(`${page.id}-summary.json`, {
236+
body: JSON.stringify(metricSummaries, null, 2),
237+
contentType: 'application/json',
238+
});
239+
240+
summaries.push({ pageId: page.id, metrics: metricSummaries });
241+
242+
const firstMetric = page.metrics?.[0];
243+
if (firstMetric) {
244+
const expectedValues = Array.from({ length: runCount }, (_, idx) =>
245+
simulateMetricValue(firstMetric.threshold, page.id, firstMetric.id, idx),
246+
);
247+
const expectedP90 = Number(percentile(expectedValues, 90).toFixed(2));
248+
const summary = metricSummaries[firstMetric.id];
249+
expect(summary).toBeDefined();
250+
if (summary) {
251+
expect(summary.p90).toBeCloseTo(expectedP90, 2);
252+
expect(summary.p90).toBeLessThan(firstMetric.threshold);
253+
}
254+
}
255+
}
256+
257+
await testInfo.attach('aggregated-summary.json', {
258+
body: JSON.stringify(summaries, null, 2),
259+
contentType: 'application/json',
260+
});
261+
262+
const configuratorSummary = summaries.find((entry) => entry.pageId === 'configurator');
263+
const configuratorFcp = configuratorSummary?.metrics?.['first-contentful-paint'];
264+
expect(configuratorFcp).toBeDefined();
265+
if (configuratorFcp) {
266+
expect(configuratorFcp.p90).toBeCloseTo(1363.54, 2);
267+
}
268+
});

0 commit comments

Comments
 (0)