Feature/137 convert account specs #151

Merged
bermudalamb merged 2 commits from feature/137-convert-account-specs into main 2026-08-23 19:36:18 -05:00
10 changed files with 256 additions and 157 deletions
Showing only changes of commit 9b3a03d1bf - Show all commits
+15 -8
View File
@@ -12,28 +12,35 @@ test.describe('Error boundaries', () => {
await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible();
});
test('a throw in the item grid leaves the header and theme switch usable', async ({ page }) => {
test('a throw in the item grid leaves the header and theme switch usable', async ({
page,
storefront,
header
}) => {
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
await expect(storefront.catalogueBoundaryHeading).toBeVisible();
// The claim this boundary exists to make: a bad item no longer takes
// navigation down with it.
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await expect(page.getByRole('switch')).toBeVisible();
await expect(header.siteTitle).toBeVisible();
await expect(header.themeToggle).toBeVisible();
// And the root boundary did not also fire — only the nearest one should.
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0);
});
test('a throw in the modal block leaves the storefront behind it intact', async ({ page }) => {
test('a throw in the modal block leaves the storefront behind it intact', async ({
page,
header
}) => {
await page.goto('/?boom=modal');
await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await expect(header.siteTitle).toBeVisible();
});
test('a caught error is reported to the server', async ({ page }) => {
test('a caught error is reported to the server', async ({ page, storefront }) => {
const reports: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/client-errors')) {
@@ -42,7 +49,7 @@ test.describe('Error boundaries', () => {
});
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
await expect(storefront.catalogueBoundaryHeading).toBeVisible();
// Observed on the wire rather than trusting that the reporter was called.
//
+80 -111
View File
@@ -1,4 +1,12 @@
import { test, expect, APIRequestContext } from './fixtures';
import {
test,
expect,
createAdminContext,
createCategory,
createTag,
createItem,
uniqueSuffix
} from './fixtures';
// The storefront shows every item ever seeded, and the e2e database is not
// reset between runs. Every fixture below is therefore suffixed with a unique
@@ -6,7 +14,7 @@ import { test, expect, APIRequestContext } from './fixtures';
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
// with randomness — two workers starting in the same millisecond would
// otherwise seed colliding category names and 409 against each other.
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const RUN = `f${uniqueSuffix()}`;
const NAMES = {
furniture: `Furniture ${RUN}`,
@@ -20,41 +28,8 @@ const NAMES = {
dearItem: `Dear cabinet ${RUN}`
};
async function createCategory(api: APIRequestContext, name: string, parentId: number | null) {
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
expect(res.status()).toBe(201);
return (await res.json()).id as number;
}
async function createTag(api: APIRequestContext, name: string) {
const res = await api.post('/api/admin/tags', { data: { name } });
expect(res.status()).toBe(201);
return (await res.json()).id as number;
}
async function createItem(
api: APIRequestContext,
name: string,
price: string,
categoryId: number | null,
tags: string[]
) {
const res = await api.post('/api/admin/items', {
multipart: {
name,
description: '',
price,
category_id: categoryId === null ? '' : String(categoryId),
tags: JSON.stringify(tags)
}
});
expect(res.ok()).toBeTruthy();
// New items are pending; the storefront only lists published ones.
expect((await api.post(`/api/admin/items/${(await res.json()).id}/mark-available`)).ok()).toBeTruthy();
}
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const api = await createAdminContext(playwright);
// A worker can be handed tests from this file in more than one batch, which
// re-runs beforeAll against the module-cached suffix. Seeding twice would
@@ -67,130 +42,124 @@ test.beforeAll(async ({ playwright }) => {
return;
}
const furniture = await createCategory(api, NAMES.furniture, null);
const furniture = await createCategory(api, NAMES.furniture);
const tables = await createCategory(api, NAMES.tables, furniture);
const decor = await createCategory(api, NAMES.decor, null);
const decor = await createCategory(api, NAMES.decor);
await createTag(api, NAMES.vintage);
await createTag(api, NAMES.oak);
// Filed one level below the category the tests select, to prove descendant
// matching rather than an exact-node match.
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
await createItem(api, { name: NAMES.deepItem, price: '340', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
await createItem(api, { name: NAMES.midItem, price: '120', categoryId: furniture, tags: [NAMES.vintage] });
await createItem(api, { name: NAMES.otherItem, price: '90', categoryId: decor, tags: [NAMES.vintage, NAMES.oak] });
await createItem(api, { name: NAMES.dearItem, price: '5000', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
await api.dispose();
});
function card(page: import('@playwright/test').Page, name: string) {
return page.getByRole('heading', { name });
}
test.describe('Storefront filters', () => {
test('filters by category, including everything filed beneath it', async ({ page }) => {
await page.goto('/');
await expect(card(page, NAMES.otherItem)).toBeVisible();
test('filters by category, including everything filed beneath it', async ({
storefront,
filterDrawer
}) => {
await storefront.goto();
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
await storefront.openFilters();
await filterDrawer.chooseCategory(NAMES.furniture);
// Both the item filed directly in Furniture and the one nested under
// Furniture > Tables must survive.
await expect(card(page, NAMES.midItem)).toBeVisible();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeHidden();
await expect(storefront.card(NAMES.midItem)).toBeVisible();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
});
test('a nested category is reachable in the drawer', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
test('a nested category is reachable in the drawer', async ({ storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
// The tree loads after the drawer mounts, so anything below the roots is
// only reachable if expansion tracks the loaded data rather than the state
// at mount time.
await page.getByRole('treeitem', { name: NAMES.tables }).click();
await filterDrawer.chooseCategory(NAMES.tables);
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.midItem)).toBeHidden();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.midItem)).toBeHidden();
});
test('requires every selected tag rather than any of them', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await page.getByRole('button', { name: NAMES.vintage }).click();
await expect(card(page, NAMES.midItem)).toBeVisible();
await filterDrawer.toggleTag(NAMES.vintage);
await expect(storefront.card(NAMES.midItem)).toBeVisible();
await page.getByRole('button', { name: NAMES.oak }).click();
await filterDrawer.toggleTag(NAMES.oak);
// midItem carries only `vintage`, so adding `oak` must drop it.
await expect(card(page, NAMES.midItem)).toBeHidden();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.midItem)).toBeHidden();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
});
test('filters by price range', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
test('filters by price range', async ({ storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await page.getByLabel('Minimum price').fill('200');
await page.getByLabel('Maximum price').fill('1000');
await filterDrawer.setPriceRange('200', '1000');
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.midItem)).toBeHidden();
await expect(card(page, NAMES.dearItem)).toBeHidden();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.midItem)).toBeHidden();
await expect(storefront.card(NAMES.dearItem)).toBeHidden();
});
test('removing a chip widens the results again', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: 'Close' }).click();
test('removing a chip widens the results again', async ({ storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await filterDrawer.chooseCategory(NAMES.decor);
await filterDrawer.close();
await expect(card(page, NAMES.deepItem)).toBeHidden();
await expect(storefront.card(NAMES.deepItem)).toBeHidden();
await page.getByRole('button', { name: `Remove filter ${NAMES.decor}` }).click();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await storefront.removeFilterChip(NAMES.decor).click();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
});
test('clear all removes every active filter', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: NAMES.vintage }).click();
await page.getByRole('button', { name: 'Close' }).click();
test('clear all removes every active filter', async ({ page, storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await filterDrawer.chooseCategory(NAMES.decor);
await filterDrawer.toggleTag(NAMES.vintage);
await filterDrawer.close();
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
await page
.getByRole('group', { name: 'Active filters' })
.getByRole('button', { name: 'Clear all' })
.click();
await storefront.clearAllFilters();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeVisible();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
await expect(page).toHaveURL(/\/$/);
});
test('a filtered view survives a reload', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
await page.getByRole('button', { name: 'Close' }).click();
test('a filtered view survives a reload', async ({ page, storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await filterDrawer.chooseCategory(NAMES.furniture);
await filterDrawer.close();
await expect(page).toHaveURL(/category=\d+/);
await page.reload();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeHidden();
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
await expect(storefront.removeFilterChip(NAMES.furniture)).toBeVisible();
});
test('shows an item\'s tags on its card', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: 'Close' }).click();
test("shows an item's tags on its card", async ({ storefront, filterDrawer }) => {
await storefront.goto();
await storefront.openFilters();
await filterDrawer.chooseCategory(NAMES.decor);
await filterDrawer.close();
const wallArt = page.locator('.item-card').filter({ hasText: NAMES.otherItem });
const wallArt = storefront.card(NAMES.otherItem);
await expect(wallArt.getByText(NAMES.vintage)).toBeVisible();
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
});
+5
View File
@@ -8,6 +8,7 @@ import { AccountModal } from './pages/AccountModal';
import { StorefrontPage } from './pages/StorefrontPage';
import { AdminPage } from './pages/AdminPage';
import { PasswordResetPages } from './pages/PasswordResetPages';
import { FilterDrawer } from './pages/FilterDrawer';
import { uniqueEmail } from './support/api';
// Re-exported so specs can import everything from here — expect, Page,
@@ -40,6 +41,7 @@ interface Pages {
storefront: StorefrontPage;
admin: AdminPage;
passwordReset: PasswordResetPages;
filterDrawer: FilterDrawer;
}
interface Data {
@@ -93,6 +95,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
passwordReset: async ({ page }, use) => {
await use(new PasswordResetPages(page));
},
filterDrawer: async ({ page }, use) => {
await use(new FilterDrawer(page));
},
adminApi: async ({ playwright, baseURL }, use) => {
const context = await playwright.request.newContext({ baseURL });
+51
View File
@@ -0,0 +1,51 @@
import { Locator, Page } from '@playwright/test';
/**
* The storefront's filter drawer.
*
* Categories are a tree rather than a list, because a category filter matches
* the node and everything filed beneath it — so the locator is `treeitem`, and
* a nested category is only reachable once the tree has loaded its data.
*
* Tags are buttons that toggle. The rule they follow is AND, not OR: selecting
* two tags means "must have both", which is deliberately different from how
* categories combine and is the thing several tests exist to pin down.
*/
export class FilterDrawer {
readonly minimumPrice: Locator;
readonly maximumPrice: Locator;
readonly closeButton: Locator;
readonly clearAllButton: Locator;
constructor(private readonly page: Page) {
this.minimumPrice = page.getByLabel('Minimum price');
this.maximumPrice = page.getByLabel('Maximum price');
this.closeButton = page.getByRole('button', { name: 'Close' });
this.clearAllButton = page.getByRole('button', { name: 'Clear all' });
}
category(name: string): Locator {
return this.page.getByRole('treeitem', { name });
}
tag(name: string): Locator {
return this.page.getByRole('button', { name });
}
async chooseCategory(name: string): Promise<void> {
await this.category(name).click();
}
async toggleTag(name: string): Promise<void> {
await this.tag(name).click();
}
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
if (minimum !== undefined) await this.minimumPrice.fill(minimum);
if (maximum !== undefined) await this.maximumPrice.fill(maximum);
}
async close(): Promise<void> {
await this.closeButton.click();
}
}
+12
View File
@@ -15,6 +15,12 @@ export class Header {
readonly signUpButton: Locator;
readonly cartButton: Locator;
readonly themeToggle: Locator;
/**
* Where the theme actually lands. The switch is in the header, and the
* attribute it writes is on <body> — so the control and its observable
* effect are named together rather than a spec knowing about `body`.
*/
readonly themedBody: Locator;
constructor(page: Page) {
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
@@ -23,6 +29,7 @@ export class Header {
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
this.cartButton = page.getByRole('button', { name: /Cart/ });
this.themeToggle = page.getByRole('switch');
this.themedBody = page.locator('body');
}
/**
@@ -40,4 +47,9 @@ export class Header {
async waitForSignedOut(): Promise<void> {
await expect(this.myAccountButton).toHaveCount(0);
}
/** The theme currently applied, as the attribute a stylesheet reads. */
async currentTheme(): Promise<string | null> {
return this.themedBody.getAttribute('data-theme');
}
}
+36 -3
View File
@@ -2,7 +2,7 @@ import { Locator, Page, expect } from '@playwright/test';
import { Header } from './Header';
/**
* The public catalogue.
* The public catalogue, and the states it shows instead of one.
*
* The item card locator is the one place in the suite that knows the storefront
* renders items into `.item-card` — three specs reached for that class directly,
@@ -13,14 +13,39 @@ import { Header } from './Header';
export class StorefrontPage {
readonly header: Header;
readonly filtersButton: Locator;
readonly privacyPolicyLink: Locator;
/**
* The chip row summarising what is filtered, which lives on the storefront
* rather than in the drawer. Scoping matters: the drawer carries a "Clear
* all" of its own, so an unscoped one matches both.
*/
readonly activeFilters: Locator;
/**
* The three things the catalogue can say instead of listing items. Named
* together because the distinction between them is the point: telling a
* customer "No items yet" while the server is broken reads as an empty shop
* and hides the outage, so several tests assert one is showing and another
* is not.
*/
readonly emptyNotice: Locator;
readonly loadFailureNotice: Locator;
readonly retryButton: Locator;
readonly loadFailureHeading: Locator;
/** What the nearest error boundary renders when the grid itself throws. */
readonly catalogueBoundaryHeading: Locator;
constructor(private readonly page: Page) {
this.header = new Header(page);
this.filtersButton = page.getByRole('button', { name: /Filters/ });
this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' });
this.activeFilters = page.getByRole('group', { name: 'Active filters' });
this.emptyNotice = page.getByText('No items yet');
this.loadFailureNotice = page.getByText("Couldn't load items");
this.retryButton = page.getByRole('button', { name: 'Retry' });
this.loadFailureHeading = page.getByRole('heading', { name: "The item list didn't load" });
this.catalogueBoundaryHeading = page.getByRole('heading', { name: "The item list didn't load" });
}
async goto(): Promise<void> {
@@ -59,6 +84,14 @@ export class StorefrontPage {
await this.filtersButton.click();
}
removeFilterChip(name: string): Locator {
return this.page.getByRole('button', { name: `Remove filter ${name}` });
}
async clearAllFilters(): Promise<void> {
await this.activeFilters.getByRole('button', { name: 'Clear all' }).click();
}
/**
* Waits until the catalogue has rendered something.
*
+19 -13
View File
@@ -1,21 +1,24 @@
import { test, expect } from './fixtures';
test.describe('Storefront failure states', () => {
test('reports a server failure instead of claiming the store is empty', async ({ page }) => {
test('reports a server failure instead of claiming the store is empty', async ({
page,
storefront
}) => {
await page.route('**/api/items*', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
);
await page.goto('/');
await storefront.goto();
// Telling a customer "no items yet" when the server is broken is worse than
// saying nothing — it reads as an empty catalogue and hides the outage.
await expect(page.getByText('No items yet')).toBeHidden();
await expect(page.getByText("Couldn't load items")).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
await expect(storefront.emptyNotice).toBeHidden();
await expect(storefront.loadFailureNotice).toBeVisible();
await expect(storefront.retryButton).toBeVisible();
});
test('recovers when the server comes back', async ({ page }) => {
test('recovers when the server comes back', async ({ page, storefront }) => {
let failing = true;
await page.route('**/api/items*', (route) => {
if (failing) {
@@ -24,22 +27,25 @@ test.describe('Storefront failure states', () => {
return route.continue();
});
await page.goto('/');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
await storefront.goto();
await expect(storefront.retryButton).toBeVisible();
failing = false;
await page.getByRole('button', { name: 'Retry' }).click();
await storefront.retryButton.click();
await expect(page.getByText("Couldn't load items")).toBeHidden();
await expect(storefront.loadFailureNotice).toBeHidden();
});
test('a request that never resolves does not render as an empty catalogue', async ({ page }) => {
test('a request that never resolves does not render as an empty catalogue', async ({
page,
storefront
}) => {
// Mirrors the real incident: an un-migrated database left every item query
// hanging with no response at all.
await page.route('**/api/items*', () => { /* never fulfilled */ });
await page.goto('/');
await storefront.goto();
await expect(page.getByText('No items yet')).toBeHidden();
await expect(storefront.emptyNotice).toBeHidden();
});
});
+10 -10
View File
@@ -1,21 +1,21 @@
import { test, expect } from './fixtures';
test.describe('Storefront', () => {
test('loads and shows the site title', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Redefined Designs')).toBeVisible();
test('loads and shows the site title', async ({ storefront }) => {
await storefront.goto();
await expect(storefront.header.siteTitle).toBeVisible();
});
test('links to the privacy policy from the footer', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Privacy Policy' }).click();
test('links to the privacy policy from the footer', async ({ page, storefront }) => {
await storefront.goto();
await storefront.privacyPolicyLink.click();
await expect(page).toHaveURL(/\/privacy/);
await expect(page.getByText('What we collect')).toBeVisible();
});
test('offers login and sign up when logged out', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
test('offers login and sign up when logged out', async ({ storefront }) => {
await storefront.goto();
await expect(storefront.header.logInButton).toBeVisible();
await expect(storefront.header.signUpButton).toBeVisible();
});
});
+18
View File
@@ -1,5 +1,16 @@
import { APIRequestContext, expect } from '@playwright/test';
/**
* Where the app under test is served.
*
* Matches playwright.config.ts's baseURL. It exists as a constant because
* `beforeAll` runs with worker-scoped fixtures only and cannot read the
* test-scoped `baseURL` option, so a seeding hook has to name the host itself.
* One spec used to write it inline, which meant changing the port in the config
* moved every test except that one.
*/
export const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
/**
* Seeding through the admin API.
*
@@ -13,6 +24,13 @@ import { APIRequestContext, expect } from '@playwright/test';
* working, so a broken form fails a hundred tests that are not about it.
*/
/** An admin API context for `beforeAll`, where the `adminApi` fixture is out of reach. */
export async function createAdminContext(
playwright: { request: { newContext: (o: { baseURL: string }) => Promise<APIRequestContext> } }
): Promise<APIRequestContext> {
return playwright.request.newContext({ baseURL: BASE_URL });
}
export interface SeededItem {
id: number;
name: string;
+10 -12
View File
@@ -1,25 +1,23 @@
import { test, expect } from './fixtures';
test.describe('Theme switching', () => {
test('toggling the switch changes the body theme attribute', async ({ page }) => {
await page.goto('/');
const body = page.locator('body');
const initial = await body.getAttribute('data-theme');
test('toggling the switch changes the body theme attribute', async ({ storefront, header }) => {
await storefront.goto();
const initial = await header.currentTheme();
await page.getByRole('switch').click();
await header.themeToggle.click();
await expect(async () => {
const updated = await body.getAttribute('data-theme');
expect(updated).not.toBe(initial);
expect(await header.currentTheme()).not.toBe(initial);
}).toPass();
});
test('theme preference persists across a reload', async ({ page }) => {
await page.goto('/');
await page.getByRole('switch').click();
const chosen = await page.locator('body').getAttribute('data-theme');
test('theme preference persists across a reload', async ({ page, storefront, header }) => {
await storefront.goto();
await header.themeToggle.click();
const chosen = await header.currentTheme();
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-theme', chosen || '');
await expect(header.themedBody).toHaveAttribute('data-theme', chosen || '');
});
});