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; +};