├── docusaurus ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ ├── reliable.png │ │ ├── logo_square.png │ │ ├── maintainable.png │ │ └── community_driven.png ├── babel.config.js ├── .eslintrc.js ├── docs │ ├── troubleshooting.md │ ├── examples.md │ ├── introduction.md │ ├── migration-v1.md │ └── methodology.md ├── tsconfig.json ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ ├── pages │ │ ├── index.module.css │ │ └── index.tsx │ └── css │ │ └── custom.css ├── .gitignore ├── sidebars.js ├── README.md ├── package.json └── docusaurus.config.js ├── packages ├── cli │ ├── src │ │ ├── index.ts │ │ ├── templates │ │ │ ├── gitignore │ │ │ ├── dangerfile │ │ │ └── reassure-tests │ │ ├── utils │ │ │ ├── logger.ts │ │ │ ├── git.ts │ │ │ ├── node.ts │ │ │ └── ascii.ts │ │ ├── constants.ts │ │ ├── bin.ts │ │ ├── options.ts │ │ └── commands │ │ │ ├── check-stability.ts │ │ │ ├── init.ts │ │ │ └── measure.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── logger │ ├── src │ │ ├── index.ts │ │ ├── colors.ts │ │ ├── warn-once.ts │ │ └── logger.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── CHANGELOG.md │ └── package.json ├── reassure │ ├── docs │ │ ├── logo.png │ │ ├── logo-dark.png │ │ ├── report-markdown.png │ │ ├── callstack-x-entain.png │ │ └── callstack-x-entain-dark.png │ ├── tsconfig.build.json │ ├── src │ │ ├── bin │ │ │ └── reassure.js │ │ └── index.ts │ ├── tsconfig.json │ └── package.json ├── compare │ ├── tsconfig.build.json │ ├── jest.config.js │ ├── src │ │ ├── utils │ │ │ ├── array.ts │ │ │ ├── markdown.ts │ │ │ ├── logs.ts │ │ │ ├── validate.ts │ │ │ ├── __tests__ │ │ │ │ └── format.test.ts │ │ │ └── format.ts │ │ ├── index.ts │ │ ├── output │ │ │ ├── json.ts │ │ │ ├── console.ts │ │ │ └── markdown.ts │ │ ├── test │ │ │ ├── default-type.perf │ │ │ ├── invalid-json.perf │ │ │ ├── valid-no-header.perf │ │ │ ├── invalid-entry.perf │ │ │ ├── valid-header.perf │ │ │ ├── compare.test.ts │ │ │ └── __snapshots__ │ │ │ │ └── compare.test.ts.snap │ │ ├── type-schemas.ts │ │ └── types.ts │ ├── babel.config.js │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── danger │ ├── tsconfig.build.json │ ├── src │ │ ├── index.ts │ │ └── dangerjs.ts │ ├── tsconfig.json │ ├── CHANGELOG.md │ └── package.json └── measure │ ├── tsconfig.build.json │ ├── jest.config.js │ ├── jest-setup.ts │ ├── babel.config.js │ ├── src │ ├── index.ts │ ├── config.ts │ ├── redundant-renders.tsx │ ├── polyfills.ts │ ├── outlier-helpers.tsx │ ├── __tests__ │ │ ├── measure-helpers.test.ts │ │ ├── measure-async-function.test.tsx │ │ ├── measure-function.test.tsx │ │ └── outlier-helpers.test.tsx │ ├── measure-helpers.tsx │ ├── measure-async-function.tsx │ ├── measure-function.tsx │ ├── output.ts │ ├── types.ts │ ├── testing-library.ts │ └── measure-renders.tsx │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── test-apps └── native │ ├── .watchmanconfig │ ├── .bundle │ └── config │ ├── tsconfig.json │ ├── .eslintrc.js │ ├── app.json │ ├── babel.config.js │ ├── jest.config.js │ ├── .prettierrc.js │ ├── jestSetup.js │ ├── index.js │ ├── __tests__ │ └── App.test.tsx │ ├── src │ ├── SlowList.test.tsx │ ├── App.tsx │ ├── SlowList.tsx │ ├── fib.perf.tsx │ ├── SlowList.perf.tsx │ ├── OtherTest.perf.tsx │ └── RedundantRenders.perf.tsx │ ├── metro.config.js │ ├── CHANGELOG.md │ ├── reassure-tests.sh │ ├── package.json │ └── .gitignore ├── .yarnrc.yml ├── .eslintignore ├── .gitattributes ├── .prettierrc.js ├── .changeset ├── large-bats-drive.md ├── rich-meals-read.md ├── config.json └── README.md ├── dangerfile.ts ├── RELEASING.md ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── improvement.md │ ├── feature_request.md │ └── bug-report.md ├── workflows │ ├── stability.yml │ ├── main.yml │ └── docs.yml └── actions │ └── setup-deps │ └── action.yml ├── turbo.json ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json └── CONTRIBUTING.md /docusaurus/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-apps/native/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | npmMinimalAgeGate: '3d' 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | docusaurus/build/** 2 | packages/**/lib/** 3 | dangerfile.ts 4 | -------------------------------------------------------------------------------- /packages/cli/src/templates/gitignore: -------------------------------------------------------------------------------- 1 | # Reassure output directory 2 | .reassure 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /test-apps/native/.bundle/config: -------------------------------------------------------------------------------- 1 | BUNDLE_PATH: "vendor/bundle" 2 | BUNDLE_FORCE_RUBY_PLATFORM: 1 3 | -------------------------------------------------------------------------------- /packages/logger/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | export { warnOnce } from './warn-once'; 3 | -------------------------------------------------------------------------------- /test-apps/native/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@react-native/typescript-config/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /test-apps/native/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native', 4 | }; 5 | -------------------------------------------------------------------------------- /test-apps/native/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ReassureTestApp", 3 | "displayName": "ReassureTestApp" 4 | } 5 | -------------------------------------------------------------------------------- /packages/reassure/docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/packages/reassure/docs/logo.png -------------------------------------------------------------------------------- /test-apps/native/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | printWidth: 120, 5 | }; 6 | -------------------------------------------------------------------------------- /docusaurus/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/docusaurus/static/img/favicon.ico -------------------------------------------------------------------------------- /docusaurus/static/img/reliable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/docusaurus/static/img/reliable.png -------------------------------------------------------------------------------- /docusaurus/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docusaurus/static/img/logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/docusaurus/static/img/logo_square.png -------------------------------------------------------------------------------- /packages/reassure/docs/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/packages/reassure/docs/logo-dark.png -------------------------------------------------------------------------------- /docusaurus/static/img/maintainable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/docusaurus/static/img/maintainable.png -------------------------------------------------------------------------------- /docusaurus/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: './../.eslintrc', 3 | rules: { 'react-native/no-raw-text': 0 }, 4 | }; 5 | -------------------------------------------------------------------------------- /docusaurus/static/img/community_driven.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/docusaurus/static/img/community_driven.png -------------------------------------------------------------------------------- /packages/reassure/docs/report-markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/packages/reassure/docs/report-markdown.png -------------------------------------------------------------------------------- /packages/cli/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/compare/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/danger/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/logger/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/measure/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/reassure/docs/callstack-x-entain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/packages/reassure/docs/callstack-x-entain.png -------------------------------------------------------------------------------- /packages/reassure/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /docusaurus/docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Troubleshooting 3 | sidebar_position: 5 4 | --- 5 | 6 | There are not current troubleshooting advice. 7 | -------------------------------------------------------------------------------- /packages/reassure/docs/callstack-x-entain-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/reassure/HEAD/packages/reassure/docs/callstack-x-entain-dark.png -------------------------------------------------------------------------------- /packages/measure/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | setupFilesAfterEnv: ['./jest-setup.ts'], 4 | clearMocks: true, 5 | }; 6 | -------------------------------------------------------------------------------- /test-apps/native/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | setupFilesAfterEnv: ['./jestSetup.js'], 4 | clearMocks: true, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/measure/jest-setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { configure } from './src/config'; 3 | 4 | configure({ testingLibrary: 'react-native' }); 5 | -------------------------------------------------------------------------------- /packages/logger/src/colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | brand: '#4FE89A', 3 | error: '#FF0000', 4 | warn: '#E7900E', 5 | verbose: '#919191', 6 | } as const; 7 | -------------------------------------------------------------------------------- /test-apps/native/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | }; 7 | -------------------------------------------------------------------------------- /test-apps/native/jestSetup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { configure } from 'reassure'; 3 | 4 | configure({ 5 | testingLibrary: 'react-native', 6 | verbose: true, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/compare/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePathIgnorePatterns: ['/lib/'], 3 | snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], 4 | clearMocks: true, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/compare/src/utils/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if array has duplicates. 3 | */ 4 | export function hasDuplicateValues(elements: T[]) { 5 | return elements.length !== new Set(elements).size; 6 | } 7 | -------------------------------------------------------------------------------- /packages/reassure/src/bin/reassure.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const importLocal = require('import-local'); 4 | 5 | if (!importLocal(__filename)) { 6 | require('@callstack/reassure-cli/bin/reassure'); 7 | } 8 | -------------------------------------------------------------------------------- /.changeset/large-bats-drive.md: -------------------------------------------------------------------------------- 1 | --- 2 | '@callstack/reassure-compare': patch 3 | '@callstack/reassure-measure': patch 4 | 'reassure-test-app': patch 5 | '@callstack/reassure-cli': patch 6 | --- 7 | 8 | chore: Jest 30 support 9 | -------------------------------------------------------------------------------- /packages/compare/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-react', '@babel/preset-typescript'], 3 | plugins: ['@babel/plugin-transform-flow-strip-types'], 4 | }; 5 | -------------------------------------------------------------------------------- /docusaurus/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | }, 7 | "exclude": ["./build"] 8 | } 9 | -------------------------------------------------------------------------------- /test-apps/native/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import { AppRegistry } from 'react-native'; 6 | import App from './src/App'; 7 | import { name as appName } from './app.json'; 8 | 9 | AppRegistry.registerComponent(appName, () => App); 10 | -------------------------------------------------------------------------------- /dangerfile.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import path from 'path'; 3 | import { dangerReassure } from 'reassure'; 4 | 5 | dangerReassure({ 6 | inputFilePath: path.join(__dirname, './test-apps/native/.reassure/output.md'), 7 | }); 8 | -------------------------------------------------------------------------------- /packages/cli/src/templates/dangerfile: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import path from 'path'; 3 | import { dangerReassure } from 'reassure'; 4 | 5 | dangerReassure({ 6 | inputFilePath: path.join(__dirname, './.reassure/output.md'), 7 | }); 8 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | We use Changeset to manage versions acros the monorepo. 4 | 5 | Steps make a new release: 6 | 7 | 1. Run `yarn version` 8 | 2. Review and commit changes to `main` branch 9 | 3. Run `yarn publish` 10 | 4. Run `git push && git push --tag` 11 | -------------------------------------------------------------------------------- /packages/logger/src/warn-once.ts: -------------------------------------------------------------------------------- 1 | import { warn } from './logger'; 2 | 3 | const warned = new Set(); 4 | 5 | export function warnOnce(message: string, ...args: unknown[]) { 6 | if (warned.has(message)) { 7 | return; 8 | } 9 | 10 | warn(message, ...args); 11 | warned.add(message); 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { configure } from '@callstack/reassure-logger'; 2 | import type { CommonOptions } from '../options'; 3 | 4 | export function configureLoggerOptions(options: CommonOptions) { 5 | configure({ 6 | silent: options.silent, 7 | verbose: options.verbose, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /.changeset/rich-meals-read.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'reassure': patch 3 | '@callstack/reassure-compare': patch 4 | '@callstack/reassure-measure': patch 5 | 'reassure-test-app': patch 6 | '@callstack/reassure-danger': patch 7 | '@callstack/reassure-logger': patch 8 | '@callstack/reassure-cli': patch 9 | --- 10 | 11 | chore: upgrade deps 12 | -------------------------------------------------------------------------------- /packages/danger/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module dangerjs 3 | * This plugin is intended to be imported/required and called inside your dangerfile.(js|ts) 4 | * by using the exported dangerReassure() function, optionally, passing an additional 5 | * configuration object. 6 | */ 7 | 8 | export { dangerReassure } from './dangerjs'; 9 | -------------------------------------------------------------------------------- /packages/measure/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-react', '@babel/preset-typescript'], 3 | plugins: ['@babel/plugin-transform-flow-strip-types'], 4 | env: { 5 | test: { 6 | presets: ['@react-native/babel-preset'], 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test-apps/native/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import React from 'react'; 6 | import ReactTestRenderer from 'react-test-renderer'; 7 | import App from '../src/App'; 8 | 9 | test('renders correctly', async () => { 10 | await ReactTestRenderer.act(() => { 11 | ReactTestRenderer.create(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /packages/compare/src/index.ts: -------------------------------------------------------------------------------- 1 | export { compare } from './compare'; 2 | export { formatMetadata } from './utils/format'; 3 | 4 | export type { 5 | MeasureHeader, 6 | MeasureMetadata, 7 | MeasureEntry, 8 | CompareResult, 9 | CompareMetadata, 10 | CompareEntry, 11 | AddedEntry, 12 | RemovedEntry, 13 | RenderIssues, 14 | } from './types'; 15 | -------------------------------------------------------------------------------- /test-apps/native/src/SlowList.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react-native'; 3 | import { SlowList } from './SlowList'; 4 | 5 | test('SlowList', () => { 6 | render(); 7 | 8 | const items = screen.getAllByText(/Item/i); 9 | expect(items).toHaveLength(10); 10 | }); 11 | -------------------------------------------------------------------------------- /docusaurus/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | 13 | .recruitmentLink { 14 | margin: 24px 0 0; 15 | } 16 | 17 | .attributionLink { 18 | margin: 32px 0 8px; 19 | } 20 | -------------------------------------------------------------------------------- /test-apps/native/metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); 2 | 3 | /** 4 | * Metro configuration 5 | * https://reactnative.dev/docs/metro 6 | * 7 | * @type {import('@react-native/metro-config').MetroConfig} 8 | */ 9 | const config = {}; 10 | 11 | module.exports = mergeConfig(getDefaultConfig(__dirname), config); 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@callstack/eslint-config/node', 3 | rules: { 4 | 'require-await': 'error', 5 | }, 6 | ignorePatterns: ['docusaurus/**', 'test-apps/**'], 7 | overrides: [ 8 | { 9 | files: ['*.ts', '*.tsx'], 10 | parserOptions: { 11 | project: './packages/**/tsconfig.json', 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /packages/compare/src/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | import { lineBreak } from 'ts-markdown-builder'; 2 | 3 | /** 4 | * Join lines of text into a single paragraph string with line breaks. 5 | * 6 | * @param lines - The lines of text to join. 7 | * @returns Paragraph string. 8 | */ 9 | export function joinLines(lines: string[]) { 10 | return lines.filter(Boolean).join(lineBreak); 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [["reassure", "@callstack/reassure-*", "reassure-test-app"]], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/compare/src/utils/logs.ts: -------------------------------------------------------------------------------- 1 | import * as logger from '@callstack/reassure-logger'; 2 | 3 | export let errors: string[] = []; 4 | export let warnings: string[] = []; 5 | 6 | export function logError(message: string, ...args: any[]) { 7 | errors.push(message); 8 | logger.error(`🛑 ${message}`, ...args); 9 | } 10 | 11 | export function logWarning(message: string) { 12 | warnings.push(message); 13 | logger.warn(`🟡 ${message}`); 14 | } 15 | -------------------------------------------------------------------------------- /test-apps/native/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { StyleSheet, View, Text } from 'react-native'; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | Test App 9 | 10 | ); 11 | } 12 | 13 | const styles = StyleSheet.create({ 14 | container: { 15 | flex: 1, 16 | alignItems: 'center', 17 | justifyContent: 'center', 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /packages/cli/src/templates/reassure-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | BASELINE_BRANCH=${GITHUB_BASE_REF:="main"} 5 | 6 | # Required for `git switch` on CI 7 | git fetch origin 8 | 9 | # Gather baseline perf measurements 10 | git switch "$BASELINE_BRANCH" 11 | 12 | yarn install 13 | yarn reassure --baseline 14 | 15 | # Gather current perf measurements & compare results 16 | git switch --detach - 17 | 18 | yarn install 19 | yarn reassure --branch 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Improvement 3 | about: Suggestions for improvement (refactors, CI, etc) 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the improvement** 10 | A clear and concise description of what the bug is. 11 | 12 | **Scope of improvement** 13 | Description what is the proposed scope of this change: what's in and what's out 14 | 15 | **Suggested implementation steps** 16 | Suggested steps to implement this improvement 17 | -------------------------------------------------------------------------------- /docusaurus/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Yarn 23 | .yarn/* 24 | !.yarn/cache 25 | !.yarn/patches 26 | !.yarn/plugins 27 | !.yarn/releases 28 | !.yarn/sdks 29 | !.yarn/versions 30 | 31 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["lib/**"] 7 | }, 8 | "clean": { 9 | "cache": false 10 | }, 11 | "typecheck": { 12 | "dependsOn": ["^build"] 13 | }, 14 | "test": { 15 | "dependsOn": ["^build"], 16 | "cache": false 17 | }, 18 | "deploy": { 19 | "dependsOn": ["build", "test", "typescript", "lint"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const RESULTS_DIRECTORY = '.reassure'; 2 | export const RESULTS_FILE = '.reassure/current.perf'; 3 | export const BASELINE_FILE = '.reassure/baseline.perf'; 4 | 5 | export const CI_SCRIPT = 'reassure-tests.sh'; 6 | 7 | export const DANGERFILE_JS = 'dangerfile.js'; 8 | export const DANGERFILE_TS = 'dangerfile.ts'; 9 | export const DANGERFILE_FALLBACK_JS = 'dangerfile-reassure.js'; 10 | export const DANGERFILE_FALLBACK_TS = 'dangerfile-reassure.ts'; 11 | 12 | export const GIT_IGNORE = '.gitignore'; 13 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/measure/src/index.ts: -------------------------------------------------------------------------------- 1 | export { configure, resetToDefaults } from './config'; 2 | export { measureRenders, measurePerformance } from './measure-renders'; 3 | export { measureFunction } from './measure-function'; 4 | export { measureAsyncFunction } from './measure-async-function'; 5 | export type { MeasureRendersOptions } from './measure-renders'; 6 | export type { MeasureFunctionOptions } from './measure-function'; 7 | export type { MeasureAsyncFunctionOptions } from './measure-async-function'; 8 | export type { MeasureType, MeasureResults } from './types'; 9 | -------------------------------------------------------------------------------- /packages/cli/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import yargs from 'yargs/yargs'; 3 | import { hideBin } from 'yargs/helpers'; 4 | import { command as init } from './commands/init'; 5 | import { command as checkStability } from './commands/check-stability'; 6 | import { command as measure } from './commands/measure'; 7 | 8 | void yargs(hideBin(process.argv)) 9 | .command(measure) 10 | .command(init) 11 | .command(checkStability) 12 | .help() 13 | .demandCommand(1, 'Please specify a command') 14 | .recommendCommands() 15 | .strict() 16 | .parse(); 17 | -------------------------------------------------------------------------------- /packages/cli/src/options.ts: -------------------------------------------------------------------------------- 1 | import type yargs from 'yargs'; 2 | 3 | export interface CommonOptions { 4 | /** Silent all non-error messages. */ 5 | silent: boolean; 6 | 7 | /** Show verbose-level logs. */ 8 | verbose: boolean; 9 | } 10 | 11 | export function applyCommonOptions(yargs: yargs.Argv<{}>) { 12 | return yargs 13 | .option('silent', { 14 | type: 'boolean', 15 | default: false, 16 | describe: 'Silence all logs except errors', 17 | }) 18 | .option('verbose', { 19 | type: 'boolean', 20 | default: false, 21 | describe: 'Output verbose level logs', 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /docusaurus/docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | id: examples 4 | --- 5 | 6 | # Examples 7 | 8 | Reassure works with any React or React Native project. Here you can find a list of the example integrations: 9 | 10 | - [React Native (Expo)](https://github.com/callstack/reassure-examples/tree/main/examples/native-expo) 11 | - [React Native (CLI)](https://github.com/callstack/reassure-examples/tree/main/examples/native-cli) 12 | - [React.js (Next.js)](https://github.com/callstack/reassure-examples/tree/main/examples/web-nextjs) 13 | - [React.js (Vite)](https://github.com/callstack/reassure-examples/tree/main/examples/web-vite) 14 | -------------------------------------------------------------------------------- /.github/workflows/stability.yml: -------------------------------------------------------------------------------- 1 | name: Test Performance Stability 2 | 3 | on: [workflow_dispatch] 4 | 5 | permissions: read-all 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: ${{ !contains(github.ref, 'main')}} 10 | 11 | jobs: 12 | test: 13 | name: Install and test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 18 | 19 | - name: Setup Deps 20 | uses: ./.github/actions/setup-deps 21 | 22 | - name: Run stability checks 23 | working-directory: ./test-apps/native 24 | run: yarn reassure check-stability 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE]' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/actions/setup-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node.js 2 | description: Setup Node.js 3 | 4 | inputs: 5 | working-directory: 6 | description: The working directory to install dependencies in 7 | required: true 8 | default: '.' 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Enable Corepack 14 | run: corepack enable 15 | shell: bash 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 19 | with: 20 | node-version: 22.x 21 | cache: 'yarn' 22 | 23 | - name: Install 24 | working-directory: ${{ inputs.working-directory }} 25 | run: yarn install --immutable 26 | shell: bash 27 | -------------------------------------------------------------------------------- /packages/compare/src/output/json.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as logger from '@callstack/reassure-logger'; 4 | import type { CompareResult } from '../types'; 5 | 6 | export async function writeToJson(filePath: string, data: CompareResult) { 7 | try { 8 | await fs.writeFile(filePath, JSON.stringify(data, null, 2)); 9 | 10 | logger.log(`✅ Written JSON output file ${filePath}`); 11 | logger.log(`🔗 ${path.resolve(filePath)}\n`); 12 | } catch (error) { 13 | logger.error(`❌ Could not write JSON output file ${filePath}`); 14 | logger.error(`🔗 ${path.resolve(filePath)}`); 15 | logger.error('Error details:', error); 16 | throw error; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/reassure/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | measureRenders, 3 | measureFunction, 4 | measureAsyncFunction, 5 | configure, 6 | resetToDefaults, 7 | measurePerformance, 8 | } from '@callstack/reassure-measure'; 9 | export { dangerReassure } from '@callstack/reassure-danger'; 10 | 11 | export type { 12 | MeasureResults, 13 | MeasureRendersOptions, 14 | MeasureFunctionOptions, 15 | MeasureAsyncFunctionOptions, 16 | MeasureType, 17 | } from '@callstack/reassure-measure'; 18 | export type { 19 | MeasureHeader, 20 | MeasureMetadata, 21 | MeasureEntry, 22 | CompareResult, 23 | CompareMetadata, 24 | CompareEntry, 25 | AddedEntry, 26 | RemovedEntry, 27 | RenderIssues, 28 | } from '@callstack/reassure-compare'; 29 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/compare/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/danger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/logger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/measure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/reassure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/compare/src/test/default-type.perf: -------------------------------------------------------------------------------- 1 | {"metadata":{"branch":"feat/perf-file-validation","commitHash":"991427a413b1ff05497a881287c9ddcba7b8de54"}} 2 | {"name":"Other Component 10 with type","type":"render","runs":10,"meanDuration":92.7,"stdevDuration":4.831608887776871,"durations":[100,97,95,94,94,94,93,90,86,84],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 3 | {"name":"Other Component 10 without type","runs":10,"meanDuration":90.5,"stdevDuration":4.552166761249221,"durations":[97,96,94,92,91,91,88,88,85,83],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 4 | {"name":"fib 30","type":"function","runs":10,"meanDuration":79.9,"stdevDuration":0.4532780026900862,"durations":[80,80,80,80,80,79,79,79,79,79],"meanCount":1,"stdevCount":0,"counts":[1,1,1,1,1,1,1,1,1,1]} 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "isolatedModules": true, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": ["esnext"], 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | }, 25 | "exclude": ["docusaurus"] 26 | } 27 | -------------------------------------------------------------------------------- /test-apps/native/src/SlowList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View, Text } from 'react-native'; 3 | interface Props { 4 | count: number; 5 | } 6 | 7 | export const SlowList = ({ count }: Props) => { 8 | const data = Array.from({ length: count }, (_, index) => index); 9 | 10 | return ( 11 | 12 | {data.map(item => ( 13 | 14 | ))} 15 | 16 | ); 17 | }; 18 | 19 | interface ItemProps { 20 | title: string; 21 | } 22 | 23 | const SlowListItem = ({ title }: ItemProps) => { 24 | const [, forceRender] = React.useState<{}>(); 25 | 26 | React.useEffect(() => { 27 | forceRender({}); 28 | }, [title]); 29 | 30 | return ( 31 | 32 | {title} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/cli/src/commands/check-stability.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs'; 2 | import { applyCommonOptions, CommonOptions } from '../options'; 3 | import { configureLoggerOptions } from '../utils/logger'; 4 | import { run as measure } from './measure'; 5 | 6 | export async function run(options: CommonOptions) { 7 | configureLoggerOptions(options); 8 | 9 | await measure({ ...options, baseline: true }); 10 | await measure({ ...options, baseline: false, compare: true }); 11 | } 12 | 13 | export const command: CommandModule<{}, CommonOptions> = { 14 | command: 'check-stability', 15 | describe: 'Checks how stable is the current machine by running measurements twice for the same code', 16 | builder: (yargs) => { 17 | return applyCommonOptions(yargs); 18 | }, 19 | handler: (args) => run(args), 20 | }; 21 | -------------------------------------------------------------------------------- /test-apps/native/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # example-native 2 | 3 | ## 1.4.0 4 | 5 | ### Patch Changes 6 | 7 | - 0870117: chore: upgrade deps 8 | - 4d7071c: chore: update test app 9 | 10 | ## 1.4.0-next.0 11 | 12 | ### Patch Changes 13 | 14 | - 0870117: chore: upgrade deps 15 | 16 | ## 1.0.2 17 | 18 | ### Patch Changes 19 | 20 | - 75dc782: chore: update deps 21 | 22 | ## 1.0.1 23 | 24 | ### Patch Changes 25 | 26 | - 0adb9cc: chore: update deps 27 | 28 | ## 1.0.0 29 | 30 | ### Minor Changes 31 | 32 | - 4352279: - Rename `measurePerformance` to `measureRenders`, `resetToDefault` to `resetToDefaults`. 33 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 34 | - ebcf9d6: chore: fix version deps 35 | 36 | ## 0.1.0 37 | 38 | ### Minor Changes 39 | 40 | - 7ad802b9: `testingLibrary` configuration option that replaces `render` and `cleanup` options 41 | -------------------------------------------------------------------------------- /packages/danger/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @callstack/reassure-danger 2 | 3 | ## 1.4.0 4 | 5 | ### Patch Changes 6 | 7 | - 0870117: chore: upgrade deps 8 | 9 | ## 1.4.0-next.0 10 | 11 | ### Patch Changes 12 | 13 | - 0870117: chore: upgrade deps 14 | 15 | ## 1.3.3 16 | 17 | ## 1.3.2 18 | 19 | ## 1.3.1 20 | 21 | ## 1.3.0 22 | 23 | ## 1.2.1 24 | 25 | ## 1.2.0 26 | 27 | ## 1.1.0 28 | 29 | ### Patch Changes 30 | 31 | - 0adb9cc: chore: update deps 32 | 33 | ## 1.0.0 34 | 35 | ### Minor Changes 36 | 37 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 38 | - ebcf9d6: chore: fix version deps 39 | 40 | ## 0.11.0 41 | 42 | ## 0.1.1 43 | 44 | ### Patch Changes 45 | 46 | - 5a1c3472: Changes for dependencies cleanup after monorepo migration 47 | 48 | ## 0.1.0 49 | 50 | ### Minor Changes 51 | 52 | - 76d60476: Extract reassure-danger to a seperate package 53 | -------------------------------------------------------------------------------- /packages/compare/src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { MeasureHeaderScheme, MeasureEntryScheme } from '../type-schemas'; 3 | import type { MeasureEntry, MeasureHeader } from '../types'; 4 | import { hasDuplicateValues } from './array'; 5 | 6 | const MeasureEntryArraySchema = z 7 | .array(MeasureEntryScheme) 8 | .refine((val) => !hasDuplicateValues(val.map((val) => val.name)), { 9 | message: `Your performance result file contains records with duplicated names. 10 | Please remove any non-unique names from your test suites and try again.`, 11 | }); 12 | 13 | export function parseHeader(header: unknown): MeasureHeader | null { 14 | return MeasureHeaderScheme.parse(header); 15 | } 16 | 17 | export function parseMeasureEntries(entries: unknown): MeasureEntry[] { 18 | return MeasureEntryArraySchema.parse(entries); 19 | } 20 | -------------------------------------------------------------------------------- /packages/measure/src/config.ts: -------------------------------------------------------------------------------- 1 | export type TestingLibrary = 'react' | 'react-native' | { render: Render; cleanup: Cleanup }; 2 | 3 | export type Render = (component: React.ReactElement) => any; 4 | export type Cleanup = () => void; 5 | 6 | type Config = { 7 | runs: number; 8 | warmupRuns: number; 9 | removeOutliers: boolean; 10 | outputFile: string; 11 | testingLibrary?: TestingLibrary; 12 | }; 13 | 14 | const defaultConfig: Config = { 15 | runs: 10, 16 | warmupRuns: 1, 17 | removeOutliers: true, 18 | outputFile: process.env.REASSURE_OUTPUT_FILE ?? '.reassure/current.perf', 19 | testingLibrary: undefined, 20 | }; 21 | 22 | export let config = defaultConfig; 23 | 24 | export function configure(customConfig: Partial) { 25 | config = { 26 | ...defaultConfig, 27 | ...customConfig, 28 | }; 29 | } 30 | 31 | export function resetToDefaults() { 32 | config = defaultConfig; 33 | } 34 | -------------------------------------------------------------------------------- /docusaurus/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /docusaurus/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ```sh 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ```sh 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ```sh 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ```sh 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ```sh 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docusaurus/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | background-color: #242526; 12 | } 13 | 14 | @media screen and (max-width: 996px) { 15 | .heroBanner { 16 | padding: 2rem; 17 | } 18 | } 19 | 20 | .buttons { 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | } 25 | 26 | .para { 27 | text-align: center; 28 | color: #fff; 29 | } 30 | 31 | .buttonContainer { 32 | margin-top: 40px; 33 | } 34 | 35 | .gettingStartedButton { 36 | font-size: 16px; 37 | font-weight: 600; 38 | color: #242526; 39 | 40 | padding: 12px 24px; 41 | border-radius: 12px; 42 | background-color: #4fe89a; 43 | 44 | transition: ease-in-out 0.3s; 45 | } 46 | 47 | .gettingStartedButton:hover { 48 | background-color: #2e2f30; 49 | text-decoration: none; 50 | } 51 | -------------------------------------------------------------------------------- /packages/cli/src/utils/git.ts: -------------------------------------------------------------------------------- 1 | import simpleGit from 'simple-git'; 2 | import * as logger from '@callstack/reassure-logger'; 3 | 4 | export async function getGitBranch() { 5 | try { 6 | const git = simpleGit(); 7 | const isRepo = await git.checkIsRepo(); 8 | if (!isRepo) { 9 | return undefined; 10 | } 11 | 12 | const branch = await git.revparse(['--abbrev-ref', 'HEAD']); 13 | return branch.trim() ? branch : undefined; 14 | } catch (error) { 15 | logger.warn('Failed to detect git branch', error); 16 | return undefined; 17 | } 18 | } 19 | 20 | export async function getGitCommitHash() { 21 | try { 22 | const git = simpleGit(); 23 | const isRepo = await git.checkIsRepo(); 24 | if (!isRepo) { 25 | return undefined; 26 | } 27 | 28 | const commitHash = await git.revparse(['HEAD']); 29 | return commitHash.trim() ? commitHash : undefined; 30 | } catch (error) { 31 | logger.warn('Failed to detect git commit hash', error); 32 | return undefined; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG]' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /test-apps/native/reassure-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | BASELINE_BRANCH=${GITHUB_BASE_REF:="main"} 5 | 6 | # Required for `git switch` on CI 7 | git fetch origin 8 | 9 | # Gather baseline perf measurements 10 | git switch "$BASELINE_BRANCH" 11 | 12 | # Next line is required because Reassure packages are imported from this monorepo and might require rebuilding. 13 | pushd ../.. && yarn install && yarn turbo run build && popd 14 | 15 | yarn install 16 | yarn reassure --baseline 17 | 18 | # Gather current perf measurements & compare results 19 | git stash # Get rid of any local changes 20 | git switch --detach - 21 | 22 | # Next line is required because Reassure packages are imported from this monorepo and might require rebuilding. 23 | pushd ../.. && yarn install && yarn turbo run build && popd 24 | 25 | yarn install 26 | yarn reassure 27 | 28 | # Print the output file path 29 | echo "🔷 Output file: .reassure/output.json" 30 | cat .reassure/output.json 31 | 32 | echo "🔷 Output file: .reassure/output.md" 33 | cat .reassure/output.md 34 | -------------------------------------------------------------------------------- /packages/logger/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @callstack/reassure-logger 2 | 3 | ## 1.4.0 4 | 5 | ### Patch Changes 6 | 7 | - 0870117: chore: upgrade deps 8 | 9 | ## 1.4.0-next.0 10 | 11 | ### Patch Changes 12 | 13 | - 0870117: chore: upgrade deps 14 | 15 | ## 1.3.3 16 | 17 | ## 1.3.2 18 | 19 | ## 1.3.1 20 | 21 | ## 1.3.0 22 | 23 | ## 1.2.1 24 | 25 | ## 1.2.0 26 | 27 | ### Patch Changes 28 | 29 | ## 1.1.0 30 | 31 | ### Patch Changes 32 | 33 | - 0adb9cc: chore: update deps 34 | 35 | ## 1.0.0 36 | 37 | ### Minor Changes 38 | 39 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 40 | - ebcf9d6: chore: fix version deps 41 | 42 | ## 0.11.0 43 | 44 | ### Minor Changes 45 | 46 | - b455bd4: refactor: simplify `reassure-logger` package exports 47 | 48 | ## 0.3.1 49 | 50 | ### Patch Changes 51 | 52 | - 2e127815: Removed "node:" prefix from "readline" import. 53 | 54 | ## 0.3.0 55 | 56 | ### Minor Changes 57 | 58 | - 235f37d4: init CLI command 59 | 60 | ## 0.2.0 61 | 62 | ### Minor Changes 63 | 64 | - 35af62a4: Custom logger implementation with verbose and silent modes. 65 | -------------------------------------------------------------------------------- /packages/compare/src/utils/__tests__/format.test.ts: -------------------------------------------------------------------------------- 1 | import { formatCountChange, formatCountDiff } from '../format'; 2 | 3 | test(`formatCountChange`, () => { 4 | expect(formatCountChange(1, 2)).toMatchInlineSnapshot(`"2 → 1 (-1, -50.0%) 🟢"`); 5 | expect(formatCountChange(1, 1)).toMatchInlineSnapshot(`"1 → 1 "`); 6 | expect(formatCountChange(2, 1)).toMatchInlineSnapshot(`"1 → 2 (+1, +100.0%) 🔴"`); 7 | expect(formatCountChange(1.01, 2.0)).toMatchInlineSnapshot(`"2 → 1.01 (-0.99, -49.5%) 🟢"`); 8 | expect(formatCountChange(1.45, 2.05)).toMatchInlineSnapshot(`"2.05 → 1.45 (-0.60, -29.3%) 🟢"`); 9 | }); 10 | 11 | test('formatCountDiff', () => { 12 | expect(formatCountDiff(2, 1)).toMatchInlineSnapshot('"+1"'); 13 | expect(formatCountDiff(0, 1)).toMatchInlineSnapshot('"-1"'); 14 | expect(formatCountDiff(2, 2)).toMatchInlineSnapshot('"±0"'); 15 | 16 | expect(formatCountDiff(1.01, 2.23)).toMatchInlineSnapshot(`"-1.22"`); 17 | expect(formatCountDiff(0.01, 5.54)).toMatchInlineSnapshot(`"-5.53"`); 18 | expect(formatCountDiff(1.01, 1.01)).toMatchInlineSnapshot('"±0"'); 19 | }); 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Yarn 44 | .yarn/* 45 | !.yarn/cache 46 | !.yarn/patches 47 | !.yarn/plugins 48 | !.yarn/releases 49 | !.yarn/sdks 50 | !.yarn/versions 51 | 52 | # Cocoapods 53 | # 54 | test-apps/native/ios/Pods 55 | 56 | # node.js 57 | # 58 | node_modules/ 59 | npm-debug.log 60 | yarn-debug.log 61 | yarn-error.log 62 | 63 | # BUCK 64 | buck-out/ 65 | \.buckd/ 66 | android/app/libs 67 | android/keystores/debug.keystore 68 | 69 | # Expo 70 | .expo/* 71 | 72 | # generated by bob 73 | lib/ 74 | 75 | # Reassure output files 76 | .reassure/ 77 | 78 | .turbo/ 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Callstack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-apps/native/src/fib.perf.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | measureFunction, 3 | measureAsyncFunction, 4 | } from '@callstack/reassure-measure'; 5 | 6 | function fib(n: number): number { 7 | if (n <= 1) { 8 | return n; 9 | } 10 | 11 | return fib(n - 1) + fib(n - 2); 12 | } 13 | 14 | jest.setTimeout(60_000); 15 | 16 | describe('`fib` function', () => { 17 | test('fib(30)', async () => { 18 | await measureFunction(() => fib(30)); 19 | }); 20 | 21 | test('fib(30) async', async () => { 22 | await measureAsyncFunction(async () => 23 | Promise.resolve().then(() => fib(30)), 24 | ); 25 | }); 26 | 27 | test('fib(31)', async () => { 28 | await measureFunction(() => fib(31)); 29 | }); 30 | 31 | test('fib(31) async', async () => { 32 | await measureAsyncFunction(async () => 33 | Promise.resolve().then(() => fib(31)), 34 | ); 35 | }); 36 | 37 | test('fib(32)', async () => { 38 | await measureFunction(() => fib(32)); 39 | }); 40 | 41 | test('fib(32) async', async () => { 42 | await measureAsyncFunction(async () => 43 | Promise.resolve().then(() => fib(32)), 44 | ); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/compare/src/test/invalid-json.perf: -------------------------------------------------------------------------------- 1 | {"name":"Other Component 10","type":"render","runs":10,"meanDuration":92.7,"stdevDuration":4.831608887776871,"durations":[100,97,95,94,94,94,93,90,86,84],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 2 | {"name":"Other Component 10 legacy scenario","type":"render","runs":10,"meanDuration":90.5,"stdevDuration":4.552166761249221,"durations":[97,96,94,92,91,91,88,88,85,83],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 3 | "name":"Other Component 20","type":"render","runs":20,"meanDuration":92.2,"stdevDuration":4.595191995301633,"durations":[99,99,98,98,96,95,94,93,93,93,93,92,90,90,89,88,88,87,87,82],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4]} 4 | {"name":"Async Component","type":"render","runs":10,"meanDuration":150.8,"stdevDuration":7.554248252914823,"durations":[160,158,158,155,153,150,149,145,144,136],"meanCount":7,"stdevCount":0,"counts":[7,7,7,7,7,7,7,7,7,7]} 5 | {"name":"fib 30","type":"function","runs":10,"meanDuration":79.9,"stdevDuration":0.4532780026900862,"durations":[80,80,80,80,80,79,79,79,79,79],"meanCount":1,"stdevCount":0,"counts":[1,1,1,1,1,1,1,1,1,1]} 6 | -------------------------------------------------------------------------------- /packages/compare/src/test/valid-no-header.perf: -------------------------------------------------------------------------------- 1 | {"name":"Other Component 10","type":"render","runs":10,"meanDuration":92.7,"stdevDuration":4.831608887776871,"durations":[100,97,95,94,94,94,93,90,86,84],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 2 | {"name":"Other Component 10 legacy scenario","type":"render","runs":10,"meanDuration":90.5,"stdevDuration":4.552166761249221,"durations":[97,96,94,92,91,91,88,88,85,83],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 3 | {"name":"Other Component 20","type":"render","runs":20,"meanDuration":92.2,"stdevDuration":4.595191995301633,"durations":[99,99,98,98,96,95,94,93,93,93,93,92,90,90,89,88,88,87,87,82],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4]} 4 | {"name":"Async Component","type":"render","runs":10,"meanDuration":150.8,"stdevDuration":7.554248252914823,"durations":[160,158,158,155,153,150,149,145,144,136],"meanCount":7,"stdevCount":0,"counts":[7,7,7,7,7,7,7,7,7,7]} 5 | {"name":"fib 30","type":"function","runs":10,"meanDuration":79.9,"stdevDuration":0.4532780026900862,"durations":[80,80,80,80,80,79,79,79,79,79],"meanCount":1,"stdevCount":0,"counts":[1,1,1,1,1,1,1,1,1,1]} 6 | -------------------------------------------------------------------------------- /docusaurus/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary-dark: #29784c; 10 | --ifm-color-primary-darker: #277148; 11 | --ifm-color-primary-darkest: #205d3b; 12 | --ifm-color-primary-light: #33925d; 13 | --ifm-color-primary-lighter: #359962; 14 | --ifm-color-primary-lightest: #3cad6e; 15 | --ifm-color-primary: #14874c; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #4fe89a; 23 | --ifm-color-primary-dark: #02a752; 24 | --ifm-color-primary-darker: #098143; 25 | --ifm-color-primary-darkest: #00602e; 26 | --ifm-color-primary-light: #abffd4; 27 | --ifm-color-primary-lighter: #92ffc7; 28 | --ifm-color-primary-lightest: #92ffc7; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /packages/compare/src/test/invalid-entry.perf: -------------------------------------------------------------------------------- 1 | {"metadata":{"branch":"feat/perf-file-validation","commitHash":"991427a413b1ff05497a881287c9ddcba7b8de54"}} 2 | {"name":"Other Component 10","type":"render","runs":10,"meanDuration":92.7,"stdevDuration":4.831608887776871,"durations":[100,97,95,94,94,94,93,90,86,84],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 3 | {"name":"Other Component 10 legacy scenario","type":"render","runs":"10","stdevDuration":4.552166761249221,"durations":[97,96,94,92,91,91,88,88,85,83],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 4 | {"name":"Other Component 20","type":"render","runs":20,"meanDuration":92.2,"stdevDuration":4.595191995301633,"durations":[99,99,98,98,96,95,94,93,93,93,93,92,90,90,89,88,88,87,87,82],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4]} 5 | {"name":"Async Component","type":"render","runs":10,"meanDuration":150.8,"stdevDuration":7.554248252914823,"durations":[160,158,158,155,153,150,149,145,144,136],"meanCount":7,"stdevCount":0,"counts":[7,7,7,7,7,7,7,7,7,7]} 6 | {"name":"fib 30","type":"function","runs":10,"meanDuration":79.9,"stdevDuration":0.4532780026900862,"durations":[80,80,80,80,80,79,79,79,79,79],"meanCount":1,"stdevCount":0,"counts":[1,1,1,1,1,1,1,1,1,1]} 7 | -------------------------------------------------------------------------------- /packages/compare/src/test/valid-header.perf: -------------------------------------------------------------------------------- 1 | {"metadata":{"branch":"feat/perf-file-validation","commitHash":"991427a413b1ff05497a881287c9ddcba7b8de54"}} 2 | {"name":"Other Component 10","type":"render","runs":10,"meanDuration":92.7,"stdevDuration":4.831608887776871,"durations":[100,97,95,94,94,94,93,90,86,84],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 3 | {"name":"Other Component 10 legacy scenario","type":"render","runs":10,"meanDuration":90.5,"stdevDuration":4.552166761249221,"durations":[97,96,94,92,91,91,88,88,85,83],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4]} 4 | {"name":"Other Component 20","type":"render","runs":20,"meanDuration":92.2,"stdevDuration":4.595191995301633,"durations":[99,99,98,98,96,95,94,93,93,93,93,92,90,90,89,88,88,87,87,82],"meanCount":4,"stdevCount":0,"counts":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4]} 5 | {"name":"Async Component","type":"render","runs":10,"meanDuration":150.8,"stdevDuration":7.554248252914823,"durations":[160,158,158,155,153,150,149,145,144,136],"meanCount":7,"stdevCount":0,"counts":[7,7,7,7,7,7,7,7,7,7]} 6 | {"name":"fib 30","type":"function","runs":10,"meanDuration":79.9,"stdevDuration":0.4532780026900862,"durations":[80,80,80,80,80,79,79,79,79,79],"meanCount":1,"stdevCount":0,"counts":[1,1,1,1,1,1,1,1,1,1]} 7 | -------------------------------------------------------------------------------- /packages/measure/src/redundant-renders.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactTestRendererJSON } from 'react-test-renderer'; 2 | import { format as prettyFormat, plugins } from 'pretty-format'; 3 | 4 | export type ElementJsonTree = ReactTestRendererJSON | ReactTestRendererJSON[] | null; 5 | 6 | export function detectRedundantUpdates(elementTrees: ElementJsonTree[], initialRenderCount: number): number[] { 7 | const result = []; 8 | 9 | for (let i = 1; i < elementTrees.length; i += 1) { 10 | if (isJsonTreeEqual(elementTrees[i], elementTrees[i - 1])) { 11 | // We want to return correct render index, so we need to take into account: 12 | // - initial render count that happened before we have access to the element tree 13 | // - the fact that the last initial render is double counted as first element tree 14 | result.push(i + initialRenderCount - 1); 15 | } 16 | } 17 | 18 | return result; 19 | } 20 | 21 | const formatOptionsZeroIndent = { 22 | plugins: [plugins.ReactTestComponent], 23 | indent: 0, 24 | }; 25 | 26 | function isJsonTreeEqual(left: ElementJsonTree | null, right: ElementJsonTree | null): boolean { 27 | const formattedLeft = prettyFormat(left, formatOptionsZeroIndent); 28 | const formattedRight = prettyFormat(right, formatOptionsZeroIndent); 29 | return formattedLeft === formattedRight; 30 | } 31 | -------------------------------------------------------------------------------- /packages/measure/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { performance as perf } from 'perf_hooks'; 2 | import { getTestingLibrary } from './testing-library'; 3 | 4 | export function applyRenderPolyfills() { 5 | const testingLibrary = getTestingLibrary(); 6 | if (testingLibrary === 'react-native') { 7 | polyfillPerformanceNow(); 8 | } 9 | } 10 | 11 | export function revertRenderPolyfills() { 12 | const testingLibrary = getTestingLibrary(); 13 | if (testingLibrary === 'react-native') { 14 | restorePerformanceNow(); 15 | } 16 | } 17 | 18 | /** 19 | * React Native Jest preset mocks the global.performance object, with `now()` method being `Date.now()`. 20 | * Ref: https://github.com/facebook/react-native/blob/3dfe22bd27429a43b4648c597b71f7965f31ca65/packages/react-native/jest/setup.js#L41 21 | * 22 | * Then React uses `performance.now()` in `Scheduler` to measure component render time. 23 | * https://github.com/facebook/react/blob/45804af18d589fd2c181f3b020f07661c46b73ea/packages/scheduler/src/forks/Scheduler.js#L59 24 | */ 25 | let originalPerformanceNow: () => number; 26 | 27 | function polyfillPerformanceNow() { 28 | originalPerformanceNow = global.performance?.now; 29 | global.performance.now = () => perf.now(); 30 | } 31 | 32 | function restorePerformanceNow() { 33 | globalThis.performance.now = originalPerformanceNow; 34 | } 35 | -------------------------------------------------------------------------------- /test-apps/native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reassure-test-app", 3 | "version": "1.4.0", 4 | "private": true, 5 | "scripts": { 6 | "lint": "eslint .", 7 | "start": "react-native start", 8 | "test": "jest", 9 | "perf-test": "reassure", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "react": "19.1.1", 14 | "react-native": "0.82.1" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.28.5", 18 | "@babel/preset-env": "^7.28.5", 19 | "@babel/runtime": "^7.28.4", 20 | "@react-native-community/cli": "20.0.2", 21 | "@react-native-community/cli-platform-android": "20.0.2", 22 | "@react-native-community/cli-platform-ios": "20.0.2", 23 | "@react-native/babel-preset": "0.82.1", 24 | "@react-native/eslint-config": "0.82.1", 25 | "@react-native/metro-config": "0.82.1", 26 | "@react-native/typescript-config": "0.82.1", 27 | "@testing-library/react-native": "^13.3.3", 28 | "@types/jest": "^30.0.0", 29 | "@types/react": "~19.1.17", 30 | "@types/react-test-renderer": "~19.1.0", 31 | "eslint": "^8.57.1", 32 | "jest": "^30.2.0", 33 | "prettier": "^3.6.2", 34 | "react-test-renderer": "19.1.1", 35 | "reassure": "workspace:^", 36 | "typescript": "^5.9.3" 37 | }, 38 | "engines": { 39 | "node": ">=18" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test-apps/native/src/SlowList.perf.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View, Text, Pressable } from 'react-native'; 3 | import { fireEvent, screen } from '@testing-library/react-native'; 4 | import { measureRenders } from 'reassure'; 5 | import { SlowList } from './SlowList'; 6 | 7 | const AsyncComponent = () => { 8 | const [count, setCount] = React.useState(0); 9 | 10 | const handlePress = () => { 11 | setTimeout(() => setCount(c => c + 1), 10); 12 | }; 13 | 14 | return ( 15 | 16 | 17 | Action 18 | 19 | 20 | Count: {count} 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | jest.setTimeout(60_000); 28 | 29 | test('Async Component', async () => { 30 | const scenario = async () => { 31 | const button = screen.getByText('Action'); 32 | 33 | fireEvent.press(button); 34 | await screen.findByText('Count: 1'); 35 | fireEvent.press(button); 36 | await screen.findByText('Count: 2'); 37 | fireEvent.press(button); 38 | await screen.findByText('Count: 3'); 39 | fireEvent.press(button); 40 | await screen.findByText('Count: 4'); 41 | fireEvent.press(button); 42 | await screen.findByText('Count: 5'); 43 | }; 44 | 45 | await measureRenders(, { scenario }); 46 | }); 47 | -------------------------------------------------------------------------------- /test-apps/native/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | **/.xcode.env.local 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | *.hprof 33 | .cxx/ 34 | *.keystore 35 | !debug.keystore 36 | .kotlin/ 37 | 38 | # node.js 39 | # 40 | node_modules/ 41 | npm-debug.log 42 | yarn-error.log 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://docs.fastlane.tools/best-practices/source-control/ 50 | 51 | **/fastlane/report.xml 52 | **/fastlane/Preview.html 53 | **/fastlane/screenshots 54 | **/fastlane/test_output 55 | 56 | # Bundle artifact 57 | *.jsbundle 58 | 59 | # Ruby / CocoaPods 60 | **/Pods/ 61 | /vendor/bundle/ 62 | 63 | # Temporary files created by Metro to check the health of the file watcher 64 | .metro-health-check* 65 | 66 | # testing 67 | /coverage 68 | 69 | # Yarn 70 | .yarn/* 71 | !.yarn/patches 72 | !.yarn/plugins 73 | !.yarn/releases 74 | !.yarn/sdks 75 | !.yarn/versions 76 | -------------------------------------------------------------------------------- /docusaurus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "USE_SSH=true CURRENT_BRANCH=feature/docusaurus-based-docs docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.1.1", 19 | "@docusaurus/preset-classic": "3.1.1", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^1.2.1", 22 | "prism-react-renderer": "^2.1.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "3.1.1", 28 | "@docusaurus/tsconfig": "3.1.1", 29 | "@docusaurus/types": "3.1.1", 30 | "@types/react": "^18.2.29", 31 | "typescript": "~5.2.2" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.5%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "engines": { 46 | "node": ">=18.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/measure/src/outlier-helpers.tsx: -------------------------------------------------------------------------------- 1 | /* Adapted from https://github.com/sharkdp/hyperfine/blob/3b0918511aee4d6f8860bb663cb7a7af57bc3814/src/outlier_detection.rs */ 2 | 3 | import * as math from 'mathjs'; 4 | 5 | // Minimum modified Z-score for a datapoint to be an outlier. Here, 1.4826 is a factor that 6 | // converts the MAD to an estimator for the standard deviation. The second factor is the number 7 | // of standard deviations. 8 | const OUTLIER_THRESHOLD = 1.4826 * 10; 9 | 10 | type OutlierResult = { 11 | results: T[]; 12 | outliers: T[]; 13 | }; 14 | 15 | export function findOutliers(items: T[]): OutlierResult { 16 | if (items.length <= 1) { 17 | return { 18 | results: items, 19 | outliers: [], 20 | }; 21 | } 22 | 23 | const durations = items.map(({ duration }) => duration); 24 | 25 | // Compute the sample median and median absolute deviation (MAD) 26 | const median = math.median(durations); 27 | const mad = math.mad(durations); 28 | 29 | return items.reduce>( 30 | (acc, result) => { 31 | const modifiedZScore = (result.duration - median) / (mad > 0 ? mad : Number.EPSILON); 32 | 33 | // An outlier is a point that is larger than the modified Z-score threshold 34 | if (Math.abs(modifiedZScore) > OUTLIER_THRESHOLD) { 35 | acc.outliers.push(result); 36 | } else { 37 | acc.results.push(result); 38 | } 39 | 40 | return acc; 41 | }, 42 | { 43 | results: [], 44 | outliers: [], 45 | } 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /docusaurus/docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Why Reassure? 3 | sidebar_position: 1 4 | --- 5 | 6 | ## The problem 7 | 8 | You want your React Native app to perform well and fast at all times. As a part of this goal, you profile the app, observe render patterns, apply memoization in the right places, etc. But it's all manual and too easy to unintentionally introduce performance regressions that would only get caught during QA or worse, by your users. 9 | 10 | ## This solution 11 | 12 | Reassure allows you to automate React Native app performance regression testing on CI or a local machine. The same way you write your 13 | integration and unit tests that automatically verify that your app is still _working correctly_, you can write 14 | performance tests that verify that your app still _working performantly_. 15 | 16 | You can think about it as a React performance testing library. In fact, Reassure is designed to reuse as much of your [React Native Testing Library](https://github.com/callstack/react-native-testing-library) tests and setup as possible. 17 | 18 | Reassure works by measuring render characteristics – duration and count – of the testing scenario you provide and comparing that to the stable version. It repeats the scenario multiple times to reduce impact of random variations in render times caused by the runtime environment. Then it applies statistical analysis to figure out whether the code changes are statistically significant or not. As a result, it generates a human-readable report summarizing the results and displays it on the CI or as a comment to your pull request. 19 | -------------------------------------------------------------------------------- /test-apps/native/src/OtherTest.perf.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, Pressable } from 'react-native'; 3 | import { screen, fireEvent } from '@testing-library/react-native'; 4 | import { measureRenders } from 'reassure'; 5 | 6 | import { SlowList } from './SlowList'; 7 | 8 | const AsyncComponent = () => { 9 | const [count, setCount] = React.useState(0); 10 | 11 | const handlePress = () => { 12 | setTimeout(() => setCount(c => c + 1), 10); 13 | }; 14 | 15 | return ( 16 | 17 | 18 | Action 19 | 20 | 21 | Count: {count} 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | jest.setTimeout(600_000); 29 | test(': 10 runs', async () => { 30 | const scenario = async () => { 31 | const button = screen.getByText('Action'); 32 | 33 | fireEvent.press(button); 34 | await screen.findByText('Count: 1'); 35 | fireEvent.press(button); 36 | await screen.findByText('Count: 2'); 37 | }; 38 | 39 | await measureRenders(, { scenario, runs: 10 }); 40 | }); 41 | 42 | test(': 20 runs', async () => { 43 | const scenario = async () => { 44 | const button = screen.getByText('Action'); 45 | 46 | fireEvent.press(button); 47 | await screen.findByText('Count: 1'); 48 | fireEvent.press(button); 49 | await screen.findByText('Count: 2'); 50 | }; 51 | 52 | await measureRenders(, { scenario, runs: 20 }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/measure/src/__tests__/measure-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { processRunResults } from '../measure-helpers'; 2 | 3 | test('processRunResults calculates correct means and stdevs', () => { 4 | const input = [ 5 | { duration: 10, count: 2 }, 6 | { duration: 12, count: 2 }, 7 | { duration: 14, count: 2 }, 8 | ]; 9 | 10 | expect(processRunResults(input, { warmupRuns: 0 })).toMatchInlineSnapshot(` 11 | { 12 | "counts": [ 13 | 2, 14 | 2, 15 | 2, 16 | ], 17 | "durations": [ 18 | 10, 19 | 12, 20 | 14, 21 | ], 22 | "meanCount": 2, 23 | "meanDuration": 12, 24 | "outlierDurations": undefined, 25 | "runs": 3, 26 | "stdevCount": 0, 27 | "stdevDuration": 2, 28 | "warmupDurations": [], 29 | } 30 | `); 31 | }); 32 | 33 | test('processRunResults applies warmupRuns option', () => { 34 | const input = [ 35 | { duration: 23, count: 1 }, 36 | { duration: 20, count: 5 }, 37 | { duration: 24, count: 5 }, 38 | { duration: 22, count: 5 }, 39 | ]; 40 | 41 | expect(processRunResults(input, { warmupRuns: 1 })).toMatchInlineSnapshot(` 42 | { 43 | "counts": [ 44 | 5, 45 | 5, 46 | 5, 47 | ], 48 | "durations": [ 49 | 20, 50 | 24, 51 | 22, 52 | ], 53 | "meanCount": 5, 54 | "meanDuration": 22, 55 | "outlierDurations": undefined, 56 | "runs": 3, 57 | "stdevCount": 0, 58 | "stdevDuration": 2, 59 | "warmupDurations": [ 60 | 23, 61 | ], 62 | } 63 | `); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/cli/src/utils/node.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path'; 2 | 3 | export function getJestBinPath() { 4 | try { 5 | // eslint-disable-next-line import/no-extraneous-dependencies 6 | const jestPackageJson = require('jest/package.json'); 7 | const jestPackagePath = dirname(require.resolve('jest/package.json')); 8 | return resolve(jestPackagePath, jestPackageJson.bin.jest || jestPackageJson.bin); 9 | } catch { 10 | return null; 11 | } 12 | } 13 | 14 | export function getNodeMajorVersion(): number { 15 | const version = process.versions.node; 16 | return parseInt(version.split('.')[0], 10); 17 | } 18 | 19 | const COMMON_NODE_FLAGS = [ 20 | // Expose garbage collector to be able to run it manually 21 | '--expose-gc', 22 | // Disable concurrent sweeping to make measurements more stable 23 | '--no-concurrent-sweeping', 24 | // Increase max memory size to reduce garbage collection frequency 25 | '--max-old-space-size=4096', 26 | ]; 27 | 28 | export function getNodeFlags(nodeMajorVersion: number): string[] { 29 | if (nodeMajorVersion < 18) { 30 | throw new Error('Node.js version 18 or higher is required to run performance tests'); 31 | } 32 | 33 | if (nodeMajorVersion == 18) { 34 | return [ 35 | ...COMMON_NODE_FLAGS, 36 | // Disable optimizing compilers, keep the baseline compilers: sparkplug (JS), liftoff (WASM) 37 | '--no-opt', 38 | ]; 39 | } 40 | 41 | return [ 42 | ...COMMON_NODE_FLAGS, 43 | // Disable optimizing compilers, keep the baseline compilers: sparkplug (JS), liftoff (WASM) 44 | '--max-opt=1', 45 | ]; 46 | } 47 | -------------------------------------------------------------------------------- /packages/measure/src/measure-helpers.tsx: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | import * as math from 'mathjs'; 3 | import type { MeasureResults } from './types'; 4 | import { findOutliers } from './outlier-helpers'; 5 | 6 | export interface RunResult { 7 | duration: number; 8 | count: number; 9 | } 10 | 11 | type ProcessRunResultsOptions = { 12 | warmupRuns: number; 13 | removeOutliers?: boolean; 14 | }; 15 | 16 | export function processRunResults(inputResults: RunResult[], options: ProcessRunResultsOptions): MeasureResults { 17 | const warmupResults = inputResults.slice(0, options.warmupRuns); 18 | const runResults = inputResults.slice(options.warmupRuns); 19 | 20 | const { results, outliers } = options.removeOutliers ? findOutliers(runResults) : { results: runResults }; 21 | 22 | const durations = results.map((result) => result.duration); 23 | const meanDuration = math.mean(...durations) as number; 24 | const stdevDuration = math.std(...durations); 25 | const warmupDurations = warmupResults.map((result) => result.duration); 26 | const outlierDurations = outliers?.map((result) => result.duration); 27 | 28 | const counts = runResults.map((result) => result.count); 29 | const meanCount = math.mean(...counts) as number; 30 | const stdevCount = math.std(...counts); 31 | 32 | return { 33 | runs: runResults.length, 34 | meanDuration, 35 | stdevDuration, 36 | durations, 37 | warmupDurations, 38 | outlierDurations, 39 | meanCount, 40 | stdevCount, 41 | counts, 42 | }; 43 | } 44 | 45 | export function getCurrentTime() { 46 | return performance.now(); 47 | } 48 | -------------------------------------------------------------------------------- /packages/logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@callstack/reassure-logger", 3 | "version": "1.4.0", 4 | "description": "Logger for Reassure project", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "source": "src/index.ts", 9 | "files": [ 10 | "lib", 11 | "!**/__tests__", 12 | "!**/__fixtures__", 13 | "!**/__mocks__" 14 | ], 15 | "scripts": { 16 | "build": "bob build", 17 | "clean": "del lib" 18 | }, 19 | "keywords": [ 20 | "react-native", 21 | "ios", 22 | "android" 23 | ], 24 | "repository": "https://github.com/callstack/reassure", 25 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 26 | "contributors": [ 27 | "Jakub Bujko (https://github.com/Xiltyn)", 28 | "Michał Pierzchała (https://github.com/thymikee)" 29 | ], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/callstack/reassure/issues" 33 | }, 34 | "homepage": "https://github.com/callstack/reassure#readme", 35 | "dependencies": { 36 | "chalk": "4.1.2" 37 | }, 38 | "devDependencies": { 39 | "del-cli": "^7.0.0", 40 | "react-native-builder-bob": "^0.24.0", 41 | "typescript": "^5.9.3" 42 | }, 43 | "react-native-builder-bob": { 44 | "source": "src", 45 | "output": "lib", 46 | "targets": [ 47 | "commonjs", 48 | "module", 49 | [ 50 | "typescript", 51 | { 52 | "project": "tsconfig.build.json" 53 | } 54 | ] 55 | ] 56 | }, 57 | "publishConfig": { 58 | "access": "public" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test-apps/native/src/RedundantRenders.perf.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View, Text, Pressable } from 'react-native'; 3 | import { fireEvent, screen } from '@testing-library/react-native'; 4 | import { measureRenders } from 'reassure'; 5 | 6 | jest.setTimeout(60_000); 7 | 8 | type RenderIssuesProps = { 9 | initial?: number; 10 | redundant?: number; 11 | }; 12 | 13 | const RenderIssues = ({ initial: initialUpdates = 0 }: RenderIssuesProps) => { 14 | const [count, setCount] = React.useState(0); 15 | const [_, forceRender] = React.useState(0); 16 | 17 | React.useEffect(() => { 18 | if (count < initialUpdates) { 19 | setCount(c => c + 1); 20 | } 21 | }, [count, initialUpdates]); 22 | 23 | return ( 24 | 25 | Count: ${count} 26 | 27 | forceRender(c => c + 1)}> 28 | Inc 29 | 30 | 31 | ); 32 | }; 33 | 34 | test('InitialRenders 1', async () => { 35 | await measureRenders(); 36 | }); 37 | 38 | test('InitialRenders 3', async () => { 39 | await measureRenders(); 40 | }); 41 | 42 | test('RedundantUpdates', async () => { 43 | const scenario = async () => { 44 | await fireEvent.press(screen.getByText('Inc')); 45 | }; 46 | 47 | await measureRenders(, { scenario }); 48 | }); 49 | 50 | test('ManyRenderIssues', async () => { 51 | const scenario = async () => { 52 | await fireEvent.press(screen.getByText('Inc')); 53 | await fireEvent.press(screen.getByText('Inc')); 54 | }; 55 | 56 | await measureRenders(, { scenario }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/measure/src/measure-async-function.tsx: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | import type { MeasureResults } from './types'; 3 | import { type RunResult, getCurrentTime, processRunResults } from './measure-helpers'; 4 | import { showFlagsOutputIfNeeded, writeTestStats } from './output'; 5 | import { MeasureFunctionOptions } from './measure-function'; 6 | 7 | export interface MeasureAsyncFunctionOptions extends MeasureFunctionOptions {} 8 | 9 | export async function measureAsyncFunction( 10 | fn: () => Promise, 11 | options?: MeasureAsyncFunctionOptions 12 | ): Promise { 13 | const stats = await measureAsyncFunctionInternal(fn, options); 14 | 15 | if (options?.writeFile !== false) { 16 | await writeTestStats(stats, 'async function'); 17 | } 18 | 19 | return stats; 20 | } 21 | 22 | async function measureAsyncFunctionInternal( 23 | fn: () => Promise, 24 | options?: MeasureAsyncFunctionOptions 25 | ): Promise { 26 | const runs = options?.runs ?? config.runs; 27 | const warmupRuns = options?.warmupRuns ?? config.warmupRuns; 28 | const removeOutliers = options?.removeOutliers ?? config.removeOutliers; 29 | 30 | showFlagsOutputIfNeeded(); 31 | 32 | const runResults: RunResult[] = []; 33 | for (let i = 0; i < runs + warmupRuns; i += 1) { 34 | await options?.beforeEach?.(); 35 | 36 | const timeStart = getCurrentTime(); 37 | await fn(); 38 | const timeEnd = getCurrentTime(); 39 | 40 | await options?.afterEach?.(); 41 | 42 | const duration = timeEnd - timeStart; 43 | runResults.push({ duration, count: 1 }); 44 | } 45 | 46 | return processRunResults(runResults, { warmupRuns, removeOutliers }); 47 | } 48 | -------------------------------------------------------------------------------- /packages/measure/src/measure-function.tsx: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | import type { MeasureResults } from './types'; 3 | import { type RunResult, getCurrentTime, processRunResults } from './measure-helpers'; 4 | import { showFlagsOutputIfNeeded, writeTestStats } from './output'; 5 | 6 | export interface MeasureFunctionOptions { 7 | runs?: number; 8 | warmupRuns?: number; 9 | removeOutliers?: boolean; 10 | writeFile?: boolean; 11 | beforeEach?: () => Promise | void; 12 | afterEach?: () => Promise | void; 13 | } 14 | 15 | export async function measureFunction(fn: () => void, options?: MeasureFunctionOptions): Promise { 16 | const stats = await measureFunctionInternal(fn, options); 17 | 18 | if (options?.writeFile !== false) { 19 | await writeTestStats(stats, 'function'); 20 | } 21 | 22 | return stats; 23 | } 24 | 25 | async function measureFunctionInternal(fn: () => void, options?: MeasureFunctionOptions): Promise { 26 | const runs = options?.runs ?? config.runs; 27 | const warmupRuns = options?.warmupRuns ?? config.warmupRuns; 28 | const removeOutliers = options?.removeOutliers ?? config.removeOutliers; 29 | 30 | showFlagsOutputIfNeeded(); 31 | 32 | const runResults: RunResult[] = []; 33 | for (let i = 0; i < runs + warmupRuns; i += 1) { 34 | await options?.beforeEach?.(); 35 | 36 | const timeStart = getCurrentTime(); 37 | fn(); 38 | const timeEnd = getCurrentTime(); 39 | 40 | await options?.afterEach?.(); 41 | 42 | const duration = timeEnd - timeStart; 43 | runResults.push({ duration, count: 1 }); 44 | } 45 | 46 | return processRunResults(runResults, { warmupRuns, removeOutliers }); 47 | } 48 | -------------------------------------------------------------------------------- /packages/cli/src/utils/ascii.ts: -------------------------------------------------------------------------------- 1 | export const ASCII_HELLO = ` 2 | ======================================================================== 3 | ========================= Welcome to Reassure ========================== 4 | ======================================================================== 5 | 6 | .(################# (################( 7 | *##################( (###################### 8 | *#########//////(## .########/ (########, 9 | *####### *####### (####### 10 | *####### #######/ #######/ 11 | *####### (############################( 12 | *####### (############################# 13 | *####### #######/ 14 | *####### ########, 15 | *####### ######### 16 | *####### (################(###### 17 | *####### /#####################( 18 | *####### (################( 19 | 20 | `; 21 | 22 | export const ASCII_BYE = ` 23 | ======================================================================== 24 | ====================== Built with ❤️ at Callstack ====================== 25 | ================================ --- =================================== 26 | ==================== Find us @ https://callstack.io ==================== 27 | ============= or on GitHub @ https://github.com/callstack ============== 28 | ======================================================================== 29 | `; 30 | -------------------------------------------------------------------------------- /packages/measure/src/output.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as logger from '@callstack/reassure-logger'; 3 | import { config } from './config'; 4 | import type { MeasureResults, MeasureType } from './types'; 5 | 6 | export async function writeTestStats( 7 | stats: MeasureResults, 8 | type: MeasureType, 9 | outputFilePath: string = config.outputFile 10 | ): Promise { 11 | const name = expect.getState().currentTestName; 12 | const line = JSON.stringify({ name, type, ...stats }) + '\n'; 13 | 14 | try { 15 | await fs.appendFile(outputFilePath, line); 16 | } catch (error) { 17 | logger.error(`Error writing ${outputFilePath}`, error); 18 | throw error; 19 | } 20 | } 21 | 22 | export async function clearTestStats(outputFilePath: string = config.outputFile): Promise { 23 | try { 24 | await fs.unlink(outputFilePath); 25 | } catch { 26 | logger.warn(`Cannot remove ${outputFilePath}. File doesn't exist or cannot be removed`); 27 | } 28 | } 29 | 30 | let hasShownFlagsOutput = false; 31 | 32 | export function showFlagsOutputIfNeeded() { 33 | if (hasShownFlagsOutput) { 34 | return; 35 | } 36 | 37 | if (!global.gc) { 38 | logger.error( 39 | '❌ Measure code is running under incorrect Node.js configuration.\n' + 40 | 'Performance test code should be run in Jest with certain Node.js flags to increase measurements stability.\n' + 41 | 'Make sure you use the Reassure CLI and run it using "reassure" command.' 42 | ); 43 | } else { 44 | logger.verbose('Measure code is running with correct Node.js configuration.'); 45 | } 46 | 47 | hasShownFlagsOutput = true; 48 | } 49 | 50 | export function setHasShownFlagsOutput(value: boolean) { 51 | hasShownFlagsOutput = value; 52 | } 53 | -------------------------------------------------------------------------------- /packages/danger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@callstack/reassure-danger", 3 | "version": "1.4.0", 4 | "description": "Performance testing companion for React and React Native", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index.ts", 10 | "files": [ 11 | "src", 12 | "lib", 13 | "!**/__tests__", 14 | "!**/__fixtures__", 15 | "!**/__mocks__" 16 | ], 17 | "scripts": { 18 | "build": "bob build", 19 | "clean": "del lib" 20 | }, 21 | "keywords": [ 22 | "react-native", 23 | "ios", 24 | "android" 25 | ], 26 | "repository": "https://github.com/callstack/reassure", 27 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 28 | "contributors": [ 29 | "Jakub Bujko (https://github.com/Xiltyn)", 30 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 31 | "Michał Pierzchała (https://github.com/thymikee)" 32 | ], 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/callstack/reassure/issues" 36 | }, 37 | "homepage": "https://github.com/callstack/reassure#readme", 38 | "devDependencies": { 39 | "del-cli": "^7.0.0", 40 | "react-native-builder-bob": "^0.24.0", 41 | "typescript": "^5.9.3" 42 | }, 43 | "react-native-builder-bob": { 44 | "source": "src", 45 | "output": "lib", 46 | "targets": [ 47 | "commonjs", 48 | "module", 49 | [ 50 | "typescript", 51 | { 52 | "project": "tsconfig.build.json" 53 | } 54 | ] 55 | ] 56 | }, 57 | "publishConfig": { 58 | "access": "public" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: ['**'] 8 | 9 | permissions: 10 | pull-requests: write # required for Danger to post comments 11 | statuses: write # required for Danger to post commit statuses 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: ${{ !contains(github.ref, 'main')}} 16 | 17 | jobs: 18 | validate: 19 | name: Validate 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 24 | 25 | - name: Setup Deps 26 | uses: ./.github/actions/setup-deps 27 | 28 | - name: Build 29 | run: yarn build 30 | 31 | - name: Validate TypeScript 32 | run: yarn typecheck && yarn typecheck:test-app 33 | 34 | - name: Validate ESLint 35 | run: yarn lint && yarn lint:deps 36 | 37 | - name: Run tests 38 | run: yarn test && yarn test:test-app 39 | 40 | - name: Run performance tests 41 | working-directory: ./test-apps/native 42 | run: ./reassure-tests.sh 43 | 44 | - name: Run Danger.js 45 | run: yarn danger ci 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | check-changeset: 50 | name: Check Changeset 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout repository 54 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 55 | with: 56 | fetch-depth: 0 57 | 58 | - name: Setup Deps 59 | uses: ./.github/actions/setup-deps 60 | 61 | - name: Check changeset 62 | if: github.ref != 'refs/heads/main' 63 | run: yarn changeset status --since=origin/${{ github.base_ref }} 64 | -------------------------------------------------------------------------------- /packages/measure/src/types.ts: -------------------------------------------------------------------------------- 1 | /** Type of measured performance characteristic. */ 2 | export type MeasureType = 'render' | 'function' | 'async function'; 3 | 4 | /** 5 | * Type representing the result of `measure*` functions. 6 | */ 7 | export interface MeasureResults { 8 | /** @deprecated Use `durations.length` instead */ 9 | runs: number; 10 | 11 | /** Arithmetic average of measured render/execution durations for each run */ 12 | meanDuration: number; 13 | 14 | /** Standard deviation of measured render/execution durations for each run */ 15 | stdevDuration: number; 16 | 17 | /** Array of measured render/execution durations for each run */ 18 | durations: number[]; 19 | 20 | /** Array of measured render/execution durations for each warmup run */ 21 | warmupDurations: number[]; 22 | 23 | /** Array of statistical outlier durations */ 24 | outlierDurations?: number[]; 25 | 26 | /** Arithmetic average of measured render/execution count for each run */ 27 | meanCount: number; 28 | 29 | /** Standard deviation of measured render/execution count for each run */ 30 | stdevCount: number; 31 | 32 | /** Array of measured render/execution count for each run */ 33 | counts: number[]; 34 | } 35 | 36 | export interface MeasureRendersResults extends MeasureResults { 37 | issues: RenderIssues; 38 | } 39 | 40 | export interface RenderIssues { 41 | /** 42 | * Update renders (re-renders) that happened immediately after component was created 43 | * e.g., synchronous `useEffects` containing `setState`. 44 | * 45 | * This types of re-renders can be optimized by initializing the component with proper state in 46 | * the initial render. 47 | */ 48 | initialUpdateCount: number; 49 | 50 | /** 51 | * Re-renders that resulted in rendering the same output as the previous render. This arrays contains numbers of render 52 | */ 53 | redundantUpdates: number[]; 54 | } 55 | -------------------------------------------------------------------------------- /docusaurus/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import React from 'react'; 3 | import clsx from 'clsx'; 4 | import Layout from '@theme/Layout'; 5 | import Link from '@docusaurus/Link'; 6 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 7 | 8 | import styles from './index.module.css'; 9 | 10 | function HomepageHeader() { 11 | return ( 12 |
13 |
14 | 15 | 16 | Reassure 21 | 22 |

Performance testing companion for React and React Native.

23 | 24 | 25 | Callstack x Entain 30 | 31 |
32 |
33 | 34 | Getting started 35 | 36 |
37 |
38 | ); 39 | } 40 | 41 | export default function Home(): JSX.Element { 42 | return ( 43 | 44 | 45 |
46 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'docusaurus/**' 8 | pull_request: 9 | branches: ['**'] 10 | paths: 11 | - 'docusaurus/**' 12 | 13 | permissions: 14 | contents: write # required to deploy to GitHub Pages 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: ${{ !contains(github.ref, 'main')}} 19 | 20 | jobs: 21 | deploy: 22 | name: Deploy to GitHub Pages 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 27 | 28 | - name: Setup Deps 29 | uses: ./.github/actions/setup-deps 30 | with: 31 | working-directory: ./docusaurus 32 | 33 | - name: Build website 34 | working-directory: ./docusaurus 35 | run: yarn build 36 | 37 | # Popular action to deploy to GitHub Pages: 38 | # Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus 39 | - name: Deploy to GitHub Pages 40 | uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 41 | if: github.ref == 'refs/heads/main' 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | # Build output to publish to the `gh-pages` branch: 45 | publish_dir: ./docusaurus/build 46 | # The following lines assign commit authorship to the official 47 | # GH-Actions bot for deploys to `gh-pages` branch: 48 | # https://github.com/actions/checkout/issues/13#issuecomment-724415212 49 | # The GH actions bot is used by default if you didn't specify the two fields. 50 | # You can swap them out with your own user credentials. 51 | user_name: github-actions[bot] 52 | user_email: 41898282+github-actions[bot]@users.noreply.github.com 53 | -------------------------------------------------------------------------------- /packages/compare/src/test/compare.test.ts: -------------------------------------------------------------------------------- 1 | import { loadFile } from '../compare'; 2 | 3 | describe('loadFile', () => { 4 | it('should load results file with header', () => { 5 | const results = loadFile(`${__dirname}/valid-header.perf`); 6 | 7 | expect(results.metadata).toEqual({ 8 | branch: 'feat/perf-file-validation', 9 | commitHash: '991427a413b1ff05497a881287c9ddcba7b8de54', 10 | }); 11 | 12 | const entries = Object.keys(results.entries); 13 | expect(entries).toHaveLength(5); 14 | expect(entries).toEqual([ 15 | 'Other Component 10', 16 | 'Other Component 10 legacy scenario', 17 | 'Other Component 20', 18 | 'Async Component', 19 | 'fib 30', 20 | ]); 21 | expect(results).toMatchSnapshot(); 22 | }); 23 | 24 | it('should load results file without header', () => { 25 | const results = loadFile(`${__dirname}/valid-no-header.perf`); 26 | 27 | expect(results.metadata).toBeUndefined(); 28 | 29 | const entries = Object.keys(results.entries); 30 | expect(entries).toHaveLength(5); 31 | expect(entries).toEqual([ 32 | 'Other Component 10', 33 | 'Other Component 10 legacy scenario', 34 | 'Other Component 20', 35 | 'Async Component', 36 | 'fib 30', 37 | ]); 38 | expect(results).toMatchSnapshot(); 39 | }); 40 | 41 | it('should fail for file with invalid JSON structure', () => { 42 | expect(() => loadFile(`${__dirname}/invalid-json.perf`)).toThrowErrorMatchingSnapshot(); 43 | }); 44 | 45 | it('should fail for file with invalid entry', () => { 46 | expect(() => loadFile(`${__dirname}/invalid-entry.perf`)).toThrowErrorMatchingSnapshot(); 47 | }); 48 | 49 | it('should support entries without type', () => { 50 | const results = loadFile(`${__dirname}/default-type.perf`); 51 | 52 | const types = Object.entries(results.entries).map(([_, value]) => value.type); 53 | expect(types).toEqual(['render', 'render', 'function']); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/compare/src/type-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | /** Metadata information for performance results. */ 4 | export const MeasureMetadataScheme = z.object({ 5 | branch: z.string().optional(), 6 | commitHash: z.string().optional(), 7 | creationDate: z.iso.datetime().optional(), 8 | }); 9 | 10 | /** Header of performance results file. */ 11 | export const MeasureHeaderScheme = z.object({ 12 | metadata: MeasureMetadataScheme, 13 | }); 14 | 15 | export const RenderIssuesScheme = z.object({ 16 | initialUpdateCount: z.number().optional(), 17 | redundantUpdates: z.array(z.number()).optional(), 18 | }); 19 | 20 | /** Entry in the performance results file. */ 21 | export const MeasureEntryScheme = z.object({ 22 | /** Name of the test scenario. */ 23 | name: z.string(), 24 | 25 | /** Type of the measured characteristic (render, function execution, async function execution). */ 26 | type: z.enum(['render', 'function', 'async function']).default('render'), 27 | 28 | /** Number of times the measurement test was run. */ 29 | runs: z.number(), 30 | 31 | /** Arithmetic average of measured render/execution durations for each run. */ 32 | meanDuration: z.number(), 33 | 34 | /** Standard deviation of measured render/execution durations for each run. */ 35 | stdevDuration: z.number(), 36 | 37 | /** Array of measured render/execution durations for each run. */ 38 | durations: z.array(z.number()), 39 | 40 | /** Array of measured render/execution durations for each run. */ 41 | warmupDurations: z.optional(z.array(z.number())), 42 | 43 | /** Array of statistical outlier durations. */ 44 | outlierDurations: z.optional(z.array(z.number())), 45 | 46 | /** Arithmetic average of measured render/execution counts for each run. */ 47 | meanCount: z.number(), 48 | 49 | /** Standard deviation of measured render/execution counts for each run. */ 50 | stdevCount: z.number(), 51 | 52 | /** Array of measured render/execution counts for each run. */ 53 | counts: z.array(z.number()), 54 | 55 | issues: z.optional(RenderIssuesScheme), 56 | }); 57 | -------------------------------------------------------------------------------- /packages/compare/src/types.ts: -------------------------------------------------------------------------------- 1 | /** Parsed performance results file. */ 2 | import type { z } from 'zod'; 3 | import type { 4 | MeasureEntryScheme, 5 | MeasureHeaderScheme, 6 | MeasureMetadataScheme, 7 | RenderIssuesScheme, 8 | } from './type-schemas'; 9 | 10 | export type MeasureHeader = z.infer; 11 | export type MeasureMetadata = z.infer; 12 | export type MeasureEntry = z.infer; 13 | export type RenderIssues = z.infer; 14 | export type MeasureType = MeasureEntry['type']; 15 | 16 | export interface MeasureResults { 17 | metadata?: MeasureMetadata; 18 | entries: Record; 19 | } 20 | 21 | /** 22 | * Compare entry for tests that have both baseline and current entry 23 | */ 24 | export interface CompareEntry { 25 | name: string; 26 | type: MeasureType; 27 | current: MeasureEntry; 28 | baseline: MeasureEntry; 29 | durationDiff: number; 30 | relativeDurationDiff: number; 31 | isDurationDiffSignificant: boolean; 32 | countDiff: number; 33 | relativeCountDiff: number; 34 | } 35 | 36 | /** 37 | * Compare entry for tests that have only current entry 38 | */ 39 | export interface AddedEntry { 40 | name: string; 41 | type: MeasureType; 42 | current: MeasureEntry; 43 | baseline?: undefined; 44 | } 45 | 46 | /** 47 | * Compare entry for tests that have only baseline entry 48 | */ 49 | export interface RemovedEntry { 50 | name: string; 51 | type: MeasureType; 52 | baseline: MeasureEntry; 53 | current?: undefined; 54 | } 55 | 56 | export interface CompareMetadata { 57 | current?: MeasureMetadata; 58 | baseline?: MeasureMetadata; 59 | } 60 | 61 | /** Output of compare function. */ 62 | export interface CompareResult { 63 | metadata: CompareMetadata; 64 | significant: CompareEntry[]; 65 | meaningless: CompareEntry[]; 66 | countChanged: CompareEntry[]; 67 | renderIssues: Array; 68 | added: AddedEntry[]; 69 | removed: RemovedEntry[]; 70 | errors: string[]; 71 | warnings: string[]; 72 | } 73 | -------------------------------------------------------------------------------- /packages/danger/src/dangerjs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | /** 5 | * Declaring dangerjs API methods used in the plugin 6 | * --- 7 | * @function warn(message: string): void; 8 | * - displays a warning message in Danger's output in MR after CI pipeline finishes running 9 | * - does NOT stop the pipeline run itself 10 | * @function markdown(message: string): void; 11 | * - displays a markdown content in Danger's output in MR after CI pipeline finishes running 12 | * - allows for displaying messages in markdown format 13 | */ 14 | declare function warn(message: string): void; 15 | declare function markdown(message: string): void; 16 | 17 | /** 18 | * Configuration object which can optionally be passed down to plugin's call. 19 | * By default, it will only pass the inputFilePath parameter 20 | */ 21 | export type DangerPluginConfig = { inputFilePath: string; debug?: boolean }; 22 | 23 | export function dangerReassure( 24 | config: DangerPluginConfig = { 25 | inputFilePath: '.reassure/output.md', 26 | } 27 | ) { 28 | const _warning = ` 29 | ⚠️ No output file found @ ${config.inputFilePath} 30 | ------------------------------------------------------------- 31 | Review reassure configuration and make sure your markdown output 32 | file can be found in the location shown above. Alternatively, 33 | you can pass your markdown output file location to plugin's 34 | config object in your dangerfile. 35 | ------------------------------------------------------------- 36 | `; 37 | 38 | try { 39 | const perfFilePath = path.resolve(config.inputFilePath); 40 | const perfFileContents = fs.readFileSync(perfFilePath, 'utf8'); 41 | 42 | if (config.debug) { 43 | if (!perfFileContents) { 44 | console.log(_warning); 45 | } else { 46 | console.log(perfFileContents); 47 | } 48 | } else { 49 | if (!perfFileContents) { 50 | warn(_warning); 51 | } else { 52 | markdown(perfFileContents); 53 | } 54 | } 55 | } catch (error) { 56 | console.error(_warning, error); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/logger/src/logger.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | import chalk from 'chalk'; 3 | import { colors } from './colors'; 4 | 5 | export type LoggerOptions = { 6 | /** Silent all non-error logs */ 7 | silent: boolean; 8 | 9 | /** Show verbose-level logs (default not shown) */ 10 | verbose: boolean; 11 | }; 12 | 13 | const defaultConfig: LoggerOptions = { 14 | verbose: false, 15 | silent: false, 16 | } as const; 17 | 18 | let config: LoggerOptions = { ...defaultConfig }; 19 | 20 | const colorError = chalk.hex(colors.error); 21 | const colorWarn = chalk.hex(colors.warn); 22 | const colorVerbose = chalk.hex(colors.verbose); 23 | 24 | export function configure(options: Partial) { 25 | config = { ...config, ...options }; 26 | } 27 | 28 | // Jest is wrapping console.* calls, so we need to get the raw console object 29 | const rawConsole = require('console') as typeof console; 30 | 31 | export function error(message: string, ...args: unknown[]) { 32 | rawConsole.error(colorError(message, ...args)); 33 | } 34 | 35 | export function warn(message: string, ...args: unknown[]) { 36 | if (config.silent) return; 37 | 38 | rawConsole.warn(colorWarn(message, ...args)); 39 | } 40 | 41 | export function log(message: string, ...args: unknown[]) { 42 | if (config.silent) return; 43 | 44 | rawConsole.log(message, ...args); 45 | } 46 | 47 | export function verbose(message: string, ...args: unknown[]) { 48 | if (!config.verbose || config.silent) return; 49 | 50 | rawConsole.log(colorVerbose(message, ...args)); 51 | } 52 | 53 | export function newLine() { 54 | if (config.silent) return; 55 | 56 | rawConsole.log(); 57 | } 58 | 59 | export function color(color: keyof typeof colors, ...args: unknown[]) { 60 | if (config.silent) return; 61 | 62 | return rawConsole.log(chalk.hex(colors[color])(...args)); 63 | } 64 | 65 | /** Log message that indicates progress of operation, does not output the trailing newline. */ 66 | export function progress(message: string) { 67 | process.stdout.write(message); 68 | } 69 | 70 | /** 71 | * Clears current lint. To be used in conjunction with `progress`. 72 | */ 73 | export function clearLine() { 74 | readline.clearLine(process.stdout, 0); 75 | readline.cursorTo(process.stdout, 0); 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reassure-root", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "Performance testing companion for React and React Native", 6 | "scripts": { 7 | "test": "turbo run test", 8 | "test:test-app": "cd ./test-apps/native && yarn test", 9 | "typecheck": "tsc --noEmit", 10 | "typecheck:test-app": "yarn --cwd ./test-apps/native typecheck", 11 | "lint": "eslint \"**/*.{js,ts,tsx}\"", 12 | "lint:deps": "yarn check-dependency-version-consistency .", 13 | "clean": "turbo run clean", 14 | "build": "turbo run build", 15 | "validate": "yarn lint && yarn lint:deps && yarn typecheck && yarn test", 16 | "changeset": "changeset", 17 | "version": "changeset version && yarn install", 18 | "publish": "yarn build && changeset publish", 19 | "danger:local": "danger local -b main" 20 | }, 21 | "keywords": [ 22 | "react-native", 23 | "ios", 24 | "android" 25 | ], 26 | "repository": "https://github.com/callstack/reassure", 27 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 28 | "contributors": [ 29 | "Jakub Bujko (https://github.com/Xiltyn)", 30 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 31 | "Michał Pierzchała (https://github.com/thymikee)" 32 | ], 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/callstack/reassure/issues" 36 | }, 37 | "homepage": "https://github.com/callstack/reassure#readme", 38 | "devDependencies": { 39 | "@callstack/eslint-config": "^15.0.0", 40 | "@changesets/cli": "^2.29.7", 41 | "babel-jest": "^30.2.0", 42 | "check-dependency-version-consistency": "^5.0.1", 43 | "danger": "^13.0.5", 44 | "eslint": "^8.57.1", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-flowtype": "^8.0.3", 47 | "eslint-plugin-prettier": "^5.5.4", 48 | "prettier": "^3.6.2", 49 | "reassure": "workspace:^", 50 | "turbo": "^2.6.1", 51 | "typescript": "^5.9.3" 52 | }, 53 | "resolutions": { 54 | "react-is": "19.1.1" 55 | }, 56 | "eslintIgnore": [ 57 | "node_modules/", 58 | "lib/" 59 | ], 60 | "workspaces": { 61 | "packages": [ 62 | "packages/*", 63 | "test-apps/native" 64 | ] 65 | }, 66 | "packageManager": "yarn@4.11.0" 67 | } 68 | -------------------------------------------------------------------------------- /packages/compare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@callstack/reassure-compare", 3 | "version": "1.4.0", 4 | "description": "Performance testing companion for React and React Native", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "source": "src/index.ts", 9 | "files": [ 10 | "lib", 11 | "!**/__tests__", 12 | "!**/__fixtures__", 13 | "!**/__mocks__" 14 | ], 15 | "scripts": { 16 | "build": "bob build", 17 | "clean": "del lib", 18 | "test": "jest" 19 | }, 20 | "keywords": [ 21 | "react-native", 22 | "ios", 23 | "android" 24 | ], 25 | "repository": "https://github.com/callstack/reassure", 26 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 27 | "contributors": [ 28 | "Jakub Bujko (https://github.com/Xiltyn)", 29 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 30 | "Michał Pierzchała (https://github.com/thymikee)" 31 | ], 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/callstack/reassure/issues" 35 | }, 36 | "homepage": "https://github.com/callstack/reassure#readme", 37 | "dependencies": { 38 | "@callstack/reassure-logger": "1.4.0", 39 | "ts-markdown-builder": "0.5.0", 40 | "ts-regex-builder": "^1.8.2", 41 | "zod": "^4.1.12" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.28.5", 45 | "@babel/plugin-transform-flow-strip-types": "^7.27.1", 46 | "@babel/preset-env": "^7.28.5", 47 | "@babel/preset-typescript": "^7.28.5", 48 | "@babel/runtime": "^7.28.4", 49 | "@relmify/jest-serializer-strip-ansi": "^1.0.2", 50 | "@types/jest": "^30.0.0", 51 | "@types/react": "~19.1.17", 52 | "babel-jest": "^30.2.0", 53 | "del-cli": "^7.0.0", 54 | "jest": "^30.2.0", 55 | "prettier": "^3.6.2", 56 | "react-native-builder-bob": "^0.24.0", 57 | "typescript": "^5.9.3" 58 | }, 59 | "react-native-builder-bob": { 60 | "source": "src", 61 | "output": "lib", 62 | "targets": [ 63 | "commonjs", 64 | "module", 65 | [ 66 | "typescript", 67 | { 68 | "project": "tsconfig.build.json" 69 | } 70 | ] 71 | ] 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /docusaurus/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './styles.module.css'; 4 | 5 | type FeatureItem = { 6 | title: string; 7 | source: string; 8 | description: JSX.Element; 9 | }; 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: 'Maintainable', 14 | source: require('@site/static/img/maintainable.png').default, 15 | description: <>Write maintainable performance tests for your React Native apps, 16 | }, 17 | { 18 | title: 'Reliable', 19 | source: require('@site/static/img/reliable.png').default, 20 | description: <>Extend your existing RNTL tests into performance tests easily, 21 | }, 22 | { 23 | title: 'Community Driven', 24 | source: require('@site/static/img/community_driven.png').default, 25 | description: <>Supported by React Native community and its core contributors, 26 | }, 27 | ]; 28 | 29 | function Feature({ title, source, description }: FeatureItem) { 30 | return ( 31 |
32 |
33 | 34 |
35 |
36 |

{title}

37 |

{description}

38 |
39 |
40 | ); 41 | } 42 | 43 | export default function HomepageFeatures(): JSX.Element { 44 | return ( 45 | <> 46 |
47 |
48 |
49 | {FeatureList.map((props, idx) => ( 50 | 51 | ))} 52 |
53 |
54 |
55 |
56 |
57 |
58 | {`Like the project? ⚛️ `} 59 | 60 | Join the team 61 | 62 | {` who does amazing stuff for clients and drives React Native Open Source! 🔥`} 63 | 66 |
67 |
68 |
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/reassure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reassure", 3 | "version": "1.4.0", 4 | "description": "Performance testing companion for React and React Native", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./lib/commonjs/index.js", 11 | "import": "./lib/module/index.js", 12 | "default": "./lib/commonjs/index.js", 13 | "types": "./lib/typescript/index.d.ts" 14 | }, 15 | "./bin/reassure": "./lib/commonjs/bin/reassure.js" 16 | }, 17 | "source": "src/index.ts", 18 | "bin": "lib/commonjs/bin/reassure.js", 19 | "files": [ 20 | "lib", 21 | "!**/__tests__", 22 | "!**/__fixtures__", 23 | "!**/__mocks__" 24 | ], 25 | "scripts": { 26 | "build": "bob build", 27 | "clean": "del lib" 28 | }, 29 | "keywords": [ 30 | "react-native", 31 | "ios", 32 | "android" 33 | ], 34 | "repository": "https://github.com/callstack/reassure", 35 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 36 | "contributors": [ 37 | "Jakub Bujko (https://github.com/Xiltyn)", 38 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 39 | "Michał Pierzchała (https://github.com/thymikee)" 40 | ], 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/callstack/reassure/issues" 44 | }, 45 | "homepage": "https://github.com/callstack/reassure#readme", 46 | "dependencies": { 47 | "@callstack/reassure-cli": "1.4.0", 48 | "@callstack/reassure-compare": "1.4.0", 49 | "@callstack/reassure-danger": "1.4.0", 50 | "@callstack/reassure-measure": "1.4.0", 51 | "import-local": "^3.2.0" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.28.5", 55 | "@babel/plugin-transform-flow-strip-types": "^7.27.1", 56 | "@babel/preset-env": "^7.28.5", 57 | "@babel/preset-typescript": "^7.28.5", 58 | "@babel/runtime": "^7.28.4", 59 | "@types/react": "~19.1.17", 60 | "del-cli": "^7.0.0", 61 | "react-native-builder-bob": "^0.24.0", 62 | "typescript": "^5.9.3" 63 | }, 64 | "react-native-builder-bob": { 65 | "source": "src", 66 | "output": "lib", 67 | "targets": [ 68 | "commonjs", 69 | "module", 70 | [ 71 | "typescript", 72 | { 73 | "project": "tsconfig.build.json" 74 | } 75 | ] 76 | ] 77 | }, 78 | "publishConfig": { 79 | "access": "public" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/measure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@callstack/reassure-measure", 3 | "version": "1.4.0", 4 | "description": "Performance measurement library for React and React Native", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "source": "src/index.ts", 9 | "files": [ 10 | "lib", 11 | "!**/__tests__", 12 | "!**/__fixtures__", 13 | "!**/__mocks__" 14 | ], 15 | "scripts": { 16 | "test": "jest", 17 | "build": "bob build", 18 | "clean": "del lib" 19 | }, 20 | "keywords": [ 21 | "react-native", 22 | "ios", 23 | "android" 24 | ], 25 | "repository": "https://github.com/callstack/reassure", 26 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 27 | "contributors": [ 28 | "Jakub Bujko (https://github.com/Xiltyn)", 29 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 30 | "Michał Pierzchała (https://github.com/thymikee)" 31 | ], 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/callstack/reassure/issues" 35 | }, 36 | "homepage": "https://github.com/callstack/reassure#readme", 37 | "dependencies": { 38 | "@callstack/reassure-logger": "1.4.0", 39 | "mathjs": "^15.1.0", 40 | "pretty-format": "^30.2.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.28.5", 44 | "@babel/plugin-transform-flow-strip-types": "^7.27.1", 45 | "@babel/preset-env": "^7.28.5", 46 | "@babel/preset-react": "^7.28.5", 47 | "@babel/preset-typescript": "^7.28.5", 48 | "@babel/runtime": "^7.28.4", 49 | "@relmify/jest-serializer-strip-ansi": "^1.0.2", 50 | "@testing-library/react": "^16.3.0", 51 | "@testing-library/react-native": "^13.3.3", 52 | "@types/jest": "^30.0.0", 53 | "@types/react": "~19.1.17", 54 | "babel-jest": "^30.2.0", 55 | "del-cli": "^7.0.0", 56 | "jest": "^30.2.0", 57 | "prettier": "^3.6.2", 58 | "react": "19.1.1", 59 | "react-native": "0.82.1", 60 | "react-native-builder-bob": "^0.24.0", 61 | "react-test-renderer": "19.1.1", 62 | "strip-ansi": "^6.0.1", 63 | "typescript": "^5.9.3" 64 | }, 65 | "peerDependencies": { 66 | "react": ">=18.0.0" 67 | }, 68 | "react-native-builder-bob": { 69 | "source": "src", 70 | "output": "lib", 71 | "targets": [ 72 | "commonjs", 73 | "module", 74 | [ 75 | "typescript", 76 | { 77 | "project": "tsconfig.build.json" 78 | } 79 | ] 80 | ] 81 | }, 82 | "publishConfig": { 83 | "access": "public" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docusaurus/docs/migration-v1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Migration to v1.x 3 | sidebar_position: 6 4 | --- 5 | 6 | import Tabs from '@theme/Tabs'; 7 | import TabItem from '@theme/TabItem'; 8 | 9 | # Migration to v1.x 10 | 11 | ## Installation 12 | 13 | 14 | 15 | ```sh 16 | npm install --save-dev reassure 17 | ``` 18 | 19 | 20 | ```sh 21 | yarn add --dev reassure 22 | ``` 23 | 24 | 25 | 26 | ## Breaking changes 27 | 28 | ### Rename `measurePerformance` to `measureRenders` 29 | 30 | The signature of the function did not change. Old name is still available but will generate warning messages when used. 31 | 32 | ### Rename `resetToDefault` to `resetToDefaults` 33 | 34 | The signature of the function did not change. Old name is no longer available. 35 | 36 | ## Testing environment 37 | 38 | Reassure v0 used Node.js JIT-less mode (`--jitless` node flag), optionally using different flags if `--enable-wasm` experimental option was passed. Reassure V1 runs tests using Node.js's non-optimized compilation to better reflect React Native runtime environment. 39 | 40 | This means that: 41 | 42 | 1. Tests will run ~2x faster than in V0. This should be visible in the single PR where you update Reassure to V1 in your repo as the baseline measurements will run with the old flags, while the current measurements will run with the new flags. Afterwards, both baseline and current measurement should run with the same compilation options. 43 | 2. WebAssembly is now enabled by default. 44 | 45 | ## Non-breaking changes 46 | 47 | ### Exporting of `Measure*` and `Compare*` types 48 | 49 | Reassure now exports following TypeScript types from root `reassure` package: 50 | 51 | - `MeasureResults` - return type of `measureRenders` and `measureFunction` 52 | - `MeasureRendersOptions` - options passed to `measureRenders` 53 | - `MeasureFunctionOptions` - options passed to `measureFunction` 54 | - `MeasureType` - type of measurement: `render` or `function` 55 | - `MeasureHeader` - header from performance file (`baseline.perf`, `current.perf`) 56 | - `MeasureMetadata` - metadata from performance file 57 | - `MeasureEntry` - single entry from performance file 58 | - `CompareResult` - format of `output.json` file 59 | - `CompareMetadata` - metadata from `output.json` file 60 | - `CompareEntry` - single comparison result from `output.json` file 61 | - `AddedEntry` - similar to `CompareEntry` but for cases when there is only `current` measurement 62 | - `RemovedEntry` - similar to `CompareEntry` but for cases when there is only `baseline` measurement 63 | 64 | ### Removal of `--enable-wasm` flag 65 | 66 | Reassure now runs tests with WebAssembly enable by default (see [testing environment](#testing-environment)). 67 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@callstack/reassure-cli", 3 | "version": "1.4.0", 4 | "description": "Performance testing companion for React and React Native", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "source": "src/index.ts", 9 | "bin": { 10 | "reassure": "lib/commonjs/bin.js" 11 | }, 12 | "files": [ 13 | "lib", 14 | "!**/__tests__", 15 | "!**/__fixtures__", 16 | "!**/__mocks__" 17 | ], 18 | "exports": { 19 | ".": { 20 | "require": "./lib/commonjs/index.js", 21 | "import": "./lib/module/index.js", 22 | "default": "./lib/commonjs/index.js", 23 | "types": "./lib/typescript/index.d.ts" 24 | }, 25 | "./bin/reassure": "./lib/commonjs/bin.js" 26 | }, 27 | "scripts": { 28 | "build": "bob build", 29 | "clean": "del lib" 30 | }, 31 | "keywords": [ 32 | "react-native", 33 | "ios", 34 | "android" 35 | ], 36 | "repository": "https://github.com/callstack/reassure", 37 | "author": "Maciej Jastrzębski (https://github.com/mdjastrzebski)", 38 | "contributors": [ 39 | "Jakub Bujko (https://github.com/Xiltyn)", 40 | "Tomasz Krzyżowski (https://github.com/TMaszko)", 41 | "Michał Pierzchała (https://github.com/thymikee)" 42 | ], 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/callstack/reassure/issues" 46 | }, 47 | "homepage": "https://github.com/callstack/reassure#readme", 48 | "dependencies": { 49 | "@callstack/reassure-compare": "1.4.0", 50 | "@callstack/reassure-logger": "1.4.0", 51 | "chalk": "4.1.2", 52 | "simple-git": "^3.30.0", 53 | "yargs": "^17.7.2" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.28.5", 57 | "@babel/plugin-transform-flow-strip-types": "^7.27.1", 58 | "@babel/preset-env": "^7.28.5", 59 | "@babel/preset-typescript": "^7.28.5", 60 | "@babel/runtime": "^7.28.4", 61 | "@relmify/jest-serializer-strip-ansi": "^1.0.2", 62 | "@types/jest": "^30.0.0", 63 | "@types/react": "~19.1.17", 64 | "@types/yargs": "^17.0.35", 65 | "babel-jest": "^30.2.0", 66 | "del-cli": "^7.0.0", 67 | "jest": "^30.2.0", 68 | "prettier": "^3.6.2", 69 | "react-native-builder-bob": "^0.24.0", 70 | "typescript": "^5.9.3" 71 | }, 72 | "react-native-builder-bob": { 73 | "source": "src", 74 | "output": "lib", 75 | "targets": [ 76 | "commonjs", 77 | "module", 78 | [ 79 | "typescript", 80 | { 81 | "project": "tsconfig.build.json" 82 | } 83 | ] 84 | ] 85 | }, 86 | "publishConfig": { 87 | "access": "public" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/measure/src/testing-library.ts: -------------------------------------------------------------------------------- 1 | import * as logger from '@callstack/reassure-logger'; 2 | import { config, Render, Cleanup } from './config'; 3 | 4 | type TestingLibraryApi = { 5 | render: Render; 6 | cleanup: Cleanup; 7 | }; 8 | 9 | let RNTL: TestingLibraryApi | undefined; 10 | try { 11 | // eslint-disable-next-line import/no-extraneous-dependencies 12 | RNTL = require('@testing-library/react-native'); 13 | } catch { 14 | // Do nothing 15 | } 16 | 17 | let RTL: TestingLibraryApi | undefined; 18 | try { 19 | // eslint-disable-next-line import/no-extraneous-dependencies 20 | RTL = require('@testing-library/react'); 21 | } catch { 22 | // Do nothing 23 | } 24 | 25 | export function resolveTestingLibrary(): TestingLibraryApi { 26 | // Explicit testing library option 27 | if (config.testingLibrary) { 28 | if (config.testingLibrary === 'react-native') { 29 | if (!RNTL) { 30 | throw new Error(`Unable to import '@testing-library/react-native' dependency`); 31 | } 32 | 33 | logger.verbose(`Using '@testing-library/react-native' to render components`); 34 | return RNTL; 35 | } 36 | 37 | if (config.testingLibrary === 'react') { 38 | if (!RTL) { 39 | throw new Error(`Unable to import '@testing-library/react' dependency`); 40 | } 41 | 42 | logger.verbose(`Using '@testing-library/react' to render components`); 43 | return RTL; 44 | } 45 | 46 | if ( 47 | typeof config.testingLibrary === 'object' && 48 | typeof config.testingLibrary.render === 'function' && 49 | typeof config.testingLibrary.cleanup === 'function' 50 | ) { 51 | logger.verbose(`Using custom 'render' and 'cleanup' functions to render components`); 52 | return config.testingLibrary; 53 | } 54 | 55 | throw new Error( 56 | `Unsupported 'testingLibrary' value. Please set 'testingLibrary' to one of following values: 'react-native', 'react' or { render, cleanup }.` 57 | ); 58 | } 59 | 60 | // Testing library auto-detection 61 | if (RNTL != null && RTL != null) { 62 | logger.warn( 63 | "Both '@testing-library/react-native' and '@testing-library/react' are installed. Using '@testing-library/react-native' by default.\n\nYou can resolve this warning by explicitly calling 'configure({ testingLibrary: 'react-native' })' or 'configure({ testingLibrary: 'react' })' in your test setup file." 64 | ); 65 | 66 | return RNTL; 67 | } 68 | 69 | if (RNTL != null) { 70 | logger.verbose(`Using '@testing-library/react-native' to render components`); 71 | return RNTL; 72 | } 73 | 74 | if (RTL != null) { 75 | logger.verbose(`Using '@testing-library/react' to render components`); 76 | return RTL; 77 | } 78 | 79 | throw new Error( 80 | `Unable to import neither '@testing-library/react-native' nor '@testing-library/react'.` + 81 | `\nAdd either of these testing libraries to your 'package.json'` 82 | ); 83 | } 84 | 85 | export function getTestingLibrary(): string | null { 86 | if (typeof config.testingLibrary === 'string') { 87 | return config.testingLibrary; 88 | } 89 | 90 | if (RNTL != null) { 91 | return 'react-native'; 92 | } 93 | 94 | if (RTL != null) { 95 | return 'react'; 96 | } 97 | 98 | return null; 99 | } 100 | -------------------------------------------------------------------------------- /docusaurus/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer').themes.github; 5 | const darkCodeTheme = require('prism-react-renderer').themes.dracula; 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'Reassure', 10 | tagline: 'Performance testing companion for React and React Native', 11 | url: 'https://callstack.github.io/', 12 | baseUrl: '/reassure/', 13 | onBrokenLinks: 'throw', 14 | onBrokenMarkdownLinks: 'warn', 15 | favicon: '/img/favicon.ico', 16 | 17 | // GitHub pages deployment config. 18 | // If you aren't using GitHub pages, you don't need these. 19 | organizationName: 'callstack', 20 | projectName: 'reassure', 21 | deploymentBranch: 'docs', 22 | 23 | // Even if you don't use internalization, you can use this field to set useful 24 | // metadata like html lang. For example, if your site is Chinese, you may want 25 | // to replace "en" with "zh-Hans". 26 | i18n: { 27 | defaultLocale: 'en', 28 | locales: ['en'], 29 | }, 30 | 31 | presets: [ 32 | [ 33 | 'classic', 34 | /** @type {import('@docusaurus/preset-classic').Options} */ 35 | ({ 36 | docs: { 37 | sidebarPath: require.resolve('./sidebars.js'), 38 | // Please change this to your repo. 39 | }, 40 | blog: { 41 | showReadingTime: true, 42 | // Please change this to your repo. 43 | }, 44 | theme: { 45 | customCss: require.resolve('./src/css/custom.css'), 46 | }, 47 | }), 48 | ], 49 | ], 50 | 51 | themeConfig: 52 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 53 | ({ 54 | navbar: { 55 | title: 'Reassure', 56 | style: 'dark', 57 | logo: { 58 | alt: 'My Site Logo', 59 | src: 'img/logo_square.png', 60 | }, 61 | items: [ 62 | { 63 | type: 'doc', 64 | docId: 'introduction', 65 | position: 'right', 66 | label: 'Docs', 67 | }, 68 | { 69 | href: 'https://github.com/callstack/reassure', 70 | label: 'GitHub', 71 | position: 'right', 72 | }, 73 | ], 74 | }, 75 | footer: { 76 | style: 'dark', 77 | links: [ 78 | { 79 | title: 'Docs', 80 | items: [ 81 | { 82 | label: 'Why Reassure?', 83 | to: '/docs/introduction', 84 | }, 85 | ], 86 | }, 87 | { 88 | title: 'Community', 89 | items: [ 90 | { 91 | label: 'Discord', 92 | href: 'https://discord.com/channels/426714625279524876/426717392027254784', 93 | }, 94 | { 95 | label: 'Twitter', 96 | href: 'https://twitter.com/callstackio', 97 | }, 98 | ], 99 | }, 100 | { 101 | title: 'More', 102 | items: [ 103 | { 104 | label: 'GitHub', 105 | href: 'https://github.com/callstack/reassure', 106 | }, 107 | ], 108 | }, 109 | ], 110 | copyright: `Copyright © ${new Date().getFullYear()} Reassure by Callstack | built with Docusaurus.`, 111 | }, 112 | prism: { 113 | theme: lightCodeTheme, 114 | darkTheme: darkCodeTheme, 115 | }, 116 | }), 117 | }; 118 | 119 | module.exports = config; 120 | -------------------------------------------------------------------------------- /packages/measure/src/__tests__/measure-async-function.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable promise/prefer-await-to-then */ 2 | import stripAnsi from 'strip-ansi'; 3 | import { measureAsyncFunction } from '../measure-async-function'; 4 | import { setHasShownFlagsOutput } from '../output'; 5 | 6 | // Exponentially slow function 7 | function fib(n: number): number { 8 | if (n <= 1) { 9 | return n; 10 | } 11 | 12 | return fib(n - 1) + fib(n - 2); 13 | } 14 | 15 | test('measureAsyncFunction captures results', async () => { 16 | const fn = jest.fn(() => Promise.resolve().then(() => fib(5))); 17 | const results = await measureAsyncFunction(fn, { runs: 1, warmupRuns: 0, writeFile: false }); 18 | 19 | expect(fn).toHaveBeenCalledTimes(1); 20 | expect(results.runs).toBe(1); 21 | expect(results.counts).toEqual([1]); 22 | }); 23 | 24 | test('measureAsyncFunction runs specified number of times', async () => { 25 | const fn = jest.fn(() => Promise.resolve().then(() => fib(5))); 26 | const results = await measureAsyncFunction(fn, { runs: 20, warmupRuns: 0, writeFile: false }); 27 | 28 | expect(fn).toHaveBeenCalledTimes(20); 29 | expect(results.runs).toBe(20); 30 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(20); 31 | expect(results.counts).toHaveLength(20); 32 | expect(results.meanCount).toBe(1); 33 | expect(results.stdevCount).toBe(0); 34 | }); 35 | 36 | test('measureAsyncFunction applies "warmupRuns" option', async () => { 37 | const fn = jest.fn(() => Promise.resolve().then(() => fib(5))); 38 | const results = await measureAsyncFunction(fn, { runs: 10, warmupRuns: 1, writeFile: false }); 39 | 40 | expect(fn).toHaveBeenCalledTimes(11); 41 | expect(results.runs).toBe(10); 42 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(10); 43 | expect(results.counts).toHaveLength(10); 44 | expect(results.meanCount).toBe(1); 45 | expect(results.stdevCount).toBe(0); 46 | }); 47 | 48 | test('measureAsyncFunction executes setup and cleanup functions for each run', async () => { 49 | const fn = jest.fn(() => Promise.resolve().then(() => fib(5))); 50 | const beforeFn = jest.fn(); 51 | const afterFn = jest.fn(); 52 | const results = await measureAsyncFunction(fn, { 53 | runs: 10, 54 | warmupRuns: 1, 55 | writeFile: false, 56 | beforeEach: beforeFn, 57 | afterEach: afterFn, 58 | }); 59 | 60 | expect(beforeFn).toHaveBeenCalledTimes(11); 61 | expect(fn).toHaveBeenCalledTimes(11); 62 | expect(afterFn).toHaveBeenCalledTimes(11); 63 | expect(results.runs).toBe(10); 64 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(10); 65 | expect(results.counts).toHaveLength(10); 66 | }); 67 | 68 | const errorsToIgnore = ['❌ Measure code is running under incorrect Node.js configuration.']; 69 | const realConsole = jest.requireActual('console') as Console; 70 | 71 | beforeEach(() => { 72 | jest.spyOn(realConsole, 'error').mockImplementation((message) => { 73 | if (!errorsToIgnore.some((error) => message.includes(error))) { 74 | realConsole.error(message); 75 | } 76 | }); 77 | }); 78 | 79 | test('measureAsyncFunction should log error when running under incorrect node flags', async () => { 80 | setHasShownFlagsOutput(false); 81 | const results = await measureAsyncFunction(jest.fn(), { runs: 1, writeFile: false }); 82 | 83 | expect(results.runs).toBe(1); 84 | const consoleErrorCalls = jest.mocked(realConsole.error).mock.calls; 85 | expect(stripAnsi(consoleErrorCalls[0][0])).toMatchInlineSnapshot(` 86 | "❌ Measure code is running under incorrect Node.js configuration. 87 | Performance test code should be run in Jest with certain Node.js flags to increase measurements stability. 88 | Make sure you use the Reassure CLI and run it using "reassure" command." 89 | `); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/compare/src/output/console.ts: -------------------------------------------------------------------------------- 1 | import * as logger from '@callstack/reassure-logger'; 2 | import type { AddedEntry, CompareResult, CompareEntry, RemovedEntry } from '../types'; 3 | import { formatCount, formatDuration, formatMetadata, formatCountChange, formatDurationChange } from '../utils/format'; 4 | import type { MeasureMetadata } from '../types'; 5 | 6 | export function printToConsole(data: CompareResult) { 7 | // No need to log errors or warnings as these were be logged on the fly 8 | 9 | logger.log('❇️ Performance comparison results:'); 10 | printMetadata('Current', data.metadata.current); 11 | printMetadata('Baseline', data.metadata.baseline); 12 | 13 | logger.log('\n➡️ Significant changes to duration'); 14 | data.significant.forEach(printRegularLine); 15 | if (data.significant.length === 0) { 16 | logger.log(' - (none)'); 17 | } 18 | 19 | logger.log('\n➡️ Meaningless changes to duration'); 20 | data.meaningless.forEach(printRegularLine); 21 | if (data.meaningless.length === 0) { 22 | logger.log(' - (none)'); 23 | } 24 | 25 | logger.log('\n➡️ Render count changes'); 26 | data.countChanged.forEach(printRegularLine); 27 | if (data.countChanged.length === 0) { 28 | logger.log(' - (none)'); 29 | } 30 | 31 | logger.log('\n➡️ Render issues'); 32 | data.renderIssues.forEach(printRenderIssuesLine); 33 | if (data.renderIssues.length === 0) { 34 | logger.log(' - (none)'); 35 | } 36 | 37 | logger.log('\n➡️ Added scenarios'); 38 | data.added.forEach(printAddedLine); 39 | if (data.added.length === 0) { 40 | logger.log(' - (none)'); 41 | } 42 | 43 | logger.log('\n➡️ Removed scenarios'); 44 | data.removed.forEach(printRemovedLine); 45 | if (data.removed.length === 0) { 46 | logger.log(' - (none)'); 47 | } 48 | 49 | logger.newLine(); 50 | } 51 | 52 | function printMetadata(name: string, metadata?: MeasureMetadata) { 53 | logger.log(` - ${name}: ${formatMetadata(metadata)}`); 54 | } 55 | 56 | function printRegularLine(entry: CompareEntry) { 57 | logger.log( 58 | ` - ${entry.name} [${entry.type}]: ${formatDurationChange(entry)} | ${formatCountChange( 59 | entry.current.meanCount, 60 | entry.baseline.meanCount 61 | )}` 62 | ); 63 | } 64 | 65 | function printRenderIssuesLine(entry: CompareEntry | AddedEntry) { 66 | const issues = []; 67 | 68 | const initialUpdateCount = entry.current.issues?.initialUpdateCount; 69 | if (initialUpdateCount) { 70 | issues.push(formatInitialUpdates(initialUpdateCount)); 71 | } 72 | 73 | const redundantUpdates = entry.current.issues?.redundantUpdates; 74 | if (redundantUpdates?.length) { 75 | issues.push(formatRedundantUpdates(redundantUpdates)); 76 | } 77 | 78 | logger.log(` - ${entry.name}: ${issues.join(' | ')}`); 79 | } 80 | 81 | function printAddedLine(entry: AddedEntry) { 82 | const { current } = entry; 83 | logger.log( 84 | ` - ${entry.name} [${entry.type}]: ${formatDuration(current.meanDuration)} | ${formatCount(current.meanCount)}` 85 | ); 86 | } 87 | 88 | function printRemovedLine(entry: RemovedEntry) { 89 | const { baseline } = entry; 90 | logger.log( 91 | ` - ${entry.name} [${entry.type}]: ${formatDuration(baseline.meanDuration)} | ${formatCount(baseline.meanCount)}` 92 | ); 93 | } 94 | 95 | export function formatInitialUpdates(count: number) { 96 | if (count === 0) return '-'; 97 | if (count === 1) return '1 initial update 🔴'; 98 | 99 | return `${count} initial updates 🔴`; 100 | } 101 | 102 | export function formatRedundantUpdates(redundantUpdates: number[]) { 103 | if (redundantUpdates.length === 0) return '-'; 104 | if (redundantUpdates.length === 1) return `1 redundant update (${redundantUpdates.join(', ')}) 🔴`; 105 | 106 | return `${redundantUpdates.length} redundant updates (${redundantUpdates.join(', ')}) 🔴`; 107 | } 108 | -------------------------------------------------------------------------------- /packages/compare/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @callstack/reassure-compare 2 | 3 | ## 1.4.0 4 | 5 | ### Patch Changes 6 | 7 | - 0870117: chore: upgrade deps 8 | - 7ad16cb: chore: tweak markdown report 9 | - b34459f: enable outlier detection by default 10 | - Updated dependencies [0870117] 11 | - @callstack/reassure-logger@1.4.0 12 | 13 | ## 1.4.0-next.0 14 | 15 | ### Patch Changes 16 | 17 | - 0870117: chore: upgrade deps 18 | - 7ad16cb: chore: tweak markdown report 19 | - Updated dependencies [0870117] 20 | - @callstack/reassure-logger@1.4.0-next.0 21 | 22 | ## 1.3.3 23 | 24 | ### Patch Changes 25 | 26 | - cbeca72: fix: markdown escaping in GH perf report 27 | - @callstack/reassure-logger@1.3.3 28 | 29 | ## 1.3.2 30 | 31 | ### Patch Changes 32 | 33 | - fix: fractional render count formatting in markdown/console 34 | - @callstack/reassure-logger@1.3.2 35 | 36 | ## 1.3.1 37 | 38 | ### Patch Changes 39 | 40 | - @callstack/reassure-logger@1.3.1 41 | 42 | ## 1.3.0 43 | 44 | ### Patch Changes 45 | 46 | - @callstack/reassure-logger@1.3.0 47 | 48 | ## 1.2.1 49 | 50 | ### Patch Changes 51 | 52 | - 75dc782: chore: update deps 53 | - @callstack/reassure-logger@1.2.1 54 | 55 | ## 1.2.0 56 | 57 | ### Patch Changes 58 | 59 | - Updated dependencies [ce30981] 60 | - @callstack/reassure-logger@1.2.0 61 | 62 | ## 1.1.0 63 | 64 | ### Minor Changes 65 | 66 | - d1d3617: Expose "Warmup Runs" and "Render Issues" in markdown report 67 | 68 | ### Patch Changes 69 | 70 | - 0adb9cc: chore: update deps 71 | - Updated dependencies [0adb9cc] 72 | - @callstack/reassure-logger@1.1.0 73 | 74 | ## 1.0.0 75 | 76 | ### Minor Changes 77 | 78 | - ebcf9d6: Detect render issues (initial render updates, redundant renders) 79 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 80 | - ebcf9d6: chore: fix version deps 81 | 82 | ## 0.11.0 83 | 84 | ### Patch Changes 85 | 86 | - b455bd4: refactor: simplify `reassure-logger` package exports 87 | - bb2046a: Replace unmaintained `markdown-builder` library with internal helper functions. 88 | - Updated dependencies [b455bd4] 89 | - @callstack/reassure-logger@0.11.0 90 | 91 | ## 0.6.0 92 | 93 | ### Minor Changes 94 | 95 | - feat: `measureFunction` API to measure regular JS functions execution time 96 | 97 | ### Patch Changes 98 | 99 | - Updated dependencies [99afdf98] 100 | - @callstack/reassure-logger@0.3.2 101 | 102 | ## 0.5.1 103 | 104 | ### Patch Changes 105 | 106 | - Updated dependencies [2e127815] 107 | - @callstack/reassure-logger@0.3.1 108 | 109 | ## 0.5.0 110 | 111 | ### Minor Changes 112 | 113 | - f8aa25fe: feat: capture date and time of measurements 114 | 115 | ## 0.4.1 116 | 117 | ### Patch Changes 118 | 119 | - Updated dependencies [235f37d4] 120 | - @callstack/reassure-logger@0.3.0 121 | 122 | ## 0.4.0 123 | 124 | ### Minor Changes 125 | 126 | - 35af62a4: Custom logger implementation with verbose and silent modes. 127 | 128 | ### Patch Changes 129 | 130 | - Updated dependencies [35af62a4] 131 | - @callstack/reassure-logger@0.2.0 132 | 133 | ## 0.3.0 134 | 135 | ### Minor Changes 136 | 137 | - 3a180f2: Validate format of performance results files when loading for comparison 138 | 139 | ## 0.2.0 140 | 141 | ### Minor Changes 142 | 143 | - b4250e3c: Include codebase metadata (branch, commit hash) in the performance report and measurement file 144 | 145 | ### Patch Changes 146 | 147 | - 5a1c3472: Changes for dependencies cleanup after monorepo migration 148 | 149 | ## 0.1.1 150 | 151 | ### Patch Changes 152 | 153 | - 4d0cca6a: Updated console output format 154 | - ca56a6d1: Move internal packages under @reassure scope to @callstack scope for simpler maintenance 155 | 156 | ## 0.1.0 157 | 158 | ### Minor Changes 159 | 160 | - d6bfef03: Add note about ignoring .reassure folder 161 | 162 | ## 0.0.3 163 | 164 | ### Patch Changes 165 | 166 | - 27f45e83: Fix typescript setup for publishing with bob 167 | 168 | ## 0.0.2 169 | 170 | ### Patch Changes 171 | 172 | - 2f8f8c06: setup changesets 173 | -------------------------------------------------------------------------------- /packages/measure/src/__tests__/measure-function.test.tsx: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | import { measureFunction } from '../measure-function'; 3 | import { measureAsyncFunction } from '../measure-async-function'; 4 | import { setHasShownFlagsOutput } from '../output'; 5 | 6 | // Exponentially slow function 7 | function fib(n: number): number { 8 | if (n <= 1) { 9 | return n; 10 | } 11 | 12 | return fib(n - 1) + fib(n - 2); 13 | } 14 | 15 | test('measureFunction captures results', async () => { 16 | const fn = jest.fn(() => fib(5)); 17 | const results = await measureFunction(fn, { runs: 1, warmupRuns: 0, writeFile: false }); 18 | 19 | expect(fn).toHaveBeenCalledTimes(1); 20 | expect(results.runs).toBe(1); 21 | expect(results.counts).toEqual([1]); 22 | }); 23 | 24 | test('measureAsyncFunction captures results', async () => { 25 | const fn = jest.fn(async () => { 26 | await Promise.resolve(); 27 | return fib(5); 28 | }); 29 | const results = await measureAsyncFunction(fn, { runs: 1, warmupRuns: 0, writeFile: false }); 30 | 31 | expect(fn).toHaveBeenCalledTimes(1); 32 | expect(results.runs).toBe(1); 33 | expect(results.counts).toEqual([1]); 34 | }); 35 | 36 | test('measureFunction runs specified number of times', async () => { 37 | const fn = jest.fn(() => fib(5)); 38 | const results = await measureFunction(fn, { runs: 20, warmupRuns: 0, writeFile: false }); 39 | 40 | expect(fn).toHaveBeenCalledTimes(20); 41 | expect(results.runs).toBe(20); 42 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(20); 43 | expect(results.counts).toHaveLength(20); 44 | expect(results.meanCount).toBe(1); 45 | expect(results.stdevCount).toBe(0); 46 | }); 47 | 48 | test('measureFunction applies "warmupRuns" option', async () => { 49 | const fn = jest.fn(() => fib(5)); 50 | const results = await measureFunction(fn, { runs: 10, warmupRuns: 1, writeFile: false }); 51 | 52 | expect(fn).toHaveBeenCalledTimes(11); 53 | expect(results.runs).toBe(10); 54 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(10); 55 | expect(results.counts).toHaveLength(10); 56 | expect(results.meanCount).toBe(1); 57 | expect(results.stdevCount).toBe(0); 58 | }); 59 | 60 | test('measureFunction executes setup and cleanup functions for each run', async () => { 61 | const fn = jest.fn(() => fib(5)); 62 | const beforeFn = jest.fn(); 63 | const afterFn = jest.fn(); 64 | const results = await measureFunction(fn, { 65 | runs: 10, 66 | warmupRuns: 1, 67 | writeFile: false, 68 | beforeEach: beforeFn, 69 | afterEach: afterFn, 70 | }); 71 | 72 | expect(beforeFn).toHaveBeenCalledTimes(11); 73 | expect(fn).toHaveBeenCalledTimes(11); 74 | expect(afterFn).toHaveBeenCalledTimes(11); 75 | expect(results.runs).toBe(10); 76 | expect(results.durations.length + (results.outlierDurations?.length ?? 0)).toBe(10); 77 | expect(results.counts).toHaveLength(10); 78 | }); 79 | 80 | const errorsToIgnore = ['❌ Measure code is running under incorrect Node.js configuration.']; 81 | const realConsole = jest.requireActual('console') as Console; 82 | 83 | beforeEach(() => { 84 | jest.spyOn(realConsole, 'error').mockImplementation((message) => { 85 | if (!errorsToIgnore.some((error) => message.includes(error))) { 86 | realConsole.error(message); 87 | } 88 | }); 89 | }); 90 | 91 | test('measureFunction should log error when running under incorrect node flags', async () => { 92 | setHasShownFlagsOutput(false); 93 | const results = await measureFunction(jest.fn(), { runs: 1, writeFile: false }); 94 | 95 | expect(results.runs).toBe(1); 96 | const consoleErrorCalls = jest.mocked(realConsole.error).mock.calls; 97 | expect(stripAnsi(consoleErrorCalls[0][0])).toMatchInlineSnapshot(` 98 | "❌ Measure code is running under incorrect Node.js configuration. 99 | Performance test code should be run in Jest with certain Node.js flags to increase measurements stability. 100 | Make sure you use the Reassure CLI and run it using "reassure" command." 101 | `); 102 | }); 103 | -------------------------------------------------------------------------------- /docusaurus/docs/methodology.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Methodology 6 | 7 | ## Assessing CI stability 8 | 9 | During performance measurements we measure React component render times with microsecond precision using `React.Profiler`. This means 10 | that the same code will run faster or slower depending on the machine. For this reason, 11 | baseline & current measurements need to be run on the same machine. Optimally, they should be run one after another. 12 | 13 | Moreover, in order to achieve meaningful results your CI agent needs to have stable performance. It does not matter 14 | really if your agent is fast or slow as long as it is consistent in its performance. That's why during the performance 15 | tests the agent should not be used for any other work that might impact measuring render times. 16 | 17 | In order to help you assess your machine stability, you can use `reassure check-stability` command. It runs performance 18 | measurements twice for the current code, so baseline and current measurements refer to the same code. In such case the 19 | expected changes are 0% (no change). The degree of random performance changes will reflect the stability of your machine. 20 | This command can be run both on CI and local machines. 21 | 22 | Normally, the random changes should be below 5%. Results of 10% and more considered too high and mean that you should 23 | work on tweaking your machine stability. 24 | 25 | > **Note**: As a trick of last resort you can increase the `run` option, from the default value of 10 to 20, 50 or even 100, for all or some of your tests, based on the assumption that more test runs will even out measurement fluctuations. That will however make your tests run even longer. 26 | 27 | You can refer to our example [GitHub workflow](https://github.com/callstack/reassure/blob/main/.github/workflows/stability.yml). 28 | 29 | ## Analyzing results 30 | 31 |

32 | Markdown report 33 |

34 | 35 | ### Results categorization 36 | 37 | Looking at the example you can notice that test scenarios can be assigned to certain categories: 38 | 39 | - **Significant Changes To Duration** shows test scenario where the performance change is statistically significant and **should** be looked into as it marks a potential performance loss/improvement 40 | - **Meaningless Changes To Duration** shows test scenarios where the performance change is not statistically significant 41 | - **Changes To Count** shows test scenarios where the render or execution count did change 42 | - **Added Scenarios** shows test scenarios which do not exist in the baseline measurements 43 | - **Removed Scenarios** shows test scenarios which do not exist in the current measurements 44 | 45 | ### Render issues (experimental) 46 | 47 | :::note 48 | 49 | This feature is experimental, and its behavior might change without increasing the major version of the package. 50 | 51 | ::: 52 | 53 | Reassure analyses your components' render patterns during the initial test run (usually the warm-up run) to spot signs of potential issues. 54 | 55 | Currently, it's able to inform you about the following types of issues: 56 | 57 | - **Initial updates** informs about the number of updates (= re-renders) that happened immediately (synchronously) after the mount (= initial render). This is most likely caused by `useEffect` hook triggering immediate re-renders using set state. In the optimal case, the initial render should not cause immediate re-renders by itself. Next, renders should be caused by some external source: user action, system event, API call response, timers, etc. 58 | 59 | - **Redundant updates** inform about renders that resulted in the same host element tree as the previous render. After each update, this check inspects the host element structure and compares it to the previous structure. If they are the same, the subsequent render could be avoided as it resulted in no visible change to the user. 60 | - This feature is available only on React Native at this time 61 | - The host element tree comparison ignores references to event handlers. This means that differences in function props (e.g. event handlers) are ignored and only non-function props (e.g. strings, numbers, objects, arrays, etc.) are considered 62 | - The report includes the indices of redundant renders for easier diagnose, 0th render is the mount (initial render), renders 1 and later are updates (re-renders) 63 | -------------------------------------------------------------------------------- /packages/cli/src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs'; 2 | import * as path from 'node:path'; 3 | import * as logger from '@callstack/reassure-logger'; 4 | import type { CommandModule } from 'yargs'; 5 | import { 6 | CI_SCRIPT, 7 | DANGERFILE_FALLBACK_JS, 8 | DANGERFILE_FALLBACK_TS, 9 | DANGERFILE_JS, 10 | DANGERFILE_TS, 11 | GIT_IGNORE, 12 | } from '../constants'; 13 | import { applyCommonOptions, CommonOptions } from '../options'; 14 | import { ASCII_BYE, ASCII_HELLO } from '../utils/ascii'; 15 | import { configureLoggerOptions } from '../utils/logger'; 16 | 17 | const TEMPLATE_PATH = path.join(__dirname, '..', 'templates'); 18 | 19 | /** 20 | * Generate requred Reassure files. 21 | */ 22 | export function run(options: CommonOptions): void { 23 | configureLoggerOptions(options); 24 | 25 | logger.color('brand', ASCII_HELLO); 26 | 27 | setUpCiScript(); 28 | setUpDangerFile(); 29 | setUpGitIgnore(); 30 | 31 | logger.log(''); 32 | logger.color('brand', 'Finished initalizing new Reassure testing environment.'); 33 | logger.log('Please refer to our CI guide in order to set up your pipelines.'); 34 | logger.log('🔗 https://callstack.github.io/reassure/docs/installation#ci-setup'); 35 | 36 | logger.color('brand', ASCII_BYE); 37 | } 38 | 39 | export const command: CommandModule<{}, CommonOptions> = { 40 | command: ['init'], 41 | describe: 'Automates basic Reassure setup steps.', 42 | builder: (yargs) => { 43 | return applyCommonOptions(yargs); 44 | }, 45 | handler: (args) => run(args), 46 | }; 47 | 48 | function setUpCiScript() { 49 | logger.log(''); 50 | logger.progress('#️⃣ CI Script:'); 51 | 52 | if (existsSync(CI_SCRIPT)) { 53 | logger.clearLine(); 54 | logger.log(`✅ CI Script: skipping - already exists`); 55 | logger.log(`🔗 ${path.resolve(CI_SCRIPT)}`); 56 | return; 57 | } 58 | 59 | copyFileSync(path.join(TEMPLATE_PATH, 'reassure-tests'), CI_SCRIPT); 60 | logger.clearLine(); 61 | logger.log(`✅ CI Script: created`); 62 | logger.log(`🔗 ${path.resolve(CI_SCRIPT)}`); 63 | } 64 | 65 | function setUpDangerFile() { 66 | const [existingFile, fallbackFile] = queryDangerfile(); 67 | 68 | logger.log(''); 69 | logger.progress('#️⃣ Dangerfile:'); 70 | 71 | if (!existingFile) { 72 | // If users does not have existing dangerfile, let use the JS one, as potentially less prolematic. 73 | copyFileSync(path.join(TEMPLATE_PATH, 'dangerfile'), DANGERFILE_JS); 74 | logger.clearLine(); 75 | logger.log(`✅ Dangerfile: created`); 76 | logger.log(`🔗 ${path.resolve(DANGERFILE_JS)}`); 77 | return; 78 | } 79 | 80 | const existingContent = readFileSync(existingFile); 81 | if (existingContent.includes('reassure')) { 82 | logger.clearLine(); 83 | logger.log(`✅ Dangerfile: skipping - already contains Reassure code`); 84 | logger.log(`🔗 ${path.resolve(existingFile)}`); 85 | return; 86 | } 87 | 88 | logger.clearLine(); 89 | logger.log(`⚠️ Dangerfile: created ${fallbackFile} - merge with existing ${existingFile}`); 90 | logger.log(`🔗 ${path.resolve(fallbackFile)}`); 91 | } 92 | 93 | function queryDangerfile(): [string, string] | [null, null] { 94 | if (existsSync(DANGERFILE_TS)) { 95 | return [DANGERFILE_TS, DANGERFILE_FALLBACK_TS]; 96 | } 97 | 98 | if (existsSync(DANGERFILE_JS)) { 99 | return [DANGERFILE_JS, DANGERFILE_FALLBACK_JS]; 100 | } 101 | 102 | return [null, null]; 103 | } 104 | 105 | function setUpGitIgnore() { 106 | logger.log(''); 107 | logger.progress('#️⃣ .gitignore:'); 108 | 109 | if (!existsSync(GIT_IGNORE)) { 110 | copyFileSync(path.join(TEMPLATE_PATH, 'gitignore'), GIT_IGNORE); 111 | logger.clearLine(); 112 | logger.log('✅ .gitignore: created'); 113 | logger.log(`🔗 ${path.resolve(GIT_IGNORE)}`); 114 | return; 115 | } 116 | 117 | const existingContent = readFileSync(GIT_IGNORE); 118 | if (existingContent.includes('.reassure')) { 119 | logger.clearLine(); 120 | logger.log(`✅ .gitignore: skipping - already contains '.reassure' entry.`); 121 | logger.log(`🔗 ${path.resolve(GIT_IGNORE)}`); 122 | return; 123 | } 124 | 125 | const gitIgnoreTemplate = readFileSync(`${TEMPLATE_PATH}/gitignore`); 126 | appendFileSync('.gitignore', gitIgnoreTemplate); 127 | logger.clearLine(); 128 | logger.log(`✅ .gitignore: added '.reassure' entry.`); 129 | logger.log(`🔗 ${path.resolve(GIT_IGNORE)}`); 130 | } 131 | -------------------------------------------------------------------------------- /packages/compare/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { buildRegExp, digit, repeat } from 'ts-regex-builder'; 2 | import type { CompareEntry, MeasureMetadata } from '../types'; 3 | 4 | /** 5 | * Utility functions used for formatting data into strings 6 | */ 7 | export function formatPercent(value: number): string { 8 | const valueAsPercent = value * 100; 9 | return `${valueAsPercent.toFixed(1)}%`; 10 | } 11 | 12 | export function formatPercentChange(value: number): string { 13 | const absValue = Math.abs(value); 14 | 15 | // Round to zero 16 | if (absValue < 0.005) return `±0.0%`; 17 | 18 | return `${value >= 0 ? '+' : '-'}${formatPercent(absValue)}`; 19 | } 20 | 21 | export function formatDuration(duration: number): string { 22 | return `${duration.toFixed(1)} ms`; 23 | } 24 | 25 | export function formatDurationDiff(value: number): string { 26 | if (value > 0) { 27 | return `+${formatDuration(value)}`; 28 | } 29 | if (value < 0) { 30 | return `${formatDuration(value)}`; 31 | } 32 | return '0 ms'; 33 | } 34 | 35 | export function formatCount(value?: number) { 36 | if (value == null) { 37 | return '?'; 38 | } 39 | 40 | return Number.isInteger(value) ? `${value}` : `${value.toFixed(2)}`; 41 | } 42 | 43 | export function formatCountDiff(current: number, baseline: number): string { 44 | const diff = current - baseline; 45 | if (diff > 0) { 46 | return `+${formatCount(diff)}`; 47 | } 48 | if (diff < 0) { 49 | return `${formatCount(diff)}`; 50 | } 51 | 52 | return '±0'; 53 | } 54 | 55 | export function formatCountChange(current?: number, baseline?: number): string { 56 | let output = `${formatCount(baseline)} → ${formatCount(current)}`; 57 | 58 | if (baseline != null && current != null && baseline !== current) { 59 | const parts = [formatCountDiff(current, baseline)]; 60 | 61 | if (baseline > 0) { 62 | const relativeDiff = (current - baseline) / baseline; 63 | parts.push(formatPercentChange(relativeDiff)); 64 | } 65 | 66 | output += ` (${parts.join(', ')})`; 67 | } 68 | 69 | output += ` ${getCountChangeSymbols(current, baseline)}`; 70 | return output; 71 | } 72 | 73 | export function formatChange(value: number): string { 74 | if (value > 0) return `+${value}`; 75 | if (value < 0) return `${value}`; 76 | return '0'; 77 | } 78 | 79 | export function formatDurationChange(entry: CompareEntry) { 80 | const { baseline, current } = entry; 81 | 82 | let output = `${formatDuration(baseline.meanDuration)} → ${formatDuration(current.meanDuration)}`; 83 | 84 | if (baseline.meanDuration != current.meanDuration) { 85 | output += ` (${formatDurationDiff(entry.durationDiff)}, ${formatPercentChange(entry.relativeDurationDiff)})`; 86 | } 87 | 88 | output += ` ${getDurationChangeSymbols(entry)}`; 89 | 90 | return output; 91 | } 92 | 93 | function getDurationChangeSymbols(entry: CompareEntry) { 94 | if (!entry.isDurationDiffSignificant) { 95 | if (entry.relativeDurationDiff > 0.15) return '🔴'; 96 | if (entry.relativeDurationDiff < -0.15) return '🟢'; 97 | return ''; 98 | } 99 | 100 | if (entry.relativeDurationDiff > 0.33) return '🔴🔴'; 101 | if (entry.relativeDurationDiff > 0.05) return '🔴'; 102 | if (entry.relativeDurationDiff < -0.33) return '🟢🟢'; 103 | if (entry.relativeDurationDiff < -0.05) return ' 🟢'; 104 | 105 | return ''; 106 | } 107 | 108 | function getCountChangeSymbols(current?: number, baseline?: number) { 109 | if (current == null || baseline == null) { 110 | return ''; 111 | } 112 | 113 | const diff = current - baseline; 114 | if (diff > 1.5) return '🔴🔴'; 115 | if (diff > 0.5) return '🔴'; 116 | if (diff < -1.5) return '🟢🟢'; 117 | if (diff < -0.5) return '🟢'; 118 | 119 | return ''; 120 | } 121 | 122 | function formatCommitMetadata(metadata?: MeasureMetadata) { 123 | if (metadata?.branch && metadata?.commitHash) { 124 | return `${metadata.branch} (${metadata.commitHash})`; 125 | } 126 | 127 | return metadata?.branch || metadata?.commitHash || '(unknown)'; 128 | } 129 | 130 | const isoDateMilliseconds = buildRegExp(['.', repeat(digit, 3), 'Z']); 131 | 132 | function formatDateTime(dateString: string) { 133 | // Remove 'T' and milliseconds part 134 | return dateString.replace('T', ' ').replace(isoDateMilliseconds, 'Z'); 135 | } 136 | 137 | export function formatMetadata(metadata?: MeasureMetadata) { 138 | let result = formatCommitMetadata(metadata); 139 | if (metadata?.creationDate) { 140 | result += ` - ${formatDateTime(metadata.creationDate)}`; 141 | } 142 | 143 | return result; 144 | } 145 | -------------------------------------------------------------------------------- /packages/measure/src/__tests__/outlier-helpers.test.tsx: -------------------------------------------------------------------------------- 1 | import { findOutliers } from '../outlier-helpers'; 2 | 3 | test('returns the original array if it has 0 elements', () => { 4 | const results: { duration: number }[] = []; 5 | 6 | expect(findOutliers(results)).toEqual({ 7 | results, 8 | outliers: [], 9 | }); 10 | }); 11 | 12 | test('returns the original array if it has 1 element', () => { 13 | const results = [{ duration: 100 }]; 14 | 15 | expect(findOutliers(results)).toEqual({ 16 | results, 17 | outliers: [], 18 | }); 19 | }); 20 | 21 | test('returns all elements if there are no outliers', () => { 22 | const results = [{ duration: 100 }, { duration: 105 }, { duration: 98 }, { duration: 102 }]; 23 | 24 | expect(findOutliers(results)).toEqual({ 25 | results, 26 | outliers: [], 27 | }); 28 | }); 29 | 30 | test('filters out significant outliers', () => { 31 | const results = [ 32 | { duration: 100 }, 33 | { duration: 105 }, 34 | { duration: 98 }, 35 | { duration: 1000 }, // outlier 36 | { duration: 102 }, 37 | ]; 38 | 39 | expect(findOutliers(results)).toEqual({ 40 | results: [{ duration: 100 }, { duration: 105 }, { duration: 98 }, { duration: 102 }], 41 | outliers: [{ duration: 1000 }], 42 | }); 43 | }); 44 | 45 | test('handles case where all elements have the same value (MAD = 0)', () => { 46 | const results = [{ duration: 100 }, { duration: 100 }, { duration: 100 }]; 47 | 48 | expect(findOutliers(results)).toEqual({ 49 | results, 50 | outliers: [], 51 | }); 52 | }); 53 | 54 | test('retains original object properties', () => { 55 | const results = [ 56 | { duration: 100, name: 'test1' }, 57 | { duration: 1000, name: 'outlier' }, // outlier 58 | { duration: 105, name: 'test2' }, 59 | { duration: 98, name: 'test3' }, 60 | { duration: 102, name: 'test4' }, 61 | ]; 62 | 63 | expect(findOutliers(results)).toEqual({ 64 | results: [ 65 | { duration: 100, name: 'test1' }, 66 | { duration: 105, name: 'test2' }, 67 | { duration: 98, name: 'test3' }, 68 | { duration: 102, name: 'test4' }, 69 | ], 70 | outliers: [{ duration: 1000, name: 'outlier' }], 71 | }); 72 | }); 73 | 74 | test('handles multiple outliers', () => { 75 | const results = [ 76 | { duration: 28 }, // outlier 77 | { duration: 13 }, 78 | { duration: 13 }, 79 | { duration: 14 }, // outlier 80 | { duration: 13 }, 81 | { duration: 13 }, 82 | { duration: 12 }, // outlier 83 | { duration: 29 }, // outlier 84 | { duration: 13 }, 85 | { duration: 13 }, 86 | ]; 87 | 88 | expect(findOutliers(results)).toEqual({ 89 | results: [ 90 | { duration: 13 }, 91 | { duration: 13 }, 92 | { duration: 13 }, 93 | { duration: 13 }, 94 | { duration: 13 }, 95 | { duration: 13 }, 96 | ], 97 | outliers: [{ duration: 28 }, { duration: 14 }, { duration: 12 }, { duration: 29 }], 98 | }); 99 | }); 100 | 101 | test('handles multiple outliers with larger values', () => { 102 | const results = [ 103 | { duration: 280 }, // outlier 104 | { duration: 130 }, 105 | { duration: 130 }, 106 | { duration: 140 }, 107 | { duration: 130 }, 108 | { duration: 125 }, 109 | { duration: 120 }, 110 | { duration: 290 }, // outlier 111 | { duration: 130 }, 112 | { duration: 135 }, 113 | ]; 114 | 115 | expect(findOutliers(results)).toEqual({ 116 | results: [ 117 | { duration: 130 }, 118 | { duration: 130 }, 119 | { duration: 140 }, 120 | { duration: 130 }, 121 | { duration: 125 }, 122 | { duration: 120 }, 123 | { duration: 130 }, 124 | { duration: 135 }, 125 | ], 126 | outliers: [{ duration: 280 }, { duration: 290 }], 127 | }); 128 | }); 129 | 130 | test('handles multiple outliers with small decimal values', () => { 131 | const results = [ 132 | { duration: 2.8 }, // outlier 133 | { duration: 1.3 }, 134 | { duration: 1.3 }, 135 | { duration: 1.4 }, // outlier 136 | { duration: 1.3 }, 137 | { duration: 1.3 }, 138 | { duration: 1.2 }, // outlier 139 | { duration: 2.9 }, // outlier 140 | { duration: 1.3 }, 141 | { duration: 1.3 }, 142 | ]; 143 | 144 | expect(findOutliers(results)).toEqual({ 145 | results: [ 146 | { duration: 1.3 }, 147 | { duration: 1.3 }, 148 | { duration: 1.3 }, 149 | { duration: 1.3 }, 150 | { duration: 1.3 }, 151 | { duration: 1.3 }, 152 | ], 153 | outliers: [{ duration: 2.8 }, { duration: 1.4 }, { duration: 1.2 }, { duration: 2.9 }], 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /packages/measure/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @callstack/reassure-measure 2 | 3 | ## 1.4.0 4 | 5 | ### Minor Changes 6 | 7 | - c51fb5f: feat: add measureAsyncFunction 8 | - 59b21d4: feat: add `removeOutliers` option to detect and drop statistical outliers 9 | - db535ea: feat: add support for setup/cleanup functions during each test run 10 | 11 | ### Patch Changes 12 | 13 | - 0870117: chore: upgrade deps 14 | - b34459f: enable outlier detection by default 15 | - Updated dependencies [0870117] 16 | - @callstack/reassure-logger@1.4.0 17 | 18 | ## 1.4.0-next.0 19 | 20 | ### Minor Changes 21 | 22 | - c51fb5f: feat: add measureAsyncFunction 23 | - 59b21d4: Added a `removeOutliers` option to detect and drop statistical outliers 24 | 25 | ### Patch Changes 26 | 27 | - 0870117: chore: upgrade deps 28 | - Updated dependencies [0870117] 29 | - @callstack/reassure-logger@1.4.0-next.0 30 | 31 | ## 1.3.3 32 | 33 | ### Patch Changes 34 | 35 | - @callstack/reassure-logger@1.3.3 36 | 37 | ## 1.3.2 38 | 39 | ### Patch Changes 40 | 41 | - @callstack/reassure-logger@1.3.2 42 | 43 | ## 1.3.1 44 | 45 | ### Patch Changes 46 | 47 | - @callstack/reassure-logger@1.3.1 48 | 49 | ## 1.3.0 50 | 51 | ### Minor Changes 52 | 53 | - 63f1f35: improve `measureRenders` precision on React Native 54 | 55 | ### Patch Changes 56 | 57 | - @callstack/reassure-logger@1.3.0 58 | 59 | ## 1.2.1 60 | 61 | ### Patch Changes 62 | 63 | - 75dc782: chore: update deps 64 | - @callstack/reassure-logger@1.2.1 65 | 66 | ## 1.2.0 67 | 68 | ### Patch Changes 69 | 70 | - Updated dependencies [ce30981] 71 | - @callstack/reassure-logger@1.2.0 72 | 73 | ## 1.1.0 74 | 75 | ### Patch Changes 76 | 77 | - 068951b: fix: respect to the "testingLibrary" config option 78 | - 0adb9cc: chore: update deps 79 | - Updated dependencies [0adb9cc] 80 | - @callstack/reassure-logger@1.1.0 81 | 82 | ## 1.0.0 83 | 84 | ### Major Changes 85 | 86 | - Rename `measurePerformance` to `measureRenders`. 87 | 88 | ### Minor Changes 89 | 90 | - ebcf9d6: Detect render issues (initial render updates, redundant renders) 91 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 92 | - ebcf9d6: chore: fix version deps 93 | 94 | ## 0.11.0 95 | 96 | ### Minor Changes 97 | 98 | - b455bd4: - Add `writeFile` option to `measurePerformance`/`measureFunction`. 99 | 100 | ### Patch Changes 101 | 102 | - b455bd4: refactor: simplify `reassure-logger` package exports 103 | - Updated dependencies [b455bd4] 104 | - @callstack/reassure-logger@0.11.0 105 | 106 | ## 0.6.0 107 | 108 | ### Minor Changes 109 | 110 | - baf90de1: (BREAKING) feat: `wrapper` option for `measurePerformance`/`measureRender` function now accepts a React component instead of wrapper function. 111 | - ea70aabb: (BREAKING) refactor: renamed `dropWorst` option to `warmupRuns` with slight change of logic behind it. 112 | - feat: `measureFunction` API to measure regular JS functions execution time 113 | 114 | ### Patch Changes 115 | 116 | - Updated dependencies [99afdf98] 117 | - @callstack/reassure-logger@0.3.2 118 | 119 | ## 0.5.1 120 | 121 | ### Patch Changes 122 | 123 | - Updated dependencies [2e127815] 124 | - @callstack/reassure-logger@0.3.1 125 | 126 | ## 0.5.0 127 | 128 | ### Minor Changes 129 | 130 | - f8aa25fe: feat: capture date and time of measurements 131 | 132 | ### Patch Changes 133 | 134 | - fa59394e: fix: exclude wrapper component from render measurements 135 | 136 | ## 0.4.1 137 | 138 | ### Patch Changes 139 | 140 | - Updated dependencies [235f37d4] 141 | - @callstack/reassure-logger@0.3.0 142 | 143 | ## 0.4.0 144 | 145 | ### Minor Changes 146 | 147 | - 35af62a4: Custom logger implementation with verbose and silent modes. 148 | 149 | ### Patch Changes 150 | 151 | - Updated dependencies [35af62a4] 152 | - @callstack/reassure-logger@0.2.0 153 | 154 | ## 0.3.1 155 | 156 | ### Patch Changes 157 | 158 | - 5a1c3472: Changes for dependencies cleanup after monorepo migration 159 | 160 | ## 0.3.0 161 | 162 | ### Minor Changes 163 | 164 | - 7ad802b9: `testingLibrary` configuration option that replaces `render` and `cleanup` options 165 | 166 | ## 0.2.0 167 | 168 | ### Minor Changes 169 | 170 | - Support React Testing Library (web) autodiscovery. 171 | 172 | ## 0.1.2 173 | 174 | ### Patch Changes 175 | 176 | - 417dcf6a: Ability to provide custom `render` and `cleanup` function for measure step. 177 | 178 | ## 0.1.1 179 | 180 | ### Patch Changes 181 | 182 | - ca56a6d1: Move internal packages under @reassure scope to @callstack scope for simpler maintenance 183 | 184 | ## 0.1.0 185 | 186 | ### Minor Changes 187 | 188 | - d6bfef03: Add note about ignoring .reassure folder 189 | 190 | ## 0.0.3 191 | 192 | ### Patch Changes 193 | 194 | - 27f45e83: Fix typescript setup for publishing with bob 195 | 196 | ## 0.0.2 197 | 198 | ### Patch Changes 199 | 200 | - 2f8f8c06: setup changesets 201 | -------------------------------------------------------------------------------- /packages/measure/src/measure-renders.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as logger from '@callstack/reassure-logger'; 3 | import { config } from './config'; 4 | import { RunResult, processRunResults } from './measure-helpers'; 5 | import { showFlagsOutputIfNeeded, writeTestStats } from './output'; 6 | import { applyRenderPolyfills, revertRenderPolyfills } from './polyfills'; 7 | import { ElementJsonTree, detectRedundantUpdates } from './redundant-renders'; 8 | import { resolveTestingLibrary, getTestingLibrary } from './testing-library'; 9 | import type { MeasureRendersResults } from './types'; 10 | 11 | logger.configure({ 12 | verbose: process.env.REASSURE_VERBOSE === 'true' || process.env.REASSURE_VERBOSE === '1', 13 | silent: process.env.REASSURE_SILENT === 'true' || process.env.REASSURE_SILENT === '1', 14 | }); 15 | 16 | export interface MeasureRendersOptions { 17 | runs?: number; 18 | warmupRuns?: number; 19 | removeOutliers?: boolean; 20 | wrapper?: React.ComponentType<{ children: React.ReactElement }>; 21 | scenario?: (screen: any) => Promise; 22 | writeFile?: boolean; 23 | beforeEach?: () => Promise | void; 24 | afterEach?: () => Promise | void; 25 | } 26 | 27 | export async function measureRenders( 28 | ui: React.ReactElement, 29 | options?: MeasureRendersOptions 30 | ): Promise { 31 | const stats = await measureRendersInternal(ui, options); 32 | 33 | if (options?.writeFile !== false) { 34 | await writeTestStats(stats, 'render'); 35 | } 36 | 37 | return stats; 38 | } 39 | 40 | /** 41 | * @deprecated The `measurePerformance` function has been renamed to `measureRenders`. The `measurePerformance` alias is now deprecated and will be removed in future releases. 42 | */ 43 | export async function measurePerformance( 44 | ui: React.ReactElement, 45 | options?: MeasureRendersOptions 46 | ): Promise { 47 | logger.warnOnce( 48 | 'The `measurePerformance` function has been renamed to `measureRenders`.\n\nThe `measurePerformance` alias is now deprecated and will be removed in future releases.' 49 | ); 50 | 51 | return await measureRenders(ui, options); 52 | } 53 | 54 | async function measureRendersInternal( 55 | ui: React.ReactElement, 56 | options?: MeasureRendersOptions 57 | ): Promise { 58 | const runs = options?.runs ?? config.runs; 59 | const scenario = options?.scenario; 60 | const warmupRuns = options?.warmupRuns ?? config.warmupRuns; 61 | const removeOutliers = options?.removeOutliers ?? config.removeOutliers; 62 | 63 | const { render, cleanup } = resolveTestingLibrary(); 64 | const testingLibrary = getTestingLibrary(); 65 | 66 | showFlagsOutputIfNeeded(); 67 | applyRenderPolyfills(); 68 | 69 | const runResults: RunResult[] = []; 70 | const renderJsonTrees: ElementJsonTree[] = []; 71 | let initialRenderCount = 0; 72 | 73 | for (let iteration = 0; iteration < runs + warmupRuns; iteration += 1) { 74 | await options?.beforeEach?.(); 75 | 76 | let duration = 0; 77 | let count = 0; 78 | let renderResult: any = null; 79 | 80 | const captureRenderDetails = () => { 81 | // We capture render details only on the first run 82 | if (iteration !== 0) { 83 | return; 84 | } 85 | 86 | // Initial render did not finish yet, so there is no "render" result yet and we cannot analyze the element tree. 87 | if (renderResult == null) { 88 | initialRenderCount += 1; 89 | return; 90 | } 91 | 92 | if (testingLibrary === 'react-native') { 93 | renderJsonTrees.push(renderResult.toJSON()); 94 | } 95 | }; 96 | 97 | const handleRender = (_id: string, _phase: string, actualDuration: number) => { 98 | duration += actualDuration; 99 | count += 1; 100 | 101 | captureRenderDetails(); 102 | }; 103 | 104 | const uiToRender = buildUiToRender(ui, handleRender, options?.wrapper); 105 | renderResult = render(uiToRender); 106 | captureRenderDetails(); 107 | 108 | if (scenario) { 109 | await scenario(renderResult); 110 | } 111 | 112 | cleanup(); 113 | global.gc?.(); 114 | 115 | await options?.afterEach?.(); 116 | 117 | runResults.push({ duration, count }); 118 | } 119 | 120 | revertRenderPolyfills(); 121 | 122 | return { 123 | ...processRunResults(runResults, { warmupRuns, removeOutliers }), 124 | issues: { 125 | initialUpdateCount: initialRenderCount - 1, 126 | redundantUpdates: detectRedundantUpdates(renderJsonTrees, initialRenderCount), 127 | }, 128 | }; 129 | } 130 | 131 | export function buildUiToRender( 132 | ui: React.ReactElement, 133 | onRender: React.ProfilerOnRenderCallback, 134 | Wrapper?: React.ComponentType<{ children: React.ReactElement }> 135 | ) { 136 | const uiWithProfiler = ( 137 | 138 | {ui} 139 | 140 | ); 141 | 142 | return Wrapper ? {uiWithProfiler} : uiWithProfiler; 143 | } 144 | -------------------------------------------------------------------------------- /packages/cli/src/commands/measure.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; 2 | import { resolve } from 'node:path'; 3 | import { spawnSync } from 'node:child_process'; 4 | import type { CommandModule } from 'yargs'; 5 | import { compare, formatMetadata } from '@callstack/reassure-compare'; 6 | import type { MeasureMetadata } from '@callstack/reassure-compare'; 7 | import * as logger from '@callstack/reassure-logger'; 8 | import { RESULTS_DIRECTORY, RESULTS_FILE, BASELINE_FILE } from '../constants'; 9 | import { applyCommonOptions, CommonOptions } from '../options'; 10 | import { getGitBranch, getGitCommitHash } from '../utils/git'; 11 | import { configureLoggerOptions } from '../utils/logger'; 12 | import { getJestBinPath, getNodeFlags, getNodeMajorVersion } from '../utils/node'; 13 | 14 | // Jest default testMatch: [ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)" ] 15 | const DEFAULT_TEST_MATCH = ['**/__perf__/**/*.[jt]s?(x)', '**/*.(perf|perf-test).[jt]s?(x)']; 16 | 17 | export interface MeasureOptions extends CommonOptions { 18 | baseline?: boolean; 19 | compare?: boolean; 20 | branch?: string; 21 | commitHash?: string; 22 | testMatch?: string[]; 23 | testRegex?: string[]; 24 | /** Rest argument used for flags after `--` separator, will be passed to test runner. */ 25 | _?: string[]; 26 | } 27 | 28 | export async function run(options: MeasureOptions) { 29 | configureLoggerOptions(options); 30 | 31 | const measurementType = options.baseline ? 'Baseline' : 'Current'; 32 | 33 | const metadata: MeasureMetadata = { 34 | creationDate: new Date().toISOString(), 35 | branch: options?.branch ?? (await getGitBranch()), 36 | commitHash: options?.commitHash ?? (await getGitCommitHash()), 37 | }; 38 | 39 | logger.log(`\n❇️ Running performance tests:`); 40 | logger.log(` - ${measurementType}: ${formatMetadata(metadata)}\n`); 41 | 42 | mkdirSync(RESULTS_DIRECTORY, { recursive: true }); 43 | 44 | const outputFile = options.baseline ? BASELINE_FILE : RESULTS_FILE; 45 | rmSync(outputFile, { force: true }); 46 | 47 | const header = { metadata }; 48 | writeFileSync(outputFile, JSON.stringify(header) + '\n'); 49 | 50 | const nodeMajorVersion = getNodeMajorVersion(); 51 | logger.verbose(`Node.js version: ${nodeMajorVersion} (${process.versions.node})`); 52 | 53 | const testRunnerPath = process.env.TEST_RUNNER_PATH ?? getJestBinPath(); 54 | if (!testRunnerPath) { 55 | logger.error( 56 | `❌ Unable to find Jest binary path. Pass explicit $TEST_RUNNER_PATH env variable to resolve the issue.` 57 | ); 58 | process.exitCode = 1; 59 | return; 60 | } 61 | 62 | const baseTestRunnerArgs = 63 | process.env.TEST_RUNNER_ARGS !== undefined ? [process.env.TEST_RUNNER_ARGS] : buildDefaultTestRunnerArgs(options); 64 | const passthroughTestRunnerArgs = options._ ?? []; 65 | 66 | const nodeArgs = [ 67 | ...getNodeFlags(nodeMajorVersion), 68 | `"${testRunnerPath}"`, 69 | ...baseTestRunnerArgs, 70 | ...passthroughTestRunnerArgs, 71 | ]; 72 | logger.verbose('Running tests using command:'); 73 | logger.verbose(`$ node \\\n ${nodeArgs.join(' \\\n ')}\n`); 74 | 75 | const spawnInfo = spawnSync('node', nodeArgs, { 76 | shell: true, 77 | stdio: 'inherit', 78 | env: { 79 | ...process.env, 80 | REASSURE_OUTPUT_FILE: outputFile, 81 | REASSURE_SILENT: options.silent.toString(), 82 | REASSURE_VERBOSE: options.verbose.toString(), 83 | }, 84 | }); 85 | 86 | logger.newLine(); 87 | 88 | if (spawnInfo.status !== 0) { 89 | logger.error(`❌ Test runner (${testRunnerPath}) exited with error code ${spawnInfo.status}`); 90 | process.exitCode = 1; 91 | return; 92 | } 93 | 94 | if (existsSync(outputFile)) { 95 | logger.log(`✅ Written ${measurementType} performance measurements to ${outputFile}`); 96 | logger.log(`🔗 ${resolve(outputFile)}\n`); 97 | } else { 98 | logger.error(`❌ Something went wrong, ${measurementType} performance file (${outputFile}) does not exist\n`); 99 | return; 100 | } 101 | 102 | if (options.baseline) { 103 | logger.log("Hint: You can now run 'reassure' to measure & compare performance against modified code.\n"); 104 | return; 105 | } 106 | 107 | if (options.compare) { 108 | if (existsSync(BASELINE_FILE)) { 109 | await compare(); 110 | } else { 111 | logger.log( 112 | `Baseline performance file does not exist, run 'reassure --baseline' on your baseline code branch to create it.\n` 113 | ); 114 | return; 115 | } 116 | } 117 | } 118 | 119 | export const command: CommandModule<{}, MeasureOptions> = { 120 | command: ['measure', '$0'], 121 | describe: '[Default] Gather performance measurements by running performance tests.', 122 | builder: (yargs) => { 123 | return applyCommonOptions(yargs) 124 | .option('baseline', { 125 | type: 'boolean', 126 | default: false, 127 | describe: 'Save measurements as baseline instead of current', 128 | }) 129 | .option('compare', { 130 | type: 'boolean', 131 | default: true, 132 | describe: 'Outputs performance comparison results', 133 | }) 134 | .option('branch', { 135 | type: 'string', 136 | describe: 'Branch name of current code to be included in the report', 137 | }) 138 | .option('commit-hash', { 139 | type: 'string', 140 | describe: 'Commit hash of current code to be included in the report', 141 | }) 142 | .option('testMatch', { 143 | type: 'string', 144 | array: true, 145 | describe: 'The glob patterns Reassure uses to detect perf test files', 146 | }) 147 | .option('testRegex', { 148 | type: 'string', 149 | array: true, 150 | describe: 'The regexp patterns Reassure uses to detect perf test files', 151 | }); 152 | }, 153 | handler: (args) => run(args), 154 | }; 155 | 156 | function buildDefaultTestRunnerArgs(options: MeasureOptions): string[] { 157 | if (options.testMatch && options.testRegex) { 158 | logger.error('Configuration options "testMatch" and "testRegex" cannot be used together.'); 159 | process.exit(1); 160 | } 161 | 162 | const commonArgs = ['--runInBand']; 163 | 164 | if (options.testMatch) { 165 | return [...commonArgs, `--testMatch=${toShellArray(options.testMatch)}`]; 166 | } 167 | 168 | if (options.testRegex) { 169 | return [...commonArgs, `--testRegex=${toShellArray(options.testRegex)}`]; 170 | } 171 | 172 | return [...commonArgs, `--testMatch=${toShellArray(DEFAULT_TEST_MATCH)}`]; 173 | } 174 | 175 | function toShellArray(texts: string[]): string { 176 | return texts 177 | .map(shellEscape) 178 | .map((text) => `"${text}"`) 179 | .join(' '); 180 | } 181 | 182 | function shellEscape(text: string) { 183 | return text.replace(/(["'$`\\])/g, '\\$1'); 184 | } 185 | -------------------------------------------------------------------------------- /packages/cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @callstack/reassure-cli 2 | 3 | ## 1.4.0 4 | 5 | ### Patch Changes 6 | 7 | - 0870117: chore: upgrade deps 8 | - Updated dependencies [0870117] 9 | - Updated dependencies [7ad16cb] 10 | - Updated dependencies [b34459f] 11 | - @callstack/reassure-compare@1.4.0 12 | - @callstack/reassure-logger@1.4.0 13 | 14 | ## 1.4.0-next.0 15 | 16 | ### Patch Changes 17 | 18 | - 0870117: chore: upgrade deps 19 | - Updated dependencies [0870117] 20 | - Updated dependencies [7ad16cb] 21 | - @callstack/reassure-compare@1.4.0-next.0 22 | - @callstack/reassure-logger@1.4.0-next.0 23 | 24 | ## 1.3.3 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [cbeca72] 29 | - @callstack/reassure-compare@1.3.3 30 | - @callstack/reassure-logger@1.3.3 31 | 32 | ## 1.3.2 33 | 34 | ### Patch Changes 35 | 36 | - Updated dependencies 37 | - @callstack/reassure-compare@1.3.2 38 | - @callstack/reassure-logger@1.3.2 39 | 40 | ## 1.3.1 41 | 42 | ### Patch Changes 43 | 44 | - aa608ae: fix: support test runner paths with spaces 45 | - @callstack/reassure-compare@1.3.1 46 | - @callstack/reassure-logger@1.3.1 47 | 48 | ## 1.3.0 49 | 50 | ### Patch Changes 51 | 52 | - @callstack/reassure-compare@1.3.0 53 | - @callstack/reassure-logger@1.3.0 54 | 55 | ## 1.2.1 56 | 57 | ### Patch Changes 58 | 59 | - e1ffeb2: fix: TEST_RUNNER_ARGS env variable support 60 | - 75dc782: chore: update deps 61 | - Updated dependencies [75dc782] 62 | - @callstack/reassure-compare@1.2.1 63 | - @callstack/reassure-logger@1.2.1 64 | 65 | ## 1.2.0 66 | 67 | ### Minor Changes 68 | 69 | - cd71fa7: Extent default testMatch: `**/__perf__/*.(js|ts)` and `*/*.perf.(js|ts)` 70 | - ce30981: --textRegex CLI option 71 | - 018cd40: Passthrough args after -- to Jest 72 | 73 | ### Patch Changes 74 | 75 | - Updated dependencies [ce30981] 76 | - @callstack/reassure-logger@1.2.0 77 | - @callstack/reassure-compare@1.2.0 78 | 79 | ## 1.1.0 80 | 81 | ### Patch Changes 82 | 83 | - 0adb9cc: chore: update deps 84 | - Updated dependencies [0adb9cc] 85 | - Updated dependencies [d1d3617] 86 | - @callstack/reassure-compare@1.1.0 87 | - @callstack/reassure-logger@1.1.0 88 | 89 | ## 1.0.0 90 | 91 | ### Minor Changes 92 | 93 | - ebcf9d6: `reassure` enables WASM support by default and runs using v8 baseline compilers: sparkplug and liftoff (WASM) 94 | - ebcf9d6: Detect render issues (initial render updates, redundant renders) 95 | - ebcf9d6: chore: migrate to Yarn Berry (4.x) 96 | - ebcf9d6: chore: fix version deps 97 | 98 | ## 0.11.0 99 | 100 | ### Patch Changes 101 | 102 | - b455bd4: refactor: simplify `reassure-logger` package exports 103 | - Updated dependencies [b455bd4] 104 | - Updated dependencies [bb2046a] 105 | - @callstack/reassure-logger@0.11.0 106 | - @callstack/reassure-compare@0.11.0 107 | 108 | ## 0.10.2 109 | 110 | ### Patch Changes 111 | 112 | - 8ca46a44: feat: Experimental options to enable WebAssembly (--enable-wasm) 113 | 114 | ## 0.10.0 115 | 116 | ### Minor Changes 117 | 118 | - baf90de1: (BREAKING) feat: `wrapper` option for `measurePerformance`/`measureRender` function now accepts a React component instead of wrapper function. 119 | - feat: `measureFunction` API to measure regular JS functions execution time 120 | 121 | ### Patch Changes 122 | 123 | - Updated dependencies 124 | - Updated dependencies [99afdf98] 125 | - @callstack/reassure-compare@0.6.0 126 | - @callstack/reassure-logger@0.3.2 127 | 128 | ## 0.9.1 129 | 130 | ### Patch Changes 131 | 132 | - Updated dependencies [2e127815] 133 | - @callstack/reassure-logger@0.3.1 134 | - @callstack/reassure-compare@0.5.1 135 | 136 | ## 0.9.0 137 | 138 | ### Minor Changes 139 | 140 | - f8aa25fe: feat: capture date and time of measurements 141 | 142 | ### Patch Changes 143 | 144 | - fa59394e: fix: exclude wrapper component from render measurements 145 | - c2dffec0: fix: support running perf tests when user has `jest-expo` installed 146 | - Updated dependencies [f8aa25fe] 147 | - @callstack/reassure-compare@0.5.0 148 | 149 | ## 0.8.0 150 | 151 | ### Minor Changes 152 | 153 | - 235f37d4: init CLI command 154 | 155 | ### Patch Changes 156 | 157 | - Updated dependencies [235f37d4] 158 | - @callstack/reassure-logger@0.3.0 159 | - @callstack/reassure-compare@0.4.1 160 | 161 | ## 0.7.0 162 | 163 | ### Minor Changes 164 | 165 | - 35af62a4: Custom logger implementation with verbose and silent modes. 166 | - a046afde: Add support for passing a custom `--testMatch` option to Jest 167 | 168 | ### Patch Changes 169 | 170 | - Updated dependencies [35af62a4] 171 | - @callstack/reassure-compare@0.4.0 172 | - @callstack/reassure-logger@0.2.0 173 | 174 | ## 0.6.1 175 | 176 | ### Patch Changes 177 | 178 | - ff5e2ff: fix: handling of TEST_RUNNER_PATH env variable 179 | 180 | ## 0.6.0 181 | 182 | ### Minor Changes 183 | 184 | - 7691e28: feat: windows support 185 | - 3a180f2: Validate format of performance results files when loading for comparison 186 | 187 | ### Patch Changes 188 | 189 | - 7cbd1d3: Fix random race condition for `reassure check-stability` command 190 | - Updated dependencies [3a180f2] 191 | - @callstack/reassure-compare@0.3.0 192 | 193 | ## 0.5.0 194 | 195 | ### Minor Changes 196 | 197 | - b4250e3c: Include codebase metadata (branch, commit hash) in the performance report and measurement file 198 | - 067f66d3: Automatically get branch and commit hash CLI options from Git if not passed 199 | 200 | ### Patch Changes 201 | 202 | - 5a1c3472: Changes for dependencies cleanup after monorepo migration 203 | - Updated dependencies [b4250e3c] 204 | - Updated dependencies [5a1c3472] 205 | - @callstack/reassure-compare@0.2.0 206 | 207 | ## 0.4.0 208 | 209 | ### Minor Changes 210 | 211 | - d9b30f57: Fail reassure if jest exits with an error 212 | 213 | ## 0.3.0 214 | 215 | ### Minor Changes 216 | 217 | - 4d0cca6a: Merge (remove) `ressure compare` with `reassure measure`. Performance comparison will be generated automatically when baseline file already exists when running `measure`. You can disable that output by specifying `--no-compare` option for `measure` command. 218 | 219 | Also set `reassure` default command as alias to `reassure measure`, so now you can run `reassure` instead of `reassure measure`. 220 | 221 | ### Patch Changes 222 | 223 | - ca56a6d1: Move internal packages under @reassure scope to @callstack scope for simpler maintenance 224 | - Updated dependencies [4d0cca6a] 225 | - Updated dependencies [ca56a6d1] 226 | - @callstack/reassure-compare@0.1.1 227 | 228 | ## 0.2.0 229 | 230 | ### Minor Changes 231 | 232 | - 472c7735: Add `TEST_RUNNER_PATH` and `TEST_RUNNER_ARGS` env variables to configure test runner 233 | 234 | ## 0.1.0 235 | 236 | ### Minor Changes 237 | 238 | - d6bfef03: Add note about ignoring .reassure folder 239 | 240 | ### Patch Changes 241 | 242 | - Updated dependencies [d6bfef03] 243 | - @callstack/reassure-compare@0.1.0 244 | 245 | ## 0.0.4 246 | 247 | ### Patch Changes 248 | 249 | - 27f45e83: Fix typescript setup for publishing with bob 250 | - Updated dependencies [27f45e83] 251 | - @callstack/reassure-compare@0.0.3 252 | 253 | ## 0.0.3 254 | 255 | ### Patch Changes 256 | 257 | - 91959c68: Fix index export 258 | 259 | ## 0.0.2 260 | 261 | ### Patch Changes 262 | 263 | - 2f8f8c06: setup changesets 264 | - Updated dependencies [2f8f8c06] 265 | - @callstack/reassure-compare@0.0.2 266 | -------------------------------------------------------------------------------- /packages/compare/src/test/__snapshots__/compare.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`loadFile should fail for file with invalid JSON structure 1`] = `"Unexpected non-whitespace character after JSON at position 6 (line 1 column 7)"`; 4 | 5 | exports[`loadFile should fail for file with invalid entry 1`] = ` 6 | "[ 7 | { 8 | "expected": "number", 9 | "code": "invalid_type", 10 | "path": [ 11 | 1, 12 | "runs" 13 | ], 14 | "message": "Invalid input: expected number, received string" 15 | }, 16 | { 17 | "expected": "number", 18 | "code": "invalid_type", 19 | "path": [ 20 | 1, 21 | "meanDuration" 22 | ], 23 | "message": "Invalid input: expected number, received undefined" 24 | } 25 | ]" 26 | `; 27 | 28 | exports[`loadFile should load results file with header 1`] = ` 29 | { 30 | "entries": { 31 | "Async Component": { 32 | "counts": [ 33 | 7, 34 | 7, 35 | 7, 36 | 7, 37 | 7, 38 | 7, 39 | 7, 40 | 7, 41 | 7, 42 | 7, 43 | ], 44 | "durations": [ 45 | 160, 46 | 158, 47 | 158, 48 | 155, 49 | 153, 50 | 150, 51 | 149, 52 | 145, 53 | 144, 54 | 136, 55 | ], 56 | "meanCount": 7, 57 | "meanDuration": 150.8, 58 | "name": "Async Component", 59 | "runs": 10, 60 | "stdevCount": 0, 61 | "stdevDuration": 7.554248252914823, 62 | "type": "render", 63 | }, 64 | "Other Component 10": { 65 | "counts": [ 66 | 4, 67 | 4, 68 | 4, 69 | 4, 70 | 4, 71 | 4, 72 | 4, 73 | 4, 74 | 4, 75 | 4, 76 | ], 77 | "durations": [ 78 | 100, 79 | 97, 80 | 95, 81 | 94, 82 | 94, 83 | 94, 84 | 93, 85 | 90, 86 | 86, 87 | 84, 88 | ], 89 | "meanCount": 4, 90 | "meanDuration": 92.7, 91 | "name": "Other Component 10", 92 | "runs": 10, 93 | "stdevCount": 0, 94 | "stdevDuration": 4.831608887776871, 95 | "type": "render", 96 | }, 97 | "Other Component 10 legacy scenario": { 98 | "counts": [ 99 | 4, 100 | 4, 101 | 4, 102 | 4, 103 | 4, 104 | 4, 105 | 4, 106 | 4, 107 | 4, 108 | 4, 109 | ], 110 | "durations": [ 111 | 97, 112 | 96, 113 | 94, 114 | 92, 115 | 91, 116 | 91, 117 | 88, 118 | 88, 119 | 85, 120 | 83, 121 | ], 122 | "meanCount": 4, 123 | "meanDuration": 90.5, 124 | "name": "Other Component 10 legacy scenario", 125 | "runs": 10, 126 | "stdevCount": 0, 127 | "stdevDuration": 4.552166761249221, 128 | "type": "render", 129 | }, 130 | "Other Component 20": { 131 | "counts": [ 132 | 4, 133 | 4, 134 | 4, 135 | 4, 136 | 4, 137 | 4, 138 | 4, 139 | 4, 140 | 4, 141 | 4, 142 | 4, 143 | 4, 144 | 4, 145 | 4, 146 | 4, 147 | 4, 148 | 4, 149 | 4, 150 | 4, 151 | 4, 152 | ], 153 | "durations": [ 154 | 99, 155 | 99, 156 | 98, 157 | 98, 158 | 96, 159 | 95, 160 | 94, 161 | 93, 162 | 93, 163 | 93, 164 | 93, 165 | 92, 166 | 90, 167 | 90, 168 | 89, 169 | 88, 170 | 88, 171 | 87, 172 | 87, 173 | 82, 174 | ], 175 | "meanCount": 4, 176 | "meanDuration": 92.2, 177 | "name": "Other Component 20", 178 | "runs": 20, 179 | "stdevCount": 0, 180 | "stdevDuration": 4.595191995301633, 181 | "type": "render", 182 | }, 183 | "fib 30": { 184 | "counts": [ 185 | 1, 186 | 1, 187 | 1, 188 | 1, 189 | 1, 190 | 1, 191 | 1, 192 | 1, 193 | 1, 194 | 1, 195 | ], 196 | "durations": [ 197 | 80, 198 | 80, 199 | 80, 200 | 80, 201 | 80, 202 | 79, 203 | 79, 204 | 79, 205 | 79, 206 | 79, 207 | ], 208 | "meanCount": 1, 209 | "meanDuration": 79.9, 210 | "name": "fib 30", 211 | "runs": 10, 212 | "stdevCount": 0, 213 | "stdevDuration": 0.4532780026900862, 214 | "type": "function", 215 | }, 216 | }, 217 | "metadata": { 218 | "branch": "feat/perf-file-validation", 219 | "commitHash": "991427a413b1ff05497a881287c9ddcba7b8de54", 220 | }, 221 | } 222 | `; 223 | 224 | exports[`loadFile should load results file without header 1`] = ` 225 | { 226 | "entries": { 227 | "Async Component": { 228 | "counts": [ 229 | 7, 230 | 7, 231 | 7, 232 | 7, 233 | 7, 234 | 7, 235 | 7, 236 | 7, 237 | 7, 238 | 7, 239 | ], 240 | "durations": [ 241 | 160, 242 | 158, 243 | 158, 244 | 155, 245 | 153, 246 | 150, 247 | 149, 248 | 145, 249 | 144, 250 | 136, 251 | ], 252 | "meanCount": 7, 253 | "meanDuration": 150.8, 254 | "name": "Async Component", 255 | "runs": 10, 256 | "stdevCount": 0, 257 | "stdevDuration": 7.554248252914823, 258 | "type": "render", 259 | }, 260 | "Other Component 10": { 261 | "counts": [ 262 | 4, 263 | 4, 264 | 4, 265 | 4, 266 | 4, 267 | 4, 268 | 4, 269 | 4, 270 | 4, 271 | 4, 272 | ], 273 | "durations": [ 274 | 100, 275 | 97, 276 | 95, 277 | 94, 278 | 94, 279 | 94, 280 | 93, 281 | 90, 282 | 86, 283 | 84, 284 | ], 285 | "meanCount": 4, 286 | "meanDuration": 92.7, 287 | "name": "Other Component 10", 288 | "runs": 10, 289 | "stdevCount": 0, 290 | "stdevDuration": 4.831608887776871, 291 | "type": "render", 292 | }, 293 | "Other Component 10 legacy scenario": { 294 | "counts": [ 295 | 4, 296 | 4, 297 | 4, 298 | 4, 299 | 4, 300 | 4, 301 | 4, 302 | 4, 303 | 4, 304 | 4, 305 | ], 306 | "durations": [ 307 | 97, 308 | 96, 309 | 94, 310 | 92, 311 | 91, 312 | 91, 313 | 88, 314 | 88, 315 | 85, 316 | 83, 317 | ], 318 | "meanCount": 4, 319 | "meanDuration": 90.5, 320 | "name": "Other Component 10 legacy scenario", 321 | "runs": 10, 322 | "stdevCount": 0, 323 | "stdevDuration": 4.552166761249221, 324 | "type": "render", 325 | }, 326 | "Other Component 20": { 327 | "counts": [ 328 | 4, 329 | 4, 330 | 4, 331 | 4, 332 | 4, 333 | 4, 334 | 4, 335 | 4, 336 | 4, 337 | 4, 338 | 4, 339 | 4, 340 | 4, 341 | 4, 342 | 4, 343 | 4, 344 | 4, 345 | 4, 346 | 4, 347 | 4, 348 | ], 349 | "durations": [ 350 | 99, 351 | 99, 352 | 98, 353 | 98, 354 | 96, 355 | 95, 356 | 94, 357 | 93, 358 | 93, 359 | 93, 360 | 93, 361 | 92, 362 | 90, 363 | 90, 364 | 89, 365 | 88, 366 | 88, 367 | 87, 368 | 87, 369 | 82, 370 | ], 371 | "meanCount": 4, 372 | "meanDuration": 92.2, 373 | "name": "Other Component 20", 374 | "runs": 20, 375 | "stdevCount": 0, 376 | "stdevDuration": 4.595191995301633, 377 | "type": "render", 378 | }, 379 | "fib 30": { 380 | "counts": [ 381 | 1, 382 | 1, 383 | 1, 384 | 1, 385 | 1, 386 | 1, 387 | 1, 388 | 1, 389 | 1, 390 | 1, 391 | ], 392 | "durations": [ 393 | 80, 394 | 80, 395 | 80, 396 | 80, 397 | 80, 398 | 79, 399 | 79, 400 | 79, 401 | 79, 402 | 79, 403 | ], 404 | "meanCount": 1, 405 | "meanDuration": 79.9, 406 | "name": "fib 30", 407 | "runs": 10, 408 | "stdevCount": 0, 409 | "stdevDuration": 0.4532780026900862, 410 | "type": "function", 411 | }, 412 | }, 413 | "metadata": undefined, 414 | } 415 | `; 416 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. 4 | 5 | ## Development workflow 6 | 7 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: 8 | 9 | ```sh 10 | yarn 11 | ``` 12 | 13 | Then run `yarn build` to build commonjs modules for workspace packages: 14 | 15 | ```sh 16 | yarn build 17 | ``` 18 | 19 | > While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development. 20 | 21 | While developing, you can run the test app app, located in `test-apps/native` folder, to test your changes. Any changes you make in the library's JavaScript code will have to rebuilt using `yarn build` command run in the top-level folder. 22 | 23 | To run the performance tests in the test app run the following command: 24 | 25 | ```sh 26 | cd test-apps/native 27 | yarn reassure 28 | ``` 29 | 30 | > **Note**: example apps are using `nohoist` workspace options, so you need to run `yarn install` by hand in their folders. 31 | 32 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 33 | 34 | ```sh 35 | yarn typecheck 36 | yarn lint 37 | ``` 38 | 39 | To fix formatting errors, run the following: 40 | 41 | ```sh 42 | yarn lint --fix 43 | ``` 44 | 45 | Remember to add tests for your change if possible. Run the unit tests by: 46 | 47 | ```sh 48 | yarn test 49 | ``` 50 | 51 | ### Linting and tests 52 | 53 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 54 | 55 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 56 | 57 | Our pre-commit hooks verify that the linter and tests pass when committing. 58 | 59 | ### Publishing to npm 60 | 61 | We use Changesets to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. 62 | 63 | To publish new versions, run the following: 64 | 65 | ```sh 66 | yarn publish 67 | ``` 68 | 69 | ### Scripts 70 | 71 | The `package.json` file contains various scripts for common tasks: 72 | 73 | - `yarn typecheck`: type-check files with TypeScript. 74 | - `yarn lint`: lint files with ESLint. 75 | - `yarn test`: run unit tests with Jest. 76 | - `yarn build`: build the files. 77 | 78 | ### Sending a pull request 79 | 80 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). 81 | 82 | When you're sending a pull request: 83 | 84 | - Prefer small pull requests focused on one change. 85 | - Verify that linters and tests are passing. 86 | - Review the documentation to make sure it looks good. 87 | - Follow the pull request template when opening a pull request. 88 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 89 | 90 | ## Code of Conduct 91 | 92 | ### Our Pledge 93 | 94 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 95 | 96 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 97 | 98 | ### Our Standards 99 | 100 | Examples of behavior that contributes to a positive environment for our community include: 101 | 102 | - Demonstrating empathy and kindness toward other people 103 | - Being respectful of differing opinions, viewpoints, and experiences 104 | - Giving and gracefully accepting constructive feedback 105 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 106 | - Focusing on what is best not just for us as individuals, but for the overall community 107 | 108 | Examples of unacceptable behavior include: 109 | 110 | - The use of sexualized language or imagery, and sexual attention or 111 | advances of any kind 112 | - Trolling, insulting or derogatory comments, and personal or political attacks 113 | - Public or private harassment 114 | - Publishing others' private information, such as a physical or email 115 | address, without their explicit permission 116 | - Other conduct which could reasonably be considered inappropriate in a 117 | professional setting 118 | 119 | ### Enforcement Responsibilities 120 | 121 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 122 | 123 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 124 | 125 | ### Scope 126 | 127 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 128 | 129 | ### Enforcement 130 | 131 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. 132 | 133 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 134 | 135 | ### Enforcement Guidelines 136 | 137 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 138 | 139 | #### 1. Correction 140 | 141 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 142 | 143 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 144 | 145 | #### 2. Warning 146 | 147 | **Community Impact**: A violation through a single incident or series of actions. 148 | 149 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 150 | 151 | #### 3. Temporary Ban 152 | 153 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 154 | 155 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 156 | 157 | #### 4. Permanent Ban 158 | 159 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 160 | 161 | **Consequence**: A permanent ban from any sort of public interaction within the community. 162 | 163 | ### Attribution 164 | 165 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 166 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 167 | 168 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 169 | 170 | [homepage]: https://www.contributor-covenant.org 171 | 172 | For answers to common questions about this code of conduct, see the FAQ at 173 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 174 | -------------------------------------------------------------------------------- /packages/compare/src/output/markdown.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as md from 'ts-markdown-builder'; 4 | import * as logger from '@callstack/reassure-logger'; 5 | import { 6 | formatCount, 7 | formatDuration, 8 | formatMetadata, 9 | formatPercent, 10 | formatCountChange, 11 | formatDurationChange, 12 | } from '../utils/format'; 13 | import { joinLines } from '../utils/markdown'; 14 | import type { AddedEntry, CompareEntry, CompareResult, RemovedEntry, MeasureEntry, RenderIssues } from '../types'; 15 | 16 | const tableHeader = ['Name', 'Type', 'Duration', 'Count']; 17 | 18 | export async function writeToMarkdown(filePath: string, data: CompareResult) { 19 | try { 20 | const markdown = buildMarkdown(data); 21 | await writeToFile(filePath, markdown); 22 | } catch (error: any) { 23 | logger.error(error); 24 | throw error; 25 | } 26 | } 27 | 28 | async function writeToFile(filePath: string, content: string) { 29 | try { 30 | await fs.writeFile(filePath, content); 31 | 32 | logger.log(`✅ Written output markdown output file ${filePath}`); 33 | logger.log(`🔗 ${path.resolve(filePath)}\n`); 34 | } catch (error) { 35 | logger.error(`❌ Could not write markdown output file ${filePath}`); 36 | logger.error(`🔗 ${path.resolve(filePath)}`); 37 | logger.error('Error details:', error); 38 | throw error; 39 | } 40 | } 41 | 42 | function buildMarkdown(data: CompareResult) { 43 | let doc = [ 44 | md.heading('Performance Comparison Report', { level: 1 }), 45 | md.list([ 46 | `${md.bold('Current')}: ${formatMetadata(data.metadata.current)}`, 47 | `${md.bold('Baseline')}: ${formatMetadata(data.metadata.baseline)}`, 48 | ]), 49 | ]; 50 | 51 | if (data.errors?.length) { 52 | doc = [ 53 | ...doc, // 54 | md.heading('Errors', { level: 2 }), 55 | md.list(data.errors.map((text) => `🛑 ${text}`)), 56 | ]; 57 | } 58 | 59 | if (data.warnings?.length) { 60 | doc = [ 61 | ...doc, // 62 | md.heading('Warnings', { level: 2 }), 63 | md.list(data.warnings.map((text) => `🟡 ${text}`)), 64 | ]; 65 | } 66 | 67 | doc = [ 68 | ...doc, 69 | md.heading('Significant Changes To Duration', { level: 3 }), 70 | buildSummaryTable(data.significant), 71 | buildDetailsTable(data.significant), 72 | md.heading('Meaningless Changes To Duration', { level: 3 }), 73 | buildSummaryTable(data.meaningless, { open: false }), 74 | buildDetailsTable(data.meaningless), 75 | ]; 76 | 77 | // Skip renders counts if user only has function measurements 78 | const allEntries = [...data.significant, ...data.meaningless, ...data.added, ...data.removed]; 79 | const hasRenderEntries = allEntries.some((e) => e.type === 'render'); 80 | if (hasRenderEntries) { 81 | doc = [ 82 | ...doc, 83 | md.heading('Render Count Changes', { level: 3 }), 84 | buildSummaryTable(data.countChanged), 85 | buildDetailsTable(data.countChanged), 86 | md.heading('Render Issues', { level: 3 }), 87 | buildRenderIssuesTable(data.renderIssues), 88 | ]; 89 | } 90 | 91 | doc = [ 92 | ...doc, 93 | md.heading('Added Entries', { level: 3 }), 94 | buildSummaryTable(data.added), 95 | buildDetailsTable(data.added), 96 | md.heading('Removed Entries', { level: 3 }), 97 | buildSummaryTable(data.removed), 98 | buildDetailsTable(data.removed), 99 | ]; 100 | 101 | return md.joinBlocks(doc); 102 | } 103 | 104 | function buildSummaryTable(entries: Array, options?: { open?: boolean }) { 105 | if (!entries.length) return md.italic('There are no entries'); 106 | 107 | const open = options?.open ?? true; 108 | 109 | const rows = entries.map((entry) => [ 110 | md.escape(entry.name), 111 | md.escape(entry.type), 112 | formatEntryDuration(entry), 113 | formatEntryCount(entry), 114 | ]); 115 | const tableContent = md.table(tableHeader, rows); 116 | return md.disclosure('Show entries', tableContent, { open }); 117 | } 118 | 119 | function buildDetailsTable(entries: Array) { 120 | if (!entries.length) return ''; 121 | 122 | const rows = entries.map((entry) => [ 123 | md.escape(entry.name), 124 | md.escape(entry.type), 125 | buildDurationDetailsEntry(entry), 126 | buildCountDetailsEntry(entry), 127 | ]); 128 | 129 | return md.disclosure('Show details', md.table(tableHeader, rows)); 130 | } 131 | 132 | function formatEntryDuration(entry: CompareEntry | AddedEntry | RemovedEntry) { 133 | if (entry.baseline != null && entry.current != null) return formatDurationChange(entry); 134 | if (entry.baseline != null) return formatDuration(entry.baseline.meanDuration); 135 | if (entry.current != null) return formatDuration(entry.current.meanDuration); 136 | return ''; 137 | } 138 | 139 | function formatEntryCount(entry: CompareEntry | AddedEntry | RemovedEntry) { 140 | if (entry.baseline != null && entry.current != null) 141 | return formatCountChange(entry.current.meanCount, entry.baseline.meanCount); 142 | if (entry.baseline != null) return formatCount(entry.baseline.meanCount); 143 | if (entry.current != null) return formatCount(entry.current.meanCount); 144 | return ''; 145 | } 146 | 147 | function buildDurationDetailsEntry(entry: CompareEntry | AddedEntry | RemovedEntry) { 148 | return md.joinBlocks([ 149 | entry.baseline != null ? buildDurationDetails('Baseline', entry.baseline) : '', 150 | entry.current != null ? buildDurationDetails('Current', entry.current) : '', 151 | ]); 152 | } 153 | 154 | function buildCountDetailsEntry(entry: CompareEntry | AddedEntry | RemovedEntry) { 155 | return md.joinBlocks([ 156 | entry.baseline != null ? buildCountDetails('Baseline', entry.baseline) : '', 157 | entry.current != null ? buildCountDetails('Current', entry.current) : '', 158 | ]); 159 | } 160 | 161 | function buildDurationDetails(title: string, entry: MeasureEntry) { 162 | const relativeStdev = entry.stdevDuration / entry.meanDuration; 163 | 164 | return joinLines([ 165 | md.bold(title), 166 | `Mean: ${formatDuration(entry.meanDuration)}`, 167 | `Stdev: ${formatDuration(entry.stdevDuration)} (${formatPercent(relativeStdev)})`, 168 | entry.durations ? `Runs: ${formatRunDurations(entry.durations)}` : '', 169 | entry.warmupDurations ? `Warmup runs: ${formatRunDurations(entry.warmupDurations)}` : '', 170 | entry.outlierDurations ? `Removed outliers: ${formatRunDurations(entry.outlierDurations)}` : '', 171 | ]); 172 | } 173 | 174 | function buildCountDetails(title: string, entry: MeasureEntry) { 175 | const relativeStdev = entry.stdevCount / entry.meanCount; 176 | 177 | return joinLines([ 178 | md.bold(title), 179 | `Mean: ${formatCount(entry.meanCount)}`, 180 | `Stdev: ${formatCount(entry.stdevCount)} (${formatPercent(relativeStdev)})`, 181 | entry.counts ? `Runs: ${entry.counts.map(formatCount).join(' ')}` : '', 182 | buildRenderIssuesList(entry.issues), 183 | ]); 184 | } 185 | 186 | function formatRunDurations(values: number[]) { 187 | if (values.length === 0) return '(none)'; 188 | 189 | return values.map((v) => `${v.toFixed(1)}`).join(' '); 190 | } 191 | 192 | function buildRenderIssuesTable(entries: Array) { 193 | if (!entries.length) return md.italic('There are no entries'); 194 | 195 | const tableHeader = ['Name', 'Initial Updates', 'Redundant Updates']; 196 | const rows = entries.map((entry) => [ 197 | md.escape(entry.name), 198 | formatInitialUpdates(entry.current.issues?.initialUpdateCount), 199 | formatRedundantUpdates(entry.current.issues?.redundantUpdates), 200 | ]); 201 | 202 | return md.table(tableHeader, rows); 203 | } 204 | 205 | function buildRenderIssuesList(issues: RenderIssues | undefined) { 206 | if (issues == null) return ''; 207 | 208 | const output = ['Render issues:']; 209 | if (issues?.initialUpdateCount) { 210 | output.push(`- Initial updates: ${formatInitialUpdates(issues.initialUpdateCount, false)}`); 211 | } 212 | if (issues?.redundantUpdates?.length) { 213 | output.push(`- Redundant updates: ${formatRedundantUpdates(issues.redundantUpdates, false)}`); 214 | } 215 | 216 | return output.join('\n'); 217 | } 218 | 219 | function formatInitialUpdates(count: number | undefined, showSymbol: boolean = true) { 220 | if (count == null) return '?'; 221 | if (count === 0) return '-'; 222 | 223 | return `${count}${showSymbol ? ' 🔴' : ''}`; 224 | } 225 | 226 | function formatRedundantUpdates(redundantUpdates: number[] | undefined, showSymbol: boolean = true) { 227 | if (redundantUpdates == null) return '?'; 228 | if (redundantUpdates.length === 0) return '-'; 229 | 230 | return `${redundantUpdates.length} (${redundantUpdates.join(', ')})${showSymbol ? ' 🔴' : ''}`; 231 | } 232 | --------------------------------------------------------------------------------