-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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); }, ```
- Loading branch information
1 parent
dc32f65
commit cfacdfc
Showing
29 changed files
with
815 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
24 changes: 24 additions & 0 deletions
24
...ront/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.perf.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}); |
20 changes: 20 additions & 0 deletions
20
...nty-front/src/modules/ui/field/display/components/__stories__/EllipsisDisplay.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = {}; |
65 changes: 65 additions & 0 deletions
65
packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
81 changes: 81 additions & 0 deletions
81
packages/twenty-front/src/testing/profiling/components/ProfilerWrapper.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
Oops, something went wrong.