diff --git a/frontend/tests/e2e/favorites-filter.spec.ts b/frontend/tests/e2e/favorites-filter.spec.ts index b85d7ec..70197a0 100644 --- a/frontend/tests/e2e/favorites-filter.spec.ts +++ b/frontend/tests/e2e/favorites-filter.spec.ts @@ -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 // 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 OTHER = `Other ${RUN}`; // 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. const SELLS = `Sells ${RUN}`; -const uniqueEmail = () => `favfilter-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - test.beforeAll(async ({ playwright }) => { - const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); - // Two items, priced apart so one test can prove favorites combines with the - // price filter rather than replacing it. - for (const [name, price] of [[KEPT, '60'], [OTHER, '900'], [SELLS, '70']] as const) { - const res = await api.post('/api/admin/items', { - multipart: { name, description: '', price, 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(); - } + const api = await createAdminContext(playwright); + // Priced apart so one test can prove favorites combines with the price filter + // rather than replacing it. + await createItem(api, { name: KEPT, price: '60' }); + await createItem(api, { name: OTHER, price: '900' }); + await createItem(api, { name: SELLS, price: '70' }); await api.dispose(); }); -async function register(page: Page) { - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); - 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 }); +/** + * Favorites an item and settles the opt-in modal that follows. + * + * 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. Its wrapper also goes on + * intercepting pointer events while it fades out, hence waiting for it to go. + */ +async function favorite( + 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('narrows the grid to favorited items and puts it in the URL', async ({ page }) => { - await register(page); - await gotoStorefrontSignedIn(page); - await favorite(page, KEPT); + test('narrows the grid to favorited items and puts it in the URL', async ({ + page, + customer, + storefront, + filterDrawer, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await favorite(storefront, favoritePrompt, KEPT); - await openFilters(page); - await favoritesSwitch(page).click(); + await storefront.openFilters(); + await filterDrawer.waitForOpen(); + await filterDrawer.favoritesOnlySwitch.click(); // Close the drawer before reading the grid behind it, as the other filter // 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(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden(); + await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible(); + await expect(storefront.addToFavoritesButton(OTHER)).toBeHidden(); // In the URL so the view can be linked, bookmarked, and reloaded. await expect(page).toHaveURL(/favorites=1/); }); - test('survives a reload, since the URL is the source of truth', async ({ page }) => { - await register(page); - await gotoStorefrontSignedIn(page); - await favorite(page, KEPT); + test('survives a reload, since the URL is the source of truth', async ({ + page, + customer, + storefront, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await favorite(storefront, favoritePrompt, KEPT); await page.goto('/?favorites=1'); - await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible(); - await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden(); + await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible(); + await expect(storefront.addToFavoritesButton(OTHER)).toBeHidden(); }); - test('shows a removable chip that restores the full catalogue', async ({ page }) => { - await register(page); - await gotoStorefrontSignedIn(page); - await favorite(page, KEPT); + test('shows a removable chip that restores the full catalogue', async ({ + page, + customer, + storefront, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await favorite(storefront, favoritePrompt, KEPT); 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(chips).toContainText('My favorites'); - await chips.getByRole('button', { name: 'Remove filter My favorites' }).click(); + await expect(storefront.activeFilters).toContainText('My favorites'); + await storefront.removeFilterChip('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/); }); - test('combines with the price filter rather than replacing it', async ({ page }) => { - await register(page); - await gotoStorefrontSignedIn(page); - await favorite(page, KEPT); - await favorite(page, OTHER); + test('combines with the price filter rather than replacing it', async ({ + page, + customer, + storefront, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await favorite(storefront, favoritePrompt, KEPT); + await favorite(storefront, favoritePrompt, OTHER); // Both are favorited; only one is under the price cap. await page.goto('/?favorites=1&max_price=50000'); - await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible(); - await expect(page.getByRole('button', { name: `Remove ${OTHER} from favorites` })).toBeHidden(); + await expect(storefront.removeFromFavoritesButton(KEPT)).toBeVisible(); + await expect(storefront.removeFromFavoritesButton(OTHER)).toBeHidden(); }); - test('prompts a signed-out visitor to sign in, then applies the filter', async ({ page }) => { - await page.goto('/'); - await openFilters(page); - await favoritesSwitch(page).click(); + test('prompts a signed-out visitor to sign in, then applies the filter', async ({ + page, + storefront, + 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 // empty grid implying the visitor has no favorites. - const prompt = page.getByRole('dialog', { name: /Create an account/ }); - await expect(prompt).toBeVisible(); + await expect(authModal.registerDialog).toBeVisible(); - await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); - await page.getByRole('textbox', { name: 'First name' }).fill('Test'); - await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); - await prompt.getByLabel('Password').fill(PASSWORD); - await prompt.getByRole('button', { name: 'Create account' }).click(); + await authModal.fillRegistration({ + email: uniqueEmail('favfilter'), + password: PASSWORD, + firstName: 'Test', + lastName: 'Customer' + }); + await authModal.submitRegistration(); // 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. - await expect(page.getByText('No items match these filters')).toBeVisible(); + await expect(storefront.noMatchesNotice).toBeVisible({ timeout: 20000 }); 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 // not claim there are no matching items, which would read as "you have no // favorites" rather than "we do not know who you are". await page.goto('/?favorites=1'); - await expect(page.getByText('Sign in to see the items you have favorited')).toBeVisible(); - await expect(page.getByText('No items match these filters')).toBeHidden(); + await expect(storefront.favoritesNeedSignInNotice).toBeVisible(); + await expect(storefront.noMatchesNotice).toBeHidden(); }); - test('keeps showing a favorite after it sells', async ({ page, playwright }) => { - await register(page); - await gotoStorefrontSignedIn(page); - await favorite(page, SELLS); + test('keeps showing a favorite after it sells', async ({ + page, + playwright, + 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 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 page.goto('/?favorites=1'); // Hiding it would make an item the customer curated vanish without // explanation, right after they were emailed to say it had sold. - await expect(page.getByRole('button', { name: `Remove ${SELLS} from favorites` })).toBeVisible(); - // Scoped to this item's cell: the ribbon sits outside the card, and other - // sold items from earlier runs are on the same page. - await expect(page.locator('.ant-col').filter({ hasText: SELLS })).toContainText('SOLD'); + await expect(storefront.removeFromFavoritesButton(SELLS)).toBeVisible(); + await expect(storefront.gridCell(SELLS)).toContainText('SOLD'); }); }); diff --git a/frontend/tests/e2e/favorites.spec.ts b/frontend/tests/e2e/favorites.spec.ts index 8c5f429..402272d 100644 --- a/frontend/tests/e2e/favorites.spec.ts +++ b/frontend/tests/e2e/favorites.spec.ts @@ -1,83 +1,55 @@ -import { test, expect, Page } from './fixtures'; +import { test, expect, createAdminContext, createItem, uniqueSuffix, uniqueEmail, PASSWORD } from './fixtures'; -const PASSWORD = 'supersecret123'; -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`; +const ITEM = `Favoritable ${uniqueSuffix()}`; test.beforeAll(async ({ playwright }) => { - const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); - const res = await api.post('/api/admin/items', { - 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(); + const api = await createAdminContext(playwright); + await createItem(api, { name: ITEM, price: '60' }); 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('a signed-out visitor is prompted to sign in, and the favorite completes', async ({ page }) => { - await page.goto('/'); - await heart(page, ITEM).first().click(); + // Drives the registration form rather than taking the `customer` fixture: + // the claim here is that the favorite a signed-out visitor asked for survives + // 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. - await expect(page.getByRole('dialog')).toBeVisible(); + await expect(authModal.registerDialog).toBeVisible(); - const email = uniqueEmail(); - await page.getByRole('dialog').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.getByRole('dialog').getByLabel('Password').fill(PASSWORD); - await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click(); + await authModal.fillRegistration({ + email: uniqueEmail('fav'), + password: PASSWORD, + firstName: 'Test', + lastName: 'Customer' + }); + await authModal.submitRegistration(); // 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 }) => { - const email = uniqueEmail(); - await register(page, email); + test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({ + page, + customer, + storefront, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await storefront.favoriteToggle(ITEM).first().click(); - await gotoStorefrontSignedIn(page); - await heart(page, ITEM).first().click(); - - const prompt = page.getByRole('dialog'); - await expect(prompt).toBeVisible(); - await expect(prompt).toContainText('Want to know if this sells?'); + await expect(favoritePrompt.dialog).toBeVisible(); + await expect(favoritePrompt.dialog).toContainText('Want to know if this sells?'); // The reason for asking has to be given, not just the ask. - await expect(prompt).toContainText(/one of a kind/i); - await expect(prompt).toContainText(/separate from any marketing/i); + await expect(favoritePrompt.dialog).toContainText(/one of a kind/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(); 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); }); - test('declining the opt-in still keeps the favorite', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); + test('declining the opt-in still keeps the favorite', async ({ + page, + customer, + storefront, + favoritePrompt + }) => { + await storefront.gotoSignedIn(); + await storefront.favoriteToggle(ITEM).first().click(); + await favoritePrompt.declineAlerts(); - await gotoStorefrontSignedIn(page); - 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(); + await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible(); const favorites = await (await page.request.get('/api/customers/me/favorites')).json(); expect(favorites).toHaveLength(1); @@ -103,49 +77,39 @@ test.describe('Favoriting items', () => { expect(me.favorite_alerts).toBe(false); }); - test('unfavoriting removes it', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); + test('unfavoriting removes it', async ({ page, customer, storefront, favoritePrompt }) => { + await storefront.gotoSignedIn(); + await storefront.favoriteToggle(ITEM).first().click(); + await favoritePrompt.declineAlerts(); + await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible(); - await gotoStorefrontSignedIn(page); - 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(); - - await page.getByRole('button', { name: `Remove ${ITEM} from favorites` }).click(); - await expect(page.getByRole('button', { name: `Add ${ITEM} to favorites` })).toBeVisible(); + await storefront.removeFromFavoritesButton(ITEM).click(); + await expect(storefront.addToFavoritesButton(ITEM)).toBeVisible(); const favorites = await (await page.request.get('/api/customers/me/favorites')).json(); expect(favorites).toEqual([]); }); - test('favorites survive a reload', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); - - await gotoStorefrontSignedIn(page); - 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(); + test('favorites survive a reload', async ({ page, customer, storefront, favoritePrompt }) => { + await storefront.gotoSignedIn(); + await storefront.favoriteToggle(ITEM).first().click(); + await favoritePrompt.declineAlerts(); + await expect(storefront.removeFromFavoritesButton(ITEM)).toBeVisible(); // Proves it was stored server-side rather than held in component state. 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 }) => { - const email = uniqueEmail(); - await register(page, email); + test('the account page can turn the alerts off again', async ({ page, customer, accountModal }) => { await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } }); - await page.goto('/account'); - // Scoped to the account modal. The storefront renders behind it and has a - // theme switch of its own, so an unscoped switch locator only picks the - // right control by DOM accident. - const account = page.getByRole('dialog', { name: 'My Account' }); - await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible(); + await accountModal.open(); + await expect( + accountModal.dialog.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(); const me = await (await page.request.get('/api/customers/me')).json(); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index f1233e1..e4ab922 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -9,6 +9,9 @@ import { StorefrontPage } from './pages/StorefrontPage'; import { AdminPage } from './pages/AdminPage'; import { PasswordResetPages } from './pages/PasswordResetPages'; 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'; // 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. export * from '@playwright/test'; 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 collectingCoverage = process.env.COVERAGE === 'true'; @@ -42,6 +55,9 @@ interface Pages { admin: AdminPage; passwordReset: PasswordResetPages; filterDrawer: FilterDrawer; + favoritePrompt: FavoritePrompt; + orders: OrdersPage; + adminInventory: AdminInventory; } interface Data { @@ -98,6 +114,15 @@ export const test = base.extend({ filterDrawer: async ({ page }, use) => { 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) => { const context = await playwright.request.newContext({ baseURL }); diff --git a/frontend/tests/e2e/orders.spec.ts b/frontend/tests/e2e/orders.spec.ts index 81a3b4d..74378a0 100644 --- a/frontend/tests/e2e/orders.spec.ts +++ b/frontend/tests/e2e/orders.spec.ts @@ -1,27 +1,8 @@ -import { test, expect, Page } 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 { - 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; -} +import { test, expect } from './fixtures'; test.describe('Order history has a page of its own', () => { - test('a signed-out visitor is sent to sign in', async ({ page }) => { - await page.goto('/orders'); + test('a signed-out visitor is sent to sign in', async ({ page, orders }) => { + await orders.goto(); 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 // it. Putting /orders in MODAL_ROUTES would quietly undo the whole change, // and this is what would catch it. - test('renders as a page rather than a modal over the storefront', async ({ page }) => { - await registerCustomer(page); - await page.goto('/orders'); + test('renders as a page rather than a modal over the storefront', async ({ + customer, + orders, + header + }) => { + await orders.goto(); - await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible(); - await expect(page.getByRole('dialog')).toHaveCount(0); - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeHidden(); + await expect(orders.heading).toBeVisible({ timeout: 20000 }); + await expect(orders.anyDialog).toHaveCount(0); + await expect(header.siteTitle).toBeHidden(); }); - test('says so when there are no orders, rather than showing an empty table', async ({ page }) => { - await registerCustomer(page); - await page.goto('/orders'); + test('says so when there are no orders, rather than showing an empty table', async ({ + customer, + orders + }) => { + await orders.goto(); - await expect(page.getByText('No orders yet')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Continue Shopping' })).toBeVisible(); + await expect(orders.noOrdersNotice).toBeVisible({ timeout: 20000 }); + await expect(orders.continueShoppingButton).toBeVisible(); }); - test('Back to Shop returns to the storefront', async ({ page }) => { - await registerCustomer(page); - await page.goto('/orders'); + test('Back to Shop returns to the storefront', async ({ page, customer, orders, header }) => { + await orders.goto(); + 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.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 // out of it is the part that has to keep working now the table has gone. - test('My Account links to it', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('My Account links to it', async ({ page, customer, accountModal, orders }) => { + await accountModal.open(); - await page.getByRole('dialog', { name: 'My Account' }) - .getByRole('button', { name: 'View order history' }).click(); + await accountModal.orderHistoryButton.click(); await expect(page).toHaveURL(/\/orders/); - await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible(); + await expect(orders.heading).toBeVisible(); }); }); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts index 63c8002..0e2bdca 100644 --- a/frontend/tests/e2e/pages/AccountModal.ts +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -17,6 +17,8 @@ export class AccountModal { readonly closeButton: Locator; readonly resendVerificationButton: Locator; readonly themeSwitches: Locator; + /** The second switch in the modal; the first is the theme. */ + readonly favoriteAlertsSwitch: Locator; readonly notVerifiedNotice: Locator; readonly firstName: Locator; @@ -44,6 +46,7 @@ export class AccountModal { this.closeButton = this.dialog.getByRole('button', { name: 'Close' }); this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' }); this.themeSwitches = this.dialog.getByRole('switch'); + this.favoriteAlertsSwitch = this.dialog.getByRole('switch').last(); this.notVerifiedNotice = this.dialog.getByText('Email not verified'); this.firstName = this.dialog.getByLabel('First name', { exact: true }); diff --git a/frontend/tests/e2e/pages/AdminInventory.ts b/frontend/tests/e2e/pages/AdminInventory.ts new file mode 100644 index 0000000..eafe8a1 --- /dev/null +++ b/frontend/tests/e2e/pages/AdminInventory.ts @@ -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 { + await this.page.getByRole('button', { name: itemName }).click(); + } + + async openItemForm(): Promise { + await this.addItemButton.click(); + } +} diff --git a/frontend/tests/e2e/pages/FavoritePrompt.ts b/frontend/tests/e2e/pages/FavoritePrompt.ts new file mode 100644 index 0000000..9a94946 --- /dev/null +++ b/frontend/tests/e2e/pages/FavoritePrompt.ts @@ -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 { + await this.acceptAlertsButton.click(); + } + + /** Declining keeps the favorite; only the alerts are refused. */ + async declineAlerts(): Promise { + await this.declineAlertsButton.click(); + } +} diff --git a/frontend/tests/e2e/pages/FilterDrawer.ts b/frontend/tests/e2e/pages/FilterDrawer.ts index 193c250..63077e6 100644 --- a/frontend/tests/e2e/pages/FilterDrawer.ts +++ b/frontend/tests/e2e/pages/FilterDrawer.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; /** * The storefront's filter drawer. @@ -16,12 +16,14 @@ export class FilterDrawer { readonly maximumPrice: Locator; readonly closeButton: Locator; readonly clearAllButton: Locator; + readonly favoritesOnlySwitch: 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' }); + this.favoritesOnlySwitch = page.getByRole('switch', { name: 'Only my favorites' }); } category(name: string): Locator { @@ -45,6 +47,16 @@ export class FilterDrawer { 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 { + await expect(this.favoritesOnlySwitch).toBeVisible(); + } + async close(): Promise { await this.closeButton.click(); } diff --git a/frontend/tests/e2e/pages/OrdersPage.ts b/frontend/tests/e2e/pages/OrdersPage.ts new file mode 100644 index 0000000..b13357e --- /dev/null +++ b/frontend/tests/e2e/pages/OrdersPage.ts @@ -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 { + await this.page.goto('/orders'); + } +} diff --git a/frontend/tests/e2e/pages/StorefrontPage.ts b/frontend/tests/e2e/pages/StorefrontPage.ts index 8e5c2f7..4bcb533 100644 --- a/frontend/tests/e2e/pages/StorefrontPage.ts +++ b/frontend/tests/e2e/pages/StorefrontPage.ts @@ -20,6 +20,10 @@ export class StorefrontPage { * all" of its own, so an unscoped one matches both. */ 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 @@ -40,6 +44,8 @@ export class StorefrontPage { this.filtersButton = page.getByRole('button', { name: /Filters/ }); this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' }); 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.loadFailureNotice = page.getByText("Couldn't load items"); @@ -75,15 +81,56 @@ export class StorefrontPage { 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 { - 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 { 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 { + await this.page.getByTitle(label, { exact: true }).click(); + } + removeFilterChip(name: string): Locator { return this.page.getByRole('button', { name: `Remove filter ${name}` }); } diff --git a/frontend/tests/e2e/pending-publish.spec.ts b/frontend/tests/e2e/pending-publish.spec.ts index 2035cd9..dccf518 100644 --- a/frontend/tests/e2e/pending-publish.spec.ts +++ b/frontend/tests/e2e/pending-publish.spec.ts @@ -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. Deliberately does not -// publish, unlike the other specs' fixtures, because the staged state is what -// is being tested here. -async function createStagedItem(page: import('@playwright/test').Page, name: string) { - const created = await page.request.post('/api/admin/items', { +/** + * Creates an item and leaves it as it arrives — pending. + * + * Deliberately does not publish, unlike the shared `createItem` helper, because + * the staged state is the thing under test here. The status assertion is the + * whole premise: creating something does not publish it. + */ +async function createStagedItem( + request: import('@playwright/test').APIRequestContext, + name: string +): Promise { + const created = await request.post('/api/admin/items', { multipart: { name, description: '', price: '55', category_id: '', tags: '[]' } }); expect(created.ok()).toBeTruthy(); const body = await created.json(); - // The whole premise: creating something does not publish it. expect(body.status).toBe('pending'); 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('a new item is held back from the storefront until published', async ({ page }) => { - const name = `Staged ${suffix()}`; - await createStagedItem(page, name); + test('a new item is held back from the storefront until published', async ({ + page, + storefront, + admin, + adminInventory + }) => { + const name = `Staged ${uniqueSuffix()}`; + await createStagedItem(page.request, name); // Not in the catalogue while pending. - await page.goto('/'); + await storefront.goto(); await expect(page.getByText(name)).toHaveCount(0); // It is in the admin, marked as pending. - await page.goto('/admin'); - const row = rowFor(page, name); - await expect(row).toBeVisible(); - await expect(row.getByText('PENDING')).toBeVisible(); + await admin.goto(); + await expect(adminInventory.row(name)).toBeVisible(); + await expect(adminInventory.status(name, 'PENDING')).toBeVisible(); - await row.getByRole('button', { name: 'Publish' }).click(); - await expect(row.getByText('AVAILABLE')).toBeVisible(); + await adminInventory.publishButton(name).click(); + await expect(adminInventory.status(name, 'AVAILABLE')).toBeVisible(); // And now a customer can see it. - await page.goto('/'); + await storefront.goto(); await expect(page.getByText(name)).toBeVisible(); }); - test('publishing can be undone while nobody is holding the item', async ({ page }) => { - const name = `Staged ${suffix()}`; - const id = await createStagedItem(page, name); - expect((await page.request.post(`/api/admin/items/${id}/mark-available`)).ok()).toBeTruthy(); + test('publishing can be undone while nobody is holding the item', async ({ + page, + storefront, + 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 page.goto('/admin'); - const row = rowFor(page, name); - await row.getByRole('button', { name: 'Unpublish' }).click(); - await expect(row.getByText('PENDING')).toBeVisible(); + await admin.goto(); + await adminInventory.unpublishButton(name).click(); + await expect(adminInventory.status(name, 'PENDING')).toBeVisible(); - await page.goto('/'); + await storefront.goto(); await expect(page.getByText(name)).toHaveCount(0); }); // 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 // 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 }) => { - const name = `Staged ${suffix()}`; - await createStagedItem(page, name); + test('a pending item previews as it will look once published', async ({ + page, + admin, + adminInventory + }) => { + const name = `Staged ${uniqueSuffix()}`; + await createStagedItem(page.request, name); - await page.goto('/admin'); - await page.getByRole('button', { name }).click(); + await admin.goto(); + await adminInventory.openPreview(name); - const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); + const drawer = adminInventory.previewDrawer(name); await expect(drawer).toBeVisible(); await expect(drawer.getByText('$55.00')).toBeVisible(); // The live card's action, not a pending placeholder. diff --git a/frontend/tests/e2e/sold-filter.spec.ts b/frontend/tests/e2e/sold-filter.spec.ts index 9657ef9..3a94f61 100644 --- a/frontend/tests/e2e/sold-filter.spec.ts +++ b/frontend/tests/e2e/sold-filter.spec.ts @@ -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 // between runs, so every fixture carries a unique run id and assertions name // 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 = { available: `Available 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 }) => { - const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); + const api = await createAdminContext(playwright); - for (const name of [NAMES.available, NAMES.sold]) { - const res = await api.post('/api/admin/items', { - multipart: { name, description: '', price: '250.00' } - }); - 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 createItem(api, { name: NAMES.available, price: '250.00' }); + const sold = await createItem(api, { name: NAMES.sold, price: '250.00' }); + await sellItem(api, sold.id); await api.dispose(); }); @@ -46,68 +23,68 @@ test.beforeAll(async ({ playwright }) => { test.describe('Filtering the storefront by availability', () => { // The change customers actually see. Asserted on a bare visit rather than on // a parameter, because the default is what changed for everyone. - test('hides sold pieces by default', async ({ page }) => { - await page.goto('/'); + test('hides sold pieces by default', async ({ page, storefront }) => { + await storefront.goto(); - await expect(cell(page, NAMES.available)).toBeVisible(); - await expect(cell(page, NAMES.sold)).toHaveCount(0); + await expect(storefront.gridCell(NAMES.available)).toBeVisible(); + await expect(storefront.gridCell(NAMES.sold)).toHaveCount(0); }); - test('All brings them back, ribbon and all', async ({ page }) => { - await page.goto('/'); + test('All brings them back, ribbon and all', async ({ page, storefront }) => { + await storefront.goto(); - await chooseAvailability(page, 'All'); + await storefront.chooseAvailability('All'); - await expect(cell(page, NAMES.sold)).toBeVisible(); - await expect(cell(page, NAMES.sold)).toContainText('SOLD'); - await expect(cell(page, NAMES.available)).toBeVisible(); + await expect(storefront.gridCell(NAMES.sold)).toBeVisible(); + await expect(storefront.gridCell(NAMES.sold)).toContainText('SOLD'); + await expect(storefront.gridCell(NAMES.available)).toBeVisible(); }); - test('Sold shows only the sold ones', async ({ page }) => { - await page.goto('/'); + test('Sold shows only the sold ones', async ({ page, storefront }) => { + await storefront.goto(); - await chooseAvailability(page, 'Sold'); + await storefront.chooseAvailability('Sold'); - await expect(cell(page, NAMES.sold)).toBeVisible(); - await expect(cell(page, NAMES.available)).toHaveCount(0); + await expect(storefront.gridCell(NAMES.sold)).toBeVisible(); + await expect(storefront.gridCell(NAMES.available)).toHaveCount(0); }); // 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. - test('the choice survives a reload, because it lives in the URL', async ({ page }) => { - await page.goto('/'); - await chooseAvailability(page, 'All'); - await expect(cell(page, NAMES.sold)).toBeVisible(); + test('the choice survives a reload, because it lives in the URL', async ({ page, storefront }) => { + await storefront.goto(); + await storefront.chooseAvailability('All'); + await expect(storefront.gridCell(NAMES.sold)).toBeVisible(); 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(); }); // 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 // 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 }) => { - await page.goto('/'); - await chooseAvailability(page, 'All'); + test('returning to Not sold leaves no status in the URL', async ({ page, storefront }) => { + await storefront.goto(); + await storefront.chooseAvailability('All'); await expect(page).toHaveURL(/status=/); - await chooseAvailability(page, 'Not sold'); + await storefront.chooseAvailability('Not sold'); 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, // so its state must not be counted as one of the drawer's filters. - test('does not inflate the Filters count', async ({ page }) => { - await page.goto('/'); - await chooseAvailability(page, 'Sold'); + test('does not inflate the Filters count', async ({ page, storefront }) => { + await storefront.goto(); + await storefront.chooseAvailability('Sold'); // 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 // 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'); }); });