Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable running the snapshot tests for both color modes #4926

Merged
merged 7 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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