mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-22 11:43:34 +03: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:
parent
dc32f65906
commit
cfacdfce60
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
|
||||
- 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
|
||||
|
20
nx.json
20
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": {
|
||||
|
@ -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'];
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user