From a96ffb811a5d296ba112f546a8d194f765be0cba Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Tue, 13 Oct 2026 22:58:55 +0200 Subject: [PATCH 1/3] Added E2E coverage for home, category, product, cart, sign-in and account pages The Playwright suite only covered guest checkout. It now also drives sign-up, sign-in and sign-out, every account section, the home page, category listings, the product page and the cart against the Docker backend. The backend's sign-up and sign-in rate limits are raised because each test signs a fresh customer up from the same address. --- e2e-backend/docker-compose.yml | 6 ++ e2e/account.spec.ts | 98 ++++++++++++++++++ e2e/auth.spec.ts | 81 +++++++++++++++ e2e/cart.spec.ts | 61 ++++++++++++ e2e/catalog.spec.ts | 92 +++++++++++++++++ e2e/product.spec.ts | 61 ++++++++++++ e2e/support.ts | 176 +++++++++++++++++++++++++++++++++ 7 files changed, 575 insertions(+) create mode 100644 e2e/account.spec.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/cart.spec.ts create mode 100644 e2e/catalog.spec.ts create mode 100644 e2e/product.spec.ts create mode 100644 e2e/support.ts diff --git a/e2e-backend/docker-compose.yml b/e2e-backend/docker-compose.yml index 7c4266b9..fd0c2d2c 100644 --- a/e2e-backend/docker-compose.yml +++ b/e2e-backend/docker-compose.yml @@ -75,6 +75,12 @@ services: # never run — fine here, since the checkout golden path doesn't depend on # any job output (see note above), and the tests never assert on images. SOLID_QUEUE_IN_PUMA: "false" + # Spree throttles sign-ups (3/min) and sign-ins (5/min) per client IP. + # Every test signs a fresh customer up from the same IP, so the + # defaults lock the suite out after its first few tests. + SPREE_RATE_LIMIT_REGISTER: "1000" + SPREE_RATE_LIMIT_LOGIN: "1000" + SPREE_RATE_LIMIT_REFRESH: "1000" ports: - "4000:3000" healthcheck: diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts new file mode 100644 index 00000000..a5d3bcaf --- /dev/null +++ b/e2e/account.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test"; +import { signUp, waitForHydration } from "./support"; + +test.describe("customer account", () => { + test.beforeEach(async ({ page }) => { + await signUp(page, { firstName: "Grace", lastName: "Hopper" }); + }); + + test("the account navigation reaches every section", async ({ page }) => { + const nav = page.getByRole("complementary").getByRole("navigation"); + const sections = [ + { link: "Orders", heading: "Order History", empty: "No orders yet" }, + { + link: "Payment Methods", + heading: "Payment Methods", + empty: "No payment methods saved", + }, + { link: "Gift Cards", heading: "Gift Cards", empty: "No gift cards" }, + { link: "Addresses", heading: "Addresses", empty: "No saved addresses" }, + ]; + + for (const section of sections) { + const link = nav.getByRole("link", { name: section.link }); + await waitForHydration(link); + await link.click(); + await expect( + page.getByRole("heading", { level: 1, name: section.heading }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByRole("heading", { name: section.empty }), + ).toBeVisible(); + } + }); + + test("a customer adds and deletes an address", async ({ page }) => { + await page + .getByRole("complementary") + .getByRole("link", { name: "Addresses" }) + .click(); + const addFirst = page.getByRole("button", { + name: "Add Your First Address", + }); + await waitForHydration(addFirst); + await addFirst.click(); + + const dialog = page.getByRole("dialog", { name: "Add New Address" }); + await dialog + .getByRole("combobox", { name: "Country" }) + .selectOption({ label: "United States" }); + await dialog + .getByRole("textbox", { name: "Address", exact: true }) + .fill("42 Analytical Engine Way"); + await dialog.getByRole("textbox", { name: "City" }).fill("New York"); + await dialog + .getByRole("textbox", { name: "ZIP / Postal code" }) + .fill("10001"); + // With the United States selected the state becomes a list, enabled once + // the country's states have loaded. + await dialog + .getByRole("combobox", { name: /state/i }) + .selectOption({ label: "New York" }); + await dialog.getByRole("button", { name: "Save Address" }).click(); + + await expect(dialog).toBeHidden({ timeout: 30_000 }); + await expect(page.getByText("42 Analytical Engine Way")).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).click(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Delete" }) + .click(); + await expect( + page.getByRole("heading", { name: "No saved addresses" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("a customer updates their name", async ({ page }) => { + await page + .getByRole("complementary") + .getByRole("link", { name: "Profile" }) + .click(); + const firstName = page.getByRole("textbox", { name: "First Name" }); + await expect(firstName).toHaveValue("Grace", { timeout: 30_000 }); + await waitForHydration(firstName); + + await firstName.fill("Amazing Grace"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await expect(page.getByText("Profile updated successfully!")).toBeVisible({ + timeout: 30_000, + }); + + await page.reload(); + await expect(page.getByRole("textbox", { name: "First Name" })).toHaveValue( + "Amazing Grace", + { timeout: 30_000 }, + ); + }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..ba613734 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { + BASE_PATH, + formWithButton, + signIn, + signUp, + waitForHydration, +} from "./support"; + +test.describe("sign up and sign in", () => { + test("a new customer signs up, signs out and signs back in", async ({ + page, + }) => { + const { email, password } = await signUp(page, { + firstName: "Ada", + lastName: "Lovelace", + }); + const sidebar = page.getByRole("complementary"); + await expect(sidebar.getByText("Ada Lovelace")).toBeVisible(); + await expect(sidebar.getByText(email)).toBeVisible(); + + const signOut = page.getByRole("button", { name: "Sign Out" }); + await waitForHydration(signOut); + await signOut.click(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible({ + timeout: 30_000, + }); + + await page.goto(`${BASE_PATH}/account`); + await signIn(page, email, password); + await expect( + page.getByRole("heading", { name: "Account Overview" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("a wrong password is refused", async ({ page }) => { + const { email } = await signUp(page); + await page.context().clearCookies(); + + await page.goto(`${BASE_PATH}/account`); + await signIn(page, email, "not-the-password"); + await expect(page.getByText("Invalid email or password")).toBeVisible({ + timeout: 30_000, + }); + }); + + test("an account page sends a guest to sign in and back", async ({ + page, + }) => { + const { email, password } = await signUp(page); + await page.context().clearCookies(); + + await page.goto(`${BASE_PATH}/account/orders`); + await expect(page).toHaveURL(/\/account\?redirect=/); + await signIn(page, email, password); + await expect( + page.getByRole("heading", { level: 1, name: "Order History" }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("sign up refuses passwords that do not match", async ({ page }) => { + await page.goto(`${BASE_PATH}/account/register`); + await waitForHydration(formWithButton(page, "Create Account")); + await page.getByRole("textbox", { name: "First name" }).fill("E2E"); + await page.getByRole("textbox", { name: "Last name" }).fill("Customer"); + await page + .getByRole("textbox", { name: "Email" }) + .fill(`e2e-mismatch-${Date.now()}@example.com`); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill("spree123!"); + await page + .getByRole("textbox", { name: "Confirm Password" }) + .fill("different123!"); + await page.getByRole("checkbox", { name: /i agree/i }).check(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect(page.getByText("Passwords do not match")).toBeVisible(); + await expect(page).toHaveURL(/\/account\/register$/); + }); +}); diff --git a/e2e/cart.spec.ts b/e2e/cart.spec.ts new file mode 100644 index 00000000..812f1b4a --- /dev/null +++ b/e2e/cart.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import { + addToCart, + BASE_PATH, + openFirstProduct, + waitForHydration, +} from "./support"; + +test.describe("cart page", () => { + test("an empty cart points back to the catalog", async ({ page }) => { + await page.goto(`${BASE_PATH}/cart`); + await expect( + page.getByRole("heading", { name: "Your cart is empty" }), + ).toBeVisible({ timeout: 30_000 }); + + const continueShopping = page.getByRole("link", { + name: "Continue Shopping", + }); + await waitForHydration(continueShopping); + await continueShopping.click(); + await expect(page).toHaveURL(/\/products$/); + }); + + test("a customer changes the quantity and removes the item", async ({ + page, + }) => { + const name = await openFirstProduct(page, `${BASE_PATH}/products`); + await addToCart(page, name); + + await page.goto(`${BASE_PATH}/cart`); + await expect( + page.getByRole("heading", { name: "Shopping Cart" }), + ).toBeVisible({ timeout: 30_000 }); + const main = page.getByRole("main"); + await expect(main.getByRole("heading", { name })).toBeVisible(); + + const subtotal = main + .getByRole("term") + .filter({ hasText: /^Subtotal$/ }) + .locator("xpath=following-sibling::*[1]"); + const initialSubtotal = (await subtotal.textContent())?.trim() ?? ""; + + const increase = main.getByRole("button", { name: "Increase quantity" }); + await waitForHydration(increase); + await increase.click(); + await expect(main.getByRole("textbox", { name: "Quantity" })).toHaveValue( + "2", + { timeout: 30_000 }, + ); + await expect(subtotal).not.toHaveText(initialSubtotal, { timeout: 30_000 }); + + await expect( + main.getByRole("link", { name: "Proceed to Checkout" }), + ).toBeVisible(); + + await main.getByRole("button", { name: `Remove ${name}` }).click(); + await expect( + page.getByRole("heading", { name: "Your cart is empty" }), + ).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/e2e/catalog.spec.ts b/e2e/catalog.spec.ts new file mode 100644 index 00000000..103b75a7 --- /dev/null +++ b/e2e/catalog.spec.ts @@ -0,0 +1,92 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + BASE_PATH, + openCategoryFromMenu, + openFirstCategory, + openFirstProduct, + waitForHydration, +} from "./support"; + +function productNames(page: Page) { + return page + .getByRole("main") + .getByRole("heading", { level: 3 }) + .allTextContents(); +} + +test.describe("home page", () => { + test("shows featured products that open their product page", async ({ + page, + }) => { + await page.goto(BASE_PATH); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Featured Products" }), + ).toBeVisible(); + + await openFirstProduct(page, BASE_PATH); + }); + + test("the header menu opens a category", async ({ page }) => { + await openCategoryFromMenu(page); + }); +}); + +test.describe("category page", () => { + test("lists the category's products with a count and breadcrumb", async ({ + page, + }) => { + const name = await openFirstCategory(page); + const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumb.getByRole("link", { name: "Home" })).toBeVisible(); + await expect(breadcrumb.getByText(name)).toBeVisible(); + await expect(page.getByText(/\d+ products?/)).toBeVisible(); + expect((await productNames(page)).length).toBeGreaterThan(0); + }); + + test("a subcategory narrows the listing", async ({ page }) => { + const parent = await openFirstCategory(page); + const subcategory = page + .getByRole("main") + .getByRole("link") + .filter({ hasNot: page.getByRole("heading") }) + .and(page.locator('a[href*="/c/"]')) + .first(); + await waitForHydration(subcategory); + const name = (await subcategory.textContent())?.trim() ?? ""; + await subcategory.click(); + + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + await expect( + page + .getByRole("navigation", { name: "Breadcrumb" }) + .getByRole("link", { name: parent }), + ).toBeVisible(); + }); + + test("sorts products by name", async ({ page }) => { + await openFirstCategory(page); + const sort = page.getByRole("button", { name: "Sort" }); + await waitForHydration(sort); + await sort.click(); + await page.getByRole("menuitemradio", { name: "Name (A-Z)" }).click(); + + await expect(page).toHaveURL(/sort=/, { timeout: 30_000 }); + await expect + .poll(async () => { + const names = await productNames(page); + const sorted = [...names].sort((a, b) => + a.localeCompare(b, "en", { sensitivity: "base" }), + ); + return names.length > 1 && names.join("|") === sorted.join("|"); + }) + .toBe(true); + }); + + test("a product card opens its product page", async ({ page }) => { + await openFirstCategory(page); + await openFirstProduct(page, page.url()); + }); +}); diff --git a/e2e/product.spec.ts b/e2e/product.spec.ts new file mode 100644 index 00000000..a871f96b --- /dev/null +++ b/e2e/product.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import { + addToCart, + BASE_PATH, + openFirstProduct, + waitForHydration, +} from "./support"; + +test.describe("product page", () => { + test("shows the product's price, availability and details", async ({ + page, + }) => { + await openFirstProduct(page, `${BASE_PATH}/products`); + + await expect(page.getByText(/\$\d/).first()).toBeVisible(); + await expect(page.getByText(/in stock/i)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Details" })).toBeVisible(); + await expect( + page.getByRole("navigation", { name: "Breadcrumb" }), + ).toBeVisible(); + }); + + test("choosing another color switches the variant", async ({ page }) => { + await openFirstProduct(page, `${BASE_PATH}/products`); + // Color swatches are named by their title. + const swatches = page.getByRole("main").locator("button[title]:enabled"); + test.skip( + (await swatches.count()) < 2, + "The first product has no second color to switch to", + ); + const color = (await swatches.nth(1).getAttribute("title")) ?? ""; + const sku = page + .getByRole("term") + .filter({ hasText: /^SKU$/ }) + .locator("xpath=following-sibling::*[1]"); + const initialSku = (await sku.textContent())?.trim(); + + await waitForHydration(swatches.nth(1)); + await swatches.nth(1).click(); + + await expect( + page.getByRole("definition").filter({ hasText: `: ${color}` }), + ).toBeVisible({ timeout: 30_000 }); + await expect(sku).not.toHaveText(initialSku ?? ""); + }); + + test("adds the chosen quantity to the cart", async ({ page }) => { + const name = await openFirstProduct(page, `${BASE_PATH}/products`); + const quantity = page.getByRole("textbox", { name: "Quantity" }); + await page.getByRole("button", { name: "Increase quantity" }).click(); + await expect(quantity).toHaveValue("2"); + + const drawer = await addToCart(page, name); + await expect(drawer.getByRole("textbox", { name: "Quantity" })).toHaveValue( + "2", + ); + await expect( + drawer.getByRole("heading", { name: /2 items/ }), + ).toBeVisible(); + }); +}); diff --git a/e2e/support.ts b/e2e/support.ts new file mode 100644 index 00000000..2eeb63a1 --- /dev/null +++ b/e2e/support.ts @@ -0,0 +1,176 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export const BASE_PATH = "/us/en"; +export const PASSWORD = "spree123!"; + +/** + * Waits until React has hydrated an element. A click or submit that lands + * before hydration is lost (a button does nothing) or, for a form, falls back + * to a native GET submit that reloads the page with the fields in the URL. + * React tags every hydrated DOM node with a `__reactFiber$…` key. + */ +export async function waitForHydration(locator: Locator): Promise { + await expect(locator).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + () => + locator.evaluate((element) => + Object.keys(element).some((key) => key.startsWith("__reactFiber")), + ), + { timeout: 30_000 }, + ) + .toBe(true); +} + +/** The form submitted by the named button (the header holds a search form too). */ +export function formWithButton(page: Page, button: string): Locator { + return page + .locator("form") + .filter({ has: page.getByRole("button", { name: button }) }); +} + +/** The first top-level category in the category navigation. */ +async function firstCategory( + page: Page, +): Promise<{ name: string; href: string }> { + await page.goto(BASE_PATH); + const link = page + .getByRole("navigation", { name: "Category navigation" }) + .getByRole("link") + .first(); + return { + name: (await link.textContent())?.trim() ?? "", + href: (await link.getAttribute("href")) ?? BASE_PATH, + }; +} + +/** Opens the first top-level category page and returns its name. */ +export async function openFirstCategory(page: Page): Promise { + const { name, href } = await firstCategory(page); + await page.goto(href); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** + * Opens a category through the header menu: the first top-level category, or + * its first subcategory when it has a panel of them. Returns the name of the + * category opened. + */ +export async function openCategoryFromMenu(page: Page): Promise { + const { name: topLevel } = await firstCategory(page); + const menuButton = page.getByRole("button", { name: "Open menu" }); + await waitForHydration(menuButton); + await menuButton.click(); + const menu = page.getByRole("dialog", { name: "Menu" }); + // Scoped to the menu's list: a category's panel has a back button that + // carries the category's name too. + const list = menu.getByRole("navigation").first(); + const categoryButton = list.getByRole("button", { + name: topLevel, + exact: true, + }); + + let target = list.getByRole("link", { name: topLevel, exact: true }); + await expect(categoryButton.or(target)).toBeVisible({ timeout: 10_000 }); + if (await categoryButton.count()) { + // The subcategory panel is the menu list without the "All Products" link. + // A click landing while the menu is still sliding in is lost. + const subcategory = menu + .getByRole("navigation") + .filter({ hasNot: page.getByRole("link", { name: "All Products" }) }) + .getByRole("link") + .first(); + await expect(async () => { + if (!(await subcategory.isVisible())) { + await categoryButton.click({ timeout: 3_000 }); + } + await expect(subcategory).toBeVisible({ timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); + target = subcategory; + } + + const name = (await target.textContent())?.trim() ?? ""; + await target.click(); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** Opens the first product listed on a listing page and returns its name. */ +export async function openFirstProduct( + page: Page, + listingPath: string, +): Promise { + await page.goto(listingPath); + const firstProduct = page + .getByRole("heading", { level: 3 }) + .getByRole("link") + .first(); + await waitForHydration(firstProduct); + const name = (await firstProduct.textContent())?.trim() ?? ""; + await firstProduct.click(); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + return name; +} + +/** + * Adds the product on the current product page to the cart and waits for the + * cart drawer to list it. Returns the drawer. + */ +export async function addToCart(page: Page, name: string): Promise { + const addButton = page.getByRole("button", { name: /add to cart/i }); + await waitForHydration(addButton); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + + const drawer = page.getByRole("dialog", { name: /^cart/i }); + await expect(drawer.getByRole("link", { name })).toBeVisible({ + timeout: 30_000, + }); + return drawer; +} + +/** Signs a new customer up through the registration form. */ +export async function signUp( + page: Page, + { firstName = "E2E", lastName = "Customer" } = {}, +): Promise<{ email: string; password: string }> { + const email = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.com`; + + await page.goto(`${BASE_PATH}/account/register`); + await waitForHydration(formWithButton(page, "Create Account")); + await page.getByRole("textbox", { name: "First name" }).fill(firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(lastName); + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await page.getByRole("textbox", { name: "Confirm Password" }).fill(PASSWORD); + await page.getByRole("checkbox", { name: /i agree/i }).check(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect( + page.getByRole("heading", { name: "Account Overview" }), + ).toBeVisible({ timeout: 30_000 }); + return { email, password: PASSWORD }; +} + +/** Fills the sign-in form on the current page and submits it. */ +export async function signIn( + page: Page, + email: string, + password: string, +): Promise { + await waitForHydration(formWithButton(page, "Sign In")); + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(password); + await page.getByRole("button", { name: "Sign In" }).click(); +} From fe8b43fa428a5ef5452d927bbe97648a42032670 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Wed, 14 Oct 2026 10:45:18 +0200 Subject: [PATCH 2/3] Added product page E2E coverage for option combinations, sale prices and sold-out variants The setup script now creates a product with color and size options, one discounted variant and one sold-out variant, since the sample catalog has none. Also fixed two timing races in the address deletion and category count tests. --- e2e/account.spec.ts | 17 ++++-- e2e/catalog.spec.ts | 5 +- e2e/product.spec.ts | 107 ++++++++++++++++++++++++++++++++- scripts/e2e/bootstrap-spree.sh | 48 ++++++++++++++- 4 files changed, 170 insertions(+), 7 deletions(-) diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts index a5d3bcaf..2400111e 100644 --- a/e2e/account.spec.ts +++ b/e2e/account.spec.ts @@ -64,11 +64,20 @@ test.describe("customer account", () => { await expect(dialog).toBeHidden({ timeout: 30_000 }); await expect(page.getByText("42 Analytical Engine Way")).toBeVisible(); - await page.getByRole("button", { name: "Delete" }).click(); - await page + // The list refreshes once more after the save, and that closes a confirm + // dialog opened just before it. + const confirmDelete = page .getByRole("alertdialog") - .getByRole("button", { name: "Delete" }) - .click(); + .getByRole("button", { name: "Delete" }); + await expect(async () => { + if (!(await confirmDelete.isVisible())) { + await page + .getByRole("button", { name: "Delete" }) + .first() + .click({ timeout: 3_000 }); + } + await confirmDelete.click({ timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); await expect( page.getByRole("heading", { name: "No saved addresses" }), ).toBeVisible({ timeout: 30_000 }); diff --git a/e2e/catalog.spec.ts b/e2e/catalog.spec.ts index 103b75a7..8d9cc65f 100644 --- a/e2e/catalog.spec.ts +++ b/e2e/catalog.spec.ts @@ -40,7 +40,10 @@ test.describe("category page", () => { const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); await expect(breadcrumb.getByRole("link", { name: "Home" })).toBeVisible(); await expect(breadcrumb.getByText(name)).toBeVisible(); - await expect(page.getByText(/\d+ products?/)).toBeVisible(); + // The count briefly renders twice while the page streams in. + await expect( + page.getByText(/\d+ products?/).filter({ visible: true }), + ).toBeVisible(); expect((await productNames(page)).length).toBeGreaterThan(0); }); diff --git a/e2e/product.spec.ts b/e2e/product.spec.ts index a871f96b..946509e8 100644 --- a/e2e/product.spec.ts +++ b/e2e/product.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; import { addToCart, BASE_PATH, @@ -59,3 +59,108 @@ test.describe("product page", () => { ).toBeVisible(); }); }); + +// Created by scripts/e2e/bootstrap-spree.sh: black in S, M and L, red in S and +// M only. Everything is $20 except red M, on sale at $15 instead of $25, and +// black L is sold out. +test.describe("product page with color and size options", () => { + const name = "E2E Two-Option Tee"; + + function detail(page: Page, term: string) { + return page + .getByRole("main") + .getByRole("term") + .filter({ hasText: new RegExp(`^${term}$`) }) + .locator("xpath=following-sibling::*[1]"); + } + + // A sold-out size carries "(Out of stock)" in its name. + function size(page: Page, label: string) { + return page + .getByRole("main") + .getByRole("button", { name: new RegExp(`^${label}\\b`) }); + } + + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE_PATH}/products/e2e-two-option-tee`); + await expect(page.getByRole("heading", { level: 1, name })).toBeVisible({ + timeout: 30_000, + }); + await expect(detail(page, "Options")).toHaveText("Color: Black, Size: S"); + await waitForHydration(size(page, "M")); + }); + + test("picking a color and a size adds that combination to the cart", async ({ + page, + }) => { + await expect(size(page, "L")).toBeEnabled(); + + await page.getByRole("main").getByTitle("Red", { exact: true }).click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: S", { + timeout: 30_000, + }); + // Red does not come in L. + await expect(size(page, "L")).toBeDisabled(); + + await size(page, "M").click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: M"); + await expect(detail(page, "SKU")).toHaveText("E2E-TEE-RED-M"); + + const drawer = await addToCart(page, name); + await expect(drawer.getByText("Color: Red, Size: M")).toBeVisible(); + }); + + test("a discounted variant shows its sale price, and the cart charges it", async ({ + page, + }) => { + const main = page.getByRole("main"); + await expect(main.getByText("$20.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeHidden(); + + await main.getByTitle("Red", { exact: true }).click(); + await size(page, "M").click(); + await expect(detail(page, "Options")).toHaveText("Color: Red, Size: M", { + timeout: 30_000, + }); + await expect(main.getByText("$15.00", { exact: true })).toBeVisible(); + await expect(main.getByText("$25.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeVisible(); + + await size(page, "S").click(); + await expect(main.getByText("$20.00", { exact: true })).toBeVisible(); + await expect(main.getByText("Sale", { exact: true })).toBeHidden(); + await expect(main.getByText("$25.00", { exact: true })).toBeHidden(); + + await size(page, "M").click(); + const drawer = await addToCart(page, name); + await expect(drawer.getByText("$15.00").first()).toBeVisible(); + }); + + test("a sold-out variant can be picked but not added to the cart", async ({ + page, + }) => { + const main = page.getByRole("main"); + const addButton = main.getByRole("button", { name: /add to cart/i }); + await expect(addButton).toBeEnabled(); + await expect(size(page, "L")).toHaveText(/out of stock/i); + await expect(size(page, "M")).not.toHaveText(/out of stock/i); + + await size(page, "L").click(); + await expect(detail(page, "Options")).toHaveText("Color: Black, Size: L", { + timeout: 30_000, + }); + await expect(main.getByText("Out of Stock", { exact: true })).toHaveCount( + 2, + ); + await expect( + main.getByRole("button", { name: "Out of Stock", exact: true }), + ).toBeDisabled(); + await expect(addButton).toBeHidden(); + // Red does not come in L, so it cannot be combined with this size. + await expect(main.getByTitle("Red", { exact: true })).toBeDisabled(); + + await size(page, "M").click(); + await expect(main.getByText("In Stock", { exact: true })).toBeVisible(); + await expect(addButton).toBeEnabled(); + }); +}); diff --git a/scripts/e2e/bootstrap-spree.sh b/scripts/e2e/bootstrap-spree.sh index a41db629..d14c6361 100755 --- a/scripts/e2e/bootstrap-spree.sh +++ b/scripts/e2e/bootstrap-spree.sh @@ -4,7 +4,8 @@ # Steps: # 1. `spree seed` — seed the default store, roles, countries. # 2. `spree sample-data` — load sample products, categories, images. -# 3. mint a publishable API key bound to a public channel, and capture +# 3. add a product that has both color and size options. +# 4. mint a publishable API key bound to a public channel, and capture # its pk_… token. # # Idempotent: the CLI's seed/sample-data tasks are no-ops on already-seeded @@ -107,6 +108,51 @@ gateway.save!(validate: false) puts "OK: gateway #{gateway.id} (#{gateway.name})" RUBY +# The sample catalog's products vary by color only and are all in stock at +# full price, so the product page tests for picking a color and a size, a +# discounted variant and a sold-out one need a product of their own. +echo "==> Creating a product with color and size options" +docker compose exec -T web bin/rails runner - <<'RUBY' +store = Spree::Store.default +color = Spree::OptionType.for_store(store).find_by!(name: 'color') +size = Spree::OptionType.for_store(store).find_or_create_by!(name: 'size') do |option_type| + option_type.label = 'Size' + option_type.kind = 'buttons' +end +colors = { 'black' => ['Black', '#000000'], 'red' => ['Red', '#cc0000'] }.to_h do |name, (label, code)| + [name, color.option_values.find_or_create_by!(name: name) { |ov| ov.label = label; ov.color_code = code }] +end +sizes = %w[S M L].each_with_index.to_h do |label, index| + [label, size.option_values.find_or_create_by!(name: label.downcase) { |ov| ov.label = label; ov.position = index + 1 }] +end + +product = store.products.find_or_initialize_by(slug: 'e2e-two-option-tee') +product.assign_attributes(name: 'E2E Two-Option Tee', status: 'active', description: 'A product with color and size options.') +product.save! +product.option_types = [color, size] +location = Spree::StockLocation.order_default.first + +# Black in S, M and L, red in S and M only. Red M is on sale and black L is +# sold out. +[ + ['black', 'S'], ['black', 'M'], ['black', 'L', { stock: 0 }], + ['red', 'S'], ['red', 'M', { price: 15, compare_at: 25 }] +].each do |color_name, size_label, overrides = {}| + sku = "E2E-TEE-#{color_name}-#{size_label}".upcase + variant = product.variants.find_or_initialize_by(sku: sku) + variant.option_values = [colors.fetch(color_name), sizes.fetch(size_label)] + variant.set_price('USD', overrides.fetch(:price, 20), overrides[:compare_at]) + variant.save! + stock_item = variant.stock_items.find_or_create_by!(stock_location: location) + stock_item.update!(backorderable: false) + stock_item.set_count_on_hand(overrides.fetch(:stock, 100)) +end +# Saving the product creates a variant without options; the page would open on it. +product.variants.reject { |variant| variant.option_values.any? }.each(&:destroy!) +store.channels.each { |channel| product.product_publications.find_or_create_by!(channel: channel) } +puts "OK: #{product.slug} (#{product.variants.count} variants)" +RUBY + # Created via `bin/rails runner` rather than `spree api-key create`, because the # key must be bound to a channel and the CLI has no --channel flag. Sample data # seeds three channels ("Online Store", "Wholesale", "Point of Sale"), and a key From 155116d3ecb28473a9c36075aeab7c1602bd6de3 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Wed, 14 Oct 2026 11:08:45 +0200 Subject: [PATCH 3/3] Removed the product page test that could skip itself It skipped whenever the first listed product showed fewer than two colors, which could happen before the swatches rendered. Switching color is now covered on the fixed two-option product. --- e2e/product.spec.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/e2e/product.spec.ts b/e2e/product.spec.ts index 946509e8..6b7086e4 100644 --- a/e2e/product.spec.ts +++ b/e2e/product.spec.ts @@ -20,30 +20,6 @@ test.describe("product page", () => { ).toBeVisible(); }); - test("choosing another color switches the variant", async ({ page }) => { - await openFirstProduct(page, `${BASE_PATH}/products`); - // Color swatches are named by their title. - const swatches = page.getByRole("main").locator("button[title]:enabled"); - test.skip( - (await swatches.count()) < 2, - "The first product has no second color to switch to", - ); - const color = (await swatches.nth(1).getAttribute("title")) ?? ""; - const sku = page - .getByRole("term") - .filter({ hasText: /^SKU$/ }) - .locator("xpath=following-sibling::*[1]"); - const initialSku = (await sku.textContent())?.trim(); - - await waitForHydration(swatches.nth(1)); - await swatches.nth(1).click(); - - await expect( - page.getByRole("definition").filter({ hasText: `: ${color}` }), - ).toBeVisible({ timeout: 30_000 }); - await expect(sku).not.toHaveText(initialSku ?? ""); - }); - test("adds the chosen quantity to the cart", async ({ page }) => { const name = await openFirstProduct(page, `${BASE_PATH}/products`); const quantity = page.getByRole("textbox", { name: "Quantity" }); @@ -99,6 +75,7 @@ test.describe("product page with color and size options", () => { await expect(detail(page, "Options")).toHaveText("Color: Red, Size: S", { timeout: 30_000, }); + await expect(detail(page, "SKU")).toHaveText("E2E-TEE-RED-S"); // Red does not come in L. await expect(size(page, "L")).toBeDisabled();