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:
Lucas Bordeau 2024-05-15 13:50:02 +02:00 committed by GitHub
parent dc32f65906
commit cfacdfce60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 815 additions and 7 deletions

View File

@ -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
View File

@ -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": {

View File

@ -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'];
} }

View File

@ -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,
}; };

View File

@ -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",

View File

@ -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,
});

View File

@ -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 = {};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 <></>;
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export const PROFILING_REPORTER_DIV_ID = 'profiling-report';

View File

@ -0,0 +1 @@
export const TIME_BETWEEN_TEST_RUNS_IN_MS = 500;

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const currentProfilingRunIndexState = atom<number>({
key: 'currentProfilingRunIndexState',
default: 0,
});

View File

@ -0,0 +1,10 @@
import { atom } from 'recoil';
export type ProfilingQueue = {
[runName: string]: string[];
};
export const profilingQueueState = atom<ProfilingQueue>({
key: 'profilingQueueState',
default: {},
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint';
export const profilingSessionDataPointsState = atom<ProfilingDataPoint[]>({
key: 'profilingSessionDataPointsState',
default: [],
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const profilingSessionRunsState = atom<string[]>({
key: 'profilingSessionRunsState',
default: [],
});

View File

@ -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: {},
},
);

View File

@ -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',
});

View File

@ -0,0 +1,7 @@
export type ProfilingDataPoint = {
id: string;
runName: string;
componentName: string;
phase: string;
durationInMs: number;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export const getProfilingQueueIdentifier = (
profilingId: string,
testIndex: number,
runName: string,
) => `${profilingId}-run[${runName}]-test[${testIndex}]`;

View File

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

View File

@ -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);
}
},
});

View File

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

View File

@ -0,0 +1,7 @@
import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun';
export const parseProfilingReportString = (
profilingReportStringifiedJson: string,
) => {
return JSON.parse(profilingReportStringifiedJson) as ProfilingReport;
};