|
| 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