2023-01-27 15:51:35 +01:00
|
|
|
|
import { toMatchImageSnapshot } from 'jest-image-snapshot'
|
2024-09-23 17:25:07 +02:00
|
|
|
|
import { getStoryContext, TestRunnerConfig, TestContext } from '@storybook/test-runner'
|
2023-11-23 13:51:01 +01:00
|
|
|
|
import type { Locator, Page, LocatorScreenshotOptions } from '@playwright/test'
|
2023-02-03 13:06:21 +01:00
|
|
|
|
import type { Mocks } from '~/mocks/utils'
|
2023-11-23 18:30:10 +01:00
|
|
|
|
import { StoryContext } from '@storybook/csf'
|
2023-02-10 12:50:43 +01:00
|
|
|
|
|
2024-06-13 08:50:11 +02:00
|
|
|
|
const DEFAULT_VIEWPORT = { width: 1280, height: 720 }
|
2024-06-13 00:06:54 +02:00
|
|
|
|
|
2023-06-01 16:33:21 +02:00
|
|
|
|
// 'firefox' is technically supported too, but as of June 2023 it has memory usage issues that make is unusable
|
|
|
|
|
type SupportedBrowserName = 'chromium' | 'webkit'
|
2024-03-18 10:51:46 +01:00
|
|
|
|
type SnapshotTheme = 'light' | 'dark'
|
2023-02-03 13:06:21 +01:00
|
|
|
|
|
|
|
|
|
// Extend Storybook interface `Parameters` with Chromatic parameters
|
2023-08-30 11:49:15 +02:00
|
|
|
|
declare module '@storybook/types' {
|
2023-02-03 13:06:21 +01:00
|
|
|
|
interface Parameters {
|
2023-08-21 09:32:49 +02:00
|
|
|
|
options?: any
|
2024-03-18 10:51:46 +01:00
|
|
|
|
/** @default 'padded' */
|
2023-02-03 13:06:21 +01:00
|
|
|
|
layout?: 'padded' | 'fullscreen' | 'centered'
|
|
|
|
|
testOptions?: {
|
2023-02-24 20:33:39 +01:00
|
|
|
|
/**
|
2023-03-02 22:11:03 +01:00
|
|
|
|
* Whether we should wait for all loading indicators to disappear before taking a snapshot.
|
2023-08-29 09:28:03 +02:00
|
|
|
|
* @default true
|
2023-02-24 20:33:39 +01:00
|
|
|
|
*/
|
2023-08-29 09:28:03 +02:00
|
|
|
|
waitForLoadersToDisappear?: boolean
|
2024-03-18 10:51:46 +01:00
|
|
|
|
/** If set, we'll wait for the given selector (or all selectors, if multiple) to be satisfied. */
|
|
|
|
|
waitForSelector?: string | string[]
|
2023-02-03 13:06:21 +01:00
|
|
|
|
/**
|
2024-03-18 10:51:46 +01:00
|
|
|
|
* Whether navigation should be included in the snapshot. Only applies to `layout: 'fullscreen'` stories.
|
2023-08-29 09:28:03 +02:00
|
|
|
|
* @default false
|
2023-02-03 13:06:21 +01:00
|
|
|
|
*/
|
2024-03-18 10:51:46 +01:00
|
|
|
|
includeNavigationInSnapshot?: boolean
|
2023-02-10 12:50:43 +01:00
|
|
|
|
/**
|
|
|
|
|
* The test will always run for all the browers, but snapshots are only taken in Chromium by default.
|
|
|
|
|
* Override this to take snapshots in other browsers too.
|
2023-08-29 09:28:03 +02:00
|
|
|
|
*
|
2023-02-24 20:33:39 +01:00
|
|
|
|
* @default ['chromium']
|
2023-02-10 12:50:43 +01:00
|
|
|
|
*/
|
|
|
|
|
snapshotBrowsers?: SupportedBrowserName[]
|
2023-05-24 14:49:53 +02:00
|
|
|
|
/** If taking a component snapshot, you can narrow it down by specifying the selector. */
|
|
|
|
|
snapshotTargetSelector?: string
|
2024-06-13 00:06:54 +02:00
|
|
|
|
/** specify an alternative viewport size */
|
|
|
|
|
viewport?: { width: number; height: number }
|
2023-02-03 13:06:21 +01:00
|
|
|
|
}
|
|
|
|
|
msw?: {
|
|
|
|
|
mocks?: Mocks
|
|
|
|
|
}
|
|
|
|
|
[name: string]: any
|
|
|
|
|
}
|
2023-11-23 18:30:10 +01:00
|
|
|
|
|
|
|
|
|
interface Globals {
|
|
|
|
|
theme: SnapshotTheme
|
|
|
|
|
}
|
2023-02-03 13:06:21 +01:00
|
|
|
|
}
|
|
|
|
|
|
2024-03-18 10:51:46 +01:00
|
|
|
|
const RETRY_TIMES = 2
|
2023-09-02 18:35:18 +02:00
|
|
|
|
const LOADER_SELECTORS = [
|
|
|
|
|
'.Spinner',
|
|
|
|
|
'.LemonSkeleton',
|
|
|
|
|
'.LemonTableLoader',
|
2024-03-18 10:51:46 +01:00
|
|
|
|
'.Toastify__toast',
|
2023-09-02 18:35:18 +02:00
|
|
|
|
'[aria-busy="true"]',
|
|
|
|
|
'.SessionRecordingPlayer--buffering',
|
2023-09-11 11:30:50 +02:00
|
|
|
|
'.Lettermark--unknown',
|
2023-09-02 18:35:18 +02:00
|
|
|
|
]
|
2023-01-27 15:51:35 +01:00
|
|
|
|
|
|
|
|
|
const customSnapshotsDir = `${process.cwd()}/frontend/__snapshots__`
|
|
|
|
|
|
2023-11-13 15:32:10 +01:00
|
|
|
|
const JEST_TIMEOUT_MS = 15000
|
|
|
|
|
const PLAYWRIGHT_TIMEOUT_MS = 10000 // Must be shorter than JEST_TIMEOUT_MS
|
2023-10-10 12:18:59 +02:00
|
|
|
|
|
2024-03-18 10:51:46 +01:00
|
|
|
|
const ATTEMPT_COUNT_PER_ID: Record<string, number> = {}
|
|
|
|
|
|
2023-02-10 12:50:43 +01:00
|
|
|
|
module.exports = {
|
|
|
|
|
setup() {
|
|
|
|
|
expect.extend({ toMatchImageSnapshot })
|
|
|
|
|
jest.retryTimes(RETRY_TIMES, { logErrorsBeforeRetry: true })
|
2023-11-13 15:32:10 +01:00
|
|
|
|
jest.setTimeout(JEST_TIMEOUT_MS)
|
2023-02-10 12:50:43 +01:00
|
|
|
|
},
|
2024-06-13 00:06:54 +02:00
|
|
|
|
async preVisit(page, context) {
|
|
|
|
|
const storyContext = await getStoryContext(page, context)
|
|
|
|
|
const viewport = storyContext.parameters?.testOptions?.viewport || DEFAULT_VIEWPORT
|
2024-06-13 08:50:11 +02:00
|
|
|
|
await page.setViewportSize(viewport)
|
|
|
|
|
},
|
2023-11-22 16:24:37 +01:00
|
|
|
|
async postVisit(page, context) {
|
2024-03-18 10:51:46 +01:00
|
|
|
|
ATTEMPT_COUNT_PER_ID[context.id] = (ATTEMPT_COUNT_PER_ID[context.id] || 0) + 1
|
2024-06-13 08:50:11 +02:00
|
|
|
|
const storyContext = await getStoryContext(page, context)
|
|
|
|
|
const viewport = storyContext.parameters?.testOptions?.viewport || DEFAULT_VIEWPORT
|
2024-03-18 10:51:46 +01:00
|
|
|
|
await page.evaluate(
|
|
|
|
|
([retry, id]) => console.log(`[${id}] Attempt ${retry}`),
|
|
|
|
|
[ATTEMPT_COUNT_PER_ID[context.id], context.id]
|
|
|
|
|
)
|
|
|
|
|
if (ATTEMPT_COUNT_PER_ID[context.id] > 1) {
|
|
|
|
|
// When retrying, resize the viewport and then resize again to default,
|
|
|
|
|
// just in case the retry is due to a useResizeObserver fail
|
|
|
|
|
await page.setViewportSize({ width: 1920, height: 1080 })
|
2024-06-13 08:50:11 +02:00
|
|
|
|
await page.setViewportSize(viewport)
|
2024-03-18 10:51:46 +01:00
|
|
|
|
}
|
2023-03-02 22:11:03 +01:00
|
|
|
|
const browserContext = page.context()
|
2023-11-22 16:24:37 +01:00
|
|
|
|
const { snapshotBrowsers = ['chromium'] } = storyContext.parameters?.testOptions ?? {}
|
2023-02-10 12:50:43 +01:00
|
|
|
|
|
2023-11-13 15:32:10 +01:00
|
|
|
|
browserContext.setDefaultTimeout(PLAYWRIGHT_TIMEOUT_MS)
|
2023-11-22 16:24:37 +01:00
|
|
|
|
const currentBrowser = browserContext.browser()!.browserType().name() as SupportedBrowserName
|
|
|
|
|
if (snapshotBrowsers.includes(currentBrowser)) {
|
|
|
|
|
await expectStoryToMatchSnapshot(page, context, storyContext, currentBrowser)
|
2023-02-10 12:50:43 +01:00
|
|
|
|
}
|
2023-11-22 16:24:37 +01:00
|
|
|
|
},
|
|
|
|
|
tags: {
|
|
|
|
|
skip: ['test-skip'], // NOTE: This is overridden by the CI action storybook-chromatic.yml to include browser specific skipping
|
2023-02-10 12:50:43 +01:00
|
|
|
|
},
|
|
|
|
|
} as TestRunnerConfig
|
|
|
|
|
|
|
|
|
|
async function expectStoryToMatchSnapshot(
|
|
|
|
|
page: Page,
|
|
|
|
|
context: TestContext,
|
|
|
|
|
storyContext: StoryContext,
|
|
|
|
|
browser: SupportedBrowserName
|
|
|
|
|
): Promise<void> {
|
2023-03-02 22:11:03 +01:00
|
|
|
|
const {
|
2023-08-29 09:28:03 +02:00
|
|
|
|
waitForLoadersToDisappear = true,
|
|
|
|
|
waitForSelector,
|
2024-03-18 10:51:46 +01:00
|
|
|
|
includeNavigationInSnapshot = false,
|
2023-03-02 22:11:03 +01:00
|
|
|
|
} = storyContext.parameters?.testOptions ?? {}
|
2023-02-24 20:33:39 +01:00
|
|
|
|
|
2023-05-24 14:49:53 +02:00
|
|
|
|
let check: (
|
|
|
|
|
page: Page,
|
|
|
|
|
context: TestContext,
|
|
|
|
|
browser: SupportedBrowserName,
|
2023-11-23 18:30:10 +01:00
|
|
|
|
theme: SnapshotTheme,
|
2023-05-24 14:49:53 +02:00
|
|
|
|
targetSelector?: string
|
|
|
|
|
) => Promise<void>
|
2023-02-10 12:50:43 +01:00
|
|
|
|
if (storyContext.parameters?.layout === 'fullscreen') {
|
2024-03-18 10:51:46 +01:00
|
|
|
|
if (includeNavigationInSnapshot) {
|
|
|
|
|
check = expectStoryToMatchViewportSnapshot
|
2023-02-10 12:50:43 +01:00
|
|
|
|
} else {
|
2024-03-18 10:51:46 +01:00
|
|
|
|
check = expectStoryToMatchSceneSnapshot
|
2023-02-10 12:50:43 +01:00
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
check = expectStoryToMatchComponentSnapshot
|
|
|
|
|
}
|
2023-03-02 22:11:03 +01:00
|
|
|
|
|
2023-11-23 16:35:15 +01:00
|
|
|
|
await waitForPageReady(page)
|
2024-03-18 10:51:46 +01:00
|
|
|
|
await page.evaluate((layout: string) => {
|
|
|
|
|
// Stop all animations for consistent snapshots, and adjust other styles
|
2023-03-08 18:06:32 +01:00
|
|
|
|
document.body.classList.add('storybook-test-runner')
|
2024-03-18 10:51:46 +01:00
|
|
|
|
document.body.classList.add(`storybook-test-runner--${layout}`)
|
|
|
|
|
}, storyContext.parameters?.layout || 'padded')
|
2023-02-24 20:33:39 +01:00
|
|
|
|
if (waitForLoadersToDisappear) {
|
2023-11-28 16:20:12 +01:00
|
|
|
|
// The timeout is reduced so that we never allow toasts – they usually signify something wrong
|
2024-03-18 10:51:46 +01:00
|
|
|
|
await page.waitForSelector(LOADER_SELECTORS.join(','), { state: 'detached', timeout: 3000 })
|
2023-02-24 20:33:39 +01:00
|
|
|
|
}
|
2024-03-18 10:51:46 +01:00
|
|
|
|
if (typeof waitForSelector === 'string') {
|
2023-08-29 09:28:03 +02:00
|
|
|
|
await page.waitForSelector(waitForSelector)
|
2024-03-18 10:51:46 +01:00
|
|
|
|
} else if (Array.isArray(waitForSelector)) {
|
|
|
|
|
await Promise.all(waitForSelector.map((selector) => page.waitForSelector(selector)))
|
2023-08-29 09:28:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-09-23 17:25:07 +02:00
|
|
|
|
// Snapshot light theme
|
2023-12-22 12:38:21 +01:00
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
document.body.setAttribute('theme', 'light')
|
|
|
|
|
})
|
2023-11-23 18:30:10 +01:00
|
|
|
|
|
2024-06-14 01:00:17 +02:00
|
|
|
|
await waitForPageReady(page)
|
2024-07-24 16:59:16 +02:00
|
|
|
|
await page.waitForFunction(() => Array.from(document.images).every((i: HTMLImageElement) => !!i.naturalWidth))
|
2024-06-14 01:00:17 +02:00
|
|
|
|
await page.waitForTimeout(2000)
|
|
|
|
|
|
2023-12-22 12:38:21 +01:00
|
|
|
|
await check(page, context, browser, 'light', storyContext.parameters?.testOptions?.snapshotTargetSelector)
|
2023-11-23 18:30:10 +01:00
|
|
|
|
|
2024-09-23 17:25:07 +02:00
|
|
|
|
// Snapshot dark theme
|
2023-12-22 12:38:21 +01:00
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
document.body.setAttribute('theme', 'dark')
|
|
|
|
|
})
|
2023-11-23 18:30:10 +01:00
|
|
|
|
|
2024-06-14 01:00:17 +02:00
|
|
|
|
await waitForPageReady(page)
|
2024-07-24 16:59:16 +02:00
|
|
|
|
await page.waitForFunction(() => Array.from(document.images).every((i: HTMLImageElement) => !!i.naturalWidth))
|
2024-06-14 01:00:17 +02:00
|
|
|
|
await page.waitForTimeout(100)
|
|
|
|
|
|
2023-12-22 12:38:21 +01:00
|
|
|
|
await check(page, context, browser, 'dark', storyContext.parameters?.testOptions?.snapshotTargetSelector)
|
2023-01-27 15:51:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
2024-03-18 10:51:46 +01:00
|
|
|
|
async function expectStoryToMatchViewportSnapshot(
|
2023-02-10 12:50:43 +01:00
|
|
|
|
page: Page,
|
|
|
|
|
context: TestContext,
|
2023-11-23 18:30:10 +01:00
|
|
|
|
browser: SupportedBrowserName,
|
|
|
|
|
theme: SnapshotTheme
|
2023-02-10 12:50:43 +01:00
|
|
|
|
): Promise<void> {
|
2023-11-23 18:30:10 +01:00
|
|
|
|
await expectLocatorToMatchStorySnapshot(page, context, browser, theme)
|
2023-01-27 15:51:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-02-10 12:50:43 +01:00
|
|
|
|
async function expectStoryToMatchSceneSnapshot(
|
|
|
|
|
page: Page,
|
|
|
|
|
context: TestContext,
|
2023-11-23 18:30:10 +01:00
|
|
|
|
browser: SupportedBrowserName,
|
|
|
|
|
theme: SnapshotTheme
|
2023-02-10 12:50:43 +01:00
|
|
|
|
): Promise<void> {
|
2024-03-18 10:51:46 +01:00
|
|
|
|
// If the `main` element isn't present, let's use `body` - this is needed in logged-out screens.
|
|
|
|
|
// We use .last(), because the order of selector matches is based on the order of elements in the DOM,
|
|
|
|
|
// and not the order of the selectors in the query.
|
|
|
|
|
await expectLocatorToMatchStorySnapshot(page.locator('body, main').last(), context, browser, theme)
|
2023-02-10 12:50:43 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expectStoryToMatchComponentSnapshot(
|
|
|
|
|
page: Page,
|
|
|
|
|
context: TestContext,
|
2023-05-24 14:49:53 +02:00
|
|
|
|
browser: SupportedBrowserName,
|
2023-11-23 18:30:10 +01:00
|
|
|
|
theme: SnapshotTheme,
|
2023-08-21 09:32:49 +02:00
|
|
|
|
targetSelector: string = '#storybook-root'
|
2023-02-10 12:50:43 +01:00
|
|
|
|
): Promise<void> {
|
2024-03-18 10:51:46 +01:00
|
|
|
|
await page.evaluate(() => {
|
2023-08-21 09:32:49 +02:00
|
|
|
|
const rootEl = document.getElementById('storybook-root')
|
2023-05-22 09:15:17 +02:00
|
|
|
|
if (!rootEl) {
|
|
|
|
|
throw new Error('Could not find root element')
|
2023-01-27 15:51:35 +01:00
|
|
|
|
}
|
2023-05-22 09:15:17 +02:00
|
|
|
|
// If needed, expand the root element so that all popovers are visible in the screenshot
|
|
|
|
|
document.querySelectorAll('.Popover').forEach((popover) => {
|
|
|
|
|
const currentRootBoundingClientRect = rootEl.getBoundingClientRect()
|
|
|
|
|
const popoverBoundingClientRect = popover.getBoundingClientRect()
|
|
|
|
|
if (popoverBoundingClientRect.right > currentRootBoundingClientRect.right) {
|
|
|
|
|
rootEl.style.width = `${popoverBoundingClientRect.right}px`
|
|
|
|
|
}
|
|
|
|
|
if (popoverBoundingClientRect.bottom > currentRootBoundingClientRect.bottom) {
|
|
|
|
|
rootEl.style.height = `${popoverBoundingClientRect.bottom}px`
|
|
|
|
|
}
|
|
|
|
|
if (popoverBoundingClientRect.top < currentRootBoundingClientRect.top) {
|
|
|
|
|
rootEl.style.height = `${-popoverBoundingClientRect.top + currentRootBoundingClientRect.bottom}px`
|
|
|
|
|
}
|
|
|
|
|
if (popoverBoundingClientRect.left < currentRootBoundingClientRect.left) {
|
|
|
|
|
rootEl.style.width = `${-popoverBoundingClientRect.left + currentRootBoundingClientRect.right}px`
|
|
|
|
|
}
|
|
|
|
|
})
|
2024-03-18 10:51:46 +01:00
|
|
|
|
})
|
2023-01-27 15:51:35 +01:00
|
|
|
|
|
2023-11-23 18:30:10 +01:00
|
|
|
|
await expectLocatorToMatchStorySnapshot(page.locator(targetSelector), context, browser, theme, {
|
|
|
|
|
omitBackground: true,
|
|
|
|
|
})
|
2023-01-27 15:51:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expectLocatorToMatchStorySnapshot(
|
|
|
|
|
locator: Locator | Page,
|
|
|
|
|
context: TestContext,
|
2023-02-10 12:50:43 +01:00
|
|
|
|
browser: SupportedBrowserName,
|
2023-11-23 18:30:10 +01:00
|
|
|
|
theme: SnapshotTheme,
|
2023-01-27 15:51:35 +01:00
|
|
|
|
options?: LocatorScreenshotOptions
|
|
|
|
|
): Promise<void> {
|
2023-11-13 15:32:10 +01:00
|
|
|
|
const image = await locator.screenshot({ ...options })
|
2024-03-18 10:51:46 +01:00
|
|
|
|
let customSnapshotIdentifier = `${context.id}--${theme}`
|
2023-02-10 12:50:43 +01:00
|
|
|
|
if (browser !== 'chromium') {
|
|
|
|
|
customSnapshotIdentifier += `--${browser}`
|
|
|
|
|
}
|
2023-01-27 15:51:35 +01:00
|
|
|
|
expect(image).toMatchImageSnapshot({
|
|
|
|
|
customSnapshotsDir,
|
2023-02-10 12:50:43 +01:00
|
|
|
|
customSnapshotIdentifier,
|
|
|
|
|
// Compare structural similarity instead of raw pixels - reducing false positives
|
|
|
|
|
// See https://github.com/americanexpress/jest-image-snapshot#recommendations-when-using-ssim-comparison
|
|
|
|
|
comparisonMethod: 'ssim',
|
2023-09-15 12:15:43 +02:00
|
|
|
|
// 0.01 would be a 1% difference
|
|
|
|
|
failureThreshold: 0.01,
|
2023-02-10 12:50:43 +01:00
|
|
|
|
failureThresholdType: 'percent',
|
2023-01-27 15:51:35 +01:00
|
|
|
|
})
|
|
|
|
|
}
|
2024-09-23 17:25:07 +02:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Just like the `waitForPageReady` helper offered by Playwright - except we only wait for `networkidle` in CI,
|
|
|
|
|
* as it doesn't work with local Storybook (the live reload feature keeps up a long-running request, so we aren't idle).
|
|
|
|
|
*/
|
|
|
|
|
async function waitForPageReady(page: Page): Promise<void> {
|
2024-11-13 22:15:24 +01:00
|
|
|
|
await page.waitForLoadState('domcontentloaded')
|
|
|
|
|
await page.waitForLoadState('load')
|
2024-09-23 17:25:07 +02:00
|
|
|
|
if (process.env.CI) {
|
2024-11-13 22:15:24 +01:00
|
|
|
|
await page.waitForLoadState('networkidle')
|
2024-09-23 17:25:07 +02:00
|
|
|
|
}
|
2024-11-13 22:15:24 +01:00
|
|
|
|
await page.evaluate(() => document.fonts.ready)
|
2024-09-23 17:25:07 +02:00
|
|
|
|
}
|