diff --git a/.github/workflows/perf-tests.yml b/.github/workflows/perf-tests.yml new file mode 100644 index 00000000000..d29a71a3290 --- /dev/null +++ b/.github/workflows/perf-tests.yml @@ -0,0 +1,91 @@ +name: Perf Tests + +# Triggers on PRs touching source that could affect perf, +# and on pushes to main/stable for baseline updates. +on: + pull_request: + branches: [main, '*-stable'] + paths: + - 'vnext/**' + - 'packages/**' + - 'vnext/Scripts/perf/**' + - '.github/workflows/perf-tests.yml' + push: + branches: [main] + paths: + - 'packages/e2e-test-app-fabric/test/__perf__/**' + + # Allow manual trigger for debugging + workflow_dispatch: + +# Cancel in-progress runs for the same PR +concurrency: + group: perf-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + perf-tests: + name: Component Performance Tests + runs-on: windows-latest + timeout-minutes: 30 + + permissions: + contents: read + actions: read + + steps: + # ── Setup ────────────────────────────────────────────── + - name: Checkout head (PR) + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need history for baseline comparison + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build perf-testing package + run: yarn workspace @react-native-windows/perf-testing build + + # ── Run Tests ────────────────────────────────────────── + - name: Run perf tests + id: perf-run + working-directory: packages/e2e-test-app-fabric + env: + CI: 'true' + RN_TARGET_PLATFORM: windows + run: yarn perf:ci + continue-on-error: true # Don't fail here — let comparison decide + + # ── Compare & Report ─────────────────────────────────── + - name: Compare against baselines + id: compare + working-directory: packages/e2e-test-app-fabric + run: yarn perf:ci:compare + continue-on-error: true + + - name: Save PR number + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > packages/e2e-test-app-fabric/.perf-results/pr-number.txt + + - name: Upload perf results + if: always() + uses: actions/upload-artifact@v4 + with: + name: perf-results + path: | + packages/e2e-test-app-fabric/.perf-results/ + packages/e2e-test-app-fabric/test/__perf__/**/__perf_snapshots__/ + retention-days: 30 + + # ── Status Gate ──────────────────────────────────────── + - name: Check for regressions + if: steps.compare.outcome == 'failure' + run: | + echo "::error::Performance regressions detected. See PR comment for details." + exit 1 diff --git a/change/@react-native-windows-perf-testing-f0fab0f1-4984-4664-b8af-b24ff2f15cd7.json b/change/@react-native-windows-perf-testing-f0fab0f1-4984-4664-b8af-b24ff2f15cd7.json new file mode 100644 index 00000000000..4d77df4b4a4 --- /dev/null +++ b/change/@react-native-windows-perf-testing-f0fab0f1-4984-4664-b8af-b24ff2f15cd7.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Performance tests for react native windows(Fabric)", + "packageName": "@react-native-windows/perf-testing", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/react-native-windows-1f753cb4-5264-49b2-a35d-47fd9d986207.json b/change/react-native-windows-1f753cb4-5264-49b2-a35d-47fd9d986207.json new file mode 100644 index 00000000000..37081aa1006 --- /dev/null +++ b/change/react-native-windows-1f753cb4-5264-49b2-a35d-47fd9d986207.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Performance tests for react native windows(Fabric)", + "packageName": "react-native-windows", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/perf-testing/.eslintrc.js b/packages/@react-native-windows/perf-testing/.eslintrc.js new file mode 100644 index 00000000000..eddc70a4c1a --- /dev/null +++ b/packages/@react-native-windows/perf-testing/.eslintrc.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +module.exports = { + extends: ['@rnw-scripts/eslint-config'], + parserOptions: { + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/@react-native-windows/perf-testing/.gitignore b/packages/@react-native-windows/perf-testing/.gitignore new file mode 100644 index 00000000000..70e0b62425f --- /dev/null +++ b/packages/@react-native-windows/perf-testing/.gitignore @@ -0,0 +1,3 @@ +lib/ +lib-commonjs/ +*.tsbuildinfo diff --git a/packages/@react-native-windows/perf-testing/README.md b/packages/@react-native-windows/perf-testing/README.md new file mode 100644 index 00000000000..d1db0d41570 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/README.md @@ -0,0 +1,155 @@ +# @react-native-windows/perf-testing + +Performance testing utilities for React Native Windows components. + +## Overview + +This package provides infrastructure for measuring and tracking performance of React Native components. It enables: + +- **Automated performance regression detection** on every PR +- **Component-wise performance baselines** (mount, unmount, re-render times) +- **Lightweight perf snapshots** similar to Jest snapshots +- **Easy extensibility** for new components via base classes + +## Installation + +```bash +npm install @react-native-windows/perf-testing --save-dev +# or +yarn add @react-native-windows/perf-testing --dev +``` + +## Quick Start + +### 1. Create a Performance Test + +```typescript +import { ComponentPerfTestBase, IScenario } from '@react-native-windows/perf-testing'; +import { View } from 'react-native'; + +class ViewPerfTest extends ComponentPerfTestBase { + readonly componentName = 'View'; + readonly category = 'core' as const; + readonly testId = 'perf-test-view'; + + createComponent(props?: Record) { + return ; + } + + getCustomScenarios(): IScenario[] { + return [ + { + name: 'nested-views', + description: 'Test nested views performance', + run: () => this.measureNestedViews(50), + }, + ]; + } +} +``` + +### 2. Write Tests + +```typescript +const viewPerfTest = new ViewPerfTest(); + +describe('View Performance', () => { + test('mount time', async () => { + const perf = await viewPerfTest.measureMount(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('rerender time', async () => { + const perf = await viewPerfTest.measureRerender(); + expect(perf).toMatchPerfSnapshot(); + }); +}); +``` + +### 3. Run Tests + +```bash +# Run perf tests +yarn perf + +# Update baselines +yarn perf:update +``` + +## API Reference + +### Core Functions + +| Function | Description | +|----------|-------------| +| `measurePerf(component, options)` | Core measurement function | +| `toMatchPerfSnapshot(threshold?)` | Jest matcher for perf snapshots | + +### Base Classes + +| Class | Description | +|-------|-------------| +| `ComponentPerfTestBase` | Abstract base class for component perf tests | + +### Interfaces + +| Interface | Description | +|-----------|-------------| +| `PerfMetrics` | Measurement results | +| `PerfThreshold` | Pass/fail thresholds | +| `IComponentPerfTest` | Contract for component tests | +| `IScenario` | Custom scenario interface | + +### Threshold Presets + +```typescript +import { ThresholdPresets } from '@react-native-windows/perf-testing'; + +// Available presets +ThresholdPresets.core // 10% regression threshold +ThresholdPresets.list // 15% for list components +ThresholdPresets.interactive // 20% for animated components +ThresholdPresets.community // 25% relaxed threshold for external use +``` + +## Extending for Custom Components + +Community developers can create perf tests for their custom native components: + +```typescript +import { ComponentPerfTestBase } from '@react-native-windows/perf-testing'; +import { MyCustomSlider } from 'my-custom-component'; + +class MyCustomSliderPerfTest extends ComponentPerfTestBase { + readonly componentName = 'MyCustomSlider'; + readonly category = 'custom' as const; + readonly testId = 'perf-test-slider'; + + createComponent(props?: Record) { + return ; + } +} +``` + +## Configuration + +### Jest Setup + +Add to your Jest setup file: + +```typescript +import '@react-native-windows/perf-testing/matchers'; +``` + +### Custom Thresholds + +```typescript +expect(perf).toMatchPerfSnapshot({ + maxDurationIncrease: 15, // Allow 15% regression + maxRenderCount: 5, +}); +``` + +## License + +MIT diff --git a/packages/@react-native-windows/perf-testing/just-task.js b/packages/@react-native-windows/perf-testing/just-task.js new file mode 100644 index 00000000000..d68028a251d --- /dev/null +++ b/packages/@react-native-windows/perf-testing/just-task.js @@ -0,0 +1,9 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + * @ts-check + */ + +require('@rnw-scripts/just-task'); diff --git a/packages/@react-native-windows/perf-testing/package.json b/packages/@react-native-windows/perf-testing/package.json new file mode 100644 index 00000000000..f102e966c79 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/package.json @@ -0,0 +1,67 @@ +{ + "name": "@react-native-windows/perf-testing", + "version": "0.0.0-canary.1031", + "description": "Performance testing utilities for React Native Windows components", + "main": "lib-commonjs/index.js", + "types": "lib-commonjs/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/react-native-windows", + "directory": "packages/@react-native-windows/perf-testing" + }, + "license": "MIT", + "private": false, + "scripts": { + "build": "rnw-scripts build", + "clean": "rnw-scripts clean", + "lint": "rnw-scripts lint", + "lint:fix": "rnw-scripts lint:fix", + "watch": "rnw-scripts watch" + }, + "dependencies": { + "@react-native-windows/fs": "0.82.0-preview.1" + }, + "devDependencies": { + "@rnw-scripts/eslint-config": "1.2.38", + "@rnw-scripts/just-task": "2.3.58", + "@rnw-scripts/ts-config": "2.0.6", + "@types/jest": "^29.2.2", + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "eslint": "^8.19.0", + "prettier": "2.8.8", + "typescript": "5.0.4" + }, + "peerDependencies": { + "jest": ">=29.0.3", + "react": ">=18.0.0", + "react-native": ">=0.72.0", + "react-test-renderer": ">=18.0.0" + }, + "peerDependenciesMeta": { + "react-test-renderer": { + "optional": true + } + }, + "files": [ + "lib-commonjs", + "README.md" + ], + "beachball": { + "defaultNpmTag": "preview", + "disallowedChangeTypes": [ + "major", + "minor", + "patch", + "premajor", + "preminor", + "prepatch" + ] + }, + "promoteRelease": true, + "engines": { + "node": ">= 22" + } +} diff --git a/packages/@react-native-windows/perf-testing/src/base/ComponentPerfTestBase.ts b/packages/@react-native-windows/perf-testing/src/base/ComponentPerfTestBase.ts new file mode 100644 index 00000000000..6cb65a4f8ac --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/base/ComponentPerfTestBase.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; +import {measurePerf} from '../core/measurePerf'; +import type { + IComponentPerfTest, + IScenario, + PerfTestCategory, +} from '../interfaces/IComponentPerfTest'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import type {PerfThreshold} from '../interfaces/PerfThreshold'; +import {DEFAULT_THRESHOLD} from '../interfaces/PerfThreshold'; + +/** + * Abstract base class for all component performance tests. + * + * Subclasses provide componentName, category, testId, and createComponent(). + * The base class handles standard mount/unmount/rerender scenarios and + * test suite generation. + */ +export abstract class ComponentPerfTestBase implements IComponentPerfTest { + abstract readonly componentName: string; + abstract readonly category: PerfTestCategory; + abstract readonly testId: string; + + get defaultThreshold(): PerfThreshold { + return DEFAULT_THRESHOLD; + } + + abstract createComponent(props?: Record): React.ReactElement; + + async measureMount(): Promise { + return measurePerf(this.createComponent(), { + name: `${this.componentName} mount`, + runs: this.defaultThreshold.minRuns ?? 10, + warmupRuns: 1, + }); + } + + async measureUnmount(): Promise { + return measurePerf(this.createComponent(), { + name: `${this.componentName} unmount`, + runs: this.defaultThreshold.minRuns ?? 10, + warmupRuns: 1, + measureUnmount: true, + }); + } + + async measureRerender(): Promise { + const component = this.createComponent(); + return measurePerf(component, { + name: `${this.componentName} rerender`, + runs: this.defaultThreshold.minRuns ?? 10, + warmupRuns: 1, + scenario: async helpers => { + // Default re-render: update with a changed prop to force reconciliation + helpers.rerender(this.createComponent({__perfRerenderKey: Date.now()})); + }, + }); + } + + /** + * Override to add component-specific scenarios. + */ + getCustomScenarios(): IScenario[] { + return []; + } + + generateTestSuite(customThreshold?: Partial): void { + describe(`${this.componentName} Performance`, () => { + test('mount time', async () => { + const perf = await this.measureMount(); + expect(perf).toMatchPerfSnapshot(customThreshold); + }); + + test('unmount time', async () => { + const perf = await this.measureUnmount(); + expect(perf).toMatchPerfSnapshot(customThreshold); + }); + + test('rerender time', async () => { + const perf = await this.measureRerender(); + expect(perf).toMatchPerfSnapshot(customThreshold); + }); + + // Custom scenarios + const customScenarios = this.getCustomScenarios(); + if (customScenarios.length > 0) { + describe(`${this.componentName}-Specific Scenarios`, () => { + for (const scenario of customScenarios) { + test(scenario.name, async () => { + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(customThreshold); + }); + } + }); + } + }); + } +} diff --git a/packages/@react-native-windows/perf-testing/src/ci/BaselineComparator.ts b/packages/@react-native-windows/perf-testing/src/ci/BaselineComparator.ts new file mode 100644 index 00000000000..0f4749c4389 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/ci/BaselineComparator.ts @@ -0,0 +1,183 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import type {PerfThreshold} from '../interfaces/PerfThreshold'; +import {DEFAULT_THRESHOLD} from '../interfaces/PerfThreshold'; +import type {SnapshotFile} from '../matchers/snapshotManager'; +import type {ComparisonResult} from '../reporters/MarkdownReporter'; +import {coefficientOfVariation, mannWhitneyU} from '../core/statistics'; + +/** + * Options for baseline comparison. + */ +export interface CompareOptions { + defaultThreshold?: PerfThreshold; + thresholdOverrides?: Record>; +} + +/** + * Full comparison output for a test suite. + */ +export interface SuiteComparison { + suiteName: string; + comparisons: ComparisonResult[]; + hasRegressions: boolean; +} + +/** + * Compares head (current) perf snapshots against base (main) snapshots. + */ +export class BaselineComparator { + static compareSuite( + suiteName: string, + headSnapshots: SnapshotFile, + baseSnapshots: SnapshotFile, + options: CompareOptions = {}, + ): SuiteComparison { + const defaultThreshold = options.defaultThreshold || DEFAULT_THRESHOLD; + const comparisons: ComparisonResult[] = []; + + // Compare all entries present in head + for (const [key, headEntry] of Object.entries(headSnapshots)) { + const baseEntry = baseSnapshots[key]; + const threshold = { + ...defaultThreshold, + ...headEntry.threshold, + ...(options.thresholdOverrides?.[key] || {}), + }; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!baseEntry) { + comparisons.push({ + metrics: headEntry.metrics, + baselineMetrics: undefined, + percentChange: undefined, + passed: true, // New scenarios always pass + }); + continue; + } + + const result = BaselineComparator.compareEntry( + headEntry.metrics, + baseEntry.metrics, + threshold, + ); + comparisons.push(result); + } + + // Flag scenarios removed in head + for (const key of Object.keys(baseSnapshots)) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!headSnapshots[key]) { + comparisons.push({ + metrics: { + ...baseSnapshots[key].metrics, + name: `[REMOVED] ${baseSnapshots[key].metrics.name}`, + }, + baselineMetrics: baseSnapshots[key].metrics, + percentChange: undefined, + passed: true, + }); + } + } + + return { + suiteName, + comparisons, + hasRegressions: comparisons.some(c => !c.passed), + }; + } + + static compareEntry( + head: PerfMetrics, + base: PerfMetrics, + threshold: PerfThreshold, + ): ComparisonResult { + const resolved: Required = { + ...DEFAULT_THRESHOLD, + ...threshold, + }; + + const percentChange = + base.medianDuration > 0 + ? ((head.medianDuration - base.medianDuration) / base.medianDuration) * + 100 + : head.medianDuration > 0 + ? Infinity + : 0; + + const errors: string[] = []; + const isTrackMode = resolved.mode === 'track'; + + // CV gate: skip regression check if measurement is too noisy + const cv = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + head.durations && head.durations.length >= 2 + ? coefficientOfVariation(head.durations) + : 0; + const tooNoisy = cv > resolved.maxCV; + + // Statistical significance gate (Mann-Whitney U) + let statSignificant = true; + if ( + !tooNoisy && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + head.durations && + head.durations.length >= 2 && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + base.durations && + base.durations.length >= 2 + ) { + const mwResult = mannWhitneyU(base.durations, head.durations); + statSignificant = mwResult.significant; + } + + // Check percentage increase AND absolute delta + const absoluteDelta = head.medianDuration - base.medianDuration; + if ( + !tooNoisy && + statSignificant && + percentChange > resolved.maxDurationIncrease && + absoluteDelta > resolved.minAbsoluteDelta + ) { + errors.push( + `Duration increased by ${percentChange.toFixed( + 1, + )}% / +${absoluteDelta.toFixed(2)}ms ` + + `(threshold: ${resolved.maxDurationIncrease}% & ${resolved.minAbsoluteDelta}ms)`, + ); + } + + // Check absolute max + if (head.medianDuration > resolved.maxDuration) { + errors.push( + `Duration ${head.medianDuration.toFixed(2)}ms exceeds ` + + `max ${resolved.maxDuration}ms`, + ); + } + + // Check render count + if (head.renderCount > resolved.maxRenderCount) { + errors.push( + `Render count ${head.renderCount} exceeds ` + + `max ${resolved.maxRenderCount}`, + ); + } + + // Track mode: report but never fail + const passed = isTrackMode ? true : errors.length === 0; + + return { + metrics: head, + baselineMetrics: base, + percentChange, + passed, + error: errors.length > 0 ? errors.join('; ') : undefined, + }; + } +} diff --git a/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts b/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts new file mode 100644 index 00000000000..9acb9250828 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import fs from '@react-native-windows/fs'; +import * as path from 'path'; +import {SnapshotManager} from '../matchers/snapshotManager'; +import type {SnapshotFile} from '../matchers/snapshotManager'; + +/** + * Summary of a single test suite's perf results. + */ +export interface SuiteResult { + testFilePath: string; + suiteName: string; + snapshots: SnapshotFile; + passed: number; + failed: number; + totalDuration: number; +} + +/** + * Full CI perf run results. + */ +export interface CIRunResults { + timestamp: string; + branch: string; + commitSha: string; + suites: SuiteResult[]; + summary: { + totalSuites: number; + totalTests: number; + passed: number; + failed: number; + durationMs: number; + }; +} + +/** + * Custom Jest reporter that collects perf snapshot data + * and writes a JSON results file for CI. + */ +export class PerfJsonReporter { + private readonly outputFile: string; + + constructor( + _globalConfig: Record, + options: {outputFile?: string} = {}, + ) { + this.outputFile = options.outputFile || '.perf-results/results.json'; + } + + onRunComplete( + _testContexts: Set, + results: { + numTotalTestSuites: number; + numPassedTestSuites: number; + numFailedTestSuites: number; + numTotalTests: number; + numPassedTests: number; + numFailedTests: number; + startTime: number; + testResults: Array<{ + testFilePath: string; + testResults: Array<{ + ancestorTitles: string[]; + title: string; + status: string; + duration?: number; + }>; + perfTimer?: {start: number; end: number}; + }>; + }, + ): void { + const suites: SuiteResult[] = []; + + for (const suite of results.testResults) { + // Load the snapshot file for this test suite (written by toMatchPerfSnapshot) + const {file: snapshotFilePath} = SnapshotManager.getSnapshotPath( + suite.testFilePath, + ); + const snapshots = SnapshotManager.load(snapshotFilePath); + + const passed = suite.testResults.filter( + t => t.status === 'passed', + ).length; + const failed = suite.testResults.filter( + t => t.status === 'failed', + ).length; + const totalDuration = suite.testResults.reduce( + (acc, t) => acc + (t.duration || 0), + 0, + ); + + suites.push({ + testFilePath: suite.testFilePath, + suiteName: path.basename(suite.testFilePath, '.perf-test.tsx'), + snapshots, + passed, + failed, + totalDuration, + }); + } + + const ciResults: CIRunResults = { + timestamp: new Date().toISOString(), + branch: + process.env.BUILD_SOURCEBRANCH || + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF || + 'unknown', + commitSha: + process.env.BUILD_SOURCEVERSION || process.env.GITHUB_SHA || 'unknown', + suites, + summary: { + totalSuites: results.numTotalTestSuites, + totalTests: results.numTotalTests, + passed: results.numPassedTests, + failed: results.numFailedTests, + durationMs: Date.now() - results.startTime, + }, + }; + + // Write results + const outputDir = path.dirname(this.outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, {recursive: true}); + } + fs.writeFileSync( + this.outputFile, + JSON.stringify(ciResults, null, 2) + '\n', + 'utf-8', + ); + + console.log(`\n📊 Perf results written to: ${this.outputFile}`); + } +} + +// Default export for Jest reporter compatibility +module.exports = PerfJsonReporter; diff --git a/packages/@react-native-windows/perf-testing/src/ci/index.ts b/packages/@react-native-windows/perf-testing/src/ci/index.ts new file mode 100644 index 00000000000..39393d73946 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/ci/index.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * CI/CD integration utilities for perf testing. + * + * @format + */ + +export {PerfJsonReporter} from './PerfJsonReporter'; +export type {SuiteResult, CIRunResults} from './PerfJsonReporter'; + +export {BaselineComparator} from './BaselineComparator'; +export type {CompareOptions, SuiteComparison} from './BaselineComparator'; diff --git a/packages/@react-native-windows/perf-testing/src/config/defaultConfig.ts b/packages/@react-native-windows/perf-testing/src/config/defaultConfig.ts new file mode 100644 index 00000000000..f415d1c8a7d --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/config/defaultConfig.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfThreshold} from '../interfaces/PerfThreshold'; + +export interface PerfTestConfig { + defaultRuns: number; + defaultWarmupRuns: number; + defaultThreshold: Required; + verbose: boolean; + outputDir: string; +} + +export const DEFAULT_CONFIG: Readonly = { + defaultRuns: 10, + defaultWarmupRuns: 1, + defaultThreshold: { + maxDurationIncrease: 10, + maxDuration: Infinity, + minAbsoluteDelta: 3, + maxRenderCount: 5, + minRuns: 10, + maxCV: 0.5, + mode: 'gate', + }, + verbose: false, + outputDir: '.perf-results', +}; + +export function createPerfConfig( + overrides: Partial, +): PerfTestConfig { + return { + ...DEFAULT_CONFIG, + ...overrides, + defaultThreshold: { + ...DEFAULT_CONFIG.defaultThreshold, + ...(overrides.defaultThreshold ?? {}), + }, + }; +} diff --git a/packages/@react-native-windows/perf-testing/src/config/index.ts b/packages/@react-native-windows/perf-testing/src/config/index.ts new file mode 100644 index 00000000000..ee315ededde --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/config/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export {DEFAULT_CONFIG, createPerfConfig} from './defaultConfig'; +export type {PerfTestConfig} from './defaultConfig'; +export {ThresholdPresets} from './thresholdPresets'; diff --git a/packages/@react-native-windows/perf-testing/src/config/thresholdPresets.ts b/packages/@react-native-windows/perf-testing/src/config/thresholdPresets.ts new file mode 100644 index 00000000000..3b1b260c488 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/config/thresholdPresets.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfThreshold} from '../interfaces/PerfThreshold'; + +/** + * Pre-configured threshold presets for different component categories. + */ +export const ThresholdPresets: Readonly< + Record> +> = { + /** Stable core components (View, Text, Image) — tight thresholds */ + core: { + maxDurationIncrease: 10, + maxDuration: Infinity, + minAbsoluteDelta: 3, + maxRenderCount: 2, + minRuns: 10, + maxCV: 0.4, + mode: 'gate', + }, + + /** Complex list/scroll components — slightly relaxed */ + list: { + maxDurationIncrease: 15, + maxDuration: Infinity, + minAbsoluteDelta: 5, + maxRenderCount: 5, + minRuns: 5, + maxCV: 0.5, + mode: 'gate', + }, + + /** Interactive components with animations — more relaxed */ + interactive: { + maxDurationIncrease: 20, + maxDuration: Infinity, + minAbsoluteDelta: 5, + maxRenderCount: 10, + minRuns: 10, + maxCV: 0.5, + mode: 'gate', + }, + + /** Community/custom components — most relaxed */ + community: { + maxDurationIncrease: 25, + maxDuration: Infinity, + minAbsoluteDelta: 5, + maxRenderCount: 15, + minRuns: 5, + maxCV: 0.6, + mode: 'track', + }, +}; diff --git a/packages/@react-native-windows/perf-testing/src/core/PerfProfiler.tsx b/packages/@react-native-windows/perf-testing/src/core/PerfProfiler.tsx new file mode 100644 index 00000000000..cf2e52b3426 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/core/PerfProfiler.tsx @@ -0,0 +1,62 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; + +/** + * Result from a single profiler measurement. + */ +export interface ProfilerResult { + phase: 'mount' | 'update' | 'nested-update'; + actualDuration: number; + baseDuration: number; + startTime: number; + commitTime: number; +} + +export type OnRenderCallback = (result: ProfilerResult) => void; + +interface PerfProfilerProps { + id: string; + onResult: OnRenderCallback; + children: React.ReactNode; +} + +/** + * Wrapper around React.Profiler that collects structured performance data. + */ +export function PerfProfiler({ + id, + onResult, + children, +}: PerfProfilerProps): React.ReactElement { + const onRender = React.useCallback( + ( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number, + ) => { + onResult({ + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + }); + }, + [onResult], + ); + + return ( + + {children} + + ); +} diff --git a/packages/@react-native-windows/perf-testing/src/core/measurePerf.ts b/packages/@react-native-windows/perf-testing/src/core/measurePerf.ts new file mode 100644 index 00000000000..4d7cb2002f0 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/core/measurePerf.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import {mean, median, standardDeviation} from './statistics'; +import type {ProfilerResult} from './PerfProfiler'; +import {PerfProfiler} from './PerfProfiler'; + +// React 19 requires all state-updating calls to be wrapped in act(). +// We lazy-require react-test-renderer, so we also lazy-resolve act. +let cachedAct: ((cb: () => void) => void) | undefined; +function getAct(): (cb: () => void) => void { + if (!cachedAct) { + const {act} = require('react-test-renderer'); + cachedAct = act; + } + return cachedAct!; +} + +/** + * Options for the `measurePerf` function. + */ +export interface MeasurePerfOptions { + name: string; + runs?: number; + warmupRuns?: number; + scenario?: (helpers: RenderHelpers) => Promise; + measureUnmount?: boolean; +} + +/** + * Helpers passed to scenario callbacks during measurement. + */ +export interface RenderHelpers { + rerender: (element: React.ReactElement) => void; + unmount: () => void; +} + +/** + * Result from a single measurement iteration. + */ +interface SingleRunResult { + duration: number; + renderCount: number; +} + +/** + * Core measurement function. + * + * Renders a component multiple times wrapped in React.Profiler, + * collects timing data, and returns aggregated metrics. + */ +export async function measurePerf( + component: React.ReactElement, + options: MeasurePerfOptions, +): Promise { + const { + name, + runs = 10, + warmupRuns = 1, + scenario, + measureUnmount = false, + } = options; + + // Lazy-require react-test-renderer to keep it as a peerDep + + const TestRenderer = require('react-test-renderer'); + + const durations: number[] = []; + let totalRenderCount = 0; + + for (let i = 0; i < warmupRuns; i++) { + await runSingleMeasurement( + TestRenderer, + component, + `${name}-warmup-${i}`, + scenario, + measureUnmount, + ); + } + + for (let i = 0; i < runs; i++) { + const result = await runSingleMeasurement( + TestRenderer, + component, + `${name}-run-${i}`, + scenario, + measureUnmount, + ); + durations.push(result.duration); + totalRenderCount += result.renderCount; + } + + return { + name, + meanDuration: mean(durations), + medianDuration: median(durations), + stdDev: standardDeviation(durations), + renderCount: Math.round(totalRenderCount / runs), + runs, + durations, + timestamp: new Date().toISOString(), + }; +} + +/** + * Execute a single measurement iteration. + */ +async function runSingleMeasurement( + testRenderer: typeof import('react-test-renderer'), + component: React.ReactElement, + profilerId: string, + scenario?: (helpers: RenderHelpers) => Promise, + measureUnmount: boolean = false, +): Promise { + const results: ProfilerResult[] = []; + + const onResult = (result: ProfilerResult) => { + results.push(result); + }; + + const wrappedComponent = React.createElement(PerfProfiler, { + id: profilerId, + onResult, + children: component, + }); + + let renderer!: ReturnType; + + const act = getAct(); + + if (measureUnmount) { + act(() => { + renderer = testRenderer.create(wrappedComponent); + }); + + results.length = 0; + + const unmountStart = performance.now(); + act(() => { + renderer.unmount(); + }); + const unmountDuration = performance.now() - unmountStart; + + return { + duration: unmountDuration, + renderCount: 0, + }; + } + + // Measure mount + const mountStart = performance.now(); + act(() => { + renderer = testRenderer.create(wrappedComponent); + }); + const mountDuration = performance.now() - mountStart; + + if (scenario) { + const helpers: RenderHelpers = { + rerender: (element: React.ReactElement) => { + const wrappedUpdate = React.createElement(PerfProfiler, { + id: profilerId, + onResult, + children: element, + }); + act(() => { + renderer.update(wrappedUpdate); + }); + }, + unmount: () => { + act(() => { + renderer.unmount(); + }); + }, + }; + await scenario(helpers); + } + + // Clean up + try { + act(() => { + renderer.unmount(); + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('unmount') && !msg.includes('not mounted')) { + throw e; + } + } + + // Prefer Profiler actualDuration, fallback to manual timing + const totalActualDuration = results.reduce( + (sum, r) => sum + r.actualDuration, + 0, + ); + + return { + duration: totalActualDuration > 0 ? totalActualDuration : mountDuration, + renderCount: results.length, + }; +} diff --git a/packages/@react-native-windows/perf-testing/src/core/statistics.ts b/packages/@react-native-windows/perf-testing/src/core/statistics.ts new file mode 100644 index 00000000000..fd02c4afbf0 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/core/statistics.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export function mean(values: number[]): number { + if (values.length === 0) { + return 0; + } + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +export function median(values: number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function standardDeviation(values: number[]): number { + if (values.length < 2) { + return 0; + } + const avg = mean(values); + const squaredDiffs = values.map(v => (v - avg) ** 2); + return Math.sqrt( + squaredDiffs.reduce((sum, v) => sum + v, 0) / (values.length - 1), + ); +} + +export function coefficientOfVariation(values: number[]): number { + if (values.length < 2) { + return 0; + } + const avg = mean(values); + if (avg === 0) { + return 0; + } + return standardDeviation(values) / avg; +} + +/** + * Normal CDF approximation (Abramowitz & Stegun 26.2.17). + * Accurate to ~1.5e-7 for all z. + */ +function normalCDF(z: number): number { + if (z < -8) { + return 0; + } + if (z > 8) { + return 1; + } + + const a1 = 0.254829592; + const a2 = -0.284496736; + const a3 = 1.421413741; + const a4 = -1.453152027; + const a5 = 1.061405429; + const p = 0.3275911; + + const sign = z < 0 ? -1 : 1; + const x = Math.abs(z) / Math.sqrt(2); + const t = 1.0 / (1.0 + p * x); + const y = + 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); + + return 0.5 * (1.0 + sign * y); +} + +export interface MannWhitneyResult { + U: number; + z: number; + p: number; + significant: boolean; +} + +/** + * Mann-Whitney U test (one-tailed: is sample2 significantly greater?). + * + * Non-parametric test appropriate for perf measurements that may not + * follow a normal distribution. Uses the normal approximation with + * tie correction, valid for n >= 8. + * + * @param sample1 - Baseline durations + * @param sample2 - Current (head) durations + * @param alpha - Significance level (default 0.05) + * @returns Test result with U statistic, z-score, p-value, and significance flag + */ +export function mannWhitneyU( + sample1: number[], + sample2: number[], + alpha: number = 0.05, +): MannWhitneyResult { + const n1 = sample1.length; + const n2 = sample2.length; + + if (n1 < 2 || n2 < 2) { + return {U: 0, z: 0, p: 1, significant: false}; + } + + // Tag each value with its group and combine + const combined: Array<{value: number; group: number}> = [ + ...sample1.map(v => ({value: v, group: 1})), + ...sample2.map(v => ({value: v, group: 2})), + ]; + + // Sort by value + combined.sort((a, b) => a.value - b.value); + + // Assign ranks with tie handling (average rank for ties) + const ranks = new Array(combined.length); + let i = 0; + while (i < combined.length) { + let j = i; + while (j < combined.length && combined[j].value === combined[i].value) { + j++; + } + const avgRank = (i + 1 + j) / 2; + for (let k = i; k < j; k++) { + ranks[k] = avgRank; + } + i = j; + } + + // Sum of ranks for sample 1 + let R1 = 0; + for (let idx = 0; idx < combined.length; idx++) { + if (combined[idx].group === 1) { + R1 += ranks[idx]; + } + } + + // U statistics + const U1 = n1 * n2 + (n1 * (n1 + 1)) / 2 - R1; + const U2 = n1 * n2 - U1; + const U = Math.min(U1, U2); + + // Normal approximation with tie correction + const n = n1 + n2; + const meanU = (n1 * n2) / 2; + + // Calculate tie correction factor + let tieCorrection = 0; + let idx = 0; + while (idx < combined.length) { + let tieCount = 1; + while ( + idx + tieCount < combined.length && + combined[idx + tieCount].value === combined[idx].value + ) { + tieCount++; + } + if (tieCount > 1) { + tieCorrection += tieCount ** 3 - tieCount; + } + idx += tieCount; + } + + const sigmaU = Math.sqrt( + (n1 * n2 * (n + 1)) / 12 - (n1 * n2 * tieCorrection) / (12 * n * (n - 1)), + ); + + if (sigmaU === 0) { + return {U, z: 0, p: 1, significant: false}; + } + + // One-tailed: is sample2 significantly greater than sample1? + // Lower U1 means sample2 tends to have higher ranks + const z = (U - meanU) / sigmaU; + const p = normalCDF(z); + + return {U, z, p, significant: p < alpha}; +} diff --git a/packages/@react-native-windows/perf-testing/src/index.ts b/packages/@react-native-windows/perf-testing/src/index.ts new file mode 100644 index 00000000000..2099c9df823 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/index.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export {measurePerf} from './core/measurePerf'; +export type {MeasurePerfOptions, RenderHelpers} from './core/measurePerf'; +export {PerfProfiler} from './core/PerfProfiler'; +export type {ProfilerResult} from './core/PerfProfiler'; +export { + mean, + median, + standardDeviation, + coefficientOfVariation, + mannWhitneyU, +} from './core/statistics'; +export type {MannWhitneyResult} from './core/statistics'; + +export type {PerfMetrics} from './interfaces/PerfMetrics'; +export type {PerfThreshold} from './interfaces/PerfThreshold'; +export {DEFAULT_THRESHOLD} from './interfaces/PerfThreshold'; +export type { + IComponentPerfTest, + IScenario, + PerfTestCategory, +} from './interfaces/IComponentPerfTest'; + +export {ComponentPerfTestBase} from './base/ComponentPerfTestBase'; + +export {MountScenario} from './scenarios/MountScenario'; +export {UnmountScenario} from './scenarios/UnmountScenario'; +export {RerenderScenario} from './scenarios/RerenderScenario'; + +import './matchers'; +export {SnapshotManager} from './matchers/snapshotManager'; +export type {SnapshotEntry, SnapshotFile} from './matchers/snapshotManager'; + +export {DEFAULT_CONFIG, createPerfConfig} from './config/defaultConfig'; +export type {PerfTestConfig} from './config/defaultConfig'; +export {ThresholdPresets} from './config/thresholdPresets'; + +export {ConsoleReporter} from './reporters/ConsoleReporter'; +export {MarkdownReporter} from './reporters/MarkdownReporter'; +export type {ComparisonResult} from './reporters/MarkdownReporter'; + +export {PerfJsonReporter} from './ci/PerfJsonReporter'; +export type {SuiteResult, CIRunResults} from './ci/PerfJsonReporter'; +export {BaselineComparator} from './ci/BaselineComparator'; +export type {CompareOptions, SuiteComparison} from './ci/BaselineComparator'; diff --git a/packages/@react-native-windows/perf-testing/src/interfaces/IComponentPerfTest.ts b/packages/@react-native-windows/perf-testing/src/interfaces/IComponentPerfTest.ts new file mode 100644 index 00000000000..4640f333423 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/interfaces/IComponentPerfTest.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfMetrics} from './PerfMetrics'; +import type {PerfThreshold} from './PerfThreshold'; + +/** + * Category for organizing perf tests. + * + * - 'core': Built-in RN components (View, Text, TextInput, Image, ScrollView) + * - 'extended': Higher-level RN components (FlatList, SectionList, RefreshControl) + * - 'custom': Community or app-specific components + */ +export type PerfTestCategory = 'core' | 'extended' | 'custom'; + +/** + * Contract that all component performance tests must implement. + */ +export interface IComponentPerfTest { + readonly componentName: string; + readonly category: PerfTestCategory; + readonly testId: string; + readonly defaultThreshold: PerfThreshold; + + measureMount(): Promise; + measureUnmount(): Promise; + measureRerender(): Promise; + + getCustomScenarios?(): IScenario[]; +} + +/** + * A single named test scenario that produces performance metrics. + */ +export interface IScenario { + name: string; + description: string; + run(): Promise; +} diff --git a/packages/@react-native-windows/perf-testing/src/interfaces/PerfMetrics.ts b/packages/@react-native-windows/perf-testing/src/interfaces/PerfMetrics.ts new file mode 100644 index 00000000000..1a0979455b3 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/interfaces/PerfMetrics.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +/** + * Performance metrics collected from a single test scenario run. + * + * All duration values are in milliseconds. + */ +export interface PerfMetrics { + /** Test scenario name (e.g., 'View mount') */ + name: string; + + /** Mean duration across all runs (ms) */ + meanDuration: number; + + /** Median duration across all runs (ms) */ + medianDuration: number; + + /** Standard deviation of durations (ms) */ + stdDev: number; + + /** Average render count per run */ + renderCount: number; + + /** Number of test runs performed */ + runs: number; + + /** Raw durations from each run (ms), used for statistical comparison */ + durations: number[]; + + /** ISO timestamp of when the measurement was taken */ + timestamp: string; + + /** + * Extension point for native timing data. + * Reserved for future integration with native perf markers. + */ + nativeTimings?: Record; + + /** + * Extension point for memory measurements. + * Reserved for future memory tracking integration. + */ + memoryDelta?: number; +} diff --git a/packages/@react-native-windows/perf-testing/src/interfaces/PerfThreshold.ts b/packages/@react-native-windows/perf-testing/src/interfaces/PerfThreshold.ts new file mode 100644 index 00000000000..38ee8880445 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/interfaces/PerfThreshold.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +/** + * Thresholds for pass/fail determination of a performance test. + * + * When comparing against a baseline snapshot, these thresholds + * determine whether a regression has occurred. + */ +export interface PerfThreshold { + /** Max allowed duration increase from baseline (percentage). Default: 10 */ + maxDurationIncrease?: number; + + /** Absolute max duration (ms) — fails if exceeded regardless of baseline */ + maxDuration?: number; + + /** + * Minimum absolute duration change (ms) required to flag a regression. + * Even if the percentage threshold is exceeded, regressions below this + * absolute delta are treated as environmental noise. Default: 3 + */ + minAbsoluteDelta?: number; + + /** Max allowed render count per measurement */ + maxRenderCount?: number; + + /** Minimum runs required for a valid measurement. Default: 10 */ + minRuns?: number; + + /** + * Max coefficient of variation (stdDev/mean) allowed for reliable comparison. + * Tests above this CV are too noisy to gate on — they warn instead of fail. + * Default: 0.5 (50%). Set lower for stricter noise filtering. + */ + maxCV?: number; + + /** + * 'gate' = fail CI on regression (default for stable tests). + * 'track' = warn only, never fail (for inherently noisy bulk scenarios). + */ + mode?: 'gate' | 'track'; +} + +export const DEFAULT_THRESHOLD: Readonly> = { + maxDurationIncrease: 10, + maxDuration: Infinity, + minAbsoluteDelta: 3, + maxRenderCount: 5, + minRuns: 10, + maxCV: 0.5, + mode: 'gate', +}; diff --git a/packages/@react-native-windows/perf-testing/src/interfaces/index.ts b/packages/@react-native-windows/perf-testing/src/interfaces/index.ts new file mode 100644 index 00000000000..5afa4202ecb --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/interfaces/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export type {PerfMetrics} from './PerfMetrics'; +export type {PerfThreshold} from './PerfThreshold'; +export {DEFAULT_THRESHOLD} from './PerfThreshold'; +export type { + IComponentPerfTest, + IScenario, + PerfTestCategory, +} from './IComponentPerfTest'; diff --git a/packages/@react-native-windows/perf-testing/src/matchers/index.ts b/packages/@react-native-windows/perf-testing/src/matchers/index.ts new file mode 100644 index 00000000000..19f067431d5 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/matchers/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +// Auto-register the toMatchPerfSnapshot matcher when this module is imported. +import './toMatchPerfSnapshot'; + +export {SnapshotManager} from './snapshotManager'; +export type {SnapshotEntry, SnapshotFile} from './snapshotManager'; diff --git a/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts b/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts new file mode 100644 index 00000000000..3f9a3aa5d87 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import fs from '@react-native-windows/fs'; +import * as path from 'path'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import type {PerfThreshold} from '../interfaces/PerfThreshold'; + +/** + * A single entry stored in a `.perf-baseline.json` file. + */ +export interface SnapshotEntry { + metrics: PerfMetrics; + threshold: PerfThreshold; + capturedAt: string; +} + +/** + * A map of snapshot key → entry, representing one `.perf-baseline.json` file. + */ +export type SnapshotFile = Record; + +/** + * Manages reading and writing of perf snapshot files. + */ +export class SnapshotManager { + static getSnapshotPath(testFilePath: string): { + dir: string; + file: string; + } { + const testDir = path.dirname(testFilePath); + const snapshotDir = path.join(testDir, '__perf_snapshots__'); + const snapshotFile = path.join( + snapshotDir, + `${path.basename(testFilePath)}.perf-baseline.json`, + ); + return {dir: snapshotDir, file: snapshotFile}; + } + + static load(snapshotFilePath: string): SnapshotFile { + if (fs.existsSync(snapshotFilePath)) { + const content = fs.readFileSync(snapshotFilePath, 'utf-8'); + const parsed = JSON.parse(content) as SnapshotFile; + // JSON.stringify turns Infinity into null — restore it on load + for (const entry of Object.values(parsed)) { + if ((entry.threshold.maxDuration as unknown) === null) { + (entry.threshold as {maxDuration: number}).maxDuration = Infinity; + } + } + return parsed; + } + return {}; + } + + static save(snapshotFilePath: string, snapshots: SnapshotFile): void { + const dir = path.dirname(snapshotFilePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}); + } + fs.writeFileSync( + snapshotFilePath, + JSON.stringify(snapshots, null, 2) + '\n', + 'utf-8', + ); + } + + // Follows Jest's "test name 1" convention + static buildKey(testName: string): string { + return `${testName} 1`; + } +} diff --git a/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts b/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts new file mode 100644 index 00000000000..12911e959cb --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts @@ -0,0 +1,246 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import type {PerfThreshold} from '../interfaces/PerfThreshold'; +import {DEFAULT_THRESHOLD} from '../interfaces/PerfThreshold'; +import {SnapshotManager} from './snapshotManager'; +import type {SnapshotEntry} from './snapshotManager'; +import {coefficientOfVariation, mannWhitneyU} from '../core/statistics'; + +interface CompareResult { + errors: string[]; + warnings: string[]; +} + +function checkNoiseGates( + received: PerfMetrics, + baseline: SnapshotEntry, + threshold: PerfThreshold, +): {tooNoisy: boolean; statSignificant: boolean; warnings: string[]} { + const warnings: string[] = []; + + // CV gate + const cv = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + received.durations && received.durations.length >= 2 + ? coefficientOfVariation(received.durations) + : 0; + const maxCV = threshold.maxCV ?? DEFAULT_THRESHOLD.maxCV; + const tooNoisy = cv > maxCV; + + if (tooNoisy) { + warnings.push( + `High variance (CV=${(cv * 100).toFixed(1)}% > ${(maxCV * 100).toFixed( + 0, + )}%) — skipping regression check`, + ); + } + + // Mann-Whitney U statistical significance + let statSignificant = true; + if ( + !tooNoisy && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + received.durations && + received.durations.length >= 2 && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + baseline.metrics.durations && + baseline.metrics.durations.length >= 2 + ) { + const mwResult = mannWhitneyU( + baseline.metrics.durations, + received.durations, + ); + statSignificant = mwResult.significant; + if (!statSignificant) { + warnings.push( + `Not statistically significant (p=${mwResult.p.toFixed( + 3, + )}) — difference may be noise`, + ); + } + } + + return {tooNoisy, statSignificant, warnings}; +} + +function compareAgainstBaseline( + received: PerfMetrics, + baseline: SnapshotEntry, + threshold: PerfThreshold, +): CompareResult { + const {tooNoisy, statSignificant, warnings} = checkNoiseGates( + received, + baseline, + threshold, + ); + const errors: string[] = []; + + // Check duration regression using MEDIAN + if (!tooNoisy && threshold.maxDurationIncrease !== undefined) { + const pctChange = + ((received.medianDuration - baseline.metrics.medianDuration) / + baseline.metrics.medianDuration) * + 100; + const absDelta = received.medianDuration - baseline.metrics.medianDuration; + const minDelta = + threshold.minAbsoluteDelta ?? DEFAULT_THRESHOLD.minAbsoluteDelta; + + if ( + pctChange > threshold.maxDurationIncrease && + absDelta > minDelta && + statSignificant + ) { + errors.push( + `Duration regression: +${pctChange.toFixed(1)}% / +${absDelta.toFixed( + 2, + )}ms ` + + `(baseline median: ${baseline.metrics.medianDuration.toFixed( + 2, + )}ms → ` + + `current median: ${received.medianDuration.toFixed(2)}ms, ` + + `threshold: ${threshold.maxDurationIncrease}% & ${minDelta}ms)`, + ); + } + } + + // Check absolute duration limit + if ( + threshold.maxDuration !== undefined && + threshold.maxDuration !== Infinity && + received.medianDuration > threshold.maxDuration + ) { + errors.push( + `Duration exceeded: ${received.medianDuration.toFixed(2)}ms > ` + + `${threshold.maxDuration}ms (absolute limit)`, + ); + } + + // Check render count + if ( + threshold.maxRenderCount !== undefined && + received.renderCount > threshold.maxRenderCount + ) { + errors.push( + `Render count exceeded: ${received.renderCount} > ` + + `${threshold.maxRenderCount}`, + ); + } + + return {errors, warnings}; +} + +/** + * Extend Jest's Matchers interface so `toMatchPerfSnapshot` is recognized. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toMatchPerfSnapshot(threshold?: Partial): R; + } + } +} + +expect.extend({ + toMatchPerfSnapshot( + received: PerfMetrics, + customThreshold?: Partial, + ) { + const testPath = expect.getState().testPath!; + const testName = expect.getState().currentTestName!; + + // Resolve snapshot file location + const {file: snapshotFile} = SnapshotManager.getSnapshotPath(testPath); + const snapshotKey = SnapshotManager.buildKey(testName); + + const isUpdateMode = + process.argv.includes('-u') || process.argv.includes('--updateSnapshot'); + + // Load existing snapshots + const snapshots = SnapshotManager.load(snapshotFile); + const baseline = snapshots[snapshotKey]; + + const threshold: PerfThreshold = {...DEFAULT_THRESHOLD, ...customThreshold}; + + // UPDATE MODE or FIRST RUN: write new baseline + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (isUpdateMode || !baseline) { + snapshots[snapshotKey] = { + metrics: received, + threshold, + capturedAt: new Date().toISOString(), + }; + + SnapshotManager.save(snapshotFile, snapshots); + + return { + pass: true, + message: () => + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + `✅ Perf snapshot ${baseline ? 'updated' : 'created'}: ${ + received.name + }` + + ` (${received.meanDuration.toFixed(2)}ms, ${ + received.renderCount + } renders)`, + }; + } + + // COMPARE MODE + const {errors, warnings} = compareAgainstBaseline( + received, + baseline, + threshold, + ); + const isTrackMode = threshold.mode === 'track'; + const warningText = + warnings.length > 0 + ? '\n' + warnings.map(w => ` ℹ ${w}`).join('\n') + : ''; + + if (isTrackMode && errors.length > 0) { + return { + pass: true, + message: () => + `⚠️ [TRACK] "${received.name}" would have failed:\n\n` + + errors.map(e => ` • ${e}`).join('\n') + + warningText + + `\n\n💡 Mode is 'track' — not blocking CI.`, + }; + } + + if (errors.length > 0) { + return { + pass: false, + message: () => + `❌ Performance regression detected in "${received.name}":\n\n` + + errors.map(e => ` • ${e}`).join('\n') + + warningText + + `\n\n📊 Baseline captured: ${baseline.capturedAt}` + + `\n💡 Run with -u flag to update baseline if intentional.`, + }; + } + + // Calculate improvement for positive feedback + const improvement = + ((baseline.metrics.medianDuration - received.medianDuration) / + baseline.metrics.medianDuration) * + 100; + + return { + pass: true, + message: () => + `✅ "${received.name}" within threshold ` + + `(median: ${received.medianDuration.toFixed(2)}ms, ` + + `${ + improvement > 0 ? `-${improvement.toFixed(1)}% faster` : 'no change' + })`, + }; + }, +}); diff --git a/packages/@react-native-windows/perf-testing/src/reporters/ConsoleReporter.ts b/packages/@react-native-windows/perf-testing/src/reporters/ConsoleReporter.ts new file mode 100644 index 00000000000..1907ec7b675 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/reporters/ConsoleReporter.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfMetrics} from '../interfaces/PerfMetrics'; + +/** + * Outputs perf results to the console. + */ +export class ConsoleReporter { + static report(results: PerfMetrics[]): void { + console.log('\n⚡ Performance Test Results\n'); + console.log('─'.repeat(72)); + console.log( + padRight('Scenario', 30) + + padRight('Mean (ms)', 12) + + padRight('Median (ms)', 13) + + padRight('StdDev', 10) + + padRight('Renders', 8), + ); + console.log('─'.repeat(72)); + + for (const result of results) { + console.log( + padRight(result.name, 30) + + padRight(result.meanDuration.toFixed(2), 12) + + padRight(result.medianDuration.toFixed(2), 13) + + padRight(result.stdDev.toFixed(2), 10) + + padRight(String(result.renderCount), 8), + ); + } + + console.log('─'.repeat(72)); + console.log(`\nTotal scenarios: ${results.length}`); + console.log(`Timestamp: ${new Date().toISOString()}\n`); + } + + static reportSingle(result: PerfMetrics): void { + console.log( + ` ${result.name}: ` + + `${result.meanDuration.toFixed(2)}ms (±${result.stdDev.toFixed( + 2, + )}ms) ` + + `[${result.renderCount} renders, ${result.runs} runs]`, + ); + } +} + +function padRight(str: string, len: number): string { + return str.length >= len ? str : str + ' '.repeat(len - str.length); +} diff --git a/packages/@react-native-windows/perf-testing/src/reporters/MarkdownReporter.ts b/packages/@react-native-windows/perf-testing/src/reporters/MarkdownReporter.ts new file mode 100644 index 00000000000..731400bcd52 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/reporters/MarkdownReporter.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import type {PerfMetrics} from '../interfaces/PerfMetrics'; + +/** + * Comparison info used when generating markdown reports. + */ +export interface ComparisonResult { + metrics: PerfMetrics; + baselineMetrics?: PerfMetrics; + percentChange?: number; + passed: boolean; + error?: string; +} + +/** + * Generates Markdown-formatted perf results for CI. + */ +export class MarkdownReporter { + static generate(comparisons: ComparisonResult[]): string { + const passed = comparisons.filter(c => c.passed); + const failed = comparisons.filter(c => !c.passed); + + let md = ''; + + if (failed.length > 0) { + md += '### ❌ Regressions\n\n'; + md += '| Scenario | Baseline | Current | Change | Status |\n'; + md += '|----------|----------|---------|--------|--------|\n'; + + for (const f of failed) { + const baseline = f.baselineMetrics + ? `${f.baselineMetrics.meanDuration.toFixed(2)}ms` + : 'N/A'; + const current = `${f.metrics.meanDuration.toFixed(2)}ms`; + const change = + f.percentChange !== undefined + ? `+${f.percentChange.toFixed(1)}%` + : 'N/A'; + md += `| ${f.metrics.name} | ${baseline} | ${current} | ${change} | ❌ |\n`; + } + md += '\n'; + } + + if (passed.length > 0) { + md += '### ✅ Passed\n\n'; + md += `${passed.length} test(s) passed\n\n`; + md += '
\nShow details\n\n'; + md += '| Scenario | Mean | Median | StdDev | Renders |\n'; + md += '|----------|------|--------|--------|---------|\n'; + + for (const p of passed) { + md += + `| ${p.metrics.name}` + + ` | ${p.metrics.meanDuration.toFixed(2)}ms` + + ` | ${p.metrics.medianDuration.toFixed(2)}ms` + + ` | ±${p.metrics.stdDev.toFixed(2)}ms` + + ` | ${p.metrics.renderCount} |\n`; + } + md += '\n
\n'; + } + + return md; + } + + static summary(comparisons: ComparisonResult[]): string { + const passed = comparisons.filter(c => c.passed).length; + const failed = comparisons.filter(c => !c.passed).length; + const total = comparisons.length; + + if (failed > 0) { + return `❌ ${failed}/${total} perf tests failed — performance regressions detected.`; + } + return `✅ All ${passed} perf tests passed.`; + } +} diff --git a/packages/@react-native-windows/perf-testing/src/reporters/index.ts b/packages/@react-native-windows/perf-testing/src/reporters/index.ts new file mode 100644 index 00000000000..666538b6512 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/reporters/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export {ConsoleReporter} from './ConsoleReporter'; +export {MarkdownReporter} from './MarkdownReporter'; +export type {ComparisonResult} from './MarkdownReporter'; diff --git a/packages/@react-native-windows/perf-testing/src/scenarios/MountScenario.ts b/packages/@react-native-windows/perf-testing/src/scenarios/MountScenario.ts new file mode 100644 index 00000000000..ec9f9ff88be --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/scenarios/MountScenario.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; +import type {IScenario} from '../interfaces/IComponentPerfTest'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import {measurePerf} from '../core/measurePerf'; + +/** + * Reusable scenario that measures component mount time. + */ +export class MountScenario implements IScenario { + readonly name: string; + readonly description: string; + + constructor( + name: string, + private readonly component: React.ReactElement, + private readonly options: {runs?: number; warmupRuns?: number} = {}, + ) { + this.name = name; + this.description = `Mount scenario for ${name}`; + } + + async run(): Promise { + return measurePerf(this.component, { + name: this.name, + runs: this.options.runs ?? 10, + warmupRuns: this.options.warmupRuns ?? 1, + }); + } +} diff --git a/packages/@react-native-windows/perf-testing/src/scenarios/RerenderScenario.ts b/packages/@react-native-windows/perf-testing/src/scenarios/RerenderScenario.ts new file mode 100644 index 00000000000..7c1fc0c0719 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/scenarios/RerenderScenario.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; +import type {IScenario} from '../interfaces/IComponentPerfTest'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import {measurePerf} from '../core/measurePerf'; +import type {RenderHelpers} from '../core/measurePerf'; + +/** + * Reusable scenario that measures component re-render time. + */ +export class RerenderScenario implements IScenario { + readonly name: string; + readonly description: string; + + constructor( + name: string, + private readonly component: React.ReactElement, + private readonly options: { + runs?: number; + warmupRuns?: number; + triggerRerender?: (helpers: RenderHelpers) => void | Promise; + } = {}, + ) { + this.name = name; + this.description = `Rerender scenario for ${name}`; + } + + async run(): Promise { + return measurePerf(this.component, { + name: this.name, + runs: this.options.runs ?? 10, + warmupRuns: this.options.warmupRuns ?? 1, + scenario: async helpers => { + if (this.options.triggerRerender) { + await this.options.triggerRerender(helpers); + } else { + // Default: re-render the same component (forces reconciliation) + helpers.rerender(this.component); + } + }, + }); + } +} diff --git a/packages/@react-native-windows/perf-testing/src/scenarios/UnmountScenario.ts b/packages/@react-native-windows/perf-testing/src/scenarios/UnmountScenario.ts new file mode 100644 index 00000000000..89dc6848868 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/scenarios/UnmountScenario.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import * as React from 'react'; +import type {IScenario} from '../interfaces/IComponentPerfTest'; +import type {PerfMetrics} from '../interfaces/PerfMetrics'; +import {measurePerf} from '../core/measurePerf'; + +/** + * Reusable scenario that measures component unmount time. + */ +export class UnmountScenario implements IScenario { + readonly name: string; + readonly description: string; + + constructor( + name: string, + private readonly component: React.ReactElement, + private readonly options: {runs?: number; warmupRuns?: number} = {}, + ) { + this.name = name; + this.description = `Unmount scenario for ${name}`; + } + + async run(): Promise { + return measurePerf(this.component, { + name: this.name, + runs: this.options.runs ?? 10, + warmupRuns: this.options.warmupRuns ?? 1, + measureUnmount: true, + }); + } +} diff --git a/packages/@react-native-windows/perf-testing/src/scenarios/index.ts b/packages/@react-native-windows/perf-testing/src/scenarios/index.ts new file mode 100644 index 00000000000..c7833eb498f --- /dev/null +++ b/packages/@react-native-windows/perf-testing/src/scenarios/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +export {MountScenario} from './MountScenario'; +export {UnmountScenario} from './UnmountScenario'; +export {RerenderScenario} from './RerenderScenario'; diff --git a/packages/@react-native-windows/perf-testing/tsconfig.json b/packages/@react-native-windows/perf-testing/tsconfig.json new file mode 100644 index 00000000000..faba54ae3c8 --- /dev/null +++ b/packages/@react-native-windows/perf-testing/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@rnw-scripts/ts-config", + "compilerOptions": { + "outDir": "lib-commonjs", + "rootDir": "src", + "composite": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/e2e-test-app-fabric/.gitignore b/packages/e2e-test-app-fabric/.gitignore index 78ac6fd423a..5537bace104 100644 --- a/packages/e2e-test-app-fabric/.gitignore +++ b/packages/e2e-test-app-fabric/.gitignore @@ -7,4 +7,5 @@ /dist /windows/RNTesterApp-Fabric/Bundle/ msbuild.binlog -/Bundle \ No newline at end of file +/Bundle +/.perf-results \ No newline at end of file diff --git a/packages/e2e-test-app-fabric/jest.perf.config.js b/packages/e2e-test-app-fabric/jest.perf.config.js new file mode 100644 index 00000000000..6d735d92299 --- /dev/null +++ b/packages/e2e-test-app-fabric/jest.perf.config.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Jest configuration for performance tests. + * + * These tests run in a lightweight node environment using react-test-renderer + * (NOT the full UIA automation environment used by E2E tests). + * + * Run: yarn perf + * Update baselines: yarn perf:update + * + * @format + * @ts-check + */ + +// Ensure the rnx-kit preset picks up the windows platform +// so react-native resolves correctly and __DEV__ is defined. +process.env.RN_TARGET_PLATFORM = process.env.RN_TARGET_PLATFORM || 'windows'; + +const assetTransform = 'react-native-windows/jest/assetFileTransformer.js'; + +module.exports = { + preset: '@rnx-kit/jest-preset', + + // Only run perf tests + roots: ['/test/'], + testMatch: ['**/__perf__/**/*.perf-test.{ts,tsx}'], + + // Perf tests need more time for multiple measurement runs + testTimeout: 120000, + + // Run sequentially for consistent measurements + maxWorkers: 1, + + // Setup file registers perf matchers + setupFilesAfterEnv: ['./jest.perf.setup.ts'], + + // Transform configuration — matches existing E2E config + transform: { + '\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': assetTransform, + 'node_modules\\\\@?react-native\\\\.*': 'babel-jest', + '@react-native-windows\\\\tester\\\\.*': 'babel-jest', + '@react-native-windows\\\\perf-testing\\\\.*': 'babel-jest', + 'vnext\\\\.*': 'babel-jest', + '\\.[jt]sx?$': 'babel-jest', + }, + + transformIgnorePatterns: ['jest-runner', 'node_modules\\\\safe-buffer'], + + globals: { + __DEV__: true, + }, + + verbose: true, + + // In CI, also emit a machine-readable JSON results file. + // The PerfJsonReporter collects all snapshot data after the run. + reporters: process.env.CI + ? [ + 'default', + [ + '@react-native-windows/perf-testing/lib-commonjs/ci/PerfJsonReporter', + {outputFile: '.perf-results/results.json'}, + ], + ] + : ['default'], +}; diff --git a/packages/e2e-test-app-fabric/jest.perf.setup.ts b/packages/e2e-test-app-fabric/jest.perf.setup.ts new file mode 100644 index 00000000000..d045a322be9 --- /dev/null +++ b/packages/e2e-test-app-fabric/jest.perf.setup.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Setup file for performance tests. + * Registers the toMatchPerfSnapshot Jest matcher. + * + * @format + */ + +// Import registers the `toMatchPerfSnapshot` matcher globally +import '@react-native-windows/perf-testing'; diff --git a/packages/e2e-test-app-fabric/package.json b/packages/e2e-test-app-fabric/package.json index e7b956b6ca3..9edff68ead6 100644 --- a/packages/e2e-test-app-fabric/package.json +++ b/packages/e2e-test-app-fabric/package.json @@ -11,7 +11,15 @@ "e2etest": "npx @react-native-community/cli rnx-test --platform windows", "e2etest:updateSnapshots": "npx @react-native-community/cli rnx-test --platform windows -u", "e2etest:debug": "npx @react-native-community/cli rnx-test --platform windows --config ./jest.debug.config.js", - "bundle:debug": "npx @react-native-community/cli bundle --entry-file index.js --bundle-output ./windows/x64/Debug/RNTesterApp-Fabric/Bundle/index.windows.bundle --assets-dest ./windows/x64/Debug/RNTesterApp-Fabric/Bundle --platform windows" + "bundle:debug": "npx @react-native-community/cli bundle --entry-file index.js --bundle-output ./windows/x64/Debug/RNTesterApp-Fabric/Bundle/index.windows.bundle --assets-dest ./windows/x64/Debug/RNTesterApp-Fabric/Bundle --platform windows", + "perf": "jest --config jest.perf.config.js", + "perf:update": "jest --config jest.perf.config.js -u", + "perf:core": "jest --config jest.perf.config.js --testPathPattern=__perf__/core", + "perf:extended": "jest --config jest.perf.config.js --testPathPattern=__perf__/extended", + "perf:ci": "jest --config jest.perf.config.js --ci --forceExit", + "perf:ci:compare": "node ../../vnext/Scripts/perf/compare-results.js", + "perf:ci:report": "node ../../vnext/Scripts/perf/post-pr-comment.js", + "perf:create": "node ../../vnext/Scripts/perf/create-perf-test.js" }, "dependencies": { "@react-native-windows/automation-channel": "0.0.0-canary.1031", @@ -33,6 +41,7 @@ "@react-native/metro-config": "0.83.0-nightly-20250917-18cb4edfa", "@react-native-windows/automation": "0.0.0-canary.1031", "@react-native-windows/automation-commands": "0.0.0-canary.1031", + "@react-native-windows/perf-testing": "0.0.0-canary.1031", "@rnw-scripts/babel-node-config": "2.3.3", "@rnw-scripts/babel-react-native-config": "0.0.0", "@rnw-scripts/eslint-config": "1.2.38", diff --git a/packages/e2e-test-app-fabric/test/__perf__/core/ActivityIndicator.perf-test.tsx b/packages/e2e-test-app-fabric/test/__perf__/core/ActivityIndicator.perf-test.tsx new file mode 100644 index 00000000000..815d8062523 --- /dev/null +++ b/packages/e2e-test-app-fabric/test/__perf__/core/ActivityIndicator.perf-test.tsx @@ -0,0 +1,213 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Performance tests for ActivityIndicator component. + * + * @format + */ + +import * as React from 'react'; +import {ActivityIndicator, View, StyleSheet} from 'react-native'; +import { + ComponentPerfTestBase, + measurePerf, +} from '@react-native-windows/perf-testing'; +import type {IScenario, PerfMetrics} from '@react-native-windows/perf-testing'; + +class ActivityIndicatorPerfTest extends ComponentPerfTestBase { + readonly componentName = 'ActivityIndicator'; + readonly category = 'core' as const; + readonly testId = 'perf-test-activity-indicator'; + + createComponent(props?: Record): React.ReactElement { + return ; + } + + getCustomScenarios(): IScenario[] { + return [ + { + name: 'size-large', + description: 'ActivityIndicator with size="large"', + run: () => this.measureSizeLarge(), + }, + { + name: 'size-small', + description: 'ActivityIndicator with size="small"', + run: () => this.measureSizeSmall(), + }, + { + name: 'with-color', + description: 'ActivityIndicator with custom color', + run: () => this.measureWithColor(), + }, + { + name: 'not-animating', + description: 'ActivityIndicator with animating={false}', + run: () => this.measureNotAnimating(), + }, + { + name: 'with-accessibility', + description: 'ActivityIndicator with accessibility props', + run: () => this.measureWithAccessibility(), + }, + { + name: 'multiple-indicators-10', + description: 'Render 10 sibling ActivityIndicators', + run: () => this.measureMultiple(10), + }, + { + name: 'multiple-indicators-50', + description: 'Render 50 sibling ActivityIndicators', + run: () => this.measureMultiple(50), + }, + ]; + } + + private async measureSizeLarge(): Promise { + return measurePerf( + , + { + name: `${this.componentName} size-large`, + runs: 10, + }, + ); + } + + private async measureSizeSmall(): Promise { + return measurePerf( + , + { + name: `${this.componentName} size-small`, + runs: 10, + }, + ); + } + + private async measureWithColor(): Promise { + return measurePerf( + , + { + name: `${this.componentName} with-color`, + runs: 10, + }, + ); + } + + private async measureNotAnimating(): Promise { + return measurePerf( + , + { + name: `${this.componentName} not-animating`, + runs: 10, + }, + ); + } + + private async measureWithAccessibility(): Promise { + return measurePerf( + , + { + name: `${this.componentName} with-accessibility`, + runs: 10, + }, + ); + } + + private async measureMultiple(count: number): Promise { + const IndicatorList = () => ( + + {Array.from({length: count}, (_, i) => ( + + ))} + + ); + + return measurePerf(, { + name: `${this.componentName} multiple-${count}`, + runs: 15, + }); + } +} + +const activityIndicatorPerfTest = new ActivityIndicatorPerfTest(); + +describe('ActivityIndicator Performance', () => { + test('mount time', async () => { + const perf = await activityIndicatorPerfTest.measureMount(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('unmount time', async () => { + const perf = await activityIndicatorPerfTest.measureUnmount(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('rerender time', async () => { + const perf = await activityIndicatorPerfTest.measureRerender(); + expect(perf).toMatchPerfSnapshot(); + }); + + describe('ActivityIndicator-Specific Scenarios', () => { + test('size-large', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[0]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('size-small', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[1]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('with-color', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[2]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('not-animating', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[3]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('with-accessibility', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[4]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot(); + }); + + test('multiple-indicators-10', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[5]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot({minAbsoluteDelta: 5}); + }); + + test('multiple-indicators-50', async () => { + const scenario = activityIndicatorPerfTest.getCustomScenarios()[6]; + const perf = await scenario.run(); + expect(perf).toMatchPerfSnapshot({ + maxDurationIncrease: 15, + minAbsoluteDelta: 5, + }); + }); + }); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/packages/e2e-test-app-fabric/test/__perf__/core/Button.perf-test.tsx b/packages/e2e-test-app-fabric/test/__perf__/core/Button.perf-test.tsx new file mode 100644 index 00000000000..6665d2e606c --- /dev/null +++ b/packages/e2e-test-app-fabric/test/__perf__/core/Button.perf-test.tsx @@ -0,0 +1,185 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Performance tests for Button component. + * + * @format + */ + +import * as React from 'react'; +import {Button, View, StyleSheet} from 'react-native'; +import { + ComponentPerfTestBase, + measurePerf, +} from '@react-native-windows/perf-testing'; +import type {IScenario, PerfMetrics} from '@react-native-windows/perf-testing'; + +class ButtonPerfTest extends ComponentPerfTestBase { + readonly componentName = 'Button'; + readonly category = 'core' as const; + readonly testId = 'perf-test-button'; + + createComponent(props?: Record): React.ReactElement { + return ( +