Skip to content

Commit

Permalink
Enable running the snapshot tests for both color modes (#4926)
Browse files Browse the repository at this point in the history
* Add the ability to check two snapshots for each test

* Add the correct RTL snapshots for collection headers

* Add the toolbar button for theme switching

* Fix the image cell snapshot wrapper

* Refactor WithTheme decorator

* Add whitespace

* Fix footer test
  • Loading branch information
obulat committed Sep 19, 2024
1 parent a4071d3 commit 50e401f
Show file tree
Hide file tree
Showing 62 changed files with 480 additions and 199 deletions.
90 changes: 90 additions & 0 deletions frontend/.storybook/decorators/with-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { watch, onMounted, reactive, h } from "vue"
import { useEffect, useGlobals } from "@storybook/preview-api"

import { EffectiveColorMode } from "~/types/ui"

import { useDarkMode } from "~/composables/use-dark-mode"
import { useUiStore } from "~/stores/ui"

import VThemeSelect from "~/components/VThemeSelect/VThemeSelect.vue"

type ThemeCssClass = `${EffectiveColorMode}-mode`
const cssClassToTheme = (
cssClass: ThemeCssClass | undefined
): EffectiveColorMode | undefined => cssClass?.split("-")[0]
const isEffectiveColorMode = (
value: string | undefined
): value is EffectiveColorMode => ["light", "dark"].includes(value)

const setElementTheme = (el: HTMLElement, cssClass: ThemeCssClass) => {
if (cssClass === "dark-mode") {
el.classList.add("dark-mode")
el.classList.remove("light-mode")
} else {
el.classList.add("light-mode")
el.classList.remove("dark-mode")
}
}
const themeState = reactive<{ value: EffectiveColorMode }>({ value: "light" })

/**
* Decorator to add the Storybook theme switcher to the addon toolbar, and the Openverse
* theme switcher to the bottom of the screen.
* We cannot use the toolbar during the tests that open an iframe without the toolbars,
* so we need to add the theme switcher to the bottom of the screen.
* The state of both is kept in sync.
*/
export const WithTheme = (story) => {
const [globals, updateGlobals] = useGlobals()
themeState.value = globals.theme

useEffect(() => {
themeState.value = globals.theme
}, [globals.theme])

return {
components: { story },
setup() {
const { cssClass } = useDarkMode()
const uiStore = useUiStore()

watch(
themeState,
(newTheme) => {
if (isEffectiveColorMode(newTheme.value)) {
uiStore.setColorMode(newTheme.value)
}
},
{ immediate: true }
)

watch(
cssClass,
(newCssClass) => {
setElementTheme(document.body, newCssClass)
const theme = cssClassToTheme(newCssClass)
if (theme) {
updateGlobals({ theme })
}
},
{ immediate: true }
)

onMounted(() => {
document.body.classList.add("bg-default")
})

// Set the height to the full height of the Storybook iframe minus the padding
// to position the theme switcher at the bottom of the screen.
return () =>
h("div", { class: "relative", style: "height: calc(100dvh - 32px);" }, [
h(story()),
h(
"div",
{ class: "absolute bottom-0", id: "storybook-theme-switcher" },
[h(VThemeSelect)]
),
])
},
}
}
23 changes: 21 additions & 2 deletions frontend/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,27 @@ import { VIEWPORTS } from "~/constants/screens"

import { WithUiStore } from "~~/.storybook/decorators/with-ui-store"
import { WithRTL } from "~~/.storybook/decorators/with-rtl"
import { WithTheme } from "~~/.storybook/decorators/with-theme"

import type { Preview } from "@storybook/vue3"

const preview: Preview = {
decorators: [WithRTL, WithUiStore],
decorators: [WithRTL, WithUiStore, WithTheme],
globalTypes: {
theme: {
name: "Theme",
description: "Color theme",
table: {
defaultValue: { summary: "light" },
},
toolbar: {
icon: "circlehollow",
items: [
{ value: "light", title: "Light" },
{ value: "dark", title: "Dark" },
],
},
},
languageDirection: {
name: "RTL",
description: "Simulate an RTL language.",
Expand All @@ -25,7 +40,6 @@ const preview: Preview = {
},
parameters: {
backgrounds: {
default: "light",
values: [
{ name: "light", value: "#ffffff" },
{ name: "dark", value: "#0d0d0d" },
Expand All @@ -42,6 +56,11 @@ const preview: Preview = {
},
},
},
initialGlobals: {
theme: "light",
languageDirection: "ltr",
backgrounds: { value: "light" },
},
}

export default preview
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export const Default: Story = {
components: { VImageCell },
setup() {
return () =>
h("ol", { class: "flex flex-wrap gap-4" }, [h(VImageCell, args)])
h("div", { class: "p-2 image-wrapper max-w-80" }, [
h("ol", { class: "flex flex-wrap gap-4" }, [h(VImageCell, args)]),
])
},
}),
name: "VImageCell",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { h } from "vue"
import { ImageDetail } from "~/types/media"

import VMediaReuse from "~/components/VMediaInfo/VMediaReuse.vue"
import VLanguageSelect from "~/components/VLanguageSelect/VLanguageSelect.vue"

import type { Meta, StoryObj } from "@storybook/vue3"

Expand Down Expand Up @@ -35,13 +34,9 @@ type Story = StoryObj<typeof meta>

export const Default: Story = {
render: (args) => ({
components: { VMediaReuse, VLanguageSelect },
components: { VMediaReuse },
setup() {
return () =>
h("div", { class: "flex flex-col gap-y-2" }, [
h(VLanguageSelect),
h(VMediaReuse, args),
])
return () => h(VMediaReuse, args)
},
}),
name: "VMediaReuse",
Expand Down
50 changes: 20 additions & 30 deletions frontend/test/playwright/utils/breakpoints.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import { test, expect, Expect } from "@playwright/test"
import { test } from "@playwright/test"

import { VIEWPORTS } from "~/constants/screens"
import type { Breakpoint } from "~/constants/screens"
import type { LanguageDirection } from "~~/test/playwright/utils/i18n"

type ScreenshotAble = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
screenshot(...args: any[]): Promise<Buffer>
}
import {
type ExpectSnapshot,
expectSnapshot as innerExpectSnapshot,
} from "~~/test/playwright/utils/expect-snapshot"

type ExpectSnapshot = <T extends ScreenshotAble>(
name: string,
s: T,
options?: Parameters<T["screenshot"]>[0],
snapshotOptions?: Parameters<ReturnType<Expect>["toMatchSnapshot"]>[0]
) => Promise<Buffer | void>
import { VIEWPORTS } from "~/constants/screens"
import type { Breakpoint } from "~/constants/screens"

type BreakpointBlock = (options: {
getConfigValues: (name: string) => {
name: `${typeof name}-${Breakpoint}-light.png`
}
breakpoint: Breakpoint
expectSnapshot: ExpectSnapshot
}) => void
Expand Down Expand Up @@ -87,24 +79,22 @@ const makeBreakpointDescribe =
userAgent: options.uaMocking ? mockUaStrings[breakpoint] : undefined,
})

const getConfigValues = (name: string) => ({
name: `${name}-${breakpoint}-light.png` as const,
})
const getSnapshotName = (name: string, dir?: LanguageDirection) => {
const dirString = dir ? (`-${dir}` as const) : ""
return `${name}${dirString}-${breakpoint}` as const
}

const expectSnapshot = async <T extends ScreenshotAble>(
name: string,
screenshotAble: T,
options?: Parameters<T["screenshot"]>[0],
snapshotOptions?: Parameters<ReturnType<Expect>["toMatchSnapshot"]>[0]
const expectSnapshot: ExpectSnapshot = async (
page,
name,
screenshotAble,
options = {}
) => {
const { name: snapshotName } = getConfigValues(name)
return expect(await screenshotAble.screenshot(options)).toMatchSnapshot(
snapshotName,
snapshotOptions
)
const snapshotName = getSnapshotName(name, options.dir)
return innerExpectSnapshot(page, snapshotName, screenshotAble, options)
}

_block({ breakpoint, getConfigValues, expectSnapshot })
_block({ breakpoint, expectSnapshot })
})
}

Expand Down
111 changes: 111 additions & 0 deletions frontend/test/playwright/utils/expect-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect } from "@playwright/test"

import { type LanguageDirection, t } from "~~/test/playwright/utils/i18n"

import type { Breakpoint } from "~/constants/screens"

import type {
Expect,
Locator,
LocatorScreenshotOptions,
Page,
PageScreenshotOptions,
} from "@playwright/test"

export type ExpectSnapshotOptions = {
screenshotOptions?: LocatorScreenshotOptions | PageScreenshotOptions
snapshotOptions?: Parameters<ReturnType<Expect>["toMatchSnapshot"]>[0]
dir?: LanguageDirection
useColorMode?: boolean
}

export type ExpectSnapshot = <T extends Locator | Page>(
page: Page,
name: ReturnType<typeof getSnapshotBaseName>,
screenshotAble: T,
options?: ExpectSnapshotOptions
) => Promise<void>

export type ExpectScreenshotAreaSnapshot = (
page: Page,
name: string,
options?: ExpectSnapshotOptions
) => Promise<void>

type EffectiveColorMode = "dark" | "light"
const themeSelectLabel = (dir: LanguageDirection) => t("theme.theme", dir)
const themeOption = (colorMode: EffectiveColorMode, dir: LanguageDirection) =>
t(`theme.choices.${colorMode}`, dir)

export const turnOnDarkMode = async (page: Page, dir: LanguageDirection) => {
// In Storybook, the footer story has two theme switchers (one in the footer, and one
// is from the story decorator), so we need to select a single one.
await page
.getByLabel(themeSelectLabel(dir))
.nth(0)
.selectOption(themeOption("dark", dir))
}

type SnapshotNameOptions = {
dir?: LanguageDirection
breakpoint?: Breakpoint
}

const getSnapshotBaseName = (
name: string,
{ dir, breakpoint }: SnapshotNameOptions = {}
) => {
const dirString = dir ? (`-${dir}` as const) : ""
const breakpointString = breakpoint ? (`-${breakpoint}` as const) : ""
return `${name}${dirString}${breakpointString}` as const
}

const getSnapshotName = (
name: ReturnType<typeof getSnapshotBaseName>,
colorMode: EffectiveColorMode = "light"
) => {
return `${name}-${colorMode}.png` as const
}

/**
* Take a screenshot of the page or a given locator, and compare it to the existing snapshots.
* Take a screenshot in both light and dark mode if `useColorMode` is true.
*/
export const expectSnapshot: ExpectSnapshot = async (
page,
name,
screenshotAble,
{ screenshotOptions, snapshotOptions, useColorMode, dir } = {}
) => {
// Hide the theme switcher before taking the screenshot.
screenshotOptions = {
...(screenshotOptions ?? {}),
style: `#storybook-theme-switcher {
visibility: hidden;
}`,
}

expect
.soft(await screenshotAble.screenshot(screenshotOptions))
.toMatchSnapshot(getSnapshotName(name, "light"), snapshotOptions)

if (!(useColorMode === true)) {
return
}
await turnOnDarkMode(page, dir ?? "ltr")

expect(await screenshotAble.screenshot(screenshotOptions)).toMatchSnapshot(
getSnapshotName(name, "dark"),
snapshotOptions
)
}

/**
* Some component stories have a screenshot area that allows to take a snapshot
* of the area around the component (for focus rings or complex stories with modals
* or popovers).
*/
export const expectScreenshotAreaSnapshot: ExpectScreenshotAreaSnapshot =
async (page, name, options = {}) => {
return expectSnapshot(page, name, page.locator(".screenshot-area"), options)
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ test.describe("content report form", () => {

await button.click()

await expectSnapshot("content-report", page, undefined, {
maxDiffPixelRatio: 0.1,
await expectSnapshot(page, "content-report", page, {
snapshotOptions: { maxDiffPixelRatio: 0.1 },
})
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,16 @@ for (const dir of languageDirections) {
await page.mouse.move(0, 0)

await expectSnapshot(
`external-${mediaType}-sources-popover-${dir}`,
page,
`external-${mediaType}-sources-popover`,
page.getByRole("dialog"),
{},
{ maxDiffPixelRatio: 0.01, maxDiffPixels: undefined }
{
dir,
snapshotOptions: {
maxDiffPixelRatio: 0.01,
maxDiffPixels: undefined,
},
}
)
})
}
Expand Down
Loading

0 comments on commit 50e401f

Please sign in to comment.