test(e2e): convert the favorites, availability and orders specs (#137)

favorites, favorites-filter, sold-filter, orders and pending-publish. Five more copies of "register a customer" and three more of the hardcoded base URL go with them.

Two locators that were hiding real knowledge are now named. `gridCell` is the item's whole antd column rather than its card, needed because the SOLD ribbon renders outside the card — three specs reached for `.ant-col` directly to get at it. And `chooseAvailability` goes through the title attribute because antd's Segmented hides the real radio behind a styled label, so the input is found by role and cannot be clicked; that fact was written out twice in comments and is now written once in code.

The favorite control is located page-wide rather than within a card. The storefront paginates as items accumulate and the control is named for its item anyway, so scoping to a card bought nothing and broke whenever the card was on another page.

favorites-filter keeps its local `favorite()` helper. It is genuinely local — decline the opt-in, wait for the fading modal to stop intercepting pointer events, confirm the heart flipped — and belongs to that file's subject rather than to the storefront. It now takes page objects as parameters instead of reaching for locators itself, which is what a spec-level helper should look like.

Two specs still drive the registration form rather than taking the `customer` fixture, and deliberately. Both are about a signed-out visitor being interrupted mid-action — favoriting an item, or switching on the favorites filter — and the claim is that the thing they asked for survives the interruption. Replacing the interruption with an API call would delete the test.

Verified: favorites 6/6, favorites-filter 7/7, sold-filter 6/6, orders and pending-publish 8/8. Notably favorites-filter's "keeps showing a favorite after it sells" passes, which had been failing on a strict-mode violation from two items sharing a name across runs.

Refs #137
This commit is contained in:
2026-08-23 19:43:45 -05:00
parent 507c56bbb8
commit 549a08038e
12 changed files with 514 additions and 355 deletions
+123 -111
View File
@@ -1,180 +1,192 @@
import { test, expect, Page } from './fixtures'; import {
test,
expect,
createAdminContext,
createItem,
sellItem,
uniqueSuffix,
uniqueEmail,
PASSWORD,
StorefrontPage,
FavoritePrompt
} from './fixtures';
const PASSWORD = 'supersecret123';
// The storefront runs against a shared database that is never reset, so every // The storefront runs against a shared database that is never reset, so every
// name has to be unique to this run or a rerun would match the last one's rows. // name has to be unique to this run or a rerun would match the last one's rows.
const RUN = `ff${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const RUN = `ff${uniqueSuffix()}`;
const KEPT = `Kept ${RUN}`; const KEPT = `Kept ${RUN}`;
const OTHER = `Other ${RUN}`; const OTHER = `Other ${RUN}`;
// Its own item because this run marks it sold, and the suite is fullyParallel: // Its own item because this run marks it sold, and the suite is fullyParallel:
// mutating an item the other tests read would make them race. // mutating an item the other tests read would make them race.
const SELLS = `Sells ${RUN}`; const SELLS = `Sells ${RUN}`;
const uniqueEmail = () => `favfilter-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => { test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); const api = await createAdminContext(playwright);
// Two items, priced apart so one test can prove favorites combines with the // Priced apart so one test can prove favorites combines with the price filter
// price filter rather than replacing it. // rather than replacing it.
for (const [name, price] of [[KEPT, '60'], [OTHER, '900'], [SELLS, '70']] as const) { await createItem(api, { name: KEPT, price: '60' });
const res = await api.post('/api/admin/items', { await createItem(api, { name: OTHER, price: '900' });
multipart: { name, description: '', price, category_id: '', tags: '[]' } await createItem(api, { name: SELLS, price: '70' });
});
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();
}
await api.dispose(); await api.dispose();
}); });
async function register(page: Page) { /**
await page.goto('/register'); * Favorites an item and settles the opt-in modal that follows.
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); *
await page.getByRole('textbox', { name: 'First name' }).fill('Test'); * The alert opt-in is offered on every favorite until it is accepted, so it is
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); * always there to decline. Clicked rather than probed with isVisible(): that
await page.getByLabel('Password').fill(PASSWORD); * check does not wait, so it loses the race with the modal appearing and leaves
await page.getByRole('button', { name: 'Create account' }).click(); * it open to block everything the test does next. Its wrapper also goes on
// Registering now closes the auth modal and returns to the page behind it, so * intercepting pointer events while it fades out, hence waiting for it to go.
// the header rather than the URL is what proves the session exists. The wait */
// is generous because this is a bcrypt round-trip rather than a render. async function favorite(
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); storefront: StorefrontPage,
favoritePrompt: FavoritePrompt,
itemName: string
) {
await storefront.addToFavoritesButton(itemName).click();
await favoritePrompt.declineAlerts();
await expect(favoritePrompt.declineAlertsButton).toBeHidden();
await expect(storefront.removeFromFavoritesButton(itemName)).toBeVisible();
} }
// The session is still resolving for a moment after a remount, and the
// favorites filter deliberately waits it out rather than guessing. Waiting for
// the account link is what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
async function favorite(page: Page, itemName: string) {
await page.getByRole('button', { name: `Add ${itemName} to favorites` }).click();
// The alert opt-in is offered on every favorite until it is accepted, so it
// is always there to decline. Clicked rather than probed with isVisible():
// that check does not wait, so it loses the race with the modal appearing and
// leaves it open to block everything the test does next.
const decline = page.getByRole('dialog').getByRole('button', { name: 'No thanks' });
await decline.click();
// Its wrapper goes on intercepting pointer events while it fades out.
await expect(decline).toBeHidden();
await expect(page.getByRole('button', { name: `Remove ${itemName} from favorites` })).toBeVisible();
}
async function openFilters(page: Page) {
await page.getByRole('button', { name: /Filters/ }).click();
await expect(favoritesSwitch(page)).toBeVisible();
}
const favoritesSwitch = (page: Page) => page.getByRole('switch', { name: 'Only my favorites' });
test.describe('Filtering the storefront by favorites', () => { test.describe('Filtering the storefront by favorites', () => {
test('narrows the grid to favorited items and puts it in the URL', async ({ page }) => { test('narrows the grid to favorited items and puts it in the URL', async ({
await register(page); page,
await gotoStorefrontSignedIn(page); customer,
await favorite(page, KEPT); storefront,
filterDrawer,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await favorite(storefront, favoritePrompt, KEPT);
await openFilters(page); await storefront.openFilters();
await favoritesSwitch(page).click(); await filterDrawer.waitForOpen();
await filterDrawer.favoritesOnlySwitch.click();
// Close the drawer before reading the grid behind it, as the other filter // Close the drawer before reading the grid behind it, as the other filter
// tests do. // tests do.
await page.getByRole('button', { name: 'Close' }).click(); await filterDrawer.close();
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden(); await expect(storefront.addToFavoritesButton(OTHER)).toBeHidden();
// In the URL so the view can be linked, bookmarked, and reloaded. // In the URL so the view can be linked, bookmarked, and reloaded.
await expect(page).toHaveURL(/favorites=1/); await expect(page).toHaveURL(/favorites=1/);
}); });
test('survives a reload, since the URL is the source of truth', async ({ page }) => { test('survives a reload, since the URL is the source of truth', async ({
await register(page); page,
await gotoStorefrontSignedIn(page); customer,
await favorite(page, KEPT); storefront,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await favorite(storefront, favoritePrompt, KEPT);
await page.goto('/?favorites=1'); await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden(); await expect(storefront.addToFavoritesButton(OTHER)).toBeHidden();
}); });
test('shows a removable chip that restores the full catalogue', async ({ page }) => { test('shows a removable chip that restores the full catalogue', async ({
await register(page); page,
await gotoStorefrontSignedIn(page); customer,
await favorite(page, KEPT); storefront,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await favorite(storefront, favoritePrompt, KEPT);
await page.goto('/?favorites=1'); await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden(); await expect(storefront.addToFavoritesButton(OTHER)).toBeHidden();
const chips = page.getByRole('group', { name: 'Active filters' }); await expect(storefront.activeFilters).toContainText('My favorites');
await expect(chips).toContainText('My favorites'); await storefront.removeFilterChip('My favorites').click();
await chips.getByRole('button', { name: 'Remove filter My favorites' }).click();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeVisible(); await expect(storefront.addToFavoritesButton(OTHER)).toBeVisible();
await expect(page).not.toHaveURL(/favorites/); await expect(page).not.toHaveURL(/favorites/);
}); });
test('combines with the price filter rather than replacing it', async ({ page }) => { test('combines with the price filter rather than replacing it', async ({
await register(page); page,
await gotoStorefrontSignedIn(page); customer,
await favorite(page, KEPT); storefront,
await favorite(page, OTHER); favoritePrompt
}) => {
await storefront.gotoSignedIn();
await favorite(storefront, favoritePrompt, KEPT);
await favorite(storefront, favoritePrompt, OTHER);
// Both are favorited; only one is under the price cap. // Both are favorited; only one is under the price cap.
await page.goto('/?favorites=1&max_price=50000'); await page.goto('/?favorites=1&max_price=50000');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible();
await expect(page.getByRole('button', { name: `Remove ${OTHER} from favorites` })).toBeHidden(); await expect(storefront.removeFromFavoritesButton(OTHER)).toBeHidden();
}); });
test('prompts a signed-out visitor to sign in, then applies the filter', async ({ page }) => { test('prompts a signed-out visitor to sign in, then applies the filter', async ({
await page.goto('/'); page,
await openFilters(page); storefront,
await favoritesSwitch(page).click(); filterDrawer,
authModal
}) => {
await storefront.goto();
await storefront.openFilters();
await filterDrawer.waitForOpen();
await filterDrawer.favoritesOnlySwitch.click();
// The same inline prompt the heart and Add to Cart use, rather than an // The same inline prompt the heart and Add to Cart use, rather than an
// empty grid implying the visitor has no favorites. // empty grid implying the visitor has no favorites.
const prompt = page.getByRole('dialog', { name: /Create an account/ }); await expect(authModal.registerDialog).toBeVisible();
await expect(prompt).toBeVisible();
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); await authModal.fillRegistration({
await page.getByRole('textbox', { name: 'First name' }).fill('Test'); email: uniqueEmail('favfilter'),
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); password: PASSWORD,
await prompt.getByLabel('Password').fill(PASSWORD); firstName: 'Test',
await prompt.getByRole('button', { name: 'Create account' }).click(); lastName: 'Customer'
});
await authModal.submitRegistration();
// Signing in resolves the gate and the filter applies on its own — the // Signing in resolves the gate and the filter applies on its own — the
// customer never sets it twice. A brand-new account has no favorites yet. // customer never sets it twice. A brand-new account has no favorites yet.
await expect(page.getByText('No items match these filters')).toBeVisible(); await expect(storefront.noMatchesNotice).toBeVisible({ timeout: 20000 });
await expect(page).toHaveURL(/favorites=1/); await expect(page).toHaveURL(/favorites=1/);
}); });
test('explains itself when a favorites link is opened without a session', async ({ page }) => { test('explains itself when a favorites link is opened without a session', async ({
page,
storefront
}) => {
// A bookmarked filtered view whose session has since expired. The grid must // A bookmarked filtered view whose session has since expired. The grid must
// not claim there are no matching items, which would read as "you have no // not claim there are no matching items, which would read as "you have no
// favorites" rather than "we do not know who you are". // favorites" rather than "we do not know who you are".
await page.goto('/?favorites=1'); await page.goto('/?favorites=1');
await expect(page.getByText('Sign in to see the items you have favorited')).toBeVisible(); await expect(storefront.favoritesNeedSignInNotice).toBeVisible();
await expect(page.getByText('No items match these filters')).toBeHidden(); await expect(storefront.noMatchesNotice).toBeHidden();
}); });
test('keeps showing a favorite after it sells', async ({ page, playwright }) => { test('keeps showing a favorite after it sells', async ({
await register(page); page,
await gotoStorefrontSignedIn(page); playwright,
await favorite(page, SELLS); customer,
storefront,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await favorite(storefront, favoritePrompt, SELLS);
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); const api = await createAdminContext(playwright);
const items = await (await api.get('/api/items')).json(); const items = await (await api.get('/api/items')).json();
const sells = items.find((item: { name: string }) => item.name === SELLS); const sells = items.find((item: { name: string }) => item.name === SELLS);
expect(await (await api.post(`/api/admin/items/${sells.id}/mark-sold`)).ok()).toBeTruthy(); await sellItem(api, sells.id);
await api.dispose(); await api.dispose();
await page.goto('/?favorites=1'); await page.goto('/?favorites=1');
// Hiding it would make an item the customer curated vanish without // Hiding it would make an item the customer curated vanish without
// explanation, right after they were emailed to say it had sold. // explanation, right after they were emailed to say it had sold.
await expect(page.getByRole('button', { name: `Remove ${SELLS} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(SELLS)).toBeVisible();
// Scoped to this item's cell: the ribbon sits outside the card, and other await expect(storefront.gridCell(SELLS)).toContainText('SOLD');
// sold items from earlier runs are on the same page.
await expect(page.locator('.ant-col').filter({ hasText: SELLS })).toContainText('SOLD');
}); });
}); });
+64 -100
View File
@@ -1,83 +1,55 @@
import { test, expect, Page } from './fixtures'; import { test, expect, createAdminContext, createItem, uniqueSuffix, uniqueEmail, PASSWORD } from './fixtures';
const PASSWORD = 'supersecret123'; const ITEM = `Favoritable ${uniqueSuffix()}`;
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const ITEM = `Favoritable ${RUN}`;
const uniqueEmail = () => `fav-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => { test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); const api = await createAdminContext(playwright);
const res = await api.post('/api/admin/items', { await createItem(api, { name: ITEM, price: '60' });
multipart: { name: ITEM, description: '', price: '60', category_id: '', 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();
await api.dispose(); await api.dispose();
}); });
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
// Registering now closes the auth modal and returns to the page behind it, so
// the header rather than the URL is what proves the session exists. The wait
// is generous because this is a bcrypt round-trip rather than a render.
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
}
// Going to the storefront remounts the app, so the session is briefly still
// resolving. The heart deliberately ignores clicks in that window rather than
// wrongly prompting a signed-in customer to sign in, so wait for the header to
// show the account link — which is exactly what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
// The storefront paginates as items accumulate, so find the card by name.
function heart(page: Page, itemName: string) {
return page.getByRole('button', { name: new RegExp(`(Add|Remove) ${itemName}`) });
}
test.describe('Favoriting items', () => { test.describe('Favoriting items', () => {
test('a signed-out visitor is prompted to sign in, and the favorite completes', async ({ page }) => { // Drives the registration form rather than taking the `customer` fixture:
await page.goto('/'); // the claim here is that the favorite a signed-out visitor asked for survives
await heart(page, ITEM).first().click(); // being interrupted by signing up, so the interruption has to be real.
test('a signed-out visitor is prompted to sign in, and the favorite completes', async ({
storefront,
authModal
}) => {
await storefront.goto();
await storefront.favoriteToggle(ITEM).first().click();
// Same inline prompt Add to Cart already uses. // Same inline prompt Add to Cart already uses.
await expect(page.getByRole('dialog')).toBeVisible(); await expect(authModal.registerDialog).toBeVisible();
const email = uniqueEmail(); await authModal.fillRegistration({
await page.getByRole('dialog').getByRole('textbox', { name: 'Email' }).fill(email); email: uniqueEmail('fav'),
await page.getByRole('textbox', { name: 'First name' }).fill('Test'); password: PASSWORD,
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); firstName: 'Test',
await page.getByRole('dialog').getByLabel('Password').fill(PASSWORD); lastName: 'Customer'
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click(); });
await authModal.submitRegistration();
// The favorite the visitor originally asked for is applied on success. // The favorite the visitor originally asked for is applied on success.
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible({ timeout: 20000 });
}); });
test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({ page }) => { test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({
const email = uniqueEmail(); page,
await register(page, email); customer,
storefront,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await storefront.favoriteToggle(ITEM).first().click();
await gotoStorefrontSignedIn(page); await expect(favoritePrompt.dialog).toBeVisible();
await heart(page, ITEM).first().click(); await expect(favoritePrompt.dialog).toContainText('Want to know if this sells?');
const prompt = page.getByRole('dialog');
await expect(prompt).toBeVisible();
await expect(prompt).toContainText('Want to know if this sells?');
// The reason for asking has to be given, not just the ask. // The reason for asking has to be given, not just the ask.
await expect(prompt).toContainText(/one of a kind/i); await expect(favoritePrompt.dialog).toContainText(/one of a kind/i);
await expect(prompt).toContainText(/separate from any marketing/i); await expect(favoritePrompt.dialog).toContainText(/separate from any marketing/i);
await prompt.getByRole('button', { name: 'Yes, email me' }).click(); await favoritePrompt.acceptAlerts();
await expect(page.getByText('We will let you know')).toBeVisible(); await expect(page.getByText('We will let you know')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json(); const me = await (await page.request.get('/api/customers/me')).json();
@@ -86,15 +58,17 @@ test.describe('Favoriting items', () => {
expect(me.marketing_consent).toBe(false); expect(me.marketing_consent).toBe(false);
}); });
test('declining the opt-in still keeps the favorite', async ({ page }) => { test('declining the opt-in still keeps the favorite', async ({
const email = uniqueEmail(); page,
await register(page, email); customer,
storefront,
favoritePrompt
}) => {
await storefront.gotoSignedIn();
await storefront.favoriteToggle(ITEM).first().click();
await favoritePrompt.declineAlerts();
await gotoStorefrontSignedIn(page); await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible();
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json(); const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toHaveLength(1); expect(favorites).toHaveLength(1);
@@ -103,49 +77,39 @@ test.describe('Favoriting items', () => {
expect(me.favorite_alerts).toBe(false); expect(me.favorite_alerts).toBe(false);
}); });
test('unfavoriting removes it', async ({ page }) => { test('unfavoriting removes it', async ({ page, customer, storefront, favoritePrompt }) => {
const email = uniqueEmail(); await storefront.gotoSignedIn();
await register(page, email); await storefront.favoriteToggle(ITEM).first().click();
await favoritePrompt.declineAlerts();
await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible();
await gotoStorefrontSignedIn(page); await storefront.removeFromFavoritesButton(ITEM).click();
await heart(page, ITEM).first().click(); await expect(storefront.addToFavoritesButton(ITEM)).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
await page.getByRole('button', { name: `Remove ${ITEM} from favorites` }).click();
await expect(page.getByRole('button', { name: `Add ${ITEM} to favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json(); const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toEqual([]); expect(favorites).toEqual([]);
}); });
test('favorites survive a reload', async ({ page }) => { test('favorites survive a reload', async ({ page, customer, storefront, favoritePrompt }) => {
const email = uniqueEmail(); await storefront.gotoSignedIn();
await register(page, email); await storefront.favoriteToggle(ITEM).first().click();
await favoritePrompt.declineAlerts();
await gotoStorefrontSignedIn(page); await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible();
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
// Proves it was stored server-side rather than held in component state. // Proves it was stored server-side rather than held in component state.
await page.reload(); await page.reload();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible(); await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible();
}); });
test('the account page can turn the alerts off again', async ({ page }) => { test('the account page can turn the alerts off again', async ({ page, customer, accountModal }) => {
const email = uniqueEmail();
await register(page, email);
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } }); await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
await page.goto('/account'); await accountModal.open();
// Scoped to the account modal. The storefront renders behind it and has a await expect(
// theme switch of its own, so an unscoped switch locator only picks the accountModal.dialog.getByText('Email me when an item I favorited is sold')
// right control by DOM accident. ).toBeVisible();
const account = page.getByRole('dialog', { name: 'My Account' });
await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible();
await account.getByRole('switch').last().click(); await accountModal.favoriteAlertsSwitch.click();
await expect(page.getByText('Turned off')).toBeVisible(); await expect(page.getByText('Turned off')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json(); const me = await (await page.request.get('/api/customers/me')).json();
+25
View File
@@ -9,6 +9,9 @@ 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 { FilterDrawer } from './pages/FilterDrawer';
import { FavoritePrompt } from './pages/FavoritePrompt';
import { OrdersPage } from './pages/OrdersPage';
import { AdminInventory } from './pages/AdminInventory';
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,
@@ -16,6 +19,16 @@ import { uniqueEmail } from './support/api';
// The explicit `test` below wins over the star export. // The explicit `test` below wins over the star export.
export * from '@playwright/test'; export * from '@playwright/test';
export * from './support/api'; export * from './support/api';
export { Header } from './pages/Header';
export { AuthModal } from './pages/AuthModal';
export { AccountModal } from './pages/AccountModal';
export { StorefrontPage } from './pages/StorefrontPage';
export { AdminPage } from './pages/AdminPage';
export { PasswordResetPages } from './pages/PasswordResetPages';
export { FilterDrawer } from './pages/FilterDrawer';
export { FavoritePrompt } from './pages/FavoritePrompt';
export { OrdersPage } from './pages/OrdersPage';
export { AdminInventory } from './pages/AdminInventory';
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output'); const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
const collectingCoverage = process.env.COVERAGE === 'true'; const collectingCoverage = process.env.COVERAGE === 'true';
@@ -42,6 +55,9 @@ interface Pages {
admin: AdminPage; admin: AdminPage;
passwordReset: PasswordResetPages; passwordReset: PasswordResetPages;
filterDrawer: FilterDrawer; filterDrawer: FilterDrawer;
favoritePrompt: FavoritePrompt;
orders: OrdersPage;
adminInventory: AdminInventory;
} }
interface Data { interface Data {
@@ -98,6 +114,15 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
filterDrawer: async ({ page }, use) => { filterDrawer: async ({ page }, use) => {
await use(new FilterDrawer(page)); await use(new FilterDrawer(page));
}, },
favoritePrompt: async ({ page }, use) => {
await use(new FavoritePrompt(page));
},
orders: async ({ page }, use) => {
await use(new OrdersPage(page));
},
adminInventory: async ({ page }, use) => {
await use(new AdminInventory(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 });
+28 -44
View File
@@ -1,27 +1,8 @@
import { test, expect, Page } from './fixtures'; import { test, expect } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `orders-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// The generous wait is the same one the other account specs use: registration is
// a bcrypt round-trip rather than a render, and runs past Playwright's 5s
// default when the suite's workers all register at once.
async function registerCustomer(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
return email;
}
test.describe('Order history has a page of its own', () => { test.describe('Order history has a page of its own', () => {
test('a signed-out visitor is sent to sign in', async ({ page }) => { test('a signed-out visitor is sent to sign in', async ({ page, orders }) => {
await page.goto('/orders'); await orders.goto();
await expect(page).toHaveURL(/\/login/, { timeout: 20000 }); await expect(page).toHaveURL(/\/login/, { timeout: 20000 });
}); });
@@ -29,43 +10,46 @@ test.describe('Order history has a page of its own', () => {
// A page, not a modal: no dialog, and the storefront is not rendered behind // A page, not a modal: no dialog, and the storefront is not rendered behind
// it. Putting /orders in MODAL_ROUTES would quietly undo the whole change, // it. Putting /orders in MODAL_ROUTES would quietly undo the whole change,
// and this is what would catch it. // and this is what would catch it.
test('renders as a page rather than a modal over the storefront', async ({ page }) => { test('renders as a page rather than a modal over the storefront', async ({
await registerCustomer(page); customer,
await page.goto('/orders'); orders,
header
}) => {
await orders.goto();
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible(); await expect(orders.heading).toBeVisible({ timeout: 20000 });
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(orders.anyDialog).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeHidden(); await expect(header.siteTitle).toBeHidden();
}); });
test('says so when there are no orders, rather than showing an empty table', async ({ page }) => { test('says so when there are no orders, rather than showing an empty table', async ({
await registerCustomer(page); customer,
await page.goto('/orders'); orders
}) => {
await orders.goto();
await expect(page.getByText('No orders yet')).toBeVisible(); await expect(orders.noOrdersNotice).toBeVisible({ timeout: 20000 });
await expect(page.getByRole('button', { name: 'Continue Shopping' })).toBeVisible(); await expect(orders.continueShoppingButton).toBeVisible();
}); });
test('Back to Shop returns to the storefront', async ({ page }) => { test('Back to Shop returns to the storefront', async ({ page, customer, orders, header }) => {
await registerCustomer(page); await orders.goto();
await page.goto('/orders'); await expect(orders.heading).toBeVisible({ timeout: 20000 });
await page.getByRole('button', { name: 'Back to Shop' }).click(); await orders.backToShopButton.click();
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); await expect(header.siteTitle).toBeVisible();
}); });
// The account view is where a customer looks for their orders, so the route // The account view is where a customer looks for their orders, so the route
// out of it is the part that has to keep working now the table has gone. // out of it is the part that has to keep working now the table has gone.
test('My Account links to it', async ({ page }) => { test('My Account links to it', async ({ page, customer, accountModal, orders }) => {
await registerCustomer(page); await accountModal.open();
await page.goto('/account');
await page.getByRole('dialog', { name: 'My Account' }) await accountModal.orderHistoryButton.click();
.getByRole('button', { name: 'View order history' }).click();
await expect(page).toHaveURL(/\/orders/); await expect(page).toHaveURL(/\/orders/);
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible(); await expect(orders.heading).toBeVisible();
}); });
}); });
+3
View File
@@ -17,6 +17,8 @@ export class AccountModal {
readonly closeButton: Locator; readonly closeButton: Locator;
readonly resendVerificationButton: Locator; readonly resendVerificationButton: Locator;
readonly themeSwitches: Locator; readonly themeSwitches: Locator;
/** The second switch in the modal; the first is the theme. */
readonly favoriteAlertsSwitch: Locator;
readonly notVerifiedNotice: Locator; readonly notVerifiedNotice: Locator;
readonly firstName: Locator; readonly firstName: Locator;
@@ -44,6 +46,7 @@ export class AccountModal {
this.closeButton = this.dialog.getByRole('button', { name: 'Close' }); this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' }); this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
this.themeSwitches = this.dialog.getByRole('switch'); this.themeSwitches = this.dialog.getByRole('switch');
this.favoriteAlertsSwitch = this.dialog.getByRole('switch').last();
this.notVerifiedNotice = this.dialog.getByText('Email not verified'); this.notVerifiedNotice = this.dialog.getByText('Email not verified');
this.firstName = this.dialog.getByLabel('First name', { exact: true }); this.firstName = this.dialog.getByLabel('First name', { exact: true });
@@ -0,0 +1,59 @@
import { Locator, Page } from '@playwright/test';
/**
* The admin inventory tab: the item table and the form that adds to it.
*
* Rows are located by the item's name rather than by index. The table is shared
* with every other run's items and is paginated and sortable, so an index means
* a different row depending on what else exists.
*/
export class AdminInventory {
readonly addItemButton: Locator;
readonly name: Locator;
readonly price: Locator;
readonly category: Locator;
readonly saveButton: Locator;
constructor(private readonly page: Page) {
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.name = page.getByLabel('Name');
this.price = page.getByLabel('Price (USD)');
this.category = page.getByLabel('Category', { exact: true });
this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
}
row(itemName: string): Locator {
return this.page.getByRole('row').filter({ hasText: itemName });
}
/** The status chip in an item's row — PENDING, AVAILABLE, RESERVED, SOLD. */
status(itemName: string, status: string): Locator {
return this.row(itemName).getByText(status);
}
publishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Publish' });
}
unpublishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Unpublish' });
}
/**
* The preview drawer, opened by clicking an item's name.
*
* It renders the item as the storefront card will, which is the question an
* admin is asking of a pending item — a customer never sees the pending state.
*/
previewDrawer(itemName: string): Locator {
return this.page.getByRole('dialog', { name: `Preview: ${itemName}` });
}
async openPreview(itemName: string): Promise<void> {
await this.page.getByRole('button', { name: itemName }).click();
}
async openItemForm(): Promise<void> {
await this.addItemButton.click();
}
}
@@ -0,0 +1,34 @@
import { Locator, Page } from '@playwright/test';
/**
* The modal offered when a customer favorites an item: an opt-in to hear if it
* sells, with the reason for asking.
*
* `getByRole('dialog')` unqualified is deliberate and safe — the storefront
* shows one modal at a time, and a signed-out visitor clicking the heart gets
* the auth prompt instead, which is a different object.
*
* The opt-in is deliberately not the marketing consent. Accepting item alerts
* must not sign anyone up for marketing, which is why the tests read both flags
* back off the API rather than trusting the copy.
*/
export class FavoritePrompt {
readonly dialog: Locator;
readonly acceptAlertsButton: Locator;
readonly declineAlertsButton: Locator;
constructor(page: Page) {
this.dialog = page.getByRole('dialog');
this.acceptAlertsButton = this.dialog.getByRole('button', { name: 'Yes, email me' });
this.declineAlertsButton = this.dialog.getByRole('button', { name: 'No thanks' });
}
async acceptAlerts(): Promise<void> {
await this.acceptAlertsButton.click();
}
/** Declining keeps the favorite; only the alerts are refused. */
async declineAlerts(): Promise<void> {
await this.declineAlertsButton.click();
}
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'; import { Locator, Page, expect } from '@playwright/test';
/** /**
* The storefront's filter drawer. * The storefront's filter drawer.
@@ -16,12 +16,14 @@ export class FilterDrawer {
readonly maximumPrice: Locator; readonly maximumPrice: Locator;
readonly closeButton: Locator; readonly closeButton: Locator;
readonly clearAllButton: Locator; readonly clearAllButton: Locator;
readonly favoritesOnlySwitch: Locator;
constructor(private readonly page: Page) { constructor(private readonly page: Page) {
this.minimumPrice = page.getByLabel('Minimum price'); this.minimumPrice = page.getByLabel('Minimum price');
this.maximumPrice = page.getByLabel('Maximum price'); this.maximumPrice = page.getByLabel('Maximum price');
this.closeButton = page.getByRole('button', { name: 'Close' }); this.closeButton = page.getByRole('button', { name: 'Close' });
this.clearAllButton = page.getByRole('button', { name: 'Clear all' }); this.clearAllButton = page.getByRole('button', { name: 'Clear all' });
this.favoritesOnlySwitch = page.getByRole('switch', { name: 'Only my favorites' });
} }
category(name: string): Locator { category(name: string): Locator {
@@ -45,6 +47,16 @@ export class FilterDrawer {
if (maximum !== undefined) await this.maximumPrice.fill(maximum); if (maximum !== undefined) await this.maximumPrice.fill(maximum);
} }
/**
* Opens the drawer and waits for its contents.
*
* The wait is the action's contract: the drawer animates in, and a click
* landing mid-animation hits the backdrop instead of the control.
*/
async waitForOpen(): Promise<void> {
await expect(this.favoritesOnlySwitch).toBeVisible();
}
async close(): Promise<void> { async close(): Promise<void> {
await this.closeButton.click(); await this.closeButton.click();
} }
+29
View File
@@ -0,0 +1,29 @@
import { Locator, Page } from '@playwright/test';
/**
* Order history, which is a page rather than a modal.
*
* That distinction is the point of the specs using this: /orders is
* deliberately not in MODAL_ROUTES, so there is no dialog and the storefront is
* not rendered behind it. Putting it back would quietly undo that, which is why
* the absence of a dialog is asserted rather than assumed.
*/
export class OrdersPage {
readonly heading: Locator;
readonly noOrdersNotice: Locator;
readonly continueShoppingButton: Locator;
readonly backToShopButton: Locator;
readonly anyDialog: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Order History' });
this.noOrdersNotice = page.getByText('No orders yet');
this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' });
this.backToShopButton = page.getByRole('button', { name: 'Back to Shop' });
this.anyDialog = page.getByRole('dialog');
}
async goto(): Promise<void> {
await this.page.goto('/orders');
}
}
+49 -2
View File
@@ -20,6 +20,10 @@ export class StorefrontPage {
* all" of its own, so an unscoped one matches both. * all" of its own, so an unscoped one matches both.
*/ */
readonly activeFilters: Locator; readonly activeFilters: Locator;
/** Shown instead of the grid when filters match nothing. */
readonly noMatchesNotice: Locator;
/** Shown instead of that when a favorites filter is set with no session. */
readonly favoritesNeedSignInNotice: Locator;
/** /**
* The three things the catalogue can say instead of listing items. Named * The three things the catalogue can say instead of listing items. Named
@@ -40,6 +44,8 @@ export class StorefrontPage {
this.filtersButton = page.getByRole('button', { name: /Filters/ }); this.filtersButton = page.getByRole('button', { name: /Filters/ });
this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' }); this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' });
this.activeFilters = page.getByRole('group', { name: 'Active filters' }); this.activeFilters = page.getByRole('group', { name: 'Active filters' });
this.noMatchesNotice = page.getByText('No items match these filters');
this.favoritesNeedSignInNotice = page.getByText('Sign in to see the items you have favorited');
this.emptyNotice = page.getByText('No items yet'); this.emptyNotice = page.getByText('No items yet');
this.loadFailureNotice = page.getByText("Couldn't load items"); this.loadFailureNotice = page.getByText("Couldn't load items");
@@ -75,15 +81,56 @@ export class StorefrontPage {
return this.card(name).getByRole('button', { name: 'Add to Cart' }); return this.card(name).getByRole('button', { name: 'Add to Cart' });
} }
/** The heart. Named for what it does rather than what it looks like. */ /**
* The heart, in whichever state it is currently in.
*
* Located page-wide rather than inside the card: the storefront paginates as
* items accumulate, and the control is named for the item anyway, so scoping
* to a card buys nothing and breaks when the card is on another page.
*/
favoriteToggle(name: string): Locator { favoriteToggle(name: string): Locator {
return this.card(name).getByRole('button', { name: /favorite/i }); return this.page.getByRole('button', { name: new RegExp(`(Add|Remove) ${name}`) });
}
/**
* The two settled states, named separately because the tests assert on the
* transition between them — a favorite that took is a "Remove" control, and
* one that did not is still an "Add".
*/
addToFavoritesButton(name: string): Locator {
return this.page.getByRole('button', { name: `Add ${name} to favorites` });
}
removeFromFavoritesButton(name: string): Locator {
return this.page.getByRole('button', { name: `Remove ${name} from favorites` });
} }
async openFilters(): Promise<void> { async openFilters(): Promise<void> {
await this.filtersButton.click(); await this.filtersButton.click();
} }
/**
* The item's whole grid cell rather than its card.
*
* The SOLD ribbon is rendered outside the card, so a test asserting on it has
* to reach the cell — and it has to be scoped to this item, because sold items
* from earlier runs share the page.
*/
gridCell(name: string): Locator {
return this.page.locator('.ant-col').filter({ hasText: name });
}
/**
* The availability segment.
*
* antd's Segmented hides the real radio behind a styled label, so the input is
* found by role but cannot be clicked. The label carries a title attribute,
* which is the same handle this suite uses for antd Select options.
*/
async chooseAvailability(label: string): Promise<void> {
await this.page.getByTitle(label, { exact: true }).click();
}
removeFilterChip(name: string): Locator { removeFilterChip(name: string): Locator {
return this.page.getByRole('button', { name: `Remove filter ${name}` }); return this.page.getByRole('button', { name: `Remove filter ${name}` });
} }
+52 -39
View File
@@ -1,75 +1,88 @@
import { test, expect } from './fixtures'; import { test, expect, uniqueSuffix, publishItem } from './fixtures';
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; /**
* Creates an item and leaves it as it arrives — pending.
// Creates an item and leaves it as it arrives — pending. Deliberately does not *
// publish, unlike the other specs' fixtures, because the staged state is what * Deliberately does not publish, unlike the shared `createItem` helper, because
// is being tested here. * the staged state is the thing under test here. The status assertion is the
async function createStagedItem(page: import('@playwright/test').Page, name: string) { * whole premise: creating something does not publish it.
const created = await page.request.post('/api/admin/items', { */
async function createStagedItem(
request: import('@playwright/test').APIRequestContext,
name: string
): Promise<number> {
const created = await request.post('/api/admin/items', {
multipart: { name, description: '', price: '55', category_id: '', tags: '[]' } multipart: { name, description: '', price: '55', category_id: '', tags: '[]' }
}); });
expect(created.ok()).toBeTruthy(); expect(created.ok()).toBeTruthy();
const body = await created.json(); const body = await created.json();
// The whole premise: creating something does not publish it.
expect(body.status).toBe('pending'); expect(body.status).toBe('pending');
return body.id as number; return body.id as number;
} }
const rowFor = (page: import('@playwright/test').Page, name: string) =>
page.getByRole('row').filter({ hasText: name });
test.describe('Staging an item until it is published', () => { test.describe('Staging an item until it is published', () => {
test('a new item is held back from the storefront until published', async ({ page }) => { test('a new item is held back from the storefront until published', async ({
const name = `Staged ${suffix()}`; page,
await createStagedItem(page, name); storefront,
admin,
adminInventory
}) => {
const name = `Staged ${uniqueSuffix()}`;
await createStagedItem(page.request, name);
// Not in the catalogue while pending. // Not in the catalogue while pending.
await page.goto('/'); await storefront.goto();
await expect(page.getByText(name)).toHaveCount(0); await expect(page.getByText(name)).toHaveCount(0);
// It is in the admin, marked as pending. // It is in the admin, marked as pending.
await page.goto('/admin'); await admin.goto();
const row = rowFor(page, name); await expect(adminInventory.row(name)).toBeVisible();
await expect(row).toBeVisible(); await expect(adminInventory.status(name, 'PENDING')).toBeVisible();
await expect(row.getByText('PENDING')).toBeVisible();
await row.getByRole('button', { name: 'Publish' }).click(); await adminInventory.publishButton(name).click();
await expect(row.getByText('AVAILABLE')).toBeVisible(); await expect(adminInventory.status(name, 'AVAILABLE')).toBeVisible();
// And now a customer can see it. // And now a customer can see it.
await page.goto('/'); await storefront.goto();
await expect(page.getByText(name)).toBeVisible(); await expect(page.getByText(name)).toBeVisible();
}); });
test('publishing can be undone while nobody is holding the item', async ({ page }) => { test('publishing can be undone while nobody is holding the item', async ({
const name = `Staged ${suffix()}`; page,
const id = await createStagedItem(page, name); storefront,
expect((await page.request.post(`/api/admin/items/${id}/mark-available`)).ok()).toBeTruthy(); admin,
adminInventory
}) => {
const name = `Staged ${uniqueSuffix()}`;
const id = await createStagedItem(page.request, name);
await publishItem(page.request, id);
await page.goto('/'); await storefront.goto();
await expect(page.getByText(name)).toBeVisible(); await expect(page.getByText(name)).toBeVisible();
await page.goto('/admin'); await admin.goto();
const row = rowFor(page, name); await adminInventory.unpublishButton(name).click();
await row.getByRole('button', { name: 'Unpublish' }).click(); await expect(adminInventory.status(name, 'PENDING')).toBeVisible();
await expect(row.getByText('PENDING')).toBeVisible();
await page.goto('/'); await storefront.goto();
await expect(page.getByText(name)).toHaveCount(0); await expect(page.getByText(name)).toHaveCount(0);
}); });
// The two features together, which is the reason for wanting both: a staged // The two features together, which is the reason for wanting both: a staged
// item is previewed as it will look once live, because a customer never sees // item is previewed as it will look once live, because a customer never sees
// the pending state and "how will this look" is the question being asked. // the pending state and "how will this look" is the question being asked.
test('a pending item previews as it will look once published', async ({ page }) => { test('a pending item previews as it will look once published', async ({
const name = `Staged ${suffix()}`; page,
await createStagedItem(page, name); admin,
adminInventory
}) => {
const name = `Staged ${uniqueSuffix()}`;
await createStagedItem(page.request, name);
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name }).click(); await adminInventory.openPreview(name);
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); const drawer = adminInventory.previewDrawer(name);
await expect(drawer).toBeVisible(); await expect(drawer).toBeVisible();
await expect(drawer.getByText('$55.00')).toBeVisible(); await expect(drawer.getByText('$55.00')).toBeVisible();
// The live card's action, not a pending placeholder. // The live card's action, not a pending placeholder.
+35 -58
View File
@@ -1,44 +1,21 @@
import { test, expect } from './fixtures'; import { test, expect, createAdminContext, createItem, sellItem, uniqueSuffix } from './fixtures';
// The storefront shows every item ever seeded and the e2e database is not reset // The storefront shows every item ever seeded and the e2e database is not reset
// between runs, so every fixture carries a unique run id and assertions name // between runs, so every fixture carries a unique run id and assertions name
// only the items this run created. // only the items this run created.
const RUN = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const RUN = `s${uniqueSuffix()}`;
const NAMES = { const NAMES = {
available: `Available piece ${RUN}`, available: `Available piece ${RUN}`,
sold: `Sold piece ${RUN}` sold: `Sold piece ${RUN}`
}; };
// Scoped to the item's own grid cell: the SOLD ribbon sits outside the card,
// and other runs' sold items share the page.
const cell = (page: import('@playwright/test').Page, name: string) =>
page.locator('.ant-col').filter({ hasText: name });
// antd Segmented hides the real radio input behind a styled label, so the input
// is found by role but cannot be clicked. The label carries a title attribute,
// which is the same handle this suite already uses for antd Select options.
// The input is still the right thing to assert checked-ness on: toBeChecked
// does not require visibility.
async function chooseAvailability(page: import('@playwright/test').Page, label: string) {
await page.getByTitle(label, { exact: true }).click();
}
test.beforeAll(async ({ playwright }) => { test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); const api = await createAdminContext(playwright);
for (const name of [NAMES.available, NAMES.sold]) { await createItem(api, { name: NAMES.available, price: '250.00' });
const res = await api.post('/api/admin/items', { const sold = await createItem(api, { name: NAMES.sold, price: '250.00' });
multipart: { name, description: '', price: '250.00' } await sellItem(api, sold.id);
});
expect(res.ok()).toBeTruthy();
const { id } = await res.json();
// Items arrive pending since #90, so publishing is what makes them public.
expect((await api.post(`/api/admin/items/${id}/mark-available`)).ok()).toBeTruthy();
if (name === NAMES.sold) {
expect((await api.post(`/api/admin/items/${id}/mark-sold`)).ok()).toBeTruthy();
}
}
await api.dispose(); await api.dispose();
}); });
@@ -46,68 +23,68 @@ test.beforeAll(async ({ playwright }) => {
test.describe('Filtering the storefront by availability', () => { test.describe('Filtering the storefront by availability', () => {
// The change customers actually see. Asserted on a bare visit rather than on // The change customers actually see. Asserted on a bare visit rather than on
// a parameter, because the default is what changed for everyone. // a parameter, because the default is what changed for everyone.
test('hides sold pieces by default', async ({ page }) => { test('hides sold pieces by default', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await expect(cell(page, NAMES.available)).toBeVisible(); await expect(storefront.gridCell(NAMES.available)).toBeVisible();
await expect(cell(page, NAMES.sold)).toHaveCount(0); await expect(storefront.gridCell(NAMES.sold)).toHaveCount(0);
}); });
test('All brings them back, ribbon and all', async ({ page }) => { test('All brings them back, ribbon and all', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await chooseAvailability(page, 'All'); await storefront.chooseAvailability('All');
await expect(cell(page, NAMES.sold)).toBeVisible(); await expect(storefront.gridCell(NAMES.sold)).toBeVisible();
await expect(cell(page, NAMES.sold)).toContainText('SOLD'); await expect(storefront.gridCell(NAMES.sold)).toContainText('SOLD');
await expect(cell(page, NAMES.available)).toBeVisible(); await expect(storefront.gridCell(NAMES.available)).toBeVisible();
}); });
test('Sold shows only the sold ones', async ({ page }) => { test('Sold shows only the sold ones', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await chooseAvailability(page, 'Sold'); await storefront.chooseAvailability('Sold');
await expect(cell(page, NAMES.sold)).toBeVisible(); await expect(storefront.gridCell(NAMES.sold)).toBeVisible();
await expect(cell(page, NAMES.available)).toHaveCount(0); await expect(storefront.gridCell(NAMES.available)).toHaveCount(0);
}); });
// The filter is view state that belongs in the URL, like every other filter // The filter is view state that belongs in the URL, like every other filter
// here, so a chosen view can be linked and survives a reload. // here, so a chosen view can be linked and survives a reload.
test('the choice survives a reload, because it lives in the URL', async ({ page }) => { test('the choice survives a reload, because it lives in the URL', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await chooseAvailability(page, 'All'); await storefront.chooseAvailability('All');
await expect(cell(page, NAMES.sold)).toBeVisible(); await expect(storefront.gridCell(NAMES.sold)).toBeVisible();
await page.reload(); await page.reload();
await expect(cell(page, NAMES.sold)).toBeVisible(); await expect(storefront.gridCell(NAMES.sold)).toBeVisible();
await expect(page.getByRole('radio', { name: 'All' })).toBeChecked(); await expect(page.getByRole('radio', { name: 'All' })).toBeChecked();
}); });
// Not sold is the default, so it is held as "no preference" rather than as an // Not sold is the default, so it is held as "no preference" rather than as an
// explicit list. Putting it in the URL would make the default look like a // explicit list. Putting it in the URL would make the default look like a
// choice somebody made, and would show it in the Filters (N) count. // choice somebody made, and would show it in the Filters (N) count.
test('returning to Not sold leaves no status in the URL', async ({ page }) => { test('returning to Not sold leaves no status in the URL', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await chooseAvailability(page, 'All'); await storefront.chooseAvailability('All');
await expect(page).toHaveURL(/status=/); await expect(page).toHaveURL(/status=/);
await chooseAvailability(page, 'Not sold'); await storefront.chooseAvailability('Not sold');
await expect(page).not.toHaveURL(/status=/); await expect(page).not.toHaveURL(/status=/);
await expect(cell(page, NAMES.sold)).toHaveCount(0); await expect(storefront.gridCell(NAMES.sold)).toHaveCount(0);
}); });
// The control sits beside the Filters button rather than inside the drawer, // The control sits beside the Filters button rather than inside the drawer,
// so its state must not be counted as one of the drawer's filters. // so its state must not be counted as one of the drawer's filters.
test('does not inflate the Filters count', async ({ page }) => { test('does not inflate the Filters count', async ({ page, storefront }) => {
await page.goto('/'); await storefront.goto();
await chooseAvailability(page, 'Sold'); await storefront.chooseAvailability('Sold');
// Matched loosely and asserted on the text, because antd's icon contributes // Matched loosely and asserted on the text, because antd's icon contributes
// its own aria-label to the button's accessible name. The text is the part // its own aria-label to the button's accessible name. The text is the part
// that would gain a "(1)" if status were counted as a drawer filter. // that would gain a "(1)" if status were counted as a drawer filter.
await expect(page.getByRole('button', { name: /Filters/ })).toHaveText('Filters'); await expect(storefront.filtersButton).toHaveText('Filters');
}); });
}); });