Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Performance Benchmarks

on:
pull_request:
branches: [master]

permissions:
contents: read
pull-requests: write

concurrency:
group: perf-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build Angular Library
run: npm run build:lib

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run performance benchmarks
run: npm run perf

- name: Generate report
run: npm run perf:report -- --output "$GITHUB_STEP_SUMMARY"

- name: Compare against baseline
id: compare
if: always()
run: |
if npm run --silent perf:compare 2>&1 | tee perf/results/comparison.txt; then
echo "regression=false" >> "$GITHUB_OUTPUT"
else
echo "regression=true" >> "$GITHUB_OUTPUT"
fi
cat perf/results/comparison.txt >> "$GITHUB_STEP_SUMMARY"

- name: Comment on PR
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const { readFileSync, existsSync } = require('fs');
const { execSync } = require('child_process');

const report = execSync('npm run --silent perf:report', { encoding: 'utf-8' });

let comparison = '';
const compPath = 'perf/results/comparison.txt';
if (existsSync(compPath)) {
comparison = '\n---\n' + readFileSync(compPath, 'utf-8');
}

// Find and update existing comment, or create a new one
const marker = '<!-- perf-benchmark-results -->';
const body = `${marker}\n${report}${comparison}`;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const existing = comments.find(c => c.body?.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-results
path: perf/results/
retention-days: 30
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ storybook-static
/reference
/playwright-report
/test-results
/perf/results/*.json

.codemie
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"perf": "playwright test --config perf/playwright.perf.config.ts",
"perf:report": "node --experimental-strip-types perf/report.ts",
"perf:compare": "node --experimental-strip-types perf/compare.ts",
"perf:baseline": "npm run perf && cp perf/results/latest.json perf/baselines/baseline.json",
"storybook": "ng run dnd:storybook",
"build-storybook": "ng run dnd:build-storybook",
"release": "node scripts/release.js",
Expand Down
351 changes: 351 additions & 0 deletions perf/baselines/baseline.json

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions perf/compare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { AggregatedMetrics } from './fixtures/statistics.ts';
import { extractScenarios } from './fixtures/extract-scenarios.ts';

const COMPARISON_METRICS = [
'totalBlockingTime',
'longTaskCount',
'layoutCount',
'recalcStyleCount',
'avgFrameTime',
'maxFrameGap',
'droppedFrames',
'p99FrameTime',
'jsHeapDelta',
];

function percentChange(baseline: number, current: number): number {
if (baseline === 0) return current === 0 ? 0 : 100;
return ((current - baseline) / Math.abs(baseline)) * 100;
}

function parseArg(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 ? args[idx + 1] : undefined;
}

function main(): void {
const args = process.argv.slice(2);
const threshold = parseFloat(parseArg(args, '--threshold') ?? '25');
const outputPath = parseArg(args, '--output');

const baselinePath = resolve(
parseArg(args, '--baseline') ?? resolve(import.meta.dirname, 'baselines/baseline.json'),
);
const latestPath = resolve(
parseArg(args, '--current') ?? resolve(import.meta.dirname, 'results/latest.json'),
);

if (!existsSync(baselinePath)) {
console.log('No baseline found. Run `npm run perf:baseline` to create one.');
process.exit(0);
}

if (!existsSync(latestPath)) {
console.error('No latest results found. Run `npm run perf` first.');
process.exit(1);
}

const baselineArr = extractScenarios(baselinePath);
const latestArr = extractScenarios(latestPath);
const baseline = new Map(baselineArr.map((s) => [s.scenario, s]));
const latest = new Map(latestArr.map((s) => [s.scenario, s]));

if (baseline.size === 0) {
console.log('Baseline contains no scenario data. Re-run `npm run perf:baseline`.');
process.exit(0);
}

const lines: string[] = [];
const emit = (line: string) => {
console.log(line);
lines.push(line);
};

emit(`\n## Performance Comparison (threshold: ${threshold}%)\n`);
emit('| Scenario | Metric | Baseline p95 | Current p95 | Change |');
emit('|----------|--------|-------------|------------|--------|');

let hasRegression = false;

for (const [name, baselineReport] of baseline) {
const currentReport = latest.get(name);
if (!currentReport) {
emit(`| ${name} | - | - | - | MISSING |`);
continue;
}

for (const metric of COMPARISON_METRICS) {
const bMetric = baselineReport[metric] as AggregatedMetrics | undefined;
const cMetric = currentReport[metric] as AggregatedMetrics | undefined;

if (!bMetric || !cMetric) continue;

const change = percentChange(bMetric.p95, cMetric.p95);
const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
const flag = change > threshold ? ' REGRESSION' : '';

if (change > threshold) {
hasRegression = true;
}

emit(
`| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`,
);
}
}

emit('');

if (hasRegression) {
emit(`**Performance regression detected** (>${threshold}% on p95 values).`);
} else {
emit('No significant regressions detected.');
}

if (outputPath) {
writeFileSync(outputPath, lines.join('\n') + '\n');
}

if (hasRegression) {
process.exit(1);
}
}

main();
66 changes: 66 additions & 0 deletions perf/fixtures/extract-scenarios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs';

interface Attachment {
name: string;
body?: string;
contentType: string;
}

interface TestResult {
attachments?: Attachment[];
}

interface TestCase {
results?: TestResult[];
}

interface Spec {
tests?: TestCase[];
}

interface Suite {
suites?: Suite[];
specs?: Spec[];
}

interface ResultsFile {
suites: Suite[];
}

export interface ScenarioReport {
scenario: string;
[metric: string]: unknown;
}

export function extractScenarios(filePath: string): ScenarioReport[] {
const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8'));
const scenarios: ScenarioReport[] = [];

function traverseSuite(suite: Suite): void {
for (const child of suite.suites ?? []) {
traverseSuite(child);
}
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const result of test.results ?? []) {
for (const attachment of result.attachments ?? []) {
if (attachment.contentType === 'application/json' && attachment.body) {
const report = JSON.parse(
Buffer.from(attachment.body, 'base64').toString('utf-8'),
) as ScenarioReport;
if (report.scenario) {
scenarios.push(report);
}
}
}
}
}
}
}

for (const suite of data.suites ?? []) {
traverseSuite(suite);
}

return scenarios;
}
Loading