Skip to content

Commit 294dac3

Browse files
hectahertzjonrohan
andauthored
Add the framework required to run component stress tests (#5929)
Co-authored-by: Jon Rohan <yes@jonrohan.codes>
1 parent 3c18f89 commit 294dac3

13 files changed

+556
-0
lines changed

.changeset/eleven-crabs-achieved.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add the framework required to run component stress tests

.github/workflows/stress-tests.yml

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Stress Tests
2+
3+
on: [push]
4+
5+
jobs:
6+
stress-tests:
7+
runs-on: ubuntu-latest-4-cores
8+
steps:
9+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
10+
- name: Set up Node.js
11+
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e
12+
with:
13+
node-version: 22
14+
cache: 'npm'
15+
- name: Install dependencies
16+
run: npm ci
17+
- name: Build storybook
18+
run: npx storybook build
19+
working-directory: packages/react
20+
- name: Run storybook
21+
id: storybook
22+
run: |
23+
npx serve -l 6006 packages/react/storybook-static &
24+
pid=$!
25+
echo "pid=$pid" >> $GITHUB_OUTPUT
26+
sleep 5
27+
- name: Run Stress Tests
28+
uses: docker://mcr.microsoft.com/playwright:v1.51.0-jammy
29+
env:
30+
STORYBOOK_URL: 'http://172.17.0.1:6006'
31+
with:
32+
args: npx playwright test --grep @stress-test"
33+
- name: Stop storybook
34+
if: ${{ always() }}
35+
run: kill ${{ steps.storybook.outputs.pid }}
36+
- name: Download previous benchmark data (if any)
37+
uses: actions/cache@v4
38+
with:
39+
path: ./cache
40+
key: stress-tests-benchmark
41+
- name: Store benchmark result
42+
uses: benchmark-action/github-action-benchmark@v1
43+
with:
44+
tool: 'customSmallerIsBetter'
45+
output-file-path: results.json
46+
# Where the previous data file is stored
47+
external-data-json-path: ./cache/stress-tests-benchmark-data.json
48+
# Workflow will fail when an alert happens
49+
fail-on-alert: true
50+
github-token: ${{ secrets.GITHUB_TOKEN }}
51+
alert-threshold: '150%'
52+
# Enable alert commit comment
53+
comment-on-alert: true
54+
# Mention @rhysd in the commit comment
55+
alert-comment-cc-users: '@hectahertz'

e2e/components/StressTests.test.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {test} from '@playwright/test'
2+
import {visit} from '../test-helpers/storybook'
3+
4+
const stressTests = [
5+
{component: 'ActionList', testName: 'Single select', id: 'stresstests-components-actionlist--single-select'},
6+
{component: 'Pagination', testName: 'Page update', id: 'stresstests-components-pagination--page-update'},
7+
{component: 'Button', testName: 'Label update', id: 'stresstests-components-button--label-update'},
8+
{component: 'Button', testName: 'Count update', id: 'stresstests-components-button--count-update'},
9+
{component: 'TreeView', testName: 'Current update', id: 'stresstests-components-treeview--current-update'},
10+
]
11+
12+
for (const {component, testName, id} of stressTests) {
13+
test.describe(`${component} Stress Tests`, () => {
14+
test(`${testName} @stress-test`, async ({page}, testInfo) => {
15+
await visit(page, {id})
16+
await page.getByTestId('start').click()
17+
const result = await page.getByTestId('result').textContent()
18+
await testInfo.attach('stress-test-result', {
19+
body: JSON.stringify({id, duration: result}),
20+
contentType: 'application/json',
21+
})
22+
})
23+
})
24+
}

e2e/stress-test-reporter.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type {Reporter, TestCase, TestResult} from '@playwright/test/reporter'
2+
import {writeFileSync} from 'fs'
3+
4+
class MyReporter implements Reporter {
5+
// Format we need:
6+
// https://github.com/benchmark-action/github-action-benchmark?tab=readme-ov-file#examples
7+
results: {name: string; unit: string; value: number}[] = []
8+
9+
onTestEnd(_test: TestCase, result: TestResult) {
10+
// Finished stress-test
11+
for (const attachment of result.attachments) {
12+
// Get the content of the attachment to an object
13+
if (
14+
attachment.body !== undefined &&
15+
attachment.name === 'stress-test-result' &&
16+
attachment.contentType === 'application/json'
17+
) {
18+
const content = JSON.parse(attachment.body.toString())
19+
this.results.push({
20+
name: content.id,
21+
unit: 'ms',
22+
value: parseFloat(content.duration),
23+
})
24+
}
25+
}
26+
}
27+
28+
onEnd() {
29+
// Finished the stress tests run
30+
const fileName = 'results.json'
31+
const fileContentString = JSON.stringify(this.results, null, 2)
32+
writeFileSync(fileName, fileContentString)
33+
}
34+
}
35+
36+
export default MyReporter

package-lock.json

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
"@types/semver": "7.7.0",
157157
"@types/styled-components": "^5.1.26",
158158
"@vitejs/plugin-react": "^4.3.3",
159+
"afterframe": "^1.0.2",
159160
"ajv": "8.16.0",
160161
"axe-core": "4.9.1",
161162
"babel-core": "7.0.0-bridge.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react'
2+
3+
import type {Meta} from '@storybook/react'
4+
import type {ComponentProps} from '../utils/types'
5+
import {StressTest} from '../utils/StressTest'
6+
import {TableIcon} from '@primer/octicons-react'
7+
import {ActionList} from '.'
8+
9+
export default {
10+
title: 'StressTests/Components/ActionList',
11+
component: ActionList,
12+
} as Meta<ComponentProps<typeof ActionList>>
13+
14+
const totalIterations = 100
15+
16+
const projects = Array.from({length: totalIterations}, (_, i) => ({
17+
name: `Project ${i + 1}`,
18+
scope: `Scope ${i + 1}`,
19+
}))
20+
21+
export const SingleSelect = () => {
22+
return (
23+
<StressTest
24+
componentName="ActionList"
25+
title="Single Select"
26+
description="Selecting a single item from a large list."
27+
totalIterations={totalIterations}
28+
renderIteration={count => {
29+
return (
30+
<>
31+
<ActionList selectionVariant="single" showDividers role="menu" aria-label="Project">
32+
{projects.map((project, index) => (
33+
<ActionList.Item
34+
key={index}
35+
role="menuitemradio"
36+
selected={index === count}
37+
aria-checked={index === count}
38+
>
39+
<ActionList.LeadingVisual>
40+
<TableIcon />
41+
</ActionList.LeadingVisual>
42+
{project.name}
43+
<ActionList.Description variant="block">{project.scope}</ActionList.Description>
44+
</ActionList.Item>
45+
))}
46+
</ActionList>
47+
</>
48+
)
49+
}}
50+
/>
51+
)
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react'
2+
3+
import type {Meta} from '@storybook/react'
4+
import type {ComponentProps} from '../utils/types'
5+
import {StressTest} from '../utils/StressTest'
6+
import {Button} from '.'
7+
8+
export default {
9+
title: 'StressTests/Components/Button',
10+
component: Button,
11+
} as Meta<ComponentProps<typeof Button>>
12+
13+
const totalIterations = 500
14+
15+
export const LabelUpdate = () => {
16+
return (
17+
<StressTest
18+
componentName="Button"
19+
title="Label update"
20+
description="Update the label a large number of times."
21+
totalIterations={totalIterations}
22+
renderIteration={count => (
23+
<div>
24+
<Button variant="primary" size="large" onClick={() => {}}>
25+
{`Button ${count + 1}`}
26+
</Button>
27+
<Button variant="default" size="medium" onClick={() => {}}>
28+
{`Button ${count + 1}`}
29+
</Button>
30+
<Button variant="danger" size="small" onClick={() => {}}>
31+
{`Button ${count + 1}`}
32+
</Button>
33+
</div>
34+
)}
35+
/>
36+
)
37+
}
38+
39+
export const CountUpdate = () => {
40+
return (
41+
<StressTest
42+
componentName="Button"
43+
title="Count update"
44+
description="Update the count a large number of times."
45+
totalIterations={totalIterations}
46+
renderIteration={count => (
47+
<div>
48+
<Button variant="primary" size="large" onClick={() => {}} count={count}>
49+
Button
50+
</Button>
51+
<Button variant="default" size="medium" onClick={() => {}} count={count}>
52+
Button
53+
</Button>
54+
<Button variant="danger" size="small" onClick={() => {}} count={count}>
55+
Button
56+
</Button>
57+
</div>
58+
)}
59+
/>
60+
)
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react'
2+
3+
import type {Meta} from '@storybook/react'
4+
import type {ComponentProps} from '../utils/types'
5+
import Pagination from './Pagination'
6+
import {StressTest} from '../utils/StressTest'
7+
8+
export default {
9+
title: 'StressTests/Components/Pagination',
10+
component: Pagination,
11+
} as Meta<ComponentProps<typeof Pagination>>
12+
13+
const totalIterations = 500
14+
15+
export const PageUpdate = () => {
16+
return (
17+
<StressTest
18+
componentName="Pagination"
19+
title="Page update"
20+
description="Navigation through a large number of pages."
21+
totalIterations={totalIterations}
22+
renderIteration={count => (
23+
<Pagination pageCount={totalIterations} currentPage={count + 1} showPages={{narrow: false}} />
24+
)}
25+
/>
26+
)
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react'
2+
3+
import type {Meta} from '@storybook/react'
4+
import type {ComponentProps} from '../utils/types'
5+
import {StressTest} from '../utils/StressTest'
6+
import {TreeView} from './TreeView'
7+
import {FileIcon, DiffAddedIcon} from '@primer/octicons-react'
8+
import Octicon from '../Octicon'
9+
10+
export default {
11+
title: 'StressTests/Components/TreeView',
12+
component: TreeView,
13+
} as Meta<ComponentProps<typeof TreeView>>
14+
15+
const totalIterations = 100
16+
17+
const Files = Array.from({length: totalIterations}, (_, i) => ({
18+
name: `File_${i + 1}.tsx`,
19+
}))
20+
21+
export const CurrentUpdate = () => {
22+
return (
23+
<StressTest
24+
componentName="TreeView"
25+
title="Simple current update"
26+
description="Marking a file as current from a large list."
27+
totalIterations={totalIterations}
28+
renderIteration={count => (
29+
<TreeView aria-label="Files changed">
30+
<TreeView.Item id="src" defaultExpanded>
31+
<TreeView.LeadingVisual>
32+
<TreeView.DirectoryIcon />
33+
</TreeView.LeadingVisual>
34+
src
35+
<TreeView.SubTree>
36+
{Files.map((file, index) => (
37+
<TreeView.Item key={index} id={`src/${file.name}`} current={index === count}>
38+
<TreeView.LeadingVisual>
39+
<FileIcon />
40+
</TreeView.LeadingVisual>
41+
{file.name}
42+
<TreeView.TrailingVisual label="Added">
43+
<Octicon icon={DiffAddedIcon} color="success.fg" />
44+
</TreeView.TrailingVisual>
45+
</TreeView.Item>
46+
))}
47+
</TreeView.SubTree>
48+
</TreeView.Item>
49+
</TreeView>
50+
)}
51+
/>
52+
)
53+
}

0 commit comments

Comments
 (0)