Skip to content

Commit

Permalink
Generic Profiling story to wrap any component (#5341)
Browse files Browse the repository at this point in the history
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
lucasbordeau committed May 15, 2024
1 parent dc32f65 commit cfacdfc
Show file tree
Hide file tree
Showing 29 changed files with 815 additions and 7 deletions.
15 changes: 15 additions & 0 deletions .github/workflows/ci-front.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions packages/twenty-front/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
Expand Down
4 changes: 3 additions & 1 deletion packages/twenty-front/.storybook/test-runner-jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getJestConfig } from '@storybook/test-runner';

const MINUTES_IN_MS = 60 * 1000;

/**
* @type {import('@jest/types').Config.InitialOptions}
*/
Expand All @@ -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,
};
34 changes: 28 additions & 6 deletions packages/twenty-front/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
},
Expand All @@ -89,46 +95,62 @@
"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": {
"options": { "port": 6006 },
"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": {
"configurations": {
"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": {
"options": { "port": 6006 },
"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",
Expand Down
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,
});
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 packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx
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>
);
};
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>
);
};

0 comments on commit cfacdfc

Please sign in to comment.