diff --git a/.github/workflows/perf-tests.yml b/.github/workflows/perf-tests.yml index 5d98e4b46a2..51a6ce647f6 100644 --- a/.github/workflows/perf-tests.yml +++ b/.github/workflows/perf-tests.yml @@ -105,6 +105,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: perf-results + include-hidden-files: true path: | packages/e2e-test-app-fabric/.perf-results/ packages/e2e-test-app-fabric/.native-perf-results/ diff --git a/change/@react-native-windows-automation-78164800-9ca8-4247-80db-488d96ee93bf.json b/change/@react-native-windows-automation-78164800-9ca8-4247-80db-488d96ee93bf.json new file mode 100644 index 00000000000..c6564677479 --- /dev/null +++ b/change/@react-native-windows-automation-78164800-9ca8-4247-80db-488d96ee93bf.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: auto-discover native perf results in compare report", + "packageName": "@react-native-windows/automation", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-automation-channel-66589478-9bd2-42e2-ab34-98bbf1e1af38.json b/change/@react-native-windows-automation-channel-66589478-9bd2-42e2-ab34-98bbf1e1af38.json new file mode 100644 index 00000000000..8612a775148 --- /dev/null +++ b/change/@react-native-windows-automation-channel-66589478-9bd2-42e2-ab34-98bbf1e1af38.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: auto-discover native perf results in compare report", + "packageName": "@react-native-windows/automation-channel", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-automation-commands-f8e5375a-d777-4ae0-a076-d79c397c6d13.json b/change/@react-native-windows-automation-commands-f8e5375a-d777-4ae0-a076-d79c397c6d13.json new file mode 100644 index 00000000000..284fa411e3b --- /dev/null +++ b/change/@react-native-windows-automation-commands-f8e5375a-d777-4ae0-a076-d79c397c6d13.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: auto-discover native perf results in compare report", + "packageName": "@react-native-windows/automation-commands", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-codegen-60591a59-c0f3-421d-93ab-c50b80733ce4.json b/change/@react-native-windows-codegen-60591a59-c0f3-421d-93ab-c50b80733ce4.json new file mode 100644 index 00000000000..964361cd9c8 --- /dev/null +++ b/change/@react-native-windows-codegen-60591a59-c0f3-421d-93ab-c50b80733ce4.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: capture live run metrics in PerfJsonReporter instead of re-reading baselines", + "packageName": "@react-native-windows/codegen", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-perf-testing-220b4fd8-6fce-4f20-bbc0-ba50f2f89d15.json b/change/@react-native-windows-perf-testing-220b4fd8-6fce-4f20-bbc0-ba50f2f89d15.json new file mode 100644 index 00000000000..6d3a768de11 --- /dev/null +++ b/change/@react-native-windows-perf-testing-220b4fd8-6fce-4f20-bbc0-ba50f2f89d15.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: capture live run metrics in PerfJsonReporter instead of re-reading baselines", + "packageName": "@react-native-windows/perf-testing", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/react-native-windows-995b7359-8f60-46fd-9939-5497cea09515.json b/change/react-native-windows-995b7359-8f60-46fd-9939-5497cea09515.json new file mode 100644 index 00000000000..cc15a0b4527 --- /dev/null +++ b/change/react-native-windows-995b7359-8f60-46fd-9939-5497cea09515.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: auto-discover native perf results in compare report", + "packageName": "react-native-windows", + "email": "74712637+iamAbhi-916@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts b/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts index 9acb9250828..183403e50af 100644 --- a/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts +++ b/packages/@react-native-windows/perf-testing/src/ci/PerfJsonReporter.ts @@ -78,11 +78,11 @@ export class PerfJsonReporter { const suites: SuiteResult[] = []; for (const suite of results.testResults) { - // Load the snapshot file for this test suite (written by toMatchPerfSnapshot) + // Use live run metrics captured during the test run const {file: snapshotFilePath} = SnapshotManager.getSnapshotPath( suite.testFilePath, ); - const snapshots = SnapshotManager.load(snapshotFilePath); + const snapshots = SnapshotManager.getRunMetrics(snapshotFilePath) ?? {}; const passed = suite.testResults.filter( t => t.status === 'passed', diff --git a/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts b/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts index 3f9a3aa5d87..7f702bc9219 100644 --- a/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts +++ b/packages/@react-native-windows/perf-testing/src/matchers/snapshotManager.ts @@ -7,6 +7,7 @@ import fs from '@react-native-windows/fs'; import * as path from 'path'; +import * as os from 'os'; import type {PerfMetrics} from '../interfaces/PerfMetrics'; import type {PerfThreshold} from '../interfaces/PerfThreshold'; @@ -26,8 +27,63 @@ export type SnapshotFile = Record; /** * Manages reading and writing of perf snapshot files. + * Also writes live run metrics to a temp directory so the CI reporter + * (which runs in the Jest main process) can access fresh results + * from the worker process. */ export class SnapshotManager { + /** Directory for live run metrics (cross-process via temp files). */ + private static readonly _runMetricsDir = + process.env.PERF_RUN_METRICS_DIR || + path.join(os.tmpdir(), 'rnw-perf-run-metrics'); + + /** Record a live metric entry for the current run (written to temp file). */ + static recordRunMetric( + snapshotFilePath: string, + key: string, + entry: SnapshotEntry, + ): void { + const metricsFile = path.join( + SnapshotManager._runMetricsDir, + Buffer.from(snapshotFilePath).toString('base64url') + '.json', + ); + let existing: SnapshotFile = {}; + if (fs.existsSync(metricsFile)) { + existing = JSON.parse( + fs.readFileSync(metricsFile, 'utf-8'), + ) as SnapshotFile; + } else if (!fs.existsSync(SnapshotManager._runMetricsDir)) { + fs.mkdirSync(SnapshotManager._runMetricsDir, {recursive: true}); + } + existing[key] = entry; + fs.writeFileSync( + metricsFile, + JSON.stringify(existing, null, 2) + '\n', + 'utf-8', + ); + } + + /** Get live run metrics for a snapshot file, or null if none were recorded. */ + static getRunMetrics(snapshotFilePath: string): SnapshotFile | null { + const metricsFile = path.join( + SnapshotManager._runMetricsDir, + Buffer.from(snapshotFilePath).toString('base64url') + '.json', + ); + if (fs.existsSync(metricsFile)) { + return JSON.parse(fs.readFileSync(metricsFile, 'utf-8')) as SnapshotFile; + } + return null; + } + + /** Clean up temp run metrics directory. */ + static clearRunMetrics(): void { + if (fs.existsSync(SnapshotManager._runMetricsDir)) { + for (const f of fs.readdirSync(SnapshotManager._runMetricsDir)) { + fs.unlinkSync(path.join(SnapshotManager._runMetricsDir, f)); + } + } + } + static getSnapshotPath(testFilePath: string): { dir: string; file: string; diff --git a/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts b/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts index 12911e959cb..52252c3758c 100644 --- a/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts +++ b/packages/@react-native-windows/perf-testing/src/matchers/toMatchPerfSnapshot.ts @@ -168,6 +168,13 @@ expect.extend({ const threshold: PerfThreshold = {...DEFAULT_THRESHOLD, ...customThreshold}; + // Always record the live metrics for the CI reporter + SnapshotManager.recordRunMetric(snapshotFile, snapshotKey, { + metrics: received, + threshold, + capturedAt: new Date().toISOString(), + }); + // UPDATE MODE or FIRST RUN: write new baseline // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (isUpdateMode || !baseline) { diff --git a/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js b/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js index 68558983a4e..c4550934b32 100644 --- a/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js +++ b/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js @@ -7,7 +7,7 @@ 'use strict'; import React from 'react'; -import { View, Text, Pressable } from 'react-native'; +import {View, Text, Pressable} from 'react-native'; function HitTestWithOverflowVisibile() { const [bgColor, setBgColor] = React.useState('red'); @@ -15,13 +15,13 @@ function HitTestWithOverflowVisibile() { return ( - Clicking the pressable should work even if it is outside the bounds - of its parent. + Clicking the pressable should work even if it is outside the bounds of + its parent. - Clicking within the visible view will trigger the pressable. - Clicking outside the bounds, where the pressable extends but is - clipped by its parent overflow:hidden, should not trigger the - pressable. + Clicking within the visible view will trigger the pressable. Clicking + outside the bounds, where the pressable extends but is clipped by its + parent overflow:hidden, should not trigger the pressable. - {text} + + {text} + - {text} + + {text} + x.trim() !== ''); + const contents = fs + .readFileSync(exclusionsFileName) + .toString() + .split(/\r?\n/) + .filter(x => x.trim() !== ''); this.suppressions = contents .filter(x => !x.startsWith('!')) .map(x => Checker.normalizeSlashes(x)); @@ -84,7 +83,6 @@ export class Checker { exclusions: string[]; urlCache: Record; - private async recurseFindMarkdownFiles( dirPath: string, callback: {(path: string): Promise}, @@ -136,9 +134,7 @@ export class Checker { } if (this.options['parse-ids']) { - await this.recurseFindMarkdownFiles(dirPath, x => - this.getAndStoreId(x), - ); + await this.recurseFindMarkdownFiles(dirPath, x => this.getAndStoreId(x)); } await this.recurseFindMarkdownFiles(dirPath, x => this.verifyMarkDownFile(x), @@ -249,9 +245,7 @@ export class Checker { const anchors = this.getAnchors(contents.toLowerCase()); if (!anchors.includes(sectionAnchor.toLowerCase())) { - if ( - !anchors.includes(sectionAnchor.replace(/\./g, '').toLowerCase()) - ) { + if (!anchors.includes(sectionAnchor.replace(/\./g, '').toLowerCase())) { if ( !( this.options['allow-local-line-sections'] && @@ -387,7 +381,7 @@ export class Checker { } private static isWebLink(url: string) { - return url.startsWith("https://") || url.startsWith('https://'); + return url.startsWith('https://') || url.startsWith('https://'); } async verifyMarkDownFile(filePath: string) { diff --git a/packages/@rnw-scripts/unbroken/src/unbroken.ts b/packages/@rnw-scripts/unbroken/src/unbroken.ts index 50dc20489cb..7b1ca4e69ff 100644 --- a/packages/@rnw-scripts/unbroken/src/unbroken.ts +++ b/packages/@rnw-scripts/unbroken/src/unbroken.ts @@ -55,7 +55,11 @@ async function run() { exclusions: {type: 'string', short: 'e'}, 'local-only': {type: 'boolean', short: 'l', default: false}, init: {type: 'boolean', short: 'i', default: false}, - 'allow-local-line-sections': {type: 'boolean', short: 'a', default: false}, + 'allow-local-line-sections': { + type: 'boolean', + short: 'a', + default: false, + }, quiet: {type: 'boolean', short: 'q', default: false}, superquiet: {type: 'boolean', short: 's', default: false}, 'parse-ids': {type: 'boolean', default: false}, diff --git a/packages/e2e-test-app-fabric/test/PointerButtonComponentTest.test.ts b/packages/e2e-test-app-fabric/test/PointerButtonComponentTest.test.ts index 3160a935a8e..4271a2ab1f5 100644 --- a/packages/e2e-test-app-fabric/test/PointerButtonComponentTest.test.ts +++ b/packages/e2e-test-app-fabric/test/PointerButtonComponentTest.test.ts @@ -123,18 +123,14 @@ describe('Pointer Button Tests', () => { }); test('onPointerUp reports correct button property on left click', async () => { await searchBox('onPointerUp'); - const component = await app.findElementByTestID( - 'pointer-up-button-target', - ); + const component = await app.findElementByTestID('pointer-up-button-target'); await component.waitForDisplayed({timeout: 5000}); const dump = await dumpVisualTree('pointer-up-button-target'); expect(dump).toMatchSnapshot(); // Left click release triggers onPointerUp with button=0 await component.click(); - const stateText = await app.findElementByTestID( - 'pointer-up-button-state', - ); + const stateText = await app.findElementByTestID('pointer-up-button-state'); await app.waitUntil( async () => { @@ -155,16 +151,12 @@ describe('Pointer Button Tests', () => { }); test('onPointerUp reports correct button property on middle click', async () => { await searchBox('onPointerUp'); - const component = await app.findElementByTestID( - 'pointer-up-button-target', - ); + const component = await app.findElementByTestID('pointer-up-button-target'); await component.waitForDisplayed({timeout: 5000}); // Middle click release triggers onPointerUp with button=1 await component.click({button: 'middle'}); - const stateText = await app.findElementByTestID( - 'pointer-up-button-state', - ); + const stateText = await app.findElementByTestID('pointer-up-button-state'); await app.waitUntil( async () => { @@ -185,16 +177,12 @@ describe('Pointer Button Tests', () => { }); test('onPointerUp reports correct button property on right click', async () => { await searchBox('onPointerUp'); - const component = await app.findElementByTestID( - 'pointer-up-button-target', - ); + const component = await app.findElementByTestID('pointer-up-button-target'); await component.waitForDisplayed({timeout: 5000}); // Right click release triggers onPointerUp with button=2 await component.click({button: 'right'}); - const stateText = await app.findElementByTestID( - 'pointer-up-button-state', - ); + const stateText = await app.findElementByTestID('pointer-up-button-state'); await app.waitUntil( async () => { diff --git a/packages/e2e-test-app-fabric/test/__perf__/interactive/TouchableOpacity.perf-test.tsx b/packages/e2e-test-app-fabric/test/__perf__/interactive/TouchableOpacity.perf-test.tsx index 425b81899a5..204b6aa7424 100644 --- a/packages/e2e-test-app-fabric/test/__perf__/interactive/TouchableOpacity.perf-test.tsx +++ b/packages/e2e-test-app-fabric/test/__perf__/interactive/TouchableOpacity.perf-test.tsx @@ -279,7 +279,7 @@ describe('TouchableOpacity Performance', () => { expect(perf).toMatchPerfSnapshot({ maxDurationIncrease: 30, minAbsoluteDelta: 15, - mode: 'gate', + mode: 'track', }); }); }); diff --git a/vnext/Scripts/perf/compare-results.js b/vnext/Scripts/perf/compare-results.js index 5fa12297d64..ee0a514ac0d 100644 --- a/vnext/Scripts/perf/compare-results.js +++ b/vnext/Scripts/perf/compare-results.js @@ -10,7 +10,7 @@ * node vnext/Scripts/perf/compare-results.js [options] * * Options: - * --results Path to CI results JSON (default: .perf-results/results.json) + * --results Path to CI results JSON (repeatable, default: .perf-results/results.json) * --baselines Path to base branch perf snapshots directory * --output Path to write markdown report (default: .perf-results/report.md) * --fail-on-regression Exit 1 if regressions found (default: true in CI) @@ -25,17 +25,29 @@ const path = require('path'); function parseArgs() { const args = process.argv.slice(2); + + // Auto-discover results files: JS perf + native perf + const defaultResults = [ + '.perf-results/results.json', + '.native-perf-results/results.json', + ].filter(p => fs.existsSync(p)); + const opts = { - results: '.perf-results/results.json', + results: defaultResults, baselines: null, // auto-detect from test file paths output: '.perf-results/report.md', failOnRegression: !!process.env.CI, }; + let explicitResults = false; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--results': - opts.results = args[++i]; + if (!explicitResults) { + opts.results = []; + explicitResults = true; + } + opts.results.push(args[++i]); break; case '--baselines': opts.baselines = args[++i]; @@ -257,17 +269,41 @@ function generateMarkdown(suiteComparisons, ciResults) { function main() { const opts = parseArgs(); - // 1. Load CI results JSON - if (!fs.existsSync(opts.results)) { - console.error(`❌ Results file not found: ${opts.results}`); + // 1. Load CI results JSON (supports multiple --results paths) + const ciResults = { + suites: [], + branch: '', + commitSha: '', + timestamp: '', + summary: { + totalSuites: 0, + totalTests: 0, + passed: 0, + failed: 0, + durationMs: 0, + }, + }; + const resultsPaths = opts.results.filter(p => fs.existsSync(p)); + if (resultsPaths.length === 0) { + console.error(`❌ No results files found: ${opts.results.join(', ')}`); console.error('Run perf tests with CI=true first: CI=true yarn perf:ci'); process.exit(1); } - - const ciResults = JSON.parse(fs.readFileSync(opts.results, 'utf-8')); - console.log( - `📊 Loaded ${ciResults.suites.length} suite(s) from ${opts.results}`, - ); + for (const resultsPath of resultsPaths) { + const partial = JSON.parse(fs.readFileSync(resultsPath, 'utf-8')); + ciResults.suites.push(...(partial.suites || [])); + ciResults.branch = ciResults.branch || partial.branch; + ciResults.commitSha = ciResults.commitSha || partial.commitSha; + ciResults.timestamp = ciResults.timestamp || partial.timestamp; + ciResults.summary.totalSuites += partial.summary?.totalSuites || 0; + ciResults.summary.totalTests += partial.summary?.totalTests || 0; + ciResults.summary.passed += partial.summary?.passed || 0; + ciResults.summary.failed += partial.summary?.failed || 0; + ciResults.summary.durationMs += partial.summary?.durationMs || 0; + console.log( + `📊 Loaded ${partial.suites.length} suite(s) from ${resultsPath}`, + ); + } // 2. Compare each suite against its committed baseline const suiteComparisons = [];