test(frontend): Automatically test Storybook stories with snapshots (#13839)
@ -15,6 +15,7 @@
|
||||
!postcss.config.js
|
||||
!playwright.config.ts
|
||||
!.kearc
|
||||
!.storybook
|
||||
!tsconfig.json
|
||||
!frontend/@posthog
|
||||
!frontend/src
|
||||
|
52
.github/workflows/ci-e2e-vrt.yml
vendored
@ -1,52 +0,0 @@
|
||||
name: E2E Visual Regression Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: Visual Regression Tests
|
||||
runs-on: ubuntu-20.04
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.28.1-focal
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7.x.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: pnpm
|
||||
|
||||
- name: Install package.json dependencies with pnpm
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install CI utilities with pnpm
|
||||
run: pnpm install concurrently http-server wait-on
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook --quiet
|
||||
|
||||
- name: Serve Storybook and run tests
|
||||
run: |
|
||||
pnpm exec concurrently -k -s first -n "SRVR,TEST" -c "magenta,blue" \
|
||||
"pnpm exec http-server storybook-static --port 6006" \
|
||||
"pnpm exec wait-on http://127.0.0.1:6006 --timeout 60 && pnpm exec playwright test"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
56
.github/workflows/ci-frontend.yml
vendored
@ -66,10 +66,10 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- id: set-test-chunks
|
||||
name: Set Chunks
|
||||
# Looks at the output of 'pnpm test -- --listTests --json'
|
||||
# Looks at the output of 'pnpm test:unit -- --listTests --json'
|
||||
# Take the 5th line of the output (the first two are pnpm non-sense)
|
||||
# Split the test into 3 parts. To increase the number split change the denominator in `length / 3`
|
||||
run: echo "test-chunks=$(pnpm test -- --listTests --json | sed -n 5p | jq -cM '[_nwise(length / 3 | ceil)]')" >> $GITHUB_OUTPUT
|
||||
run: echo "test-chunks=$(pnpm test:unit --listTests --json | sed -n 5p | jq -cM '[_nwise(length / 3 | ceil)]')" >> $GITHUB_OUTPUT
|
||||
- id: set-test-chunk-ids
|
||||
name: Set Chunk IDs
|
||||
run: echo "test-chunk-ids=$(echo $CHUNKS | jq -cM 'to_entries | map(.key)')" >> $GITHUB_OUTPUT
|
||||
@ -106,7 +106,57 @@ jobs:
|
||||
|
||||
- name: Test with Jest
|
||||
# set maxWorkers or Jest only uses 1 CPU in GitHub Actions
|
||||
run: echo $CHUNKS | jq '.[${{ matrix.chunk }}] | .[] | @text' | xargs pnpm test -- --maxWorkers=2
|
||||
run: pnpm test:unit --maxWorkers=2 $(echo $CHUNKS | jq '.[${{ matrix.chunk }}] | .[] | @text')
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
CHUNKS: ${{ needs.jest-setup.outputs['test-chunks'] }}
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression tests
|
||||
runs-on: ubuntu-20.04
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.29.2-focal
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: '0'
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7.x.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: pnpm
|
||||
|
||||
- name: Install package.json dependencies with pnpm
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install CI utilities with pnpm
|
||||
run: pnpm install http-server wait-on
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook --quiet # Silence since progress logging results in a massive wall of spam
|
||||
|
||||
- name: Serve Storybook in the background
|
||||
run: pnpm exec http-server storybook-static --port 6006 --silent &
|
||||
|
||||
- name: Compare current Storybook to the baseline
|
||||
run: |
|
||||
pnpm wait-on http://127.0.0.1:6006 --timeout 60
|
||||
pnpm test:visual-regression:ci
|
||||
|
||||
- name: Upload Playwright report and diffs
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always() # We want the report even if there's no failure
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
frontend/__snapshots__/__diff_output__/
|
||||
retention-days: 7
|
||||
|
1
.gitignore
vendored
@ -17,6 +17,7 @@ frontend/.cache/
|
||||
.mypy_cache
|
||||
frontend/dist/
|
||||
frontend/types/
|
||||
frontend/__snapshots__/__diff_output__/
|
||||
*Type.ts
|
||||
frontend/pnpm-error.log
|
||||
frontend/tmp
|
||||
|
@ -1,4 +1,13 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Check if staged files contain any added or modified PNGs
|
||||
if git diff --cached --name-status | grep '^[AM]' | grep -q '.png$'; then
|
||||
# Error if OptiPNG is not installed
|
||||
if ! command -v optipng >/dev/null; then
|
||||
echo "PNG files must be optimized before being committed, but OptiPNG is not installed! Fix this with \`brew/apt install optipng\`."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
|
10
.storybook/decorators/withSnapshotsDisabled.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { DecoratorFn } from '@storybook/react'
|
||||
|
||||
/** Workaround for https://github.com/storybookjs/test-runner/issues/74 */
|
||||
// TODO: Smoke-test all the stories by removing this decorator, once all the stories pass
|
||||
export const withSnapshotsDisabled: DecoratorFn = (Story, { parameters }) => {
|
||||
if (parameters?.chromatic?.disableSnapshot && navigator.userAgent.includes('StorybookTestRunner')) {
|
||||
return <>Disabled for Test Runner</>
|
||||
}
|
||||
return <Story />
|
||||
}
|
@ -7,6 +7,7 @@ import { getStorybookAppContext } from './app-context'
|
||||
import { withKea } from './decorators/withKea'
|
||||
import { withMockDate } from './decorators/withMockDate'
|
||||
import { defaultMocks } from '~/mocks/handlers'
|
||||
import { withSnapshotsDisabled } from './decorators/withSnapshotsDisabled'
|
||||
|
||||
const setupMsw = () => {
|
||||
// Make sure the msw worker is started
|
||||
@ -27,9 +28,9 @@ const setupPosthogJs = () => {
|
||||
}
|
||||
setupPosthogJs()
|
||||
|
||||
// Setup storybook global parameters. See https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters
|
||||
/** Storybook global parameters. See https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters */
|
||||
export const parameters = {
|
||||
chromatic: { disableSnapshot: true },
|
||||
chromatic: { disableSnapshot: true }, // TODO: Make snapshots the default, instead disable them on a per-story basis
|
||||
actions: { argTypesRegex: '^on[A-Z].*', disabled: true },
|
||||
controls: {
|
||||
matchers: {
|
||||
@ -66,6 +67,7 @@ export const parameters = {
|
||||
|
||||
// Setup storybook global decorators. See https://storybook.js.org/docs/react/writing-stories/decorators#global-decorators
|
||||
export const decorators: Meta['decorators'] = [
|
||||
withSnapshotsDisabled,
|
||||
// Make sure the msw service worker is started, and reset the handlers to defaults.
|
||||
withKea,
|
||||
// Allow us to time travel to ensure our stories don't change over time.
|
||||
|
73
.storybook/test-runner.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { toMatchImageSnapshot } from 'jest-image-snapshot'
|
||||
import { getStoryContext, TestRunnerConfig, TestContext } from '@storybook/test-runner'
|
||||
import { Locator, Page, LocatorScreenshotOptions } from 'playwright-core'
|
||||
|
||||
const customSnapshotsDir = `${process.cwd()}/frontend/__snapshots__`
|
||||
|
||||
async function expectStoryToMatchFullPageSnapshot(page: Page, context: TestContext): Promise<void> {
|
||||
await expectLocatorToMatchStorySnapshot(page, context)
|
||||
}
|
||||
|
||||
async function expectStoryToMatchSceneSnapshot(page: Page, context: TestContext): Promise<void> {
|
||||
await expectLocatorToMatchStorySnapshot(page.locator('.main-app-content'), context)
|
||||
}
|
||||
|
||||
async function expectStoryToMatchComponentSnapshot(page: Page, context: TestContext): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const rootEl = document.getElementById('root')
|
||||
|
||||
if (rootEl) {
|
||||
// don't expand the container element to limit the screenshot
|
||||
// to the component's size
|
||||
rootEl.style.display = 'inline-block'
|
||||
}
|
||||
|
||||
// make the body transparent to take the screenshot
|
||||
// without background
|
||||
document.body.style.background = 'transparent'
|
||||
})
|
||||
|
||||
await expectLocatorToMatchStorySnapshot(page.locator('#root'), context, { omitBackground: true })
|
||||
}
|
||||
|
||||
async function expectLocatorToMatchStorySnapshot(
|
||||
locator: Locator | Page,
|
||||
context: TestContext,
|
||||
options?: LocatorScreenshotOptions
|
||||
): Promise<void> {
|
||||
const image = await locator.screenshot(options)
|
||||
expect(image).toMatchImageSnapshot({
|
||||
customSnapshotsDir,
|
||||
customSnapshotIdentifier: context.id,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setup() {
|
||||
expect.extend({ toMatchImageSnapshot })
|
||||
},
|
||||
async postRender(page, context) {
|
||||
const storyContext = await getStoryContext(page, context)
|
||||
|
||||
await page.evaluate(() => {
|
||||
// Stop all animations for consistent snapshots
|
||||
document.body.classList.add('dangerously-stop-all-animations')
|
||||
})
|
||||
|
||||
// Wait for the network to be idle for up to 500 ms, to allow assets like images to load. This is suboptimal,
|
||||
// because `networkidle` is not resolved reliably here, so we might wait for the full timeout - but it works.
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(500)])
|
||||
|
||||
if (!storyContext.parameters?.chromatic?.disableSnapshot) {
|
||||
if (storyContext.parameters?.layout === 'fullscreen') {
|
||||
if (storyContext.parameters.testRunner?.includeNavigation) {
|
||||
await expectStoryToMatchFullPageSnapshot(page, context)
|
||||
} else {
|
||||
await expectStoryToMatchSceneSnapshot(page, context)
|
||||
}
|
||||
} else {
|
||||
await expectStoryToMatchComponentSnapshot(page, context)
|
||||
}
|
||||
}
|
||||
},
|
||||
} as TestRunnerConfig
|
@ -3,12 +3,20 @@
|
||||
# We do this to ensure our reference images for visual regression tests are the same during development and in CI.
|
||||
#
|
||||
|
||||
FROM mcr.microsoft.com/playwright:v1.28.1-focal
|
||||
FROM mcr.microsoft.com/playwright:v1.29.2-focal
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN pnpm install @playwright/test@1.28.1
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
COPY playwright.config.ts ./
|
||||
ENV CYPRESS_INSTALL_BINARY=0
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
COPY playwright.config.ts webpack.config.js babel.config.js tsconfig.json ./
|
||||
|
||||
COPY .storybook/ .storybook/
|
||||
|
||||
COPY frontend/ frontend/
|
||||
|
@ -5,7 +5,7 @@ services:
|
||||
dockerfile: Dockerfile.playwright
|
||||
network_mode: host
|
||||
volumes:
|
||||
- './frontend/__snapshots__:/work/frontend/__snapshots__'
|
||||
- './playwright:/work/playwright'
|
||||
- './playwright-report:/work/playwright-report'
|
||||
- './test-results:/work/test-results'
|
||||
# profiles: [playwright]
|
||||
|
BIN
frontend/__snapshots__/lemon-ui-alert-message--closable.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/__snapshots__/lemon-ui-alert-message--error.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/__snapshots__/lemon-ui-alert-message--info.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/__snapshots__/lemon-ui-alert-message--success.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/__snapshots__/lemon-ui-alert-message--warning.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 685 B |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--active.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--as-links.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--default.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--full-width.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--loading.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--menu-buttons.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--more.png
Normal file
After Width: | Height: | Size: 148 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--no-padding.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 6.7 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--sizes.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--text-only.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 12 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--with-side-icon.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-button--with-tooltip.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 8.1 KiB |
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 8.1 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-checkbox--basic.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-checkbox--bordered.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-checkbox--disabled.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-checkbox--no-label.png
Normal file
After Width: | Height: | Size: 182 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-checkbox--overview.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-dialog--customised.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-dialog--minimal.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-dialog--template.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-divider--default.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-divider--large.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-divider--thick-dashed.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-divider--vertical.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-file-input--default.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--basic.png
Normal file
After Width: | Height: | Size: 439 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--clearable.png
Normal file
After Width: | Height: | Size: 568 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--danger-status.png
Normal file
After Width: | Height: | Size: 466 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--disabled.png
Normal file
After Width: | Height: | Size: 420 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--numeric.png
Normal file
After Width: | Height: | Size: 439 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--password.png
Normal file
After Width: | Height: | Size: 541 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--search.png
Normal file
After Width: | Height: | Size: 615 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-input--small.png
Normal file
After Width: | Height: | Size: 522 B |
After Width: | Height: | Size: 613 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-label--basic.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-label--overview.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-modal--inline.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-modal--lemon-modal.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 8.4 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-modal--without-content.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--danger.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--default.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--disabled.png
Normal file
After Width: | Height: | Size: 1023 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--full-width.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--icon-only.png
Normal file
After Width: | Height: | Size: 325 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--large.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--loading.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--outlined.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--small.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--success.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--tall.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--text-only.png
Normal file
After Width: | Height: | Size: 876 B |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--warning.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--with-side-icon.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/__snapshots__/lemon-ui-lemon-row--with-tooltip.png
Normal file
After Width: | Height: | Size: 1.1 KiB |