mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-31 12:47:58 +00:00 
			
		
		
		
	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 : <img width="515" alt="image" src="https://github.com/twentyhq/twenty/assets/26528466/273d2d91-26da-437a-890e-778cb6c1f993"> 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); }, ```
This commit is contained in:
		
							
								
								
									
										15
									
								
								.github/workflows/ci-front.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/ci-front.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -63,6 +63,21 @@ jobs: | |||||||
|         run: npx nx reset:env twenty-front |         run: npx nx reset:env twenty-front | ||||||
|       - name: Run storybook tests |       - name: Run storybook tests | ||||||
|         run: npx nx storybook:static:test twenty-front --configuration=${{ matrix.storybook_scope }} |         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: |   front-chromatic-deployment: | ||||||
|     if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' |     if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' | ||||||
|     needs: front-sb-build |     needs: front-sb-build | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								nx.json
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								nx.json
									
									
									
									
									
								
							| @@ -177,6 +177,17 @@ | |||||||
|         "port": 6006 |         "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": { |     "storybook:static:test": { | ||||||
|       "executor": "nx:run-commands", |       "executor": "nx:run-commands", | ||||||
|       "options": { |       "options": { | ||||||
| @@ -186,6 +197,15 @@ | |||||||
|         "port": 6006 |         "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": { |     "chromatic": { | ||||||
|       "executor": "nx:run-commands", |       "executor": "nx:run-commands", | ||||||
|       "options": { |       "options": { | ||||||
|   | |||||||
| @@ -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') { |   if (process.env.STORYBOOK_SCOPE === 'ui-docs') { | ||||||
|     return ['../src/modules/ui/**/*.docs.mdx']; |     return ['../src/modules/ui/**/*.docs.mdx']; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
| import { getJestConfig } from '@storybook/test-runner'; | import { getJestConfig } from '@storybook/test-runner'; | ||||||
|  |  | ||||||
|  | const MINUTES_IN_MS = 60 * 1000; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * @type {import('@jest/types').Config.InitialOptions} |  * @type {import('@jest/types').Config.InitialOptions} | ||||||
|  */ |  */ | ||||||
| @@ -9,5 +11,5 @@ export default { | |||||||
|   /** Add your own overrides below |   /** Add your own overrides below | ||||||
|    * @see https://jestjs.io/docs/configuration |    * @see https://jestjs.io/docs/configuration | ||||||
|    */ |    */ | ||||||
|   testTimeout: process.env.STORYBOOK_SCOPE === 'pages' ? 60000 : 15000, |   testTimeout: 2 * MINUTES_IN_MS, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -81,6 +81,12 @@ | |||||||
|             "NODE_OPTIONS": "--max_old_space_size=5000", |             "NODE_OPTIONS": "--max_old_space_size=5000", | ||||||
|             "STORYBOOK_SCOPE": "pages" |             "STORYBOOK_SCOPE": "pages" | ||||||
|           } |           } | ||||||
|  |         }, | ||||||
|  |         "performance": {  | ||||||
|  |           "env": { | ||||||
|  |             "NODE_OPTIONS": "--max_old_space_size=5000", | ||||||
|  |             "STORYBOOK_SCOPE": "performance" | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
| @@ -89,7 +95,8 @@ | |||||||
|       "configurations": { |       "configurations": { | ||||||
|         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, |         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, | ||||||
|         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, |         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, | ||||||
|         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } |         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, | ||||||
|  |         "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "storybook:static": { |     "storybook:static": { | ||||||
| @@ -97,7 +104,8 @@ | |||||||
|       "configurations": { |       "configurations": { | ||||||
|         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, |         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, | ||||||
|         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, |         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, | ||||||
|         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } |         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, | ||||||
|  |         "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "storybook:coverage": { |     "storybook:coverage": { | ||||||
| @@ -105,7 +113,8 @@ | |||||||
|         "text": {}, |         "text": {}, | ||||||
|         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, |         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, | ||||||
|         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, |         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, | ||||||
|         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } } |         "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, | ||||||
|  |         "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "storybook:test": { |     "storybook:test": { | ||||||
| @@ -113,22 +122,35 @@ | |||||||
|       "configurations": { |       "configurations": { | ||||||
|         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, |         "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, | ||||||
|         "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, |         "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": { |     "storybook:static:test": { | ||||||
|       "options": { |       "options": { | ||||||
|         "commands": [ |         "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 |         "port": 6006 | ||||||
|       }, |       }, | ||||||
|       "configurations": { |       "configurations": { | ||||||
|         "docs": { "scope": "ui-docs" }, |         "docs": { "scope": "ui-docs" }, | ||||||
|         "modules": { "scope": "modules" }, |         "modules": { "scope": "modules" }, | ||||||
|         "pages": { "scope": "pages" } |         "pages": { "scope": "pages" }, | ||||||
|  |         "performance": { "scope": "performance" } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "storybook:performance:test": {}, | ||||||
|     "graphql:generate": { |     "graphql:generate": { | ||||||
|       "executor": "nx:run-commands", |       "executor": "nx:run-commands", | ||||||
|       "defaultConfiguration": "data", |       "defaultConfiguration": "data", | ||||||
|   | |||||||
| @@ -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, | ||||||
|  | }); | ||||||
| @@ -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<typeof EllipsisDisplay>; | ||||||
|  |  | ||||||
|  | export const Default: Story = {}; | ||||||
| @@ -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 ( | ||||||
|  |     <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> | ||||||
|  |       <ProfilingQueueEffect | ||||||
|  |         numberOfRuns={numberOfRuns} | ||||||
|  |         numberOfTestsPerRun={numberOfTests} | ||||||
|  |         profilingId={id} | ||||||
|  |       /> | ||||||
|  |       <div> | ||||||
|  |         Profiling {numberOfTests} times the component {parameters.componentName}{' '} | ||||||
|  |         : | ||||||
|  |       </div> | ||||||
|  |       {skip ? ( | ||||||
|  |         <></> | ||||||
|  |       ) : ( | ||||||
|  |         <> | ||||||
|  |           <ProfilingReporter /> | ||||||
|  |           <div style={{ visibility: 'hidden', width: 0, height: 0 }}> | ||||||
|  |             {testArray.map((_, index) => ( | ||||||
|  |               <ProfilerWrapper | ||||||
|  |                 key={id + index} | ||||||
|  |                 componentName={parameters.componentName} | ||||||
|  |                 runName={currentRunName} | ||||||
|  |                 testIndex={index} | ||||||
|  |                 profilingId={id} | ||||||
|  |               > | ||||||
|  |                 <Story /> | ||||||
|  |               </ProfilerWrapper> | ||||||
|  |             ))} | ||||||
|  |           </div> | ||||||
|  |           {profilingSessionStatus === 'finished' && ( | ||||||
|  |             <div data-testid="profiling-session-finished" /> | ||||||
|  |           )} | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -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 ( | ||||||
|  |     <Profiler id={profilingId} onRender={handleRender}> | ||||||
|  |       {children} | ||||||
|  |     </Profiler> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -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 <></>; | ||||||
|  | }; | ||||||
| @@ -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 ( | ||||||
|  |     <div | ||||||
|  |       data-profiling-report={JSON.stringify(profilingReport)} | ||||||
|  |       id={PROFILING_REPORTER_DIV_ID} | ||||||
|  |     > | ||||||
|  |       <StyledTable> | ||||||
|  |         <thead> | ||||||
|  |           <tr> | ||||||
|  |             <th>Run name</th> | ||||||
|  |             <th>Min</th> | ||||||
|  |             <th>Average</th> | ||||||
|  |             <th>P50</th> | ||||||
|  |             <th>P80</th> | ||||||
|  |             <th>P90</th> | ||||||
|  |             <th>P95</th> | ||||||
|  |             <th>P99</th> | ||||||
|  |             <th>Max</th> | ||||||
|  |           </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |           <tr style={{ fontWeight: 'bold' }}> | ||||||
|  |             <td>Total</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.min * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.average * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.p50 * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.p80 * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.p90 * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.p95 * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.p99 * 1000) / 1000}ms</td> | ||||||
|  |             <td>{Math.round(profilingReport.total.max * 1000) / 1000}ms</td> | ||||||
|  |           </tr> | ||||||
|  |           {Object.entries(profilingReport.runs).map(([runName, report]) => ( | ||||||
|  |             <tr key={runName}> | ||||||
|  |               <td>{runName}</td> | ||||||
|  |               <td>{Math.round(report.min * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.average * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.p50 * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.p80 * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.p90 * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.p95 * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.p99 * 1000) / 1000}ms</td> | ||||||
|  |               <td>{Math.round(report.max * 1000) / 1000}ms</td> | ||||||
|  |             </tr> | ||||||
|  |           ))} | ||||||
|  |         </tbody> | ||||||
|  |       </StyledTable> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | export const PROFILING_REPORTER_DIV_ID = 'profiling-report'; | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | export const TIME_BETWEEN_TEST_RUNS_IN_MS = 500; | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | export const currentProfilingRunIndexState = atom<number>({ | ||||||
|  |   key: 'currentProfilingRunIndexState', | ||||||
|  |   default: 0, | ||||||
|  | }); | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | export type ProfilingQueue = { | ||||||
|  |   [runName: string]: string[]; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const profilingQueueState = atom<ProfilingQueue>({ | ||||||
|  |   key: 'profilingQueueState', | ||||||
|  |   default: {}, | ||||||
|  | }); | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; | ||||||
|  |  | ||||||
|  | export const profilingSessionDataPointsState = atom<ProfilingDataPoint[]>({ | ||||||
|  |   key: 'profilingSessionDataPointsState', | ||||||
|  |   default: [], | ||||||
|  | }); | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | export const profilingSessionRunsState = atom<string[]>({ | ||||||
|  |   key: 'profilingSessionRunsState', | ||||||
|  |   default: [], | ||||||
|  | }); | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; | ||||||
|  |  | ||||||
|  | export const profilingSessionState = atom<Record<string, ProfilingDataPoint[]>>( | ||||||
|  |   { | ||||||
|  |     key: 'profilingSessionState', | ||||||
|  |     default: {}, | ||||||
|  |   }, | ||||||
|  | ); | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { atom } from 'recoil'; | ||||||
|  |  | ||||||
|  | export type ProfilingSessionStatus = 'running' | 'finished' | 'not_started'; | ||||||
|  |  | ||||||
|  | export const profilingSessionStatusState = atom<ProfilingSessionStatus>({ | ||||||
|  |   key: 'profilingSessionStatusState', | ||||||
|  |   default: 'not_started', | ||||||
|  | }); | ||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | export type ProfilingDataPoint = { | ||||||
|  |   id: string; | ||||||
|  |   runName: string; | ||||||
|  |   componentName: string; | ||||||
|  |   phase: string; | ||||||
|  |   durationInMs: number; | ||||||
|  | }; | ||||||
| @@ -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; | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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<ProfilingReportItem, 'sumById'>; | ||||||
|  |   runs: { | ||||||
|  |     [runName: string]: { | ||||||
|  |       runName: string; | ||||||
|  |     } & ProfilingReportItem; | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,64 @@ | |||||||
|  | import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; | ||||||
|  | import { ProfilingReportByComponent } from '~/testing/profiling/types/ProfilingReportByComponent'; | ||||||
|  |  | ||||||
|  | export const computeProfilingReportByComponent = ( | ||||||
|  |   profilingReport: Record<string, ProfilingDataPoint[]>, | ||||||
|  | ) => { | ||||||
|  |   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; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | export const getProfilingQueueIdentifier = ( | ||||||
|  |   profilingId: string, | ||||||
|  |   testIndex: number, | ||||||
|  |   runName: string, | ||||||
|  | ) => `${profilingId}-run[${runName}]-test[${testIndex}]`; | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -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<any> => ({ | ||||||
|  |   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); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }); | ||||||
| @@ -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; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun'; | ||||||
|  |  | ||||||
|  | export const parseProfilingReportString = ( | ||||||
|  |   profilingReportStringifiedJson: string, | ||||||
|  | ) => { | ||||||
|  |   return JSON.parse(profilingReportStringifiedJson) as ProfilingReport; | ||||||
|  | }; | ||||||
		Reference in New Issue
	
	Block a user
	 Lucas Bordeau
					Lucas Bordeau