test(e2e): convert the storefront and filter specs onto page objects (#137)
storefront, storefront-errors, theme, error-boundary and filters. filters.spec.ts carried the last hand-rolled copies of createCategory, createTag and createItem, and the hardcoded `http://localhost:5173` that meant changing the port in the config would have moved every test except this one. Seeding now goes through support/api, and the host it needs lives in one constant. It has to be a constant rather than the config's baseURL: beforeAll runs with worker-scoped fixtures only and cannot read a test-scoped option, which is why the URL was inlined there in the first place. New FilterDrawer object. The two rules it encodes are the ones the tests exist to pin down and neither is guessable from a locator: categories are a tree because the filter matches a node and everything filed beneath it, and tags combine with AND rather than OR, so selecting two means "must have both". The active-filter chips go on StorefrontPage rather than the drawer, because that is where they render — and the scoping matters, since the drawer carries a "Clear all" of its own that an unscoped locator also matches. StorefrontPage gains the three things the catalogue says instead of listing items. They are named together deliberately: the distinction between "No items yet" and "Couldn't load items" is the point, and several tests assert one is showing while the other is not, because telling a customer the shop is empty when the server is broken hides the outage. The theme switch and the attribute it writes are both on Header now. The switch is in the header and `data-theme` lands on <body>, so a spec previously had to know about `body` to observe the control it had just clicked. Verified: 12/12 across the four small specs, 8/8 on filters, tsc clean, lint unchanged at the 30-warning src baseline. Refs #137
This commit is contained in:
@@ -12,28 +12,35 @@ test.describe('Error boundaries', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible();
|
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 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
|
// The claim this boundary exists to make: a bad item no longer takes
|
||||||
// navigation down with it.
|
// navigation down with it.
|
||||||
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
await expect(header.siteTitle).toBeVisible();
|
||||||
await expect(page.getByRole('switch')).toBeVisible();
|
await expect(header.themeToggle).toBeVisible();
|
||||||
|
|
||||||
// And the root boundary did not also fire — only the nearest one should.
|
// And the root boundary did not also fire — only the nearest one should.
|
||||||
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0);
|
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 page.goto('/?boom=modal');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible();
|
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[] = [];
|
const reports: string[] = [];
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
if (request.url().includes('/api/client-errors')) {
|
if (request.url().includes('/api/client-errors')) {
|
||||||
@@ -42,7 +49,7 @@ test.describe('Error boundaries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/?boom=catalogue');
|
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.
|
// Observed on the wire rather than trusting that the reporter was called.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
|
||||||
// with randomness — two workers starting in the same millisecond would
|
// with randomness — two workers starting in the same millisecond would
|
||||||
// otherwise seed colliding category names and 409 against each other.
|
// 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 = {
|
const NAMES = {
|
||||||
furniture: `Furniture ${RUN}`,
|
furniture: `Furniture ${RUN}`,
|
||||||
@@ -20,41 +28,8 @@ const NAMES = {
|
|||||||
dearItem: `Dear cabinet ${RUN}`
|
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 }) => {
|
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
|
// 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
|
// re-runs beforeAll against the module-cached suffix. Seeding twice would
|
||||||
@@ -67,130 +42,124 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
return;
|
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 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.vintage);
|
||||||
await createTag(api, NAMES.oak);
|
await createTag(api, NAMES.oak);
|
||||||
|
|
||||||
// Filed one level below the category the tests select, to prove descendant
|
// Filed one level below the category the tests select, to prove descendant
|
||||||
// matching rather than an exact-node match.
|
// matching rather than an exact-node match.
|
||||||
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.deepItem, price: '340', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
|
await createItem(api, { name: NAMES.midItem, price: '120', categoryId: furniture, tags: [NAMES.vintage] });
|
||||||
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.otherItem, price: '90', categoryId: decor, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.dearItem, price: '5000', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
|
|
||||||
await api.dispose();
|
await api.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
function card(page: import('@playwright/test').Page, name: string) {
|
|
||||||
return page.getByRole('heading', { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Storefront filters', () => {
|
test.describe('Storefront filters', () => {
|
||||||
test('filters by category, including everything filed beneath it', async ({ page }) => {
|
test('filters by category, including everything filed beneath it', async ({
|
||||||
await page.goto('/');
|
storefront,
|
||||||
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
filterDrawer
|
||||||
|
}) => {
|
||||||
|
await storefront.goto();
|
||||||
|
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
await filterDrawer.chooseCategory(NAMES.furniture);
|
||||||
|
|
||||||
// Both the item filed directly in Furniture and the one nested under
|
// Both the item filed directly in Furniture and the one nested under
|
||||||
// Furniture > Tables must survive.
|
// Furniture > Tables must survive.
|
||||||
await expect(card(page, NAMES.midItem)).toBeVisible();
|
await expect(storefront.card(NAMES.midItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a nested category is reachable in the drawer', async ({ page }) => {
|
test('a nested category is reachable in the drawer', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
// The tree loads after the drawer mounts, so anything below the roots is
|
// 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
|
// only reachable if expansion tracks the loaded data rather than the state
|
||||||
// at mount time.
|
// 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(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requires every selected tag rather than any of them', async ({ page }) => {
|
test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
await page.getByRole('button', { name: NAMES.vintage }).click();
|
await filterDrawer.toggleTag(NAMES.vintage);
|
||||||
await expect(card(page, NAMES.midItem)).toBeVisible();
|
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.
|
// midItem carries only `vintage`, so adding `oak` must drop it.
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters by price range', async ({ page }) => {
|
test('filters by price range', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
await page.getByLabel('Minimum price').fill('200');
|
await filterDrawer.setPriceRange('200', '1000');
|
||||||
await page.getByLabel('Maximum price').fill('1000');
|
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
await expect(card(page, NAMES.dearItem)).toBeHidden();
|
await expect(storefront.card(NAMES.dearItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removing a chip widens the results again', async ({ page }) => {
|
test('removing a chip widens the results again', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
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 storefront.removeFilterChip(NAMES.decor).click();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clear all removes every active filter', async ({ page }) => {
|
test('clear all removes every active filter', async ({ page, storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: NAMES.vintage }).click();
|
await filterDrawer.toggleTag(NAMES.vintage);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await filterDrawer.close();
|
||||||
|
|
||||||
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
|
await storefront.clearAllFilters();
|
||||||
await page
|
|
||||||
.getByRole('group', { name: 'Active filters' })
|
|
||||||
.getByRole('button', { name: 'Clear all' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a filtered view survives a reload', async ({ page }) => {
|
test('a filtered view survives a reload', async ({ page, storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
await filterDrawer.chooseCategory(NAMES.furniture);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await filterDrawer.close();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/category=\d+/);
|
await expect(page).toHaveURL(/category=\d+/);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
|
||||||
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
|
await expect(storefront.removeFilterChip(NAMES.furniture)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows an item\'s tags on its card', async ({ page }) => {
|
test("shows an item's tags on its card", async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
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.vintage)).toBeVisible();
|
||||||
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AccountModal } from './pages/AccountModal';
|
|||||||
import { StorefrontPage } from './pages/StorefrontPage';
|
import { StorefrontPage } from './pages/StorefrontPage';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { PasswordResetPages } from './pages/PasswordResetPages';
|
import { PasswordResetPages } from './pages/PasswordResetPages';
|
||||||
|
import { FilterDrawer } from './pages/FilterDrawer';
|
||||||
import { uniqueEmail } from './support/api';
|
import { uniqueEmail } from './support/api';
|
||||||
|
|
||||||
// Re-exported so specs can import everything from here — expect, Page,
|
// Re-exported so specs can import everything from here — expect, Page,
|
||||||
@@ -40,6 +41,7 @@ interface Pages {
|
|||||||
storefront: StorefrontPage;
|
storefront: StorefrontPage;
|
||||||
admin: AdminPage;
|
admin: AdminPage;
|
||||||
passwordReset: PasswordResetPages;
|
passwordReset: PasswordResetPages;
|
||||||
|
filterDrawer: FilterDrawer;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -93,6 +95,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
|||||||
passwordReset: async ({ page }, use) => {
|
passwordReset: async ({ page }, use) => {
|
||||||
await use(new PasswordResetPages(page));
|
await use(new PasswordResetPages(page));
|
||||||
},
|
},
|
||||||
|
filterDrawer: async ({ page }, use) => {
|
||||||
|
await use(new FilterDrawer(page));
|
||||||
|
},
|
||||||
|
|
||||||
adminApi: async ({ playwright, baseURL }, use) => {
|
adminApi: async ({ playwright, baseURL }, use) => {
|
||||||
const context = await playwright.request.newContext({ baseURL });
|
const context = await playwright.request.newContext({ baseURL });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ export class Header {
|
|||||||
readonly signUpButton: Locator;
|
readonly signUpButton: Locator;
|
||||||
readonly cartButton: Locator;
|
readonly cartButton: Locator;
|
||||||
readonly themeToggle: 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) {
|
constructor(page: Page) {
|
||||||
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
||||||
@@ -23,6 +29,7 @@ export class Header {
|
|||||||
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
|
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
|
||||||
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
||||||
this.themeToggle = page.getByRole('switch');
|
this.themeToggle = page.getByRole('switch');
|
||||||
|
this.themedBody = page.locator('body');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,4 +47,9 @@ export class Header {
|
|||||||
async waitForSignedOut(): Promise<void> {
|
async waitForSignedOut(): Promise<void> {
|
||||||
await expect(this.myAccountButton).toHaveCount(0);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Locator, Page, expect } from '@playwright/test';
|
|||||||
import { Header } from './Header';
|
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
|
* 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,
|
* renders items into `.item-card` — three specs reached for that class directly,
|
||||||
@@ -13,14 +13,39 @@ import { Header } from './Header';
|
|||||||
export class StorefrontPage {
|
export class StorefrontPage {
|
||||||
readonly header: Header;
|
readonly header: Header;
|
||||||
readonly filtersButton: Locator;
|
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 retryButton: Locator;
|
||||||
readonly loadFailureHeading: Locator;
|
|
||||||
|
/** What the nearest error boundary renders when the grid itself throws. */
|
||||||
|
readonly catalogueBoundaryHeading: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.header = new Header(page);
|
this.header = new Header(page);
|
||||||
this.filtersButton = page.getByRole('button', { name: /Filters/ });
|
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.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> {
|
async goto(): Promise<void> {
|
||||||
@@ -59,6 +84,14 @@ export class StorefrontPage {
|
|||||||
await this.filtersButton.click();
|
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.
|
* Waits until the catalogue has rendered something.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Storefront failure states', () => {
|
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) =>
|
await page.route('**/api/items*', (route) =>
|
||||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
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
|
// 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.
|
// saying nothing — it reads as an empty catalogue and hides the outage.
|
||||||
await expect(page.getByText('No items yet')).toBeHidden();
|
await expect(storefront.emptyNotice).toBeHidden();
|
||||||
await expect(page.getByText("Couldn't load items")).toBeVisible();
|
await expect(storefront.loadFailureNotice).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Retry' })).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;
|
let failing = true;
|
||||||
await page.route('**/api/items*', (route) => {
|
await page.route('**/api/items*', (route) => {
|
||||||
if (failing) {
|
if (failing) {
|
||||||
@@ -24,22 +27,25 @@ test.describe('Storefront failure states', () => {
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
await expect(storefront.retryButton).toBeVisible();
|
||||||
|
|
||||||
failing = false;
|
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
|
// Mirrors the real incident: an un-migrated database left every item query
|
||||||
// hanging with no response at all.
|
// hanging with no response at all.
|
||||||
await page.route('**/api/items*', () => { /* never fulfilled */ });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Storefront', () => {
|
test.describe('Storefront', () => {
|
||||||
test('loads and shows the site title', async ({ page }) => {
|
test('loads and shows the site title', async ({ storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByText('Redefined Designs')).toBeVisible();
|
await expect(storefront.header.siteTitle).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('links to the privacy policy from the footer', async ({ page }) => {
|
test('links to the privacy policy from the footer', async ({ page, storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('link', { name: 'Privacy Policy' }).click();
|
await storefront.privacyPolicyLink.click();
|
||||||
await expect(page).toHaveURL(/\/privacy/);
|
await expect(page).toHaveURL(/\/privacy/);
|
||||||
await expect(page.getByText('What we collect')).toBeVisible();
|
await expect(page.getByText('What we collect')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('offers login and sign up when logged out', async ({ page }) => {
|
test('offers login and sign up when logged out', async ({ storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(storefront.header.logInButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
await expect(storefront.header.signUpButton).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { APIRequestContext, expect } from '@playwright/test';
|
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.
|
* 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.
|
* 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 {
|
export interface SeededItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Theme switching', () => {
|
test.describe('Theme switching', () => {
|
||||||
test('toggling the switch changes the body theme attribute', async ({ page }) => {
|
test('toggling the switch changes the body theme attribute', async ({ storefront, header }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
const body = page.locator('body');
|
const initial = await header.currentTheme();
|
||||||
const initial = await body.getAttribute('data-theme');
|
|
||||||
|
|
||||||
await page.getByRole('switch').click();
|
await header.themeToggle.click();
|
||||||
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const updated = await body.getAttribute('data-theme');
|
expect(await header.currentTheme()).not.toBe(initial);
|
||||||
expect(updated).not.toBe(initial);
|
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('theme preference persists across a reload', async ({ page }) => {
|
test('theme preference persists across a reload', async ({ page, storefront, header }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('switch').click();
|
await header.themeToggle.click();
|
||||||
const chosen = await page.locator('body').getAttribute('data-theme');
|
const chosen = await header.currentTheme();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('body')).toHaveAttribute('data-theme', chosen || '');
|
await expect(header.themedBody).toHaveAttribute('data-theme', chosen || '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user