From cfacdfce608414f857580b4610513ad7e5ad92cb Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 15 May 2024 13:50:02 +0200 Subject: [PATCH] Generic Profiling story to wrap any component (#5341) This PR introduces a Profiling feature for our story book tests. It also implements a new CI job : front-sb-test-performance, that only runs stories suffixed with `.perf.stories.tsx` ## How it works It allows to wrap any component into an array of React Profiler components that will run tests many times to have the most replicable average render time possible. It is simply used by calling the new `getProfilingStory` util. Internally it creates a defined number of tests, separated by an arbitrary waiting time to allow the CPU to give more stable results. It will do 3 warm-up and 3 finishing runs of tests because the first and last renders are always a bit erratic, so we want to measure only the runs in-between. On the UI side it gives a table of results : image On the programmatic side, it stores the result in a div that can then be parsed by the play fonction of storybook, to expect a defined threshold. ```tsx play: async ({ canvasElement }) => { await findByTestId( canvasElement, 'profiling-session-finished', {}, { timeout: 60000 }, ); const profilingReport = getProfilingReportFromDocument(canvasElement); if (!isDefined(profilingReport)) { return; } const p95result = profilingReport?.total.p95; expect( p95result, `Component render time is more than p95 threshold (${p95ThresholdInMs}ms)`, ).toBeLessThan(p95ThresholdInMs); }, ``` --- .github/workflows/ci-front.yaml | 15 +++ nx.json | 20 +++ packages/twenty-front/.storybook/main.ts | 4 + .../.storybook/test-runner-jest.config.js | 4 +- packages/twenty-front/project.json | 34 +++++- .../EllipsisDisplay.perf.stories.tsx | 24 ++++ .../__stories__/EllipsisDisplay.stories.tsx | 20 +++ .../testing/decorators/ProfilerDecorator.tsx | 65 ++++++++++ .../profiling/components/ProfilerWrapper.tsx | 81 ++++++++++++ .../components/ProfilingQueueEffect.tsx | 115 ++++++++++++++++++ .../components/ProfilingReporter.tsx | 80 ++++++++++++ .../constants/ProfilingReporterDivId.ts | 1 + .../constants/TimeBetweenTestRunsInMs.ts | 1 + .../states/currentProfilingRunState.ts | 6 + .../profiling/states/profilingQueueState.ts | 10 ++ .../states/profilingSessionDataPointsState.ts | 8 ++ .../states/profilingSessionRunsState.ts | 6 + .../profiling/states/profilingSessionState.ts | 10 ++ .../states/profilingSessionStatusState.ts | 8 ++ .../profiling/types/ProfilingDataPoint.ts | 7 ++ .../types/ProfilingReportByComponent.ts | 15 +++ .../profiling/types/ProfilingReportByRun.ts | 22 ++++ .../profiling/utils/computeProfilingReport.ts | 88 ++++++++++++++ .../computeProfilingReportByComponent.ts | 64 ++++++++++ .../utils/getProfilingQueueIdentifier.ts | 5 + .../utils/getProfilingReportFromDocument.ts | 32 +++++ .../profiling/utils/getProfilingStory.ts | 57 +++++++++ .../testing/profiling/utils/getTestArray.ts | 13 ++ .../utils/parseProfilingReportString.ts | 7 ++ 29 files changed, 815 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.perf.stories.tsx create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.stories.tsx create mode 100644 packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx create mode 100644 packages/twenty-front/src/testing/profiling/components/ProfilerWrapper.tsx create mode 100644 packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx create mode 100644 packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx create mode 100644 packages/twenty-front/src/testing/profiling/constants/ProfilingReporterDivId.ts create mode 100644 packages/twenty-front/src/testing/profiling/constants/TimeBetweenTestRunsInMs.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/currentProfilingRunState.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/profilingQueueState.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/profilingSessionDataPointsState.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/profilingSessionRunsState.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/profilingSessionState.ts create mode 100644 packages/twenty-front/src/testing/profiling/states/profilingSessionStatusState.ts create mode 100644 packages/twenty-front/src/testing/profiling/types/ProfilingDataPoint.ts create mode 100644 packages/twenty-front/src/testing/profiling/types/ProfilingReportByComponent.ts create mode 100644 packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/computeProfilingReportByComponent.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/getProfilingQueueIdentifier.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/getProfilingReportFromDocument.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/getTestArray.ts create mode 100644 packages/twenty-front/src/testing/profiling/utils/parseProfilingReportString.ts diff --git a/.github/workflows/ci-front.yaml b/.github/workflows/ci-front.yaml index 6f983332dc..f6c33210bc 100644 --- a/.github/workflows/ci-front.yaml +++ b/.github/workflows/ci-front.yaml @@ -63,6 +63,21 @@ jobs: run: npx nx reset:env twenty-front - name: Run storybook tests run: npx nx storybook:static:test twenty-front --configuration=${{ matrix.storybook_scope }} + front-sb-test-performance: + runs-on: ci-8-cores + env: + REACT_APP_SERVER_BASE_URL: http://localhost:3000 + steps: + - name: Fetch local actions + uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/workflows/actions/yarn-install + - name: Install Playwright + run: cd packages/twenty-front && npx playwright install + - name: Front / Write .env + run: npx nx reset:env twenty-front + - name: Run storybook tests + run: npx nx storybook:performance:test twenty-front front-chromatic-deployment: if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' needs: front-sb-build diff --git a/nx.json b/nx.json index 7d78eb8abd..51a1c0f0d2 100644 --- a/nx.json +++ b/nx.json @@ -177,6 +177,17 @@ "port": 6006 } }, + "storybook:test:nocoverage": { + "executor": "nx:run-commands", + "inputs": ["^default", "excludeTests"], + "options": { + "cwd": "{projectRoot}", + "commands": [ + "test-storybook --url http://localhost:{args.port} --maxWorkers=3" + ], + "port": 6006 + } + }, "storybook:static:test": { "executor": "nx:run-commands", "options": { @@ -186,6 +197,15 @@ "port": 6006 } }, + "storybook:performance:test": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:dev {projectName} --configuration=performance --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test:nocoverage {projectName} --port={args.port} --configuration=performance'" + ], + "port": 6006 + } + }, "chromatic": { "executor": "nx:run-commands", "options": { diff --git a/packages/twenty-front/.storybook/main.ts b/packages/twenty-front/.storybook/main.ts index e3a39f5fce..6abcc3657c 100644 --- a/packages/twenty-front/.storybook/main.ts +++ b/packages/twenty-front/.storybook/main.ts @@ -17,6 +17,10 @@ const computeStoriesGlob = () => { ]; } + if (process.env.STORYBOOK_SCOPE === 'performance') { + return ['../src/modules/**/*.perf.stories.@(js|jsx|ts|tsx)']; + } + if (process.env.STORYBOOK_SCOPE === 'ui-docs') { return ['../src/modules/ui/**/*.docs.mdx']; } diff --git a/packages/twenty-front/.storybook/test-runner-jest.config.js b/packages/twenty-front/.storybook/test-runner-jest.config.js index 7964bd169f..29994547ee 100644 --- a/packages/twenty-front/.storybook/test-runner-jest.config.js +++ b/packages/twenty-front/.storybook/test-runner-jest.config.js @@ -1,5 +1,7 @@ import { getJestConfig } from '@storybook/test-runner'; +const MINUTES_IN_MS = 60 * 1000; + /** * @type {import('@jest/types').Config.InitialOptions} */ @@ -9,5 +11,5 @@ export default { /** Add your own overrides below * @see https://jestjs.io/docs/configuration */ - testTimeout: process.env.STORYBOOK_SCOPE === 'pages' ? 60000 : 15000, + testTimeout: 2 * MINUTES_IN_MS, }; diff --git a/packages/twenty-front/project.json b/packages/twenty-front/project.json index dfa5365b3c..c85c18c52d 100644 --- a/packages/twenty-front/project.json +++ b/packages/twenty-front/project.json @@ -81,6 +81,12 @@ "NODE_OPTIONS": "--max_old_space_size=5000", "STORYBOOK_SCOPE": "pages" } + }, + "performance": { + "env": { + "NODE_OPTIONS": "--max_old_space_size=5000", + "STORYBOOK_SCOPE": "performance" + } } } }, @@ -89,7 +95,8 @@ "configurations": { "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, - "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } } }, "storybook:static": { @@ -97,7 +104,8 @@ "configurations": { "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, - "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } } }, "storybook:coverage": { @@ -105,7 +113,8 @@ "text": {}, "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, - "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } } }, "storybook:test": { @@ -113,22 +122,35 @@ "configurations": { "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, - "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } + + } + }, + "storybook:test:nocoverage": { + "configurations": { + "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, + "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } + } }, "storybook:static:test": { "options": { "commands": [ - "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" + "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:static {projectName} --configuration={args.scope} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" ], "port": 6006 }, "configurations": { "docs": { "scope": "ui-docs" }, "modules": { "scope": "modules" }, - "pages": { "scope": "pages" } + "pages": { "scope": "pages" }, + "performance": { "scope": "performance" } } }, + "storybook:performance:test": {}, "graphql:generate": { "executor": "nx:run-commands", "defaultConfiguration": "data", diff --git a/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.perf.stories.tsx new file mode 100644 index 0000000000..63d9b9a211 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.perf.stories.tsx @@ -0,0 +1,24 @@ +import { Meta } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui'; + +import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; +import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; + +const meta: Meta = { + title: 'UI/Input/EllipsisDisplay/EllipsisDisplay', + component: EllipsisDisplay, + decorators: [ComponentDecorator], + args: { + maxWidth: 100, + children: 'This is a long text that should be truncated', + }, +}; + +export default meta; + +export const Performance = getProfilingStory({ + componentName: 'EllipsisDisplay', + averageThresholdInMs: 0.1, + numberOfRuns: 20, + numberOfTestsPerRun: 10, +}); diff --git a/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.stories.tsx b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.stories.tsx new file mode 100644 index 0000000000..d331c13145 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.stories.tsx @@ -0,0 +1,20 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui'; + +import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; + +const meta: Meta = { + title: 'UI/Input/EllipsisDisplay/EllipsisDisplay', + component: EllipsisDisplay, + decorators: [ComponentDecorator], + args: { + maxWidth: 100, + children: 'This is a long text that should be truncated', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx b/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx new file mode 100644 index 0000000000..6d14603873 --- /dev/null +++ b/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx @@ -0,0 +1,65 @@ +import { Decorator } from '@storybook/react'; +import { useRecoilState } from 'recoil'; + +import { ProfilerWrapper } from '~/testing/profiling/components/ProfilerWrapper'; +import { ProfilingQueueEffect } from '~/testing/profiling/components/ProfilingQueueEffect'; +import { ProfilingReporter } from '~/testing/profiling/components/ProfilingReporter'; +import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState'; +import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState'; +import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; +import { getTestArray } from '~/testing/profiling/utils/getTestArray'; + +export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => { + const numberOfTests = parameters.numberOfTests ?? 2; + const numberOfRuns = parameters.numberOfRuns ?? 2; + + const [currentProfilingRunIndex] = useRecoilState( + currentProfilingRunIndexState, + ); + + const [profilingSessionStatus] = useRecoilState(profilingSessionStatusState); + const [profilingSessionRuns] = useRecoilState(profilingSessionRunsState); + + const skip = profilingSessionRuns.length === 0; + + const currentRunName = profilingSessionRuns[currentProfilingRunIndex]; + + const testArray = getTestArray(id, numberOfTests, currentRunName); + + return ( +
+ +
+ Profiling {numberOfTests} times the component {parameters.componentName}{' '} + : +
+ {skip ? ( + <> + ) : ( + <> + +
+ {testArray.map((_, index) => ( + + + + ))} +
+ {profilingSessionStatus === 'finished' && ( +
+ )} + + )} +
+ ); +}; diff --git a/packages/twenty-front/src/testing/profiling/components/ProfilerWrapper.tsx b/packages/twenty-front/src/testing/profiling/components/ProfilerWrapper.tsx new file mode 100644 index 0000000000..7fba31d21e --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/components/ProfilerWrapper.tsx @@ -0,0 +1,81 @@ +import { Profiler, ProfilerOnRenderCallback } from 'react'; +import { useRecoilCallback } from 'recoil'; + +import { profilingQueueState } from '~/testing/profiling/states/profilingQueueState'; +import { profilingSessionDataPointsState } from '~/testing/profiling/states/profilingSessionDataPointsState'; +import { profilingSessionState } from '~/testing/profiling/states/profilingSessionState'; +import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; +import { getProfilingQueueIdentifier } from '~/testing/profiling/utils/getProfilingQueueIdentifier'; +import { isDefined } from '~/utils/isDefined'; + +export const ProfilerWrapper = ({ + profilingId, + testIndex, + componentName, + runName, + children, +}: { + profilingId: string; + testIndex: number; + componentName: string; + runName: string; + children: React.ReactNode; +}) => { + const handleRender: ProfilerOnRenderCallback = useRecoilCallback( + ({ set, snapshot }) => + (id, phase, actualDurationInMs) => { + const dataPointId = getProfilingQueueIdentifier( + profilingId, + testIndex, + runName, + ); + + const newDataPoint: ProfilingDataPoint = { + componentName, + runName, + id: dataPointId, + phase, + durationInMs: actualDurationInMs, + }; + + set( + profilingSessionDataPointsState, + (currentProfilingSessionDataPoints) => [ + ...currentProfilingSessionDataPoints, + newDataPoint, + ], + ); + + set(profilingSessionState, (currentProfilingSession) => ({ + ...currentProfilingSession, + [id]: [...(currentProfilingSession[id] ?? []), newDataPoint], + })); + + const queueIdentifier = dataPointId; + + const currentProfilingQueue = snapshot + .getLoadable(profilingQueueState) + .getValue(); + + const currentQueue = currentProfilingQueue[runName]; + + if (!isDefined(currentQueue)) { + return; + } + + const newQueue = currentQueue.filter((id) => id !== queueIdentifier); + + set(profilingQueueState, (currentProfilingQueue) => ({ + ...currentProfilingQueue, + [runName]: newQueue, + })); + }, + [profilingId, testIndex, componentName, runName], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx b/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx new file mode 100644 index 0000000000..a00f22aaf4 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx @@ -0,0 +1,115 @@ +import { useEffect } from 'react'; +import { useRecoilState } from 'recoil'; + +import { TIME_BETWEEN_TEST_RUNS_IN_MS } from '~/testing/profiling/constants/TimeBetweenTestRunsInMs'; +import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState'; +import { profilingQueueState } from '~/testing/profiling/states/profilingQueueState'; +import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState'; +import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; +import { getTestArray } from '~/testing/profiling/utils/getTestArray'; + +export const ProfilingQueueEffect = ({ + profilingId, + numberOfTestsPerRun, + numberOfRuns, +}: { + profilingId: string; + numberOfTestsPerRun: number; + numberOfRuns: number; +}) => { + const [currentProfilingRunIndex, setCurrentProfilingRunIndex] = + useRecoilState(currentProfilingRunIndexState); + + const [profilingSessionStatus, setProfilingSessionStatus] = useRecoilState( + profilingSessionStatusState, + ); + + const [profilingSessionRuns, setProfilingSessionRuns] = useRecoilState( + profilingSessionRunsState, + ); + + const [profilingQueue, setProfilingQueue] = + useRecoilState(profilingQueueState); + + useEffect(() => { + (async () => { + if (profilingSessionStatus === 'not_started') { + setProfilingSessionStatus('running'); + setCurrentProfilingRunIndex(0); + + const newTestRuns = [ + 'warm-up-1', + 'warm-up-2', + 'warm-up-3', + ...[ + ...Array.from({ length: numberOfRuns }, (_, i) => `real-run-${i}`), + ], + 'finishing-run-1', + 'finishing-run-2', + 'finishing-run-3', + ]; + + setProfilingSessionRuns(newTestRuns); + + const testArray = getTestArray( + profilingId, + numberOfTestsPerRun, + newTestRuns[0], + ); + + setProfilingQueue((currentProfilingQueue) => ({ + ...currentProfilingQueue, + [newTestRuns[0]]: testArray, + })); + } else if (profilingSessionStatus === 'running') { + const testsStillToRun = + profilingQueue[profilingSessionRuns[currentProfilingRunIndex]]; + + const allTestsAreRun = testsStillToRun.length > 0; + + const isFinalRun = + currentProfilingRunIndex === profilingSessionRuns.length - 1; + + if (allTestsAreRun) { + if (isFinalRun) { + setProfilingSessionStatus('finished'); + return; + } + + await new Promise((resolve) => + setTimeout(resolve, TIME_BETWEEN_TEST_RUNS_IN_MS), + ); + + const nextIndex = currentProfilingRunIndex + 1; + + setCurrentProfilingRunIndex(nextIndex); + + const testArray = getTestArray( + profilingId, + numberOfTestsPerRun, + profilingSessionRuns[nextIndex], + ); + + setProfilingQueue((currentProfilingQueue) => ({ + ...currentProfilingQueue, + [profilingSessionRuns[nextIndex]]: testArray, + })); + } + } + })(); + }, [ + profilingQueue, + numberOfTestsPerRun, + profilingId, + currentProfilingRunIndex, + setProfilingQueue, + setCurrentProfilingRunIndex, + profilingSessionStatus, + setProfilingSessionStatus, + profilingSessionRuns, + setProfilingSessionRuns, + numberOfRuns, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx b/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx new file mode 100644 index 0000000000..7c1ea5bdb4 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { PROFILING_REPORTER_DIV_ID } from '~/testing/profiling/constants/ProfilingReporterDivId'; +import { profilingSessionDataPointsState } from '~/testing/profiling/states/profilingSessionDataPointsState'; +import { computeProfilingReport } from '~/testing/profiling/utils/computeProfilingReport'; + +const StyledTable = styled.table` + border: 1px solid black; + + th, + td { + border: 1px solid black; + } + + td { + padding: 5px; + } +`; + +export const ProfilingReporter = () => { + const [profilingSessionDataPoints] = useRecoilState( + profilingSessionDataPointsState, + ); + + const profilingReport = useMemo( + () => computeProfilingReport(profilingSessionDataPoints), + [profilingSessionDataPoints], + ); + + return ( +
+ + + + Run name + Min + Average + P50 + P80 + P90 + P95 + P99 + Max + + + + + Total + {Math.round(profilingReport.total.min * 1000) / 1000}ms + {Math.round(profilingReport.total.average * 1000) / 1000}ms + {Math.round(profilingReport.total.p50 * 1000) / 1000}ms + {Math.round(profilingReport.total.p80 * 1000) / 1000}ms + {Math.round(profilingReport.total.p90 * 1000) / 1000}ms + {Math.round(profilingReport.total.p95 * 1000) / 1000}ms + {Math.round(profilingReport.total.p99 * 1000) / 1000}ms + {Math.round(profilingReport.total.max * 1000) / 1000}ms + + {Object.entries(profilingReport.runs).map(([runName, report]) => ( + + {runName} + {Math.round(report.min * 1000) / 1000}ms + {Math.round(report.average * 1000) / 1000}ms + {Math.round(report.p50 * 1000) / 1000}ms + {Math.round(report.p80 * 1000) / 1000}ms + {Math.round(report.p90 * 1000) / 1000}ms + {Math.round(report.p95 * 1000) / 1000}ms + {Math.round(report.p99 * 1000) / 1000}ms + {Math.round(report.max * 1000) / 1000}ms + + ))} + + +
+ ); +}; diff --git a/packages/twenty-front/src/testing/profiling/constants/ProfilingReporterDivId.ts b/packages/twenty-front/src/testing/profiling/constants/ProfilingReporterDivId.ts new file mode 100644 index 0000000000..078adac125 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/constants/ProfilingReporterDivId.ts @@ -0,0 +1 @@ +export const PROFILING_REPORTER_DIV_ID = 'profiling-report'; diff --git a/packages/twenty-front/src/testing/profiling/constants/TimeBetweenTestRunsInMs.ts b/packages/twenty-front/src/testing/profiling/constants/TimeBetweenTestRunsInMs.ts new file mode 100644 index 0000000000..d559557a64 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/constants/TimeBetweenTestRunsInMs.ts @@ -0,0 +1 @@ +export const TIME_BETWEEN_TEST_RUNS_IN_MS = 500; diff --git a/packages/twenty-front/src/testing/profiling/states/currentProfilingRunState.ts b/packages/twenty-front/src/testing/profiling/states/currentProfilingRunState.ts new file mode 100644 index 0000000000..da05d73035 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/currentProfilingRunState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const currentProfilingRunIndexState = atom({ + key: 'currentProfilingRunIndexState', + default: 0, +}); diff --git a/packages/twenty-front/src/testing/profiling/states/profilingQueueState.ts b/packages/twenty-front/src/testing/profiling/states/profilingQueueState.ts new file mode 100644 index 0000000000..f38449cbb9 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/profilingQueueState.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +export type ProfilingQueue = { + [runName: string]: string[]; +}; + +export const profilingQueueState = atom({ + key: 'profilingQueueState', + default: {}, +}); diff --git a/packages/twenty-front/src/testing/profiling/states/profilingSessionDataPointsState.ts b/packages/twenty-front/src/testing/profiling/states/profilingSessionDataPointsState.ts new file mode 100644 index 0000000000..732f655f94 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/profilingSessionDataPointsState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; + +export const profilingSessionDataPointsState = atom({ + key: 'profilingSessionDataPointsState', + default: [], +}); diff --git a/packages/twenty-front/src/testing/profiling/states/profilingSessionRunsState.ts b/packages/twenty-front/src/testing/profiling/states/profilingSessionRunsState.ts new file mode 100644 index 0000000000..ebfd3ab3fb --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/profilingSessionRunsState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const profilingSessionRunsState = atom({ + key: 'profilingSessionRunsState', + default: [], +}); diff --git a/packages/twenty-front/src/testing/profiling/states/profilingSessionState.ts b/packages/twenty-front/src/testing/profiling/states/profilingSessionState.ts new file mode 100644 index 0000000000..81543f412d --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/profilingSessionState.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; + +export const profilingSessionState = atom>( + { + key: 'profilingSessionState', + default: {}, + }, +); diff --git a/packages/twenty-front/src/testing/profiling/states/profilingSessionStatusState.ts b/packages/twenty-front/src/testing/profiling/states/profilingSessionStatusState.ts new file mode 100644 index 0000000000..25d8acaebf --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/profilingSessionStatusState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +export type ProfilingSessionStatus = 'running' | 'finished' | 'not_started'; + +export const profilingSessionStatusState = atom({ + key: 'profilingSessionStatusState', + default: 'not_started', +}); diff --git a/packages/twenty-front/src/testing/profiling/types/ProfilingDataPoint.ts b/packages/twenty-front/src/testing/profiling/types/ProfilingDataPoint.ts new file mode 100644 index 0000000000..81803c16fe --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/types/ProfilingDataPoint.ts @@ -0,0 +1,7 @@ +export type ProfilingDataPoint = { + id: string; + runName: string; + componentName: string; + phase: string; + durationInMs: number; +}; diff --git a/packages/twenty-front/src/testing/profiling/types/ProfilingReportByComponent.ts b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByComponent.ts new file mode 100644 index 0000000000..7dba6a2967 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByComponent.ts @@ -0,0 +1,15 @@ +export type ProfilingReportByComponent = { + [componentName: string]: { + sumById: { [id: string]: number }; + sum: number; + dataPointCount: number; + average: number; + p50: number; + p80: number; + p90: number; + p95: number; + p99: number; + min: number; + max: number; + }; +}; diff --git a/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts new file mode 100644 index 0000000000..f2714c702e --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts @@ -0,0 +1,22 @@ +export type ProfilingReportItem = { + sumById: { [id: string]: number }; + sum: number; + dataPointCount: number; + average: number; + p50: number; + p80: number; + p90: number; + p95: number; + p99: number; + min: number; + max: number; +}; + +export type ProfilingReport = { + total: Omit; + runs: { + [runName: string]: { + runName: string; + } & ProfilingReportItem; + }; +}; diff --git a/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts new file mode 100644 index 0000000000..d9afc33bee --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts @@ -0,0 +1,88 @@ +import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; +import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun'; + +export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { + const profilingReport = { total: {}, runs: {} } as ProfilingReport; + + for (const dataPoint of dataPoints) { + profilingReport.runs[dataPoint.runName] = { + ...profilingReport.runs[dataPoint.runName], + sumById: { + ...profilingReport.runs[dataPoint.runName]?.sumById, + [dataPoint.id]: + (profilingReport.runs[dataPoint.runName]?.sumById?.[dataPoint.id] ?? + 0) + dataPoint.durationInMs, + }, + sum: + (profilingReport.runs[dataPoint.runName]?.sum ?? 0) + + dataPoint.durationInMs, + }; + } + + for (const runName of Object.keys(profilingReport.runs)) { + const ids = Object.keys(profilingReport.runs[runName].sumById); + const valuesUnsorted = Object.values(profilingReport.runs[runName].sumById); + + const valuesSortedAsc = [...valuesUnsorted].sort((a, b) => a - b); + + const numberOfIds = ids.length; + + profilingReport.runs[runName].average = + profilingReport.runs[runName].sum / numberOfIds; + + profilingReport.runs[runName].min = Math.min( + ...Object.values(profilingReport.runs[runName].sumById), + ); + + profilingReport.runs[runName].max = Math.max( + ...Object.values(profilingReport.runs[runName].sumById), + ); + + const p50Index = Math.floor(numberOfIds * 0.5); + const p80Index = Math.floor(numberOfIds * 0.8); + const p90Index = Math.floor(numberOfIds * 0.9); + const p95Index = Math.floor(numberOfIds * 0.95); + const p99Index = Math.floor(numberOfIds * 0.99); + + profilingReport.runs[runName].p50 = valuesSortedAsc[p50Index]; + profilingReport.runs[runName].p80 = valuesSortedAsc[p80Index]; + profilingReport.runs[runName].p90 = valuesSortedAsc[p90Index]; + profilingReport.runs[runName].p95 = valuesSortedAsc[p95Index]; + profilingReport.runs[runName].p99 = valuesSortedAsc[p99Index]; + } + + const runNamesForTotal = Object.keys(profilingReport.runs).filter((runName) => + runName.startsWith('real-run'), + ); + + const runsForTotal = runNamesForTotal.map( + (runName) => profilingReport.runs[runName], + ); + + profilingReport.total = { + sum: Object.values(runsForTotal).reduce((acc, run) => acc + run.sum, 0), + average: + Object.values(runsForTotal).reduce((acc, run) => acc + run.average, 0) / + Object.keys(runsForTotal).length, + min: Math.min(...Object.values(runsForTotal).map((run) => run.min)), + max: Math.max(...Object.values(runsForTotal).map((run) => run.max)), + p50: + Object.values(runsForTotal).reduce((acc, run) => acc + run.p50, 0) / + Object.keys(runsForTotal).length, + p80: + Object.values(runsForTotal).reduce((acc, run) => acc + run.p80, 0) / + Object.keys(runsForTotal).length, + p90: + Object.values(runsForTotal).reduce((acc, run) => acc + run.p90, 0) / + Object.keys(runsForTotal).length, + p95: + Object.values(runsForTotal).reduce((acc, run) => acc + run.p95, 0) / + Object.keys(runsForTotal).length, + p99: + Object.values(runsForTotal).reduce((acc, run) => acc + run.p99, 0) / + Object.keys(runsForTotal).length, + dataPointCount: dataPoints.length, + }; + + return profilingReport; +}; diff --git a/packages/twenty-front/src/testing/profiling/utils/computeProfilingReportByComponent.ts b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReportByComponent.ts new file mode 100644 index 0000000000..8007ec79ac --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReportByComponent.ts @@ -0,0 +1,64 @@ +import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; +import { ProfilingReportByComponent } from '~/testing/profiling/types/ProfilingReportByComponent'; + +export const computeProfilingReportByComponent = ( + profilingReport: Record, +) => { + const reportByComponent = {} as ProfilingReportByComponent; + + const dataPoints = Object.entries(profilingReport) + .map(([, dataPoints]) => dataPoints) + .flat(1); + + for (const dataPoint of dataPoints) { + reportByComponent[dataPoint.componentName] = { + ...reportByComponent?.[dataPoint.componentName], + sumById: { + ...reportByComponent?.[dataPoint.componentName]?.sumById, + [dataPoint.id]: + (reportByComponent[dataPoint.componentName]?.sumById?.[ + dataPoint.id + ] ?? 0) + dataPoint.durationInMs, + }, + sum: + (reportByComponent[dataPoint.componentName]?.sum ?? 0) + + dataPoint.durationInMs, + }; + } + + for (const componentName of Object.keys(reportByComponent)) { + const ids = Object.keys(reportByComponent[componentName].sumById); + const valuesUnsorted = Object.values( + reportByComponent[componentName].sumById, + ); + + const valuesSortedAsc = [...valuesUnsorted].sort((a, b) => a - b); + + const numberOfIds = ids.length; + + reportByComponent[componentName].average = + reportByComponent[componentName].sum / numberOfIds; + + reportByComponent[componentName].min = Math.min( + ...Object.values(reportByComponent[componentName].sumById), + ); + + reportByComponent[componentName].max = Math.max( + ...Object.values(reportByComponent[componentName].sumById), + ); + + const p50Index = Math.floor(numberOfIds * 0.5); + const p80Index = Math.floor(numberOfIds * 0.8); + const p90Index = Math.floor(numberOfIds * 0.9); + const p95Index = Math.floor(numberOfIds * 0.95); + const p99Index = Math.floor(numberOfIds * 0.99); + + reportByComponent[componentName].p50 = valuesSortedAsc[p50Index]; + reportByComponent[componentName].p80 = valuesSortedAsc[p80Index]; + reportByComponent[componentName].p90 = valuesSortedAsc[p90Index]; + reportByComponent[componentName].p95 = valuesSortedAsc[p95Index]; + reportByComponent[componentName].p99 = valuesSortedAsc[p99Index]; + } + + return reportByComponent; +}; diff --git a/packages/twenty-front/src/testing/profiling/utils/getProfilingQueueIdentifier.ts b/packages/twenty-front/src/testing/profiling/utils/getProfilingQueueIdentifier.ts new file mode 100644 index 0000000000..788858c178 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/getProfilingQueueIdentifier.ts @@ -0,0 +1,5 @@ +export const getProfilingQueueIdentifier = ( + profilingId: string, + testIndex: number, + runName: string, +) => `${profilingId}-run[${runName}]-test[${testIndex}]`; diff --git a/packages/twenty-front/src/testing/profiling/utils/getProfilingReportFromDocument.ts b/packages/twenty-front/src/testing/profiling/utils/getProfilingReportFromDocument.ts new file mode 100644 index 0000000000..f2571af560 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/getProfilingReportFromDocument.ts @@ -0,0 +1,32 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { PROFILING_REPORTER_DIV_ID } from '~/testing/profiling/constants/ProfilingReporterDivId'; +import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun'; +import { parseProfilingReportString } from '~/testing/profiling/utils/parseProfilingReportString'; +import { isDefined } from '~/utils/isDefined'; + +export const getProfilingReportFromDocument = ( + documentElement: Element, +): ProfilingReport | null => { + const profilingReportElement = documentElement.querySelector( + `#${PROFILING_REPORTER_DIV_ID}`, + ); + + if (!isDefined(profilingReportElement)) { + return null; + } + + const profilingReportString = profilingReportElement.getAttribute( + 'data-profiling-report', + ); + + if (!isNonEmptyString(profilingReportString)) { + return null; + } + + const parsedProfilingReport = parseProfilingReportString( + profilingReportString, + ); + + return parsedProfilingReport; +}; diff --git a/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts b/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts new file mode 100644 index 0000000000..17d39e560a --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts @@ -0,0 +1,57 @@ +import { StoryObj } from '@storybook/react'; +import { expect, findByTestId } from '@storybook/test'; + +import { ProfilerDecorator } from '~/testing/decorators/ProfilerDecorator'; +import { getProfilingReportFromDocument } from '~/testing/profiling/utils/getProfilingReportFromDocument'; +import { isDefined } from '~/utils/isDefined'; + +export const getProfilingStory = ({ + componentName, + p95ThresholdInMs, + averageThresholdInMs, + numberOfRuns, + numberOfTestsPerRun, +}: { + componentName: string; + p95ThresholdInMs?: number; + averageThresholdInMs: number; + numberOfRuns: number; + numberOfTestsPerRun: number; +}): StoryObj => ({ + decorators: [ProfilerDecorator], + parameters: { + numberOfRuns, + numberOfTests: numberOfTestsPerRun, + componentName, + }, + play: async ({ canvasElement }) => { + await findByTestId( + canvasElement, + 'profiling-session-finished', + {}, + { timeout: 2 * 60000 }, + ); + + const profilingReport = getProfilingReportFromDocument(canvasElement); + + if (!isDefined(profilingReport)) { + return; + } + + const averageResult = profilingReport?.total.average; + + expect( + averageResult, + `Component render time is more than average threshold (${averageThresholdInMs}ms)`, + ).toBeLessThan(averageThresholdInMs); + + if (isDefined(p95ThresholdInMs)) { + const p95result = profilingReport?.total.p95; + + expect( + p95result, + `Component render time is more than p95 threshold (${p95ThresholdInMs}ms)`, + ).toBeLessThan(p95ThresholdInMs); + } + }, +}); diff --git a/packages/twenty-front/src/testing/profiling/utils/getTestArray.ts b/packages/twenty-front/src/testing/profiling/utils/getTestArray.ts new file mode 100644 index 0000000000..8c791421d0 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/getTestArray.ts @@ -0,0 +1,13 @@ +import { getProfilingQueueIdentifier } from '~/testing/profiling/utils/getProfilingQueueIdentifier'; + +export const getTestArray = ( + profilingId: string, + numberOfTestsPerRun: number, + runName: string, +) => { + const testArray = Array.from({ length: numberOfTestsPerRun }, (_, i) => + getProfilingQueueIdentifier(profilingId, i, runName), + ); + + return testArray; +}; diff --git a/packages/twenty-front/src/testing/profiling/utils/parseProfilingReportString.ts b/packages/twenty-front/src/testing/profiling/utils/parseProfilingReportString.ts new file mode 100644 index 0000000000..c9b2d600e6 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/utils/parseProfilingReportString.ts @@ -0,0 +1,7 @@ +import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun'; + +export const parseProfilingReportString = ( + profilingReportStringifiedJson: string, +) => { + return JSON.parse(profilingReportStringifiedJson) as ProfilingReport; +};